embedding-shapes

About

Repository Versions
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;
+}