Stati 2 started as four bug reports

I filed four bug reports about Stati's templates in one afternoon, each with a fix. Two hours later I threw the fixes away and broke the template API instead.

Cache: 2/2 hits (100.0%), says the build, and /a/shallow still says A v1. I changed it to A v2 in site/a/_partials/box.eta, the section's own partial, which overrides the one in site/_partials/. Stati 1 renders the section's file, but its cache recorded the root one as the page's dependency. As far as the cache knows, nothing changed, and the old page stays until its cache time runs out.

That was one of five bug reports I filed against Stati's templates in one afternoon. The one that only happened on Windows went out as a patch that evening. The other four came with a suggested fix each, and two hours after filing them I opened a sixth issue that replaced all four fixes with a breaking change. That issue became most of Stati 2.0.

The cache was guessing

A cached page in Stati 1 kept a list of the templates it depended on, and Stati made that list by reading the templates with regular expressions: find the include() and layout() calls and the stati.partials reads, then resolve each name to a file. The renderer resolved names its own way. Three of the bug reports were places where the two disagreed, and the cache was confidently wrong:

  • A base layout outside an _ folder was found, then dropped from the list.
  • A layout() call with a data argument, or after other code in its tag, wasn't found at all, and a path relative to the calling template was resolved from site/ instead.
  • The box.eta one: partials were looked up from the layout's folder instead of the page's.

Each fix was reasonable on its own: a better pattern, Eta's path rules, the page's partial map. Together they would have made the parser a closer copy of the renderer, and it still couldn't read stati.partials[`icon-${name}`], because no regular expression knows what name is. I had fixed the same kind of disagreement for layouts four days earlier. Two lookups for one name drift apart, however often you patch them.

So Stati 2 stops guessing. While a page renders, Stati records every template it rendered and every name it looked up, including the lookups that found nothing, and that record goes into the cache. The page rebuilds when a recorded template changes or disappears, or when a name it looked up would now find a different file, say because you added a section override. Nothing reads templates with regular expressions anymore.

That only works if every template comes in through Stati, so templates are included by path, through one function:

eta
<!-- site/layout.eta in Stati 1: partials by name -->
<%~ stati.partials.header %>
<%~ stati.partials.card({ title: stati.page.title }) %>

<!-- Stati 2: the same templates by their path from site/, without .eta -->
<%~ stati.include('_partials/header') %>
<%~ stati.include('_components/cards/card', { title: stati.page.title }) %>

A name is looked up from the page's folder up to site/, nearest first, as partials were, so a section still overrides a template by having its own at the same path. Templates don't need underscore folders anymore either, and an underscore now only means "no pages in here".

Every partial, three times per page

The fourth bug report wasn't about the cache. In Stati 1, stati.partials.header was something you print, so it had to exist before the layout started, and partials could print other partials. Stati handled that by rendering every partial in scope, in passes, before each page, and the check meant to end the passes early never fired on a working site. Three passes, every partial, used or not. Two partials, three pages and a layout that prints neither of them came to nine renders per partial. I counted them with a filter that adds one to a counter.

Component partials needed <% if (!stati.props) { return; } %> as their first line to get through the passes cheaply, and partials that printed partials worked only three levels deep.

stati.include() renders a template when it's called, so a page renders its layout, the shells it names and the templates it includes. Nothing else. Stati's benchmark site shows what that's worth: 100 plain pages, and a folder of six components the plain pages never use. In Stati 1 every plain page rendered all six components three times, guard and all, before its layout ran. Medians from the benchmark workflow on the last 1.x pull request and on the 2.0 one:

Benchmark Last 1.x PR 2.0 PR
Full build, 101 pages 135 ms 55 ms
Full build with nested components, 111 pages 151 ms 58 ms
Warm build, every page from the cache 33 ms 35 ms
One page changed 37 ms 33 ms

CI runners are noisy, and the fastest 1.x runs I can find from that week are 100 and 96 ms, still well behind. The warm and incremental builds didn't move, because they render one page or none.

One shell for every layout

From the first concept doc, a Stati layout was a whole HTML page in one file. I liked that. It also meant that a section's layout.eta replaced the root's instead of wrapping it, so every layout carried its own head, header and footer. The docs starter and Stati's own docs site both had a layout.eta and a home.eta that differed in the body class and nothing else.

Eta's own layout() could share a shell, and the first two bugs on that list are what happened to the sites that used it. Stati 2 has stati.layout(): a layout names its shell, and the shell prints stati.body where the layout's output goes.

eta
<!-- site/base.eta, the only complete document -->
<!DOCTYPE html>
<html lang="en">
  <%~ stati.include('_partials/head') %>
  <body>
    <%~ stati.include('_partials/header') %>
    <%~ stati.body %>
    <%~ stati.include('_partials/footer') %>
  </body>
</html>
eta
<!-- site/blog/layout.eta: wrap this layout in base.eta -->
<% stati.layout('base') %>
<main class="post"><%~ stati.content %></main>

Eta still compiles its own include(), layout() and block() into every template, and they resolve names their own way, skip the dev server's checks and never reach the record. So 2.0 blocks them. A small Eta plugin swaps them for functions that name the Stati call to use:

text
Eta's layout() is not available in Stati templates. Use stati.layout('./base', data) instead; the data arrives as stati.props.

The plugin finds Eta's functions by the exact code Eta generates for them, and if a newer Eta generates something else, Stati refuses to create the engine rather than let Eta's include() back in quietly. That's why @stati/core pins Eta to exactly 4.6.0.

Filters become helpers

The functions a site gave its templates were called filters. You registered them under eta.filters, and Stati spread them into the root of stati, among its own keys. A filter named page replaced the page, and one named image lost to Stati's. The dev server couldn't tell your function from a typo, and neither could the types. Even the name was off: Eta has a filter option of its own, and this wasn't it.

Now they're helpers, a top-level config option, and templates call stati.helpers.formatDate(). The root of stati is Stati's, helpers is yours, and the dev server knows which is which:

text
Unknown property "stati.formatDate". Did you mean "stati.helpers.formatDate"?

Why it all ships at once

In April I wrote that every breaking change would cost me a long think and a changelog entry you can act on in a minute. 2.0 is the first major version since then.

I could have added stati.include() next to stati.partials in a 1.30, deprecated the old one and removed it in 2.0. That's two upgrades for you, and compatibility code for me that exists only to be deleted. There's no codemod either. The upgrade guide has a before and after table for every call, and the build and the dev server's error overlay point at each one you missed. Every docs page was checked against the code before the release, and 66 places were wrong.

The dependencies came along for the same reason. A week before, I had updated everything that could move without touching your site, and opened an issue for each one that couldn't. Eta 4 could change how templates render. markdown-it 15 changes what becomes a link: www.example.com without a scheme isn't one anymore, and [email protected] is, as a mailto: link, because it's shaped like an email address. Either would have needed a major version of its own.

Others were about npm audit. gray-matter, which read the front matter, hasn't been published since 2023 and brings js-yaml 3 and sprintf-js with their advisories. An overrides entry in Stati's package.json doesn't reach your project, so Stati splits the front matter itself now and parses it with js-yaml 4. On 354 Markdown files, Stati's own, the examples' and this blog's, it reads the same data and body as gray-matter, with two exceptions on purpose. Front matter marked ---js is an error instead of JavaScript that runs, and a file with broken front matter no longer "builds" on the second try with the front matter printed in the page, which gray-matter's cache made possible. YAML 1.2 also reads time: 10:30 as text. YAML 1.1 read it as the number 630, in base 60.

Tailwind 3 has advisories that only Tailwind 4 fixes, so the CLI runs both now and new sites get 4. The docs site moved by hand, because the official upgrade tool rewrote every component into @utility blocks, and headless Chrome found 178 of its 184 page views pixel-identical afterwards, light and dark, at two widths.

A pipe nobody writes to

One fix in 2.0 has nothing to do with templates. stati dev --tailwind-input started Tailwind's watcher with the dev server's own stdin, and Tailwind ends --watch when its stdin closes. In a terminal it never does. In a CI job, or the background shell a coding agent runs the dev server in, stdin is closed from the start, so Tailwind quit at once with exit code 0 and the CSS quietly went stale.

--watch=always turns that off, and I tried it: kill the dev server with taskkill /F and the watcher stays behind. So Tailwind gets a pipe as its stdin now, which the CLI holds open and never writes to. However the dev server ends, the pipe closes and Tailwind goes with it.

Left for later

markdown-it-prism, the docs starter's highlighter, still caps markdown-it below 15, so npm installs a second, older markdown-it just for the plugin. Version 5 lifts the cap and is in review upstream. TypeScript 7 waits until typescript-eslint supports it.

And this blog still runs Stati 1.29, with 106 stati.partials calls in 26 of its 34 templates.