Skip to main content
Module

x/blog/blog.tsx

Minimal boilerplate blogging.
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
// 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, ColorScheme, createReporter, dirname, Feed, Fragment, fromFileUrl, frontMatter, gfm, h, html, HtmlOptions, join, relative, removeMarkdown, serve, serveDir, UnoCSS, walk,} from "./deps.ts";import { pooledMap } from "https://deno.land/std@0.187.0/async/pool.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";import { WalkEntry } from "https://deno.land/std@0.176.0/fs/walk.ts";
export { Fragment, h };
const IS_DEV = Deno.args.includes("--dev") && "watchFs" in Deno;const POSTS = new Map<string, Post>();const HMR_SOCKETS: Set<WebSocket> = new Set();
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); });}`;
function errorHandler(err: unknown) { return new Response(`Internal server error: ${(err as Error)?.message}`, { status: 500, });}
/** The main function of the library. * * ```jsx * import blog, { ga } from "https://deno.land/x/blog/blog.tsx"; * * blog({ * title: "My Blog", * description: "The blog description.", * avatar: "./avatar.png", * middlewares: [ * ga("GA-ANALYTICS-KEY"), * ], * }); * ``` */export default async function blog(settings?: BlogSettings) { html.use(UnoCSS(settings?.unocss)); // Load custom unocss module if provided html.use(ColorScheme("auto"));
const url = callsites()[1].getFileName()!; const blogState = await configureBlog(url, IS_DEV, settings);
const blogHandler = createBlogHandler(blogState); serve(blogHandler, { port: blogState.port, hostname: blogState.hostname, onError: errorHandler, });}
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?.slice().reverse();
const handlers: (() => Response | Promise<Response>)[] = [];
const ctx = { next() { const handler = handlers.shift()!; return Promise.resolve(handler()); }, connInfo, state, };
if (mws) { 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( url: string, isDev: boolean, settings?: BlogSettings,): Promise<BlogState> { let directory;
try { const blogPath = fromFileUrl(url); directory = dirname(blogPath); } catch (e) { console.error(e); throw new Error("Cannot run blog from a remote URL."); }
const state: BlogState = { directory, ...settings, };
await loadContent(directory, isDev);
return state;}
async function loadContent(blogDirectory: string, isDev: boolean) { // Read posts from the current directory and store them in memory. const postsDirectory = join(blogDirectory, "posts");
const traversal: WalkEntry[] = []; for await (const entry of walk(postsDirectory)) { if (entry.isFile && entry.path.endsWith(".md")) { traversal.push(entry); } }
const pool = pooledMap( 25, traversal, (entry) => loadPost(postsDirectory, entry.path), );
for await (const _ of pool) { // noop }
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")) { try { await loadPost(postsDirectory, path); HMR_SOCKETS.forEach((socket) => { socket.send("refresh"); }); } catch (err) { console.error(`loadPost ${path} error:`, err.message); } } } } }}
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 { body: content, attrs: _data } = frontMatter<Record<string, unknown>>( contents, );
const data = recordGetter(_data);
let snippet: string | undefined = data.get("snippet") ?? data.get("abstract") ?? data.get("summary") ?? data.get("description"); if (!snippet) { const maybeSnippet = content.split("\n\n")[0]; if (maybeSnippet) { snippet = removeMarkdown(maybeSnippet.replace("\n", " ")); } else { snippet = ""; } }
const post: Post = { title: data.get("title") ?? "Untitled", author: data.get("author"), // Note: users can override path of a blog post using // pathname in front matter. pathname: data.get("pathname") ?? pathname, // Note: no error when publish_date is wrong or missed publishDate: data.get("publish_date") instanceof Date ? data.get("publish_date")! : new Date(), snippet, markdown: content, coverHtml: data.get("cover_html"), ogImage: data.get("og:image"), tags: data.get("tags"), allowIframes: data.get("allow_iframes"), disableHtmlSanitization: data.get("disable_html_sanitization"), readTime: readingTime(content), }; POSTS.set(pathname, post);}
export async function handler( req: Request, ctx: BlogContext,) { const { state: blogState } = ctx; const { pathname, searchParams } = new URL(req.url); const canonicalUrl = blogState.canonicalUrl || new URL(req.url).origin; const ogImage = typeof blogState.ogImage !== "string" ? blogState.ogImage?.url : blogState.ogImage; const twitterCard = typeof blogState.ogImage !== "string" ? blogState.ogImage?.twitterCard : "summary_large_image";
if (pathname === "/feed") { return serveRSS(req, blogState, POSTS); }
if (IS_DEV) { 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; } }
const sharedHtmlOptions: HtmlOptions = { lang: blogState.lang ?? "en", scripts: IS_DEV ? [{ src: "/hmr.js" }] : undefined, links: [ { href: `${canonicalUrl}${new URL(req.url).pathname}`, rel: "canonical" }, ], };
if (typeof blogState.favicon === "string") { sharedHtmlOptions.links?.push({ href: blogState.favicon, type: "image/x-icon", rel: "icon", }); } else { if (blogState.favicon?.light) { sharedHtmlOptions.links?.push({ href: blogState.favicon.light, type: "image/x-icon", media: "(prefers-color-scheme:light)", rel: "icon", }); }
if (blogState.favicon?.dark) { sharedHtmlOptions.links?.push({ href: blogState.favicon.dark, type: "image/x-icon", media: "(prefers-color-scheme:dark)", rel: "icon", }); } }
if (pathname === "/") { return html({ ...sharedHtmlOptions, title: blogState.title ?? "My Blog", meta: { "description": blogState.description, "og:title": blogState.title, "og:description": blogState.description, "og:image": ogImage ?? blogState.cover, "twitter:title": blogState.title, "twitter:description": blogState.description, "twitter:image": ogImage ?? blogState.cover, "twitter:card": ogImage ? twitterCard : undefined, }, styles: [ ...(blogState.style ? [blogState.style] : []), ], body: ( <Index state={blogState} posts={filterPosts(POSTS, searchParams)} /> ), }); }
const post = POSTS.get(decodeURIComponent(pathname)); if (post) { // Check for an Accept: text/plain header if ( req.headers.has("Accept") && req.headers.get("Accept") === "text/plain" ) { return new Response(post.markdown); } return html({ ...sharedHtmlOptions, title: post.title, meta: { "description": post.snippet, "og:title": post.title, "og:description": post.snippet, "og:image": post.ogImage, "twitter:title": post.title, "twitter:description": post.snippet, "twitter:image": post.ogImage, "twitter:card": post.ogImage ? twitterCard : undefined, }, styles: [ gfm.CSS, `.markdown-body { --color-canvas-default: transparent !important; --color-canvas-subtle: #edf0f2; --color-border-muted: rgba(128,128,128,0.2); } .markdown-body img + p { margin-top: 16px; }`, ...(blogState.style ? [blogState.style] : []), ], body: <PostPage post={post} state={blogState} />, }); }
let fsRoot = blogState.directory; try { await Deno.lstat(join(blogState.directory, "./posts", pathname)); fsRoot = join(blogState.directory, "./posts"); } catch (e) { if (!(e instanceof Deno.errors.NotFound)) { console.error(e); return new Response(e.message, { status: 500 }); } }
return serveDir(req, { fsRoot });}
/** Serves the rss/atom feed of the blog. */function serveRSS( req: Request, state: BlogState, posts: Map<string, Post>,): Response { const url = state.canonicalUrl ? new URL(state.canonicalUrl) : 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.description, id: `${origin}/blog`, link: `${origin}/blog`, language: state.lang ?? "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", }, });}
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 as Error; res = new Response(`Internal server error: ${err.message}`, { 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.startsWith("http")) ) { maybeRedirect = "/" + maybeRedirect; }
return new Response(null, { status: 307, headers: { "location": maybeRedirect, }, }); } try { return await ctx.next(); } catch (e) { console.error(e); return new Response(`Internal server error: ${e.message}`, { status: 500, }); } };}
function filterPosts( posts: Map<string, Post>, searchParams: URLSearchParams,) { const tag = searchParams.get("tag"); if (!tag) { return posts; } return new Map( Array.from(posts.entries()).filter(([, p]) => p.tags?.includes(tag)), );}
function recordGetter(data: Record<string, unknown>) { return { get<T>(key: string): T | undefined { return data[key] as T; }, };}
function readingTime(text: string) { const wpm = 225; const words = text.split(/\s+/).length; return Math.ceil(words / wpm);}