Skip to main content
Module

x/blog/blog.tsx

Minimal boilerplate blogging.
Go to Latest
File
// Copyright 2022 the Deno authors. All rights reserved. MIT license.
/** @jsx h *//// <reference no-default-lib="true"/>/// <reference lib="dom" />/// <reference lib="dom.asynciterable" />/// <reference lib="deno.ns" />
import { callsites, createReporter, dirname, Feed, fromFileUrl, frontMatter, gfm, h, join, relative, removeMarkdown, serve, serveDir, ssr, walk,} from "./deps.ts";import { Index, PostPage } from "./components.tsx";import type { ConnInfo, FeedItem } from "./deps.ts";import type { BlogContext, BlogMiddleware, BlogSettings, BlogState, Post,} from "./types.d.ts";
const IS_DEV = Deno.args.includes("--dev") && "watchFs" in Deno;const HMR_SOCKETS: Set<WebSocket> = new Set();const POSTS = new Map<string, Post>();const HMR_CLIENT = `let socket;let reconnectTimer;
const wsOrigin = window.location.origin .replace("http", "ws") .replace("https", "wss");const hmrUrl = wsOrigin + "/hmr";
hmrSocket();
function hmrSocket(callback) { if (socket) { socket.close(); }
socket = new WebSocket(hmrUrl); socket.addEventListener("open", callback); socket.addEventListener("message", (event) => { if (event.data === "refresh") { console.log("refreshings"); window.location.reload(); } });
socket.addEventListener("close", () => { console.log("reconnecting..."); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { hmrSocket(() => { window.location.reload(); }); }, 1000); });}`;
/** The main function of the library. * * ```js * import blog from "https://deno.land/x/blog/blog.tsx"; * blog(); * ``` * * Configure it: * * ```js * import blog, { ga } from "https://deno.land/x/blog/blog.tsx"; * blog({ * title: "My blog title", * subtitle: "Subtitle", * header: * `A header that will be visible on the index page. You can use *Markdown* here.`, * middlewares: [ * ga("GA-ANALYTICS-KEY"), * ], * }); * ``` */export default async function blog(settings?: BlogSettings) { const url = callsites()[1].getFileName()!; const blogState = await configureBlog(IS_DEV, url, settings);
const blogHandler = createBlogHandler(blogState); serve(blogHandler);}
export function createBlogHandler( state: BlogState,) { const inner = handler; const withMiddlewares = composeMiddlewares(state); return function handler(req: Request, connInfo: ConnInfo) { // Redirect requests that end with a trailing slash // to their non-trailing slash counterpart. // Ex: /about/ -> /about const url = new URL(req.url); if (url.pathname.length > 1 && url.pathname.endsWith("/")) { url.pathname = url.pathname.slice(0, -1); return Response.redirect(url.href, 307); } return withMiddlewares(req, connInfo, inner); };}
function composeMiddlewares( state: BlogState,) { return ( req: Request, connInfo: ConnInfo, inner: (req: Request, ctx: BlogContext) => Promise<Response>, ) => { const mws = state.middlewares.reverse();
const handlers: (() => Response | Promise<Response>)[] = [];
const ctx = { next() { const handler = handlers.shift()!; return Promise.resolve(handler()); }, connInfo, state, };
for (const mw of mws) { handlers.push(() => mw(req, ctx)); }
handlers.push(() => inner(req, ctx));
const handler = handlers.shift()!; return handler(); };}
export async function configureBlog( isDev: boolean, url: string, maybeSetting?: BlogSettings,): Promise<BlogState> { let directory;
try { const blogPath = fromFileUrl(url); directory = dirname(blogPath); } catch (e) { console.log(e); throw new Error("Cannot run blog from a remote URL."); }
let blogState: BlogState = { title: "Blog", directory, middlewares: [], };
if (maybeSetting) { blogState = { ...blogState, ...maybeSetting, };
if (maybeSetting.header) { const { content } = frontMatter(maybeSetting.header) as { content: string; };
blogState.header = content; } }
await loadContent(directory, isDev);
return blogState;}
export function ga(gaKey: string): BlogMiddleware { if (gaKey.length == 0) { throw new Error("GA key cannot be empty."); }
const gaReporter = createReporter({ id: gaKey });
return async function ( request: Request, ctx: BlogContext, ): Promise<Response> { let err: undefined | Error; let res: undefined | Response;
const start = performance.now(); try { res = await ctx.next() as Response; } catch (e) { err = e; res = new Response("Internal server error", { status: 500, }); } finally { if (gaReporter) { gaReporter(request, ctx.connInfo, res!, start, err); } } return res; };}
export function redirects(redirectMap: Record<string, string>): BlogMiddleware { return async function (req: Request, ctx: BlogContext): Promise<Response> { const { pathname } = new URL(req.url);
let maybeRedirect = redirectMap[pathname];
if (!maybeRedirect) { // trim leading slash maybeRedirect = redirectMap[pathname.slice(1)]; }
if (maybeRedirect) { if (!maybeRedirect.startsWith("/")) { maybeRedirect = "/" + maybeRedirect; }
return new Response(null, { status: 307, headers: { "location": maybeRedirect, }, }); }
return await ctx.next(); };}
async function loadContent(blogDirectory: string, isDev: boolean) { // Read posts from the current directory and store them in memory. const postsDirectory = join(blogDirectory, "posts");
// TODO(@satyarohith): not efficient for large number of posts. for await ( const entry of walk(postsDirectory) ) { if (entry.isFile && entry.path.endsWith(".md")) { await loadPost(postsDirectory, entry.path); } }
if (isDev) { watchForChanges(postsDirectory).catch(() => {}); }}
// Watcher watches for .md file changes and updates the posts.async function watchForChanges(postsDirectory: string) { const watcher = Deno.watchFs(postsDirectory); for await (const event of watcher) { if (event.kind === "modify" || event.kind === "create") { for (const path of event.paths) { if (path.endsWith(".md")) { await loadPost(postsDirectory, path); HMR_SOCKETS.forEach((socket) => { socket.send("refresh"); }); } } } }}
async function loadPost(postsDirectory: string, path: string) { const contents = await Deno.readTextFile(path); let pathname = "/" + relative(postsDirectory, path); // Remove .md extension. pathname = pathname.slice(0, -3);
const { content, data } = frontMatter(contents) as { data: Record<string, string>; content: string; };
let snippet = data.snippet; if (!snippet) { const maybeSnippet = content.split("\n\n")[0]; if (maybeSnippet) { snippet = removeMarkdown(maybeSnippet.replace("\n", " ")); } else { snippet = ""; } }
const post: Post = { title: data.title, author: data.author, // Note: users can override path of a blog post using // pathname in front matter. pathname: data.pathname ?? pathname, publishDate: new Date(data.publish_date), snippet, markdown: content, coverHtml: data.cover_html, background: data.background, ogImage: data["og:image"], }; POSTS.set(pathname, post); console.log("Load: ", post.pathname);}
export async function handler( req: Request, ctx: BlogContext,) { const { state: blogState } = ctx; const { pathname } = new URL(req.url);
if (pathname == "/static/gfm.css") { return new Response(gfm.CSS, { headers: { "content-type": "text/css", }, }); }
if (pathname == "/hmr.js") { return new Response(HMR_CLIENT, { headers: { "content-type": "application/javascript", }, }); }
if (pathname == "/hmr") { const { response, socket } = Deno.upgradeWebSocket(req); HMR_SOCKETS.add(socket); socket.onclose = () => { HMR_SOCKETS.delete(socket); };
return response; }
if (pathname == "/") { return ssr(() => ( <Index posts={POSTS} state={blogState} hmr={IS_DEV} /> )); }
if (pathname == "/feed") { return serveRSS(req, blogState, POSTS); }
const post = POSTS.get(pathname); if (post) { return ssr(() => <PostPage post={post} hmr={IS_DEV} state={blogState} />); }
// Try to serve static files from the posts/ directory first. const response = await serveDir(req, { fsRoot: join(blogState.directory, "./posts"), }); if (response.status != 404) { return response; }
// Fallback to serving static files from the root, this will handle 404s // as well. return serveDir(req, { fsRoot: blogState.directory });}
/** Serves the rss/atom feed of the blog. */function serveRSS( req: Request, state: BlogState, posts: Map<string, Post>,): Response { const url = new URL(req.url); const origin = url.origin; const copyright = `Copyright ${new Date().getFullYear()} ${origin}`; const feed = new Feed({ title: state.title ?? "Blog", description: state.subtitle, id: `${origin}/blog`, link: `${origin}/blog`, language: "en", favicon: `${origin}/favicon.ico`, copyright: copyright, generator: "Feed (https://github.com/jpmonette/feed) for Deno", feedLinks: { atom: `${origin}/feed`, }, });
for (const [_key, post] of posts.entries()) { const item: FeedItem = { id: `${origin}/${post.title}`, title: post.title, description: post.snippet, date: post.publishDate, link: `${origin}${post.pathname}`, author: post.author?.split(",").map((author: string) => ({ name: author.trim(), })), image: post.ogImage, copyright, published: post.publishDate, }; feed.addItem(item); }
const atomFeed = feed.atom1(); return new Response(atomFeed, { headers: { "content-type": "application/atom+xml; charset=utf-8", }, });}