Changelog
Notable changes to Zigapagos. The format follows Keep a Changelog; versioning is semver with the pre-1.0 caveat that a minor bump may break an API.
Two things to know before reading it:
This file starts at the first public release, deliberately. The repository carries 372 commits, but only the last two are this fork’s: the full history of the upstream generator it forks, then a Zig 0.16 port and one squashed commit for all of the fork’s own work. Splitting work that was never released into a per-version history would be invention, so this file does not do it. The subsystem specs in
docs/are the source of truth for what the code does; this file records when it changed, starting now.This repository does not carry the forked project’s release tags. A single fork-point tag,
fork-point-zine-v0-11-2, marks where the history diverges, and0.1.0was the first Zigapagos version number —build.zig.zondeclares the current one.zigapagos versionprints the stringbuild/config.zigstamped into the binary at build time, derived fromgit describe --match '*.*.*' --tagsagainst the tags that actually exist here, never from an inherited tag, because there are none to describe against. It has exactly three shapes:v0.2.0— the build sat on a release tag, so describe’s output is used verbatim.v0.2.0-dev.7+e1d7033— an untagged commit: the last release tag, how many commits past it, and the abbreviated hash. This is deliberately not howgit describespells it. The height is moved into a-dev.pre-release, the hash into+build metadata, and describe’s conventionalgprefix is dropped, so everything after the leadingvis a valid semver version and orders against the releases below. Thevis not part of that version number — semver excludes it — it is carried over from the tag name, which describe returns verbatim.unknown—gitis missing, orgit describefailed: nothing to describe against, because the checkout is shallow, or because the tree is not a git repository at all. CI’s ownbuild-binaryjob is the shallow case, so the binary it uploads saysunknownwhile a release build says the tag.
tests/npm/packaging.shpins the installed binary’s output to that grammar andtests/changelog/version-shape.shpins this list to it, so the tool and this paragraph cannot disagree without a gate going red.
Unreleased
Pending changes are not listed here — they live one file each in changelog.d/, and scripts/assemble-changelog.sh folds them into a new version section at release time. That is the whole point: two pull requests each adding their own fragment merge cleanly, whereas two pull requests each appending a bullet to this block collide on the same lines. To see what is queued for the next release, read changelog.d/.
0.3.0 - 2026-08-02
Added
- Publish native arm64 release archives for Linux and macOS, with matching npm packages and shell-installer support. Apple Silicon and Linux arm64 installs now receive binaries built and exercised on their native runner architecture.
zigapagos release --summary: after a build, print on stdout an inventory of the files it emitted, grouped by category — pages, page aliases and alternatives, page assets, site assets, build assets, SPA shells, SPA routing manifests and the SPA 404 fallback. Every entry is recorded where the file is written, andtests/summary/summary.shcompares the printed set against the emitted tree, so the report cannot describe a tree the build did not produce. A build with rendering errors prints a one-line refusal instead of an inventory — on stdout too, so--summary >fileanswers on the same stream whatever the build’s outcome.- A shell installer,
curl -fsSL https://valthon.github.io/zigapagos/install.sh | sh, now the headline install method on the README and the download page. It installs a complete zigapagos — the binary, the@z/runtimetree it renders islands and SPAs through, and Bun and ZigBase when the host has neither — under~/.local/share/zigapagos, with a generated launcher in~/.local/bin. Nosudo, no edits to shell startup files, and nothing written until each download has been verified against the release’s published SHA-256 sums. It is idempotent: a second run installs alongside the first and repoints the launcher.--version,--prefix,--bin-dir,--no-bunand--no-zigbasecover the rest. Windows hosts are refused with the same wording the npm package uses, rather than being given an emulated build that looks native. - A
runtime.tar.xzrelease asset: the@z/runtimetree with its dependencies vendored. This is what makes an install outside npm able to render an island at all — the per-target archives carry the binary alone, and the sidecar, bundlers and slicers are scripts inside that tree. It is staged by the same code that stages npm’s copy (npm/stage-runtime.mjs), so the two channels ship the same files by construction. zigapagos releasebuilds the per-site islands runtime slice. The second pass over the built island bundles (/islands/_runtime.js) used to run only as a build-graph step, so a toolchain-free build silently shipped the full shared runtime to every island page.zigapagos releaseemits host config and the strict-CSP artifacts. The per-namespace server config (.spamarker +zigbase.static_routes.zig,nginx.nginx.conf,.htaccess) and the site-widecsp.{nginx.conf,apache.conf,zigbase.txt}are written over the finished output tree. Every npm-path build until now shipped a tree with neither, which loses SPA deep-link fallback and serves a CSP that blocks the site’s own inline import map.zigapagos release --source-maps, replacingOptions.source_maps. Still opt-in and off by default.docs/runtime-dependencies.md— what the standalone binary needs at run time, stated once instead of inferred. A table covering every command and the external programs it requires; whenzigapagos releaseactually needs Bun (the condition is the configuration, not whether the site has islands — withZIGAPAGOS_RUNTIME_DIRset, a site with none still spawns the sidecar); how the pinned ZigBase is resolved, cached and fetched, including thecurlandtarthe fetch shells out to; and what each distribution supplies. Notably: a release archive carries the binary alone, so islands and SPAs built from one need an@z/runtimetree pointed at byZIGAPAGOS_RUNTIME_DIR—@z/runtimeisprivate: trueand cannot be installed from npm on its own.tests/meta/runtime-deps-doc.shchecks that page against the sources every claim came from: the command table againstsrc/main.zig’sCommandenum, the ZigBase pin and cache path againstsrc/cli/zigbase.zig, the environment variable againstsrc/cli/release.zig,@z/runtime’s privacy againstruntime/package.json, the binary-only release archive againstbuild/release.zig, and every flag the page names against the file that parses it.
Changed
- Build the x86_64 macOS archive natively on
macos-15-intelinstead of cross-compiling it on an arm64macos-latestrunner. - Release and development builds now share the checked-in Wuffs translation shims instead of release builds invoking
zig translate-c. This removes a hand-maintained divergence and avoids the Zig 0.16 translation crash that blocked native arm64 artifacts. docs/runtime-dependencies.md’s distribution table gains aninstall.shcolumn, and its gate (tests/meta/runtime-deps-doc.sh) gains a rule that fails the build if that column ever describes a script or an asset that no longer exists.zigapagos initnow scaffolds only the frontmatter a page needs..authorand.draftare gone from every template and.dateremains only on the blog posts and devlog years whose listing layouts render one —.titleand.layoutare the only required fields, and the scaffold no longer models the optional ones as obligatory. The sample homepage and the quick start now say which fields have defaults.zigapagos migrate --helpnow states outright that the command converts nothing: it reads the Astro project, writes aMIGRATION.mdworklist, and the port itself is manual —--scaffoldbeing the one exception, and only for islands. The README bullet and the site’s overview page said or implied otherwise.zigapagos devis zero-config. Run it in a site directory with no arguments and it works:--sitedefaults topublic, the same directory a barezigapagos releasewrites to;- the rebuild command defaults to this binary’s own
release, resolved by absolute path rather than by name onPATH, so an npm install and a downloaded release tarball both work (it waszig build, which named a toolchain a standalone user never installed); - the island/SPA source directories to watch are derived from the entries
releasediscovers, so a component edit rebuilds without--watch-dir; - a missing
zigbaseis fetched from the pinned release into the cache (SHA256-verified) instead of failing with instructions.--no-downloadrestores the previous behaviour for offline machines and for CI that pins its own binary.zigapagos e2eis unchanged: it still fetches only on--download-zigbase, because an unannounced network fetch in CI is a surprise rather than a convenience.
zigapagos initnow points a new site atzigapagos devrather than at the bare command.
Removed
The bundled live server is gone, along with its
--proxyreverse-proxy mode, theserveandserversubcommands, and the bare-command entry point that started it (issue #56).zigapagosis a standalone executable, and a standalone executable has no default action: run bare it now prints its help and exits 0, which is whatnpx zigapagosdoes too. An argument that names no command prints the same menu and exits non-zero.The consumer zig-build API is gone.
zigapagos.website(),zigapagos.e2e()andzigapagos.dev(), the option types (Options,Island,Spa,BuildAsset,E2eOptions,DevOptions) and the wholebuild/half that served them no longer exist. A site is built by RUNNING thezigapagosbinary;zig buildbuilds zigapagos itself and nothing else. Nothing in a zigapagos project needs a Zig toolchain, abuild.zig, abuild.zig.zonor a.pathdependency on this repository any more.The replacements, all of which already existed:
was now zigapagos.website(b, .{ .islands = …, .spas = … })zigapagos release --island=SRC --spa='SRC|BASE'zigapagos.e2e(b, opts, .{})+zig build e2e -- CMDzigapagos e2e --site=DIR -- CMDzigapagos.dev(b, opts, .{})+zig build devzigapagos devOptions.source_maps = truezigapagos release --source-mapsOptions.not_found--spa-not-found=NAMEOptions.build_assets--build-asset=NAME PATH [--install=P | --install-always=P]zigapagos release --spa-chunks=and--spa-slice=are removed. They existed to handreleasebundles the build graph had already produced; it now builds them itself.zigapagos init --from-astro --zigapagos-pathis removed with thebuild.zig.zonit filled in. The importer scaffolds abuild.shinstead.
Fixed
zigapagos releasenow honoursZIGAPAGOS_HOT_ISLANDS, passing--hotto the island bundle driver when it is set. Nothing on thereleasepath read the variablezigapagos devsets, so a dev rebuild produced non-hot island bundles and an island hot-swap silently reset everyuseStateinstead of preserving it.- A CLI report no longer overwrites the command’s own stderr when both streams are redirected to one file (
cmd >f 2>&1).explain,doctor,validateandmigrate --doctorbuilt their buffered stdout writers withIo.File.writer, which writes positionally from an offset of its own, so the end-of-command flush landed on top of bytes stderr had already committed — silently corrupting the merged output. They now usewriterStreaming. - Island bundles are minified. The build graph passed
--minifyto the shared runtime, both runtime slicers and every SPA bundle, and to islands alone did not. The four islands on this project’s own marketing site shrink from 4113 to 2094 bytes. - The island-sidecar spawn diagnostics no longer send you to a
build.zigthat does not exist. All three ENOENT messages ended by pointing at the consumer build API’s.islandstable, which is gone; they now name the flag that actually configures each input (--bun,--island-sidecar,--island-src-dir) andZIGAPAGOS_RUNTIME_DIR. The interpreter message also claimed bun alone cannot enable islands on a toolchain-free install “because the sidecar script and@z/runtimecome from that Zig build integration” — false for the npm path, which ships both, and true only of a release archive. - The README no longer claims a release archive “gets you the same thing” as the npm channel. It does not: the archive is the binary alone, so it has neither Bun nor the
@z/runtimetree that islands and SPAs need. The quick-start’s “a plain content site needs neither it nor Bun” was ambiguous in the same direction — settingZIGAPAGOS_RUNTIME_DIRis exactly what makes a content-only build require Bun, andnpx zigapagosalways sets it. - A failed island-sidecar spawn now names the input that is actually missing. A
bunthat is not onPATHpreviously producedfailed to spawn island sidecar (bun …/render.ts): FileNotFound, which reads as “render.ts is missing” about a path that resolves; the interpreter, the sidecar script and the island source dir are now reported separately, each with the fix for that specific case. - The changelog’s own description of what
zigapagos versionprints. It documentedgit describe‘s rawv0.1.1-<n>-g<sha>spelling, whichbuild/config.zignever emits — it reformats that into av-prefixed semver version (v0.2.0-dev.7+e1d7033) — and the paragraph is published as the site’s changelog page. All three shapes the binary can actually print are now listed, andtests/changelog/version-shape.shfails the build if the list and the emitter disagree. zigapagos devno longer risks a crash or invalid free when overlapping watched directories refer to the same path with different spellings. Allocation failures and malformed input also fail cleanly instead of leaking or indexing out of bounds.
Known limitations
- With
auto_heading_idson, a same-page reference through the$link.ref('slug')Scripty directive still fails withunknown ref— SuperMD’s owninvalid_refcheck runs insideAst.init, before ids can be injected. Plain Markdown links ([t](#slug),[t](/other#slug)) are validated later and work fine;$link.unsafeRef('slug')is the workaround for the Scripty-directive case. - A content-authored
<z-island>only accepts static props (:propsZiggy literals and literalprop-NAME="value"attributes).prop-NAME="$page.*"Scripty expressions do not resolve in content — Scripty is evaluated by SuperHTML at layout render time, and an=htmlfence’s body is emitted verbatim, never run through SuperHTML’s template evaluator. A page-bound prop still needs a layout. - No Windows support until the Zig 0.17 port. Inherited upstream code (
src/cli/watcher/WindowsWatcher.zig,src/wuffs.zig) does not compile on stable Zig 0.16.0 —os.windowshas neitherOVERLAPPEDnorPAGE_READONLYthere — and the fix rides upstream’s 0.17-dev branch. - No FreeBSD binary, and a source build does not substitute for one: the watcher-selection logic compiles the inotify-based
LinuxWatcheron FreeBSD, whoseinotify_init1/inotify_add_watch/inotify_rm_watchthat target does not have, and there is no checked-in Wuffs shim for it undersrc/hacks/. Both blockers are recorded against the shipped matrix inbuild/release.zig, which is the list to re-add the target to once they are fixed. - Strict CSP requires deploying the emitted header. The build writes the hash-strict policy, but serving it (and re-serving it after a rebuild, since the hashes are byte-exact) is the host’s job.
style-srcstill needsunsafe-inlinefor the framework’s inlinestyleattributes. - A per-locale
host_url_overridecannot be exercised locally.zigapagos devpoints ZigBase at one built tree and serves it verbatim on one origin, so a multi-host locale set builds correctly but can only be checked as deployed. The emitted output is unaffected; this is a preview limitation, not a build one. - Prebuilt binaries cover four Unix targets:
x86_64-linux-musl,aarch64-linux-musl,x86_64-macosandaarch64-macos, each built on its own native runner, withruntime.tar.xzandSHA256SUMSbeside them. arm64 archives start at this release — earlier tags are x64-only, andv0.1.0predates prebuilt binaries entirely. Any other host still needs a source build. - Pre-1.0: APIs may change between minor versions.
Internal
- CI now builds its reusable
zigapagosbinary for the architecture’s baseline CPU instead of the build runner’s native CPU. A runner with AVX-512 produced an artifact whosecompiler_rt.memcpyexecuted AVX-512 unconditionally on unrelated downstream runners; hosts without that feature raised SIGILL, and a signal on a render worker could leave the main thread waiting forever. The artifact gate now rejects host-width YMM/ZMM memcpy code. - Contributors blocked by a Codeberg outage can run
scripts/rescue-codeberg.shto verify and warm the exact pinnedtranslate-cpackages from GitHub before retrying their normal build. site/scripts/md-to-smd.tsno longer rewrites link targets inside inline code spans. It already left fenced blocks alone, but a link-shaped string between backticks in a paragraph was rewritten like a real link — silently, in both directions: a published target became a$link.page(...)directive the author never wrote, and an unpublished one became a bare GitHub URL with no marker at all. The second row was live on this site’s own changelog page, where a sentence about thecontent directives published a link tohttps://github.com/valthon/zigapagos/blob/main/…. Spans are now split per CommonMark (a run of N backticks closes on the next run of exactly N) and only the prose between them is rewritten;slugifyHeadingunwraps spans the same way, so a multi-backtick span in a heading no longer leaves its padding spaces behind as an extra hyphen in the anchor (#66).- A fence language is looked up in
fenceLangRemapwithObject.hasOwnrather than a raw index, so a fence taggedconstructor,toString,valueOf,hasOwnPropertyor__proto__is a miss instead of resolving throughObject.prototypeand splicing a stringified function into the mirror’s language slot. The module is documented as copy-me code taking a caller-supplied table, and the caller supplying it is exactly the person who cannot see the lookup (#67). - npm publishing authenticates with OIDC trusted publishing instead of a stored registry credential. Each package names
valthon/zigapagos+release.ymlas its trusted publisher on npmjs.com, and thepublish-npmjob exchanges the OIDC identity GitHub mints for the run for a short-lived registry token — so the right to publish belongs to that one workflow rather than to a credential anything able to read it could use. The job’s guard that failed whenNPM_PUBLISH_ENABLEDwas set but no stored credential was configured went with it; against a repository that deliberately has none, that guard would have failed the next tag. TheNPM_PUBLISH_ENABLEDarming switch is unchanged. - The publish job installs and asserts
npm >= 11.5before uploading anything. OIDC trusted publishing is an npm 11.5 feature andnode-version: 24does not imply it — node 24.2.0 bundles npm 11.3.0, and 24.5.0 was the first with 11.5.1 — so an older npm would have failed as a bare authentication error partway through a dependency-ordered publish. tests/meta/npm-oidc.shpins the publishing job’s shape:publish-npmmust keepid-token: write— without it GitHub mints no identity and OIDC has nothing to exchange — and both gating conditions. The job runs only on av*tag, so without a gate these are properties nothing tests until a release.- The published-release smoke workflow now also installs
zigapagos@<version>from npm on each platform and runs it. The archives it already checked cannot show a platform package that was never published, or anoptionalDependenciesresolution that yields an install with no binary in it. - Release CI now warms the exact pinned
translate-cpackages from Zig’s official GitHub archive before resolving the dependency graph. This avoids persistent Codeberg protocol failures on GitHub-hosted runners; content hashes and a resolved-graph gate ensure the mirror cannot silently supply different or stale dependency bytes. Only dependency fetching is retried, while compilation runs once.
0.2.0 - 2026-07-30
Added
- npm distribution.
npx zigapagosnow scaffolds, serves and builds a content site with no Zig toolchain. Three packages, released together atbuild.zig.zon‘s version:@zigapagos/cli-<platform>carrying the prebuilt binary,@zigapagos/cli(canonical) resolving the right one at run time throughoptionalDependencies, and the unscopedzigapagosas a thin alias sonpx zigapagosworks. Prebuilt for macOS x64 and Linux x64 — the two targetsbuild/release.zigships. Every other host is refused with the reason rather than the bare fact, and arm64 (macOS or Linux) is refused rather than served the x64 binary:npm installfails withEBADPLATFORMbecause the launcher packages declare theos/cputhey have binaries for, so an unsupported host cannot end up with an install that looks clean and has no binary in it. Everything builds fromnpm i zigapagosalone — content, islands, native SPAs andzigapagos dev:@zigapagos/cliships the@z/runtimesources and the Bun SSR sidecar, and declaresbun,typescriptand@zigbase/serveras optional dependencies, so the tools it shells out to are installed rather than asked for. npm putsnode_modules/.binonPATH, and the launcher appends it to the child’s, so the zigbase locator finds the server with no flag, no global install and nothing downloaded.--omit=optionalstill builds; it losesdev’s server and the SPA runtime slice. The remaining difference from a Zig build is caching, not capability. The published READMEs say so. Seenpm/README.md. - The zigbase dependency is the scoped
@zigbase/server, at exactly thepinned_versioninsrc/cli/zigbase.zig(currently ZigBasev0.12.0) — the same release--download-zigbasefetches, sozigapagos devruns one zigbase however it was installed.npm/check-toolchain.mjsfails the build if those two ever disagree. zigapagos doctor [DIR]: audits a BUILT output tree (defaultpublic, read-only — never builds, never touches site source) for authoring mistakes that are only visible in the final emitted HTML. Ships two checks:abs-url-meta(a root-relative Open Graph / Twitter / canonical URL — crawlers can’t resolve it, so this is anerror) anddangling-internal-link(a root-relativehref/srcwith no file behind it in the tree, including under--url-prefix— awarn, since a client-routed SPA route legitimately has no file). Exit code: anyerrorfinding, or a file doctor could not read, exits non-zero;warn-only findings exit 0 unless--strictis passed.zigapagos validate [OPTIONS]: a fast, in-memory subset ofzigapagos release‘s checks (issue #45). Parses and analyzes the site — frontmatter/Ziggy schema, SuperMD parse, layout resolution, content-side$link.page/asset references, output-URL collisions, template SuperHTML/Scripty parse, the:directive lint, and template RENDER errors (a failing Scripty expression, a$site.page(...)naming no page) — WITHOUT bundling islands, spawning the Bun sidecar, or writing an output tree. It does not cover island SSR, the typed island props check, SPA route enumeration/spec checks, asset installation, or CSS minification — those stayrelease-only, so a greenvalidateis a subset guarantee, not a greenrelease. Measured (this repo’sexamples/tsx-site, warm caches): a content-only edit loop goes fromzig build’s ~2s tovalidate’s ~0.02–0.03s — and unlikezig build,validateneeds nobun,node_modules,build.zig, or consumer build graph, and never writes the output tree.zigapagos explain <route>: resolves one output route to its content source, layoutextendschain (for a route that is one of a page’salternatives, that alternative’s OWN layout, not the page’s), effective frontmatter (after schema defaults), islands (as declared in the markup, not SSR-verified), page-owned assets (referenced vs. pruned), and EMITTED PATHS relative to the output directory (issue #47). Runs the same kind of fast in-memory build asvalidate. Content routes only — a memory build never prerenders SPAs, so a client-routed SPA route is not covered; the miss message says so.zigapagos languages: lists every code-fence language registered for syntax highlighting.zigapagos release --format=jsonemits build diagnostics as NDJSON on stderr — one minified JSON object per line,{"code","severity","file","line","col","message","help"}— instead of the historical multi-line prose. The consumer this is for is an unattended agent: it can now tell which diagnostic fired without pattern-matching English. Default is--format=textand text mode is byte-for-byte unchanged.- The diagnostic
codeis the stability guarantee;messageandhelpexplicitly are not.src/diag-codes.frozenis the append-only ledger that makes that a gate rather than a promise — a code is never renamed and never reused for a different meaning after retirement, enforced against the enum on every build. zigapagos explain-code <CODE>prints the long form of any code: what condition produced the diagnostic and what to change in the source.zigapagos explain-codewith no argument lists every registered code with a one-line summary. Every code is required by the compiler to have both, so the listing cannot go stale relative to what the build emits.- The two
:directive lints get one code each rather than a shared one —ZP_TEMPLATE_ELSE_DIRECTIVEandZP_TEMPLATE_BRANCHING_WITHOUT_END_TAG— because they are unrelated failures with unrelated fixes andcodeis what a consumer switches on. docs/diagnostics.mdis the consumer contract: the wire schema, what is and is not stable, and an explicit inventory of what is not converted and why — including the rule that matters most, skip a stderr line that does not parse as JSON rather than failing the run, since the Bun sidecar and the usage-menu path legitimately write prose to the same stream.- Islands can now be embedded directly in
.smdcontent, not only in layouts: inside a fenced code block whose fence info is=html(SuperMD’s existing validated raw-HTML escape hatch), use the hyphenated<z-island src="…" client:load :props='…'></z-island>spelling — the islands pass treats it identically to<island>in a layout (SSR,data-z-props, the import map, the runtime script, thetscprops gate, and the dev island-usage manifest all apply unchanged). The hyphen is required: superhtml’s.html-mode validator (used to vet the fence body) rejects a non-hyphenated custom element name per the HTML spec, unlike the lax.superhtmllayout mode where<island>has always worked. Seedocs/islands.md, “Islands in content (.smd)”. - Opt-in
auto_heading_idssite setting (Site/MultilingualSiteinzigapagos.ziggy): injects a GitHub-compatible slugidinto every heading that doesn’t already carry an explicit$heading.id(...)/$section.id(...), so a same-page#anchoror cross-page/page#anchorlink written against a doc’s existing GitHub rendering keeps working without hand-writing an id on every heading. Off by default; an explicit id always wins and is never overwritten. Seedocs/migration/astro-to-zigapagos.md’s “Heading anchors:auto_heading_ids” section. $site.asset(...).absLink()/$page.asset(...).absLink(): likelink(), but always returns an absolute URL (host_url+url_path_prefix+ asset path), and installs the asset the same waylink()does. Use it for URLs consumed outside the page itself —og:*/twitter:*meta tags, canonical links, feeds — sincelink()’s output is root-relative and scrapers do not resolve those (#25)..asset_fingerprint = trueinzigapagos.ziggyinstalls every linked site asset under a content-hashed filename (assets/style.css→/style.a1b2c3d4.css), and every seam that prints a site-asset URL —.link()/.absLink(), thecontent directives, andspa.headhrefs — resolves to that name through one shared formatter, so an installed file and a link to it cannot drift apart. A changed file is a changed URL, which is what lets a deploy putCache-Control: immutableon the asset tree. Opt-in and release-only;static_assetsentries, build assets, page assets and the in-memory live server keep verbatim names. Seedocs/assets.md.--allow-missing-pages(zigapagos releaseand the live server; for azigapagos devloop setallow_missing_pagesin yourbuild.zig, sincedevre-runs your rebuild command rather than building the site itself — the tolerance is identical either way, so a green dev preview and a CI release agree): tolerate a$link.page/$link.sibling/$link.sub(content) or$site.page(...)(template) reference to a page that doesn’t exist YET, instead of hard-failing the build. The reference renders as the real,url_prefix-awarehrefthe target page will have once it’s written (a 404 until then), and the build log gets a warning naming the ref and the computed href instead of a fatal error. This is the fix for incremental authoring: previously, adding a navigation link before its target page existed broke the entire build (one dangling link → zero pages built), which is exactly what “site under construction” always looks like.- A relative
.aliasesentry that basenames as404.html,robots.txt, orsitemap.xmlnow prints a build-time warning showing where it actually resolves. Alias resolution itself is unchanged — a relative entry still joins to the page’s own output directory, exactly as before; this only flags the common mistake of meaning a site-wide override (e.g."/404.html"to replace the SPA fallback) but writing the bare relative form instead. - A layout route now receives its matched child as a
childrenprop as well as through<Outlet/>— the two are the same channel (childrenis an<Outlet/>), so a layout written as<div>{children}</div>renders its child instead of an empty container. Rendering both warns, and so does rendering neither. zigapagoswarns at build time when a SPA declares nospa.headon a site that has stylesheet assets, since SPA shells have a fixed<head>and do not inherit site styles.head: []declares the omission deliberate and silences it.docs/generated-content.md: documents the generated-content pattern this site’s own docs pages use as a copyable recipe (a registry, a deterministic generator, per-file.gitignoreentries, and a freshness gate), instead of a built-incontent_generatorsconfig hook. The verdict on #34 is that a hook would only automate the cheap part (invoking a script); the actual cost is the five SuperMD transformations a generator has to apply, which are documented here in full instead.
Changed
:elseis now a build error. SuperHTML validates it at parse time and then never evaluates it — the renderer null-unwraps its (mandatorily absent) value, so no template using:elsehas ever rendered. The error names the fix: write the negated condition on a second<ctx>,<ctx :if="$cond">…</ctx><ctx :if="$cond.not()">…</ctx>.:if/:loopon an element with no end tag — a void element like<img>,<br>,<input>, or a self-closing<item/>in an.xmlalternative layout — is now a build error. SuperHTML restarts a conditional or a loop by rewinding to the element’s end tag; with none it rewinds to the start of the file and splices the whole raw template source into the page (previously with exit code 0), or slices backwards and panics. The error names the fix: wrap the element in<ctx>.- A
<Link>rendered outside a<Router>is now a build error rather than a silently dead anchor: without router context the href cannot resolve against the SPA base and the click is never intercepted, so the prerendered shell shipped a link that 404s on a path-prefixed host. On the client the same situation warns once per href instead of throwing. Use a plain<a>for a non-router anchor. - The build error for a dynamic route with no
skeletonnow names the concrete pathname the shell is prerendered at.
Fixed
absLink()on a multilingual site returned a root-relative URL for page assets ($page.asset(...)). It is now absolute in every locale, and stays correct across locales too:$page.locale('de').asset(...).absLink()emits the target locale’s host exactly once, including when that locale setshost_url_override.- On a multilingual site whose locale sets
host_url_override, a site asset linked withlink()lost the separator afterassets_prefix_pathand came out ashttps://example.com/staticfoo.css(orhttps://example.comfoo.csswith no prefix). This affectedlink()on those sites beforeabsLink()existed, and is fixed for both. - A full build now names the site assets it pruned. An asset installs only when something bumps its refcount, and everything else was dropped in silence — a hand-authored SVG vanished from a build when its last
.link()went away, and finding out why meant reading the refcount logic. The report is a sorted, capped list with the true total and both fixes spelled out. It stays a warning, since staging a file ahead of the page that will use it is legitimate, and it is suppressed wherever it would fire on correct code: incremental rebuilds, a build whose render pass failed, assets consumed by.bytes()/.size()/.sriHash()/.ziggy(),.keep/.gitkeepplaceholders, and anassets_dir_paththat doubles as a content dir. - A content directory that holds
.smdpages but noindex.smdnow produces a build-log warning. Such a directory never becomes a section, so its pages join the enclosing section with deeper URLs, no page is built at the directory’s own URL, and$page.subpages()aimed at it returns an empty list — which previously looked like “my section is empty” with nothing pointing at the cause. The warning names the directory, the URL that is not built, and theindex.smdto create; when a sibling<dirname>.smdalready occupies that URL it says so, since that is the usual shape of the mistake. It is a warning, not an error: an index-less directory is a legitimate URL-shaping tool. - An unknown code-fence language (e.g. a typo like
```zig++) is now a build-log WARNING instead of a fatal error. The fence still renders — as escaped, unhighlighted text, the same outputenable_treesitter=falsealready produces for every language — and the warning includes a did-you-mean suggestion when one is available (runzigapagos languagesto see the full registered list). - A
$linkreference starting with a leading.(SuperMD’s syntax for “subpage of this section”) that fails because the current page isn’t a section now includes a note clarifying that a leading.means “subpage of this section”, not a relative path, and points at$link.page(...)for linking a sibling page instead. $link.page('')— which looks like it should work, because$site.page('')accepts an empty ref for the homepage — now fails with a note pointing at$link.site(), the correct builtin for linking to the site’s homepage, instead of just SuperMD’s bare “path is empty”.- Under
--format=json, afatal.msgno longer aborts a Debug build with SIGABRT: it emits oneZP_FATALobject and exits 1. Thestd.Progressbar and the Debug/tracy/tsan warning banners are suppressed in that mode too, since all three write to the same stderr the NDJSON stream uses. - The site’s
url_path_prefixis now composed intoRouter.basein both environments, so a path-prefixed deploy (a GitHub project-pages site) emits prerendered<a href>values that carry the prefix, works without JavaScript, and soft-navigates to a URL that survives a hard refresh. The prefix reaches the build’s SSR pass over the sidecar protocol and the browser over adata-z-prefixattribute on the shell’s hydration root, so the two can never disagree. Sites with nourl_path_prefixare unaffected, byte for byte. zigapagos serveprefixes the SPA bundle and runtime URLs it bakes into dev shells, which its own request handler already required.- An island’s SSR pathname (
host.pathname(),useLocation()) now carries the site’surl_path_prefix, matching what the browser reports. An island that branches on the path — active-nav highlighting, breadcrumbs — used to render one thing at build time and another after hydration. - The generated nginx, Apache and ZigBase host configs now account for a site’s
url_path_prefix, each according to its own semantics rather than by prepending the prefix everywhere: nginx prefixes itslocationselectors andtry_filestargets; Apache emits aRewriteBaseand keeps its per-directory patterns relative; ZigBase prefixes its.matchpatterns but leaves.servetargets pointing at the output tree, which has no prefix directory.routing-manifest.jsoncarries the prefix as its ownurl_path_prefixfield for them to apply — its route values stay tree-relative. - The migration guide now spells out the three separate
:iftraps, including the one that is still legal and still surprising::ifon a real element gates only its BODY, so the tag and every one of its attributes are emitted either way (this is how a documentation sidebar shippedaria-current="page"on all 14 nav items with a green build). Wrap the element in<ctx>to make the element itself conditional.
Known limitations
- With
auto_heading_idson, a same-page reference through the$link.ref('slug')Scripty directive still fails withunknown ref— SuperMD’s owninvalid_refcheck runs insideAst.init, before ids can be injected. Plain Markdown links ([t](#slug),[t](/other#slug)) are validated later and work fine;$link.unsafeRef('slug')is the workaround for the Scripty-directive case. - A content-authored
<z-island>only accepts static props (:propsZiggy literals and literalprop-NAME="value"attributes).prop-NAME="$page.*"Scripty expressions do not resolve in content — Scripty is evaluated by SuperHTML at layout render time, and an=htmlfence’s body is emitted verbatim, never run through SuperHTML’s template evaluator. A page-bound prop still needs a layout. - No Windows support until the Zig 0.17 port. Inherited upstream code (
src/cli/serve/watcher/WindowsWatcher.zig,src/wuffs.zig) does not compile on stable Zig 0.16.0, and the fix rides upstream’s 0.17-dev branch. - FreeBSD needs 15 or newer for live reload: the watcher reuses the inotify-based
LinuxWatcher, and inotify entered the FreeBSD base system in 15. There is no kqueue backend. Building and serving static output is unaffected. - Strict CSP requires deploying the emitted header. The build writes the hash-strict policy, but serving it (and re-serving it after a rebuild, since the hashes are byte-exact) is the host’s job.
style-srcstill needsunsafe-inlinefor the framework’s inlinestyleattributes. host_url_overrideon a locale is not supported by the live server.- Prebuilt binaries cover x64 only. GitHub Releases ship an
x86_64-linux-musl.tar.xz, anx86_64-macos.zipandSHA256SUMS, fromv0.1.1onward, and the npm packages repackage those same two binaries. An arm64 host — Apple Silicon included — and any commit earlier thanv0.1.1still need a source build. - Pre-1.0: APIs may change between minor versions.
Internal
zig build test-assetshad been compiling and running zero tests while exiting 0, for as long as the step has existed:filtersis a compile-time--test-filter, and no test inmain.zigmatchedassets:, so nothing pastmain.zigwas ever analysed. It now carries the anchor the other suites already had. Fallout: that finally compiledsrc/PathTable.zig’s inheritedtest PathTable, which had rotted against agetPath→getPathNoNamerename and no longer built — repaired in place.- CI no longer resolves an npm package at workflow runtime.
browser-e2e.yml’s site job served the built site withbunx serve, which downloads whatever the registry has at the moment the job runs, in a repository that pins its toolchain inmise.toml, passes--frozen-lockfileto everybun installand materializes its Zig dependencies from hashes. It now usespython3 -m http.server, already present on every runner, andtests/meta/ci-package-pins.shfails the build on an unpinnednpx/bunx/bun x/pnpm dlxin any workflow so the hole cannot reopen. (#50) - CI builds
site/on the pull-request path (newsitejob inci.yml), running the four assertions —build.sh,docs-mirror.sh,links.sh,js-budget.sh— that previously ran only as deploy gates inpages.ymland in the scheduledbrowser-e2e.yml. It reuses thezigapagosbinary thebuild-binaryjob already publishes, so nothing compiles. - The branding gate takes an inline opt-out.
<!-- branding-ok: why -->sanctions the upstream project’s name on that line and<!-- branding-ok:begin why -->/<!-- branding-ok:end -->sanctions a block, for the cases where naming it literally is the accurate thing to do — this repository’s fork-point tag is named after the upstream release it marks, so the passage inCHANGELOG.mdexplaining which tags exist here can now say so instead of gesturing at it. A reason is required, an unbalanced block fails, a marker that exempts nothing fails as stale, and every sanctioned mention is printed on success. The gate also no longer excludes itself from its own search, andtests/branding.test.shpins each of those rules from both sides. (#60) - Extracted the SuperMD transformer out of
site/scripts/gen-docs-mirror.tsintosite/scripts/md-to-smd.ts, a repo-agnostic module with no repo-specific constants (paths, URLs, or fence-language remaps are all passed in viaTransformOptions), so it is the thingdocs/generated-content.mdtells a reader to copy. Verified byte-identical output against the pre-extraction generator across all 9 existing mirrors. - That transformer tracked fenced code blocks by toggling a boolean on any line that was exactly three backticks (or tildes) followed by a bare
[A-Za-z0-9_-]*language. A doc that shows fenced Markdown nests a three-backtick block inside a four-backtick one, and SuperMD’s own raw-HTML escape hatch is the fence info string=html— neither is that shape, so the inner closing fence was read as an opener and the tracker stayed inverted for the rest of the file, silently dropping every$heading.id(...)and every link rewrite after it.docs/islands.mdhit this, and the two links whose targets had lost their ids then failed the site build withunknown ref. Fence recognition now follows CommonMark: a run of three OR MORE delimiters, an arbitrary info string (with no backtick in a backtick fence’s), and a closer that must match the opener’s character, be at least as long, and carry no info string. - Templated
site/test/docs-mirror.sh’s repo-specific paths behind variables at the top, and fixed its rendered-HTML directive check, which used to grep the built page for a literal Scripty directive with no way to tell a real leak from a directive shown as a documented code sample — a false positivedocs/generated-content.mdwould have tripped immediately. It now strips<pre>/<code>before matching. - Added
site/test/md-to-smd.test.ts, unit tests for the extracted transformer covering heading-slug edge cases (the em-dash double-hyphen, dedup, an indented fence), link rewriting, the leading-title strip, and the Ziggy frontmatter emitter, wired intosite/test/docs-mirror.shso CI runs them without a workflow change. - The release target matrix is declared in three places —
build/release.zig,npm/cli/targets.jsonandrelease.yml’s build matrix — andnpm/check-targets.mjsnow fails when they disagree, deriving each npm key/cpu/os and archive name from the zig triple rather than trusting the JSON. Wired into CI throughtests/npm/targets.shand into the release workflow before anything is packed. A staletargets.jsonwould otherwise publish a platform package whose binary nobody built. release.ymlgained annpm-packagejob that assembles and install-tests the packages from the archives the release already builds — on pull requests too, so a packaging defect is caught before a tag rather than by a published version that cannot be replaced. Publishing is a separate job gated on av*tag and theNPM_PUBLISH_ENABLEDrepository variable.
0.1.1 - 2026-07-28
Internal
Changelog entries are now recorded as one fragment file per change in
changelog.d/, assembled into a version section byscripts/assemble-changelog.shat release, so parallel pull requests never conflict onCHANGELOG.md. Seechangelog.d/README.md.Four byte-identical private copies of
escapeRegExpin build-time TypeScript are gone, replaced by the right tool for each of the two contexts they were serving. The three JavaScript call sites (lint-island-imports.ts,react-alias.ts,sidecar/bundle-island.ts) now use the standardRegExp.escape, which is specified for exactly the ECMAScriptRegExpposition they feed. The fourth,emit-host-config.ts, emits ApacheRewriteRulepatterns — PCRE, a different dialect — so it gets a purpose-namedescapePcrewhose contract matches its output language and which keeps a deployed.htaccessreadable (^app/.*$, not^\x61pp/.*$).Generated Apache config now has a validation net rather than only literal-string greps:
emit-host-config.test.tsruns each emittedRewriteRulepattern through a real Perl-compatible regex engine and asserts it matches the URLs it should and rejects the near-misses an unescaped.would have swallowed.buildAllowgained the metacharacter-escaping test it never had.Eleven of the fourteen test scripts
tests/meta/unrun-scripts.txtinventoried as knowingly unrun now run in CI: the non-browserexamples/tsx-site/test/*.sh— island SSR and the bundle/import-map wiring, SSR↔CSR parity, byte-parity against a rawbun build, depfile incrementality, the props-check gate in both directions,migrate --doctor, and the four SPA prerender scripts (routing manifest, nginx/zigbase host config, code splitting, baked flag defaults, guarded routes, nested layouts) — plus the live-server smoke test.They are a step in the existing
e2e-dev-loopjob rather than atests/<area>/shim, and the distinction is the whole point. Every one of them runszig buildinsideexamples/tsx-site, i.e. a full consumer build of zigapagos-as-a-dependency, ande2e-dev-loopis the only job that already pays for one — itstests/serve/dev.shstep drives that project’s ownzig build dev. A shim would have put them ine2e-rest, which builds the repo and not the example, buying a cold ~265s consumer build and making that job the run’s critical path. Measured against the warm tree the job already has, the eleven cost 49s in CI (29s locally) against the 468s thedev.shstep above them takes.serve.shalone was inventoried at 76.1s; behinddev.shit is ~5s, which is the placement argument in one number.The list is literal, not a glob, for the opposite reason
e2e-restuses a glob: a new sibling in that directory should NOT be adopted onto the PR path automatically — it might be the next one that needs a browser or four minutes. Being unnamed there is exactly what makesscript-coverage.shstop and ask.tests/meta/script-coverage.shno longer counts a script as CI-run because a workflow comment names it. Its rule (b) was a plaingit grepover.github/workflows/, so prose saying “these two are deliberately not run here” would have vouched for precisely the scripts it was disclaiming — and, since both are also inventoried, would have failed the gate with “run by CI but also listed”. Rule (b) now applies the same non-comment filter rule (c) already had. The two Playwright paths are spelled in full in that comment on purpose: they pin the filter, because removing it turns the gate red by name.(
site/test/build.shand the two Playwright scripts were the three still inventoried at this point; all three were wired up before this release shipped — see the entry below for where each ended up and why.)The
typescriptdevDependency moves 5.9.3 → 6.0.3, the final JavaScript-based TypeScript line. The compiler API thatruntime/scripts/slice-host.tsandruntime/sidecar/hot-transform.tsparse with is fully present, so neither needed re-platforming, and the runtime suite is unchanged at 617 passing.site/bun.lockandexamples/tsx-site/bun.lockare regenerated in step: each embeds its own copy of@z/runtime’s resolved dependencies and bun does not refresh them for a linked package on a plain install, so left alone they would have kept the props-check gate running 5.9.3 while the runtime was tested on 6.0.3.TypeScript 7.x is capped out via a Dependabot
ignoreon>=7.0.0. 7.0 is the Go rewrite and its npm package no longer ships the JavaScript compiler API —import ts from "typescript"resolves tolib/version.cjsand yields only{version, versionMajorMinor}, taking the runtime suite to 566 passing / 51 failing. The cap is deliberately a version bound rather than a major-block, which is what let 6.x through. It lifts when a 7.x ships a usable programmatic API (7.1 at the earliest).The
tsconfig.jsonfiles were audited against TypeScript 6.0’s deprecation list and needed no changes: none usesbaseUrl,outFile,downlevelIteration,target: es5,moduleResolution: node|node10|classic,module: amd|umd|system|none, or an explicitly falseesModuleInterop/allowSyntheticDefaultImports/alwaysStrict. No source file uses themodulenamespace keyword or importassertsyntax.ignoreDeprecationsis therefore not needed, and the config surface is already clean for whatever 7.x removes.Dependabot no longer groups major version bumps with routine ones. The
bungroups forruntime/are restricted to minor and patch, and a newruntime-majorsgroup collects every major into its own pull request, so a breaking major can no longer block unrelated updates from merging.github-actionsdeliberately keeps its single group: everyuses:is pinned to a bare major tag, so majors are the only update it can produce and splitting would reintroduce per-action pull-request spam.happy-domand@happy-dom/global-registratormove to 20.11.0.tests/meta/unrun-scripts.txtis empty. All 36 tracked test scripts are now run by CI; the inventory that started at 14 rows and was cut to 3 is at 0. The file stays because the gate reading it is the point, not the list.site/test/build.shmoved intopages.yml, betweenBuild siteandUpload artifact. That makes it a deploy gate: a failed assertion fails the build job, the artifact is never uploaded, anddeploy(whichneeds: build) never runs, so the previous good deployment stays live. It is nearly free there — the workflow has already builtsite/, so the script’s own install and build are warm no-ops and the five greps measured 1.7s — against ~120s in anyci.ymljob, becausesite/is a third consumer project with its own.zig-cachethat nothing else warms. The residual gap is stated rather than glossed:pages.ymltriggers on push tomainand manual dispatch only, so these assertions gate the deploy and not the PR.examples/tsx-site/test/{hydrate,spa_slice}.shmoved into a newbrowser-e2e.ymlon a nightlyschedule:plusworkflow_dispatch:. Scheduled rather than PR-gating because each is ~125s on top of a ~265s cold consumer build plus a ~150MB browser install, and because what they catch — a real-browser hydration or runtime-slicing regression — arrives with aruntime/srcchange or a dependency bump, unattended. Each script gets its own matrix runner (fail-fast: false):spa_slice.shopens byrm -rfing.zig-cacheandzig-out, so the two cannot share a build, and separate runners make that hazard structurally impossible rather than merely avoided.One correction to the plan the inventory carried: the install step is
playwright install --with-deps chrome, notchromium. All ten*_playwright.pyhelpers launch withchannel="chrome", which on Linux resolves to/opt/google/chrome/chrome— the bundled Chromium build satisfies none of them, and the run would have died at browser launch after paying for the whole consumer build.tests/meta/script-coverage.shgained a self-test,tests/meta/script-coverage.test.sh, in the shape ofscripts/check-allocator-contracts.test.sh: seven cases against throwaway git repos in$TMPDIR. That gate has shipped three defects already — a self-vouching inventory, apipefail+grep -qSIGPIPE race, and a comment filter applied to one rule but not the other — and every one made it pass when it should have failed.Two of the cases exist because emptying the inventory silently removed the only thing pinning the comment filter. That filter is what stops a script a workflow merely mentions in prose from counting as run, and it was pinned by accident: while the two Playwright scripts were inventoried, dropping the filter made the gate see them as both CI-run and listed and fail by name. With the inventory empty, removing the filter now breaks nothing in the tree — confirmed by deleting the filter line and watching the real gate still pass 36/36. Case 5 makes the pin deliberate; case 6 is its guard rail, that comment-awareness has not become “never believe a workflow”.
contract/test/drift.sh— the test that proves the cross-tier codegen gate is not vacuous — was itself vacuous, and now runs in CI. Its Case B asserted only thattsc --noEmitexited non-zero, which a compiler that fails to launch also does:contract/has nonode_modules, sobun x tscresolvedtscoffPATH, hit mise’s shim, and died withNo version is set for shim: tsc— exit 1, nothing type-checked,PASS Case Bprinted. Cases A and B now assert on the diagnostic text (theexperiments→variantshunk in api-check’s staged diff; both assignability directions of the_assert.tstripwire, and no diagnostic from anywhere else), and a new Case D feeds those assertions canned “the tool never ran” output to prove they reject it.The same script now runs the repo’s pinned TypeScript rather than whatever
bun xresolves.bun x tscwith no local install can fetch from npm, wherelatestis 7.x — the line this repo deliberately caps out — so the gate could have silently type-checked with a compiler the manifest pins away from. It now invokesruntime/node_modules/typescriptthrough bun and fails if the installed version does not match the one locked inruntime/bun.lock.drift.sh’s restore no longer discards more than it mutates. It used togit checkout HEAD -- contract/, which coverscontract/test/drift.shitself — editing the script and running it reverted the edit mid-run. The restore set is now exactly the two paths a case writes to, a pre-flight refuses to start when either is already dirty, and the EXIT trap both restores and fails the run if anything is left behind.A
tests/contract/drift.shshim puts the gate in CI’stests/*/*.shglob, alongside the existingtests/changelog/assemble.shandtests/release/scripts.shhooks. It costs ~1.5s and spawns no server, so it runs in thee2e-restshard rather than a job of its own. Being outside that glob, and unnamed inci.yml, is why the rot above went unseen.examples/tsx-site/test/spa.shhad rotted the same way, and is fixed. Its two nginx assertions expected the unquotedtry_files $uri $uri/ /app/index.html;, butemit-host-config.tshas run every interpolated route value throughnginxQuote()since that helper landed, so both greps had matched nothing for as long as they had existed — and the script sits outside thetests/*/*.shglob, so nothing ran it. They now match the quoted form byte-for-byte, with a third assertion covering the dynamiclocation’stry_filesorder. The emitter itself was never at risk:runtime/scripts/emit-host-config.test.tspins the same strings and does run in CI. What the e2e assertions add is that those bytes actually reachzig-out/site/app/nginx.nginx.confin a real build.A new gate,
tests/meta/script-coverage.sh, makes that class of rot impossible to introduce silently: every test script must be either run by CI or listed intests/meta/unrun-scripts.txtwith a written reason, and the gate fails on one that is neither — as well as on a stale row for a script that has since been wired up or deleted. This is the same shape asscripts/allocator-allowlist.txtand its checker. It isgit ls-filesplusgrepover tracked text, so it costs no toolchain and runs in well under a second.The inventory it enforces records the audit behind it: all 34 tracked test scripts were run by hand, 20 are covered by CI, and the 14 that are not (
site/test/build.shplus the 13 underexamples/tsx-site/test/) all currently pass. Each row carries its measured wall-clock and what adopting it would cost, so the trade-off can be re-checked rather than re-derived.
0.1.0 - 2026-07-26
First public release. Fork point: upstream 496e42d (v0.11.2-17-g496e42d).
Everything below is “added” relative to that fork point; the sections separate what is new from what the port changed and removed.
Added
- TSX islands. Each
.island.tsxis server-rendered at build time through a Bun sidecar and spliced into the page as real HTML with adata-z-propsJSON block. In the browser it hydrates onclient:load,client:idle,client:visible,client:mediaorclient:only. Islands share one Preact instance via a generated import map pointing at a single shared runtime, so a page with no islands ships no JavaScript. @z/runtime, the first-party shipped runtime: the hook set (useState/useEffect/useLayoutEffect/useRef/useMemo/useCallback/useReducer/useContext/useSyncExternalStore),createContext,createPortal, thehost.*bindings (store,fetchShared, cookies, clock, scroll/resize/matchMedia, scoped enhancers,loadScript,portal,reportError,pathname), feature-flag hooks (useFlag/useVariant/FeatureFlag/Experiment/initFlags), and the island lifecycle (bootIsland/initIslands).- Native SPAs from a single
.spa.tsxexportingspa+routes: prerendered per-route skeletons, a_shell.htmlfor dynamic routes, two-phase hydration, soft navigation, route guards with a cascade over nested scopes, nested layouts viachildren+<Outlet/>, a site-wide404.html, aspa.headhook, per-route code splitting vialazy(), and host-agnostic routing manifests emitted for ZigBase, nginx and Apache. - Dev loop.
zigapagos devbuilds, serves through the stock ZigBase binary, and rebuilds on change; the bundled live server adds live reload over SSE, a--proxy PREFIX=UPSTREAMreverse proxy for cookie-auth backends, and live feature flags. Both understand SPAs, including incremental island rebuilds. - Astro migration tooling.
zigapagos migrate <dir>scans an Astro project, detectsclient:*usage and writes aMIGRATION.mdworklist;zigapagos init --from-astroscaffolds islands with the React →@z/runtimeimport swaps already applied and refuses to clobber existing files. The specs indocs/migration/are written as a deterministic mapping so an agent can complete a port unattended. - Cross-tier codegen for a typed backend client, with
zig build api-checkfailing the build whencontract/generateddrifts from the schema. - Strict-CSP emit. For any site with islands or SPAs, the build scans the emitted HTML, computes a sha256 for each unique inline script, and writes
csp.nginx.conf,csp.apache.confandcsp.zigbase.txtat the site root:script-srcis'self'plus hashes with nounsafe-inline. Verified with zero CSP violations in real Chrome against a hardened vhost. - Correctness gates, all wired into CI: eleven
test-*unit suites, a 616-test Bun suite forruntime/, twelve hermetic shell e2e scripts (real server boots against a stub ZigBase), azig fmt --checkgate over every tracked.zigfile, a-Dsingle-threadedcompile gate over the exe and every test binary, an island-propstscgate, andscripts/check-allocator-contracts.sh, which fails on any new test that wrapsstd.testing.allocatorin an arena (that turns Zig’s leak detector off) unless it is allowlisted with a written justification. NO_SLOP.md, the review standard every Zig change is held to, including the four allocator-ownership contracts and theRenderArenamarker type that makes contract 4 compiler-checked rather than convention.- Docs and examples:
docs/islands.md,docs/spa.md,docs/observability.md,docs/cross-tier-codegen.md,docs/migration/*,docs/ROADMAP.md; a workedexamples/tsx-site/with SSR and real-browser hydration tests; andsite/, the project’s own dogfooded site.
Changed
- Ported to released Zig 0.16.0. Upstream moved to 0.17-dev; this fork tracks released Zig only and defers the 0.17 port until 0.17.0 ships, at which point it lands together with the next upstream release-tag sync.
- Renamed throughout — binary, branding, generated asset names — with a CI gate that fails on a stray upstream name outside the attribution allowlist.
- Build-pass order in
src/root.zigis now load-bearing. The SPA prerender runs before the page render/emit pass, because it is the pass that executes the author’s own code and holds the SPA spec validation (overlapping bases, basename collisions,..in a route path, a declared SPA with no sidecar). Running it after the page pass meant those failures aborted with every page already rewritten. This is not atomicity: a failure partway through writing shells still leaves partial output. - The import map is emitted before any
modulepreloador module script in<head>, since the first module script closes the window in which a map may be declared.
Removed
- The old Zig-WASM island path (
render(*Z)) and itsengine/tree. Islands are TSX only; porting from React is now a near-mechanical import swap. windows-latestfrom CI. Inherited upstream code (src/cli/serve/watcher/WindowsWatcher.zig,src/wuffs.zig) does not compile on stable Zig 0.16.0 —std.os.windowsno longer exposesOVERLAPPED/PAGE_READONLY— and the fix rides upstream’s 0.17-dev branch. It returns with the 0.17 port.
Known limitations
- No Windows support until the Zig 0.17 port (above).
- FreeBSD needs 15 or newer for live reload: the watcher reuses the inotify-based
LinuxWatcher, and inotify entered the FreeBSD base system in 15. There is no kqueue backend. Building and serving static output is unaffected. - Strict CSP requires deploying the emitted header. The build writes the hash-strict policy, but serving it (and re-serving it after a rebuild, since the hashes are byte-exact) is the host’s job.
style-srcstill needsunsafe-inlinefor the framework’s inlinestyleattributes. host_url_overrideon a locale is not supported by the live server.- Binary releases (
SHA256SUMS, anx86_64-linux-musl.tar.xz, anx86_64-macos.zip) are published via GitHub Releases. - Pre-1.0: APIs may change between minor versions. Only the most recent release is supported — there are no backports.