embedding-shapes

en / es / ca / sv

Sobre mi

Versions del repositori
2026-04-20 275d3ae Show "Versions" for all of the post's languages
diff --git a/nix/site/render/pages.nix b/nix/site/render/pages.nix
index 1ffe7d8..cb3e0a9 100644
--- a/nix/site/render/pages.nix
+++ b/nix/site/render/pages.nix
@@ -74 +74 @@ in {
-        filename = page.post.filename;
+        filenames = map (locale: page.group.translations.${locale}.filename) page.group.availableLocales;
diff --git a/nix/versions.nix b/nix/versions.nix
index 679c496..42a6261 100644
--- a/nix/versions.nix
+++ b/nix/versions.nix
@@ -4,2 +4,6 @@ let
-  versionsMd = { name, summary ? "Versions", file ? null, follow ? false }:
-    pkgs.runCommandLocal name {
+  versionsMd = { name, summary ? "Versions", file ? null, files ? [], follow ? false }:
+    let
+      trackedFiles =
+        if files != [] then files
+        else lib.optional (file != null) file;
+    in pkgs.runCommandLocal name {
@@ -13 +16,0 @@ let
-      file=${lib.escapeShellArg (if file == null then "" else file)}
@@ -15,3 +18,9 @@ let
-
-      if [ -n "$file" ]; then
-        ${pkgs.git}/bin/git log ${lib.optionalString follow "--follow"} --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
+      tracked_files=()
+      ${lib.concatMapStringsSep "\n" (trackedFile:
+        "tracked_files+=(${lib.escapeShellArg trackedFile})"
+      ) trackedFiles}
+
+      if [ "''${#tracked_files[@]}" -gt 0 ]; then
+        ${pkgs.git}/bin/git log \
+          ${lib.optionalString (follow && (builtins.length trackedFiles == 1)) "--follow"} \
+          --date=short --format='%H%x09%ad%x09%s' -- "''${tracked_files[@]}" > "$log" 2>/dev/null || true
@@ -45 +54 @@ let
-          if [ -z "$file" ]; then
+          if [ "''${#tracked_files[@]}" -eq 0 ]; then
@@ -46,0 +56,4 @@ let
+          elif [ "''${#tracked_files[@]}" -gt 1 ]; then
+            # Keep post-level history easy to follow by showing the combined patch
+            # for all translation files touched by the commit.
+            ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "''${tracked_files[@]}" 2>/dev/null > "$diff_body" || true
@@ -47,0 +61 @@ let
+            file="''${tracked_files[0]}"
@@ -88,6 +102,13 @@ in {
-  postVersionsHtml = { filename, summary ? "Versions" }: versionsHtml {
-    name = "post-versions-${lib.removeSuffix ".md" filename}.md";
-    inherit summary;
-    file = "posts/${filename}";
-    follow = true;
-  };
+  postVersionsHtml = { filename ? null, filenames ? [], summary ? "Versions" }:
+    let
+      normalizedFilenames =
+        if filenames != [] then filenames
+        else lib.optional (filename != null) filename;
+    in versionsHtml {
+      name =
+        if normalizedFilenames == [] then "post-versions.md"
+        else "post-versions-${lib.removeSuffix ".md" (builtins.head normalizedFilenames)}.md";
+      inherit summary;
+      files = map (postFilename: "posts/${postFilename}") normalizedFilenames;
+      follow = builtins.length normalizedFilenames == 1;
+    };
2026-04-20 6e388bf Añadir version en español y catalán
diff --git a/posts/good-taste.ca.md b/posts/good-taste.ca.md
new file mode 100644
index 0000000..d8cf58d
--- /dev/null
+++ b/posts/good-taste.ca.md
@@ -0,0 +1,23 @@
+---
+title: Bon Gust
+date: 2026-01-25
+slug: bon-gust
+---
+
+Ara mateix corre molt de catastrofisme amb la idea que tots els desenvolupadors, la gent creativa i tants altres ens quedarem sense feina perquè la IA ve a prendre'ns-la. Una part d’aquest catastrofisme té base real: hi ha feines que es mesuren bàsicament en ritme de producció i compliment d’especificacions, i la IA en pot substituir una bona part. Però aquí parlo d’una altra mena de feina, aquella en què hi ha autoria i t’hi jugues alguna cosa en allò que produeixes, com a creador humà.
+
+Entenc per què molts de nosaltres tenim por de la IA i arrosseguem aquesta sensació de fatalitat, però alhora, com a creador, no em preocupa especialment. I no és perquè pensi que la IA no pugui generar coses, o fins i tot coses bones, perquè crec que sí que pot. És perquè el difícil no ha estat mai produir només alguna cosa. El que sempre ha estat difícil és produir alguna cosa bona, alguna cosa feta per algú amb bon criteri. Una persona real que té al davant centenars d’opcions i sap, o intueix, què s’ha de quedar, què s’ha de tallar, què cal portar més enllà i què s’ha de refusar, i que al final et comparteix, a través del mitjà, les decisions que ha pres.
+
+Per deixar-ho clar, aquí parlo sobretot de «crear» i d’autoria, no de consum, perquè tu, com a consumidor, ets l’únic que pot jutjar si una cosa és bona o no per a tu. Però quan estàs fent alguna cosa, si vols que els altres la gaudeixin, el criteri passa a ser una part enorme d’allò que realment saps fer bé. I no em refereixo al criteri dels crítics estirats, que al capdavall també són consumidors, sinó al criteri del creador: direcció, contenció, ritme, assumir riscos i, de vegades, fins i tot ofendre.
+
+Els models d’IA i les plataformes semblen regredir cap a la mitjana. Optimitzen per una cosa que «sona bé» i «no molesta ningú», i acaben produint una cosa «acceptable» o fins i tot «versemblant». Aquesta veu per defecte és just el contrari de l’autoria, perquè en l’autoria explícitament no vols portar-ho tot cap a la mitjana. Vols aconseguir un efecte emocional concret amb un ritme concret, i de vegades estàs disposat a arriscar decisions que semblen equivocades fins que tot el conjunt encaixa. Crec que per això gran part del que surt de la IA resulta tan pla i desangelat.
+
+No estic intentant dir que «la IA no et pugui fer sentir res», perquè no crec que sigui veritat. Crec que la IA pot generar alguna cosa que arribi, i fins i tot pots entrenar-la i/o orientar-la explícitament perquè generi resultats més «emocionals». Però el que realment passa, fins i tot aquí, és que hi ha una persona decidint i triant què fa que una cosa «arribi», i el model n’aprèn la forma. Al capdavall estàs encapsulant el criteri, no pas substituint-lo. A l’altra banda no s’hi juga res, no hi ha cap punt de vista que s’hi comprometi, no hi ha cap moment en què de cop digui: «No, això no ho diré» o «Sí, això és increïble, ara anem en una direcció completament nova».
+
+Sense direcció ni edició humanes, la IA no farà més que anar-te servint infinites versions versemblants fins que, per casualitat, una funcioni. Infinits «prou bé», però poca cosa més.
+
+Al capdavall, crec que la IA és un accelerador. Si ja tens criteri, t’ajudarà a anar més de pressa, i això em sembla bo: la gent molt bona continua fent coses molt bones.
+
+Però també facilita que gent sense criteri generi molt material que o bé és pla o bé, directament, no té ni cap ni peus. Si l’ecosistema no premia la qualitat i el que val la pena, acaba premiant el volum i la velocitat, i tot es va decantant una mica cap al soroll.
+
+Tinc la sensació que estem construint les coses equivocades. Ara mateix tot va de «substituir la part humana» en comptes de «fer millors eines per a la part humana». Jo no vull una màquina que substitueixi el meu criteri, vull eines que m’ajudin a fer-lo servir millor: veure abans on tallar, comparar direccions, comparar opcions d’arquitectura, trobar on m’he deixat coses, detectar quan se’ns està tornant tot massa genèric, i ajudar-me a prendre decisions més precises i més deliberades.
diff --git a/posts/good-taste.es.md b/posts/good-taste.es.md
new file mode 100644
index 0000000..1a804d2
--- /dev/null
+++ b/posts/good-taste.es.md
@@ -0,0 +1,23 @@
+---
+title: Buen Gusto
+date: 2026-01-25
+slug: buen-gusto
+---
+
+Hay mucho fatalismo circulando, esa idea de que todos los desarrolladores, creativos y demás vamos a perder el trabajo porque la IA viene a quitárnoslo. Parte de ese fatalismo es real, hay trabajos que, en el fondo, son puro sacar volumen y cumplir especificaciones, y la IA puede sustituir una buena parte de eso. Pero de lo que hablo aquí es del otro tipo de trabajo, aquel en el que hay autoría y en el que, como creador humano, te juegas algo en lo que produces.
+
+Entiendo por qué muchos le tenemos miedo a la IA y cargamos con esa sensación de fatalismo, pero al mismo tiempo, como creativo, no me preocupa demasiado. Y no porque crea que la IA no pueda generar cosas, o incluso generar cosas buenas, porque sí creo que puede. Lo difícil nunca ha sido producir algo sin más. Lo que siempre ha sido difícil es producir algo bueno, algo hecho por alguien con criterio. Por un ser humano de verdad, que se planta delante de cientos de decisiones y sabe, o siente, qué debe quedarse, qué hay que cortar, qué hay que llevar más allá y qué conviene rechazar, y que al final te transmite esas decisiones a través del medio.
+
+Para que quede claro, aquí hablo sobre todo de "crear" y de autoría, no de consumo, porque tú, como consumidor, eres el único que puede juzgar si algo es bueno o no para ti. Pero cuando estás haciendo algo, si tu objetivo es que otros lo disfruten, el criterio pasa a ser una parte enorme de aquello que de verdad haces bien. No el gusto tal como lo entienden los críticos pedantes, que al final también son consumidores, sino el criterio del creador: dirección, contención, ritmo, asumir riesgos y, a veces, ofender.
+
+Los modelos de IA y las plataformas parecen tender a la regresión a la media. Optimizan hacia algo que "suena bien" y "no molesta a nadie", y terminan produciendo algo "aceptable" o incluso "verosímil". Esa voz por defecto es lo contrario de la autoría, porque cuando hay autoría explícitamente no quieres limitarte a promediarlo todo. Quieres provocar un efecto emocional concreto, con un ritmo concreto, y a veces estás dispuesto a arriesgarte con decisiones que parecen equivocadas hasta que el conjunto termina de encajar. Creo que por eso gran parte de lo que genera la IA se siente tan insípido y sin emoción.
+
+No intento decir que "la IA no puede hacerte sentir nada", porque no creo que eso sea verdad. Creo que la IA puede generar algo que de verdad conecte, incluso puedes entrenarla y/o guiarla explícitamente para que produzca resultados más "emocionales". Pero lo que realmente está ocurriendo, incluso ahí, es que hay un ser humano decidiendo y afinando qué cuenta como algo que de verdad conecta, y el modelo aprende ese patrón. Al final, lo que haces es capturar el criterio, no sustituirlo. Al otro lado no hay nada en juego, no hay un punto de vista que se comprometa de verdad, no hay un momento en el que de pronto diga "No, eso no lo voy a decir" o "Sí, esto es increíble, ahora vamos en una dirección completamente nueva".
+
+Sin dirección ni edición humanas, la IA no hará más que seguir dándote infinitas variantes plausibles hasta que una, por pura casualidad, funcione. Un infinito de "está bien", pero poco más.
+
+Al final, creo que la IA actúa como un acelerante. Si ya tienes criterio, te ayudará a ir más rápido, y eso me parece algo bueno: la gente muy buena sigue produciendo cosas muy buenas.
+
+Pero, por otro lado, también hace más fácil que la gente sin criterio genere una enorme cantidad de material que o bien es insípido o, directamente, un disparate. Si el ecosistema no premia la calidad y lo bueno, lo que termina premiando es el volumen y la velocidad, y todo se va inclinando un poco hacia el ruido.
+
+Siento que estamos construyendo lo equivocado. Ahora mismo todo va de "reemplazar la parte humana" en lugar de "hacer mejores herramientas para la parte humana". Yo no quiero una máquina que sustituya mi criterio, quiero herramientas que me ayuden a usar mejor mi criterio, a ver antes dónde cortar, a comparar enfoques, a comparar decisiones de arquitectura, a encontrar dónde se me han pasado cosas, a detectar cuándo nos estamos volviendo genéricos y a tomar decisiones más afinadas y deliberadas.
2026-04-18 a0e9562 Localify the feeds
diff --git a/nix/site/model/site.nix b/nix/site/model/site.nix
index 23a1ef5..98acb97 100644
--- a/nix/site/model/site.nix
+++ b/nix/site/model/site.nix
@@ -126,0 +127,5 @@ let
+  localeFeedGroupsFor = locale:
+    lib.take config.feedMaxItems (
+      lib.filter (group: builtins.hasAttr locale group.translations) posts.groups
+    );
+
@@ -129 +134 @@ let
-      let variant = displayVariantFor locale group;
+      let variant = group.translations.${locale};
@@ -136 +141 @@ let
-    ) feedGroups;
+    ) (localeFeedGroupsFor locale);
diff --git a/nix/tests/site-output.nix b/nix/tests/site-output.nix
index 7a18726..74fe116 100644
--- a/nix/tests/site-output.nix
+++ b/nix/tests/site-output.nix
@@ -17,0 +18,5 @@ pkgs.runCommand "site-output-check" {
+  test -f ${site}/es/atom.xml
+  test -f ${site}/es/rss.xml
+  test -f ${site}/ca/atom.xml
+  test -f ${site}/ca/rss.xml
+  test -f ${site}/sv/atom.xml
@@ -32,0 +38,7 @@ pkgs.runCommand "site-output-check" {
+  grep -F 'https://emsh.cat/sv/god-smak/' ${site}/sv/atom.xml >/dev/null
+  if grep -F '<entry>' ${site}/es/atom.xml >/dev/null; then exit 1; fi
+  if grep -F '<item>' ${site}/es/rss.xml >/dev/null; then exit 1; fi
+  if grep -F '<entry>' ${site}/ca/atom.xml >/dev/null; then exit 1; fi
+  if grep -F '<item>' ${site}/ca/rss.xml >/dev/null; then exit 1; fi
+  if grep -F 'https://emsh.cat/en/good-taste/' ${site}/sv/atom.xml >/dev/null; then exit 1; fi
+  if grep -F 'https://emsh.cat/en/one-human-one-agent-one-browser/' ${site}/sv/atom.xml >/dev/null; then exit 1; fi
2026-04-18 dcdc119 Handle feed migration correctly
diff --git a/nix/site/data/config.nix b/nix/site/data/config.nix
index 8d26ead..a1b2ee4 100644
--- a/nix/site/data/config.nix
+++ b/nix/site/data/config.nix
@@ -18,6 +18,6 @@
-  legacyRootPosts = [
-    "cursor-implied-success-without-evidence"
-    "good-taste"
-    "introducing-niccup"
-    "one-human-one-agent-one-browser"
-  ];
+  legacyRootPosts = {
+    "cursor-implied-success-without-evidence" = "cursor-implied-success-without-evidence";
+    "good-taste" = "good-taste";
+    "introducing-niccup" = "introducing-niccup";
+    "one-human-one-agent-one-browser" = "one-human-one-agent-one-browser";
+  };
diff --git a/nix/site/model/site.nix b/nix/site/model/site.nix
index 5ce30a5..23a1ef5 100644
--- a/nix/site/model/site.nix
+++ b/nix/site/model/site.nix
@@ -5,0 +6 @@ let
+  feedGroups = lib.take config.feedMaxItems posts.groups;
@@ -13,0 +15,5 @@ let
+  legacyRootPathFor = group:
+    if builtins.hasAttr group.id config.legacyRootPosts
+    then "/${config.legacyRootPosts.${group.id}}/"
+    else null;
+
@@ -68,2 +74,24 @@ let
-    { format = "atom"; locale = i18n.defaultLocale; output = "atom.xml"; path = "/atom.xml"; }
-    { format = "rss"; locale = i18n.defaultLocale; output = "rss.xml"; path = "/rss.xml"; }
+    {
+      description = (stringsFor i18n.defaultLocale).intro;
+      entries = compatibilityFeedEntries;
+      feedId = routes.absoluteUrl "/";
+      format = "atom";
+      homeUrl = routes.absoluteUrl "/";
+      locale = i18n.defaultLocale;
+      output = "atom.xml";
+      path = "/atom.xml";
+      selfUrl = routes.absoluteUrl "/atom.xml";
+      title = config.siteTitle;
+    }
+    {
+      description = (stringsFor i18n.defaultLocale).intro;
+      entries = compatibilityFeedEntries;
+      feedId = routes.absoluteUrl "/";
+      format = "rss";
+      homeUrl = routes.absoluteUrl "/";
+      locale = i18n.defaultLocale;
+      output = "rss.xml";
+      path = "/rss.xml";
+      selfUrl = routes.absoluteUrl "/rss.xml";
+      title = config.siteTitle;
+    }
@@ -73,0 +102,3 @@ let
+      description = (stringsFor locale).intro;
+      entries = localeFeedEntriesFor locale;
+      feedId = routes.absoluteUrl (routes.feedPath locale "atom");
@@ -74,0 +106 @@ let
+      homeUrl = routes.absoluteUrl (routes.homePath locale);
@@ -77,0 +110,2 @@ let
+      selfUrl = routes.absoluteUrl (routes.feedPath locale "atom");
+      title = config.siteTitle;
@@ -79,0 +114,3 @@ let
+      description = (stringsFor locale).intro;
+      entries = localeFeedEntriesFor locale;
+      feedId = routes.absoluteUrl (routes.feedPath locale "rss");
@@ -80,0 +118 @@ let
+      homeUrl = routes.absoluteUrl (routes.homePath locale);
@@ -83,0 +122,2 @@ let
+      selfUrl = routes.absoluteUrl (routes.feedPath locale "rss");
+      title = config.siteTitle;
@@ -87 +127,27 @@ let
-  legacyPostRedirects = map (groupKey:
+  localeFeedEntriesFor = locale:
+    map (group:
+      let variant = displayVariantFor locale group;
+      in {
+        body = variant.body;
+        date = group.date;
+        title = variant.title;
+        url = routes.absoluteUrl (routes.postPath variant.locale variant.slug);
+      }
+    ) feedGroups;
+
+  compatibilityFeedEntries = map (group:
+    let
+      variant = group.translations.${i18n.defaultLocale};
+      legacyPath = legacyRootPathFor group;
+    in {
+      body = variant.body;
+      date = group.date;
+      title = variant.title;
+      url = routes.absoluteUrl (
+        if legacyPath != null then legacyPath
+        else routes.postPath variant.locale variant.slug
+      );
+    }
+  ) feedGroups;
+
+  legacyPostRedirects = lib.mapAttrsToList (groupKey: legacySlug:
@@ -90,2 +156,2 @@ let
-      output = routes.htmlOutputPath "/${groupKey}/";
-      path = "/${groupKey}/";
+      output = routes.htmlOutputPath "/${legacySlug}/";
+      path = "/${legacySlug}/";
@@ -106,11 +171,0 @@ in {
-  feedEntriesFor = locale:
-    let groups = lib.take config.feedMaxItems posts.groups;
-    in map (group:
-      let variant = displayVariantFor locale group;
-      in {
-        body = variant.body;
-        date = group.date;
-        title = variant.title;
-        url = routes.absoluteUrl (routes.postPath variant.locale variant.slug);
-      }
-    ) groups;
diff --git a/nix/site/render/feeds.nix b/nix/site/render/feeds.nix
index cebf744..0151c8b 100644
--- a/nix/site/render/feeds.nix
+++ b/nix/site/render/feeds.nix
@@ -39 +39 @@ in {
-      entries = siteModel.feedEntriesFor feed.locale;
+      entries = feed.entries;
@@ -46,4 +46,4 @@ in {
-      [ "title" config.siteTitle ]
-      [ "id" "${config.siteUrl}${feed.path}" ]
-      (xmlLink { attrs = { href = "${config.siteUrl}${feed.path}"; rel = "self"; type = "application/atom+xml"; }; })
-      (xmlLink { attrs = { href = "${config.siteUrl}/${feed.locale}/"; }; })
+      [ "title" feed.title ]
+      [ "id" feed.feedId ]
+      (xmlLink { attrs = { href = feed.selfUrl; rel = "self"; type = "application/atom+xml"; }; })
+      (xmlLink { attrs = { href = feed.homeUrl; }; })
@@ -68 +68 @@ in {
-      entries = siteModel.feedEntriesFor feed.locale;
+      entries = feed.entries;
@@ -74,3 +74,3 @@ in {
-        [ "title" config.siteTitle ]
-        (xmlLink { content = "${config.siteUrl}/${feed.locale}/"; })
-        [ "description" (siteModel.stringsFor feed.locale).intro ]
+        [ "title" feed.title ]
+        (xmlLink { content = feed.homeUrl; })
+        [ "description" feed.description ]
diff --git a/nix/tests/site-output.nix b/nix/tests/site-output.nix
index ad5de50..7a18726 100644
--- a/nix/tests/site-output.nix
+++ b/nix/tests/site-output.nix
@@ -15,0 +16 @@ pkgs.runCommand "site-output-check" {
+  test -f ${site}/rss.xml
@@ -26,0 +28,5 @@ pkgs.runCommand "site-output-check" {
+  grep -F '<id>https://emsh.cat/</id>' ${site}/atom.xml >/dev/null
+  grep -F 'https://emsh.cat/good-taste/' ${site}/atom.xml >/dev/null
+  grep -F '<link>https://emsh.cat/</link>' ${site}/rss.xml >/dev/null
+  grep -F '<guid isPermaLink="true">https://emsh.cat/good-taste/</guid>' ${site}/rss.xml >/dev/null
+  grep -F 'https://emsh.cat/en/good-taste/' ${site}/en/atom.xml >/dev/null
2026-04-18 9006d97 We're going multilingual
diff --git a/flake.nix b/flake.nix
index 22348d9..b5b4c3a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -11 +11 @@
-      systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
+      systems = [ "x86_64-linux" ];
@@ -19,0 +20,9 @@
+      checks =
+        let
+          system = "x86_64-linux";
+          pkgs = import nixpkgs { inherit system; };
+          site = import ./nix/site.nix { inherit pkgs niccup; };
+        in {
+          ${system} = import ./nix/tests { inherit pkgs; site = site.default; };
+        };
+
@@ -28 +36,0 @@
-
diff --git a/nix/serve.nix b/nix/serve.nix
index b64e4a3..a8f315d 100644
--- a/nix/serve.nix
+++ b/nix/serve.nix
@@ -28,0 +29 @@ in
+  meta.description = "Serve the blog locally with live rebuilds.";
diff --git a/nix/site.nix b/nix/site.nix
index d5a8b9e..6f8e477 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -4,2 +4,4 @@ let
-  site = import ./site/logic.nix { inherit pkgs; };
-  ui = import ./site/presentation.nix { lib = pkgs.lib; h = niccup.lib; };
+  lib = pkgs.lib;
+  config = import ./site/data/config.nix;
+  i18n = import ./site/data/i18n.nix { inherit lib; };
+  routes = import ./site/model/routes.nix { inherit lib config i18n; };
@@ -7,7 +9,84 @@ let
-  indexHtml = pkgs.writeText "index.html" (ui.renderIndexPage { posts = site.posts; });
-  postsHtml = pkgs.writeText "posts.html" (ui.renderPostsIndexPage { posts = site.posts; });
-  aboutHtml = pkgs.writeText "about.html" (ui.renderAboutPage { repoVersions = site.repoVersions; });
-  rssXml = pkgs.writeText "rss.xml" (ui.renderRssFeed { posts = site.posts; });
-  atomXml = pkgs.writeText "atom.xml" (ui.renderAtomFeed { posts = site.posts; });
-  sitemapXml = pkgs.writeText "sitemap.xml" (ui.renderSitemapXml { posts = site.posts; });
-  robotsTxt = pkgs.writeText "robots.txt" (ui.renderRobotsTxt {});
+  projectRoot = ../.;
+  postsDir = projectRoot + "/posts";
+  repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
+  gitDir =
+    if builtins.pathExists (projectRoot + "/.git") then projectRoot + "/.git"
+    else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
+      then builtins.path { name = "blog-git-dir"; path = repoRoot + "/.git"; }
+      else null;
+
+  mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
+    ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
+  '');
+
+  highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
+    echo '```c
+    x
+    ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
+      | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
+      | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
+  '';
+
+  posts = import ./site/content/posts.nix {
+    defaultLocale = i18n.defaultLocale;
+    inherit mdToHtml pkgs postsDir;
+    reservedPostSlugs = config.reservedPostSlugs;
+    supportedLocales = i18n.locales;
+  };
+
+  versions = import ./versions.nix {
+    inherit gitDir lib mdToHtml pkgs;
+  };
+
+  siteModel = import ./site/model/site.nix {
+    inherit config i18n lib posts routes;
+  };
+
+  common = import ./site/render/common.nix {
+    inherit config i18n lib routes;
+    h = niccup.lib;
+  };
+
+  pages = import ./site/render/pages.nix {
+    inherit common lib siteModel versions;
+    h = niccup.lib;
+  };
+
+  feeds = import ./site/render/feeds.nix {
+    inherit config lib siteModel;
+    h = niccup.lib;
+  };
+
+  renderRedirect = import ./site/render/redirect.nix {
+    h = niccup.lib;
+    inherit routes;
+  };
+
+  writeFile = path: text: {
+    inherit path;
+    source = pkgs.writeText (builtins.replaceStrings [ "/" ] [ "-" ] path) text;
+  };
+
+  htmlFiles = map (page:
+    writeFile page.output (
+      if page.kind == "home" then pages.renderHomePage page
+      else if page.kind == "posts" then pages.renderPostsPage page
+      else if page.kind == "about" then pages.renderAboutPage page
+      else pages.renderPostPage page
+    )
+  ) siteModel.htmlPages;
+
+  feedFiles = map (feed:
+    writeFile feed.output (
+      if feed.format == "atom" then feeds.renderAtomFeed feed
+      else feeds.renderRssFeed feed
+    )
+  ) (siteModel.localeFeeds ++ siteModel.compatibilityFeeds);
+
+  extraFiles = [
+    (writeFile siteModel.sitemapPath (feeds.renderSitemapXml {}))
+    (writeFile siteModel.robotsPath (feeds.renderRobotsTxt {}))
+  ];
+
+  redirectFiles = map (redirect: writeFile redirect.output (renderRedirect redirect)) siteModel.redirects;
+  generatedFiles = htmlFiles ++ feedFiles ++ extraFiles ++ redirectFiles;
@@ -17,0 +97 @@ in {
+    cp ${../CNAME} $out/CNAME
@@ -19 +99 @@ in {
-    cp ${site.highlightCss} $out/highlight.css
+    cp ${highlightCss} $out/highlight.css
@@ -22,12 +102,3 @@ in {
-    cp ${indexHtml} $out/index.html
-    cp ${rssXml} $out/rss.xml
-    cp ${atomXml} $out/atom.xml
-    cp ${sitemapXml} $out/sitemap.xml
-    cp ${robotsTxt} $out/robots.txt
-    mkdir -p $out/posts
-    cp ${postsHtml} $out/posts/index.html
-    mkdir -p $out/about
-    cp ${aboutHtml} $out/about/index.html
-    ${builtins.concatStringsSep "\n" (map (post:
-      "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (ui.renderPostPage { inherit post; })} $out/${post.slug}/index.html"
-    ) site.posts)}
+    ${builtins.concatStringsSep "\n" (map (file:
+      "install -Dm644 ${file.source} $out/${file.path}"
+    ) generatedFiles)}
diff --git a/nix/site/content/frontmatter.nix b/nix/site/content/frontmatter.nix
new file mode 100644
index 0000000..fb42afe
--- /dev/null
+++ b/nix/site/content/frontmatter.nix
@@ -0,0 +1,73 @@
+{ lib }:
+
+let
+  dropWhile = pred: list:
+    if list == [] then []
+    else if pred (builtins.head list) then dropWhile pred (builtins.tail list)
+    else list;
+
+  trim = line: lib.trim line;
+
+  stripOuterQuotes = value:
+    let
+      length = builtins.stringLength value;
+      first = if length > 0 then builtins.substring 0 1 value else "";
+      last = if length > 0 then builtins.substring (length - 1) 1 value else "";
+    in
+      if length >= 2 && ((first == "\"" && last == "\"") || (first == "'" && last == "'"))
+      then builtins.substring 1 (length - 2) value
+      else value;
+
+  parseKeyValue = line:
+    let
+      match = builtins.match "^[[:space:]]*([A-Za-z0-9_-]+):[[:space:]]*(.*)[[:space:]]*$" line;
+    in
+      if match == null then null else {
+        key = builtins.elemAt match 0;
+        value = stripOuterQuotes (builtins.elemAt match 1);
+      };
+
+  parseMetadata = lines:
+    lib.foldl' (metadata: line:
+      let parsed = parseKeyValue line;
+      in if parsed == null then metadata else metadata // { ${parsed.key} = parsed.value; }
+    ) {} lines;
+
+in {
+  parse = { content, sourceName, ... }:
+    let
+      lines = lib.splitString "\n" content;
+      hasFrontmatter = lines != [] && builtins.head lines == "---";
+      remaining = if lines == [] then [] else builtins.tail lines;
+      endIndex =
+        if hasFrontmatter
+        then lib.lists.findFirstIndex (line: line == "---") null remaining
+        else null;
+      _ =
+        if hasFrontmatter && endIndex == null
+        then builtins.throw "Post `${sourceName}` starts frontmatter but never closes it."
+        else null;
+
+      frontmatterLines = if endIndex == null then [] else lib.take endIndex remaining;
+      bodyLines0 =
+        if endIndex == null then lines
+        else lib.drop (endIndex + 1) remaining;
+      bodyLines1 = dropWhile (line: trim line == "") bodyLines0;
+      metadata = parseMetadata frontmatterLines;
+
+      hasTopLevelH1 = bodyLines1 != [] && lib.hasPrefix "# " (builtins.head bodyLines1);
+      h1Title =
+        if hasTopLevelH1
+        then trim (lib.removePrefix "# " (builtins.head bodyLines1))
+        else null;
+      bodyLines2 =
+        if hasTopLevelH1
+        then dropWhile (line: trim line == "") (builtins.tail bodyLines1)
+        else bodyLines1;
+    in {
+      bodyMarkdown = lib.concatStringsSep "\n" bodyLines2;
+      date = metadata.date or null;
+      slug = metadata.slug or null;
+      title = metadata.title or h1Title;
+    };
+}
diff --git a/nix/site/content/posts.nix b/nix/site/content/posts.nix
new file mode 100644
index 0000000..4abe40b
--- /dev/null
+++ b/nix/site/content/posts.nix
@@ -0,0 +1,129 @@
+{ pkgs
+, postsDir
+, supportedLocales
+, defaultLocale
+, reservedPostSlugs
+, mdToHtml
+}:
+
+let
+  lib = pkgs.lib;
+  frontmatter = import ./frontmatter.nix { inherit lib; };
+
+  ensure = condition: message:
+    if condition then true else builtins.throw message;
+
+  filenameToTitle = baseName:
+    let
+      words = lib.splitString "-" baseName;
+      capitalize = word:
+        let chars = lib.stringToCharacters word;
+        in
+          if chars == [] then ""
+          else lib.concatStrings ([ lib.toUpper (builtins.head chars) ] ++ builtins.tail chars);
+    in
+      lib.concatStringsSep " " (map capitalize words);
+
+  parseFilename = filename:
+    let
+      localized = builtins.match "^(.*)\\.([a-z][a-z])\\.md$" filename;
+      _suffixCheck =
+        ensure (lib.hasSuffix ".md" filename) "Post `${filename}` does not end in `.md`.";
+    in
+      if localized != null then {
+        groupKey = builtins.elemAt localized 0;
+        locale = builtins.elemAt localized 1;
+      } else {
+        groupKey = lib.removeSuffix ".md" filename;
+        locale = defaultLocale;
+      };
+
+  postFiles = lib.filterAttrs (name: type:
+    type == "regular" && lib.hasSuffix ".md" name
+  ) (builtins.readDir postsDir);
+
+  variants = lib.mapAttrsToList (filename: _:
+    let
+      fileInfo = parseFilename filename;
+      _localeCheck =
+        ensure (lib.elem fileInfo.locale supportedLocales)
+          "Post `${filename}` uses unsupported locale `${fileInfo.locale}`.";
+
+      parsed = frontmatter.parse {
+        content = builtins.readFile (postsDir + "/${filename}");
+        sourceName = filename;
+      };
+      slug =
+        if parsed.slug != null && parsed.slug != "" then parsed.slug
+        else builtins.throw "Post `${filename}` is missing required `slug` frontmatter.";
+      _reservedSlugCheck =
+        ensure (!(lib.elem slug reservedPostSlugs))
+          "Post `${filename}` uses reserved slug `${slug}`.";
+      bodyPath = pkgs.writeText "post-${fileInfo.groupKey}-${fileInfo.locale}.md" parsed.bodyMarkdown;
+    in {
+      body = mdToHtml bodyPath;
+      date = parsed.date;
+      filename = filename;
+      groupKey = fileInfo.groupKey;
+      locale = fileInfo.locale;
+      slug = slug;
+      title =
+        if parsed.title != null then parsed.title
+        else filenameToTitle fileInfo.groupKey;
+    }
+  ) postFiles;
+
+  variantsByGroup = lib.foldl' (groups: variant:
+    groups // {
+      ${variant.groupKey} = (groups.${variant.groupKey} or []) ++ [ variant ];
+    }
+  ) {} variants;
+
+  groupedPosts = lib.mapAttrsToList (groupKey: groupVariants:
+    let
+      locales = map (variant: variant.locale) groupVariants;
+      uniqueLocales = lib.unique locales;
+      _duplicateLocaleCheck =
+        ensure (builtins.length locales == builtins.length uniqueLocales)
+          "Post group `${groupKey}` defines the same locale more than once.";
+      translations = lib.listToAttrs (map (variant: {
+        name = variant.locale;
+        value = variant;
+      }) groupVariants);
+      english =
+        if builtins.hasAttr defaultLocale translations then translations.${defaultLocale}
+        else builtins.throw "Post group `${groupKey}` is missing its English source file.";
+    in {
+      availableLocales = lib.filter (locale: builtins.hasAttr locale translations) supportedLocales;
+      date = english.date;
+      id = groupKey;
+      inherit translations;
+    }
+  ) variantsByGroup;
+
+  sortedGroups =
+    let
+      dateKey = group: if group.date == null then "0000-00-00" else group.date;
+    in lib.sort (a: b: dateKey a > dateKey b) groupedPosts;
+
+  checkedSlugs = lib.foldl' (seen: group:
+    lib.foldl' (innerSeen: locale:
+      let
+        variant = group.translations.${locale};
+        key = "${locale}:${variant.slug}";
+        existing = innerSeen.${key} or null;
+        _duplicateSlugCheck =
+          ensure (existing == null)
+            "Post `${variant.filename}` reuses locale slug `${variant.slug}` already used by `${existing.filename}`.";
+      in
+        innerSeen // { ${key} = variant; }
+    ) seen group.availableLocales
+  ) {} sortedGroups;
+
+in {
+  byId = lib.listToAttrs (map (group: {
+    name = group.id;
+    value = group;
+  }) sortedGroups);
+  groups = builtins.seq checkedSlugs sortedGroups;
+}
diff --git a/nix/site/data/config.nix b/nix/site/data/config.nix
new file mode 100644
index 0000000..8d26ead
--- /dev/null
+++ b/nix/site/data/config.nix
@@ -0,0 +1,24 @@
+{
+  siteTitle = "embedding-shapes";
+  siteUrl = "https://emsh.cat";
+  feedMaxItems = 20;
+
+  reservedPostSlugs = [
+    "about"
+    "atom.xml"
+    "posts"
+    "rss.xml"
+  ];
+
+  legacyRootSections = [
+    "about"
+    "posts"
+  ];
+
+  legacyRootPosts = [
+    "cursor-implied-success-without-evidence"
+    "good-taste"
+    "introducing-niccup"
+    "one-human-one-agent-one-browser"
+  ];
+}
diff --git a/nix/site/data/i18n.nix b/nix/site/data/i18n.nix
new file mode 100644
index 0000000..bf972cc
--- /dev/null
+++ b/nix/site/data/i18n.nix
@@ -0,0 +1,64 @@
+{ lib }:
+
+let
+  locales = [ "en" "es" "ca" "sv" ];
+  defaultLocale = "en";
+
+  strings = {
+    en = {
+      about = "About";
+      builtWith = "Built with";
+      email = "Email";
+      home = "Home";
+      intro = "Welcome to my blog. I write about technology, Nix, and other topics.";
+      posts = "Posts";
+      recentPosts = "Recent Posts";
+      repoVersions = "Repository Versions";
+      versions = "Versions";
+    };
+
+    es = {
+      about = "Sobre mí";
+      builtWith = "Desarrollado con";
+      email = "Correo electrónico";
+      home = "Inicio";
+      intro = "Te doy la bienvenida a mi blog. Escribo sobre tecnología, Nix y otros temas.";
+      posts = "Entradas";
+      recentPosts = "Entradas recientes";
+      repoVersions = "Versiones del repositorio";
+      versions = "Versiones";
+    };
+
+    ca = {
+      about = "Sobre mi";
+      builtWith = "Desenvolupat amb";
+      email = "Correu electrònic";
+      home = "Inici";
+      intro = "Et dono la benvinguda al meu blog. Hi escric sobre tecnologia, Nix i altres temes.";
+      posts = "Entrades";
+      recentPosts = "Entrades recents";
+      repoVersions = "Versions del repositori";
+      versions = "Versions";
+    };
+
+    sv = {
+      about = "Om mig";
+      builtWith = "Byggd med";
+      email = "E-post";
+      home = "Hem";
+      intro = "Välkommen till min blogg. Jag skriver om teknik, Nix och andra ämnen.";
+      posts = "Inlägg";
+      recentPosts = "Senaste inläggen";
+      repoVersions = "Versioner i repot";
+      versions = "Versioner";
+    };
+  };
+
+  ensureLocale = locale:
+    if lib.elem locale locales then locale
+    else builtins.throw "Unsupported locale `${locale}`.";
+
+in {
+  inherit defaultLocale locales strings;
+  stringsFor = locale: strings.${ensureLocale locale};
+}
diff --git a/nix/site/logic.nix b/nix/site/logic.nix
deleted file mode 100644
index 3c75d0a..0000000
--- a/nix/site/logic.nix
+++ /dev/null
@@ -1,130 +0,0 @@
-{ pkgs }:
-
-let
-  lib = pkgs.lib;
-
-  projectRoot = ../..;
-  postsDir = projectRoot + "/posts";
-
-  repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
-  gitDir =
-    if builtins.pathExists (projectRoot + "/.git") then (projectRoot + "/.git")
-    else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
-      then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
-      else null;
-
-  # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
-  # Pandoc automatically skips YAML frontmatter
-  mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
-    ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
-  '');
-
-  versions = import ../versions.nix { inherit pkgs lib gitDir mdToHtml; };
-
-  dropWhile = pred: list:
-    if list == [] then []
-    else if pred (builtins.head list) then dropWhile pred (builtins.tail list)
-    else list;
-
-  # Parse YAML frontmatter (title/date) and derive a markdown body
-  # - If frontmatter is present, it's stripped from the body.
-  # - If the first non-empty body line is a Markdown H1, it's treated as the title
-  #   (only when no frontmatter title is present) and stripped from the body.
-  parsePost = content:
-    let
-      lines = lib.splitString "\n" content;
-      hasFrontmatter = lines != [] && (builtins.head lines) == "---";
-      tailLines = if lines != [] then builtins.tail lines else [];
-      frontmatterEndIdx = if hasFrontmatter
-        then lib.lists.findFirstIndex (l: l == "---") null tailLines
-        else null;
-      frontmatterLines = if frontmatterEndIdx != null
-        then lib.take frontmatterEndIdx tailLines
-        else [];
-      bodyLines0 = if hasFrontmatter && frontmatterEndIdx != null
-        then lib.drop (frontmatterEndIdx + 1) tailLines
-        else lines;
-
-      trimLine = l: lib.trim l;
-      stripOuterQuotes = s:
-        let
-          len = builtins.stringLength s;
-          first = if len > 0 then builtins.substring 0 1 s else "";
-          last = if len > 0 then builtins.substring (len - 1) 1 s else "";
-        in if len >= 2 && ((first == "\"" && last == "\"") || (first == "'" && last == "'"))
-          then builtins.substring 1 (len - 2) s
-          else s;
-      isBlank = l: (trimLine l) == "";
-      bodyLines1 = dropWhile isBlank bodyLines0;
-
-      titleLine = lib.findFirst (l: lib.hasPrefix "title:" l) null frontmatterLines;
-      frontmatterTitle = if titleLine != null
-        then stripOuterQuotes (trimLine (lib.removePrefix "title:" titleLine))
-        else null;
-
-      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
-      date = if dateLine != null then trimLine (lib.removePrefix "date:" dateLine) else null;
-
-      hasTopLevelH1 = bodyLines1 != [] && lib.hasPrefix "# " (builtins.head bodyLines1);
-      h1Title = if hasTopLevelH1 then trimLine (lib.removePrefix "# " (builtins.head bodyLines1)) else null;
-
-      title = if frontmatterTitle != null then frontmatterTitle else h1Title;
-
-      bodyLines2 =
-        if hasTopLevelH1
-        then dropWhile isBlank (builtins.tail bodyLines1)
-        else bodyLines1;
-      bodyMarkdown = lib.concatStringsSep "\n" bodyLines2;
-    in { inherit title date bodyMarkdown; };
-
-  # Generate syntax highlighting CSS from pandoc
-  highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
-    echo '```c
-    x
-    ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
-      | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
-      | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
-  '';
-
-  # Read all .md files from posts directory
-  postFiles = lib.filterAttrs (name: type:
-    type == "regular" && lib.hasSuffix ".md" name
-  ) (builtins.readDir postsDir);
-
-  # Convert filename to title: "hello-world.md" -> "Hello World"
-  filenameToTitle = filename:
-    let
-      slug = lib.removeSuffix ".md" filename;
-      words = lib.splitString "-" slug;
-      capitalize = s:
-        let chars = lib.stringToCharacters s;
-        in if chars == [] then ""
-           else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
-    in lib.concatStringsSep " " (map capitalize words);
-
-  # Build post objects from files
-  posts = lib.mapAttrsToList (filename: _:
-    let
-      content = builtins.readFile (postsDir + "/${filename}");
-      parsed = parsePost content;
-      slug = lib.removeSuffix ".md" filename;
-      mdBodyPath = pkgs.writeText "post-${slug}.md" parsed.bodyMarkdown;
-    in {
-      inherit slug;
-      title = if parsed.title != null then parsed.title else filenameToTitle filename;
-      date = parsed.date;
-      body = mdToHtml mdBodyPath;
-      versions = versions.postVersionsHtml filename;
-    }) postFiles;
-
-  # Sort posts by date, newest first
-  sortedPosts =
-    let
-      dateKey = p: if p.date == null then "0000-00-00" else p.date;
-    in lib.sort (a: b: dateKey a > dateKey b) posts;
-
-in {
-  posts = sortedPosts;
-  inherit highlightCss;
-  inherit (versions) repoVersions;
-}
diff --git a/nix/site/model/routes.nix b/nix/site/model/routes.nix
new file mode 100644
index 0000000..18dc3ff
--- /dev/null
+++ b/nix/site/model/routes.nix
@@ -0,0 +1,32 @@
+{ lib, config, i18n }:
+
+let
+  ensureTrailingSlash = path:
+    if lib.hasSuffix "/" path then path else "${path}/";
+
+  trimTrailingSlash = path:
+    if path != "/" && lib.hasSuffix "/" path
+    then lib.removeSuffix "/" path
+    else path;
+
+in {
+  absoluteUrl = path: "${config.siteUrl}${path}";
+
+  feedOutputPath = locale: format: "${locale}/${format}.xml";
+  feedPath = locale: format: "/${locale}/${format}.xml";
+
+  homePath = locale: "/${locale}/";
+
+  htmlOutputPath = path:
+    if path == "/"
+    then "index.html"
+    else "${trimTrailingSlash (lib.removePrefix "/" (ensureTrailingSlash path))}/index.html";
+
+  pagePath = locale: pageKey:
+    if pageKey == "home" then "/${locale}/"
+    else if pageKey == "posts" then "/${locale}/posts/"
+    else if pageKey == "about" then "/${locale}/about/"
+    else builtins.throw "Unknown page key `${pageKey}`.";
+
+  postPath = locale: slug: "/${locale}/${slug}/";
+}
diff --git a/nix/site/model/site.nix b/nix/site/model/site.nix
new file mode 100644
index 0000000..5ce30a5
--- /dev/null
+++ b/nix/site/model/site.nix
@@ -0,0 +1,152 @@
+{ lib, config, i18n, posts, routes }:
+
+let
+  latestGroup = lib.findFirst (group: group.date != null) null posts.groups;
+  latestDate = if latestGroup != null then latestGroup.date else null;
+
+  stringsFor = locale: i18n.stringsFor locale;
+
+  displayVariantFor = locale: group:
+    if builtins.hasAttr locale group.translations
+    then group.translations.${locale}
+    else group.translations.${i18n.defaultLocale};
+
+  pageLanguageLinks = pageKey: currentLocale:
+    map (locale: {
+      current = locale == currentLocale;
+      href = routes.pagePath locale pageKey;
+      inherit locale;
+    }) i18n.locales;
+
+  postLanguageLinks = group: currentLocale:
+    map (locale: {
+      current = locale == currentLocale;
+      href = routes.postPath locale group.translations.${locale}.slug;
+      inherit locale;
+    }) group.availableLocales;
+
+  listingEntriesFor = locale: groups:
+    map (group:
+      let variant = displayVariantFor locale group;
+      in {
+        date = group.date;
+        href = routes.postPath variant.locale variant.slug;
+        languageLinks = postLanguageLinks group locale;
+        title = variant.title;
+      }
+    ) groups;
+
+  pageEntries = pageKey: titleFor:
+    map (locale: {
+      kind = pageKey;
+      locale = locale;
+      output = routes.htmlOutputPath (routes.pagePath locale pageKey);
+      path = routes.pagePath locale pageKey;
+      title = titleFor locale;
+    }) i18n.locales;
+
+  homePages = pageEntries "home" (_: config.siteTitle);
+  postsPages = pageEntries "posts" (locale: (stringsFor locale).posts);
+  aboutPages = pageEntries "about" (locale: (stringsFor locale).about);
+
+  postPages = lib.concatMap (group:
+    map (locale:
+      let variant = group.translations.${locale};
+      in {
+        group = group;
+        kind = "post";
+        locale = locale;
+        output = routes.htmlOutputPath (routes.postPath locale variant.slug);
+        path = routes.postPath locale variant.slug;
+        post = variant;
+        title = variant.title;
+      }
+    ) group.availableLocales
+  ) posts.groups;
+
+  compatibilityFeeds = [
+    { format = "atom"; locale = i18n.defaultLocale; output = "atom.xml"; path = "/atom.xml"; }
+    { format = "rss"; locale = i18n.defaultLocale; output = "rss.xml"; path = "/rss.xml"; }
+  ];
+
+  localeFeeds = lib.concatMap (locale: [
+    {
+      format = "atom";
+      inherit locale;
+      output = routes.feedOutputPath locale "atom";
+      path = routes.feedPath locale "atom";
+    }
+    {
+      format = "rss";
+      inherit locale;
+      output = routes.feedOutputPath locale "rss";
+      path = routes.feedPath locale "rss";
+    }
+  ]) i18n.locales;
+
+  legacyPostRedirects = map (groupKey:
+    let group = posts.byId.${groupKey};
+    in {
+      output = routes.htmlOutputPath "/${groupKey}/";
+      path = "/${groupKey}/";
+      target = routes.postPath i18n.defaultLocale group.translations.${i18n.defaultLocale}.slug;
+    }
+  ) config.legacyRootPosts;
+
+  legacySectionRedirects = map (section: {
+    output = routes.htmlOutputPath "/${section}/";
+    path = "/${section}/";
+      target = routes.pagePath i18n.defaultLocale section;
+    }) config.legacyRootSections;
+
+in {
+  aboutPages = aboutPages;
+  compatibilityFeeds = compatibilityFeeds;
+  displayVariantFor = displayVariantFor;
+  feedEntriesFor = locale:
+    let groups = lib.take config.feedMaxItems posts.groups;
+    in map (group:
+      let variant = displayVariantFor locale group;
+      in {
+        body = variant.body;
+        date = group.date;
+        title = variant.title;
+        url = routes.absoluteUrl (routes.postPath variant.locale variant.slug);
+      }
+    ) groups;
+  homePages = homePages;
+  htmlPages = homePages ++ postsPages ++ aboutPages ++ postPages;
+  languageAlternatesForPage = pageKey:
+    map (locale: {
+      href = routes.pagePath locale pageKey;
+      inherit locale;
+    }) i18n.locales;
+  languageAlternatesForPost = group:
+    map (locale: {
+      href = routes.postPath locale group.translations.${locale}.slug;
+      inherit locale;
+    }) group.availableLocales;
+  latestDate = latestDate;
+  localeFeeds = localeFeeds;
+  listingEntriesFor = listingEntriesFor;
+  pageLanguageLinks = pageLanguageLinks;
+  postGroups = posts.groups;
+  postLanguageLinks = postLanguageLinks;
+  postPages = postPages;
+  postsPages = postsPages;
+  redirects = [
+    {
+      output = "index.html";
+      path = "/";
+      target = routes.pagePath i18n.defaultLocale "home";
+    }
+  ] ++ legacySectionRedirects ++ legacyPostRedirects;
+  robotsPath = "robots.txt";
+  sitemapEntries =
+    map (page: {
+      lastmod = if page.kind == "post" then page.group.date else latestDate;
+      loc = routes.absoluteUrl page.path;
+    }) (homePages ++ postsPages ++ aboutPages ++ postPages);
+  sitemapPath = "sitemap.xml";
+  stringsFor = stringsFor;
+}
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
deleted file mode 100644
index bdf6396..0000000
--- a/nix/site/presentation.nix
+++ /dev/null
@@ -1,233 +0,0 @@
-{ lib, h }:
-
-let
-  siteTitle = "embedding-shapes";
-  siteUrl = "https://emsh.cat";
-  siteDescription = "Welcome to my blog. I write about technology, Nix, and other topics.";
-
-  homeUrl = "${siteUrl}/";
-  postUrl = slug: "${siteUrl}/${slug}/";
-  niccupUrl = "${siteUrl}/niccup/";
-
-  xmlHeader = encoding: ''<?xml version="1.0" encoding="${encoding}"?>'';
-
-  isoDateToRfc3339 = date: "${date}T00:00:00Z";
-  isoDateToRfc822 = date:
-    let
-      year = builtins.substring 0 4 date;
-      monthNum = builtins.substring 5 2 date;
-      day = builtins.substring 8 2 date;
-      monthMap = {
-        "01" = "Jan"; "02" = "Feb"; "03" = "Mar"; "04" = "Apr";
-        "05" = "May"; "06" = "Jun"; "07" = "Jul"; "08" = "Aug";
-        "09" = "Sep"; "10" = "Oct"; "11" = "Nov"; "12" = "Dec";
-      };
-      month = monthMap.${monthNum} or monthNum;
-    in "${day} ${month} ${year} 00:00:00 +0000";
-
-  feedMaxItems = 20;
-
-  mkFeedModel = posts:
-    let
-      feedPosts = lib.take feedMaxItems posts;
-      entries = map (post: {
-        inherit (post) title date body;
-        url = postUrl post.slug;
-      }) feedPosts;
-      latestEntry = lib.findFirst (e: e.date != null) null entries;
-      latestDate = if latestEntry != null then latestEntry.date else null;
-    in { inherit entries latestDate; };
-
-  xmlEscape = s: builtins.replaceStrings
-    [ "&" "<" ">" "\"" "'" ]
-    [ "&amp;" "&lt;" "&gt;" "&quot;" "&apos;" ]
-    (builtins.toString s);
-
-  xmlAttrs = attrs: builtins.concatStringsSep "" (lib.mapAttrsToList (k: v: " ${k}=\"${xmlEscape v}\"") attrs);
-
-  xmlLink = { attrs ? {}, content ? null }:
-    let renderedAttrs = xmlAttrs attrs;
-    in if content == null
-      then h.raw "<link${renderedAttrs} />\n"
-      else h.raw "<link${renderedAttrs}>${xmlEscape content}</link>\n";
-
-  navLink = { href, label, key, active }: [
-    "a"
-    (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
-    label
-  ];
-
-  plausibleAnalytics = [
-    [ "script" { async = true; src = "https://plausible.io/js/pa-FG5K-GlhTzYQkb3KeYVzG.js"; } ]
-    [ "script" (h.raw ''
-      window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
-      plausible.init()
-    '') ]
-  ];
-
-  header = navActive: [ "header"
-    [ "a" { href = "/"; } siteTitle ]
-    [ "nav"
-      (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
-      (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
-      (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
-    ]
-  ];
-
-  footer = [ "footer" [ "p" "Built with "  [ "a" { href = niccupUrl; } "niccup" ] " · " [ "a" { href = "/atom.xml"; } "Atom" ] " / " [ "a" { href = "/rss.xml"; } "RSS" ] ] ];
-
-  postList = posts: [ "ul" { class = "post-list"; }
-    (map (p: [ "li"
-      [ "a" { href = "/${p.slug}/"; }
-        (lib.optionals (p.date != null) [
-          [ "span" { class = "post-date"; } p.date ]
-          [ "br" ]
-        ])
-        [ "span" { class = "post-title"; } p.title ]
-      ]
-    ]) posts)
-  ];
-
-  renderPage = { title, content, path ? null }:
-    let
-      navActive =
-        if path == "/" then "home"
-        else if path == "/posts/" then "posts"
-        else if path == "/about/" then "about"
-        else null;
-
-      canonicalHref = if path != null then "${siteUrl}${path}" else null;
-    in h.renderPretty [
-    "html" { lang = "en"; }
-    [ "head"
-      [ "meta" { charset = "utf-8"; } ]
-      [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
-      [ "title" title ]
-      (lib.optional (canonicalHref != null) [ "link" { rel = "canonical"; href = canonicalHref; } ])
-      [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
-      [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
-      [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
-      [ "link" { rel = "alternate"; type = "application/rss+xml"; title = "${siteTitle} RSS"; href = "/rss.xml"; } ]
-      [ "link" { rel = "alternate"; type = "application/atom+xml"; title = "${siteTitle} Atom"; href = "/atom.xml"; } ]
-      plausibleAnalytics
-    ]
-    [ "body"
-      (header navActive)
-      [ "main" content ]
-      footer
-    ]
-  ];
-
-in {
-  renderIndexPage = { posts }: renderPage {
-    title = siteTitle;
-    path = "/";
-    content = [
-      [ "p" { class = "intro"; } siteDescription ]
-      [ "h2" "Recent Posts" ]
-      (postList posts)
-    ];
-  };
-
-  renderPostsIndexPage = { posts }: renderPage {
-    title = "Posts";
-    path = "/posts/";
-    content = [
-      [ "h1" "Posts" ]
-      (postList posts)
-    ];
-  };
-
-  renderAboutPage = { repoVersions }: renderPage {
-    title = "About";
-    path = "/about/";
-    content = [
-      [ "h1" "About" ]
-      [ "ul"
-        [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
-        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/emsh.cat"; } "@emsh.cat" ] ]
-        [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
-        [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
-      ]
-      (lib.optional (repoVersions != "") (h.raw repoVersions))
-    ];
-  };
-
-  renderPostPage = { post }: renderPage {
-    title = post.title;
-    path = "/${post.slug}/";
-    content = [
-      [ "h1" post.title ]
-      (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
-      (h.raw post.body)
-      (lib.optional (post.versions != "") (h.raw post.versions))
-    ];
-  };
-
-  renderRssFeed = { posts }:
-    let
-      m = mkFeedModel posts;
-      lastBuildDate = if m.latestDate != null then isoDateToRfc822 m.latestDate else null;
-    in (xmlHeader "UTF-8") + "\n" + (h.render [
-      "rss" { version = "2.0"; }
-      [ "channel"
-        [ "title" siteTitle ]
-        (xmlLink { content = homeUrl; })
-        [ "description" siteDescription ]
-        [ "language" "en" ]
-        (lib.optional (lastBuildDate != null) [ "lastBuildDate" lastBuildDate ])
-        (map (e: [ "item"
-          [ "title" e.title ]
-          (xmlLink { content = e.url; })
-          [ "guid" { isPermaLink = "true"; } e.url ]
-          (lib.optional (e.date != null) [ "pubDate" (isoDateToRfc822 e.date) ])
-          [ "description" e.body ]
-        ]) m.entries)
-      ]
-    ]);
-
-  renderAtomFeed = { posts }:
-    let
-      m = mkFeedModel posts;
-      feedUpdated = if m.latestDate != null then isoDateToRfc3339 m.latestDate else "1970-01-01T00:00:00Z";
-    in (xmlHeader "utf-8") + "\n" + (h.render [
-      "feed" { xmlns = "http://www.w3.org/2005/Atom"; }
-      [ "title" siteTitle ]
-      [ "id" homeUrl ]
-      (xmlLink { attrs = { href = homeUrl; }; })
-      (xmlLink { attrs = { rel = "self"; type = "application/atom+xml"; href = "${siteUrl}/atom.xml"; }; })
-      [ "updated" feedUpdated ]
-      (map (e:
-        let updated = if e.date != null then isoDateToRfc3339 e.date else feedUpdated;
-        in [ "entry"
-          [ "title" e.title ]
-          [ "id" e.url ]
-          (xmlLink { attrs = { href = e.url; }; })
-          [ "updated" updated ]
-          (lib.optional (e.date != null) [ "published" (isoDateToRfc3339 e.date) ])
-          [ "content" { type = "html"; } e.body ]
-        ]
-      ) m.entries)
-    ]);
-
-  renderSitemapXml = { posts }:
-    let
-      latestPostWithDate = lib.findFirst (p: p.date != null) null posts;
-      siteLastMod = if latestPostWithDate != null then latestPostWithDate.date else null;
-      urls =
-        [
-          { loc = homeUrl; lastmod = siteLastMod; }
-          { loc = "${siteUrl}/posts/"; lastmod = siteLastMod; }
-          { loc = "${siteUrl}/about/"; lastmod = siteLastMod; }
-        ]
-        ++ (map (p: { loc = postUrl p.slug; lastmod = p.date; }) posts);
-    in (xmlHeader "UTF-8") + "\n" + (h.render [
-      "urlset" { xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9"; }
-      (map (u: [ "url"
-        [ "loc" u.loc ]
-        (lib.optional (u.lastmod != null) [ "lastmod" u.lastmod ])
-      ]) urls)
-    ]);
-
-  renderRobotsTxt = {}: "User-agent: *\nAllow: /\nSitemap: ${siteUrl}/sitemap.xml\n";
-}
diff --git a/nix/site/render/common.nix b/nix/site/render/common.nix
new file mode 100644
index 0000000..fbf937e
--- /dev/null
+++ b/nix/site/render/common.nix
@@ -0,0 +1,105 @@
+{ lib, h, config, i18n, routes }:
+
+let
+  intersperse = separator: items:
+    if items == [] then []
+    else if builtins.tail items == [] then [ (builtins.head items) ]
+    else [ (builtins.head items) separator ] ++ intersperse separator (builtins.tail items);
+
+  renderLanguageLinks = { class, links }:
+    [ "p" { class = class; }
+      (intersperse " / " (map (link:
+        [ "a"
+          ((if link.current then { "aria-current" = "page"; } else {}) // { href = link.href; })
+          link.locale
+        ]
+      ) links))
+    ];
+
+  plausibleAnalytics = [
+    [ "script" {
+      async = true;
+      src = "https://plausible.io/js/pa-FG5K-GlhTzYQkb3KeYVzG.js";
+    } ]
+    [ "script" (h.raw ''
+      window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
+      plausible.init()
+    '') ]
+  ];
+
+in {
+  inherit renderLanguageLinks;
+
+  renderPage = { active, alternates, canonicalPath, content, locale, title, xDefaultPath, languageLinks }:
+    let
+      strings = i18n.stringsFor locale;
+      navLink = pageKey: label:
+        [ "a"
+          ((if active == pageKey then { "aria-current" = "page"; } else {})
+            // { href = routes.pagePath locale pageKey; })
+          label
+        ];
+    in
+      h.renderPretty [
+        "html" { lang = locale; }
+        [ "head"
+          [ "meta" { charset = "utf-8"; } ]
+          [ "meta" { content = "width=device-width, initial-scale=1"; name = "viewport"; } ]
+          [ "title" title ]
+          [ "link" { href = routes.absoluteUrl canonicalPath; rel = "canonical"; } ]
+          (map (alternate:
+            [ "link" {
+              href = routes.absoluteUrl alternate.href;
+              hreflang = alternate.locale;
+              rel = "alternate";
+            } ]
+          ) alternates)
+          [ "link" {
+            href = routes.absoluteUrl xDefaultPath;
+            hreflang = "x-default";
+            rel = "alternate";
+          } ]
+          [ "link" { href = "/style.css"; rel = "stylesheet"; } ]
+          [ "link" { href = "/highlight.css"; rel = "stylesheet"; } ]
+          [ "link" { href = "/favicon.svg"; rel = "icon"; } ]
+          [ "link" {
+            href = routes.feedPath locale "rss";
+            rel = "alternate";
+            title = "${config.siteTitle} RSS";
+            type = "application/rss+xml";
+          } ]
+          [ "link" {
+            href = routes.feedPath locale "atom";
+            rel = "alternate";
+            title = "${config.siteTitle} Atom";
+            type = "application/atom+xml";
+          } ]
+          plausibleAnalytics
+        ]
+        [ "body"
+          [ "header"
+            [ "div" { class = "brand"; }
+              [ "a" { class = "site-title"; href = routes.pagePath locale "home"; } config.siteTitle ]
+              (renderLanguageLinks { class = "locale-switcher"; links = languageLinks; })
+            ]
+            [ "nav"
+              (navLink "home" strings.home)
+              (navLink "posts" strings.posts)
+              (navLink "about" strings.about)
+            ]
+          ]
+          [ "main" content ]
+          [ "footer"
+            [ "p"
+              strings.builtWith
+              " "
+              [ "a" { href = "https://emsh.cat/niccup/"; } "niccup" ]
+              " · "
+              [ "a" { href = routes.feedPath locale "atom"; } "Atom" ]
+              " / "
+              [ "a" { href = routes.feedPath locale "rss"; } "RSS" ]
+            ]
+          ]
+        ]
+      ];
+}
diff --git a/nix/site/render/feeds.nix b/nix/site/render/feeds.nix
new file mode 100644
index 0000000..cebf744
--- /dev/null
+++ b/nix/site/render/feeds.nix
@@ -0,0 +1,97 @@
+{ lib, h, config, siteModel }:
+
+let
+  xmlHeader = encoding: ''<?xml version="1.0" encoding="${encoding}"?>'';
+
+  isoDateToRfc3339 = date: "${date}T00:00:00Z";
+
+  isoDateToRfc822 = date:
+    let
+      year = builtins.substring 0 4 date;
+      monthNum = builtins.substring 5 2 date;
+      day = builtins.substring 8 2 date;
+      monthMap = {
+        "01" = "Jan"; "02" = "Feb"; "03" = "Mar"; "04" = "Apr";
+        "05" = "May"; "06" = "Jun"; "07" = "Jul"; "08" = "Aug";
+        "09" = "Sep"; "10" = "Oct"; "11" = "Nov"; "12" = "Dec";
+      };
+    in "${day} ${monthMap.${monthNum} or monthNum} ${year} 00:00:00 +0000";
+
+  xmlEscape = value: builtins.replaceStrings
+    [ "&" "<" ">" "\"" "'" ]
+    [ "&amp;" "&lt;" "&gt;" "&quot;" "&apos;" ]
+    (builtins.toString value);
+
+  xmlAttrs = attrs:
+    builtins.concatStringsSep "" (lib.mapAttrsToList (key: value:
+      " ${key}=\"${xmlEscape value}\""
+    ) attrs);
+
+  xmlLink = { attrs ? {}, content ? null }:
+    let renderedAttrs = xmlAttrs attrs;
+    in if content == null
+      then h.raw "<link${renderedAttrs} />\n"
+      else h.raw "<link${renderedAttrs}>${xmlEscape content}</link>\n";
+
+in {
+  renderAtomFeed = feed:
+    let
+      entries = siteModel.feedEntriesFor feed.locale;
+      feedUpdated =
+        if entries == [] || builtins.head entries == null || (builtins.head entries).date == null
+        then "1970-01-01T00:00:00Z"
+        else isoDateToRfc3339 (builtins.head entries).date;
+    in (xmlHeader "utf-8") + "\n" + (h.render [
+      "feed" { xmlns = "http://www.w3.org/2005/Atom"; }
+      [ "title" config.siteTitle ]
+      [ "id" "${config.siteUrl}${feed.path}" ]
+      (xmlLink { attrs = { href = "${config.siteUrl}${feed.path}"; rel = "self"; type = "application/atom+xml"; }; })
+      (xmlLink { attrs = { href = "${config.siteUrl}/${feed.locale}/"; }; })
+      [ "updated" feedUpdated ]
+      (map (entry:
+        let updated = if entry.date != null then isoDateToRfc3339 entry.date else feedUpdated;
+        in [ "entry"
+          [ "title" entry.title ]
+          [ "id" entry.url ]
+          (xmlLink { attrs = { href = entry.url; }; })
+          [ "updated" updated ]
+          (lib.optional (entry.date != null) [ "published" updated ])
+          [ "content" { type = "html"; } entry.body ]
+        ]
+      ) entries)
+    ]);
+
+  renderRobotsTxt = {}: "User-agent: *\nAllow: /\nSitemap: ${config.siteUrl}/sitemap.xml\n";
+
+  renderRssFeed = feed:
+    let
+      entries = siteModel.feedEntriesFor feed.locale;
+      latestEntry = lib.findFirst (entry: entry.date != null) null entries;
+      lastBuildDate = if latestEntry != null then isoDateToRfc822 latestEntry.date else null;
+    in (xmlHeader "UTF-8") + "\n" + (h.render [
+      "rss" { version = "2.0"; }
+      [ "channel"
+        [ "title" config.siteTitle ]
+        (xmlLink { content = "${config.siteUrl}/${feed.locale}/"; })
+        [ "description" (siteModel.stringsFor feed.locale).intro ]
+        [ "language" feed.locale ]
+        (lib.optional (lastBuildDate != null) [ "lastBuildDate" lastBuildDate ])
+        (map (entry: [ "item"
+          [ "title" entry.title ]
+          (xmlLink { content = entry.url; })
+          [ "guid" { isPermaLink = "true"; } entry.url ]
+          (lib.optional (entry.date != null) [ "pubDate" (isoDateToRfc822 entry.date) ])
+          [ "description" entry.body ]
+        ]) entries)
+      ]
+    ]);
+
+  renderSitemapXml = {}:
+    (xmlHeader "UTF-8") + "\n" + (h.render [
+      "urlset" { xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9"; }
+      (map (entry: [ "url"
+        [ "loc" entry.loc ]
+        (lib.optional (entry.lastmod != null) [ "lastmod" entry.lastmod ])
+      ]) siteModel.sitemapEntries)
+    ]);
+}
diff --git a/nix/site/render/pages.nix b/nix/site/render/pages.nix
new file mode 100644
index 0000000..1ffe7d8
--- /dev/null
+++ b/nix/site/render/pages.nix
@@ -0,0 +1,111 @@
+{ lib, h, common, siteModel, versions }:
+
+let
+  postList = { groups, showLanguages }:
+    [ "ul" { class = "post-list"; }
+      (map (entry:
+        [ "li"
+          [ "a" { class = "post-link"; href = entry.href; }
+            (lib.optionals (entry.date != null) [
+              [ "span" { class = "post-date"; } entry.date ]
+              [ "br" ]
+            ])
+            [ "span" { class = "post-title"; } entry.title ]
+          ]
+          (lib.optionals showLanguages [
+            (common.renderLanguageLinks {
+              class = "post-languages";
+              links = entry.languageLinks;
+            })
+          ])
+        ]
+      ) groups)
+    ];
+
+in {
+  renderAboutPage = page:
+    let
+      strings = siteModel.stringsFor page.locale;
+      repoVersionsHtml = versions.repoVersionsHtml strings.repoVersions;
+    in common.renderPage {
+      active = "about";
+      alternates = siteModel.languageAlternatesForPage "about";
+      canonicalPath = page.path;
+      content = [
+        [ "h1" strings.about ]
+        [ "ul"
+          [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
+          [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/emsh.cat"; } "@emsh.cat" ] ]
+          [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
+          [ "li" "${strings.email}: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
+        ]
+        (lib.optional (repoVersionsHtml != "") (h.raw repoVersionsHtml))
+      ];
+      languageLinks = siteModel.pageLanguageLinks "about" page.locale;
+      locale = page.locale;
+      title = page.title;
+      xDefaultPath = "/en/about/";
+    };
+
+  renderHomePage = page:
+    let strings = siteModel.stringsFor page.locale;
+    in common.renderPage {
+      active = "home";
+      alternates = siteModel.languageAlternatesForPage "home";
+      canonicalPath = page.path;
+      content = [
+        [ "p" { class = "intro"; } strings.intro ]
+        [ "h2" strings.recentPosts ]
+        (postList {
+          groups = siteModel.listingEntriesFor page.locale siteModel.postGroups;
+          showLanguages = false;
+        })
+      ];
+      languageLinks = siteModel.pageLanguageLinks "home" page.locale;
+      locale = page.locale;
+      title = page.title;
+      xDefaultPath = "/en/";
+    };
+
+  renderPostPage = page:
+    let
+      strings = siteModel.stringsFor page.locale;
+      versionsHtml = versions.postVersionsHtml {
+        filename = page.post.filename;
+        summary = strings.versions;
+      };
+    in common.renderPage {
+      active = null;
+      alternates = siteModel.languageAlternatesForPost page.group;
+      canonicalPath = page.path;
+      content = [
+        [ "h1" page.post.title ]
+        (lib.optional (page.group.date != null) [ "p" { class = "post-date"; } page.group.date ])
+        (h.raw page.post.body)
+        (lib.optional (versionsHtml != "") (h.raw versionsHtml))
+      ];
+      languageLinks = siteModel.postLanguageLinks page.group page.locale;
+      locale = page.locale;
+      title = page.title;
+      xDefaultPath = "/en/${page.group.translations.en.slug}/";
+    };
+
+  renderPostsPage = page:
+    let strings = siteModel.stringsFor page.locale;
+    in common.renderPage {
+      active = "posts";
+      alternates = siteModel.languageAlternatesForPage "posts";
+      canonicalPath = page.path;
+      content = [
+        [ "h1" strings.posts ]
+        (postList {
+          groups = siteModel.listingEntriesFor page.locale siteModel.postGroups;
+          showLanguages = true;
+        })
+      ];
+      languageLinks = siteModel.pageLanguageLinks "posts" page.locale;
+      locale = page.locale;
+      title = page.title;
+      xDefaultPath = "/en/posts/";
+    };
+}
diff --git a/nix/site/render/redirect.nix b/nix/site/render/redirect.nix
new file mode 100644
index 0000000..fc4b48c
--- /dev/null
+++ b/nix/site/render/redirect.nix
@@ -0,0 +1,18 @@
+{ h, routes }:
+
+redirect:
+h.renderPretty [
+  "html" { lang = "en"; }
+  [ "head"
+    [ "meta" { charset = "utf-8"; } ]
+    [ "meta" { content = "width=device-width, initial-scale=1"; name = "viewport"; } ]
+    [ "meta" { content = "0; url=${redirect.target}"; "http-equiv" = "refresh"; } ]
+    [ "meta" { content = "noindex"; name = "robots"; } ]
+    [ "title" "Redirecting" ]
+    [ "link" { href = routes.absoluteUrl redirect.target; rel = "canonical"; } ]
+    [ "script" (h.raw "window.location.replace(${builtins.toJSON redirect.target});") ]
+  ]
+  [ "body"
+    [ "p" "Redirecting to " [ "a" { href = redirect.target; } redirect.target ] "." ]
+  ]
+]
diff --git a/nix/tests/content-model.nix b/nix/tests/content-model.nix
new file mode 100644
index 0000000..b52287c
--- /dev/null
+++ b/nix/tests/content-model.nix
@@ -0,0 +1,28 @@
+{ pkgs }:
+
+let
+  config = import ../site/data/config.nix;
+  i18n = import ../site/data/i18n.nix { lib = pkgs.lib; };
+
+  loadPosts = postsDir: import ../site/content/posts.nix {
+    inherit pkgs postsDir;
+    defaultLocale = i18n.defaultLocale;
+    mdToHtml = path: builtins.readFile path;
+    reservedPostSlugs = config.reservedPostSlugs;
+    supportedLocales = i18n.locales;
+  };
+
+  validPosts = loadPosts ./fixtures/valid/posts;
+  goodTaste = validPosts.byId.good-taste;
+  invalidResult = builtins.tryEval (
+    let brokenPosts = loadPosts ./fixtures/invalid/posts;
+    in builtins.deepSeq brokenPosts.groups true
+  );
+
+in pkgs.runCommand "content-model-check" {} ''
+  test "${goodTaste.translations.en.slug}" = "good-taste"
+  test "${goodTaste.translations.sv.slug}" = "god-smak"
+  test "${builtins.concatStringsSep "," goodTaste.availableLocales}" = "en,sv"
+  test "${if invalidResult.success then "1" else "0"}" = "0"
+  echo ok > "$out"
+''
diff --git a/nix/tests/default.nix b/nix/tests/default.nix
new file mode 100644
index 0000000..cf8c1ed
--- /dev/null
+++ b/nix/tests/default.nix
@@ -0,0 +1,6 @@
+{ pkgs, site }:
+
+{
+  content-model = import ./content-model.nix { inherit pkgs; };
+  site-output = import ./site-output.nix { inherit pkgs site; };
+}
diff --git a/nix/tests/fixtures/invalid/posts/broken.md b/nix/tests/fixtures/invalid/posts/broken.md
new file mode 100644
index 0000000..6683173
--- /dev/null
+++ b/nix/tests/fixtures/invalid/posts/broken.md
@@ -0,0 +1,5 @@
+---
+title: Broken Post
+---
+
+Missing slug.
diff --git a/nix/tests/fixtures/valid/posts/good-taste.md b/nix/tests/fixtures/valid/posts/good-taste.md
new file mode 100644
index 0000000..4a196a0
--- /dev/null
+++ b/nix/tests/fixtures/valid/posts/good-taste.md
@@ -0,0 +1,7 @@
+---
+title: Good Taste
+date: 2026-01-25
+slug: good-taste
+---
+
+English post body.
diff --git a/nix/tests/fixtures/valid/posts/good-taste.sv.md b/nix/tests/fixtures/valid/posts/good-taste.sv.md
new file mode 100644
index 0000000..055d696
--- /dev/null
+++ b/nix/tests/fixtures/valid/posts/good-taste.sv.md
@@ -0,0 +1,6 @@
+---
+title: God smak
+slug: god-smak
+---
+
+Svensk posttext.
diff --git a/nix/tests/fixtures/valid/posts/plain-post.md b/nix/tests/fixtures/valid/posts/plain-post.md
new file mode 100644
index 0000000..1a87521
--- /dev/null
+++ b/nix/tests/fixtures/valid/posts/plain-post.md
@@ -0,0 +1,6 @@
+---
+title: Plain Post
+slug: plain-post
+---
+
+English only post.
diff --git a/nix/tests/site-output.nix b/nix/tests/site-output.nix
new file mode 100644
index 0000000..ad5de50
--- /dev/null
+++ b/nix/tests/site-output.nix
@@ -0,0 +1,29 @@
+{ pkgs, site }:
+
+pkgs.runCommand "site-output-check" {
+  nativeBuildInputs = [ pkgs.gnugrep ];
+} ''
+  test -f ${site}/en/index.html
+  test -f ${site}/es/index.html
+  test -f ${site}/ca/index.html
+  test -f ${site}/sv/index.html
+  test -f ${site}/en/posts/index.html
+  test -f ${site}/en/good-taste/index.html
+  test -f ${site}/good-taste/index.html
+  test -f ${site}/about/index.html
+  test -f ${site}/posts/index.html
+  test -f ${site}/atom.xml
+  test -f ${site}/en/atom.xml
+  test -f ${site}/sv/rss.xml
+
+  grep -F 'https://emsh.cat/en/good-taste/' ${site}/en/good-taste/index.html >/dev/null
+  grep -F 'hreflang="x-default"' ${site}/en/good-taste/index.html >/dev/null
+  grep -F 'class="post-languages"' ${site}/en/posts/index.html >/dev/null
+  grep -F 'url=/en/' ${site}/index.html >/dev/null
+  grep -F 'url=/en/about/' ${site}/about/index.html >/dev/null
+  grep -F 'url=/en/posts/' ${site}/posts/index.html >/dev/null
+  grep -F 'url=/en/good-taste/' ${site}/good-taste/index.html >/dev/null
+  grep -F 'window.location.replace("/en/good-taste/")' ${site}/good-taste/index.html >/dev/null
+
+  echo ok > "$out"
+''
diff --git a/nix/versions.nix b/nix/versions.nix
index f74ad3b..679c496 100644
--- a/nix/versions.nix
+++ b/nix/versions.nix
@@ -88 +88 @@ in {
-  postVersionsHtml = filename: versionsHtml {
+  postVersionsHtml = { filename, summary ? "Versions" }: versionsHtml {
@@ -89,0 +90 @@ in {
+    inherit summary;
@@ -94 +95 @@ in {
-  repoVersions = versionsHtml {
+  repoVersionsHtml = summary: versionsHtml {
@@ -96 +97 @@ in {
-    summary = "Repository Versions";
+    inherit summary;
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index 7914998..4236976 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -3,0 +4 @@ date: 2026-01-16
+slug: cursor-implied-success-without-evidence
diff --git a/posts/good-taste.md b/posts/good-taste.md
index cb53147..eda48d9 100644
--- a/posts/good-taste.md
+++ b/posts/good-taste.md
@@ -3,0 +4 @@ date: 2026-01-25
+slug: good-taste
diff --git a/posts/good-taste.sv.md b/posts/good-taste.sv.md
new file mode 100644
index 0000000..e89b46e
--- /dev/null
+++ b/posts/good-taste.sv.md
@@ -0,0 +1,23 @@
+---
+title: God Smak
+date: 2026-01-25
+slug: god-smak
+---
+
+Det cirkulerar mycket domedagssnack om hur alla utvecklare, kreatörer och andra ska bli av med jobben när AI kommer och tar dem. En del av den oron är befogad. Det finns jobb som i princip bara handlar om att mata igenom volym enligt spec, och där kan AI ersätta stora delar. Men det jag pratar om här är den andra sortens arbete, där du som mänsklig skapare har ett upphovsmannaskap och något på spel i det du producerar.
+
+Jag förstår varför många av oss är rädda för AI och bär på den där domedagskänslan, men samtidigt är jag som kreatör inte särskilt orolig, och det är inte för att jag tror att AI inte kan generera saker, eller ens bra saker, för det tror jag att den kan. Det är för att det svåra aldrig har varit att bara få fram något. Det som alltid har varit svårt är att få fram något bra, något gjort av någon med god smak. En verklig människa som står inför hundratals val och vet, eller känner, vad som får vara kvar, vad som ska bort, vad som behöver drivas längre och vad som inte ska släppas igenom, och som till sist delar de valen med dig genom sitt medium.
+
+Bara så det är sagt, jag pratar främst om skapande och upphovsmannaskap här, inte om konsumtion, eftersom du som konsument är den enda som kan avgöra om något är bra för dig eller inte. Men när du gör något, och målet är att andra ska uppskatta det, blir god smak en enorm del av det du faktiskt är bra på. Inte smak i den mening som snobbiga kritiker ägnar sig åt, som ju i slutänden också är konsumenter, utan skaparens smak: riktning, återhållsamhet, tempo, risktagande och ibland att väcka anstöt.
+
+AI-modellerna och plattformarna verkar ha en tendens att dras mot medelvärdet. De optimerar för något som "låter rätt" och "inte upprör någon", och resultatet blir något som är "acceptabelt" eller till och med "plausibelt". Den här standardrösten är motsatsen till upphovsmannaskap, där du uttryckligen inte vill jämna ut allt till ett genomsnitt. Du vill landa i en specifik känslomässig effekt med en specifik rytm, och ibland är du beredd att göra val som ser fel ut ända tills helheten sitter. Jag tror att det är därför så mycket AI-genererat material känns så platt och känslolöst.
+
+Jag försöker inte säga att "AI inte kan få dig att känna något", för det tror jag inte stämmer. Jag tror att AI kan generera något som träffar, och du kan till och med uttryckligen träna och/eller styra AI för att få mer "känslomässiga" resultat. Men det som faktiskt händer, även där, är att en människa avgör och kuraterar vad som räknas som en "fullträff", och modellen lär sig hur det ser ut. I slutänden kapslar du in smak, du ersätter den inte. På andra sidan finns inget som står på spel, inget perspektiv som någon faktiskt förbinder sig till, inget ögonblick där den plötsligt säger: "Nej, det där tänker jag inte säga" eller "Ja, det där är fantastiskt, nu går vi i en helt ny riktning".
+
+Utan mänsklig styrning och redigering kommer AI bara fortsätta mata dig med oändligt många plausibla versioner tills någon av dem råkar fungera. Oändligt mycket "helt okej", men inte mycket mer.
+
+I grunden tror jag att AI fungerar som en accelerator. Har du redan god smak hjälper den dig att röra dig snabbare, och det känns som något bra: riktigt bra kreatörer fortsätter att skapa riktigt bra saker.
+
+Men samtidigt gör det också lättare för människor utan smak att generera stora mängder material som antingen är slätstruket eller ibland rent nonsens. Om ekosystemet inte belönar hög kvalitet och sådant som faktiskt är bra, belönar det i stället volym och snabbhet, och allt lutar lite mer mot brus.
+
+Det känns som att vi bygger fel saker. Hela känslan just nu är "ersätt den mänskliga delen" i stället för "bygg bättre verktyg för den mänskliga delen". Jag vill inte ha en maskin som ersätter min smak, jag vill ha verktyg som hjälper mig att använda min smak bättre: se snabbare vad som ska bort, jämföra riktningar, jämföra arkitekturval, hitta det jag har missat, fånga upp när vi börjar glida in i generiska lösningar och fatta mer precisa, medvetna val.
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
index cdab009..8960345 100644
--- a/posts/introducing-niccup.md
+++ b/posts/introducing-niccup.md
@@ -3,0 +4 @@ date: 2025-12-03
+slug: introducing-niccup
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
index 71817f7..1cb19a0 100644
--- a/posts/one-human-one-agent-one-browser.md
+++ b/posts/one-human-one-agent-one-browser.md
@@ -3,0 +4 @@ date: 2026-01-27
+slug: one-human-one-agent-one-browser
diff --git a/style.css b/style.css
index d6a1ada..1f1c43c 100644
--- a/style.css
+++ b/style.css
@@ -1,9 +1,2 @@
-html {
-  overflow-y: scroll;
-}
-
-* {
-  margin: 0;
-  padding: 0;
-  box-sizing: border-box;
-}
+html { overflow-y: scroll; }
+* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -12,7 +5,2 @@ body {
-  font-family: system-ui, -apple-system, sans-serif;
-  line-height: 1.7;
-  color: #c9c9c9;
-  background: #161616;
-  max-width: 48rem;
-  margin: 0 auto;
-  padding: 3rem 1.5rem;
+  max-width: 48rem; margin: 0 auto; padding: 3rem 1.5rem; background: #161616;
+  color: #c9c9c9; font-family: system-ui, -apple-system, sans-serif; line-height: 1.7;
@@ -21,6 +9,2 @@ body {
-header {
-  margin-bottom: 3rem;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
+a { color: #8ab4c2; text-decoration-thickness: 1px; text-underline-offset: 2px; }
+a:hover { color: #a8ced8; text-decoration-thickness: 2px; }
@@ -28,6 +12,3 @@ header {
-header > a {
-  font-size: 1.25rem;
-  font-weight: 600;
-  text-decoration: none;
-  color: #e8e8e8;
-  letter-spacing: -0.02em;
+header {
+  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start;
+  gap: 1rem 2rem; margin-bottom: 3rem;
@@ -36,4 +17 @@ header > a {
-header nav {
-  display: flex;
-  gap: 1.5rem;
-}
+.brand { display: flex; flex-direction: column; gap: 0.45rem; }
@@ -41 +19,2 @@ header nav {
-header nav a {
+.site-title {
+  color: #e8e8e8; font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em;
@@ -43,2 +21,0 @@ header nav a {
-  color: #777;
-  font-size: 0.9375rem;
@@ -47,2 +24,3 @@ header nav a {
-header nav a:hover {
-  color: #b8b8b8;
+.locale-switcher,
+.post-languages {
+  display: flex; flex-wrap: wrap; gap: 0.35rem; color: #666; font-size: 0.8125rem;
@@ -51,4 +29,2 @@ header nav a:hover {
-header nav a[aria-current="page"] {
-  color: #e8e8e8;
-  text-shadow: 0.02em 0 0 currentColor, -0.02em 0 0 currentColor;
-}
+.locale-switcher a,
+.post-languages a { color: #777; text-decoration: none; }
@@ -56,8 +32,2 @@ header nav a[aria-current="page"] {
-main h1 {
-  font-size: 2rem;
-  font-weight: 700;
-  margin-bottom: 1.5rem;
-  line-height: 1.25;
-  letter-spacing: -0.03em;
-  color: #e8e8e8;
-}
+.locale-switcher a:hover,
+.post-languages a:hover { color: #c3c3c3; }
@@ -65,6 +35,2 @@ main h1 {
-main h2 {
-  font-size: 1.25rem;
-  font-weight: 600;
-  margin: 2rem 0 1rem;
-  color: #d8d8d8;
-}
+.locale-switcher a[aria-current="page"],
+.post-languages a[aria-current="page"] { color: #e8e8e8; }
@@ -72,10 +38,4 @@ main h2 {
-main h3 {
-  font-size: 1.1rem;
-  font-weight: 600;
-  margin: 1.75rem 0 0.75rem;
-  color: #d0d0d0;
-}
-
-main p {
-  margin-bottom: 1.25rem;
-}
+header nav { display: flex; flex-wrap: wrap; gap: 1.5rem; padding-top: 0.15rem; }
+header nav a { color: #777; font-size: 0.9375rem; text-decoration: none; }
+header nav a:hover { color: #b8b8b8; }
+header nav a[aria-current="page"] { color: #e8e8e8; text-shadow: 0.02em 0 0 currentColor, -0.02em 0 0 currentColor; }
@@ -83,8 +43,3 @@ main p {
-main img,
-main video {
-  max-width: 100%;
-  height: auto;
-}
-
-main ul, main ol {
-  margin: 1.25rem 0 1.25rem 1.25rem;
+main h1 {
+  margin-bottom: 1.5rem; color: #e8e8e8; font-size: 2rem; font-weight: 700;
+  letter-spacing: -0.03em; line-height: 1.25;
@@ -93,3 +48,6 @@ main ul, main ol {
-main li {
-  margin-bottom: 0.375rem;
-}
+main h2 { margin: 2rem 0 1rem; color: #d8d8d8; font-size: 1.25rem; font-weight: 600; }
+main h3 { margin: 1.75rem 0 0.75rem; color: #d0d0d0; font-size: 1.1rem; font-weight: 600; }
+main p { margin-bottom: 1.25rem; }
+main img, main video { max-width: 100%; height: auto; }
+main ul, main ol { margin: 1.25rem 0 1.25rem 1.25rem; }
+main li { margin-bottom: 0.375rem; }
@@ -98,5 +56,2 @@ main blockquote {
-  border-left: 2px solid #444;
-  padding-left: 1.25rem;
-  margin: 1.5rem 0;
-  font-style: italic;
-  color: #999;
+  margin: 1.5rem 0; padding-left: 1.25rem; border-left: 2px solid #444;
+  color: #999; font-style: italic;
@@ -106,7 +61,2 @@ main pre {
-  background: #1e1e1e;
-  padding: 1rem 1.25rem;
-  overflow-x: auto;
-  margin: 1.5rem 0;
-  font-size: 0.875rem;
-  line-height: 1.5;
-  border-radius: 4px;
+  margin: 1.5rem 0; padding: 1rem 1.25rem; overflow-x: auto; border-radius: 4px;
+  background: #1e1e1e; font-size: 0.875rem; line-height: 1.5;
@@ -115,3 +65 @@ main pre {
-main code {
-  font-family: ui-monospace, "SF Mono", monospace;
-}
+main code { font-family: ui-monospace, "SF Mono", monospace; }
@@ -119,3 +67,3 @@ main code {
-main p code, main li code {
-  background: #252525;
-  padding: 0.125em 0.375em;
+main p code,
+main li code {
+  padding: 0.125em 0.375em; border-radius: 3px; background: #252525; color: #d4d4d4;
@@ -123,8 +70,0 @@ main p code, main li code {
-  border-radius: 3px;
-  color: #d4d4d4;
-}
-
-a {
-  color: #8ab4c2;
-  text-decoration-thickness: 1px;
-  text-underline-offset: 2px;
@@ -133,4 +73,4 @@ a {
-a:hover {
-  color: #a8ced8;
-  text-decoration-thickness: 2px;
-}
+main table { width: 100%; margin: 1.5rem 0; border-collapse: collapse; font-size: 0.9375rem; }
+main th, main td { padding: 0.625rem 0.875rem; border-bottom: 1px solid #2a2a2a; text-align: left; }
+main th { color: #a8a8a8; font-weight: 600; }
+main tbody tr:hover { background: #1c1c1c; }
@@ -138,6 +78,8 @@ a:hover {
-main table {
-  width: 100%;
-  border-collapse: collapse;
-  margin: 1.5rem 0;
-  font-size: 0.9375rem;
-}
+.intro { margin-bottom: 2.5rem; color: #888; font-size: 1.125rem; }
+.post-list { margin: 0; padding: 0; list-style: none; }
+.post-list li { margin-bottom: 0.85rem; }
+.post-link { color: #b8b8b8; font-size: 1.0625rem; text-decoration: none; }
+.post-link:hover { color: #e0e0e0; text-decoration: none; }
+.post-link:hover .post-title { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 2px; }
+.post-date { color: #777; font-size: 0.9375rem; }
+.post-languages { margin-top: 0.2rem; }
@@ -145,5 +87 @@ main table {
-main th, main td {
-  padding: 0.625rem 0.875rem;
-  text-align: left;
-  border-bottom: 1px solid #2a2a2a;
-}
+footer { margin-top: 4rem; color: #555; font-size: 0.8125rem; }
@@ -151,80 +89,5 @@ main th, main td {
-main th {
-  color: #a8a8a8;
-  font-weight: 600;
-}
-
-main tbody tr:hover {
-  background: #1c1c1c;
-}
-
-.intro {
-  font-size: 1.125rem;
-  color: #888;
-  margin-bottom: 2.5rem;
-}
-
-.post-list {
-  list-style: none;
-  margin-left: 0;
-  margin-right: 0;
-  padding-left: 0;
-  padding-right: 0;
-}
-
-.post-list li {
-  margin-bottom: 0.5rem;
-}
-
-.post-list a {
-  text-decoration: none;
-  color: #b8b8b8;
-  font-size: 1.0625rem;
-}
-
-.post-list a:hover {
-  color: #e0e0e0;
-  text-decoration: none;
-}
-
-.post-list a:hover .post-title {
-  text-decoration: underline;
-  text-decoration-thickness: 2px;
-  text-underline-offset: 2px;
-}
-
-.post-date {
-  color: #777;
-  font-size: 0.9375rem;
-}
-
-footer {
-  margin-top: 4rem;
-  color: #555;
-  font-size: 0.8125rem;
-}
-
-.versions {
-  margin-top: 3rem;
-  padding-top: 1.5rem;
-  border-top: 1px solid #2a2a2a;
-}
-
-.versions > summary {
-  cursor: pointer;
-  color: #b8b8b8;
-  font-weight: 600;
-}
-
-.versions > summary:hover {
-  color: #e0e0e0;
-}
-
-.versions details.version {
-  margin-top: 1rem;
-}
-
-.versions details.version > summary {
-  cursor: pointer;
-  color: #999;
-  font-size: 0.9375rem;
-}
+.versions { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid #2a2a2a; }
+.versions > summary { color: #b8b8b8; cursor: pointer; font-weight: 600; }
+.versions > summary:hover { color: #e0e0e0; }
+.versions details.version { margin-top: 1rem; }
+.versions details.version > summary { color: #999; cursor: pointer; font-size: 0.9375rem; }
@@ -233,2 +96 @@ footer {
-  background: #252525;
-  padding: 0.125em 0.375em;
+  padding: 0.125em 0.375em; border-radius: 3px; background: #252525; color: #d4d4d4;
@@ -236,2 +97,0 @@ footer {
-  border-radius: 3px;
-  color: #d4d4d4;
@@ -240,3 +100,2 @@ footer {
-.versions pre {
-  white-space: pre-wrap;
-}
+.versions pre,
+.versions pre code { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -244,3 +103,4 @@ footer {
-.versions pre code {
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
+@media (max-width: 640px) {
+  body { padding: 2rem 1rem; }
+  header { margin-bottom: 2.5rem; }
+  header nav { gap: 1rem; }
2026-01-27 2543cad Add links to Atom/RSS
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 6750203..bdf6396 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -77 +77 @@ let
-  footer = [ "footer" [ "p" "Built with "  [ "a" { href = niccupUrl; } "niccup" ]] ];
+  footer = [ "footer" [ "p" "Built with "  [ "a" { href = niccupUrl; } "niccup" ] " · " [ "a" { href = "/atom.xml"; } "Atom" ] " / " [ "a" { href = "/rss.xml"; } "RSS" ] ] ];
2026-01-27 7916ba2 I'm apparently very bad at spelling, luckily :set spell exists
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
index 613df72..71817f7 100644
--- a/posts/one-human-one-agent-one-browser.md
+++ b/posts/one-human-one-agent-one-browser.md
@@ -8 +8 @@ date: 2026-01-27
-Just for the fun of it, I thought I'd embark on a week long quest to generate millions of tokens and millions lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
+Just for the fun of it, I thought I'd embark on a week-long quest to generate millions of tokens and millions of lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
@@ -10 +10 @@ Just for the fun of it, I thought I'd embark on a week long quest to generate mi
-But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so lets see if we can hack something together, one human brain and one LLM agent brain!
+But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so let's see if we can hack something together, one human brain and one LLM agent brain!
@@ -14 +14 @@ But then I remembered that I have something even better: a human brain! It is us
-The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzsers.
+The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzers.
@@ -28 +28 @@ For extra fun when building this, I set these requirements for myself and the ag
-So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requrements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links to just for the fun of it.
+So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requirements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links just for the fun of it.
@@ -30 +30 @@ So with these things in mind, I set out on the journal to build a browser "from
-After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It's was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
+After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
@@ -34 +34 @@ After about a day together with Codex, I had something that could via X11 and cU
-Second day I got annoying by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compability fixes, some performance issues and improved the font/text rendering a bunch.  Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
+Second day I got annoyed by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compatibility fixes, some performance issues and improved the font/text rendering a bunch. Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask Codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
@@ -38 +38 @@ Second day I got annoying by the tests spawning windows while I was doing other
-Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and whatnot. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
@@ -46 +46 @@ Then the fourth day (whaaaat?) was basically polish, fixing CI for all three pla
-And here it is, in all it's glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
+And here it is, in all its glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
@@ -51,0 +52 @@ And here it is, in all it's glory, made in ~20K lines of code and under 72 hours
+You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent can build a browser from scratch.
@@ -53,3 +54 @@ And here it is, in all it's glory, made in ~20K lines of code and under 72 hours
-You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent, can build a browser from scratch.
-
-This is what the "lines of code" count ended up being after all was said and done, including support three OSes:
+This is what the "lines of code" count ended up being after all was said and done, including support for three OSes:
@@ -151 +150 @@ SUM:                                             2440            365          20
-- This could probably scale to multiple humans too, each equiped with their own agent, imagine what we could achieve!
+- This could probably scale to multiple humans too, each equipped with their own agent, imagine what we could achieve!
@@ -153 +152 @@ SUM:                                             2440            365          20
-- The human who drives the agent might matter more than how the agents work and are setup, the judge is still out on this one
+- The human who drives the agent might matter more than how the agents work and are set up, the judge is still out on this one
@@ -155 +154 @@ SUM:                                             2440            365          20
-If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected. 
+If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected.
2026-01-27 b819707 Touchups + headers
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
index d3eef24..613df72 100644
--- a/posts/one-human-one-agent-one-browser.md
+++ b/posts/one-human-one-agent-one-browser.md
@@ -15,0 +16,2 @@ The above might look like a simple .webm video, but it's actually a highly sophi
+## Day 1 - Starting out
+
@@ -29,0 +32,2 @@ After about a day together with Codex, I had something that could via X11 and cU
+## Day 2 - Moving On
+
@@ -32 +36,3 @@ Second day I got annoying by the tests spawning windows while I was doing other
-Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video below, and also added support for the back button.
+## Day 3 - Polish & Cross-platform (+ day 4)
+
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
@@ -34 +40 @@ Third day we made large changes, lots of new features and a bunch of new feature
-At the end of the third day we also added support for macOS finally, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
+At the end of the third day we also added starting support for macOS, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
@@ -36 +42 @@ At the end of the third day we also added support for macOS finally, and managed
-Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI.
+Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI. Still all within 72 hours (3 days * 24 hours, which obviously this is how you count days).
2026-01-27 71a7236 Make layout slightly larger
diff --git a/style.css b/style.css
index a760580..d6a1ada 100644
--- a/style.css
+++ b/style.css
@@ -16 +16 @@ body {
-  max-width: 38rem;
+  max-width: 48rem;
2026-01-27 bc26dcb Add blogpost + video about "one agent one browser"
diff --git a/content/one-agent-one-browser-hn.png b/content/one-agent-one-browser-hn.png
new file mode 100644
index 0000000..4a1c250
Binary files /dev/null and b/content/one-agent-one-browser-hn.png differ
diff --git a/content/one-human-one-agent-one-browser.webm b/content/one-human-one-agent-one-browser.webm
new file mode 100644
index 0000000..d2a201f
Binary files /dev/null and b/content/one-human-one-agent-one-browser.webm differ
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
new file mode 100644
index 0000000..d3eef24
--- /dev/null
+++ b/posts/one-human-one-agent-one-browser.md
@@ -0,0 +1,149 @@
+---
+title: One Human + One Agent = One Browser From Scratch
+date: 2026-01-27
+---
+
+# One Human + One Agent = One Browser From Scratch
+
+Just for the fun of it, I thought I'd embark on a week long quest to generate millions of tokens and millions lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
+
+But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so lets see if we can hack something together, one human brain and one LLM agent brain!
+
+![Demonstration of one-agent-one-browser running with a bunch of different websites on Linux/X11](/content/one-human-one-agent-one-browser.webm)
+
+The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzsers.
+
+For extra fun when building this, I set these requirements for myself and the agent:
+
+- I have three days to build it
+- Not a single 3rd party Rust library/dependency allowed
+- Allowed to use anything (commonly) provided out of the box on the OS it runs on
+- Should run on Windows, macOS and common Linux distributions
+- Should be able to render some websites, most importantly, my own blog and Hacker News, should be easy right?
+- The codebase can always compile and be built
+- The codebase should be readable by a human, although code quality isn't the top concern
+
+So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requrements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links to just for the fun of it.
+
+After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It's was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
+
+Second day I got annoying by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compability fixes, some performance issues and improved the font/text rendering a bunch.  Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
+
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video below, and also added support for the back button.
+
+At the end of the third day we also added support for macOS finally, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
+
+Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI.
+
+## The results after ~3 days (~70 hours)
+
+And here it is, in all it's glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
+
+[![Screenshot of one-agent-one-browser running on X11](/content/one-agent-one-browser-hn.png)](https://github.com/embedding-shapes/one-agent-one-browser)
+
+> You could try compiling it yourself (zero Rust dependencies, so it's really fast :) ), or you can find binaries built on CI here:<br/><small>[https://github.com/embedding-shapes/one-agent-one-browser/releases](https://github.com/embedding-shapes/one-agent-one-browser/releases)</small>
+
+
+You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent, can build a browser from scratch.
+
+This is what the "lines of code" count ended up being after all was said and done, including support three OSes:
+
+```shell
+$ git rev-parse HEAD
+e2556016a5aa504ecafd5577c1366854ffd0e280
+
+$ cloc src --by-file
+      72 text files.
+      72 unique files.
+       0 files ignored.
+
+github.com/AlDanial/cloc v 2.06  T=0.06 s (1172.5 files/s, 373824.0 lines/s)
+-----------------------------------------------------------------------------------
+File                                            blank        comment           code
+-----------------------------------------------------------------------------------
+src/layout/flex.rs                                 96              0            994
+src/layout/inline.rs                               85              0            933
+src/layout/mod.rs                                  82              0            910
+src/browser.rs                                     78              0            867
+src/platform/macos/painter.rs                      96              0            765
+src/platform/x11/cairo.rs                          77              0            713
+src/platform/windows/painter.rs                    88              0            689
+src/bin/render-test.rs                             87              0            666
+src/style/builder.rs                               83              0            663
+src/platform/windows/d2d.rs                        53              0            595
+src/platform/windows/windowed.rs                   72              0            591
+src/style/declarations.rs                          18              0            547
+src/image.rs                                       81              0            533
+src/platform/macos/windowed.rs                     80              2            519
+src/net/winhttp.rs                                 61              2            500
+src/platform/x11/mod.rs                            56              2            487
+src/css.rs                                        103            346            423
+src/html.rs                                        58              0            413
+src/platform/x11/painter.rs                        48              0            407
+src/platform/x11/scale.rs                          57              3            346
+src/layout/table.rs                                39              1            340
+src/platform/x11/xft.rs                            35              0            338
+src/style/parse.rs                                 34              0            311
+src/win/wic.rs                                     39              8            305
+src/style/mod.rs                                   26              0            292
+src/style/computer.rs                              35              0            279
+src/platform/x11/xlib.rs                           32              0            278
+src/layout/floats.rs                               31              0            265
+src/resources.rs                                   36              0            238
+src/css_media.rs                                   36              1            232
+src/debug.rs                                       32              0            227
+src/platform/windows/dwrite.rs                     20              0            222
+src/render.rs                                      18              0            196
+src/style/custom_properties.rs                     34              0            186
+src/platform/windows/scale.rs                      28              0            184
+src/url.rs                                         32              0            173
+src/layout/helpers.rs                              12              0            172
+src/net/curl.rs                                    31              0            171
+src/platform/macos/svg.rs                          35              0            171
+src/browser/url_loader.rs                          17              0            166
+src/platform/windows/gdi.rs                        17              0            165
+src/platform/windows/scaled.rs                     16              0            159
+src/platform/macos/scaled.rs                       16              0            158
+src/layout/svg_xml.rs                               9              0            152
+src/win/com.rs                                     26              0            152
+src/png.rs                                         27              0            146
+src/layout/replaced.rs                             15              0            131
+src/net/pool.rs                                    18              0            129
+src/platform/macos/scale.rs                        17              0            124
+src/style/selectors.rs                             18              0            123
+src/style/length.rs                                17              0            121
+src/cli.rs                                         15              0            112
+src/platform/windows/headless.rs                   20              0            112
+src/platform/macos/headless.rs                     19              0            109
+src/bin/fetch-resource.rs                          14              0            101
+src/geom.rs                                        10              0            101
+src/browser/render_helpers.rs                      11              0            100
+src/dom.rs                                         11              0            100
+src/style/background.rs                            15              0            100
+src/layout/tests.rs                                 7              0             85
+src/platform/windows/d3d11.rs                      14              0             83
+src/win/stream.rs                                  10              0             63
+src/platform/windows/svg.rs                        13              0             54
+src/main.rs                                         4              0             33
+src/platform/mod.rs                                 6              0             28
+src/app.rs                                          5              0             25
+src/lib.rs                                          1              0             20
+src/platform/windows/mod.rs                         2              0             19
+src/net/mod.rs                                      4              0             16
+src/platform/macos/mod.rs                           2              0             14
+src/platform/windows/wstr.rs                        0              0              5
+src/win/mod.rs                                      0              0              3
+-----------------------------------------------------------------------------------
+SUM:                                             2440            365          20150
+-----------------------------------------------------------------------------------
+```
+
+## Takeaways
+
+- One human using one agent seems far more effective than one human using thousands of agents
+- One agent can work on a single codebase for hours, making real progress on ambitious projects
+- This could probably scale to multiple humans too, each equiped with their own agent, imagine what we could achieve!
+- Sometimes slower is faster and also better
+- The human who drives the agent might matter more than how the agents work and are setup, the judge is still out on this one
+
+If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected. 
2026-01-25 a0a9c92 New "Good Taste" post
diff --git a/posts/good-taste.md b/posts/good-taste.md
new file mode 100644
index 0000000..cb53147
--- /dev/null
+++ b/posts/good-taste.md
@@ -0,0 +1,22 @@
+---
+title: Good Taste
+date: 2026-01-25
+---
+
+There is a lot of doom going around, how all developers, creatives and others are losing our jobs because AI is coming to take them. Some of that doom is real; there's work that's basically throughput and spec compliance, and AI can replace big chunks of it. But what I’m talking about here is the other kind of work, where you have authorship and stake in what you're producing, as a human creator.
+
+I get why many of us fear AI and carry this sense of doom with us, but at the same time, as a creative I'm not super worried about it, and not because I think AI cannot generate things, or even generate good things, because I think it can. But because the hard thing has never been to just produce *something*. What's always been hard, is producing *something good*, something made by someone with Good Taste. A real human being that stand in front of hundreds of choices and knows (or feels) what gets to stay, what gets cut, what to push and what should be refused, and finally sharing the choices they made with you, through the medium.
+
+Just to be clear, I'm mainly talking about "creating" and authorship here, not consumption, since you as a consumer are the only judge if something is good or not for you. But when you're making something, if your goal is for others to enjoy it, Good Taste becomes a huge part of what you actually excel at. Not taste as in what the snobby critics do (who ultimately are consumers), but the creator's taste; direction, restraint, pacing, taking risks and sometimes causing offense.
+
+The AI models and the platforms seems to tend to regress to the mean. They optimize for something that "sounds right" and "doesn't upset anyone", creating something that is "acceptable" or even "plausible". This default voice is the opposite of authorship, where you explicitly *don't* want to just average thing out. You want to hit a specific emotional effect with specific rhythm, and you're sometimes willing to risk choices that look wrong until the entire thing is put together. I think this is why most AI output feels so bland and emotionless.
+
+I'm not trying to say that "AI can't make you feel anything" because I don't think it's true, I think AI can generate something that hits, you can even explicitly train and/or steer AI to produce more "emotional" outputs. But what's actually happening, even there, is that there is a human deciding and curating what counts as a "hit", and the model is learning the shape of that. Ultimately you're bottling taste, not replacing it. There is no stake at the other side, no point of view that is getting committed to, no moment where it suddenly goes "No, I'm not saying that" or "Yes, that's amazing, completely new direction now".
+
+Without any human steering and editing, the AI will just keep handing you infinite plausible takes until one of them happen to work. Infinite "fine", but not much more.
+
+Ultimately, I think AI is an accelerant. If you already have Good Taste, it'll help you move faster, which feels like a good thing: great people continue to produce great things.
+
+But on the other hand, it also makes it easier for people without taste to generate a lot of output that's either bland or sometimes straight up nonsense. Without the ecosystem rewarding high quality and good things, it instead rewards volume and speed, everything slightly tilting to noise.
+
+I feel like we're building the wrong things. The whole vibe right now is "replace the human part" instead of "make better tools for the human part". I don't want a machine that replaces my taste, I want tools that help me use my taste better; see the cut faster, compare directions, compare architectural choices, find where I've missed things, catch when we're going into generics, and help me make sharper intentional choices.
2026-01-23 ab57a94 Sitemap + robots.txt + update old URLs
diff --git a/nix/site.nix b/nix/site.nix
index e195a83..d5a8b9e 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -11,0 +12,2 @@ let
+  sitemapXml = pkgs.writeText "sitemap.xml" (ui.renderSitemapXml { posts = site.posts; });
+  robotsTxt = pkgs.writeText "robots.txt" (ui.renderRobotsTxt {});
@@ -22,0 +25,2 @@ in {
+    cp ${sitemapXml} $out/sitemap.xml
+    cp ${robotsTxt} $out/robots.txt
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index b2d34a4..6750203 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -9,0 +10 @@ let
+  niccupUrl = "${siteUrl}/niccup/";
@@ -76 +77 @@ let
-  footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
+  footer = [ "footer" [ "p" "Built with "  [ "a" { href = niccupUrl; } "niccup" ]] ];
@@ -210,0 +212,21 @@ in {
+
+  renderSitemapXml = { posts }:
+    let
+      latestPostWithDate = lib.findFirst (p: p.date != null) null posts;
+      siteLastMod = if latestPostWithDate != null then latestPostWithDate.date else null;
+      urls =
+        [
+          { loc = homeUrl; lastmod = siteLastMod; }
+          { loc = "${siteUrl}/posts/"; lastmod = siteLastMod; }
+          { loc = "${siteUrl}/about/"; lastmod = siteLastMod; }
+        ]
+        ++ (map (p: { loc = postUrl p.slug; lastmod = p.date; }) posts);
+    in (xmlHeader "UTF-8") + "\n" + (h.render [
+      "urlset" { xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9"; }
+      (map (u: [ "url"
+        [ "loc" u.loc ]
+        (lib.optional (u.lastmod != null) [ "lastmod" u.lastmod ])
+      ]) urls)
+    ]);
+
+  renderRobotsTxt = {}: "User-agent: *\nAllow: /\nSitemap: ${siteUrl}/sitemap.xml\n";
diff --git a/nix/versions.nix b/nix/versions.nix
index 093d7fe..f74ad3b 100644
--- a/nix/versions.nix
+++ b/nix/versions.nix
@@ -99 +98,0 @@ in {
-
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
index d023363..cdab009 100644
--- a/posts/introducing-niccup.md
+++ b/posts/introducing-niccup.md
@@ -21 +21 @@ That's it. Nix data structures in, HTML out. Zero dependencies. Works with flake
-[Source Code](https://github.com/embedding-shapes/niccup) | [Website/Docs](https://embedding-shapes.github.io/niccup/) | [Introduction Blog Post](https://embedding-shapes.github.io/introducing-niccup/)
+[Source Code](https://github.com/embedding-shapes/niccup) | [Website/Docs](https://emsh.cat/niccup/) | [Introduction Blog Post](https://emsh.cat/introducing-niccup/)
2026-01-23 45649fa Update bsky username
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 33f7545..b2d34a4 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -147 +147 @@ in {
-        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
+        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/emsh.cat"; } "@emsh.cat" ] ]
2026-01-22 35d3187 Change canonical domain for blog, now we're emsh.cat :)
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 4168887..33f7545 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -5 +5 @@ let
-  siteUrl = "https://embedding-shapes.github.io";
+  siteUrl = "https://emsh.cat";
@@ -96,0 +97,2 @@ let
+
+      canonicalHref = if path != null then "${siteUrl}${path}" else null;
@@ -102,0 +105 @@ let
+      (lib.optional (canonicalHref != null) [ "link" { rel = "canonical"; href = canonicalHref; } ])
@@ -153,0 +157 @@ in {
+    path = "/${post.slug}/";
2026-01-21 4319ddd Add CNAME file
diff --git a/CNAME b/CNAME
new file mode 100644
index 0000000..a2fbca1
--- /dev/null
+++ b/CNAME
@@ -0,0 +1 @@
+emsh.cat
2026-01-21 f1e1972 Add Atom/RSS feed
diff --git a/nix/site.nix b/nix/site.nix
index 9ad3a77..e195a83 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -9,0 +10,2 @@ let
+  rssXml = pkgs.writeText "rss.xml" (ui.renderRssFeed { posts = site.posts; });
+  atomXml = pkgs.writeText "atom.xml" (ui.renderAtomFeed { posts = site.posts; });
@@ -18,0 +21,2 @@ in {
+    cp ${rssXml} $out/rss.xml
+    cp ${atomXml} $out/atom.xml
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index affea54..4168887 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -3,0 +4,49 @@ let
+  siteTitle = "embedding-shapes";
+  siteUrl = "https://embedding-shapes.github.io";
+  siteDescription = "Welcome to my blog. I write about technology, Nix, and other topics.";
+
+  homeUrl = "${siteUrl}/";
+  postUrl = slug: "${siteUrl}/${slug}/";
+
+  xmlHeader = encoding: ''<?xml version="1.0" encoding="${encoding}"?>'';
+
+  isoDateToRfc3339 = date: "${date}T00:00:00Z";
+  isoDateToRfc822 = date:
+    let
+      year = builtins.substring 0 4 date;
+      monthNum = builtins.substring 5 2 date;
+      day = builtins.substring 8 2 date;
+      monthMap = {
+        "01" = "Jan"; "02" = "Feb"; "03" = "Mar"; "04" = "Apr";
+        "05" = "May"; "06" = "Jun"; "07" = "Jul"; "08" = "Aug";
+        "09" = "Sep"; "10" = "Oct"; "11" = "Nov"; "12" = "Dec";
+      };
+      month = monthMap.${monthNum} or monthNum;
+    in "${day} ${month} ${year} 00:00:00 +0000";
+
+  feedMaxItems = 20;
+
+  mkFeedModel = posts:
+    let
+      feedPosts = lib.take feedMaxItems posts;
+      entries = map (post: {
+        inherit (post) title date body;
+        url = postUrl post.slug;
+      }) feedPosts;
+      latestEntry = lib.findFirst (e: e.date != null) null entries;
+      latestDate = if latestEntry != null then latestEntry.date else null;
+    in { inherit entries latestDate; };
+
+  xmlEscape = s: builtins.replaceStrings
+    [ "&" "<" ">" "\"" "'" ]
+    [ "&amp;" "&lt;" "&gt;" "&quot;" "&apos;" ]
+    (builtins.toString s);
+
+  xmlAttrs = attrs: builtins.concatStringsSep "" (lib.mapAttrsToList (k: v: " ${k}=\"${xmlEscape v}\"") attrs);
+
+  xmlLink = { attrs ? {}, content ? null }:
+    let renderedAttrs = xmlAttrs attrs;
+    in if content == null
+      then h.raw "<link${renderedAttrs} />\n"
+      else h.raw "<link${renderedAttrs}>${xmlEscape content}</link>\n";
+
@@ -19 +68 @@ let
-    [ "a" { href = "/"; } "embedding-shapes" ]
+    [ "a" { href = "/"; } siteTitle ]
@@ -56,0 +106,2 @@ let
+      [ "link" { rel = "alternate"; type = "application/rss+xml"; title = "${siteTitle} RSS"; href = "/rss.xml"; } ]
+      [ "link" { rel = "alternate"; type = "application/atom+xml"; title = "${siteTitle} Atom"; href = "/atom.xml"; } ]
@@ -68 +119 @@ in {
-    title = "embedding-shapes";
+    title = siteTitle;
@@ -71 +122 @@ in {
-      [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
+      [ "p" { class = "intro"; } siteDescription ]
@@ -109,0 +161,46 @@ in {
+
+  renderRssFeed = { posts }:
+    let
+      m = mkFeedModel posts;
+      lastBuildDate = if m.latestDate != null then isoDateToRfc822 m.latestDate else null;
+    in (xmlHeader "UTF-8") + "\n" + (h.render [
+      "rss" { version = "2.0"; }
+      [ "channel"
+        [ "title" siteTitle ]
+        (xmlLink { content = homeUrl; })
+        [ "description" siteDescription ]
+        [ "language" "en" ]
+        (lib.optional (lastBuildDate != null) [ "lastBuildDate" lastBuildDate ])
+        (map (e: [ "item"
+          [ "title" e.title ]
+          (xmlLink { content = e.url; })
+          [ "guid" { isPermaLink = "true"; } e.url ]
+          (lib.optional (e.date != null) [ "pubDate" (isoDateToRfc822 e.date) ])
+          [ "description" e.body ]
+        ]) m.entries)
+      ]
+    ]);
+
+  renderAtomFeed = { posts }:
+    let
+      m = mkFeedModel posts;
+      feedUpdated = if m.latestDate != null then isoDateToRfc3339 m.latestDate else "1970-01-01T00:00:00Z";
+    in (xmlHeader "utf-8") + "\n" + (h.render [
+      "feed" { xmlns = "http://www.w3.org/2005/Atom"; }
+      [ "title" siteTitle ]
+      [ "id" homeUrl ]
+      (xmlLink { attrs = { href = homeUrl; }; })
+      (xmlLink { attrs = { rel = "self"; type = "application/atom+xml"; href = "${siteUrl}/atom.xml"; }; })
+      [ "updated" feedUpdated ]
+      (map (e:
+        let updated = if e.date != null then isoDateToRfc3339 e.date else feedUpdated;
+        in [ "entry"
+          [ "title" e.title ]
+          [ "id" e.url ]
+          (xmlLink { attrs = { href = e.url; }; })
+          [ "updated" updated ]
+          (lib.optional (e.date != null) [ "published" (isoDateToRfc3339 e.date) ])
+          [ "content" { type = "html"; } e.body ]
+        ]
+      ) m.entries)
+    ]);
2026-01-21 6cdf79e Add Plausible
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index c191536..affea54 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -9,0 +10,8 @@ let
+  plausibleAnalytics = [
+    [ "script" { async = true; src = "https://plausible.io/js/pa-FG5K-GlhTzYQkb3KeYVzG.js"; } ]
+    [ "script" (h.raw ''
+      window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
+      plausible.init()
+    '') ]
+  ];
+
@@ -48,0 +57 @@ let
+      plausibleAnalytics
2026-01-17 8a5eb5c Dont underline date
diff --git a/style.css b/style.css
index 47e50cf..a760580 100644
--- a/style.css
+++ b/style.css
@@ -185,0 +186,4 @@ main tbody tr:hover {
+  text-decoration: none;
+}
+
+.post-list a:hover .post-title {
@@ -186,0 +191 @@ main tbody tr:hover {
+  text-decoration-thickness: 2px;
2026-01-17 08f6f69 Better post titles + show date at index post listing
diff --git a/nix/site/logic.nix b/nix/site/logic.nix
index 735f8a5..3c75d0a 100644
--- a/nix/site/logic.nix
+++ b/nix/site/logic.nix
@@ -24,3 +24,10 @@ let
-  # Parse YAML frontmatter to extract date
-  # Expects format: ---\ndate: YYYY-MM-DD\n---
-  parseFrontmatter = content:
+  dropWhile = pred: list:
+    if list == [] then []
+    else if pred (builtins.head list) then dropWhile pred (builtins.tail list)
+    else list;
+
+  # Parse YAML frontmatter (title/date) and derive a markdown body
+  # - If frontmatter is present, it's stripped from the body.
+  # - If the first non-empty body line is a Markdown H1, it's treated as the title
+  #   (only when no frontmatter title is present) and stripped from the body.
+  parsePost = content:
@@ -29 +36,2 @@ let
-      hasFrontmatter = (builtins.head lines) == "---";
+      hasFrontmatter = lines != [] && (builtins.head lines) == "---";
+      tailLines = if lines != [] then builtins.tail lines else [];
@@ -31 +39 @@ let
-        then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
+        then lib.lists.findFirstIndex (l: l == "---") null tailLines
@@ -34 +42 @@ let
-        then lib.take frontmatterEndIdx (builtins.tail lines)
+        then lib.take frontmatterEndIdx tailLines
@@ -36,3 +44,19 @@ let
-      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
-      date = if dateLine != null
-        then lib.trim (lib.removePrefix "date:" dateLine)
+      bodyLines0 = if hasFrontmatter && frontmatterEndIdx != null
+        then lib.drop (frontmatterEndIdx + 1) tailLines
+        else lines;
+
+      trimLine = l: lib.trim l;
+      stripOuterQuotes = s:
+        let
+          len = builtins.stringLength s;
+          first = if len > 0 then builtins.substring 0 1 s else "";
+          last = if len > 0 then builtins.substring (len - 1) 1 s else "";
+        in if len >= 2 && ((first == "\"" && last == "\"") || (first == "'" && last == "'"))
+          then builtins.substring 1 (len - 2) s
+          else s;
+      isBlank = l: (trimLine l) == "";
+      bodyLines1 = dropWhile isBlank bodyLines0;
+
+      titleLine = lib.findFirst (l: lib.hasPrefix "title:" l) null frontmatterLines;
+      frontmatterTitle = if titleLine != null
+        then stripOuterQuotes (trimLine (lib.removePrefix "title:" titleLine))
@@ -40 +64,15 @@ let
-    in { inherit date; };
+
+      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
+      date = if dateLine != null then trimLine (lib.removePrefix "date:" dateLine) else null;
+
+      hasTopLevelH1 = bodyLines1 != [] && lib.hasPrefix "# " (builtins.head bodyLines1);
+      h1Title = if hasTopLevelH1 then trimLine (lib.removePrefix "# " (builtins.head bodyLines1)) else null;
+
+      title = if frontmatterTitle != null then frontmatterTitle else h1Title;
+
+      bodyLines2 =
+        if hasTopLevelH1
+        then dropWhile isBlank (builtins.tail bodyLines1)
+        else bodyLines1;
+      bodyMarkdown = lib.concatStringsSep "\n" bodyLines2;
+    in { inherit title date bodyMarkdown; };
@@ -71,2 +109 @@ let
-      frontmatter = parseFrontmatter content;
-    in {
+      parsed = parsePost content;
@@ -74,3 +111,6 @@ let
-      title = filenameToTitle filename;
-      date = frontmatter.date;
-      body = mdToHtml (postsDir + "/${filename}");
+      mdBodyPath = pkgs.writeText "post-${slug}.md" parsed.bodyMarkdown;
+    in {
+      inherit slug;
+      title = if parsed.title != null then parsed.title else filenameToTitle filename;
+      date = parsed.date;
+      body = mdToHtml mdBodyPath;
@@ -81 +121,4 @@ let
-  sortedPosts = lib.sort (a: b: a.date > b.date) posts;
+  sortedPosts =
+    let
+      dateKey = p: if p.date == null then "0000-00-00" else p.date;
+    in lib.sort (a: b: dateKey a > dateKey b) posts;
@@ -88 +130,0 @@ in {
-
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index b061cf1..c191536 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -22 +22,9 @@ let
-    (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) posts)
+    (map (p: [ "li"
+      [ "a" { href = "/${p.slug}/"; }
+        (lib.optionals (p.date != null) [
+          [ "span" { class = "post-date"; } p.date ]
+          [ "br" ]
+        ])
+        [ "span" { class = "post-title"; } p.title ]
+      ]
+    ]) posts)
@@ -86,0 +95 @@ in {
+      [ "h1" post.title ]
@@ -93 +101,0 @@ in {
-
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index 95e3b7b..7914998 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -1,0 +2 @@
+title: Cursor's latest "browser experiment" implied success without evidence
@@ -5,2 +5,0 @@ date: 2026-01-16
-# Cursor's latest "browser experiment" implied success without evidence
-
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
index 8a2509e..d023363 100644
--- a/posts/introducing-niccup.md
+++ b/posts/introducing-niccup.md
@@ -1,0 +2 @@
+title: Niccup: Hiccup-like HTML Generation in ~120 Lines of Pure Nix
@@ -5,2 +5,0 @@ date: 2025-12-03
-# Niccup: Hiccup-like HTML Generation in ~120 Lines of Pure Nix
-
diff --git a/style.css b/style.css
index b298b51..47e50cf 100644
--- a/style.css
+++ b/style.css
@@ -167,0 +168,4 @@ main tbody tr:hover {
+  margin-left: 0;
+  margin-right: 0;
+  padding-left: 0;
+  padding-right: 0;
@@ -185,0 +190,5 @@ main tbody tr:hover {
+.post-date {
+  color: #777;
+  font-size: 0.9375rem;
+}
+
2026-01-17 77b9b50 Further refactoring
diff --git a/nix/site.nix b/nix/site.nix
index 671b045..9ad3a77 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -4,2 +4,2 @@ let
-  lib = pkgs.lib;
-  h = niccup.lib;
+  site = import ./site/logic.nix { inherit pkgs; };
+  ui = import ./site/presentation.nix { lib = pkgs.lib; h = niccup.lib; };
@@ -7,154 +7,3 @@ let
-  postsDir = ../posts;
-  repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
-  gitDir =
-    if builtins.pathExists ../.git then ../.git
-    else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
-      then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
-      else null;
-
-  # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
-  # Pandoc automatically skips YAML frontmatter
-  mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
-    ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
-  '');
-
-  versions = import ./versions.nix { inherit pkgs lib gitDir mdToHtml; };
-  inherit (versions) postVersionsHtml repoVersions;
-
-  # Parse YAML frontmatter to extract date
-  # Expects format: ---\ndate: YYYY-MM-DD\n---
-  parseFrontmatter = content:
-    let
-      lines = lib.splitString "\n" content;
-      hasFrontmatter = (builtins.head lines) == "---";
-      frontmatterEndIdx = if hasFrontmatter
-        then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
-        else null;
-      frontmatterLines = if frontmatterEndIdx != null
-        then lib.take frontmatterEndIdx (builtins.tail lines)
-        else [];
-      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
-      date = if dateLine != null
-        then lib.trim (lib.removePrefix "date:" dateLine)
-        else null;
-    in { inherit date; };
-
-  # Generate syntax highlighting CSS from pandoc
-  highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
-    echo '```c
-    x
-    ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
-      | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
-      | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
-  '';
-
-  # Read all .md files from posts directory
-  postFiles = lib.filterAttrs (name: type:
-    type == "regular" && lib.hasSuffix ".md" name
-  ) (builtins.readDir postsDir);
-
-  # Convert filename to title: "hello-world.md" -> "Hello World"
-  filenameToTitle = filename:
-    let
-      slug = lib.removeSuffix ".md" filename;
-      words = lib.splitString "-" slug;
-      capitalize = s:
-        let chars = lib.stringToCharacters s;
-        in if chars == [] then ""
-           else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
-    in lib.concatStringsSep " " (map capitalize words);
-
-  # Build post objects from files
-  posts = lib.mapAttrsToList (filename: _:
-    let
-      content = builtins.readFile (postsDir + "/${filename}");
-      frontmatter = parseFrontmatter content;
-    in {
-      slug = lib.removeSuffix ".md" filename;
-      title = filenameToTitle filename;
-      date = frontmatter.date;
-      body = mdToHtml (postsDir + "/${filename}");
-      versions = postVersionsHtml filename;
-    }) postFiles;
-
-  # Sort posts by date, newest first
-  sortedPosts = lib.sort (a: b: a.date > b.date) posts;
-
-  navLink = { href, label, key, active }: [
-    "a"
-    (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
-    label
-  ];
-
-  header = navActive: [ "header"
-    [ "a" { href = "/"; } "embedding-shapes" ]
-    [ "nav"
-      (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
-      (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
-      (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
-    ]
-  ];
-
-  footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
-
-  postList = [ "ul" { class = "post-list"; }
-    (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) sortedPosts)
-  ];
-
-  renderPage = { title, content, path ? null }:
-    let
-      navActive =
-        if path == "/" then "home"
-        else if path == "/posts/" then "posts"
-        else if path == "/about/" then "about"
-        else null;
-    in h.renderPretty [
-    "html" { lang = "en"; }
-    [ "head"
-      [ "meta" { charset = "utf-8"; } ]
-      [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
-      [ "title" title ]
-      [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
-      [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
-      [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
-    ]
-    [ "body"
-      (header navActive)
-      [ "main" content ]
-      footer
-    ]
-  ];
-
-  indexHtml = pkgs.writeText "index.html" (renderPage {
-    title = "embedding-shapes";
-    path = "/";
-    content = [
-      [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
-      [ "h2" "Recent Posts" ]
-      postList
-    ];
-  });
-
-  postsHtml = pkgs.writeText "posts.html" (renderPage {
-    title = "Posts";
-    path = "/posts/";
-    content = [
-      [ "h1" "Posts" ]
-      postList
-    ];
-  });
-
-  aboutHtml = pkgs.writeText "about.html" (renderPage {
-    title = "About";
-    path = "/about/";
-    content = [
-      [ "h1" "About" ]
-      [ "ul"
-        [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
-        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
-        [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
-        [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
-      ]
-      (lib.optional (repoVersions != "") (h.raw repoVersions))
-    ];
-  });
+  indexHtml = pkgs.writeText "index.html" (ui.renderIndexPage { posts = site.posts; });
+  postsHtml = pkgs.writeText "posts.html" (ui.renderPostsIndexPage { posts = site.posts; });
+  aboutHtml = pkgs.writeText "about.html" (ui.renderAboutPage { repoVersions = site.repoVersions; });
@@ -166 +15 @@ in {
-    cp ${highlightCss} $out/highlight.css
+    cp ${site.highlightCss} $out/highlight.css
@@ -175,9 +24,2 @@ in {
-      "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (renderPage {
-        inherit (post) title;
-        content = [
-          (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
-          (h.raw post.body)
-          (lib.optional (post.versions != "") (h.raw post.versions))
-        ];
-      })} $out/${post.slug}/index.html"
-    ) sortedPosts)}
+      "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (ui.renderPostPage { inherit post; })} $out/${post.slug}/index.html"
+    ) site.posts)}
diff --git a/nix/site/logic.nix b/nix/site/logic.nix
new file mode 100644
index 0000000..735f8a5
--- /dev/null
+++ b/nix/site/logic.nix
@@ -0,0 +1,88 @@
+{ pkgs }:
+
+let
+  lib = pkgs.lib;
+
+  projectRoot = ../..;
+  postsDir = projectRoot + "/posts";
+
+  repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
+  gitDir =
+    if builtins.pathExists (projectRoot + "/.git") then (projectRoot + "/.git")
+    else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
+      then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
+      else null;
+
+  # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
+  # Pandoc automatically skips YAML frontmatter
+  mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
+    ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
+  '');
+
+  versions = import ../versions.nix { inherit pkgs lib gitDir mdToHtml; };
+
+  # Parse YAML frontmatter to extract date
+  # Expects format: ---\ndate: YYYY-MM-DD\n---
+  parseFrontmatter = content:
+    let
+      lines = lib.splitString "\n" content;
+      hasFrontmatter = (builtins.head lines) == "---";
+      frontmatterEndIdx = if hasFrontmatter
+        then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
+        else null;
+      frontmatterLines = if frontmatterEndIdx != null
+        then lib.take frontmatterEndIdx (builtins.tail lines)
+        else [];
+      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
+      date = if dateLine != null
+        then lib.trim (lib.removePrefix "date:" dateLine)
+        else null;
+    in { inherit date; };
+
+  # Generate syntax highlighting CSS from pandoc
+  highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
+    echo '```c
+    x
+    ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
+      | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
+      | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
+  '';
+
+  # Read all .md files from posts directory
+  postFiles = lib.filterAttrs (name: type:
+    type == "regular" && lib.hasSuffix ".md" name
+  ) (builtins.readDir postsDir);
+
+  # Convert filename to title: "hello-world.md" -> "Hello World"
+  filenameToTitle = filename:
+    let
+      slug = lib.removeSuffix ".md" filename;
+      words = lib.splitString "-" slug;
+      capitalize = s:
+        let chars = lib.stringToCharacters s;
+        in if chars == [] then ""
+           else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
+    in lib.concatStringsSep " " (map capitalize words);
+
+  # Build post objects from files
+  posts = lib.mapAttrsToList (filename: _:
+    let
+      content = builtins.readFile (postsDir + "/${filename}");
+      frontmatter = parseFrontmatter content;
+    in {
+      slug = lib.removeSuffix ".md" filename;
+      title = filenameToTitle filename;
+      date = frontmatter.date;
+      body = mdToHtml (postsDir + "/${filename}");
+      versions = versions.postVersionsHtml filename;
+    }) postFiles;
+
+  # Sort posts by date, newest first
+  sortedPosts = lib.sort (a: b: a.date > b.date) posts;
+
+in {
+  posts = sortedPosts;
+  inherit highlightCss;
+  inherit (versions) repoVersions;
+}
+
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
new file mode 100644
index 0000000..b061cf1
--- /dev/null
+++ b/nix/site/presentation.nix
@@ -0,0 +1,93 @@
+{ lib, h }:
+
+let
+  navLink = { href, label, key, active }: [
+    "a"
+    (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
+    label
+  ];
+
+  header = navActive: [ "header"
+    [ "a" { href = "/"; } "embedding-shapes" ]
+    [ "nav"
+      (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
+      (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
+      (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
+    ]
+  ];
+
+  footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
+
+  postList = posts: [ "ul" { class = "post-list"; }
+    (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) posts)
+  ];
+
+  renderPage = { title, content, path ? null }:
+    let
+      navActive =
+        if path == "/" then "home"
+        else if path == "/posts/" then "posts"
+        else if path == "/about/" then "about"
+        else null;
+    in h.renderPretty [
+    "html" { lang = "en"; }
+    [ "head"
+      [ "meta" { charset = "utf-8"; } ]
+      [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
+      [ "title" title ]
+      [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
+      [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
+      [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
+    ]
+    [ "body"
+      (header navActive)
+      [ "main" content ]
+      footer
+    ]
+  ];
+
+in {
+  renderIndexPage = { posts }: renderPage {
+    title = "embedding-shapes";
+    path = "/";
+    content = [
+      [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
+      [ "h2" "Recent Posts" ]
+      (postList posts)
+    ];
+  };
+
+  renderPostsIndexPage = { posts }: renderPage {
+    title = "Posts";
+    path = "/posts/";
+    content = [
+      [ "h1" "Posts" ]
+      (postList posts)
+    ];
+  };
+
+  renderAboutPage = { repoVersions }: renderPage {
+    title = "About";
+    path = "/about/";
+    content = [
+      [ "h1" "About" ]
+      [ "ul"
+        [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
+        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
+        [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
+        [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
+      ]
+      (lib.optional (repoVersions != "") (h.raw repoVersions))
+    ];
+  };
+
+  renderPostPage = { post }: renderPage {
+    title = post.title;
+    content = [
+      (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
+      (h.raw post.body)
+      (lib.optional (post.versions != "") (h.raw post.versions))
+    ];
+  };
+}
+
2026-01-17 510fab1 Split things up a bit, slightly cleaner
diff --git a/flake.nix b/flake.nix
index 92467ed..22348d9 100644
--- a/flake.nix
+++ b/flake.nix
@@ -17,273 +17,2 @@
-          lib = pkgs.lib;
-          h = niccup.lib;
-
-          postsDir = ./posts;
-          repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
-          gitDir =
-            if builtins.pathExists ./.git then ./.git
-            else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
-              then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
-              else null;
-
-          # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
-          # Pandoc automatically skips YAML frontmatter
-          mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
-            ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
-          '');
-
-          versionsMd = { name, summary ? "Versions", file ? null, follow ? false }:
-            pkgs.runCommandLocal name {
-              nativeBuildInputs = [ pkgs.git pkgs.gnused pkgs.gnugrep ];
-            } ''
-              set -euo pipefail
-              export GIT_DIR=${gitDir}
-              export GIT_OPTIONAL_LOCKS=0
-
-              summary=${lib.escapeShellArg summary}
-              file=${lib.escapeShellArg (if file == null then "" else file)}
-              log="$TMPDIR/log.tsv"
-
-              if [ -n "$file" ]; then
-                ${pkgs.git}/bin/git log ${lib.optionalString follow "--follow"} --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
-              else
-                ${pkgs.git}/bin/git log --date=short --format='%H%x09%ad%x09%s' > "$log" 2>/dev/null || true
-              fi
-
-              if [ ! -s "$log" ]; then
-                : > "$out"
-                exit 0
-              fi
-
-              {
-                echo '<details class="versions">'
-                echo "<summary>$summary</summary>"
-                echo
-
-                while IFS="$(printf '\t')" read -r hash date subject; do
-                  short="$(printf '%.7s' "$hash")"
-                  esc_subject="$(printf '%s' "$subject" | ${pkgs.gnused}/bin/sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g')"
-
-                  echo '<details class="version">'
-                  echo "<summary>$date <code>$short</code> $esc_subject</summary>"
-                  echo
-
-                  echo '````````diff'
-                  diff_full="$TMPDIR/diff.full"
-                  diff_body="$TMPDIR/diff.body"
-                  diff_word="$TMPDIR/diff.word"
-
-                  if [ -z "$file" ]; then
-                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" 2>/dev/null > "$diff_body" || true
-                  else
-                    status="$(${pkgs.git}/bin/git show --no-color --format= --name-status -1 "$hash" -- "$file" 2>/dev/null | ${pkgs.gnused}/bin/sed -n '1s/\t.*$//p')"
-
-                    case "$status" in
-                      A*|D*)
-                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
-                        ;;
-                      *)
-                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 --word-diff=porcelain "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
-                        ;;
-                    esac
-
-                    ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
-
-                    if ! printf '%s' "$status" | ${pkgs.gnugrep}/bin/grep -qE '^(A|D)'; then
-                      ${pkgs.gnused}/bin/sed '/^~$/d' "$diff_body" > "$diff_word"
-                      if ${pkgs.gnugrep}/bin/grep -qE '^[+-]' "$diff_word"; then
-                        cat "$diff_word" > "$diff_body"
-                      else
-                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
-                        ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
-                      fi
-                    fi
-                  fi
-
-                  cat "$diff_body"
-                  echo '````````'
-                  echo
-                  echo '</details>'
-                  echo
-                done < "$log"
-
-                echo '</details>'
-              } > "$out"
-            '';
-
-          versionsHtml = args:
-            if gitDir == null then ""
-            else mdToHtml (versionsMd args);
-
-          postVersionsHtml = filename: versionsHtml {
-            name = "post-versions-${lib.removeSuffix ".md" filename}.md";
-            file = "posts/${filename}";
-            follow = true;
-          };
-
-          repoVersions = versionsHtml {
-            name = "repo-versions.md";
-            summary = "Repository Versions";
-          };
-
-          # Parse YAML frontmatter to extract date
-          # Expects format: ---\ndate: YYYY-MM-DD\n---
-          parseFrontmatter = content:
-            let
-              lines = lib.splitString "\n" content;
-              hasFrontmatter = (builtins.head lines) == "---";
-              frontmatterEndIdx = if hasFrontmatter
-                then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
-                else null;
-              frontmatterLines = if frontmatterEndIdx != null
-                then lib.take frontmatterEndIdx (builtins.tail lines)
-                else [];
-              dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
-              date = if dateLine != null
-                then lib.trim (lib.removePrefix "date:" dateLine)
-                else null;
-            in { inherit date; };
-
-          # Generate syntax highlighting CSS from pandoc
-          highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
-            echo '```c
-            x
-            ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
-              | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
-              | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
-          '';
-
-          # Read all .md files from posts directory
-          postFiles = lib.filterAttrs (name: type:
-            type == "regular" && lib.hasSuffix ".md" name
-          ) (builtins.readDir postsDir);
-
-          # Convert filename to title: "hello-world.md" -> "Hello World"
-          filenameToTitle = filename:
-            let
-              slug = lib.removeSuffix ".md" filename;
-              words = lib.splitString "-" slug;
-              capitalize = s:
-                let chars = lib.stringToCharacters s;
-                in if chars == [] then ""
-                   else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
-            in lib.concatStringsSep " " (map capitalize words);
-
-          # Build post objects from files
-          posts = lib.mapAttrsToList (filename: _:
-            let
-              content = builtins.readFile (postsDir + "/${filename}");
-              frontmatter = parseFrontmatter content;
-            in {
-              slug = lib.removeSuffix ".md" filename;
-              title = filenameToTitle filename;
-              date = frontmatter.date;
-              body = mdToHtml (postsDir + "/${filename}");
-              versions = postVersionsHtml filename;
-            }) postFiles;
-
-          # Sort posts by date, newest first
-          sortedPosts = lib.sort (a: b: a.date > b.date) posts;
-
-          navLink = { href, label, key, active }: [
-            "a"
-            (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
-            label
-          ];
-
-          header = navActive: [ "header"
-            [ "a" { href = "/"; } "embedding-shapes" ]
-            [ "nav"
-              (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
-              (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
-              (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
-            ]
-          ];
-
-          footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
-
-          postList = [ "ul" { class = "post-list"; }
-            (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) sortedPosts)
-          ];
-
-          renderPage = { title, content, path ? null }:
-            let
-              navActive =
-                if path == "/" then "home"
-                else if path == "/posts/" then "posts"
-                else if path == "/about/" then "about"
-                else null;
-            in h.renderPretty [
-            "html" { lang = "en"; }
-            [ "head"
-              [ "meta" { charset = "utf-8"; } ]
-              [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
-              [ "title" title ]
-              [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
-              [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
-              [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
-            ]
-            [ "body"
-              (header navActive)
-              [ "main" content ]
-              footer
-            ]
-          ];
-
-          indexHtml = pkgs.writeText "index.html" (renderPage {
-            title = "embedding-shapes";
-            path = "/";
-            content = [
-              [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
-              [ "h2" "Recent Posts" ]
-              postList
-            ];
-          });
-
-          postsHtml = pkgs.writeText "posts.html" (renderPage {
-            title = "Posts";
-            path = "/posts/";
-            content = [
-              [ "h1" "Posts" ]
-              postList
-            ];
-          });
-
-          aboutHtml = pkgs.writeText "about.html" (renderPage {
-            title = "About";
-            path = "/about/";
-            content = [
-              [ "h1" "About" ]
-              [ "ul"
-                [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
-                [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
-                [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
-                [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
-              ]
-              (lib.optional (repoVersions != "") (h.raw repoVersions))
-            ];
-          });
-
-        in {
-          default = pkgs.runCommand "blog" {} ''
-            mkdir -p $out
-            cp ${./style.css} $out/style.css
-            cp ${highlightCss} $out/highlight.css
-            cp ${./favicon.svg} $out/favicon.svg
-            cp -r ${./content} $out/content
-            cp ${indexHtml} $out/index.html
-            mkdir -p $out/posts
-            cp ${postsHtml} $out/posts/index.html
-            mkdir -p $out/about
-            cp ${aboutHtml} $out/about/index.html
-            ${builtins.concatStringsSep "\n" (map (post:
-              "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (renderPage {
-                inherit (post) title;
-                content = [
-                  (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
-                  (h.raw post.body)
-                  (lib.optional (post.versions != "") (h.raw post.versions))
-                ];
-              })} $out/${post.slug}/index.html"
-            ) sortedPosts)}
-          '';
-        });
+        in import ./nix/site.nix { inherit pkgs niccup; }
+      );
@@ -298,0 +28 @@
+
diff --git a/nix/serve.nix b/nix/serve.nix
index d63dcb4..b64e4a3 100644
--- a/nix/serve.nix
+++ b/nix/serve.nix
@@ -15 +15 @@ let
-      echo "Watching: posts/, style.css, flake.nix"
+      echo "Watching: posts/, style.css, flake.nix, nix/"
@@ -24 +24 @@ let
-      watchexec --watch posts --watch style.css --watch flake.nix -- env BLOG_REPO_ROOT="$REPO_ROOT" nix build --impure
+      watchexec --watch posts --watch style.css --watch flake.nix --watch nix -- env BLOG_REPO_ROOT="$REPO_ROOT" nix build --impure
diff --git a/nix/site.nix b/nix/site.nix
new file mode 100644
index 0000000..671b045
--- /dev/null
+++ b/nix/site.nix
@@ -0,0 +1,185 @@
+{ pkgs, niccup }:
+
+let
+  lib = pkgs.lib;
+  h = niccup.lib;
+
+  postsDir = ../posts;
+  repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
+  gitDir =
+    if builtins.pathExists ../.git then ../.git
+    else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
+      then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
+      else null;
+
+  # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
+  # Pandoc automatically skips YAML frontmatter
+  mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
+    ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
+  '');
+
+  versions = import ./versions.nix { inherit pkgs lib gitDir mdToHtml; };
+  inherit (versions) postVersionsHtml repoVersions;
+
+  # Parse YAML frontmatter to extract date
+  # Expects format: ---\ndate: YYYY-MM-DD\n---
+  parseFrontmatter = content:
+    let
+      lines = lib.splitString "\n" content;
+      hasFrontmatter = (builtins.head lines) == "---";
+      frontmatterEndIdx = if hasFrontmatter
+        then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
+        else null;
+      frontmatterLines = if frontmatterEndIdx != null
+        then lib.take frontmatterEndIdx (builtins.tail lines)
+        else [];
+      dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
+      date = if dateLine != null
+        then lib.trim (lib.removePrefix "date:" dateLine)
+        else null;
+    in { inherit date; };
+
+  # Generate syntax highlighting CSS from pandoc
+  highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
+    echo '```c
+    x
+    ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
+      | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
+      | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
+  '';
+
+  # Read all .md files from posts directory
+  postFiles = lib.filterAttrs (name: type:
+    type == "regular" && lib.hasSuffix ".md" name
+  ) (builtins.readDir postsDir);
+
+  # Convert filename to title: "hello-world.md" -> "Hello World"
+  filenameToTitle = filename:
+    let
+      slug = lib.removeSuffix ".md" filename;
+      words = lib.splitString "-" slug;
+      capitalize = s:
+        let chars = lib.stringToCharacters s;
+        in if chars == [] then ""
+           else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
+    in lib.concatStringsSep " " (map capitalize words);
+
+  # Build post objects from files
+  posts = lib.mapAttrsToList (filename: _:
+    let
+      content = builtins.readFile (postsDir + "/${filename}");
+      frontmatter = parseFrontmatter content;
+    in {
+      slug = lib.removeSuffix ".md" filename;
+      title = filenameToTitle filename;
+      date = frontmatter.date;
+      body = mdToHtml (postsDir + "/${filename}");
+      versions = postVersionsHtml filename;
+    }) postFiles;
+
+  # Sort posts by date, newest first
+  sortedPosts = lib.sort (a: b: a.date > b.date) posts;
+
+  navLink = { href, label, key, active }: [
+    "a"
+    (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
+    label
+  ];
+
+  header = navActive: [ "header"
+    [ "a" { href = "/"; } "embedding-shapes" ]
+    [ "nav"
+      (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
+      (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
+      (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
+    ]
+  ];
+
+  footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
+
+  postList = [ "ul" { class = "post-list"; }
+    (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) sortedPosts)
+  ];
+
+  renderPage = { title, content, path ? null }:
+    let
+      navActive =
+        if path == "/" then "home"
+        else if path == "/posts/" then "posts"
+        else if path == "/about/" then "about"
+        else null;
+    in h.renderPretty [
+    "html" { lang = "en"; }
+    [ "head"
+      [ "meta" { charset = "utf-8"; } ]
+      [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
+      [ "title" title ]
+      [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
+      [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
+      [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
+    ]
+    [ "body"
+      (header navActive)
+      [ "main" content ]
+      footer
+    ]
+  ];
+
+  indexHtml = pkgs.writeText "index.html" (renderPage {
+    title = "embedding-shapes";
+    path = "/";
+    content = [
+      [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
+      [ "h2" "Recent Posts" ]
+      postList
+    ];
+  });
+
+  postsHtml = pkgs.writeText "posts.html" (renderPage {
+    title = "Posts";
+    path = "/posts/";
+    content = [
+      [ "h1" "Posts" ]
+      postList
+    ];
+  });
+
+  aboutHtml = pkgs.writeText "about.html" (renderPage {
+    title = "About";
+    path = "/about/";
+    content = [
+      [ "h1" "About" ]
+      [ "ul"
+        [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
+        [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
+        [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
+        [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
+      ]
+      (lib.optional (repoVersions != "") (h.raw repoVersions))
+    ];
+  });
+
+in {
+  default = pkgs.runCommand "blog" {} ''
+    mkdir -p $out
+    cp ${../style.css} $out/style.css
+    cp ${highlightCss} $out/highlight.css
+    cp ${../favicon.svg} $out/favicon.svg
+    cp -r ${../content} $out/content
+    cp ${indexHtml} $out/index.html
+    mkdir -p $out/posts
+    cp ${postsHtml} $out/posts/index.html
+    mkdir -p $out/about
+    cp ${aboutHtml} $out/about/index.html
+    ${builtins.concatStringsSep "\n" (map (post:
+      "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (renderPage {
+        inherit (post) title;
+        content = [
+          (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
+          (h.raw post.body)
+          (lib.optional (post.versions != "") (h.raw post.versions))
+        ];
+      })} $out/${post.slug}/index.html"
+    ) sortedPosts)}
+  '';
+}
diff --git a/nix/versions.nix b/nix/versions.nix
new file mode 100644
index 0000000..093d7fe
--- /dev/null
+++ b/nix/versions.nix
@@ -0,0 +1,99 @@
+{ pkgs, lib, gitDir, mdToHtml }:
+
+let
+  versionsMd = { name, summary ? "Versions", file ? null, follow ? false }:
+    pkgs.runCommandLocal name {
+      nativeBuildInputs = [ pkgs.git pkgs.gnused pkgs.gnugrep ];
+    } ''
+      set -euo pipefail
+      export GIT_DIR=${gitDir}
+      export GIT_OPTIONAL_LOCKS=0
+
+      summary=${lib.escapeShellArg summary}
+      file=${lib.escapeShellArg (if file == null then "" else file)}
+      log="$TMPDIR/log.tsv"
+
+      if [ -n "$file" ]; then
+        ${pkgs.git}/bin/git log ${lib.optionalString follow "--follow"} --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
+      else
+        ${pkgs.git}/bin/git log --date=short --format='%H%x09%ad%x09%s' > "$log" 2>/dev/null || true
+      fi
+
+      if [ ! -s "$log" ]; then
+        : > "$out"
+        exit 0
+      fi
+
+      {
+        echo '<details class="versions">'
+        echo "<summary>$summary</summary>"
+        echo
+
+        while IFS="$(printf '\t')" read -r hash date subject; do
+          short="$(printf '%.7s' "$hash")"
+          esc_subject="$(printf '%s' "$subject" | ${pkgs.gnused}/bin/sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g')"
+
+          echo '<details class="version">'
+          echo "<summary>$date <code>$short</code> $esc_subject</summary>"
+          echo
+
+          echo '````````diff'
+          diff_full="$TMPDIR/diff.full"
+          diff_body="$TMPDIR/diff.body"
+          diff_word="$TMPDIR/diff.word"
+
+          if [ -z "$file" ]; then
+            ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" 2>/dev/null > "$diff_body" || true
+          else
+            status="$(${pkgs.git}/bin/git show --no-color --format= --name-status -1 "$hash" -- "$file" 2>/dev/null | ${pkgs.gnused}/bin/sed -n '1s/\t.*$//p')"
+
+            case "$status" in
+              A*|D*)
+                ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                ;;
+              *)
+                ${pkgs.git}/bin/git show --no-color --format= --unified=0 --word-diff=porcelain "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                ;;
+            esac
+
+            ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
+
+            if ! printf '%s' "$status" | ${pkgs.gnugrep}/bin/grep -qE '^(A|D)'; then
+              ${pkgs.gnused}/bin/sed '/^~$/d' "$diff_body" > "$diff_word"
+              if ${pkgs.gnugrep}/bin/grep -qE '^[+-]' "$diff_word"; then
+                cat "$diff_word" > "$diff_body"
+              else
+                ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
+              fi
+            fi
+          fi
+
+          cat "$diff_body"
+          echo '````````'
+          echo
+          echo '</details>'
+          echo
+        done < "$log"
+
+        echo '</details>'
+      } > "$out"
+    '';
+
+  versionsHtml = args:
+    if gitDir == null then ""
+    else mdToHtml (versionsMd args);
+
+in {
+  postVersionsHtml = filename: versionsHtml {
+    name = "post-versions-${lib.removeSuffix ".md" filename}.md";
+    file = "posts/${filename}";
+    follow = true;
+  };
+
+  repoVersions = versionsHtml {
+    name = "repo-versions.md";
+    summary = "Repository Versions";
+  };
+}
+
2026-01-17 b422b94 Share full changelog of website on about page
diff --git a/flake.nix b/flake.nix
index e39c5f3..92467ed 100644
--- a/flake.nix
+++ b/flake.nix
@@ -34,28 +34,26 @@
-          postVersionsMd = filename: pkgs.runCommandLocal "post-versions-${lib.removeSuffix ".md" filename}.md" {
-            nativeBuildInputs = [ pkgs.git pkgs.gnused pkgs.gnugrep ];
-          } ''
-            set -euo pipefail
-            export GIT_DIR=${gitDir}
-            export GIT_OPTIONAL_LOCKS=0
-
-            file="posts/${filename}"
-            log="$TMPDIR/log.tsv"
-
-            ${pkgs.git}/bin/git log --follow --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
-
-            if [ ! -s "$log" ]; then
-              : > "$out"
-              exit 0
-            fi
-
-            {
-              echo '<details class="versions">'
-              echo '<summary>Versions</summary>'
-              echo
-
-              while IFS="$(printf '\t')" read -r hash date subject; do
-                short="$(printf '%.7s' "$hash")"
-                esc_subject="$(printf '%s' "$subject" | ${pkgs.gnused}/bin/sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g')"
-
-                echo '<details class="version">'
-                echo "<summary>$date <code>$short</code> $esc_subject</summary>"
+          versionsMd = { name, summary ? "Versions", file ? null, follow ? false }:
+            pkgs.runCommandLocal name {
+              nativeBuildInputs = [ pkgs.git pkgs.gnused pkgs.gnugrep ];
+            } ''
+              set -euo pipefail
+              export GIT_DIR=${gitDir}
+              export GIT_OPTIONAL_LOCKS=0
+
+              summary=${lib.escapeShellArg summary}
+              file=${lib.escapeShellArg (if file == null then "" else file)}
+              log="$TMPDIR/log.tsv"
+
+              if [ -n "$file" ]; then
+                ${pkgs.git}/bin/git log ${lib.optionalString follow "--follow"} --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
+              else
+                ${pkgs.git}/bin/git log --date=short --format='%H%x09%ad%x09%s' > "$log" 2>/dev/null || true
+              fi
+
+              if [ ! -s "$log" ]; then
+                : > "$out"
+                exit 0
+              fi
+
+              {
+                echo '<details class="versions">'
+                echo "<summary>$summary</summary>"
@@ -64,4 +62,3 @@
-                echo '````````diff'
-                diff_full="$TMPDIR/diff.full"
-                diff_body="$TMPDIR/diff.body"
-                diff_word="$TMPDIR/diff.word"
+                while IFS="$(printf '\t')" read -r hash date subject; do
+                  short="$(printf '%.7s' "$hash")"
+                  esc_subject="$(printf '%s' "$subject" | ${pkgs.gnused}/bin/sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g')"
@@ -69 +66,3 @@
-                status="$(${pkgs.git}/bin/git show --no-color --format= --name-status -1 "$hash" -- "$file" 2>/dev/null | ${pkgs.gnused}/bin/sed -n '1s/\t.*$//p')"
+                  echo '<details class="version">'
+                  echo "<summary>$date <code>$short</code> $esc_subject</summary>"
+                  echo
@@ -71,8 +70,4 @@
-                case "$status" in
-                  A*|D*)
-                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
-                    ;;
-                  *)
-                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 --word-diff=porcelain "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
-                    ;;
-                esac
+                  echo '````````diff'
+                  diff_full="$TMPDIR/diff.full"
+                  diff_body="$TMPDIR/diff.body"
+                  diff_word="$TMPDIR/diff.word"
@@ -80,6 +75,2 @@
-                ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
-
-                if ! printf '%s' "$status" | ${pkgs.gnugrep}/bin/grep -qE '^(A|D)'; then
-                  ${pkgs.gnused}/bin/sed '/^~$/d' "$diff_body" > "$diff_word"
-                  if ${pkgs.gnugrep}/bin/grep -qE '^[+-]' "$diff_word"; then
-                    cat "$diff_word" > "$diff_body"
+                  if [ -z "$file" ]; then
+                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" 2>/dev/null > "$diff_body" || true
@@ -87 +78,11 @@
-                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                    status="$(${pkgs.git}/bin/git show --no-color --format= --name-status -1 "$hash" -- "$file" 2>/dev/null | ${pkgs.gnused}/bin/sed -n '1s/\t.*$//p')"
+
+                    case "$status" in
+                      A*|D*)
+                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                        ;;
+                      *)
+                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 --word-diff=porcelain "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                        ;;
+                    esac
+
@@ -88,0 +90,10 @@
+
+                    if ! printf '%s' "$status" | ${pkgs.gnugrep}/bin/grep -qE '^(A|D)'; then
+                      ${pkgs.gnused}/bin/sed '/^~$/d' "$diff_body" > "$diff_word"
+                      if ${pkgs.gnugrep}/bin/grep -qE '^[+-]' "$diff_word"; then
+                        cat "$diff_word" > "$diff_body"
+                      else
+                        ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                        ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
+                      fi
+                    fi
@@ -90 +100,0 @@
-                fi
@@ -92,6 +102,6 @@
-                cat "$diff_body"
-                echo '````````'
-                echo
-                echo '</details>'
-                echo
-              done < "$log"
+                  cat "$diff_body"
+                  echo '````````'
+                  echo
+                  echo '</details>'
+                  echo
+                done < "$log"
@@ -99,3 +109,3 @@
-              echo '</details>'
-            } > "$out"
-          '';
+                echo '</details>'
+              } > "$out"
+            '';
@@ -103 +113 @@
-          postVersionsHtml = filename:
+          versionsHtml = args:
@@ -105 +115,12 @@
-            else mdToHtml (postVersionsMd filename);
+            else mdToHtml (versionsMd args);
+
+          postVersionsHtml = filename: versionsHtml {
+            name = "post-versions-${lib.removeSuffix ".md" filename}.md";
+            file = "posts/${filename}";
+            follow = true;
+          };
+
+          repoVersions = versionsHtml {
+            name = "repo-versions.md";
+            summary = "Repository Versions";
+          };
@@ -240,0 +262 @@
+              (lib.optional (repoVersions != "") (h.raw repoVersions))
2026-01-17 5bfac0e Add link to Mastodon too, because why not
diff --git a/flake.nix b/flake.nix
index e082ae1..e39c5f3 100644
--- a/flake.nix
+++ b/flake.nix
@@ -237,0 +238 @@
+                [ "li" "Mastodon: " [ "a" { href = "https://mastodon.social/@embedding_shapes"; } "@embedding_shapes@mastodon.social" ] ]
2026-01-17 0fc4f8a Cleaner way of doing active page
diff --git a/flake.nix b/flake.nix
index d2eed64..e082ae1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -187 +187,8 @@
-          renderPage = { title, content, navActive ? null }: h.renderPretty [
+          renderPage = { title, content, path ? null }:
+            let
+              navActive =
+                if path == "/" then "home"
+                else if path == "/posts/" then "posts"
+                else if path == "/about/" then "about"
+                else null;
+            in h.renderPretty [
@@ -206 +213 @@
-            navActive = "home";
+            path = "/";
@@ -216 +223 @@
-            navActive = "posts";
+            path = "/posts/";
@@ -225 +232 @@
-            navActive = "about";
+            path = "/about/";
2026-01-17 65b6eab Active page navigation
diff --git a/flake.nix b/flake.nix
index 8f9eb3c..d2eed64 100644
--- a/flake.nix
+++ b/flake.nix
@@ -166 +166,7 @@
-          header = [ "header"
+          navLink = { href, label, key, active }: [
+            "a"
+            (if key == active then { inherit href; "aria-current" = "page"; } else { inherit href; })
+            label
+          ];
+
+          header = navActive: [ "header"
@@ -169,3 +175,3 @@
-              [ "a" { href = "/"; } "Home" ]
-              [ "a" { href = "/posts/"; } "Posts" ]
-              [ "a" { href = "/about/"; } "About" ]
+              (navLink { href = "/"; label = "Home"; key = "home"; active = navActive; })
+              (navLink { href = "/posts/"; label = "Posts"; key = "posts"; active = navActive; })
+              (navLink { href = "/about/"; label = "About"; key = "about"; active = navActive; })
@@ -181 +187 @@
-          renderPage = { title, content }: h.renderPretty [
+          renderPage = { title, content, navActive ? null }: h.renderPretty [
@@ -192 +198 @@
-              header
+              (header navActive)
@@ -199,0 +206 @@
+            navActive = "home";
@@ -208,0 +216 @@
+            navActive = "posts";
@@ -216,0 +225 @@
+            navActive = "about";
diff --git a/style.css b/style.css
index ebd7e94..b298b51 100644
--- a/style.css
+++ b/style.css
@@ -0,0 +1,4 @@
+html {
+  overflow-y: scroll;
+}
+
@@ -46,0 +51,5 @@ header nav a:hover {
+header nav a[aria-current="page"] {
+  color: #e8e8e8;
+  text-shadow: 0.02em 0 0 currentColor, -0.02em 0 0 currentColor;
+}
+
2026-01-17 dab7257 Add about page
diff --git a/flake.nix b/flake.nix
index 6b17424..8f9eb3c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -170,0 +171 @@
+              [ "a" { href = "/about/"; } "About" ]
@@ -213,0 +215,12 @@
+          aboutHtml = pkgs.writeText "about.html" (renderPage {
+            title = "About";
+            content = [
+              [ "h1" "About" ]
+              [ "ul"
+                [ "li" "GitHub: " [ "a" { href = "https://github.com/embedding-shapes/"; } "embedding-shapes" ] ]
+                [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
+                [ "li" "Email: " [ "a" { href = "mailto:embedding-shapes@proton.me"; } "embedding-shapes@proton.me" ] ]
+              ]
+            ];
+          });
+
@@ -223,0 +237,2 @@
+            mkdir -p $out/about
+            cp ${aboutHtml} $out/about/index.html
2026-01-17 ee87967 Add visible versions/history of posts at the bottom
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 104ff21..b26656e 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -21,0 +22,2 @@ jobs:
+        with:
+          fetch-depth: 0
@@ -24 +26 @@ jobs:
-        run: nix build .#default
+        run: BLOG_REPO_ROOT="$GITHUB_WORKSPACE" nix build --impure .#default
diff --git a/Justfile b/Justfile
index 8a83317..ad4edf6 100644
--- a/Justfile
+++ b/Justfile
@@ -7 +7 @@ build:
-  nix build
+  BLOG_REPO_ROOT=$(pwd) nix build --impure
diff --git a/flake.nix b/flake.nix
index 60c2e05..6b17424 100644
--- a/flake.nix
+++ b/flake.nix
@@ -21 +21,6 @@
-          gitDir = if builtins.pathExists ./.git then ./.git else null;
+          repoRoot = builtins.getEnv "BLOG_REPO_ROOT";
+          gitDir =
+            if builtins.pathExists ./.git then ./.git
+            else if repoRoot != "" && builtins.pathExists (repoRoot + "/.git")
+              then builtins.path { path = repoRoot + "/.git"; name = "blog-git-dir"; }
+              else null;
@@ -30 +35 @@
-            nativeBuildInputs = [ pkgs.git pkgs.gnused ];
+            nativeBuildInputs = [ pkgs.git pkgs.gnused pkgs.gnugrep ];
@@ -60 +65,28 @@
-                ${pkgs.git}/bin/git show --no-color --format= --unified=3 "$hash" -- "$file" 2>/dev/null || true
+                diff_full="$TMPDIR/diff.full"
+                diff_body="$TMPDIR/diff.body"
+                diff_word="$TMPDIR/diff.word"
+
+                status="$(${pkgs.git}/bin/git show --no-color --format= --name-status -1 "$hash" -- "$file" 2>/dev/null | ${pkgs.gnused}/bin/sed -n '1s/\t.*$//p')"
+
+                case "$status" in
+                  A*|D*)
+                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                    ;;
+                  *)
+                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 --word-diff=porcelain "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                    ;;
+                esac
+
+                ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
+
+                if ! printf '%s' "$status" | ${pkgs.gnugrep}/bin/grep -qE '^(A|D)'; then
+                  ${pkgs.gnused}/bin/sed '/^~$/d' "$diff_body" > "$diff_word"
+                  if ${pkgs.gnugrep}/bin/grep -qE '^[+-]' "$diff_word"; then
+                    cat "$diff_word" > "$diff_body"
+                  else
+                    ${pkgs.git}/bin/git show --no-color --format= --unified=0 "$hash" -- "$file" 2>/dev/null > "$diff_full" || true
+                    ${pkgs.gnused}/bin/sed -n '/^@@ /,$p' "$diff_full" > "$diff_body"
+                  fi
+                fi
+
+                cat "$diff_body"
diff --git a/nix/serve.nix b/nix/serve.nix
index 0f7bfbc..d63dcb4 100644
--- a/nix/serve.nix
+++ b/nix/serve.nix
@@ -7,0 +8,2 @@ let
+      REPO_ROOT="$(pwd)"
+
@@ -10 +12 @@ let
-      nix build
+      env BLOG_REPO_ROOT="$REPO_ROOT" nix build --impure
@@ -22 +24 @@ let
-      watchexec --watch posts --watch style.css --watch flake.nix -- nix build
+      watchexec --watch posts --watch style.css --watch flake.nix -- env BLOG_REPO_ROOT="$REPO_ROOT" nix build --impure
diff --git a/style.css b/style.css
index 3e92001..ebd7e94 100644
--- a/style.css
+++ b/style.css
@@ -215,0 +216,9 @@ footer {
+
+.versions pre {
+  white-space: pre-wrap;
+}
+
+.versions pre code {
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
2026-01-17 fa0271e Versioning
diff --git a/flake.nix b/flake.nix
index 6da407a..60c2e05 100644
--- a/flake.nix
+++ b/flake.nix
@@ -20,0 +21 @@
+          gitDir = if builtins.pathExists ./.git then ./.git else null;
@@ -27,0 +29,46 @@
+          postVersionsMd = filename: pkgs.runCommandLocal "post-versions-${lib.removeSuffix ".md" filename}.md" {
+            nativeBuildInputs = [ pkgs.git pkgs.gnused ];
+          } ''
+            set -euo pipefail
+            export GIT_DIR=${gitDir}
+            export GIT_OPTIONAL_LOCKS=0
+
+            file="posts/${filename}"
+            log="$TMPDIR/log.tsv"
+
+            ${pkgs.git}/bin/git log --follow --date=short --format='%H%x09%ad%x09%s' -- "$file" > "$log" 2>/dev/null || true
+
+            if [ ! -s "$log" ]; then
+              : > "$out"
+              exit 0
+            fi
+
+            {
+              echo '<details class="versions">'
+              echo '<summary>Versions</summary>'
+              echo
+
+              while IFS="$(printf '\t')" read -r hash date subject; do
+                short="$(printf '%.7s' "$hash")"
+                esc_subject="$(printf '%s' "$subject" | ${pkgs.gnused}/bin/sed -e 's/&/&amp;/g' -e 's/</&lt;/g' -e 's/>/&gt;/g')"
+
+                echo '<details class="version">'
+                echo "<summary>$date <code>$short</code> $esc_subject</summary>"
+                echo
+
+                echo '````````diff'
+                ${pkgs.git}/bin/git show --no-color --format= --unified=3 "$hash" -- "$file" 2>/dev/null || true
+                echo '````````'
+                echo
+                echo '</details>'
+                echo
+              done < "$log"
+
+              echo '</details>'
+            } > "$out"
+          '';
+
+          postVersionsHtml = filename:
+            if gitDir == null then ""
+            else mdToHtml (postVersionsMd filename);
+
@@ -80,0 +128 @@
+              versions = postVersionsHtml filename;
@@ -149,0 +198 @@
+                  (lib.optional (post.versions != "") (h.raw post.versions))
diff --git a/style.css b/style.css
index 3fa6a6e..3e92001 100644
--- a/style.css
+++ b/style.css
@@ -181,0 +182,34 @@ footer {
+
+.versions {
+  margin-top: 3rem;
+  padding-top: 1.5rem;
+  border-top: 1px solid #2a2a2a;
+}
+
+.versions > summary {
+  cursor: pointer;
+  color: #b8b8b8;
+  font-weight: 600;
+}
+
+.versions > summary:hover {
+  color: #e0e0e0;
+}
+
+.versions details.version {
+  margin-top: 1rem;
+}
+
+.versions details.version > summary {
+  cursor: pointer;
+  color: #999;
+  font-size: 0.9375rem;
+}
+
+.versions details.version > summary code {
+  background: #252525;
+  padding: 0.125em 0.375em;
+  font-size: 0.875em;
+  border-radius: 3px;
+  color: #d4d4d4;
+}
2026-01-16 db6064b Add link to tested commits
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index 9888b16..95e3b7b 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -33 +33 @@ And if you try to compile it yourself, you'll see that it's very far away from b
-Multiple recent GitHub Actions runs on `main` show failures (including workflow-file errors), and independent build attempts report dozens of compiler errors, recent PRs were all merged with failing CI, and going back in the Git history from most recent commit back about 100 commits, I couldn't find a single commit that compiled cleanly.
+Multiple recent GitHub Actions runs on `main` show failures (including workflow-file errors), and independent build attempts report dozens of compiler errors, recent PRs were all merged with failing CI, and going back in the Git history from most recent commit back 100 commits,<br/>[I couldn't find a single commit that compiled cleanly](https://gist.github.com/embedding-shapes/f5d096dd10be44ff82b6e5ccdaf00b29).
2026-01-16 3dcd6e7 Fix linebreak typo
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index 6f466a4..9888b16 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -9,3 +9 @@ On January 14th 2026, Cursor published a blog post titled "Scaling long-running
-In the blog post, they talk about their experiments with running "coding agents autonomously for weeks"
-
-with the explicit goal of
+In the blog post, they talk about their experiments with running "coding agents autonomously for weeks" with the explicit goal of
2026-01-16 c74ab74 Fix favicon, fix typos, made better simply
diff --git a/favicon.svg b/favicon.svg
index edcfd7c..7087da2 100644
--- a/favicon.svg
+++ b/favicon.svg
@@ -2 +2 @@
-  <text y="32" font-size="32">🚀</text>
+  <text y="16" font-size="16">🫠</text>
diff --git a/flake.nix b/flake.nix
index 116a505..6da407a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -107,0 +108 @@
+              [ "link" { rel = "icon"; href = "/favicon.svg"; } ]
@@ -137,0 +139,2 @@
+            cp ${./favicon.svg} $out/favicon.svg
+            cp -r ${./content} $out/content
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index 2b8470a..6f466a4 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -33 +33,3 @@ And below it, they say "While it might seem like a simple screenshot, building a
-And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build. Multiple recent GitHub Actions runs on `main` show failures (including workflow-file errors), and independent build attempts report dozens of compiler errors, recent PRs were all merged with failing CI, and going back in the Git history from most recent commit, I couldn't find a single commit that compiled cleanly.
+And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build.
+
+Multiple recent GitHub Actions runs on `main` show failures (including workflow-file errors), and independent build attempts report dozens of compiler errors, recent PRs were all merged with failing CI, and going back in the Git history from most recent commit back about 100 commits, I couldn't find a single commit that compiled cleanly.
@@ -37 +39 @@ I'm not sure what the "agents" they unleashed on this codebase actually did, but
-And diving into the codebase, if the compilation errors didn't make that sure, makes it very clear to any software developer that none of this is actually engineered code. It is what is typically known as "AI slop", low quality *something* that surely represents *something*, but it doesn't have intention behind it, and it doesn't even compile at this point.
+And diving into the codebase, if the compilation errors didn't make that clear already, makes it very clear to any software developer that none of this is actually engineered code. It is what is typically known as "AI slop", low quality *something* that surely represents *something*, but it doesn't have intention behind it, and it doesn't even compile at this point.
@@ -59 +61 @@ The closest they get to implying that this was a success, is this part:
-But this extraordinary claim isn't backed up by any evidence. In the blog post they never provide a working commit, build instructions or even a demo that can reproduced.
+But this extraordinary claim isn't backed up by any evidence. In the blog post they never provide a working commit, build instructions or even a demo that can be reproduced.
diff --git a/style.css b/style.css
index 61326a8..3fa6a6e 100644
--- a/style.css
+++ b/style.css
@@ -73,0 +74,6 @@ main p {
+main img,
+main video {
+  max-width: 100%;
+  height: auto;
+}
+
2026-01-16 bafc54f Favicon + changes + cursor video
diff --git a/content/cursor-screenshots.webm b/content/cursor-screenshots.webm
new file mode 100644
index 0000000..bdcf43a
Binary files /dev/null and b/content/cursor-screenshots.webm differ
diff --git a/favicon.svg b/favicon.svg
new file mode 100644
index 0000000..edcfd7c
--- /dev/null
+++ b/favicon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <text y="32" font-size="32">🚀</text>
+</svg>
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
index e1ce556..2b8470a 100644
--- a/posts/cursor-implied-success-without-evidence.md
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -21 +21 @@ Finally they arrived at a point where something "solved most of our coordination
-This is where things get a bit murky and unclear. They claim "Despite the codebase size, new agents can still understand it and make meaningful progress" and "Hundreds of workers run concurrently, pushing to the same branch with minimal conflicts", but they never actually say if this is successful or not, is it actually working? Can you run this browser yourself? We don't know and they never say.
+This is where things get a bit murky and unclear. They claim "Despite the codebase size, new agents can still understand it and make meaningful progress" and "Hundreds of workers run concurrently, pushing to the same branch with minimal conflicts", but they never actually say if this is successful or not, is it actually working? Can you run this browser yourself? We don't know and they never say explicitly.
@@ -25 +25 @@ After this, they embed the following video:
-[video]
+![](/content/cursor-screenshots.webm)
@@ -33 +33 @@ And below it, they say "While it might seem like a simple screenshot, building a
-And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build. Multiple recent CI workflow runs on `main` are failing, all the PRs were merged with failing CI, and going back in the Git history from most recent commit, I couldn't find a single commit that compiled cleanly.
+And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build. Multiple recent GitHub Actions runs on `main` show failures (including workflow-file errors), and independent build attempts report dozens of compiler errors, recent PRs were all merged with failing CI, and going back in the Git history from most recent commit, I couldn't find a single commit that compiled cleanly.
@@ -39 +39,3 @@ And diving into the codebase, if the compilation errors didn't make that sure, m
-They later start to talk about what's next, but not a single word about how to run it, what to expect, how it's working or anything else. Cursor's blog post provides no reproducible demo/build instructions or known-good commit, beyond linking the repo. Regardless of intent, Cursor's blog post creates the impression of a functioning prototype while leaving out the basic reproducibility markers one would expect from such claim. They never explicitly claim it's actually working, so no one can say they lied at least.
+They later start to talk about what's next, but not a single word about how to run it, what to expect, how it's working or anything else. Cursor's blog post provides no reproducible demo and no known-good revision (tag/release/commit) to verify the screenshots, beyond linking the repo.
+
+Regardless of intent, Cursor's blog post creates the impression of a functioning prototype while leaving out the basic reproducibility markers one would expect from such claim. They never explicitly claim it's actually working, so no one can say they lied at least.
@@ -46,0 +49,2 @@ Which seems like a really strange conclusion to arrive at, when all they've prov
+A "browser experiment" doesn't need to rival Chrome. A reasonable minimum bar is: it compiles on a supported toolchain and can render a trivial HTML file. Cursor's post doesn’t demonstrate that bar, and current public build attempts fail at this too.
+
@@ -55 +59 @@ The closest they get to implying that this was a success, is this part:
-But this extraordinary claim isn't backed up by any evidence. They never provide a working commit, build instructions or even a demo that can reproduced.
+But this extraordinary claim isn't backed up by any evidence. In the blog post they never provide a working commit, build instructions or even a demo that can reproduced.
2026-01-16 d664475 Move
diff --git a/posts/are-cursor-trying-to-bamboozle-the-world.md b/posts/are-cursor-trying-to-bamboozle-the-world.md
deleted file mode 100644
index 441aaee..0000000
--- a/posts/are-cursor-trying-to-bamboozle-the-world.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-date: 2026-01-16
----
-
-# Is Cursor trying to bamboozle the world?
-
-On January 14th 2026, Cursor published a blog post titled "Scaling long-running autonomous coding" (https://cursor.com/blog/scaling-agents)
-
-In the blog post, they talk about their experiements about running "coding agents autonomously for weeks" with the goal of "understand[ing] how far we can push the frontier of agentic coding for projects that typically take human teams months to complete".
-
-They talk about some approaches they tried, why they think those failed, and how to address the difficulties.
-
-Finally they arrived at a point where "This solved most of our coordination problems and let us scale to very large projects without any single agent", which then lead to this:
-
-> To test this system, we pointed it at an ambitious goal: building a web browser from scratch. The agents ran for close to a week, writing over 1 million lines of code across 1,000 files. You can explore the source code on GitHub (https://github.com/wilsonzlin/fastrender)
-
-This is where things get a bit murky and unclear. They claim "Despite the codebase size, new agents can still understand it and make meaningful progress" and "Hundreds of workers run concurrently, pushing to the same branch with minimal conflicts", but they never actually say if this is successful or not, is it actually working?
-
-Then after this, they embed the following video:
-
-[video]
-
-And below it, they say "While it might seem like a simple screenshot, building a browser from scratch is extremely difficult.".
-
-However, here's the bamboozle:
-
-#### They never actually claim this browser is working and functional
-
-And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build.
-
-I'm not sure what the "agents" they unleashed on this codebase actually did, but they seemingly never ran "cargo build" or even less "cargo check", because both of those commands surface 10s of errors (which surely would balloon should we solve them) and about 100 warnings.
-
-They later start to talk about what's next, but not a single word about how to run it, what to expect, how it's working or anything else.
-
-And diving into the codebase, if the compilation errors didn't make that sure, makes it very clear to any software developer that none of this is actually engineered code. It is what is typically known as "AI slop", low quality *something* that surely represents *something*, but it doesn't have intention behind it, and it doesn't even compile at this point.
-
-They finish of the article saying:
-
-> But the core question, can we scale autonomous coding by throwing more agents at a problem, has a more optimistic answer than we expected.
-
-Which seems like a really strange conclusion to arrive at, when all they've proved so far, is that agents can output millions of tokens and still not end up with something that actually works.
diff --git a/posts/cursor-implied-success-without-evidence.md b/posts/cursor-implied-success-without-evidence.md
new file mode 100644
index 0000000..e1ce556
--- /dev/null
+++ b/posts/cursor-implied-success-without-evidence.md
@@ -0,0 +1,57 @@
+---
+date: 2026-01-16
+---
+
+# Cursor's latest "browser experiment" implied success without evidence
+
+On January 14th 2026, Cursor published a blog post titled "Scaling long-running autonomous coding" (https://cursor.com/blog/scaling-agents)
+
+In the blog post, they talk about their experiments with running "coding agents autonomously for weeks"
+
+with the explicit goal of
+
+> understand[ing] how far we can push the frontier of agentic coding for projects that typically take human teams months to complete
+
+They talk about some approaches they tried, why they think those failed, and how to address the difficulties.
+
+Finally they arrived at a point where something "solved most of our coordination problems and let us scale to very large projects without any single agent", which then led to this:
+
+> To test this system, we pointed it at an ambitious goal: building a web browser from scratch. The agents ran for close to a week, writing over 1 million lines of code across 1,000 files. You can explore the source code on GitHub (https://github.com/wilsonzlin/fastrender)
+
+This is where things get a bit murky and unclear. They claim "Despite the codebase size, new agents can still understand it and make meaningful progress" and "Hundreds of workers run concurrently, pushing to the same branch with minimal conflicts", but they never actually say if this is successful or not, is it actually working? Can you run this browser yourself? We don't know and they never say.
+
+After this, they embed the following video:
+
+[video]
+
+And below it, they say "While it might seem like a simple screenshot, building a browser from scratch is extremely difficult.".
+
+### They never actually claim this browser is working and functional
+
+> error: could not compile 'fastrender' (lib) due to 34 previous errors; 94 warnings emitted
+
+And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build. Multiple recent CI workflow runs on `main` are failing, all the PRs were merged with failing CI, and going back in the Git history from most recent commit, I couldn't find a single commit that compiled cleanly.
+
+I'm not sure what the "agents" they unleashed on this codebase actually did, but they seemingly never ran "cargo build" or even less "cargo check", because both of those commands surface 10s of errors (which surely would balloon should we solve them) and about 100 warnings. There is an open GitHub issue in their repository about this right now: https://github.com/wilsonzlin/fastrender/issues/98
+
+And diving into the codebase, if the compilation errors didn't make that sure, makes it very clear to any software developer that none of this is actually engineered code. It is what is typically known as "AI slop", low quality *something* that surely represents *something*, but it doesn't have intention behind it, and it doesn't even compile at this point.
+
+They later start to talk about what's next, but not a single word about how to run it, what to expect, how it's working or anything else. Cursor's blog post provides no reproducible demo/build instructions or known-good commit, beyond linking the repo. Regardless of intent, Cursor's blog post creates the impression of a functioning prototype while leaving out the basic reproducibility markers one would expect from such claim. They never explicitly claim it's actually working, so no one can say they lied at least.
+
+They finish off the article saying:
+
+> But the core question, can we scale autonomous coding by throwing more agents at a problem, has a more optimistic answer than we expected.
+
+Which seems like a really strange conclusion to arrive at, when all they've proved so far, is that agents can output millions of tokens and still not end up with something that actually works.
+
+## Conclusion
+
+Cursor never says "this browser is production-ready", but they do frame it as "building a web browser from scratch" and "meaningful progress" and then use a screenshot and "extremely difficult" language, wanting to give the impression that this experiment actually was a success.
+
+The closest they get to implying that this was a success, is this part:
+
+> Hundreds of agents can work together on a single codebase for weeks, making real progress on ambitious projects.
+
+But this extraordinary claim isn't backed up by any evidence. They never provide a working commit, build instructions or even a demo that can reproduced.
+
+I don't think anyone expects this browser to be the next Chrome, but I do think that if you claim you've built a browser, it should at least be able to demonstrate being able to be compiled + loading a basic HTML file at the very least.
2026-01-16 57f4a7f Add
diff --git a/posts/are-cursor-trying-to-bamboozle-the-world.md b/posts/are-cursor-trying-to-bamboozle-the-world.md
new file mode 100644
index 0000000..441aaee
--- /dev/null
+++ b/posts/are-cursor-trying-to-bamboozle-the-world.md
@@ -0,0 +1,41 @@
+---
+date: 2026-01-16
+---
+
+# Is Cursor trying to bamboozle the world?
+
+On January 14th 2026, Cursor published a blog post titled "Scaling long-running autonomous coding" (https://cursor.com/blog/scaling-agents)
+
+In the blog post, they talk about their experiements about running "coding agents autonomously for weeks" with the goal of "understand[ing] how far we can push the frontier of agentic coding for projects that typically take human teams months to complete".
+
+They talk about some approaches they tried, why they think those failed, and how to address the difficulties.
+
+Finally they arrived at a point where "This solved most of our coordination problems and let us scale to very large projects without any single agent", which then lead to this:
+
+> To test this system, we pointed it at an ambitious goal: building a web browser from scratch. The agents ran for close to a week, writing over 1 million lines of code across 1,000 files. You can explore the source code on GitHub (https://github.com/wilsonzlin/fastrender)
+
+This is where things get a bit murky and unclear. They claim "Despite the codebase size, new agents can still understand it and make meaningful progress" and "Hundreds of workers run concurrently, pushing to the same branch with minimal conflicts", but they never actually say if this is successful or not, is it actually working?
+
+Then after this, they embed the following video:
+
+[video]
+
+And below it, they say "While it might seem like a simple screenshot, building a browser from scratch is extremely difficult.".
+
+However, here's the bamboozle:
+
+#### They never actually claim this browser is working and functional
+
+And if you try to compile it yourself, you'll see that it's very far away from being a functional browser at all, and seemingly, it never actually was able to build.
+
+I'm not sure what the "agents" they unleashed on this codebase actually did, but they seemingly never ran "cargo build" or even less "cargo check", because both of those commands surface 10s of errors (which surely would balloon should we solve them) and about 100 warnings.
+
+They later start to talk about what's next, but not a single word about how to run it, what to expect, how it's working or anything else.
+
+And diving into the codebase, if the compilation errors didn't make that sure, makes it very clear to any software developer that none of this is actually engineered code. It is what is typically known as "AI slop", low quality *something* that surely represents *something*, but it doesn't have intention behind it, and it doesn't even compile at this point.
+
+They finish of the article saying:
+
+> But the core question, can we scale autonomous coding by throwing more agents at a problem, has a more optimistic answer than we expected.
+
+Which seems like a really strange conclusion to arrive at, when all they've proved so far, is that agents can output millions of tokens and still not end up with something that actually works.
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
index 0a89f19..8a2509e 100644
--- a/posts/introducing-niccup.md
+++ b/posts/introducing-niccup.md
@@ -22,3 +22 @@ That's it. Nix data structures in, HTML out. Zero dependencies. Works with flake
-The code is available here: [embedding-shapes/niccup](https://github.com/embedding-shapes/niccup)
-
-The website/docs/API and [some fun examples](https://embedding-shapes.github.io/niccup/examples/quine/) can be found here: [https://embedding-shapes.github.io/niccup/](https://embedding-shapes.github.io/niccup/)
+[Source Code](https://github.com/embedding-shapes/niccup) | [Website/Docs](https://embedding-shapes.github.io/niccup/) | [Introduction Blog Post](https://embedding-shapes.github.io/introducing-niccup/)
2025-12-03 ef01f53 Init
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..42a210e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,14 @@
+name: CI
+
+on:
+  push:
+    branches: [master, main]
+  pull_request:
+
+jobs:
+  check:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+      - uses: DeterminateSystems/determinate-nix-action@b3e3f405539b332fcb96794525f35fb10c230baa # v3.13.2
+      - run: nix flake check
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..104ff21
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,39 @@
+name: Deploy to GitHub Pages
+
+on:
+  push:
+    branches: [master, main]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+concurrency:
+  group: pages
+  cancel-in-progress: false
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+      - uses: DeterminateSystems/determinate-nix-action@b3e3f405539b332fcb96794525f35fb10c230baa # v3.13.2
+      - name: Build website
+        run: nix build .#default
+      - name: Upload artifact
+        uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
+        with:
+          path: result
+
+  deploy:
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    runs-on: ubuntu-latest
+    needs: build
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca0bb18
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+# Nix build outputs
+**/result
+**/result-*
+
+# Direnv
+/.direnv
+.envrc
+
+# Editor swap/backup
+*~
+.*.swp
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..8a83317
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,7 @@
+default: build
+
+serve:
+  nix run .#serve
+
+build:
+  nix build
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..d4b182f
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,49 @@
+{
+  "nodes": {
+    "niccup": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      },
+      "locked": {
+        "lastModified": 1764779610,
+        "narHash": "sha256-PXnXdcG2iNMmPkWDmD+j95jFolkY+77w6fEGZc4uF+A=",
+        "owner": "embedding-shapes",
+        "repo": "niccup",
+        "rev": "ff6c858f1e04a6c3ad086b5c320d3c9d7a00e5eb",
+        "type": "github"
+      },
+      "original": {
+        "owner": "embedding-shapes",
+        "repo": "niccup",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1764522689,
+        "narHash": "sha256-SqUuBFjhl/kpDiVaKLQBoD8TLD+/cTUzzgVFoaHrkqY=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "8bb5646e0bed5dbd3ab08c7a7cc15b75ab4e1d0f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-25.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "niccup": "niccup",
+        "nixpkgs": [
+          "niccup",
+          "nixpkgs"
+        ]
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..116a505
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,160 @@
+{
+  description = "Blog using niccup with dynamic post loading";
+
+  inputs = {
+    niccup.url = "github:embedding-shapes/niccup";
+    nixpkgs.follows = "niccup/nixpkgs";
+  };
+
+  outputs = { self, nixpkgs, niccup }:
+    let
+      systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
+      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
+    in {
+      packages = forAllSystems (system:
+        let
+          pkgs = import nixpkgs { inherit system; };
+          lib = pkgs.lib;
+          h = niccup.lib;
+
+          postsDir = ./posts;
+
+          # Convert markdown to HTML using pandoc (supports GFM tables + syntax highlighting)
+          # Pandoc automatically skips YAML frontmatter
+          mdToHtml = mdPath: builtins.readFile (pkgs.runCommandLocal "md-to-html" {} ''
+            ${pkgs.pandoc}/bin/pandoc -f gfm -t html --highlight-style=breezedark ${mdPath} -o $out
+          '');
+
+          # Parse YAML frontmatter to extract date
+          # Expects format: ---\ndate: YYYY-MM-DD\n---
+          parseFrontmatter = content:
+            let
+              lines = lib.splitString "\n" content;
+              hasFrontmatter = (builtins.head lines) == "---";
+              frontmatterEndIdx = if hasFrontmatter
+                then lib.lists.findFirstIndex (l: l == "---") null (builtins.tail lines)
+                else null;
+              frontmatterLines = if frontmatterEndIdx != null
+                then lib.take frontmatterEndIdx (builtins.tail lines)
+                else [];
+              dateLine = lib.findFirst (l: lib.hasPrefix "date:" l) null frontmatterLines;
+              date = if dateLine != null
+                then lib.trim (lib.removePrefix "date:" dateLine)
+                else null;
+            in { inherit date; };
+
+          # Generate syntax highlighting CSS from pandoc
+          highlightCss = pkgs.runCommandLocal "highlight.css" {} ''
+            echo '```c
+            x
+            ```' | ${pkgs.pandoc}/bin/pandoc -f gfm -t html --standalone --highlight-style=breezedark \
+              | ${pkgs.gnused}/bin/sed -n '/code span\./,/^[[:space:]]*<\/style>/p' \
+              | ${pkgs.gnugrep}/bin/grep -v '</style>' > $out
+          '';
+
+          # Read all .md files from posts directory
+          postFiles = lib.filterAttrs (name: type:
+            type == "regular" && lib.hasSuffix ".md" name
+          ) (builtins.readDir postsDir);
+
+          # Convert filename to title: "hello-world.md" -> "Hello World"
+          filenameToTitle = filename:
+            let
+              slug = lib.removeSuffix ".md" filename;
+              words = lib.splitString "-" slug;
+              capitalize = s:
+                let chars = lib.stringToCharacters s;
+                in if chars == [] then ""
+                   else lib.concatStrings ([ (lib.toUpper (builtins.head chars)) ] ++ (builtins.tail chars));
+            in lib.concatStringsSep " " (map capitalize words);
+
+          # Build post objects from files
+          posts = lib.mapAttrsToList (filename: _:
+            let
+              content = builtins.readFile (postsDir + "/${filename}");
+              frontmatter = parseFrontmatter content;
+            in {
+              slug = lib.removeSuffix ".md" filename;
+              title = filenameToTitle filename;
+              date = frontmatter.date;
+              body = mdToHtml (postsDir + "/${filename}");
+            }) postFiles;
+
+          # Sort posts by date, newest first
+          sortedPosts = lib.sort (a: b: a.date > b.date) posts;
+
+          header = [ "header"
+            [ "a" { href = "/"; } "embedding-shapes" ]
+            [ "nav"
+              [ "a" { href = "/"; } "Home" ]
+              [ "a" { href = "/posts/"; } "Posts" ]
+            ]
+          ];
+
+          footer = [ "footer" [ "p" "Built with "  [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
+
+          postList = [ "ul" { class = "post-list"; }
+            (map (p: [ "li" [ "a" { href = "/${p.slug}/"; } p.title ] ]) sortedPosts)
+          ];
+
+          renderPage = { title, content }: h.renderPretty [
+            "html" { lang = "en"; }
+            [ "head"
+              [ "meta" { charset = "utf-8"; } ]
+              [ "meta" { name = "viewport"; content = "width=device-width, initial-scale=1"; } ]
+              [ "title" title ]
+              [ "link" { rel = "stylesheet"; href = "/style.css"; } ]
+              [ "link" { rel = "stylesheet"; href = "/highlight.css"; } ]
+            ]
+            [ "body"
+              header
+              [ "main" content ]
+              footer
+            ]
+          ];
+
+          indexHtml = pkgs.writeText "index.html" (renderPage {
+            title = "embedding-shapes";
+            content = [
+              [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
+              [ "h2" "Recent Posts" ]
+              postList
+            ];
+          });
+
+          postsHtml = pkgs.writeText "posts.html" (renderPage {
+            title = "Posts";
+            content = [
+              [ "h1" "Posts" ]
+              postList
+            ];
+          });
+
+        in {
+          default = pkgs.runCommand "blog" {} ''
+            mkdir -p $out
+            cp ${./style.css} $out/style.css
+            cp ${highlightCss} $out/highlight.css
+            cp ${indexHtml} $out/index.html
+            mkdir -p $out/posts
+            cp ${postsHtml} $out/posts/index.html
+            ${builtins.concatStringsSep "\n" (map (post:
+              "mkdir -p $out/${post.slug} && cp ${pkgs.writeText "index.html" (renderPage {
+                inherit (post) title;
+                content = [
+                  (lib.optional (post.date != null) [ "p" { class = "post-date"; } post.date ])
+                  (h.raw post.body)
+                ];
+              })} $out/${post.slug}/index.html"
+            ) sortedPosts)}
+          '';
+        });
+
+      apps = forAllSystems (system:
+        let
+          pkgs = import nixpkgs { inherit system; };
+        in {
+          serve = import ./nix/serve.nix { inherit pkgs; };
+        });
+    };
+}
diff --git a/nix/serve.nix b/nix/serve.nix
new file mode 100644
index 0000000..0f7bfbc
--- /dev/null
+++ b/nix/serve.nix
@@ -0,0 +1,29 @@
+{ pkgs }:
+
+let
+  serve = pkgs.writeShellApplication {
+    name = "serve";
+    runtimeInputs = [ pkgs.python3 pkgs.watchexec ];
+    text = ''
+      # Initial build
+      echo "Building..."
+      nix build
+
+      echo "Serving at http://localhost:8000"
+      echo "Watching: posts/, style.css, flake.nix"
+      echo "Press Ctrl+C to stop"
+
+      # Start HTTP server in background
+      python3 -m http.server 8000 --directory result &
+      server_pid=$!
+      trap 'kill $server_pid 2>/dev/null' EXIT
+
+      # Watch and rebuild on changes
+      watchexec --watch posts --watch style.css --watch flake.nix -- nix build
+    '';
+  };
+in
+{
+  type = "app";
+  program = "${serve}/bin/serve";
+}
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
new file mode 100644
index 0000000..0a89f19
--- /dev/null
+++ b/posts/introducing-niccup.md
@@ -0,0 +1,223 @@
+---
+date: 2025-12-03
+---
+
+# Niccup: Hiccup-like HTML Generation in ~120 Lines of Pure Nix
+
+Ever wish it was really simple to create HTML from just Nix expressions, not even having to deal with function calls or other complexities? With niccup, now there is!
+
+```nix
+[ "div#main.container"
+  { lang = "en"; }
+  [ "h1" "Hello" ] ]
+```
+```html
+<div class="container" id="main" lang="en">
+  <h1>Hello</h1>
+</div>
+```
+
+That's it. Nix data structures in, HTML out. Zero dependencies. Works with flakes or without.
+
+The code is available here: [embedding-shapes/niccup](https://github.com/embedding-shapes/niccup)
+
+The website/docs/API and [some fun examples](https://embedding-shapes.github.io/niccup/examples/quine/) can be found here: [https://embedding-shapes.github.io/niccup/](https://embedding-shapes.github.io/niccup/)
+
+## Why Generate HTML from Nix?
+
+If you're building static sites, documentation, or web artifacts as part of a Nix derivation, you've probably resorted to one of these:
+
+1. String interpolation (`''<div>${title}</div>''`). Works until you need escaping or composition
+2. External templating tools. Another dependency, another language, another build step
+3. Importing HTML files, no programmatic generation
+
+Niccup takes a different approach: represent HTML as native Nix data structures. This gives you `map`, `filter`, `builtins.concatStringsSep`, and the entire Nix expression language for free. No new syntax to learn. No dependencies to manage.
+
+## The Syntax
+
+An element is a list: `[ tag-spec attrs? children... ]`
+
+### Tag Specs with CSS Shorthand
+
+```nix
+"div"
+# <div></div>
+
+"input#search"
+# <input id="search">
+
+"button.btn.primary"
+# <button class="btn primary"></button>
+
+"form#login.auth.dark"
+# <form class="auth dark" id="login"></form>
+```
+
+### Attributes
+
+The optional second element can be an attribute set:
+
+```nix
+[ "a"
+  { href = "/about"; target = "_blank"; }
+  "About" ]
+# <a href="/about" target="_blank">About</a>
+```
+
+Classes from the shorthand and attribute set are merged:
+
+```nix
+[ "div.base"
+  { class = [ "added" "another" ]; }
+  "content" ]
+# <div class="base added another">content</div>
+```
+
+Boolean handling:
+
+```nix
+[ "input"
+  { type = "checkbox";
+    checked = true;
+    disabled = false; } ]
+# <input checked="checked" type="checkbox">
+```
+
+`true` renders as `attr="attr"`. `false` and `null` are omitted entirely.
+
+### Children and Composition
+
+Children can be strings, numbers, nested elements, or lists:
+
+```nix
+[ "p"
+  "Text with "
+  [ "strong" "emphasis" ]
+  " and more." ]
+# <p>Text with <strong>emphasis</strong> and more.</p>
+```
+
+Lists are flattened one level, which makes `map` work naturally:
+
+```nix
+[ "ul"
+  (map (item: [ "li" item ])
+       [ "One" "Two" "Three" ]) ]
+# <ul><li>One</li><li>Two</li><li>Three</li></ul>
+```
+
+Text content is automatically escaped:
+
+```nix
+[ "p" "<script>alert('xss')</script>" ]
+# <p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>
+```
+
+### Raw HTML and Comments
+
+For trusted HTML that shouldn't be escaped:
+
+```nix
+[ "div" (raw "<strong>Already formatted</strong>") ]
+# <div><strong>Already formatted</strong></div>
+```
+
+For HTML comments:
+
+```nix
+[ "div" (comment "TODO: refactor")
+  [ "p" "Content" ] ]
+# <div><!-- TODO: refactor --><p>Content</p></div>
+```
+
+### Void Elements
+
+Self-closing tags work as expected:
+
+```nix
+[ "img" { src = "photo.jpg"; alt = "A photo"; } ]
+# <img alt="A photo" src="photo.jpg">
+
+[ "meta" { charset = "utf-8"; } ]
+# <meta charset="utf-8">
+```
+
+## API
+
+Four functions. That's the entire public interface.
+
+| Function | Description |
+|----------|-------------|
+| `render` | Render to minified HTML |
+| `renderPretty` | Render to indented HTML (2-space indent) |
+| `raw` | Mark a string as trusted, unescaped HTML |
+| `comment` | Create an HTML comment node |
+
+## A Real Example: Blog Generator
+
+```nix
+{ pkgs, niccup }:
+let
+  h = niccup.lib;
+
+  posts = [
+    { slug = "hello"; title = "Hello World"; body = "Welcome!"; }
+    { slug = "update"; title = "An Update"; body = "More content here."; }
+  ];
+
+  layout = { title, content }: h.renderPretty [
+    "html" { lang = "en"; }
+    [ "head"
+      [ "meta" { charset = "utf-8"; } ]
+      [ "meta" { name = "viewport"; content = "width=device-width"; } ]
+      [ "title" title ]
+    ]
+    [ "body"
+      [ "nav" (map (p: [ "a" { href = "/${p.slug}.html"; } p.title ]) posts) ]
+      [ "main" content ]
+      [ "footer" "Generated with niccup" ]
+    ]
+  ];
+
+  renderPost = post: layout {
+    title = post.title;
+    content = [ "article" [ "h1" post.title ] [ "p" post.body ] ];
+  };
+
+in pkgs.runCommand "blog" {} ''
+  mkdir -p $out
+  ${builtins.concatStringsSep "\n" (map (p: ''
+    cat > $out/${p.slug}.html << 'EOF'
+    ${renderPost p}
+    EOF
+  '') posts)}
+''
+```
+
+This produces a complete static site as a Nix derivation. Add a post to the list, rebuild, done.
+
+## Limitations
+
+Being upfront about what niccup doesn't do:
+
+- **Attribute order is alphabetical.** Nix attribute sets have no insertion order; `builtins.attrNames` returns keys sorted lexicographically. You cannot control attribute order in the output.
+
+- **One-level flattening only.** `[ "ul" (map ...) ]` works because `map` returns a list that gets flattened. Deeper nesting like `[ "ul" [ [ [ "li" "x" ] ] ] ]` won't flatten further, you'll get nested elements, not flattened children.
+
+- **Eager evaluation.** The entire tree is evaluated before rendering. For the static site generation use case, this is fine. If you're generating gigabytes of HTML, this isn't your tool.
+
+- **No streaming.** Output is a single string. Again, fine for static sites; not designed for chunked HTTP responses.
+
+## Why Hiccup?
+
+The Hiccup format originated in Clojure and has been battle-tested for over a decade. It maps naturally to Nix because both languages treat data structures as first-class citizens. The syntax is minimal, just lists and attribute sets, and composes with existing Nix idioms without friction.
+
+The name "niccup" is a portmanteau: **Ni**x + Hic**cup**.
+
+## Source
+
+The entire implementation is ~120 lines of pure Nix with no external dependencies. The code, tests, and additional examples are available at:
+
+**[github.com/embedding-shapes/niccup](https://github.com/embedding-shapes/niccup)**
+
+MIT licensed.
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..61326a8
--- /dev/null
+++ b/style.css
@@ -0,0 +1,175 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+body {
+  font-family: system-ui, -apple-system, sans-serif;
+  line-height: 1.7;
+  color: #c9c9c9;
+  background: #161616;
+  max-width: 38rem;
+  margin: 0 auto;
+  padding: 3rem 1.5rem;
+}
+
+header {
+  margin-bottom: 3rem;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+header > a {
+  font-size: 1.25rem;
+  font-weight: 600;
+  text-decoration: none;
+  color: #e8e8e8;
+  letter-spacing: -0.02em;
+}
+
+header nav {
+  display: flex;
+  gap: 1.5rem;
+}
+
+header nav a {
+  text-decoration: none;
+  color: #777;
+  font-size: 0.9375rem;
+}
+
+header nav a:hover {
+  color: #b8b8b8;
+}
+
+main h1 {
+  font-size: 2rem;
+  font-weight: 700;
+  margin-bottom: 1.5rem;
+  line-height: 1.25;
+  letter-spacing: -0.03em;
+  color: #e8e8e8;
+}
+
+main h2 {
+  font-size: 1.25rem;
+  font-weight: 600;
+  margin: 2rem 0 1rem;
+  color: #d8d8d8;
+}
+
+main h3 {
+  font-size: 1.1rem;
+  font-weight: 600;
+  margin: 1.75rem 0 0.75rem;
+  color: #d0d0d0;
+}
+
+main p {
+  margin-bottom: 1.25rem;
+}
+
+main ul, main ol {
+  margin: 1.25rem 0 1.25rem 1.25rem;
+}
+
+main li {
+  margin-bottom: 0.375rem;
+}
+
+main blockquote {
+  border-left: 2px solid #444;
+  padding-left: 1.25rem;
+  margin: 1.5rem 0;
+  font-style: italic;
+  color: #999;
+}
+
+main pre {
+  background: #1e1e1e;
+  padding: 1rem 1.25rem;
+  overflow-x: auto;
+  margin: 1.5rem 0;
+  font-size: 0.875rem;
+  line-height: 1.5;
+  border-radius: 4px;
+}
+
+main code {
+  font-family: ui-monospace, "SF Mono", monospace;
+}
+
+main p code, main li code {
+  background: #252525;
+  padding: 0.125em 0.375em;
+  font-size: 0.875em;
+  border-radius: 3px;
+  color: #d4d4d4;
+}
+
+a {
+  color: #8ab4c2;
+  text-decoration-thickness: 1px;
+  text-underline-offset: 2px;
+}
+
+a:hover {
+  color: #a8ced8;
+  text-decoration-thickness: 2px;
+}
+
+main table {
+  width: 100%;
+  border-collapse: collapse;
+  margin: 1.5rem 0;
+  font-size: 0.9375rem;
+}
+
+main th, main td {
+  padding: 0.625rem 0.875rem;
+  text-align: left;
+  border-bottom: 1px solid #2a2a2a;
+}
+
+main th {
+  color: #a8a8a8;
+  font-weight: 600;
+}
+
+main tbody tr:hover {
+  background: #1c1c1c;
+}
+
+.intro {
+  font-size: 1.125rem;
+  color: #888;
+  margin-bottom: 2.5rem;
+}
+
+.post-list {
+  list-style: none;
+}
+
+.post-list li {
+  margin-bottom: 0.5rem;
+}
+
+.post-list a {
+  text-decoration: none;
+  color: #b8b8b8;
+  font-size: 1.0625rem;
+}
+
+.post-list a:hover {
+  color: #e0e0e0;
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+
+footer {
+  margin-top: 4rem;
+  color: #555;
+  font-size: 0.8125rem;
+}