// Copyright 2022 the Deno authors. All rights reserved. MIT license.
/** @jsx h */
///
///
///
///
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();
const HMR_SOCKETS: Set = 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,
) => {
const mws = state.middlewares?.slice().reverse();
const handlers: (() => Response | Promise)[] = [];
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 {
let directory;
try {
const blogPath = URL.canParse(url) ? fromFileUrl(url) : url;
directory = dirname(blogPath);
} catch (e) {
console.error(e);
throw new Error("Cannot run blog from a remote URL.");
}
// Override blog directory, if `rootDirectory` is provided
directory = settings?.rootDirectory ?? directory;
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>(
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 = "";
}
}
// Note: users can override path of a blog post using
// pathname in front matter.
pathname = data.get("pathname") ?? pathname;
const post: Post = {
title: data.get("title") ?? "Untitled",
author: data.get("author"),
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),
renderMath: data.get("render_math"),
};
if (POSTS.get(pathname)) {
console.warn(`Duplicate blog post path: ${pathname}`);
}
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" },
],
};
const sharedMetaTags = {
"theme-color": blogState.theme === "dark" ? "#000" : null,
};
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: {
...sharedMetaTags,
"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: (
),
});
}
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: {
...sharedMetaTags,
"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] : []),
...(post.renderMath ? [gfm.KATEX_CSS] : []),
],
body: ,
});
}
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,
): 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.pathname}`,
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 {
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): BlogMiddleware {
return async function (req: Request, ctx: BlogContext): Promise {
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,
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) {
return {
get(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);
}