about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.ts42
-rw-r--r--src/config.ts4
-rw-r--r--src/index.ts4
-rw-r--r--src/posts.ts63
-rw-r--r--src/templates.ts238
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();
+  }
+}