No description
  • Astro 65%
  • JavaScript 18.3%
  • CSS 7.8%
  • Shell 5.9%
  • Nix 1.9%
  • Other 1.1%
Find a file
Ari Seyhun d89b39018b
Some checks failed
site / site (push) Failing after 56s
docs: the site describes hekla 0.7.1 and heklang 0.11.0
2026-09-22 15:36:31 +10:00
.claude/skills/upstream-sync docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
.forgejo/workflows feat: build and deploy the site as a nix flake 2026-09-10 19:51:29 +10:00
examples docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
public feat(highlight): subject and under are declarations, and index is claimed positionally 2026-09-20 02:48:32 +10:00
scripts chore(gate): a spelled count and a renamed path parameter both fail the build 2026-09-20 02:48:36 +10:00
src docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
.gitignore docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
astro.config.mjs feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
devenv.lock feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
devenv.nix feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
devenv.yaml feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
flake.lock docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
flake.nix feat: build and deploy the site as a nix flake 2026-09-10 19:51:29 +10:00
og.svg feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
package-lock.json feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
package.json feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00
README.md docs: the site describes hekla 0.7.1 and heklang 0.11.0 2026-09-22 15:36:31 +10:00
tsconfig.json feat: the hekla landing page and documentation site 2026-09-10 19:46:49 +10:00

hekla site

The Hekla landing page and documentation, one Astro project, built to a fully static site. Served from hekla.tqwewe.com.

Develop

Node comes from devenv, matching the other repositories in this stack:

devenv shell
npm install
npm run dev      # local dev server
npm run build    # check, build, index for search, then check the output
npm run preview  # serve the built site

npm run build is gated at both ends. scripts/check-docs.mjs runs before it and again over dist/ afterwards, and either one failing fails the build. See The docs gate below for why the second pass exists.

Where the code samples come from

Every .hk sample carrying a src= is read from a project that is checked and tested, not retyped. Two sources feed them, both read at build time by src/lib/read.mjs:

  • ../hekla/examples/orders for the landing page, the language page and most of the docs. hekla's own CI runs hekla check, hekla test and hekla openapi against it on every push, so a language change that breaks it breaks visibly rather than quietly rotting the site.
  • examples/ in this repository, for the projects the site describes but hekla does not ship: the Subscribe command on /dcb/, the waitlist that Getting started builds, the sealed record on /docs/personal-data/, and the four the demo on /agents/ replays. See examples/README.md for how to re-check them.

The rest of the fences are written into the page, so a shape with no project behind it is a claim nobody re-runs. Give a new one a project before a page asserts it.

The loader fails the build on a missing file, an out-of-range line number, a slice that cuts a declaration in half, and a sample of a body-bearing kind that carries no body at all. That last one is the truncation a brace count cannot see: it is what a signature returned on its own looks like. A silently empty code block is worse than a broken build.

In markdown, a fence pulls its body from disk:

```hek src="hekla/examples/orders/commands/place-order.hk" name=PlaceOrder
```

src alone takes the whole file; name= takes one declaration and the comment above it, anchor= takes an // ANCHOR: block, and lines=N-M takes a range.

src/lib/read.mjs is plain JavaScript so astro.config.mjs can import it too; src/lib/examples.ts re-exports it for .astro pages. One reader, so a page and a plugin cannot disagree.

Editing a sample in a sibling repository needs --force. Astro's content cache is keyed on the markdown file, and knows nothing about ../hekla. npm run build always passes --force; in dev, restart with astro dev --force.

hek check and hekla check are different rule sets. A program can pass the first and fail the second, so any project shown here is verified with hekla check, not just hek.

Capture command output with ../hekla/target/debug/hekla, not the hekla on PATH, which is usually an older release. Every transcript on the site is from the repo build.

Markdown pipeline

Astro 7 parses markdown with Sätteri, not unified, so the extensions in src/lib/md/ are mdast and hast plugins registered through markdown.processor. markdown.remarkPlugins would need a separate @astrojs/markdown-remark install and would swap out the Rust parser to regain an API this one already has. Do not go that way.

Plugin Phase Does
include-code.mjs mdast replaces a src= fence body from disk
callouts.mjs mdast > [!NOTE] becomes <aside class="callout">
heading-anchors.mjs hast a # link on h2 to h4
table-wrap.mjs hast wraps a table in a scroll container

Two ordering facts the plugins depend on. mdast finishes before Shiki, which is what lets include-code swap a fence body and still have it highlighted. And Astro appends its own heading-id plugin after these, which is why heading-anchors assigns the id itself rather than reading one: Astro keeps an id it finds, so the anchor's href and the slug in render().headings are the same string by construction.

heading-anchors is exported as a factory, because Sätteri resolves one per document. A slugger shared at module scope would carry its duplicate counter across every page.

The docs gate

scripts/check-docs.mjs fails the build when the docs and the source repositories disagree. It parses each fact two or three independent ways and compares the parses against each other, and every count asserts a floor, because the failure mode of a script like this is a regex that stops matching and reports "all 0 items present, pass".

Check Against
45 diagnostic codes heklang/src/diagnostic.rs: the enum, the slug() arms and Code::ALL
12 subcommands hekla/src/cli.rs, both directions
28 routes hekla/src/server.rs: the consts against route_table()
32 hard, 20 soft keywords heklang/src/lex.rs and at_soft in parse.rs
house style no dashes, and no -- outside code
frontmatter no unquoted ": " in a YAML value

Run over dist/ with --dist it adds three more: every markdown file produced exactly one page, every page has content, and nothing ships a dash outside code.

Those last three are not belt and braces. Astro reports a failed render and then ships the page with an empty body, exit 0, and a frontmatter error drops the document entirely the same way. A docs page containing nothing is the one failure that looks like success from the build log.

The dash check runs twice for a related reason: Sätteri applies smart punctuation, so -- in a source file ships as an en dash. Only the dist pass sees what actually goes out. Turning smart punctuation off would be the wrong fix; the curly quotes are wanted.

Two things are asserted by absence. cli-hekla.md says hekla fmt and hekla lsp do not exist, and the script checks that they still do not, so if either ever ships, the page claiming otherwise is what fails.

Keeping up with hekla and heklang

flake.lock is the record of what these pages describe. Its hekla and heklang revisions are the trees the samples were read from and the prose was written against, so git log <locked rev>..HEAD in either repository is exactly the delta nobody has folded in yet. nix flake update hekla heklang is what makes a refresh the new baseline, which is why it is a commit rather than background drift.

.claude/skills/upstream-sync/ is that pass written down: which upstream document maps onto which page, the version strings that need changing with it, and the last sync's date and revisions.

Syntax highlighting

Shiki needs a TextMate grammar and heklang ships a tree-sitter one, so src/lib/hek.tmLanguage.json is written for this site, transcribed from heklang/tree-sitter-hek/queries/highlights.scm and grammar.js. It is loaded by astro.config.mjs for markdown fences and passed directly to every <Code /> component.

src/lib/shiki.mjs holds the two themes. They are the site's own palette rather than a vendored theme, so code and page share one set of colours. Six hues carry meaning: sulfur for a declaration, ice for an event path, violet for an annotation, sage for a string, clay for a number, and rose for a call that reaches the world.

Both files are worth upstreaming into heklang eventually.

public/hek.tmLanguage.json is the same grammar, served at https://hekla.tqwewe.com/hek.tmLanguage.json for editors with no tree-sitter (VS Code, Sublime). It is a copy rather than a build step, and check-docs.mjs fails if the two diverge, so what those editors get is exactly what this site renders.

Docs shell

src/content/docs/ is flat. The sidebar groups by the group frontmatter field, ordered by the hard-coded GROUPS list in src/lib/nav.ts, which throws on a group not in it rather than quietly sorting it to the end.

Toc.astro renders the third column from render().headings, at xl and up, and only when a page has three or more. Below that the column is removed as well, or the empty rail shoves the text off centre.

Its scroll-spy guards two things that break naive versions: IntersectionObserver only reports headings whose visibility changed, so the visible set is kept rather than rebuilt per batch; and a section taller than the observation band leaves it empty, so the highlight is never cleared when nothing intersects.

Search is a custom interface over the Pagefind API in Search.astro, not the bundled pagefind-ui, which carries its own palette. It is a native <dialog>, so the top layer, the backdrop, the inert page, escape-to-close and focus restoration all come for free.

/pagefind/pagefind.js does not exist until pagefind runs over dist, so the import holds its specifier in a variable and carries /* @vite-ignore */. Both halves are needed, or the build fails resolving it. Under astro dev there is no index at all and the dialog says so rather than throwing.

The agent demo

/agents/ shows an agent writing a project, and it is a recording rather than a model. Nothing on the page calls an API. Each scenario names a real project under examples/agents/, and AgentDemo.astro reveals its already-highlighted source a character at a time by slicing text nodes, so the typing keeps its syntax colours.

The page says it is a recording, in the copy, next to the demo. Keep that line.

Three things the animation has to keep doing:

  • Time-based, not frame-based. A 120Hz display would otherwise type twice as fast as a 60Hz one.
  • Never blocked on a frame. requestAnimationFrame is raced against a timer, because a background tab produces no frames and would otherwise leave a half-typed panel.
  • Complete without JavaScript. The first scenario renders in full from the server, and the script only empties it once someone asks to watch it.

A scenario is addressable, so /agents/#receipt opens the page on that one.

Copy buttons

CopyButtons.astro adds one to every code block on the site, from a single script. A panel that renders a caption row marks it data-code-head and the button sits there; a bare block (a markdown fence in the docs) gets one floated over its corner.

The same script owns two other page-wide behaviours, because it is already the one place that touches every block: a heading anchor copies its absolute URL on click, and a .table-wrap gets tabindex and a role only when it actually scrolls, so a table that fits does not become a pointless tab stop.

Two details worth keeping:

  • The text is captured at load, not read when the button is pressed. The agent demo reveals its code a character at a time, so a late read would copy a half-written file.
  • A terminal copies its commands only. It carries data-copy-text holding the $ lines with the prompt stripped, because copying a session's output gives you something you cannot paste.

The styles live in global.css rather than in the component, because Astro scopes a component's <style> and these buttons are appended into other components.

install.sh

public/install.sh is served at https://hekla.tqwewe.com/install.sh:

curl -fsSL https://hekla.tqwewe.com/install.sh | sh              # hekla
curl -fsSL https://hekla.tqwewe.com/install.sh | sh -s -- hek    # hek
curl -fsSL https://hekla.tqwewe.com/install.sh | sh -s -- hekla hek

It resolves the newest vX.Y.Z release through the Forgejo API, downloads the archive for the detected architecture, verifies the published .sha256, and installs into /usr/local/bin (via sudo if needed, falling back to ~/.local/bin). Overrides: HEKLA_VERSION, HEK_VERSION, HEKLA_INSTALL_DIR, HEKLA_LIBC.

It is modelled on tephra/site/public/install.sh, and the two should stay close enough to read as one thing.

Coverage is Linux only: {x86_64,aarch64}-unknown-linux-{gnu,musl}, which is what both release workflows build. There is no macOS or Windows artefact, so the script refuses with a message pointing at cargo install. Adding Darwin would mean a macOS runner; until then every page that shows the installer also shows the cargo line beside it.

The heklang repository tags three crates (v0.5.4 for the hek CLI, heklang-v0.11.0, tree-sitter-hek-v0.6.3), and only the bare vX.Y.Z tags carry binaries. The version filter depends on that.

Design

Instrument Serif for display, Geist Sans for text, Geist Mono for code, all self-hosted through @fontsource, no CDN. The palette is a warm near-black ground with one sulfur accent, defined once as custom properties in src/styles/global.css and exposed to Tailwind through @theme inline, so no utility needs a dark: variant.

The full light palette is on :root; dark redefines the same tokens under both prefers-color-scheme and an explicit [data-theme], so the system default and the toggle both work.

In prose, .prose takes the full column and each text element takes the 42rem measure back individually, so a table or a code block can use the width the paragraphs do not need.

Two rules keep the page from scrolling sideways on a phone, and both are easy to undo by accident:

  • .grid > * { min-width: 0 }. A grid item defaults to min-width: auto, so its track is sized to the widest thing inside it. One long code line then widens the whole page.
  • A scroll container must be position: relative. A static one is not a containing block, so an absolutely positioned descendant escapes it. The sr-only cells inside the capability matrix did exactly that and stretched the landing page by 229px.

House style

Dense, declarative, technically specific. The rules that are checked mechanically:

  • No em dashes and no en dashes standing in for them, in source or in output.
  • British spelling throughout.
  • No claiming a feature that is not built. Cross-check against hekla/ROADMAP.md under "Deferred, with triggers" before describing anything as working. In particular there is no auth, no hot reload, no SDK, no hekla fmt, no language server, no event versioning, no filter spanning two indexes, no automatic dead-lettering and no offline /docs.
  • No framing a feature by what the project was like before it existed. Everything here is pre-1.0; describe it as though it has always worked this way.
  • No benchmark numbers. The figures in hekla/ROADMAP.md mostly predate the port to heklang and describe code that no longer exists. If numbers go on the site later they get re-measured and printed with their hardware.

Deploying

flake.nix builds the site to a directory of static files:

nix build .#site      # result/ is what nginx serves

zion takes this repository as a flake input and points nginx's root at that store path, so a deploy is an ordinary colmena apply. Nothing is uploaded: no deploy user, no key, no release directory, no CI secret. Rolling back is rolling back the generation, and the version serving traffic is a property of zion's flake.lock rather than of whatever last won a race to upload. See modules/services/hekla-site.nix there.

The flake pins hekla and heklang as inputs, so a build is reproducible down to the code samples, and refreshing the docs against a newer runtime is nix flake update hekla here: a commit rather than a background drift.

HEKLA_SOURCES is how the sandbox finds them. The reader normally walks up from this directory to the sibling checkouts; in a build there is nothing above the source, so the derivation hands over a link farm holding hekla, heklang and site under exactly the names the sample paths use.

That pinning is also why CI still exists. .forgejo/workflows/site.yaml checks out all three repositories at main and runs the same npm run build, so drift between the docs and the two source repositories is caught on every push, rather than lying dormant in the lock file until someone updates it. It does not deploy.

The demo

/demo/ sends people to demo.hekla.tqwewe.com, which is a real hekla serve on zion running examples/courses from this repository, with the console that ships inside the binary. modules/services/hekla-demo.nix there is the whole deployment.

Why courses and not one of the others: only an effect calls out, and it declares none. effect::start_all spawns no pool for such a project, and a command's host is built with no HTTP client to hand it, so the process has no reachable outbound network at all. That is structural rather than configuration. And every command input is a Uuid, an Int or a CourseTitle variant, so there is no free-text field on a shared instance for one visitor to leave something in front of the next.

The reset is a systemd timer restarting the unit every thirty minutes. ExecStartPre deletes the data directory and ExecStartPost runs scripts/demo/seed.sh, so a reset is an ordinary restart rather than a script with a rm -rf in it. Booting from an empty directory takes milliseconds, because a projector with no log to read stamps its definition hash rather than replaying.

scripts/demo/traffic.sh is a second unit that keeps the instance moving, subscribing and unsubscribing among six courses it owns. It never touches the six the seed reserves for visitors, so the generator can never be what filled one of those. The demo page says the traffic is generated, next to the link, for the same reason /agents/ says it is a recording.

How full the generator runs its courses is set by how many students it has, not by their capacities: each course is an independent birth-death chain whose stationary distribution is Binomial(students, 1/2) truncated at capacity. scripts/demo/lib.sh carries the invariant that falls out of that, which is that every generator capacity must stay above half the student count.

That page reads the course ids, capacities and student ids out of scripts/demo/lib.sh at build time rather than restating them, so it cannot describe an instance that does not exist. The three arrays it reads are literals for that reason, and it asserts the shape of every row: the parse stops at the first ), so a stray comment inside one of those arrays would otherwise reach the page as a card reading "seats NaN" rather than failing the build.

Nothing about the instance is authenticated, exactly as the documentation says. nginx allows every GET and POST only to /commands/, which is what refuses /projectors/{name}/replay and /effects/{name}/skip/{position} without naming either. GET /metrics is denied by name on top of that, being an operator surface rather than a visitor one. The refusal is a JSON error envelope rather than nginx HTML, so the console renders it as a message instead of a blank failure.

Not built yet

  • Published editor extensions. /docs/editor-setup/ documents a local install for each editor; nothing is on the VS Code Marketplace, the Zed registry, Helix's default language list or nvim-treesitter upstream, and tree-sitter-hek is on crates.io but not npm.

  • The OG image is generated from og.svg in this directory, and it sets DejaVu rather than the site's own faces because the woff2 files it self-hosts cannot be rasterised directly.

    magick -density 144 og.svg -resize 1200x630 -depth 8 -strip PNG24:public/og.png