about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--Caddyfile77
-rw-r--r--Dockerfile31
-rw-r--r--Makefile11
-rwxr-xr-xbin/build.ts30
-rwxr-xr-xbun.lockbbin7240 -> 14725 bytes
-rw-r--r--config.toml20
-rw-r--r--content/feed-styles/index.md4
-rw-r--r--content/post/_index.md6
-rw-r--r--flake.nix1
-rw-r--r--package.json13
-rw-r--r--redis.Caddyfile2
-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
-rw-r--r--templates/404.html37
-rw-r--r--templates/feed-styles.xsl (renamed from templates/feed-styles.html)4
-rw-r--r--templates/feed.xml24
-rw-r--r--templates/footer.html1
-rw-r--r--templates/index.html99
-rw-r--r--templates/post.html50
-rw-r--r--templates/style.css (renamed from themes/bear/templates/style.css)7
-rw-r--r--templates/tag.html49
-rw-r--r--templates/tags.html40
-rw-r--r--themes/bear/.envrc1
-rw-r--r--themes/bear/.gitignore1
-rw-r--r--themes/bear/LICENSE21
-rw-r--r--themes/bear/README.md83
-rw-r--r--themes/bear/config.toml35
-rw-r--r--themes/bear/content/_index.md23
-rw-r--r--themes/bear/content/bear.md15
-rw-r--r--themes/bear/content/blog/_index.md3
-rw-r--r--themes/bear/content/blog/markdown-syntax.md130
-rw-r--r--themes/bear/content/zola.md36
-rw-r--r--themes/bear/flake.lock61
-rw-r--r--themes/bear/flake.nix20
-rw-r--r--themes/bear/netlify.toml25
-rw-r--r--themes/bear/screenshot.pngbin262700 -> 0 bytes
-rw-r--r--themes/bear/templates/404.html8
-rw-r--r--themes/bear/templates/base.html30
-rw-r--r--themes/bear/templates/footer.html5
-rw-r--r--themes/bear/templates/header.html11
-rw-r--r--themes/bear/templates/index.html7
-rw-r--r--themes/bear/templates/nav.html6
-rw-r--r--themes/bear/templates/page.html29
-rw-r--r--themes/bear/templates/section.html34
-rw-r--r--themes/bear/templates/security_tags.html2
-rw-r--r--themes/bear/templates/seo_tags.html2
l---------themes/bear/templates/style.css.html1
-rw-r--r--themes/bear/templates/taxonomy_list.html14
-rw-r--r--themes/bear/templates/taxonomy_single.html34
-rw-r--r--themes/bear/theme.toml25
-rw-r--r--tsconfig.json3
54 files changed, 670 insertions, 852 deletions
diff --git a/Caddyfile b/Caddyfile
deleted file mode 100644
index c779358..0000000
--- a/Caddyfile
+++ /dev/null
@@ -1,77 +0,0 @@
-{
-	admin off
-	persist_config off
-	auto_https off
-	import globals/*
-	servers :80 {
-		metrics
-		protocols h1 h2c
-		trusted_proxies static private_ranges
-	}
-	servers :9091 {
-		protocols h1
-	}
-}
-
-:9091 {
-	metrics
-}
-
-http://,
-http://alanpearce.uk,
-http://www.alanpearce.uk,
-http://www.alanpearce.eu {
-	header {
-		Cache-Control max-age=31536000
-		X-Content-Type-Options nosniff
-		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
-		Content-Security-Policy "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'"
-	}
-	redir https://alanpearce.eu{uri} permanent
-}
-
-http://aln.pe {
-	header {
-		Cache-Control max-age=86400
-		X-Content-Type-Options nosniff
-		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
-		Content-Security-Policy "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'"
-	}
-
-	redir /pronouns https://en.pronouns.page/@alanpearce
-	redir /pronomen https://de.pronouns.page/@alanpearce
-	redir /git https://git.alanpearce.eu
-	redir /gpg https://alanpearce.eu/public_key.asc
-	redir /status https://stats.uptimerobot.com/GgzRkHBDr7
-	redir /* https://alanpearce.eu/{uri}
-}
-
-http://alanpearce.eu {
-	root * {$SITE_ROOT}
-	file_server {
-		precompressed br zstd gzip
-	}
-	header {
-		Cache-Control max-age=14400
-		X-Content-Type-Options nosniff
-		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
-		Content-Security-Policy "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'; form-action 'none'; base-uri 'self'"
-	}
-	handle_errors {
-		@404 expression `{err.status_code} == 404`
-		handle @404 {
-			rewrite * /404.html
-		}
-		file_server {
-			precompressed br zstd gzip
-		}
-	}
-	header /feed-styles/ Content-Type text/xsl
-	error /feed-styles/index.html* 404
-	respond /favicon.ico 204
-	redir /index.xml /atom.xml
-	redir /post/index.xml /atom.xml
-
-	@http header X-Forwarded-Proto http
-	redir @http https://alanpearce.eu{uri}
-}
diff --git a/Dockerfile b/Dockerfile
index 24a273a..e7771fe 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,34 +25,32 @@ COPY --link bun.lockb package.json ./
 RUN bun install --ci
 
 # Copy application code
-COPY --link src src
-
-FROM ghcr.io/getzola/zola:v${ZOLA_VERSION} as ssg
-
-WORKDIR /web
-
 COPY --link config.toml config.toml
-COPY --link themes themes
+COPY --link src src
+COPY --link bin bin
+COPY --link content content
 COPY --link templates templates
 COPY --link static static
-COPY --link content content
 
-RUN [ "zola", "build", "--force" ]
+RUN ./bin/build.ts && rm -r node_modules
 
-FROM alpine:20230901 as postprocess
+ENV NODE_ENV=production
+RUN [ "bun", "install", "--production", "--ci" ]
 
-WORKDIR /web
+FROM alpine:20230901 as postprocess
 
 RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
 
 RUN apk add --no-cache prettier@testing make fd brotli gzip zstd
 
+WORKDIR /app
+
 COPY --link Makefile ./
-COPY --from=ssg /web/config.toml ./
-COPY --from=ssg /web/public public
+COPY --link config.toml ./
+COPY --from=build /app/public public
 
-RUN make postprocess format
-RUN make -j4 compress
+RUN make format
+# RUN make -j4 compress
 
 # Final stage for app image
 FROM base
@@ -60,9 +58,10 @@ FROM base
 # Copy built application
 COPY config.toml /app/
 COPY --from=build /app /app
-COPY --from=postprocess /web/public /app/public
 
 # Start the server by default, this can be overwritten at runtime
 EXPOSE 3000
 EXPOSE 9091
+
+ENV NODE_ENV=production
 CMD [ "bun", "run", "src/index.ts" ]
diff --git a/Makefile b/Makefile
index 288985d..ef95ece 100644
--- a/Makefile
+++ b/Makefile
@@ -7,18 +7,9 @@ gzip := @gzip --best --keep --force
 zstd := @zstd --force --no-pass-through -19 --quiet --stdout
 pr   := @printf "%4s %s\n"
 
-public/feed-styles.xsl:
-	@mv public/feed-styles/index.html $@
-	@rm -fr public/feed-styles
-
-postprocess: .postprocessstamp
-
-.postprocessstamp: public/feed-styles.xsl
-	@touch .postprocessstamp
-
 format: .formatstamp
 
-.formatstamp: public/index.html .postprocessstamp
+.formatstamp:
 	@echo "Formatting HTML..."
 	@prettier --write --parser html --print-width 120 "public/**/*.html" > /dev/null
 	@touch .formatstamp
diff --git a/bin/build.ts b/bin/build.ts
new file mode 100755
index 0000000..d6b6df9
--- /dev/null
+++ b/bin/build.ts
@@ -0,0 +1,30 @@
+#!/usr/bin/env bun
+import fs from "node:fs";
+import log from "loglevel";
+
+import generateSite from "../src/templates";
+
+log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc);
+
+fs.mkdirSync("public", { recursive: true });
+log.info("Generating site...");
+try {
+  await generateSite();
+} catch (error) {
+  log.error("Error generating site", error);
+  process.exit(1);
+}
+
+log.info("Copying static files...");
+if (!fs.existsSync("static")) {
+  log.error("static directory not found");
+  process.exit(1);
+}
+fs.readdirSync("static").map((file) => {
+  log.info(`Copying static/${file}`);
+  fs.cpSync(`static/${file}`, `public/${file}`, {
+    dereference: true,
+    recursive: true,
+    preserveTimestamps: true,
+  });
+});
diff --git a/bun.lockb b/bun.lockb
index 2608647..b4d52ec 100755
--- a/bun.lockb
+++ b/bun.lockb
Binary files differdiff --git a/config.toml b/config.toml
index f7ec542..b62dbe2 100644
--- a/config.toml
+++ b/config.toml
@@ -6,6 +6,9 @@ description = "Developer, Emacs User"
 
 generate_feed = true
 
+domain_start_date = "2014-06-07"
+original_domain = "alanpearce.eu"
+
 theme = "bear"
 
 [markdown]
@@ -39,20 +42,3 @@ content-security-policy = "default-src 'none'; img-src 'self'; object-src 'none'
 [[extra.main_menu]]
     name = "Repositories"
     url = "https://git.alanpearce.eu"
-
-[[extra.contact_menu]]
-    name = "alan@alanpearce.eu"
-    url = "mailto:alan@alanpearce.eu"
-    weight = 1
-[[extra.contact_menu]]
-    name = "Codeberg"
-    url = "https://codeberg.org/alanpearce"
-[[extra.contact_menu]]
-    name = "GitHub"
-    url = "https://github.com/alanpearce"
-[[extra.contact_menu]]
-    name = "Mastodon"
-    url = "https://ieji.de/@alanpearce"
-[[extra.contact_menu]]
-    name = "Bluesky"
-    url = "https://bsky.app/profile/alanpearce.eu"
diff --git a/content/feed-styles/index.md b/content/feed-styles/index.md
deleted file mode 100644
index bac7916..0000000
--- a/content/feed-styles/index.md
+++ /dev/null
@@ -1,4 +0,0 @@
-+++
-title = "feed-styles"
-template = "feed-styles.html"
-+++
diff --git a/content/post/_index.md b/content/post/_index.md
deleted file mode 100644
index e0d2523..0000000
--- a/content/post/_index.md
+++ /dev/null
@@ -1,6 +0,0 @@
-+++
-title = "Posts"
-sort_by = "date"
-paginate_reversed = true
-transparent = true
-+++
diff --git a/flake.nix b/flake.nix
index 3ccad0d..8786bb7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -14,7 +14,6 @@
         let
           pkgs = nixpkgs.legacyPackages.${system};
           nativeBuildInputs = with pkgs; [
-            zola
             nodePackages.prettier
             fd
             brotli
diff --git a/package.json b/package.json
index acb0224..47922d2 100644
--- a/package.json
+++ b/package.json
@@ -2,18 +2,25 @@
   "name": "homestead",
   "module": "src/index.ts",
   "scripts": {
-    "start": "bun run ."
+    "start": "bun run .",
+    "build": "bun run bin/build.ts"
   },
   "devDependencies": {
-    "bun-types": "latest"
+    "bun-types": "latest",
+    "cheerio": "1.0.0-rc.12",
+    "highlight.js": "^11.8.0",
+    "marked": "^9.0.3",
+    "marked-highlight": "^2.0.6",
+    "toml-matter": "^1.0.0"
   },
   "peerDependencies": {
     "typescript": "^5.0.0"
   },
   "type": "module",
   "dependencies": {
-    "@sentry/node": "^7.69.0",
+    "@sentry/node": "^7.70.0",
     "bun-prometheus-client": "^0.0.2",
+    "loglevel": "^1.8.1",
     "toml": "^3.0.0"
   }
 }
diff --git a/redis.Caddyfile b/redis.Caddyfile
deleted file mode 100644
index c0f4bfc..0000000
--- a/redis.Caddyfile
+++ /dev/null
@@ -1,2 +0,0 @@
-storage redis {
-}
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();
+  }
+}
diff --git a/templates/404.html b/templates/404.html
new file mode 100644
index 0000000..4e64fcc
--- /dev/null
+++ b/templates/404.html
@@ -0,0 +1,37 @@
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <h1>404</h1>
+      <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
+    </main>
+    <footer>
+      Licensed under a
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >Creative Commons Attribution 4.0 International License</a
+      >.
+    </footer>
+  </body>
+</html>
diff --git a/templates/feed-styles.html b/templates/feed-styles.xsl
index a66ddc0..0197e87 100644
--- a/templates/feed-styles.html
+++ b/templates/feed-styles.xsl
@@ -12,9 +12,7 @@
         <meta charset="utf-8" />
         <meta http-equiv="content-type" content="text/html; charset=utf-8" />
         <meta name="viewport" content="width=device-width, initial-scale=1" />
-        <style>
-          {%- include "style.css.html" ignore missing -%}
-        </style>
+        <style></style>
       </head>
       <body>
         <main>
diff --git a/templates/feed.xml b/templates/feed.xml
new file mode 100644
index 0000000..ddc90dd
--- /dev/null
+++ b/templates/feed.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<?xml-stylesheet href="/feed-styles.xsl" type="text/xsl"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+  <title>Example Feed</title>
+  <link href="http://example.org/"></link>
+  <id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
+  <updated>2003-12-13T18:30:02Z</updated>
+  <entry>
+    <title>Atom-Powered Robots Run Amok</title>
+    <link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"></link>
+    <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+    <updated>2003-12-13T18:30:02Z</updated>
+    <summary>Some text.</summary>
+    <content type="html">
+      <div>
+        <p>This is the entry content.</p>
+      </div>
+    </content>
+    <author>
+      <name>John Doe</name> 
+    </author>
+  </entry>
+
+</feed>
diff --git a/templates/footer.html b/templates/footer.html
deleted file mode 100644
index 422c90c..0000000
--- a/templates/footer.html
+++ /dev/null
@@ -1 +0,0 @@
-<footer>Licensed under a <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 International License</a>.</footer>
diff --git a/templates/index.html b/templates/index.html
index a79b1c8..b4832a9 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,39 +1,64 @@
-{% extends "base.html" %}
-
-{% block body_attrs %} class="h-card vcard"{% endblock %}
-
-{% block title_class %} p-name fn{% endblock %}
-
-{% block main -%}
-  <main id="content">
-    <div>
-      {{ section.content | safe -}}
-    </div>
-    <section>
-      <h2>Latest Posts</h2>
-      <ul class="h-feed">
-        {%- set section = get_section(path="post/_index.md") %}
-        {%- for page in section.pages | slice(end=3) %}
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <div id="content"></div>
+      <section>
+        <h2>Latest Posts</h2>
+        <ul class="h-feed">
           <li class="h-entry">
-            <time class="dt-published" datetime="{{ page.date | date(format="%+") }}">{{ page.date | date(format=config.extra.date_format) }}</time>
-            <a class="u-url p-name" href="{{ page.path | safe }}">{{ page.title }}</a>
+            <span>
+              <time
+                class="dt-published"
+                datetime="2000-12-31T12:33:02+02:00"
+                pubdate
+              >
+                2000-12-31
+              </time>
+            </span>
+            <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
           </li>
-        {%- endfor %}
-      </ul>
-    </section>
-    <section>
-      <h2>Elsewhere on the Internet</h2>
-      <ul>
-        {%- for item in config.extra.contact_menu %}
-          <li>
-            {%- if item.url is starting_with("mailto:") -%}
-              <a href="{{ item.url | safe }}" class="u-email email" rel="me">{{ item.name }}</a>
-            {%- else -%}
-              <a href="{{ item.url | safe }}" class="u-url url" rel="me">{{ item.name }}</a>
-            {%- endif -%}
-          </li>
-        {%- endfor %}
-      </ul>
-    </section>
-  </main>
-{% endblock %}
+        </ul>
+      </section>
+      <section>
+        <h2>Elsewhere on the Internet</h2>
+        <ul>
+          <li><a href="mailto:alan@alanpearce.eu">alan@alanpearce.eu</a></li>
+          <li><a href="https://codeberg.org/alanpearce/">Codeberg</a></li>
+          <li><a href="https://github.com/alanpearce/">GitHub</a></li>
+          <li><a href="https://gitlab.com/alanpearce/">GitLab</a></li>
+          <li><a href="https://ieji.de/@alanpearce">Mastodon</a></li>
+          <li><a href="https://bsky.app/profile/alanpearce.eu">Bluesky</a></li>
+        </ul>
+      </section>
+    </main>
+    <footer>
+      Licensed under a
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >Creative Commons Attribution 4.0 International License</a
+      >.
+    </footer>
+  </body>
+</html>
diff --git a/templates/post.html b/templates/post.html
new file mode 100644
index 0000000..2a66058
--- /dev/null
+++ b/templates/post.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title></title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title=""
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#main">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title"></a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="main">
+      <article class="h-entry">
+        <h1 class="p-name">Post Title</h1>
+        <p>
+          <time class="dt-published" pubdate>2000-12-31</time>
+        </p>
+        <div class="e-content">
+          Enim lobortis scelerisque fermentum dui faucibus in ornare quam
+          viverra. Eget egestas purus viverra accumsan in nisl nisi, scelerisque
+          eu ultrices vitae, auctor eu augue ut lectus arcu, bibendum at.
+        </div>
+        <ul class="p-categories">
+          Tags:
+          <li><a class="p-category" href="/tags/sample/">#sample</a></li>
+        </ul>
+      </article>
+    </main>
+    <footer>
+      Licensed under a
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >Creative Commons Attribution 4.0 International License</a
+      >.
+    </footer>
+  </body>
+</html>
diff --git a/themes/bear/templates/style.css b/templates/style.css
index e1e12aa..23722e1 100644
--- a/themes/bear/templates/style.css
+++ b/templates/style.css
@@ -61,6 +61,7 @@ nav a {
 .tags > li {
   list-style: none;
   display: inline-block;
+  padding-right: 1ex;
 }
 
 textarea {
@@ -72,7 +73,8 @@ input {
   font-size: 1rem;
 }
 
-main,article {
+main,
+article {
   line-height: 1.6;
 }
 
@@ -161,7 +163,8 @@ ul.blog-posts li a:visited {
   h5,
   h6,
   strong,
-  b {
+  b,
+  .title {
     color: #eee;
   }
 
diff --git a/templates/tag.html b/templates/tag.html
new file mode 100644
index 0000000..ae5b8e9
--- /dev/null
+++ b/templates/tag.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Site Title"
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#content">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site Title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="content">
+      <ul class="h-feed blog-posts">
+        <li class="h-entry">
+          <span>
+            <time
+              class="dt-published"
+              datetime="2000-12-31T12:33:02+02:00"
+              pubdate
+            >
+              2000-12-31
+            </time>
+          </span>
+          <a class="p-name u-url" href="/post/lorem-ipsum/">Lorem Ipsum</a>
+        </li>
+      </ul>
+    </main>
+    <footer>
+      Licensed under a
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >Creative Commons Attribution 4.0 International License</a
+      >.
+    </footer>
+  </body>
+</html>
diff --git a/templates/tags.html b/templates/tags.html
new file mode 100644
index 0000000..a724c62
--- /dev/null
+++ b/templates/tags.html
@@ -0,0 +1,40 @@
+<!doctype html>
+<html lang="en-GB">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Site Title</title>
+    <meta name="referrer" content="no-referrer-when-downgrade" />
+    <link
+      rel="alternate"
+      type="application/atom+xml"
+      title="Site title"
+      href="/atom.xml"
+    />
+    <style></style>
+  </head>
+  <body>
+    <a class="skip" href="#content">Skip to main content</a>
+    <header>
+      <h2>
+        <a href="/" class="title">Site title</a>
+      </h2>
+      <nav>
+        <a href="/">Home</a>
+      </nav>
+    </header>
+    <main id="content">
+      <ul class="tags">
+        <li class="h-feed">
+          <a href="/tags/tag">#tag</a>
+        </li>
+      </ul>
+    </main>
+    <footer>
+      Licensed under a
+      <a rel="license" href="http://creativecommons.org/licenses/by/4.0/"
+        >Creative Commons Attribution 4.0 International License</a
+      >.
+    </footer>
+  </body>
+</html>
diff --git a/themes/bear/.envrc b/themes/bear/.envrc
deleted file mode 100644
index 3550a30..0000000
--- a/themes/bear/.envrc
+++ /dev/null
@@ -1 +0,0 @@
-use flake
diff --git a/themes/bear/.gitignore b/themes/bear/.gitignore
deleted file mode 100644
index 87174b6..0000000
--- a/themes/bear/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-/public/
diff --git a/themes/bear/LICENSE b/themes/bear/LICENSE
deleted file mode 100644
index ddc924b..0000000
--- a/themes/bear/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2023 Alan Pearce
-Copyright (c) 2020 Jan Raasch
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/themes/bear/README.md b/themes/bear/README.md
deleted file mode 100644
index db77233..0000000
--- a/themes/bear/README.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# Zola ʕ•ᴥ•ʔ Bear Blog
-
-[![Netlify Status](https://api.netlify.com/api/v1/badges/121b53ce-c913-4604-9179-eb3cca31cd2c/deploy-status)](https://app.netlify.com/sites/zola-bearblog/deploys)
-
-🧸 A [Zola](https://www.getzola.org/)-theme based on [Bear Blog](https://bearblog.dev).
-
-> Free, no-nonsense, super-fast blogging.
-
-## Demo
-
-For a current & working demo of this theme, please check out <https://zola-bearblog.netlify.app/> 🎯.
-
-## Screenshot
-
-![Screenshot][screenshot]
-
-When the user's browser is running »dark mode«, the dark color scheme will be used automatically. The default is the light/white color scheme. Check out the [`style.html`](https://codeberg.org/alanpearce/zola-bearblog/src/branch/main/templates/style.html)-file for the implementation.
-
-## Installation
-
-If you already have a Zola site on your machine, you can simply add this theme via
-
-```
-git submodule add https://codeberg.org/alanpearce/zola-bearblog themes/zola-bearblog
-```
-
-Then, adjust the `config.toml` as detailed below.
-
-For more information, read the official [setup guide][zola-setup-guide] of Zola.
-
-Alternatively, you can quickly deploy a copy of the theme site to Netlify using this button:
-
-[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://gitlab.com/alanpearce/zola-bearblog)
-
-(Note that this method makes it harder to keep up-to-date with theme updates, which might be necessary for newer versions of Zola.)
-
-## Adjust configuration / config.toml
-
-Please check out the included [config.toml](https://codeberg.org/alanpearce/zola-bearblog/src/branch/main/config.toml)
-
-## Content & structure
-
-### Menu
-
-Create an array in `extra` with a key of `main_menu`. `url` is passed to [`get_url`](https://www.getzola.org/documentation/templates/overview/#get-url)
-
-```toml
-[[extra.main_menu]]
-name = "Bear"
-url = "@/bear.md"
-
-[[extra.main_menu]]
-name = "Zola"
-url = "@/zola.md"
-
-[[extra.main_menu]]
-name = "Blog"
-url = "@/blog/_index.md"
-```
-
-### Adding / editing content
-
-#### Index-Page
-
-The contents of the `index`-page may be changed by editing your `content/_index.md`-file.
-
-
-### Adding your branding / colors / css
-
-Add a `custom_head.html`-file to your `templates/`-directory. In there you may add a `<style>`-tag, *or* you may add a `<link>`-tag referencing your own `custom.css` (in case you prefer to have a separate `.css`-file). Check out the [`style.html`](https://codeberg.org/alanpearce/zola-bearblog/src/branch/main/templates/style.html)-file to find out which CSS-styles are applied by default.
-
-## Issues / Feedback / Contributing
-Please use [Codeberg issues](https://codeberg.org/alanpearce/zola-bearblog/issues) and [Pull Requests](https://codeberg.org/alanpearce/zola-bearblog/pulls).
-
-## Special Thanks 🎁
-
-A special thank you goes out to [Herman](https://herman.bearblog.dev), for creating the original [ʕ•ᴥ•ʔ Bear Blog](https://bearblog.dev/) and [Jan Raasch](https://www.janraasch.com) for creating the hugo port of the Bear Blog theme.
-
-## License
-[MIT License](http://en.wikipedia.org/wiki/MIT_License) © [Alan Pearce](https://www.alanpearce.eu/)
-
-[zola-setup-guide]: https://www.getzola.org/documentation/getting-started/installation/
-[screenshot]: https://codeberg.org/alanpearce/zola-bearblog/raw/branch/main/screenshot.png
diff --git a/themes/bear/config.toml b/themes/bear/config.toml
deleted file mode 100644
index 78e9f9a..0000000
--- a/themes/bear/config.toml
+++ /dev/null
@@ -1,35 +0,0 @@
-title = "Zola ʕ•ᴥ•ʔ Bear Blog"
-base_url = "https://zola-bearblog.netlify.app/"
-description = "A Zola-theme based on Bear Blog."
-
-# Whether to automatically compile all Sass files in the sass directory
-compile_sass = false
-
-# Whether to build a search index to be used later on by a JavaScript library
-build_search_index = false
-
-taxonomies = [
-  {name = "categories", feed = true},
-  {name = "tags", feed = true},
-]
-
-[markdown]
-# Whether to do syntax highlighting
-# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola
-highlight_code = true
-
-[extra]
-date_format="%d %b, %Y"
-webserver_sends_csp_headers=true
-
-[[extra.main_menu]]
-name = "Bear"
-url = "@/bear.md"
-
-[[extra.main_menu]]
-name = "Zola"
-url = "@/zola.md"
-
-[[extra.main_menu]]
-name = "Blog"
-url = "@/blog/_index.md"
diff --git a/themes/bear/content/_index.md b/themes/bear/content/_index.md
deleted file mode 100644
index 24b3925..0000000
--- a/themes/bear/content/_index.md
+++ /dev/null
@@ -1,23 +0,0 @@
-+++
-+++
-# A match made in heaven
-
-There is a website obesity crisis. Bloated websites full of scripts, ads, and trackers are slowing your readers down every time they try to read your well-crafted content.
-
-Zola Bear Blog is all you need to build a fantastic and optimized site or blog. It works perfectly on **any** viewing device. All you need to focus on is writing good content.
-
-[Go to the original bear blog](https://bearblog.dev/).
-
----
-
-What happens when you combine the worlds' fastest, most lightweight static site generator with a design theme built to provide you with free, no-nonsense, super-fast blogging capabilities?
-
-**Use this theme, and find out!**
-
-Made with 💚 by [Alan Pearce](https://alanpearce.eu).
-
----
-
-Simply publish content online, grow an audience, and keep your pages tiny, fast, and **optimized for search engines**.
-
-Each page is ~5kb, and you can **host your blog yourself**.
diff --git a/themes/bear/content/bear.md b/themes/bear/content/bear.md
deleted file mode 100644
index dd7da4b..0000000
--- a/themes/bear/content/bear.md
+++ /dev/null
@@ -1,15 +0,0 @@
-+++
-title = "Bear"
-+++
-
-Website: [https://bearblog.dev](https://bearblog.dev)
-
-There is a website obesity crisis. Bloated websites are full of scripts, ads, and trackers slowing your readers down every time they try to read your well-crafted content.
-
-Bear is all you need to build a fantastic and optimized site or blog. It works perfectly on **any** viewing device. All you need to focus on is writing good content.
-
-Bear makes it simple to publish content online and grow an audience while keeping pages tiny, fast, and **optimized for search engines.**
-
-Each page is ~5kb.
-
-Learn more and contribute on [GitHub](https://github.com/HermanMartinus/bearblog).
diff --git a/themes/bear/content/blog/_index.md b/themes/bear/content/blog/_index.md
deleted file mode 100644
index 34651ab..0000000
--- a/themes/bear/content/blog/_index.md
+++ /dev/null
@@ -1,3 +0,0 @@
-+++
-title = "Blog"
-+++
diff --git a/themes/bear/content/blog/markdown-syntax.md b/themes/bear/content/blog/markdown-syntax.md
deleted file mode 100644
index 18f912a..0000000
--- a/themes/bear/content/blog/markdown-syntax.md
+++ /dev/null
@@ -1,130 +0,0 @@
-+++
-title = "Markdown Syntax Guide"
-date = "2020-01-03"
-description = "Sample article showcasing basic Markdown syntax and formatting for HTML elements."
-taxonomies.tags = [
-    "markdown",
-    "syntax",
-]
-+++
-
-For a quick cheatsheet, check out [https://simplemde.com/markdown-guide](https://simplemde.com/markdown-guide).
-
----
-
-This article offers a sample of basic Markdown syntax that can be used in Zola content files, also it shows whether basic HTML elements are decorated with CSS in a Zola theme.
-<!--more-->
-
-## Headings
-
-The following HTML `<h1>`—`<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.
-
-# H1
-## H2
-### H3
-#### H4
-##### H5
-###### H6
-
-## Paragraph
-
-Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.
-
-Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.
-
-## Blockquotes
-
-The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.
-
-#### Blockquote without attribution
-
-> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
-> **Note** that you can use *Markdown syntax* within a blockquote.
-
-#### Blockquote with attribution
-
-> Don't communicate by sharing memory, share memory by communicating.<br>
-> — <cite>Rob Pike[^1]</cite>
-
-[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
-
-## Tables
-
-Tables aren't part of the core Markdown spec, but Hugo supports supports them out-of-the-box.
-
-   Name | Age
---------|------
-    Bob | 27
-  Alice | 23
-
-#### Inline Markdown within tables
-
-| Italics   | Bold     | Code   |
-| --------  | -------- | ------ |
-| *italics* | **bold** | `code` |
-
-## Code Blocks
-
-#### Code block with backticks
-
-```html
-<!doctype html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  <title>Example HTML5 Document</title>
-</head>
-<body>
-  <p>Test</p>
-</body>
-</html>
-```
-
-#### Code block indented with four spaces
-
-    <!doctype html>
-    <html lang="en">
-    <head>
-      <meta charset="utf-8">
-      <title>Example HTML5 Document</title>
-    </head>
-    <body>
-      <p>Test</p>
-    </body>
-    </html>
-
-## List Types
-
-#### Ordered List
-
-1. First item
-2. Second item
-3. Third item
-
-#### Unordered List
-
-* List item
-* Another item
-* And another item
-
-#### Nested list
-
-* Fruit
-  * Apple
-  * Orange
-  * Banana
-* Dairy
-  * Milk
-  * Cheese
-
-## Other Elements — abbr, sub, sup, kbd, mark
-
-<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
-
-H<sub>2</sub>O
-
-X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
-
-Press <kbd><kbd>CTRL</kbd>+<kbd>ALT</kbd>+<kbd>Delete</kbd></kbd> to end the session.
-
-Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
diff --git a/themes/bear/content/zola.md b/themes/bear/content/zola.md
deleted file mode 100644
index 5458750..0000000
--- a/themes/bear/content/zola.md
+++ /dev/null
@@ -1,36 +0,0 @@
-+++
-title = "Zola"
-+++
-## No dependencies
-
-Zola comes as a single executable with Sass compilation, syntax
-highlighting, table of contents and many other features that
-traditionally require setting up a dev environment or adding some
-JavaScript libraries to your site.
-
-## Blazing fast
-
-The average site will be generated in less than a second, including
-Sass compilation and syntax highlighting.
-
-## Scalable
-
-Zola renders your whole site as static files, making it trivial to
-handle any kind of traffic you will throw at it at no cost without
-having to worry about managing a server or a database.
-
-## Easy to use
-
-From the CLI to the template engine, everything is designed to be
-intuitive. Don't take my word for it though, look at the documentation
-and see for yourself.
-
-## Flexible
-
-Zola gets out of your way so you can focus on your content, be it a
-blog, a knowledge base, a landing page or a combination of them.
-
-## Augmented Markdown
-
-Zola comes with shortcodes and internal links to make it easier to
-write your content.
diff --git a/themes/bear/flake.lock b/themes/bear/flake.lock
deleted file mode 100644
index afbf271..0000000
--- a/themes/bear/flake.lock
+++ /dev/null
@@ -1,61 +0,0 @@
-{
-  "nodes": {
-    "flake-utils": {
-      "inputs": {
-        "systems": "systems"
-      },
-      "locked": {
-        "lastModified": 1681202837,
-        "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "rev": "cfacdce06f30d2b68473a46042957675eebb3401",
-        "type": "github"
-      },
-      "original": {
-        "owner": "numtide",
-        "repo": "flake-utils",
-        "type": "github"
-      }
-    },
-    "nixpkgs": {
-      "locked": {
-        "lastModified": 1681693905,
-        "narHash": "sha256-XdXMvCt+i2ZcmAIPZvu3RUwcdaC9OX7d1WMAJJokzeA=",
-        "owner": "NixOS",
-        "repo": "nixpkgs",
-        "rev": "db34d7561caa508ece0265a56f382c5d3b7a6c1b",
-        "type": "github"
-      },
-      "original": {
-        "owner": "NixOS",
-        "ref": "nixpkgs-unstable",
-        "repo": "nixpkgs",
-        "type": "github"
-      }
-    },
-    "root": {
-      "inputs": {
-        "flake-utils": "flake-utils",
-        "nixpkgs": "nixpkgs"
-      }
-    },
-    "systems": {
-      "locked": {
-        "lastModified": 1681028828,
-        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
-        "owner": "nix-systems",
-        "repo": "default",
-        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
-        "type": "github"
-      },
-      "original": {
-        "owner": "nix-systems",
-        "repo": "default",
-        "type": "github"
-      }
-    }
-  },
-  "root": "root",
-  "version": 7
-}
diff --git a/themes/bear/flake.nix b/themes/bear/flake.nix
deleted file mode 100644
index c257b2a..0000000
--- a/themes/bear/flake.nix
+++ /dev/null
@@ -1,20 +0,0 @@
-{
-  description = "A bear blog theme for Zola";
-  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
-  inputs.flake-utils.url = "github:numtide/flake-utils";
-
-  outputs = { self, nixpkgs, flake-utils }:
-    flake-utils.lib.eachDefaultSystem (system:
-      let
-        pkgs = nixpkgs.legacyPackages.${system};
-      in
-      {
-        devShells.default = pkgs.mkShell {
-          packages = with pkgs; [
-            git
-            gnugrep
-            zola
-          ];
-        };
-      });
-}
diff --git a/themes/bear/netlify.toml b/themes/bear/netlify.toml
deleted file mode 100644
index 6be8468..0000000
--- a/themes/bear/netlify.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-[build]
-publish = "public"
-command = "zola build"
-
-[build.environment]
-# Set the version name that you want to use and Netlify will automatically use it.
-ZOLA_VERSION = "0.17.2"
-
-# The magic for deploying previews of branches.
-# We need to override the base url with whatever url Netlify assigns to our
-# preview site.  We do this using the Netlify environment variable
-# `$DEPLOY_PRIME_URL`.
-
-[context.deploy-preview]
-command = "zola build --base-url $DEPLOY_PRIME_URL"
-
-[[headers]]
-  for = "/*"
-  [headers.values]
-    X-Frame-Options = "DENY"
-    X-Content-Type-Options = "nosniff"
-    X-XSS-Protection = "1; mode=block"
-    Referrer-Policy = "strict-origin-when-cross-origin"
-    Strict-Transport-Security = "max-age=63072000; includeSubdomains"
-    Content-Security-Policy = "default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'sha256-2Sbht7dvdhJX00j84akXy9AejYUf6sOM3OpwMxNiDXQ='; form-action 'none'; base-uri 'self'; frame-ancestors 'none'"
diff --git a/themes/bear/screenshot.png b/themes/bear/screenshot.png
deleted file mode 100644
index 273266a..0000000
--- a/themes/bear/screenshot.png
+++ /dev/null
Binary files differdiff --git a/themes/bear/templates/404.html b/themes/bear/templates/404.html
deleted file mode 100644
index 15fd75c..0000000
--- a/themes/bear/templates/404.html
+++ /dev/null
@@ -1,8 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}404{% endblock %}
-
-{% block main %}
-  <h1>404</h1>
-  <h2>ʕノ•ᴥ•ʔノ ︵ ┻━┻</h2>
-{% endblock %}
diff --git a/themes/bear/templates/base.html b/themes/bear/templates/base.html
deleted file mode 100644
index 3845773..0000000
--- a/themes/bear/templates/base.html
+++ /dev/null
@@ -1,30 +0,0 @@
-<!DOCTYPE html>
-<html lang="{{ lang | default(value="en-US" ) }}">
-<head>
-  {%- if config.webserver_sends_csp_headers %}
-  {%- include "security_tags.html" ignore missing %}
-  {%- endif %}
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1.0">
-  {%- if config.extra.favicon %}
-  <link rel="shortcut icon" href="{{ config.extra.favicon }}">
-  {%- endif %}
-  <title>{%- block title %}{{ config.title }}{%- endblock %}</title>
-  <meta name="referrer" content="no-referrer-when-downgrade">
-  {%- if config.generate_feed %}
-  {% block rss -%}
-  <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="{{ config.title }}" href="/{{ config.feed_filename }}">
-  {%- endblock -%}
-  {%- endif %}
-  <style>
-  {%- include "style.css.html" ignore missing -%}
-  </style>
-  {% include "custom_head.html" ignore missing -%}
-</head>
-<body>
-  {% include "header.html" ignore missing -%}
-  {% block main %}{%- endblock -%}
-  {% include "footer.html" ignore missing -%}
-  {% include "custom_body.html" ignore missing -%}
-</body>
-</html>
diff --git a/themes/bear/templates/footer.html b/themes/bear/templates/footer.html
deleted file mode 100644
index c952a93..0000000
--- a/themes/bear/templates/footer.html
+++ /dev/null
@@ -1,5 +0,0 @@
-<footer>
-  {%- if not config.extra.hide_made_with_line %}
-    Made with <a href="https://codeberg.org/alanpearce/zola-bearblog">Zola ʕ•ᴥ•ʔ Bear</a>
-  {%- endif %}
-</footer>
diff --git a/themes/bear/templates/header.html b/themes/bear/templates/header.html
deleted file mode 100644
index 55c1756..0000000
--- a/themes/bear/templates/header.html
+++ /dev/null
@@ -1,11 +0,0 @@
-<a class="skip" href="#content">Skip to main content</a>
-<header>
-  <h2>
-    <a href="/" class="title">
-      {{- config.title -}}
-    </a>
-  </h2>
-  <nav>
-    {% include "nav.html" %}
-  </nav>
-</header>{{ "" -}}
diff --git a/themes/bear/templates/index.html b/themes/bear/templates/index.html
deleted file mode 100644
index 6caf1d5..0000000
--- a/themes/bear/templates/index.html
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-  <main>
-    {{ section.content | safe }}
-  </main>
-{% endblock %}
diff --git a/themes/bear/templates/nav.html b/themes/bear/templates/nav.html
deleted file mode 100644
index fe5fdd6..0000000
--- a/themes/bear/templates/nav.html
+++ /dev/null
@@ -1,6 +0,0 @@
-  <a href="/">Home</a>
-{%- if config.extra.main_menu %}
-  {%- for item in config.extra.main_menu %}
-  <a href="{{ item.url | safe }}">{{ item.name }}</a>
-  {%- endfor %}
-{%- endif -%}
diff --git a/themes/bear/templates/page.html b/themes/bear/templates/page.html
deleted file mode 100644
index 93611b5..0000000
--- a/themes/bear/templates/page.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}{{ page.title }} | {{ super() }}{% endblock %}
-
-{% block main %}
-  {%- if not page.extra.menu %}
-    <h1>{{ page.title }}</h1>
-    {%- if page.date %}
-      <p>
-        <time datetime='{{ page.date | date(format='%+') }}' pubdate>
-          {{- page.date | date(format=config.extra.date_format) -}}
-        </time>
-      </p>
-    {%- endif %}
-  {%- endif %}
-  <main id="content">
-    {{ page.content | trim | safe }}
-  </main>
-  <ul class="tags">
-    {%- if page.taxonomies %}
-      {%- for name, taxon in page.taxonomies %}
-        {{ name | capitalize }}:
-        {%- for item in taxon %}
-          <li><a href="{{ get_taxonomy_url(kind=name, name=item) }}">#{{ item }}</a></li>
-        {%- endfor %}
-      {%- endfor %}
-    {%- endif %}
-  </ul>
-{% endblock %}
diff --git a/themes/bear/templates/section.html b/themes/bear/templates/section.html
deleted file mode 100644
index f633036..0000000
--- a/themes/bear/templates/section.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-  <main id="content">
-    {%- if taxonomy.term %}
-      <h3 style="margin-bottom:0">Filtering for "{{ section.title }}"</h3>
-      <small>
-        <a href="{{ get_url(path="@/blog/_index.md") }}">Remove filter</a>
-      </small>
-    {%- endif %}
-    <ul class="blog-posts">
-      {%- for page in section.pages %}
-        <li>
-          <span>
-            <time datetime='{{ page.date | date(format='%+') }}' pubdate>
-              {{ page.date | date(format=config.extra.date_format) }}
-            </time>
-          </span>
-          <a href="{{ page.path | urlencode | safe }}">{{ page.title }}</a>
-        </li>
-      {% else %}
-        <li>
-          No posts yet
-        </li>
-      {%- endfor %}
-    </ul>
-    <ul class="tags">
-      {%- set tags = get_taxonomy(kind="tags") %}
-      {%- for post in tags.items %}
-        <li><a href="{{ post.path | urlencode | safe }}">#{{ post.name }}</a></li>
-      {%- endfor %}
-    </ul>
-  </main>
-{% endblock %}
diff --git a/themes/bear/templates/security_tags.html b/themes/bear/templates/security_tags.html
deleted file mode 100644
index 0f922ea..0000000
--- a/themes/bear/templates/security_tags.html
+++ /dev/null
@@ -1,2 +0,0 @@
-<!-- These tags are here for demostration. It's recommended to send them via HTTP headers instead. -->
-<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
diff --git a/themes/bear/templates/seo_tags.html b/themes/bear/templates/seo_tags.html
deleted file mode 100644
index 4eb2bc8..0000000
--- a/themes/bear/templates/seo_tags.html
+++ /dev/null
@@ -1,2 +0,0 @@
-<meta name="title" content="{% if page.title %}{{ page.title }}{% else %}{{ config.title }}{% endif %}">
-<meta name="description" content="{{ config.description }}" />
diff --git a/themes/bear/templates/style.css.html b/themes/bear/templates/style.css.html
deleted file mode 120000
index f6b71cc..0000000
--- a/themes/bear/templates/style.css.html
+++ /dev/null
@@ -1 +0,0 @@
-style.css
\ No newline at end of file
diff --git a/themes/bear/templates/taxonomy_list.html b/themes/bear/templates/taxonomy_list.html
deleted file mode 100644
index abf4294..0000000
--- a/themes/bear/templates/taxonomy_list.html
+++ /dev/null
@@ -1,14 +0,0 @@
-{% extends "base.html" %}
-
-{% block main %}
-  <main id="content">
-    <small>
-      <div>
-        {% set tags = get_taxonomy(kind="tags") %}
-        {% for post in tags.items %}
-          <a href="{{ post.permalink }}">#{{ post.name }}</a>&nbsp;
-        {% endfor %}
-      </div>
-    </small>
-  </main>
-{% endblock %}
diff --git a/themes/bear/templates/taxonomy_single.html b/themes/bear/templates/taxonomy_single.html
deleted file mode 100644
index a96139c..0000000
--- a/themes/bear/templates/taxonomy_single.html
+++ /dev/null
@@ -1,34 +0,0 @@
-{% extends "base.html" %}
-
-{% block rss -%}
-  <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="{{ config.title }}" href="/{{ config.feed_filename }}">
-  {%- set rss_path = "/tags/" ~ term.name ~ "/" ~ config.feed_filename %}
-  <link rel="alternate" type={% if config.feed_filename == "atom.xml" %}"application/atom+xml"{% else %}"application/rss+xml"{% endif %} title="{% if term %}{{ term.name | title }}{% else %}{{ section.title | title }}{% endif %}" href="{{ rss_path }}">
-{%- endblock -%}
-
-{% block main -%}
-  <main id="content">
-    {%- if taxonomy.term %}
-      <h3 style="margin-bottom:0">Filtering for "{{ term.name }}"</h3>
-      <small>
-        <a href="{{ get_url(path="@/blog/_index.md") }}">Remove filter</a>
-      </small>
-    {%- endif %}
-    <ul class="blog-posts">
-      {%- for page in term.pages %}
-        <li>
-          <span>
-            <time datetime='{{ page.date | date(format='%+') }}' pubdate>
-              {{ page.date | date(format=config.extra.date_format) }}
-            </time>
-          </span>
-          <a href="{{ page.path | urlencode | safe }}">{{ page.title }}</a>
-        </li>
-      {% else %}
-        <li>
-          No posts yet
-        </li>
-      {%- endfor %}
-    </ul>
-  </main>
-{% endblock %}
diff --git a/themes/bear/theme.toml b/themes/bear/theme.toml
deleted file mode 100644
index 902c8ec..0000000
--- a/themes/bear/theme.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-name = "Bear"
-description = "Bear blog theme"
-license = "MIT"
-homepage = "https://codeberg.org/alanpearce/zola-bearblog"
-# The minimum version of Zola required
-min_version = "0.4.0"
-# An optional live demo URL
-demo = "https://zola-bearblog.netlify.app/"
-
-# Any variable there can be overridden in the end user `config.toml`
-# You don't need to prefix variables by the theme name but as this will
-# be merged with user data, some kind of prefix or nesting is preferable
-# Use snake_casing to be consistent with the rest of Zola
-[extra]
-date_format="%d %b, %Y"
-webserver_sends_csp_headers=false
-
-[author]
-name = "Alan Pearce"
-homepage = "https://alanpearce.eu"
-
-[original]
-author = "janraasch"
-repo = "https://github.com/janraasch/hugo-bearblog/"
-homepage = "https://www.janraasch.com"
diff --git a/tsconfig.json b/tsconfig.json
index 1c542d7..79f7630 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,6 +15,9 @@
     "allowSyntheticDefaultImports": true,
     "forceConsistentCasingInFileNames": true,
     "allowJs": true,
+    "paths": {
+      "toml-matter": ["./node_modules/toml-matter"]
+    },
     "types": [
       "bun-types" // add Bun global
     ]