About
- GitHub: embedding-shapes
- Bluesky: @emsh.cat
- Mastodon: @[email protected]
- Email: [email protected]
Repository Versions
2026-01-27 2543cad Add links to Atom/RSS
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 6750203..bdf6396 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -77 +77 @@ let
- footer = [ "footer" [ "p" "Built with " [ "a" { href = niccupUrl; } "niccup" ]] ];
+ footer = [ "footer" [ "p" "Built with " [ "a" { href = niccupUrl; } "niccup" ] " · " [ "a" { href = "/atom.xml"; } "Atom" ] " / " [ "a" { href = "/rss.xml"; } "RSS" ] ] ];2026-01-27 7916ba2 I'm apparently very bad at spelling, luckily :set spell exists
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
index 613df72..71817f7 100644
--- a/posts/one-human-one-agent-one-browser.md
+++ b/posts/one-human-one-agent-one-browser.md
@@ -8 +8 @@ date: 2026-01-27
-Just for the fun of it, I thought I'd embark on a week long quest to generate millions of tokens and millions lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
+Just for the fun of it, I thought I'd embark on a week-long quest to generate millions of tokens and millions of lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
@@ -10 +10 @@ Just for the fun of it, I thought I'd embark on a week long quest to generate mi
-But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so lets see if we can hack something together, one human brain and one LLM agent brain!
+But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so let's see if we can hack something together, one human brain and one LLM agent brain!
@@ -14 +14 @@ But then I remembered that I have something even better: a human brain! It is us
-The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzsers.
+The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzers.
@@ -28 +28 @@ For extra fun when building this, I set these requirements for myself and the ag
-So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requrements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links to just for the fun of it.
+So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requirements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links just for the fun of it.
@@ -30 +30 @@ So with these things in mind, I set out on the journal to build a browser "from
-After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It's was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
+After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
@@ -34 +34 @@ After about a day together with Codex, I had something that could via X11 and cU
-Second day I got annoying by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compability fixes, some performance issues and improved the font/text rendering a bunch. Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
+Second day I got annoyed by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compatibility fixes, some performance issues and improved the font/text rendering a bunch. Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask Codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
@@ -38 +38 @@ Second day I got annoying by the tests spawning windows while I was doing other
-Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and whatnot. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
@@ -46 +46 @@ Then the fourth day (whaaaat?) was basically polish, fixing CI for all three pla
-And here it is, in all it's glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
+And here it is, in all its glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
@@ -51,0 +52 @@ And here it is, in all it's glory, made in ~20K lines of code and under 72 hours
+You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent can build a browser from scratch.
@@ -53,3 +54 @@ And here it is, in all it's glory, made in ~20K lines of code and under 72 hours
-You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent, can build a browser from scratch.
-
-This is what the "lines of code" count ended up being after all was said and done, including support three OSes:
+This is what the "lines of code" count ended up being after all was said and done, including support for three OSes:
@@ -151 +150 @@ SUM: 2440 365 20
-- This could probably scale to multiple humans too, each equiped with their own agent, imagine what we could achieve!
+- This could probably scale to multiple humans too, each equipped with their own agent, imagine what we could achieve!
@@ -153 +152 @@ SUM: 2440 365 20
-- The human who drives the agent might matter more than how the agents work and are setup, the judge is still out on this one
+- The human who drives the agent might matter more than how the agents work and are set up, the judge is still out on this one
@@ -155 +154 @@ SUM: 2440 365 20
-If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected.
+If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected.2026-01-27 b819707 Touchups + headers
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
index d3eef24..613df72 100644
--- a/posts/one-human-one-agent-one-browser.md
+++ b/posts/one-human-one-agent-one-browser.md
@@ -15,0 +16,2 @@ The above might look like a simple .webm video, but it's actually a highly sophi
+## Day 1 - Starting out
+
@@ -29,0 +32,2 @@ After about a day together with Codex, I had something that could via X11 and cU
+## Day 2 - Moving On
+
@@ -32 +36,3 @@ Second day I got annoying by the tests spawning windows while I was doing other
-Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video below, and also added support for the back button.
+## Day 3 - Polish & Cross-platform (+ day 4)
+
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video above, and also added support for the back button because it was annoying to start from scratch if I clicked the wrong link while testing.
@@ -34 +40 @@ Third day we made large changes, lots of new features and a bunch of new feature
-At the end of the third day we also added support for macOS finally, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
+At the end of the third day we also added starting support for macOS, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
@@ -36 +42 @@ At the end of the third day we also added support for macOS finally, and managed
-Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI.
+Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI. Still all within 72 hours (3 days * 24 hours, which obviously this is how you count days).2026-01-27 71a7236 Make layout slightly larger
diff --git a/style.css b/style.css
index a760580..d6a1ada 100644
--- a/style.css
+++ b/style.css
@@ -16 +16 @@ body {
- max-width: 38rem;
+ max-width: 48rem;2026-01-27 bc26dcb Add blogpost + video about "one agent one browser"
diff --git a/content/one-agent-one-browser-hn.png b/content/one-agent-one-browser-hn.png
new file mode 100644
index 0000000..4a1c250
Binary files /dev/null and b/content/one-agent-one-browser-hn.png differ
diff --git a/content/one-human-one-agent-one-browser.webm b/content/one-human-one-agent-one-browser.webm
new file mode 100644
index 0000000..d2a201f
Binary files /dev/null and b/content/one-human-one-agent-one-browser.webm differ
diff --git a/posts/one-human-one-agent-one-browser.md b/posts/one-human-one-agent-one-browser.md
new file mode 100644
index 0000000..d3eef24
--- /dev/null
+++ b/posts/one-human-one-agent-one-browser.md
@@ -0,0 +1,149 @@
+---
+title: One Human + One Agent = One Browser From Scratch
+date: 2026-01-27
+---
+
+# One Human + One Agent = One Browser From Scratch
+
+Just for the fun of it, I thought I'd embark on a week long quest to generate millions of tokens and millions lines of source code to create one basic browser that can render HTML and CSS (no JS tho), and hopefully I could use this to receive even more VC investments.
+
+But then I remembered that I have something even better: a human brain! It is usually better than any machine at coordinating and thinking through things, so lets see if we can hack something together, one human brain and one LLM agent brain!
+
+
+
+The above might look like a simple .webm video, but it's actually a highly sophisticated and advanced browser that was super hard to build, encoded as pixels in a video file! Wowzsers.
+
+For extra fun when building this, I set these requirements for myself and the agent:
+
+- I have three days to build it
+- Not a single 3rd party Rust library/dependency allowed
+- Allowed to use anything (commonly) provided out of the box on the OS it runs on
+- Should run on Windows, macOS and common Linux distributions
+- Should be able to render some websites, most importantly, my own blog and Hacker News, should be easy right?
+- The codebase can always compile and be built
+- The codebase should be readable by a human, although code quality isn't the top concern
+
+So with these things in mind, I set out on the journal to build a browser "from scratch". I started with something really based, being able to just render "Hello World". Then to be able to render some nested tags. Added the ability of taking screenshots so the agent could use that. Added specifications for HTML/CSS (which I think the agent never used :| ), and tried to nail down the requrements for the agent to use. Also started doing "regression" or "E2E" tests with the screenshotting feature, so we could compare to some baseline images and so on. Added the ability to click on links to just for the fun of it.
+
+After about a day together with Codex, I had something that could via X11 and cURL, fetch and render websites when run, and the Cargo.lock is empty. It's was about 7500 lines long in total at that point, split across files with all of them under 1000 lines long (which was a stated requirement, so not a surprise).
+
+Second day I got annoying by the tests spawning windows while I was doing other stuff, so added a --headless flag too. Did some fixes for resizing the window, various compability fixes, some performance issues and improved the font/text rendering a bunch. Workflow was basically to pick a website, share a screenshot of the website without JavaScript, ask codex to replicate it following our instructions. Most of the time was the agent doing work by itself, and me checking in when it notifies me it was done.
+
+Third day we made large changes, lots of new features and a bunch of new features supported. More regression tests, fixing performance issues, fixing crashes and what not. Also added scrolling because this is a mother fucking browser, it has to be able to scroll. Added some debug logs too because that'll look cool in the demonstration video below, and also added support for the back button.
+
+At the end of the third day we also added support for macOS finally, and managed to get a window to open, and the tests to pass. Seems to work OK :) Once we had that working, we also added Windows support, basically the same process, just another platform after all.
+
+Then the fourth day (whaaaat?) was basically polish, fixing CI for all three platforms, making it pass and finally cutting a release based on what got built in CI.
+
+## The results after ~3 days (~70 hours)
+
+And here it is, in all it's glory, made in ~20K lines of code and under 72 hours of total elapsed time from first commit to last:
+
+[](https://github.com/embedding-shapes/one-agent-one-browser)
+
+> You could try compiling it yourself (zero Rust dependencies, so it's really fast :) ), or you can find binaries built on CI here:<br/><small>[https://github.com/embedding-shapes/one-agent-one-browser/releases](https://github.com/embedding-shapes/one-agent-one-browser/releases)</small>
+
+
+You can clone the repository, build it and try it out for yourself. It's not great, I wouldn't even say it's good, but it works, and demonstrates that one person with one agent, can build a browser from scratch.
+
+This is what the "lines of code" count ended up being after all was said and done, including support three OSes:
+
+```shell
+$ git rev-parse HEAD
+e2556016a5aa504ecafd5577c1366854ffd0e280
+
+$ cloc src --by-file
+ 72 text files.
+ 72 unique files.
+ 0 files ignored.
+
+github.com/AlDanial/cloc v 2.06 T=0.06 s (1172.5 files/s, 373824.0 lines/s)
+-----------------------------------------------------------------------------------
+File blank comment code
+-----------------------------------------------------------------------------------
+src/layout/flex.rs 96 0 994
+src/layout/inline.rs 85 0 933
+src/layout/mod.rs 82 0 910
+src/browser.rs 78 0 867
+src/platform/macos/painter.rs 96 0 765
+src/platform/x11/cairo.rs 77 0 713
+src/platform/windows/painter.rs 88 0 689
+src/bin/render-test.rs 87 0 666
+src/style/builder.rs 83 0 663
+src/platform/windows/d2d.rs 53 0 595
+src/platform/windows/windowed.rs 72 0 591
+src/style/declarations.rs 18 0 547
+src/image.rs 81 0 533
+src/platform/macos/windowed.rs 80 2 519
+src/net/winhttp.rs 61 2 500
+src/platform/x11/mod.rs 56 2 487
+src/css.rs 103 346 423
+src/html.rs 58 0 413
+src/platform/x11/painter.rs 48 0 407
+src/platform/x11/scale.rs 57 3 346
+src/layout/table.rs 39 1 340
+src/platform/x11/xft.rs 35 0 338
+src/style/parse.rs 34 0 311
+src/win/wic.rs 39 8 305
+src/style/mod.rs 26 0 292
+src/style/computer.rs 35 0 279
+src/platform/x11/xlib.rs 32 0 278
+src/layout/floats.rs 31 0 265
+src/resources.rs 36 0 238
+src/css_media.rs 36 1 232
+src/debug.rs 32 0 227
+src/platform/windows/dwrite.rs 20 0 222
+src/render.rs 18 0 196
+src/style/custom_properties.rs 34 0 186
+src/platform/windows/scale.rs 28 0 184
+src/url.rs 32 0 173
+src/layout/helpers.rs 12 0 172
+src/net/curl.rs 31 0 171
+src/platform/macos/svg.rs 35 0 171
+src/browser/url_loader.rs 17 0 166
+src/platform/windows/gdi.rs 17 0 165
+src/platform/windows/scaled.rs 16 0 159
+src/platform/macos/scaled.rs 16 0 158
+src/layout/svg_xml.rs 9 0 152
+src/win/com.rs 26 0 152
+src/png.rs 27 0 146
+src/layout/replaced.rs 15 0 131
+src/net/pool.rs 18 0 129
+src/platform/macos/scale.rs 17 0 124
+src/style/selectors.rs 18 0 123
+src/style/length.rs 17 0 121
+src/cli.rs 15 0 112
+src/platform/windows/headless.rs 20 0 112
+src/platform/macos/headless.rs 19 0 109
+src/bin/fetch-resource.rs 14 0 101
+src/geom.rs 10 0 101
+src/browser/render_helpers.rs 11 0 100
+src/dom.rs 11 0 100
+src/style/background.rs 15 0 100
+src/layout/tests.rs 7 0 85
+src/platform/windows/d3d11.rs 14 0 83
+src/win/stream.rs 10 0 63
+src/platform/windows/svg.rs 13 0 54
+src/main.rs 4 0 33
+src/platform/mod.rs 6 0 28
+src/app.rs 5 0 25
+src/lib.rs 1 0 20
+src/platform/windows/mod.rs 2 0 19
+src/net/mod.rs 4 0 16
+src/platform/macos/mod.rs 2 0 14
+src/platform/windows/wstr.rs 0 0 5
+src/win/mod.rs 0 0 3
+-----------------------------------------------------------------------------------
+SUM: 2440 365 20150
+-----------------------------------------------------------------------------------
+```
+
+## Takeaways
+
+- One human using one agent seems far more effective than one human using thousands of agents
+- One agent can work on a single codebase for hours, making real progress on ambitious projects
+- This could probably scale to multiple humans too, each equiped with their own agent, imagine what we could achieve!
+- Sometimes slower is faster and also better
+- The human who drives the agent might matter more than how the agents work and are setup, the judge is still out on this one
+
+If one person with one agent can produce equal or better results than "hundreds of agents for weeks", then the answer to the question: "Can we scale autonomous coding by throwing more agents at a problem?", probably has a more pessimistic answer than some expected. 2026-01-25 a0a9c92 New "Good Taste" post
diff --git a/posts/good-taste.md b/posts/good-taste.md
new file mode 100644
index 0000000..cb53147
--- /dev/null
+++ b/posts/good-taste.md
@@ -0,0 +1,22 @@
+---
+title: Good Taste
+date: 2026-01-25
+---
+
+There is a lot of doom going around, how all developers, creatives and others are losing our jobs because AI is coming to take them. Some of that doom is real; there's work that's basically throughput and spec compliance, and AI can replace big chunks of it. But what I’m talking about here is the other kind of work, where you have authorship and stake in what you're producing, as a human creator.
+
+I get why many of us fear AI and carry this sense of doom with us, but at the same time, as a creative I'm not super worried about it, and not because I think AI cannot generate things, or even generate good things, because I think it can. But because the hard thing has never been to just produce *something*. What's always been hard, is producing *something good*, something made by someone with Good Taste. A real human being that stand in front of hundreds of choices and knows (or feels) what gets to stay, what gets cut, what to push and what should be refused, and finally sharing the choices they made with you, through the medium.
+
+Just to be clear, I'm mainly talking about "creating" and authorship here, not consumption, since you as a consumer are the only judge if something is good or not for you. But when you're making something, if your goal is for others to enjoy it, Good Taste becomes a huge part of what you actually excel at. Not taste as in what the snobby critics do (who ultimately are consumers), but the creator's taste; direction, restraint, pacing, taking risks and sometimes causing offense.
+
+The AI models and the platforms seems to tend to regress to the mean. They optimize for something that "sounds right" and "doesn't upset anyone", creating something that is "acceptable" or even "plausible". This default voice is the opposite of authorship, where you explicitly *don't* want to just average thing out. You want to hit a specific emotional effect with specific rhythm, and you're sometimes willing to risk choices that look wrong until the entire thing is put together. I think this is why most AI output feels so bland and emotionless.
+
+I'm not trying to say that "AI can't make you feel anything" because I don't think it's true, I think AI can generate something that hits, you can even explicitly train and/or steer AI to produce more "emotional" outputs. But what's actually happening, even there, is that there is a human deciding and curating what counts as a "hit", and the model is learning the shape of that. Ultimately you're bottling taste, not replacing it. There is no stake at the other side, no point of view that is getting committed to, no moment where it suddenly goes "No, I'm not saying that" or "Yes, that's amazing, completely new direction now".
+
+Without any human steering and editing, the AI will just keep handing you infinite plausible takes until one of them happen to work. Infinite "fine", but not much more.
+
+Ultimately, I think AI is an accelerant. If you already have Good Taste, it'll help you move faster, which feels like a good thing: great people continue to produce great things.
+
+But on the other hand, it also makes it easier for people without taste to generate a lot of output that's either bland or sometimes straight up nonsense. Without the ecosystem rewarding high quality and good things, it instead rewards volume and speed, everything slightly tilting to noise.
+
+I feel like we're building the wrong things. The whole vibe right now is "replace the human part" instead of "make better tools for the human part". I don't want a machine that replaces my taste, I want tools that help me use my taste better; see the cut faster, compare directions, compare architectural choices, find where I've missed things, catch when we're going into generics, and help me make sharper intentional choices.2026-01-23 ab57a94 Sitemap + robots.txt + update old URLs
diff --git a/nix/site.nix b/nix/site.nix
index e195a83..d5a8b9e 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -11,0 +12,2 @@ let
+ sitemapXml = pkgs.writeText "sitemap.xml" (ui.renderSitemapXml { posts = site.posts; });
+ robotsTxt = pkgs.writeText "robots.txt" (ui.renderRobotsTxt {});
@@ -22,0 +25,2 @@ in {
+ cp ${sitemapXml} $out/sitemap.xml
+ cp ${robotsTxt} $out/robots.txt
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index b2d34a4..6750203 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -9,0 +10 @@ let
+ niccupUrl = "${siteUrl}/niccup/";
@@ -76 +77 @@ let
- footer = [ "footer" [ "p" "Built with " [ "a" { href = "https://embedding-shapes.github.io/niccup/"; } "niccup" ]] ];
+ footer = [ "footer" [ "p" "Built with " [ "a" { href = niccupUrl; } "niccup" ]] ];
@@ -210,0 +212,21 @@ in {
+
+ renderSitemapXml = { posts }:
+ let
+ latestPostWithDate = lib.findFirst (p: p.date != null) null posts;
+ siteLastMod = if latestPostWithDate != null then latestPostWithDate.date else null;
+ urls =
+ [
+ { loc = homeUrl; lastmod = siteLastMod; }
+ { loc = "${siteUrl}/posts/"; lastmod = siteLastMod; }
+ { loc = "${siteUrl}/about/"; lastmod = siteLastMod; }
+ ]
+ ++ (map (p: { loc = postUrl p.slug; lastmod = p.date; }) posts);
+ in (xmlHeader "UTF-8") + "\n" + (h.render [
+ "urlset" { xmlns = "http://www.sitemaps.org/schemas/sitemap/0.9"; }
+ (map (u: [ "url"
+ [ "loc" u.loc ]
+ (lib.optional (u.lastmod != null) [ "lastmod" u.lastmod ])
+ ]) urls)
+ ]);
+
+ renderRobotsTxt = {}: "User-agent: *\nAllow: /\nSitemap: ${siteUrl}/sitemap.xml\n";
diff --git a/nix/versions.nix b/nix/versions.nix
index 093d7fe..f74ad3b 100644
--- a/nix/versions.nix
+++ b/nix/versions.nix
@@ -99 +98,0 @@ in {
-
diff --git a/posts/introducing-niccup.md b/posts/introducing-niccup.md
index d023363..cdab009 100644
--- a/posts/introducing-niccup.md
+++ b/posts/introducing-niccup.md
@@ -21 +21 @@ That's it. Nix data structures in, HTML out. Zero dependencies. Works with flake
-[Source Code](https://github.com/embedding-shapes/niccup) | [Website/Docs](https://embedding-shapes.github.io/niccup/) | [Introduction Blog Post](https://embedding-shapes.github.io/introducing-niccup/)
+[Source Code](https://github.com/embedding-shapes/niccup) | [Website/Docs](https://emsh.cat/niccup/) | [Introduction Blog Post](https://emsh.cat/introducing-niccup/)2026-01-23 45649fa Update bsky username
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 33f7545..b2d34a4 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -147 +147 @@ in {
- [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/embedding-shapes.bsky.social"; } "embedding-shapes.bsky.social" ] ]
+ [ "li" "Bluesky: " [ "a" { href = "https://bsky.app/profile/emsh.cat"; } "@emsh.cat" ] ]2026-01-22 35d3187 Change canonical domain for blog, now we're emsh.cat :)
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index 4168887..33f7545 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -5 +5 @@ let
- siteUrl = "https://embedding-shapes.github.io";
+ siteUrl = "https://emsh.cat";
@@ -96,0 +97,2 @@ let
+
+ canonicalHref = if path != null then "${siteUrl}${path}" else null;
@@ -102,0 +105 @@ let
+ (lib.optional (canonicalHref != null) [ "link" { rel = "canonical"; href = canonicalHref; } ])
@@ -153,0 +157 @@ in {
+ path = "/${post.slug}/";2026-01-21 4319ddd Add CNAME file
diff --git a/CNAME b/CNAME
new file mode 100644
index 0000000..a2fbca1
--- /dev/null
+++ b/CNAME
@@ -0,0 +1 @@
+emsh.cat2026-01-21 f1e1972 Add Atom/RSS feed
diff --git a/nix/site.nix b/nix/site.nix
index 9ad3a77..e195a83 100644
--- a/nix/site.nix
+++ b/nix/site.nix
@@ -9,0 +10,2 @@ let
+ rssXml = pkgs.writeText "rss.xml" (ui.renderRssFeed { posts = site.posts; });
+ atomXml = pkgs.writeText "atom.xml" (ui.renderAtomFeed { posts = site.posts; });
@@ -18,0 +21,2 @@ in {
+ cp ${rssXml} $out/rss.xml
+ cp ${atomXml} $out/atom.xml
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index affea54..4168887 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -3,0 +4,49 @@ let
+ siteTitle = "embedding-shapes";
+ siteUrl = "https://embedding-shapes.github.io";
+ siteDescription = "Welcome to my blog. I write about technology, Nix, and other topics.";
+
+ homeUrl = "${siteUrl}/";
+ postUrl = slug: "${siteUrl}/${slug}/";
+
+ xmlHeader = encoding: ''<?xml version="1.0" encoding="${encoding}"?>'';
+
+ isoDateToRfc3339 = date: "${date}T00:00:00Z";
+ isoDateToRfc822 = date:
+ let
+ year = builtins.substring 0 4 date;
+ monthNum = builtins.substring 5 2 date;
+ day = builtins.substring 8 2 date;
+ monthMap = {
+ "01" = "Jan"; "02" = "Feb"; "03" = "Mar"; "04" = "Apr";
+ "05" = "May"; "06" = "Jun"; "07" = "Jul"; "08" = "Aug";
+ "09" = "Sep"; "10" = "Oct"; "11" = "Nov"; "12" = "Dec";
+ };
+ month = monthMap.${monthNum} or monthNum;
+ in "${day} ${month} ${year} 00:00:00 +0000";
+
+ feedMaxItems = 20;
+
+ mkFeedModel = posts:
+ let
+ feedPosts = lib.take feedMaxItems posts;
+ entries = map (post: {
+ inherit (post) title date body;
+ url = postUrl post.slug;
+ }) feedPosts;
+ latestEntry = lib.findFirst (e: e.date != null) null entries;
+ latestDate = if latestEntry != null then latestEntry.date else null;
+ in { inherit entries latestDate; };
+
+ xmlEscape = s: builtins.replaceStrings
+ [ "&" "<" ">" "\"" "'" ]
+ [ "&" "<" ">" """ "'" ]
+ (builtins.toString s);
+
+ xmlAttrs = attrs: builtins.concatStringsSep "" (lib.mapAttrsToList (k: v: " ${k}=\"${xmlEscape v}\"") attrs);
+
+ xmlLink = { attrs ? {}, content ? null }:
+ let renderedAttrs = xmlAttrs attrs;
+ in if content == null
+ then h.raw "<link${renderedAttrs} />\n"
+ else h.raw "<link${renderedAttrs}>${xmlEscape content}</link>\n";
+
@@ -19 +68 @@ let
- [ "a" { href = "/"; } "embedding-shapes" ]
+ [ "a" { href = "/"; } siteTitle ]
@@ -56,0 +106,2 @@ let
+ [ "link" { rel = "alternate"; type = "application/rss+xml"; title = "${siteTitle} RSS"; href = "/rss.xml"; } ]
+ [ "link" { rel = "alternate"; type = "application/atom+xml"; title = "${siteTitle} Atom"; href = "/atom.xml"; } ]
@@ -68 +119 @@ in {
- title = "embedding-shapes";
+ title = siteTitle;
@@ -71 +122 @@ in {
- [ "p" { class = "intro"; } "Welcome to my blog. I write about technology, Nix, and other topics." ]
+ [ "p" { class = "intro"; } siteDescription ]
@@ -109,0 +161,46 @@ in {
+
+ renderRssFeed = { posts }:
+ let
+ m = mkFeedModel posts;
+ lastBuildDate = if m.latestDate != null then isoDateToRfc822 m.latestDate else null;
+ in (xmlHeader "UTF-8") + "\n" + (h.render [
+ "rss" { version = "2.0"; }
+ [ "channel"
+ [ "title" siteTitle ]
+ (xmlLink { content = homeUrl; })
+ [ "description" siteDescription ]
+ [ "language" "en" ]
+ (lib.optional (lastBuildDate != null) [ "lastBuildDate" lastBuildDate ])
+ (map (e: [ "item"
+ [ "title" e.title ]
+ (xmlLink { content = e.url; })
+ [ "guid" { isPermaLink = "true"; } e.url ]
+ (lib.optional (e.date != null) [ "pubDate" (isoDateToRfc822 e.date) ])
+ [ "description" e.body ]
+ ]) m.entries)
+ ]
+ ]);
+
+ renderAtomFeed = { posts }:
+ let
+ m = mkFeedModel posts;
+ feedUpdated = if m.latestDate != null then isoDateToRfc3339 m.latestDate else "1970-01-01T00:00:00Z";
+ in (xmlHeader "utf-8") + "\n" + (h.render [
+ "feed" { xmlns = "http://www.w3.org/2005/Atom"; }
+ [ "title" siteTitle ]
+ [ "id" homeUrl ]
+ (xmlLink { attrs = { href = homeUrl; }; })
+ (xmlLink { attrs = { rel = "self"; type = "application/atom+xml"; href = "${siteUrl}/atom.xml"; }; })
+ [ "updated" feedUpdated ]
+ (map (e:
+ let updated = if e.date != null then isoDateToRfc3339 e.date else feedUpdated;
+ in [ "entry"
+ [ "title" e.title ]
+ [ "id" e.url ]
+ (xmlLink { attrs = { href = e.url; }; })
+ [ "updated" updated ]
+ (lib.optional (e.date != null) [ "published" (isoDateToRfc3339 e.date) ])
+ [ "content" { type = "html"; } e.body ]
+ ]
+ ) m.entries)
+ ]);2026-01-21 6cdf79e Add Plausible
diff --git a/nix/site/presentation.nix b/nix/site/presentation.nix
index c191536..affea54 100644
--- a/nix/site/presentation.nix
+++ b/nix/site/presentation.nix
@@ -9,0 +10,8 @@ let
+ plausibleAnalytics = [
+ [ "script" { async = true; src = "https://plausible.io/js/pa-FG5K-GlhTzYQkb3KeYVzG.js"; } ]
+ [ "script" (h.raw ''
+ window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
+ plausible.init()
+ '') ]
+ ];
+
@@ -48,0 +57 @@ let
+ plausibleAnalytics2026-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"; } "@[email protected]" ] ]
- [ "li" "Email: " [ "a" { href = "mailto:[email protected]"; } "[email protected]" ] ]
- ]
- (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"; } "@[email protected]" ] ]
+ [ "li" "Email: " [ "a" { href = "mailto:[email protected]"; } "[email protected]" ] ]
+ ]
+ (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/&/&/g' -e 's/</</g' -e 's/>/>/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"; } "@[email protected]" ] ]
- [ "li" "Email: " [ "a" { href = "mailto:[email protected]"; } "[email protected]" ] ]
- ]
- (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"; } "@[email protected]" ] ]
+ [ "li" "Email: " [ "a" { href = "mailto:[email protected]"; } "[email protected]" ] ]
+ ]
+ (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/&/&/g' -e 's/</</g' -e 's/>/>/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/&/&/g' -e 's/</</g' -e 's/>/>/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/&/&/g' -e 's/</</g' -e 's/>/>/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"; } "@[email protected]" ] ]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:[email protected]"; } "[email protected]" ] ]
+ ]
+ ];
+ });
+
@@ -223,0 +237,2 @@
+ mkdir -p $out/about
+ cp ${aboutHtml} $out/about/index.html2026-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/&/&/g' -e 's/</</g' -e 's/>/>/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 of2026-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]
+
@@ -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><script>alert('xss')</script></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;
+}