Tech · Note
A small static site generator
Part of the how this site is built series. This one covers the generator: about 900 lines of Node that turn a folder of markdown into the site. The idea is own the glue, borrow the hard parts. Anything with nasty edge cases is a library, and the code in between is small enough to read in one sitting.
Why not a framework
I tried MkDocs first and it looked like a docs portal. Astro or Eleventy would have worked, but a custom generator meant one renderer shared with the phone admin, no upgrade churn, and a build that does exactly what this site needs and nothing else. With Claude writing most of the glue, it was quicker than learning a framework’s way of doing things.
The libraries
| Job | Library | Notes |
|---|---|---|
| Markdown | markdown-it | Raw HTML off, smart quotes on, headings get ids for anchors. |
| Code highlighting | Shiki | one-dark-pro and one-light themes, rendered at build time so no client JS. |
| Images | sharp | WebP at 800 and 1600 px for body images; covers cropped to a 1800×640 banner, a 1200×630 social preview and a 128 px thumbnail. |
| Search | Pagefind | Indexes the built HTML; search runs in the browser with no server. |
| Frontmatter | js-yaml | CORE_SCHEMA, so dates stay as plain strings. |
Templates are plain JavaScript functions using a small html tagged template that escapes
everything by default. No template language to learn.
The content model
Every post is a folder:
content/
food/
egg-custard/
index.md
cover.jpg
tech/
how-this-site-is-built/
index.md
Frontmatter only needs four fields:
---
title: "Egg custard"
type: recipe # recipe or note: decides the layout
section: food # where it lives in the menu, and the URL
date: 2024-10-11
tags: [dessert] # optional, but must exist in taxonomy.yml
cover: cover.jpg # optional
---section and type are separate on purpose. A food reference sheet and a Linux how-to are both notes,
but they live in different sections. Recipes can also carry serves, prep, cook and diet, and the
recipe layout puts the ingredients beside the method on wide screens.
taxonomy.yml and the validator
taxonomy.yml lists every section, type and tag, each tag with a one-line description and optional
aliases. The validator checks every post against it before anything is written:
- required fields are there, and dates are real dates
- the section matches the folder the post is in
- the type exists, and type-specific fields only appear on that type
- every tag exists (aliases are rewritten to the real tag, with a warning)
- the cover file actually exists in the post folder
Any error stops the build. That is the point: tags cannot sprawl, and a typo never reaches the live site. The same validator runs in the phone admin before it saves.
What the build does
npm run build runs site/build.js, which:
- Loads
taxonomy.ymlandsite.ymland validates every post. Stops on the first batch of errors. - Renders each post: markdown to HTML, images resized and cached, cover crops made.
- Writes the home page, section pages, tag pages, a tag index, related posts, a 404 page, RSS
(
feed.xml),sitemap.xmlandrobots.txt. - Writes
_headersand_redirectsfor Cloudflare Pages (see the Pages post). - Builds the Pagefind search index over the finished HTML.
After a build, npm run check walks every page in dist/ and fails if any internal link, image or
#anchor does not resolve. Resized images are cached in .cache/, so a rebuild on my laptop takes about a second.
Commands
npm run validate # check content against taxonomy.yml
npm run build # validate, then build into dist/
npm run check # every internal link, image and anchor in dist/ resolves
npm test # unit tests (node --test)
npm run dev # build and serve dist/ on http://localhost:8080Setting up something similar
- Start a Node 22 project with
"type": "module"and installmarkdown-it,shiki,sharp,pagefindandjs-yaml. - Write a loader that finds
content/*/*/index.md, and splits frontmatter from the body with a regex plusjs-yaml. - Write the validator before the templates. A strict vocabulary file is the cheapest way to keep a site tidy as it grows.
- Render with markdown-it, plug Shiki in as its
highlightfunction, and resize images with sharp. - Write templates as functions that return strings, with escaping by default.
- Run Pagefind’s Node API over the output folder last.
- Add a link checker. It pays for itself the first time you rename a post.
I kept the markdown setup in its own module without Shiki (site/lib/markdown-base.js), so the
Cloudflare Worker behind the admin can import it without bundling every highlighting grammar.