- Astro 65%
- JavaScript 18.3%
- CSS 7.8%
- Shell 5.9%
- Nix 1.9%
- Other 1.1%
|
|
||
|---|---|---|
| .claude/skills/upstream-sync | ||
| .forgejo/workflows | ||
| examples | ||
| public | ||
| scripts | ||
| src | ||
| .gitignore | ||
| astro.config.mjs | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| og.svg | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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/ordersfor the landing page, the language page and most of the docs. hekla's own CI runshekla check,hekla testandhekla openapiagainst 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: theSubscribecommand on/dcb/, the waitlist that Getting started builds, the sealed record on/docs/personal-data/, and the four the demo on/agents/replays. Seeexamples/README.mdfor 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.
requestAnimationFrameis 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-textholding 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 tomin-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. Thesr-onlycells 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.mdunder "Deferred, with triggers" before describing anything as working. In particular there is no auth, no hot reload, no SDK, nohekla 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.mdmostly 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 ornvim-treesitterupstream, andtree-sitter-hekis on crates.io but not npm. -
The OG image is generated from
og.svgin 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