Engineering // Engineering 01 Technical

How the site was built: Zola, Tera components, Tailwind and a content API

Published 2026-09-14 · 8 min read

What the build has to deliver

Catharsis as a Service™ is two things at once: an artwork (a poster and a mock “emotional state transition endpoint”) and a bilingual evidence library about catharsis. The library makes psychological claims to a general audience, so the engineering had four requirements from the start:

  1. Every page in English and Czech, with identical structure, so neither edition can quietly fall behind.
  2. Every claim traceable to a dated, graded ledger entry and to sources checked against Crossref.
  3. Readable without JavaScript, fast on a phone, and correct under the repository subpath that GitHub Pages serves (/catharsis-as-a-service/).
  4. Nothing reported as done until it is live: deployment is complete only after the production URL has been tested.

All of it was built on 14 September 2026 in one working session, by an AI coding agent directed by one person. The case study shows the timeline; Supervised AI delivery describes how the work was directed. This article covers the result.

Source layout: content, data and strings

The repository separates four kinds of source, and each has one home:

ConcernLives inRead by
Pages and landing blockscontent/**/index.md and index.cs.mdZola
Structured recordsdata/*.toml: references, source appraisals, claims, glossary, command registryZola templates, validators, API exporter
UI strings[translations] in zola.tomltrans() in templates
Presentationtemplates/ and styles/app.cssZola, Tailwind

The rule behind the split is recorded in the repository as project.zola-source-of-truth: “the next artifact must be a Markdown file pair, not a template edit”. Templates never branch on language; they call trans(key=…, lang=lang), and the translation validator fails the build when a key exists in one dictionary but not the other, or when two editions of a page have a different front-matter shape.

The data files are the part most static sites do not have. A research note cites registry ids such as kjaervik-bushman-2024; the bibliography entry, its appraisal and the claims that rely on it live in data/. The template resolves ids at build time:

{%- set registry = load_data(path="data/references.toml") -%}
...
{%- set ref = registry.references[id] %}

Keeping bibliographic facts in data rather than in page bodies means one correction reaches every page that cites the source, and a validator can compare the whole registry with Crossref in one pass. The ledger design is described in The evidence ledger as code.

Zola 0.23.6 and Tera v2 components

Zola was chosen because it is a single static binary with built-in multilingual content, taxonomies, feeds, a sitemap and an internal link checker. There is no Node or Ruby runtime between Markdown and HTML, and the version is pinned in .zola-version; CI downloads that release and checks its SHA-256 digest before using it.

Version 0.23 ships Tera v2, which replaces macros with components: named, typed template functions with a JSX-like call syntax. The first checkpoint in the Majordomus record for this project notes the port explicitly (“Tera v2 port: macros replaced by components”). A component declares its parameters and their types:

{% component library.tags(tags: array, lang: string) %}
{%- if tags | length > 0 %}
<ul class="tag-list" aria-label="{{ trans(key='nav_tags', lang=lang) }}">

and a page template calls it like an element:

{{ <library.cards pages={section.pages} lang={lang} /> }}

Typed parameters matter more than they look. A missing or misspelled argument fails at build time instead of rendering an empty string, which is the failure mode that makes template bugs invisible in static sites. The components are grouped by feature rather than by HTML element: library (cards, key points, references, figures), evidence (kind labels, level and confidence badges, claim cards), discover (glossary terms and related reading), models, landing, artifact and posters. The page you are reading is rendered by templates/essay.html, the same template the research and theory essays use; this section only sets kind = "technical" so the page carries the TECHNICAL label instead of EMPIRICAL.

Styling: Tailwind CSS 4 and Flowbite, self-hosted

Tailwind 4 is configured in CSS rather than in a JavaScript config file. The entry point declares exactly which files the class scanner reads, loads the Flowbite plugin and defines the design tokens:

@import 'tailwindcss' source(none);

@source '../templates';
@source '../static/js/app.js';
@source '../node_modules/flowbite/lib/esm/components';

@plugin 'flowbite/plugin';

@theme {
  --color-ink: #050505;
  --color-bone: #e9e4d8;
  --color-blood: #ef2929;

source(none) turns off automatic detection, so a class only ships if it appears in a template, in the one script that toggles classes, or in the Flowbite components the markup uses. The tokens (ink, bone, blood; Anton, Barlow Condensed and JetBrains Mono) come from the poster, so the library and the artwork read as one object.

No asset is loaded from a CDN. Fonts are self-hosted with their OFL licences, and a 17-line script, scripts/vendor.mjs, copies the pinned Alpine and Flowbite bundles from node_modules into static/js/vendor/. The site therefore makes no third-party requests, which is also what lets the evidence ledger state that there are no analytics, trackers or cookies.

Behaviour: Alpine and Flowbite, never owners of content

Two small libraries provide interaction, with a strict division of labour written into the architecture rule: “One owner per piece of DOM state: Alpine or Flowbite, never both.”

  • Flowbite supplies mechanics that are easy to get wrong for accessibility: the modal, the drawer, tooltips and the accordion.
  • Alpine.js supplies local state: the view switch on the artifact page, the copy button, claim panels that open their sources, the interactive models and the search page.

Scripts are deferred and ordered so that page scripts register their Alpine components before Alpine starts:

<script src="{{ get_url(path='js/vendor/flowbite.min.js', cachebust=true) }}" defer></script>
<script src="{{ get_url(path='js/app.js', cachebust=true) }}" defer></script>
<script src="{{ get_url(path='js/vendor/alpine.min.js', cachebust=true) }}" defer></script>

Every piece of content is in the HTML. A claim card’s sources panel, for example, is hidden by an Alpine binding, and a .no-js rule keeps it open when scripts do not run. The browser suite loads pages with JavaScript disabled and checks that this holds.

The subpath problem

GitHub Pages serves a project site under /<repository>/. A hard-coded /assets/poster.png works on a developer machine and breaks in production. The project handles this in three places:

  • base_url in zola.toml is copied from the GitHub API, and a comment says so. The deploy job compares it with the URL that GitHub’s configure-pages action returns and stops if they differ.
  • Templates build every URL through get_url, resize_image or a permalink, never by string concatenation.
  • npm run preview serves the production build under http://127.0.0.1:4173/catharsis-as-a-service/, and the browser suite runs against that address, so a base-path mistake fails before a push rather than after it.

The content API and the Rust client

The library is also published as data. python3 scripts/export-api.py writes api/v1/ next to the site: an index, research and advice collections per language, references, the claims ledger, source appraisals, the glossary, the evidence changelog, the command registry and a search index per language. The exporter’s docstring states the constraint that makes it testable: “Output is deterministic: no timestamps, stable ordering.” A Python test exports twice and compares the bytes.

The Rust workspace in crates/ is the consumer. caas-content deserializes the export into types and checks the invariants other applications depend on; caas-cli is a command-line client:

let library = Library::load("public/api/v1")?;
assert!(library.problems().is_empty());

for claim in library.claims_due_by("2027-03-31") {
    println!("{} {} {}", claim.id, claim.claim_type, claim.review_due);
}

The point of a second language is independence. The Python exporter and the Rust types are written separately, so a field renamed on one side fails the contract tests on the other. CI exports the API of the commit being tested and runs cargo test --workspace against it, next to cargo clippy with warnings treated as errors. The CLI is runnable as cargo run -p caas-cli; its claims list --due-by command is a practical way to see which claims need review before a given date. The contract is documented in docs/RUST-INTEGRATION.md.

The pipeline end to end

npm run build is the short path: vendor the bundles, compile Tailwind, run Zola, export the API. npm run validate is the path that matters, a single Bash script, scripts/validate.sh, whose steps stop at the first failure:

step toolchain zola_version
step assets npm run --silent assets
step javascript js_syntax
step i18n python3 scripts/validate-i18n.py
step content python3 scripts/validate-content.py
step references python3 scripts/check-references.py
step claims python3 scripts/validate-claims.py
step social python3 scripts/render-social.py --check
step zola-check zola check --skip-external-links
step zola-build zola build --base-url "$BASE_URL"
step api python3 scripts/export-api.py --out public --base-url "$BASE_URL"
step html python3 scripts/validate-html.py public "$BASE_URL"

The first release ran seven of these stages; the others were added as the library grew (the history of validate.sh on GitHub shows when). Stages are ordered from cheapest to most expensive, so a missing translation fails in a second, before a full build.

On GitHub, pages.yml chains four stages, each gated on the previous one:

  1. CI, reused from ci.yml so that the gate before a deployment is the gate before a merge: conventional commits, Majordomus supervision, validate and build, Python tests, Rust, references against Crossref, and the browser suite on the subpath preview.
  2. Deploy uploads the site artifact produced by the validate job. The deployment is not a second build, so the files that were checked are the files that ship.
  3. Verify production waits until the live site serves the commit being deployed, then runs npm run smoke and npm run test:production against the real URL.
  4. Release publishes a GitHub Release. The version is computed from the Conventional Commit subjects since the last tag, and the notes link the pipeline run; scripts/release.sh --dry-run prints the same computation locally without publishing.

The wait in step 3 relies on the footer, which renders the commit SHA from the build environment:

for attempt in $(seq 1 40); do
  if curl -fsS "${BASE_URL}?revision=${GITHUB_SHA}" | grep -q "$GITHUB_SHA"; then
    echo "production serves $GITHUB_SHA (attempt $attempt)"
    exit 0
  fi
  sleep 15
done

Without it, the tests could pass against the previous deployment while the CDN still served old files. The same metadata feeds the generated status page.

What the architecture cost, and what it bought

The split between content, data and templates costs indirection. A contributor adding a research note has to know that its claims go in data/claims.toml, its references in data/references.toml with an appraisal in data/sources.toml, and its commands in data/commands.toml. The validators turn that knowledge into error messages with file and line, which is the only reason the split is workable for an AI agent and for people alike.

What it bought is visible in the records. The first deployment pipeline, run 34834554169, passed all seven jobs and published v0.1.0. By the time of writing, five more releases had followed the same day, each after a live verification. The two pipeline failures recorded that day stopped where the design says they should: one before deployment, when the Crossref check rejected a reference title, and one after deployment but before release, when a browser test failed against production. Both are discussed in Testing a static site, and the timing is in Time accounting.

References

  1. Sig Nihl (2026). catharsis-as-a-service: repository, Pages workflow runs and release history. GitHub. https://github.com/korczis/catharsis-as-a-service
  2. Majordomus (2026). Majordomus: A lightweight supervisory control layer for AI-assisted work (version 0.6.0). https://majordomus.dev