Migrating from Astro
A deterministic mapping from Astro constructs to their Zigapagos equivalents, written to be followed mechanically (by a human or an AI agent) with minimal judgment. Each row/section says: Astro thing → Zigapagos thing, with the exact target syntax. Where a construct has no equivalent yet, it is listed under Gaps with the recommended workaround.
Zigapagos is a permanent fork of the upstream SSG (see the repository README’s Acknowledgements for attribution): the static layer is inherited near-verbatim (content in SuperMD .smd, layouts in SuperHTML .shtml, config in Ziggy zigapagos.ziggy), and the interactive layer is islands — components authored in TypeScript TSX against @z/runtime (a vendored Preact runtime), SSR’d at build time by a Bun sidecar, and hydrated client-side via an import map.
Migration is two jobs: (1) the static layer maps almost 1:1 and is largely mechanical; (2) React/Preact islands become
.island.tsxfiles — swapreact/react-domimports to@z/runtime, usehost.*where SSR-safety matters, and drop any third-party npm deps. Hooks, JSX, events, and component structure are unchanged. See the recipes for the full authoring guide.
1. Project structure
| Astro | Zigapagos | Notes |
|---|---|---|
src/pages/**/*.astro | content/**/*.smd | File path → URL path (both file-based routing). |
src/layouts/*.astro | layouts/*.shtml | SuperHTML layouts. |
src/components/*.{astro,jsx,tsx} | components/*.island.tsx (islands) or layouts/templates/*.shtml (static partials) | Interactive → TSX island; static-only → SuperHTML partial. |
src/content/** (content collections) | content/**/*.smd | Collections → content directories; see §11. |
public/** | assets/** (+ static_assets in config) | Static passthrough. |
astro.config.mjs | zigapagos.ziggy + build.sh | Config split: site config in Ziggy, island/SPA entries on the zigapagos release command line. |
package.json / node_modules (for the site) | package.json with @z/runtime | Bun manages island deps; @z/runtime is the only runtime dep. There is no Zig-side dependency: zigapagos is a binary you run. |
2. Config: astro.config.mjs → zigapagos.ziggy
// astro.config.mjs // zigapagos.ziggy
export default defineConfig({ Site {
site: "https://example.com", .title = "My Site",
// ... .host_url = "https://example.com",
}); .content_dir_path = "content",
.layouts_dir_path = "layouts",
.assets_dir_path = "assets",
.static_assets = ["**"],
}
zigapagos.ziggy must begin with Site { (the typed root). Site title has no Astro config equivalent (Astro takes it per-page) — set it here as the default.
static_assets — don’t skip this, or your CSS/images will 404. Unlike Astro, Zigapagos does not copy the assets directory verbatim. An asset under assets_dir_path is only installed into the output if it is either (a) referenced from a layout/content via $site.asset('path').link(), or (b) listed in static_assets. A plain <link href="/style.css"> or <img src="/logo.png"> to an asset that is neither silently 404s — nothing warns you. So any asset you reference by a raw URL (site-wide CSS, favicons, OG images, fonts, CNAME, JS you include with a literal <script src>) must be in static_assets.
static_assets entries are relative to assets_dir_path and may be:
- an exact file:
"favicon.ico","css/site.css"; - a
**glob to install a whole subtree without enumerating every file —"**"installs the entire assets directory (the closest equivalent to Astro’s “copypublic/verbatim”),"img/**"installs everything underassets/img/.
.static_assets = ["**"], // copy the whole assets dir (Astro-like)
.static_assets = ["css/**", "favicon.ico"], // a subtree glob + an exact file
(The glob is a simple prefix match — a/b/** installs everything whose path starts with a/b/. A glob that matches nothing is a build error, so a typo’d directory is caught rather than silently dropped.)
3. Routing
Both are file-based and 1:1:
| Astro | Zigapagos |
|---|---|
src/pages/index.astro → / | content/index.smd → / |
src/pages/about.astro → /about | content/about.smd → /about/ |
src/pages/blog/index.astro | content/blog/index.smd (a section index) |
src/pages/blog/[slug].astro (dynamic) | one content/blog/<slug>.smd per entry |
4. Pages & frontmatter
Astro page frontmatter (JS) → SuperMD Ziggy frontmatter:
--- ---
// src/pages/post.astro .title = "My Post",
const title = "My Post"; .date = @date("2024-01-01T00:00:00"),
const pubDate = new Date("2024-01-01"); .author = "Jane",
--- .layout = "post.shtml",
<Layout title={title}>…</Layout> ---
Body content in Markdown (SuperMD).
.layoutis mandatory and names a file inlayouts/..titleis also required..authorand.dateare optional (default:""and the Unix epoch). A marketing/landing/contact page can omit both —$page.authoris then""and$page.dateis1970-01-01. Only.titleand.layoutare required by the default schema. (Earlier versions required.author/.date, which surfaced asmissing field: 'author'.)- Custom fields don’t need a schema change. Anything site-specific goes under
.custom = { … }(a free-form, always-optional bag) and is read as$page.customin the layout. You do not edit any schema to add custom fields. - Which top-level fields exist / are required is fixed by the compiled-in schema (the
Pagetype), not by a per-site config file. A field is optional iff it has a default; a field with no default and no value in a page’s frontmatter is reported asmissing field: '<name>'at build time. The shipped default requires only.titleand.layout; everything else (author,date,description,tags,aliases, …) is optional. frontmatter.ziggy-schemais the editor schema, not a runtime override. It mirrors the compiled-in schema so the Ziggy LSP can validate/autocomplete your.smdfrontmatter in-editor; changing it does not change build-time validation. Keep it in sync with the real schema. To actually add or re-require a typed top-level field you change the compiled schema (src/context/Page.zig, since Zigapagos is a fork) and mirror the change infrontmatter.ziggy-schemafor the editor. For per-page data, prefer.custom(above) — no rebuild needed.- Astro’s frontmatter is arbitrary JS; Zigapagos frontmatter is typed Ziggy. Put custom fields under
.custom = { … }→ read as$page.customin the layout. - Page body: Astro JSX/Markdown → SuperMD Markdown (
.smd).
Heading anchors: auto_heading_ids
SuperMD only gives a heading an id when the author writes an explicit $heading.id(...)/$section.id(...) directive — unlike GitHub (and most Markdown renderers Astro content was likely written against), it does not auto-slugify heading text. Content ported verbatim from a GitHub-rendered doc often relies on GitHub’s implicit heading anchors for same-page navigation ([Contents](#installation)), and without a matching id those links fail the build with unknown ref — every heading would otherwise need a hand-written id before the port compiles.
Set auto_heading_ids = true on Site (or MultilingualSite) in zigapagos.ziggy to inject a GitHub-compatible slug id into every heading that doesn’t already have one (see src/heading_slugs.zig). It’s opt-in and off by default — existing sites that rely on “an unmatched anchor is a build error” keep that behaviour unless they turn it on. An explicit $heading.id(...)/$section.id(...) always wins and is never overwritten, and GitHub’s own duplicate-heading dedupe (foo, foo-1, foo-2, …) and double-hyphen-around-punctuation behaviour are matched, so anchors computed from a doc’s existing GitHub rendering keep working unchanged.
Known limitation: a same-page reference through the $link.ref('slug') Scripty directive still fails with unknown ref, because SuperMD’s own invalid_ref check runs inside Ast.init, before ids can be injected. Plain Markdown links do work, because they take a different, later-validated path: [t](#slug) (same page) and [t](/other-page#slug) (cross page) are both fine. Only the Scripty form is affected, and its workaround is $link.unsafeRef('slug') — which emits the same anchor but, as the name says, skips the id-existence check, so a typo in the slug becomes a dead link instead of a build error.
5. Layouts & templating: .astro → .shtml (SuperHTML)
| Astro (JSX-ish) | Zigapagos (SuperHTML + Scripty) |
|---|---|
{title} | :text="$page.title" (on the element) |
<Fragment set:html={content} /> | :html="$page.content()" |
{cond && <p>…</p>} | <ctx :if="$cond"><p>…</p></ctx> |
{items.map(i => <li>{i}</li>)} | <ul :loop="$items"><li :text="$loop.it"></li></ul> |
<Layout> wrapper / <slot/> | <extend template="base.shtml"> + <super> slots |
import Header from …; <Header/> (static) | <extend>/partials in layouts/templates/ |
<a href={url}> | <a href="$expr"> (Scripty) or static href="/x" |
SuperHTML is valid HTML5 + special attributes; logic is Scripty (a sandboxed expression language). See the upstream SuperHTML docs linked from the repository README’s Acknowledgements.
A dynamic attribute uses the BARE name — not a
:prefix. The only:directives are:if,:loop,:text,:html(plus:propson<island>). For a dynamicsrc/href/class/etc., write the bare name with a Scripty value —src="$page.custom.get('hero')", not:src="$expr". By analogy with:text, migrators reach for:src/:href; that used to evaluate the value but keep the literal:srcattribute (so the realsrcwas never set and the asset silently broke). This is now a build error naming the attribute and line, with the bare-name fix.
:loopis a CONTAINER directive — this is the #1 migration mistake. The element that carries:loopis rendered once; its children are what repeat, once per item, with the current item bound to$loop.it. So to turn a JSXitems.map(i => <li>{i}</li>)into N<li>s, put:loopon the wrapper (<ul>) and make the repeated node (<li>) its child:<!-- CORRECT: one <ul>, one <li> per item --> <ul :loop="$items"><li :text="$loop.it"></li></ul> <!-- WRONG: :loop on the <li> renders ONE <li> whose children repeat, i.e. a single <li> containing N <span>s — not N <li>s. --> <li :loop="$items"><span :text="$loop.it"></span></li>If the items are objects/maps, index fields with
$loop.it.get('field')(e.g.<ul :loop="$rows"><li :text="$loop.it.get('name')"></li></ul>). The loop body can contain multiple repeated children — they all repeat together per item.
:ifdoes NOT make the element conditional — and there is no:else. Three separate traps, all of which used to build green and emit wrong HTML:
:ifon a real element only gates its BODY. The tag and every one of its attributes are emitted either way, with$ifbound to the value. So<a aria-current="page" :if="$isCurrent">Docs</a>marks every nav link as the current page and merely blanks the ones that should not be — the exact bug this repo shipped. To make the element itself conditional, wrap it in<ctx>, which never prints a tag of its own:<ctx :if="$isCurrent"><a aria-current="page" href="$url">Docs</a></ctx> <ctx :if="$isCurrent.not()"><a href="$url">Docs</a></ctx>
:if/:loopon a void element is a build error.<img>,<br>,<input>, … (and a self-closing<item/>in an.xmlalternative layout) have no end tag, and SuperHTML restarts a conditional or a loop by rewinding to the end tag. With none it rewinds to the start of the file and splices the whole raw template source into the page, so the build rejects it. Wrap the element instead.There is no
:else. SuperHTML parses it and then never evaluates it; it is now a build error. Write the negated condition on a second<ctx>, as in the pair above.
No :with/scoping directive — repeat the full path (or flatten the frontmatter). There is no way to bind a sub-object to a short alias for a block: :with and $ctx/$with do not exist (you’ll get builtin function not found). Reference nested .custom frontmatter with the full Scripty path each time:
<!-- nested: .custom = { .hero = { .eyebrow = "Hi", .title = "We build things" } } -->
<p :text="$page.custom.get('hero').get('eyebrow')"></p>
<h1 :text="$page.custom.get('hero').get('title')"></h1>
Two ways to keep this manageable:
- Iterating a list of objects already scopes —
:loopbinds each element to$loop.it, so$loop.it.get('title')is the per-item short form. The full-path repetition only hurts for a single deeply-nested object referenced many times. - Flatten in frontmatter for that single-object case: lift the values you use repeatedly to top-level
.customkeys (.custom = { .hero_eyebrow = "Hi", .hero_title = "…" }) so the layout reads$page.custom.get('hero_eyebrow')— one.getinstead of a chain. This keeps the typed nested shape out of the hot path.
(A real :with/scoping directive would be a SuperHTML/Scripty change — the static layer is inherited from the upstream SSG (see the repository README’s Acknowledgements) — so it is out of scope here; this is the current, documented behaviour.)
6. Interactive components → TSX islands (the core of the work)
The migration from React to a Zigapagos island is a near-mechanical import swap, not a from-scratch rewrite. Hooks, JSX, events, and component structure are identical; the only changes are:
- Rename the file
<Name>.island.tsx. - Swap
import … from "react"/"react-dom"→import … from "@z/runtime". - Replace
document.*/window.*calls withhost.*equivalents where SSR-safety matters (anything that must not execute on the server). - Remove third-party npm imports (see no-npm guardrail).
Before (React):
import { useState } from "react";
interface Props { headline: string }
export default function Hero({ headline }: Props) {
const [open, setOpen] = useState(false);
return (
<section>
<h1>{headline}</h1>
<button onClick={() => setOpen(!open)}>{open ? "−" : "+"}</button>
</section>
);
}
After (Zigapagos .island.tsx):
import { useState } from "@z/runtime";
export interface Props { headline: string }
export default function Hero({ headline }: Props) {
const [open, setOpen] = useState(false);
return (
<section>
<h1>{headline}</h1>
<button onClick={() => setOpen(!open)}>{open ? "−" : "+"}</button>
</section>
);
}
And in a layout:
<island src="components/Hero.island.tsx" client:load prop-headline="$page.title"></island>
Component model mapping:
| React / Astro | Zigapagos TSX island |
|---|---|
import { useState } from "react" | import { useState } from "@z/runtime" |
import { createPortal } from "react-dom" | import { createPortal } from "@z/runtime" |
document.cookie | host.cookies.get(name) / host.cookies.set(name, value, opts?) |
window.location.pathname | host.pathname() |
window.location.search | host.search() |
window.location.hash | host.hash() |
window.scrollY via listener | host.onScroll(cb, signal?) |
window.matchMedia(q) | host.matchMedia(query, cb, signal?) |
fetch(url) | host.fetchShared(url, storeName) (shared) or host.fetchOpts(req) (richer) |
window.zigapagosOnError seam | host.reportError(msg) |
| script injection | host.loadScript(url) |
| cross-island shared state | host.store.* + useSyncExternalStore |
Use host.* for anything that touches the DOM or browser APIs directly. Everything else — hooks, context, refs, memos, reducers — is standard Preact-compat and needs no changes. See the recipes for the full host.* API table and worked examples.
7. Client directives (1:1 names)
| Astro | Zigapagos | Behaviour |
|---|---|---|
client:load | client:load | Hydrate immediately. |
client:idle | client:idle | Hydrate on requestIdleCallback. |
client:visible | client:visible | Hydrate when scrolled into view. |
client:media="(q)" | client:media="(q)" | Hydrate when the media query matches. |
client:only | client:only | No SSR; mount fresh on the client. |
8. Props
| Astro | Zigapagos |
|---|---|
<C count={5} label="hi" /> | <island src="C.island.tsx" … prop-count="5" prop-label="hi"> |
<C config={{a:1}} /> (structured) | prop-config="$page.custom.get('cfg').toJson()" |
| props are JS values | props are serialised to JSON and passed via data-z-props; typed against Props at SSR |
- Scalar props (
string,number,boolean) useprop-NAME="$expr"— the SuperHTML/Scripty expression is evaluated at build time and JSON-serialised. - Structured props (objects, arrays) use
.toJson()on a Scripty expression:prop-items="$page.custom.get('faq').toJson()". The component’s typedPropsfield is then JSON-parsed. - Props are dev-validated against the exported
Propsinterface at SSR time.
<!-- .custom = { .faq = [ { .q = "…", .a = "…" }, … ] } -->
<island src="components/FAQList.island.tsx" client:visible
prop-items="$page.custom.get('faq').toJson()"></island>
export interface Item { q: string; a: string }
export interface Props { items: Item[] }
9. Slots / children
Both the default slot and named slots are supported, Astro-style. Content between <island> tags is rendered by SuperHTML first, then routed to the island: <template slot="NAME"> blocks become named slots; everything else becomes the default slot (children).
| Astro | Zigapagos |
|---|---|
<slot /> (in component) | {children} — declare children?: ComponentChildren in Props |
<slot name="heading" /> (in component) | {slots?.heading} — declare slots?: Slots in Props |
<slot>fallback</slot> (fallback content) | {children ?? <p>fallback</p>} / {slots?.heading ?? <h2>{title}</h2>} |
<C><div slot="heading">…</div></C> (usage) | <island …><template slot="heading">…</template></island> |
<C>default content</C> (usage) | <island …>default content</island> |
<island src="components/Panel.island.tsx" client:load :props='{ .title = "Panel" }'>
<template slot="heading"><h2>Custom Heading</h2></template>
<p>default body</p>
</island>
import type { ComponentChildren } from "@z/runtime";
import type { Slots } from "@z/runtime";
export interface Props {
title: string;
children?: ComponentChildren; // default slot
slots?: Slots; // named slots
}
export default function Panel({ title, children, slots }: Props) {
return (
<section>
<header>{slots?.heading ?? <h2>{title}</h2>}</header>
<div>{children}</div>
</section>
);
}
- Slot content is opaque, already-rendered HTML — it may use full SuperHTML/Scripty, but the island receives it as pre-rendered DOM, not as VNodes it can introspect or map over.
childrenandslotsare reserved prop names; declare both optional and fall back gracefully (?? <Default/>).- Nested
<island>tags inside slot content work: they SSR in place and hydrate independently.
See recipes — slot composition for the full rules (whitespace trimming, slot="default", hydration mechanics).
10. Build wiring (replaces bundler config)
One zigapagos release invocation builds the whole site. It:
- Spawns a Bun sidecar to SSR each island (produces the HTML fragment +
data-z-propsJSON injected into the page). - Bundles each island to an ES module at
/islands/<Name>.island.js, with@z/runtimekept external. - Emits
/zigapagos-runtime.js(the shared Preact bundle) and an import map wiring"@z/runtime"to it, ensuring one Preact instance.
Put it in a build.sh so there is one place your entries are declared:
#!/usr/bin/env bash
set -euo pipefail
cd ""
bun install --frozen-lockfile 2>/dev/null || bun install
exec zigapagos release \
--force \
--output=public \
--island-props-check=error \
--island=components/Hero.island.tsx \
--island=components/Promo.island.tsx \
"$@"
Each --island= value is the string you write in <island src="...">. zigapagos init --from-astro writes this file for you, with one line per detected island.
The consumer project also needs a Bun project for the island deps:
// package.json
{ "dependencies": { "@z/runtime": "file:../../runtime" } }
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@z/runtime",
"moduleResolution": "bundler",
"strict": true
}
}
build.sh runs bun install first so the Bun sidecar can resolve @z/runtime. See examples/tsx-site/ for a complete working project (build.sh, package.json, tsconfig.json, components/Hero.island.tsx, layouts/index.shtml, and the test/ssr.sh + test/hydrate.sh test scripts).
Toolchain — you need
zigapagosandbun, and no Zig.zigapagosis a standalone executable: install it withnpx zigapagos(which brings its runtime tree and Bun with it) or download a precompiled binary from the releases page. Nothing in a migrated project is compiled from Zig source, so no Zig toolchain error can come from your site — if you hit one, it is coming from a source build of zigapagos itself, not from the migration.
11. Content collections & site-wide data
Per-section collections
| Astro | Zigapagos |
|---|---|
src/content/blog/*.md + schema | content/blog/*.smd + section index.smd |
getCollection("blog") | $page.subpages() from the section index |
collection schema (zod) | Ziggy frontmatter (typed) + frontmatter.ziggy-schema |
Site-wide shared data (the “content database” singleton)
Many Astro sites are content-DB-driven: one singleton (e.g. src/content/site/main.json) holds owner bio, contact, hours, nav, hero copy, discount config, etc., and every page reads it via getSite(). That’s one source of truth, many consumers — not a per-page value.
Zigapagos models this with a global data layer: drop a Ziggy file under the project’s data/ directory and read it from any layout with $site.data('<name>').
| Astro | Zigapagos |
|---|---|
src/content/site/main.json (singleton) | data/site.ziggy |
const site = await getSite() | $site.data('site') (a Ziggy map) |
site.owner.name | $site.data('site').get('owner').get('name') |
site.nav.map(...) | <ul :loop="$site.data('site').get('nav')">…</ul> |
// data/site.ziggy
{
.owner = { .name = "Jane Runner", .email = "jane@example.com" },
.hours = "Mon-Fri 9-5",
.nav = ["Home", "Services", "Contact"],
}
<!-- any layout, read the same data everywhere -->
<p :text="$site.data('site').get('owner').get('name')"></p>
<ul :loop="$site.data('site').get('nav')"><li :text="$loop.it"></li></ul>
- The file is parsed once at build time and shared across every page.
- Each
data/<name>.ziggyis its own namespace. Index with.get('field'). - The directory is configurable via
data_dir_pathinzigapagos.ziggy(defaultdata). - Use this for genuinely site-global data. For per-page values, keep using
.customfrontmatter (§4).
Build-time config into islands. Use $site.data(...) to pass build-time config (public API keys, feature endpoints, CDN base) into islands as props:
<island src="components/ContactForm.island.tsx" client:visible
prop-endpoint="$site.data('config').get('form_endpoint')"></island>
Keep secrets server-side; only public, client-safe config belongs in data/.
12. Styling
| Astro | Zigapagos |
|---|---|
<style> scoped in .astro | a CSS file in assets/, linked from the layout |
import "./x.css" | <link rel="stylesheet" href="…"> (asset) |
| Tailwind/integrations | plain CSS in assets/ (no integration system yet) |
13. SPA mode (client-routed apps)
For an Astro site that embeds a client-routed app (React Router, wouter, …), port the app to a single .spa.tsx with exported spa config and routes. Zigapagos prerenders every route’s skeleton, emits a host-agnostic routing-manifest.json, and @z/runtime’s first-party Router handles History-API soft navigation, nested/layout routes, async guards, lazy (code-split) routes, and scroll restoration.
A translation table: <BrowserRouter>+<Routes> → export const routes = [...]
<Router routes={routes}>;useParams/useNavigate/useSearchParams→ same names from@z/runtime;getStaticPathson[id].astro→staticPathson the dynamic route.
A dynamic route prerenders one _shell.html fallback; add staticPaths to also emit real per-entry pages:
{ path: "/club/:id", component: ClubDetail, skeleton: ClubSkeleton,
staticPaths: async () => (await loadClubs()).map((c) => ({ id: c.id })) }
Full reference: docs/spa.md.
Gaps (not yet supported)
Flag these during migration; use the workaround:
- Dynamic routes (
[slug]) in static content — the content layer has nogetStaticPaths; generate one.smdper entry. For app-like pages, a.spa.tsxdynamic route (/club/:id) with astaticPathshook prerenders one real page per enumerated entry (see SPA mode). client:only="framework"— there is one runtime, so the framework argument is meaningless; write plainclient:only. A value on any directive other thanclient:mediafails the build with a clear error.window.location— usehost.pathname(),host.search(), andhost.hash()(SSR/client parity: on the serversearchcomes from the build-time SSR URL when one carries a query, else"";hashis always""server-side — fragments never reach a server). Inside a SPA, prefer the router hooks (useLocation,useSearchParams,useParams).- Client-side routing / History API on classic island pages — a multi-page island site has no soft navigation; use plain
<a href>. For a client-routed app, use SPA mode (§13):@z/runtime’sRouterships pushState/popstate soft-nav, nested routes, guards, and lazy chunks. - Third-party npm packages in islands — allowed only via the opt-in bridge: add the package to
z-runtime.config.jsonunderislandImports.npmCompat(React-compatible packages, bundled per-island withreactaliased to the shared runtime) orislandImports.firstParty(your own scopes). See the npm guardrail. Packages that bundle their own React/Preact copy stay unsupported. - Implicit React context tree across island boundaries — islands are isolated Preact roots. Coordinate via
host.store.*+useSyncExternalStore. For auth/session, callhost.fetchShared("/api/session", "session")in each island that needs it — the runtime makes one request and shares the result. (Within a single.spa.tsxapp, context works normally — it is one tree.)
Migration procedure (for an agent)
Scaffold the target:
zigapagos.ziggy(§2),content/,layouts/,assets/,components/,build.sh,package.json,tsconfig.json.Run
zigapagos migrate <astro-dir>to generateMIGRATION.md: a ready-to-follow worklist with all islands detected.Example:
zigapagos migrate src/my-astro-site -o MIGRATION.mdThat report is the only thing
migrateproduces. It reads<astro-dir>and converts nothing in it: steps 2-7 below are the conversion, and the mapping sections above are what they follow. The one exception is--scaffold DIR, which writes a starter island per detected island — React imports already rewritten,interface Propscarried over — as a head start on step 4.Static layer: convert each
src/pages/*.astro→content/*.smd(§4) and eachsrc/layouts/*.astro→layouts/*.shtml(§5). Map routing per §3.Inventory islands: list every component used with a
client:*directive. Each becomes acomponents/<Name>.island.tsx.Port each island: rename to
.island.tsx, swapreact/react-domimports to@z/runtime, replace direct DOM/browser calls withhost.*, remove npm deps not in the allowed set. See recipes.Props/directives/slots: translate per §7–§9. Check the gaps.
Build wiring: add one
--island=per island tobuild.sh(§10). Run it; fix SSR/TS diagnostics until clean.Verify: run the site and confirm each island SSRs correctly and hydrates.
Machine-readable diagnostics for step 6’s fix loop (issue #46). An agent driving this migration unattended can run the underlying release build with --format=json (e.g. zigapagos release --format=json -o public) to get one NDJSON object per build error on stderr instead of prose — a stable code field to switch on, plus file/line/col when known. Run zigapagos explain-code <CODE> for the long-form fix for any code that shows up (zigapagos explain-code with no argument lists them all). See docs/diagnostics.md for the full schema and stability contract, including what stays prose (page-render errors, most Config.load fatals) as of this writing.