bongo, a static site generator I kept rewriting
bongo started in 2020, was rewritten twice, and now builds this site. It is public today.
The first commit to bongo is from December 9, 2020. Its message is "initial commit". The second one is "oi vey".
That about sums up the next five years. bongo has been a build script, a published package, an orphan branch called rewrite, and a folder called archive/. It built a few of my sites along the way, and I never told anyone about it, because it was never the version I wanted to tell people about.
This is that version. It builds this site, and it is public today.
#Why another one
There is no shortage of static site generators. Most of them fall into two groups, and I wanted neither.
The first group is the framework group. You write components, the framework renders them on the server, and then it ships a runtime so the components can wake up in the browser. That is the right tool for an app. For a blog, it is a lot of machinery to print some HTML.
The second group is the template group. These are fast and simple, but asset handling is an afterthought. You end up with a separate bundler, a separate CSS pipeline, and a config file whose job is to glue them together.
I wanted the second group with the asset story of the first. Write HTML templates. Point at a script or a stylesheet. Get a bundled, hashed, minified file, and a page that links to it. No glue.
#What it is
bongo is a static site generator. It renders templates with a fork of tempura, bundles scripts with Rolldown, and builds styles with LightningCSS. A project is one config file and a few folders:
assets/ scripts, styles, fonts
content/ pages: Markdown, HTML, or .hbs
data/ JSON, YAML, or modules, as $data
layouts/ page shells
partials/ reusable pieces, in .hbs, .js, or .ts
bongo.config.ts
The path of a content file is its URL. content/blog/bongo.md is /blog/bongo/. There is no route table to keep in sync, because content is not code. (Routes in an app are a different story, and I have opinions about that.)
Two commands do everything:
bongo dev
bongo build --minify
bongo runs in Bun, Deno, and Node 24 or later. Image work needs Bun, because it uses Bun.Image. Without Bun, images are copied as they are.
#Assets are elements
This is the part I care about most. A layout asks for an asset with an element:
<bongo:style src="phosphor/core.css"></bongo:style>
<bongo:style src="phosphor/{{sheet}}.css"></bongo:style>
<bongo:script src="root.ts" defer></bongo:script>
<bongo:script src="phosphor/main.ts"></bongo:script>
Those four lines are from the <head> of this site. bongo finds each element after the page renders, groups them, and bundles each group once. Then it puts the real <link> or <script> back in place, with a content hash in the file name. The {{ sheet }} is a normal template value, so each kind of page gets its own stylesheet with no extra config.
The output tells you what it made, with sizes:
type.D48w-oFq.js 1.37 kB │ 735 B
visual.b87b14c5.css 9.12 kB │ 2.61 kB
I like that a build prints the gzip size of each file. If a page gets heavy, I see it in the terminal, before a user feels it.
#Templates that are just JavaScript
Templates use Handlebars syntax, but the expressions are JavaScript. There are no helpers to register for a filter or a sort. This is the top of the blog index on this site:
{{#var posts = $section.filter(p => p.path !== $page.path).sort((a, b) => String(b.params.date || '').localeCompare(String(a.params.date || '')))}}
A partial can be a .hbs file or a JavaScript module. The module form is a function that returns a string. This site uses one to turn a post's accent color into a hue for its theme:
{{#partial src="phosphor-hue" hex=$page.params.accent }}
The partial is a plain function in partials/phosphor-hue.mjs. It parses the hex, converts it to OKLCH, and returns a number. You can test it with bun test, because it is not a template. It is just a function.
#Content that is not HTML
A site is more than its pages. This one has a sitemap, a feed, a search index, and a social card for each page.
An output file is a template with no layout. content/sitemap.xml.hbs writes /sitemap.xml, and it can read every page from $pages.
A data module is a script that writes JSON. content/search.json.ts writes /search.json, which drives the : command line on this site:
import type { Build } from "bongo";
export default ({ $pages }: Build.Data): Entry[] => {
let posts = $pages
.filter((page) => page.section === "blog" && page.path !== "/blog/")
.sort((a, b) => String(b.params.date || "").localeCompare(String(a.params.date || "")));
// ...
return [
...posts.map((page) => ({
label: page.title,
hint: `writing · ${page.params.kind}`,
href: page.path,
})),
...pkgs.map((page) => ({
label: page.title,
hint: `oss · ${page.params.language || "other"}`,
href: page.path,
})),
];
};
It runs once every page is known, so it never reads a source file twice. It has types, so my editor knows what $pages holds.
bongo can also draw a partial as an image, with $assets.render. That is how you get an Open Graph card per page with no screenshot service.
#Pages for agents too
A growing share of readers are not people. They are agents that fetch a page and read it.
HTML is a poor format for them. So bongo can write a Markdown copy of each page next to its HTML. One element in the layout turns it on:
<bongo:alternate type="text/markdown" />
For /config/, that writes /config.md, and it puts a <link rel="alternate"> in the page so tools can find the copy. An llms.txt is then a small output file that lists those copies. The bongo docs site has both.
I think of this as the same job as a sitemap, ten years later. I wrote more about designing for agents in APIs for agents and humans.
#Builds that skip work
A static site should build fast. Most of the work in this version went into that.
bongo keeps a record of its last good build. On the next build, it hashes only the source files whose size or modified time changed. Then it renders only the pages that a change reaches. It knows that a post links a stylesheet, and that the stylesheet imports another file. It also knows that the blog index reads the titles of all posts. Change one title and the index renders again. Change a sentence in the body and it does not.
It is careful about what it does not know, too. A hook that reads the clock, or a loader that opens its own file, is out of its sight. The README says so, and --fresh renders everything.
This site has about 300 pages. On my machine, a full build with --fresh takes about half a second.
#The rewrites
The git history has three eras, and each one left a folder in archive/.
The first, from 2020 into 2021, was a build script and a runtime. A later version was published as @lukeed/bongo. In December 2022, I rebuilt the core and inlined tempura. In 2023 and 2024, a rewrite lived on its own orphan branch. Most of the 372 commits in the repo are from 2026, when that work became packages/bongo.
I kept the old versions on purpose. It is useful to see the same idea three times, written by three slightly different versions of me. The problems did not change: assets, templates, and speed. The answers got smaller and more boring each time, and that is how I knew this one was close.
#The docs came first
The bongo README is long, and that is on purpose. It explains what a build deletes, what it keeps, and when it refuses to touch a folder. It explains what an incremental build cannot see. If a behavior is hard to explain in plain words, it is usually the behavior that needs to change, not the words.
That is how I work now, and I wrote about why in README-driven design.
#Try it
This site is the best example I have. It has a CRT theme, a canvas scene per page, a command line, a feed, and a page for each of my packages. It is all templates, a few TypeScript files, and one config. View the source of any page, and you can see what bongo wrote.
bongo is not small. I said so in Small on purpose, and it is still true. But it is made of parts that do one job each, and it does what I wanted in 2020: you write HTML, you point at your assets, and you get a site.