diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/app.ts | 42 | ||||
-rw-r--r-- | src/config.ts | 4 | ||||
-rw-r--r-- | src/index.ts | 4 | ||||
-rw-r--r-- | src/posts.ts | 63 | ||||
-rw-r--r-- | src/templates.ts | 238 |
5 files changed, 340 insertions, 11 deletions
diff --git a/src/app.ts b/src/app.ts index 727ec2f..57ae71d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,17 +3,17 @@ import fs, { Stats } from "node:fs"; import type { BunFile, Serve } from "bun"; import * as Sentry from "@sentry/node"; import prom from "bun-prometheus-client"; +import log from "loglevel"; -import readConfig from "./config"; +import config from "./config"; + +log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc); Sentry.init({ release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`, tracesSampleRate: 1.0, }); -const publicDir = "public" + path.sep; - -const config = readConfig(); const defaultHeaders = { ...config.extra.headers, vary: "Accept-Encoding", @@ -85,13 +85,14 @@ function walkDirectory(root: string, dir: string) { if (dir !== "") { registerFile(relPath, dir + path.sep, absPath, stat); } + } else { + registerFile(relPath, relPath, absPath, stat); } - registerFile(relPath, relPath, absPath, stat); } } } -walkDirectory(publicDir, ""); +walkDirectory("public/", ""); async function serveFile( file: File | undefined, @@ -194,8 +195,22 @@ export const server = { } transaction.setHttpStatus(200); transaction.setTag("http.content-encoding", "identity"); - return serveFile(file); + return serveFile(file, 200, { + "content-type": file.type, + }); } else { + if (files.has(pathname + "/")) { + log.info(`Redirecting to: ${pathname + "/"}`); + metrics.requests.inc({ + method: request.method, + path: pathname, + status_code: 302, + }); + return new Response("", { + status: 302, + headers: { location: pathname + "/" }, + }); + } metrics.requests.inc({ method: request.method, path: pathname, @@ -203,7 +218,18 @@ export const server = { }); transaction.setHttpStatus(404); transaction.setTag("http.content-encoding", "identity"); - return serveFile(files.get("/404.html"), 404); + const notfound = files.get("/404.html"); + if (notfound) { + return serveFile(notfound, 404, { + "content-type": "text/html; charset=utf-8", + }); + } else { + log.warn("404.html not found"); + return new Response("404 Not Found", { + status: 404, + headers: { "content-type": "text/plain", ...defaultHeaders }, + }); + } } } catch (error) { transaction.setTag("http.content-encoding", "identity"); diff --git a/src/config.ts b/src/config.ts index 8fbe3c1..a96b9c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,4 @@ import path from "node:path"; import fs from "node:fs"; import toml from "toml"; -export default function readConfig() { - return toml.parse(fs.readFileSync("config.toml", "utf-8")); -} +export default toml.parse(fs.readFileSync("config.toml", "utf-8")); diff --git a/src/index.ts b/src/index.ts index 83ad03d..c18ebb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ +import log from "loglevel"; + import { server, metricsServer } from "./app"; +log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc); + const metricsServed = Bun.serve(metricsServer); console.info(`Metrics server started on port ${metricsServed.port}`); diff --git a/src/posts.ts b/src/posts.ts new file mode 100644 index 0000000..c727e12 --- /dev/null +++ b/src/posts.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import fs from "node:fs/promises"; + +import { matter } from "toml-matter"; +import * as marked from "marked"; + +type MatterFile = ReturnType<typeof matter>; + +export type Post = { + input: string; + output: string; + basename: string; + url: string; + title: string; + date: Date; + description: string | undefined; + taxonomies: Record<string, string[]>; +}; + +export async function getPost(filename: string): Promise<MatterFile> { + return matter(await Bun.file(filename).text()); +} + +export async function readPosts( + root: string, + inputDir: string, + outputDir: string, +): Promise<{ posts: Array<Post>; tags: Set<string> }> { + let tags = new Set<string>(); + let posts = new Array<Post>(); + const subdir = path.join(root, inputDir); + for (let pathname of await fs.readdir(subdir)) { + const pathFromDir = path.join(inputDir, pathname); + const pathFromRoot = path.join(subdir, pathname); + const stat = await fs.stat(pathFromRoot); + if (stat.isFile() && path.extname(pathname) === ".md") { + if (pathname !== "_index.md") { + const input = pathFromRoot; + const output = pathFromRoot + .replace(root, outputDir) + .replace(".md", "/index.html"); + const url = pathFromRoot.replace(root, "").replace(".md", "/"); + + const file = await getPost(input); + + file.data.taxonomies?.tags?.map((t: string) => + tags.add(t.toLowerCase()), + ); + posts.push({ + input, + output, + basename: path.basename(pathname, ".md"), + url, + ...file.data, + } as Post); + } + } + } + return { + posts: posts.sort((a, b) => b.date.getTime() - a.date.getTime()), + tags, + }; +} diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..50c45ab --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,238 @@ +import * as fs from "node:fs/promises"; +import * as cheerio from "cheerio"; +import { matter } from "toml-matter"; +import { Marked } from "marked"; +import log from "loglevel"; + +import config from "./config"; +import { getPost, readPosts, type Post } from "./posts"; + +const marked = new Marked(); +marked.use({ + gfm: true, +}); + +function addMenu( + parent: cheerio.Cheerio<cheerio.AnyNode>, + child: cheerio.Cheerio<cheerio.AnyNode>, +) { + for (const link of config.extra.main_menu) { + parent.append(child.clone().attr("href", link.url).text(link.name)); + } +} + +export async function layout( + $: cheerio.CheerioAPI, + pageTitle: string, +): Promise<cheerio.CheerioAPI> { + $("html").attr("lang", config.default_language); + $("head > link[rel=alternate]").attr("title", config.title); + addMenu($("nav"), $("nav a")); + $(".title").text(config.title); + $("title").text(pageTitle); + $(".p-name").text(pageTitle); + $("head") + .children("style") + .text(await Bun.file("templates/style.css").text()); + return $; +} + +async function render404Page(): Promise<string> { + const $ = await layout( + cheerio.load(await Bun.file("templates/404.html").text()), + "404 Not Found", + ); + return $.html(); +} + +async function renderHomepage(posts: Array<Post>): Promise<string> { + const file = matter(await Bun.file("content/_index.md").text()); + const $ = await layout( + cheerio.load(await Bun.file("templates/index.html").text()), + config.title, + ); + $("#content").html(marked.parse(file.content)); + const $feed = $(".h-feed"); + const $tpl = $(".h-entry").remove(); + + for (const post of posts) { + const $post = $tpl.clone(); + $post.find(".p-name").text(post.title); + $post.find(".u-url").attr("href", post.url); + $post + .find(".dt-published") + .attr("datetime", post.date.toISOString()) + .text(post.date.toISOString().slice(0, 10)); + $post.appendTo($feed); + } + return $.html(); +} + +async function renderPost(file: Post, content: string) { + const $ = await layout( + cheerio.load(await Bun.file("templates/post.html").text()), + file.title, + ); + + $(".h-entry .dt-published") + .attr("datetime", file.date.toISOString()) + .text(file.date.toISOString().slice(0, 10)); + $(".h-entry .e-content").html(content); + const categories = $(".h-entry .p-categories"); + const cat = categories.find(".p-category").parentsUntil(categories); + cat.remove(); + for (const tag of file.taxonomies.tags) { + categories.append( + cat + .clone() + .find(".p-category") + .attr("href", `/tags/${tag}/`) + .text(`#${tag}`) + .parent(), + ); + } + + return $.html(); +} + +async function renderListPage(tag: string, posts: Post[]) { + const $ = await layout( + cheerio.load(await Bun.file("templates/tag.html").text()), + tag || config.title, + ); + const $feed = $(".h-feed"); + const $tpl = $(".h-entry").remove(); + + for (const post of posts) { + const $post = $tpl.clone(); + $post.find(".p-name").text(post.title); + $post.find(".u-url").attr("href", post.url); + $post + .find(".dt-published") + .attr("datetime", post.date.toISOString()) + .text(post.date.toISOString().slice(0, 10)); + $post.appendTo($feed); + } + return $.html(); +} + +async function renderTags(tags: string[]) { + const $ = await layout( + cheerio.load(await Bun.file("templates/tags.html").text()), + config.title, + ); + const $tags = $(".tags"); + const $tpl = $(".h-feed"); + $tpl.remove(); + for (const tag of tags) { + const $tag = $tpl.clone(); + $tag.find("a").attr("href", `/tags/${tag}/`).text(`#${tag}`); + $tag.appendTo($tags); + } + return $.html(); +} + +const makeTagURI = (specific: string) => + `tag:${config.original_domain},${config.domain_start_date}:${specific}`; + +async function renderFeed(title: string, posts: Post[], tag?: string) { + const $ = cheerio.load(await Bun.file("templates/feed.xml").text(), { + xml: true, + }); + const $feed = $("feed"); + $feed.children("title").text(title); + $feed.children("link").attr("href", config.base_url); + $feed.children("id").text(makeTagURI(tag || "feed")); + $feed.children("updated").text(posts[0].date.toISOString()); + + const $tpl = $("feed > entry").remove(); + for (const post of posts) { + const $post = $tpl.clone(); + $post.children("title").text(post.title); + $post + .children("link") + .attr("href", new URL(post.url, config.base_url).href); + $post.children("id").text(makeTagURI(post.basename)); + $post.children("updated").text(post.date.toISOString()); + $post.find("author > name").text(config.title); + $post.children("summary").text(post.description || ""); + const content = marked.parse((await getPost(post.input)).content); + $post.children("content").html(Bun.escapeHTML(await content)); + $post.appendTo($feed); + } + + return $.xml(); +} + +async function renderFeedStyles() { + const $ = cheerio.load(await Bun.file("templates/feed-styles.xsl").text(), { + xml: true, + }); + $("style").text(await Bun.file("templates/style.css").text()); + return $.xml(); +} + +export default async function generateSite() { + const tasks = []; + const { posts, tags } = await readPosts("content", "post", "public"); + await fs.mkdir("public/post", { recursive: true }); + for (const post of posts) { + const content = await marked.parse((await getPost(post.input)).content); + await fs.mkdir(`public/post/${post.basename}`, { recursive: true }); + tasks.push(async () => { + log.debug(`Rendering post ${post.basename} to ${post.output}`); + return Bun.write(post.output, await renderPost(post, content)); + }); + } + await fs.mkdir("public/tags", { recursive: true }); + tasks.push(async () => { + log.debug("Rendering tags page to public/tags/index.html"); + return Bun.write("public/tags/index.html", await renderTags([...tags])); + }); + for (const tag of tags) { + log.debug(`Processing tag ${tag}`); + const matchingPosts = posts.filter((p) => p.taxonomies.tags.includes(tag)); + await fs.mkdir(`public/tags/${tag}`, { recursive: true }); + tasks.push(async () => { + log.debug(`Rendering tag ${tag} to public/tags/${tag}/index.html`); + return Bun.write( + `public/tags/${tag}/index.html`, + await renderListPage(tag, matchingPosts), + ); + }); + + tasks.push(async () => { + log.debug(`Rendering tag ${tag} feed to public/tags/${tag}/atom.xml`); + return Bun.write( + `public/tags/${tag}/atom.xml`, + await renderFeed(`${config.title} - ${tag}`, matchingPosts), + ); + }); + } + tasks.push(async () => { + log.debug("Rendering posts page to public/post/index.html"); + return Bun.write("public/post/index.html", await renderListPage("", posts)); + }); + tasks.push(async () => { + log.debug("Rendering site feed to public/atom.xml"); + return Bun.write("public/atom.xml", await renderFeed(config.title, posts)); + }); + tasks.push(async () => { + log.debug("Rendering feed styles to public/feed-styles.xsl"); + return Bun.write("public/feed-styles.xsl", await renderFeedStyles()); + }); + tasks.push(async () => { + log.debug("Rendering homepage to public/index.html"); + return Bun.write( + "public/index.html", + await renderHomepage(posts.slice(0, 3)), + ); + }); + tasks.push(async () => { + log.debug("Rendering 404 page to public/404.html"); + return Bun.write("public/404.html", await render404Page()); + }); + for (const task of tasks) { + await task(); + } +} |