diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 0cd4555..c4d63c6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -166,8 +166,25 @@ jobs:
if [ "$runtime" = "zero" ]; then
cd site
bun install --frozen-lockfile
- sf build
- test -f .spacefast/zero/public/index.html
+ if jq -e '.scripts.build' package.json >/dev/null; then
+ bun run build
+ fi
+ if [ -f dist/sf.jsonc ]; then
+ # Framework layout: the build script staged the capsule into
+ # dist/ and already ran `sf build dist` (e.g. astro-zero).
+ test -f dist/.spacefast/zero/public/index.html
+ else
+ sf build
+ test -f .spacefast/zero/public/index.html
+ fi
+ if jq -e '.scripts.test' package.json >/dev/null; then
+ bun run test
+ fi
+ elif [ "$runtime" = "functions" ]; then
+ cd site
+ bun install --frozen-lockfile
+ # Sanity build only: `sf publish` runs its own OpenNext build.
+ bun run build
elif [ -f site/package.json ]; then
cd site
bun install --frozen-lockfile || bun install
@@ -178,10 +195,13 @@ jobs:
working-directory: recipes/${{ matrix.slug }}
run: |
runtime=$(jq -r '.runtime // "static"' meta.json)
- if [ "$runtime" = "zero" ]; then
+ if [ "$runtime" = "zero" ] && [ -f site/dist/sf.jsonc ]; then
+ dir=site/dist
+ elif [ "$runtime" = "zero" ] || [ "$runtime" = "functions" ]; then
dir=site
elif [ -d site/dist ]; then dir=site/dist
elif [ -d site/build ]; then dir=site/build
+ elif [ -d site/out ]; then dir=site/out
else dir=site
fi
echo "dir=$dir" >> "$GITHUB_OUTPUT"
@@ -196,6 +216,7 @@ jobs:
SPACEFAST_API_URL: https://api.spacefast.com
run: |
space_slug=$(jq -er '.publish_slug // .slug' meta.json)
+ runtime=$(jq -r '.runtime // "static"' meta.json)
publish_args=(
"${{ steps.dir.outputs.dir }}"
--team spacefast-examples
@@ -206,6 +227,10 @@ jobs:
--wait
--json
)
+ if [ "$runtime" = "zero" ]; then
+ # The Build step already ran the framework build and `sf build`.
+ publish_args+=(--skip-build)
+ fi
set +e
result=$(sf publish "${publish_args[@]}" --space "$space_slug" 2>&1)
diff --git a/.gitignore b/.gitignore
index 0a9cc70..aa3bb11 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,12 @@
node_modules/
dist/
build/
+out/
+.next/
+.open-next/
.stattic/
.spacefast/
+__spacefast/
.astro/
.DS_Store
*.log
diff --git a/recipes/astro-zero/README.md b/recipes/astro-zero/README.md
new file mode 100644
index 0000000..d1794cd
--- /dev/null
+++ b/recipes/astro-zero/README.md
@@ -0,0 +1,130 @@
+# Marginalia — Astro + Spacefast Zero
+
+An Astro blog whose every post carries a realtime comment thread and emoji
+reactions, published as **one** space. Astro renders the essays at build time;
+a Spacefast Zero capsule owns the conversation underneath them.
+
+Live:
+
+## The composition
+
+A Zero space publishes a *project root*, not a folder of files: the CLI walks
+that root, compiles `server/` into the version's runtime artifact, and publishes
+everything else as content. Astro, meanwhile, refuses to build into its own
+project root. So the two are stacked rather than merged: **`site/dist/` is both
+Astro's build output and the Zero project root.** `astro build` fills it,
+`scripts/stage-capsule.mjs` copies `sf.jsonc`, `server/` and `shared/` in beside
+the pages, and `sf build dist` compiles the capsule and assembles the publish
+tree.
+
+The one trick that makes this work is in `sf.jsonc`: `runtime.client` names
+`client/index.tsx` and **that file does not exist**. A Zero capsule with no
+client entry is server-only — it generates no app shell, so nothing claims
+`index.html` and Astro's homepage survives. (Create `client/index.tsx` and the
+generated shell shadows it — the build log says
+`Warning: index.html is not published`.) The
+live half of the page is instead a Preact island that Astro bundles itself,
+importing `useQuery`/`useMutation`/`useAuth` straight from `@spacefast/zero/client`.
+The client discovers the runtime from the page's own origin, so the island takes
+exactly one prop: the post slug.
+
+```
+site/
+ src/ Astro: pages, layouts, Markdown collection, island
+ server/index.ts capsule: tables, queries, mutations, /api/comments
+ shared/comments.ts pure TS both halves import (limits, cleanup, types)
+ sf.jsonc space config + zero runtime declaration
+ scripts/stage-capsule.mjs
+ dist/ astro build output == the published Zero project root
+```
+
+## What it exercises
+
+| Surface | Where |
+| --- | --- |
+| Astro static build, content collections, islands | `src/` |
+| `@spacefast/astro` integration, `mode: "static"` | `astro.config.mjs` |
+| Merged `_redirects` / `_headers` (inline + on-disk `_headers`) | `astro.config.mjs`, `_headers` |
+| Zero schema, indexes, queries, mutations | `server/index.ts` |
+| Realtime invalidation over Cast (`ctx.invalidate`) | `addComment`, `toggleReaction` |
+| Guest identity, no signup | `ctx.auth` / `useAuth()` |
+| HTTP endpoints (`GET`/`POST /api/comments`) | `server/index.ts` |
+
+`/latest` and `/comments` are redirects declared inline in `astro.config.mjs`;
+the security and cache headers come from the `_headers` file next to it. The
+integration merges both, mirrors them in `astro dev`, and writes the compiled
+files into `dist/`.
+
+## Run it
+
+```sh
+cd site
+bun install
+
+bun run dev # astro dev on :4321 — pages, layout, copy, redirect parity
+bun run build # astro build → stage capsule → sf build dist
+bun run runtime # sf dev --dir dist — the capsule's database and endpoints
+```
+
+`bun run runtime` reads `dist/`, so it needs a `bun run build` first.
+
+`sf dev` starts the **capsule** dev server. It has no static-file lane, so it
+serves the runtime and its endpoints but not the Astro pages — the two local
+servers are separate today. Verify the runtime through its endpoints; `sf dev`
+prints a private URL containing a capability token, which is also accepted as a
+bearer token:
+
+```sh
+CAP=
+curl -H "Authorization: Bearer $CAP" \
+ 'http://127.0.0.1:4173/api/comments?post=emoji-are-punctuation-now'
+
+curl -X POST -H "Authorization: Bearer $CAP" -H 'content-type: application/json' \
+ -d '{"post":"emoji-are-punctuation-now","author":"Ada","body":"Read it twice."}' \
+ http://127.0.0.1:4173/api/comments
+```
+
+## Publish
+
+```sh
+cd site
+bun install
+bun run build
+sf publish dist --wait
+```
+
+`sf publish` is given `dist`, not `site` — that directory is the project root
+holding `sf.jsonc`. Live, the same endpoints need no token:
+
+```sh
+curl 'https://.view.fast/api/comments?post=emoji-are-punctuation-now'
+```
+
+## For CI
+
+The stock Zero recipe lane (`bun install && sf build`, then publish `site/`) does
+**not** work here: `sf build` on a Zero project compiles the capsule and stops —
+it never runs a framework build, even when `package.json` has one. This recipe
+needs:
+
+```sh
+cd recipes/astro-zero/site
+bun install --frozen-lockfile
+bun run build # astro build → stage → sf build dist
+test -f dist/.spacefast/zero/public/index.html
+# then publish the directory recipes/astro-zero/site/dist
+```
+
+## Notes and known edges
+
+- Every build ships the Zero platform bundle (`/_spacefast/platform/…`,
+ `client.js`, `zero.css`) even though the pages never load it — the island
+ bundles its own copy of the client through Vite. It is inert weight in the
+ version, not on the wire.
+- `astro build` empties `dist/`, which also removes the `.spacefast/` state the
+ CLI writes there. Publish with `--space` (or re-link) after a rebuild.
+- The island's loading state keys off the reactions query rather than the
+ comments query: `useQuery` returns `[]` before its first value arrives, so an
+ empty comment list cannot tell you whether the thread is empty or still
+ asking. The reactions query always resolves to one row per emoji, which makes
+ its length an honest "the runtime answered" signal.
diff --git a/recipes/astro-zero/meta.json b/recipes/astro-zero/meta.json
new file mode 100644
index 0000000..eb53983
--- /dev/null
+++ b/recipes/astro-zero/meta.json
@@ -0,0 +1,25 @@
+{
+ "name": "Marginalia",
+ "title": "Astro blog with live comments",
+ "slug": "astro-zero",
+ "vertical": "Developers & integrations",
+ "tech": "Astro + Spacefast Zero",
+ "runtime": "zero",
+ "summary": "A prerendered Astro blog whose every post carries realtime comments and emoji reactions from a Spacefast Zero capsule, published as one space.",
+ "order": 320,
+ "live_url": "https://astro-zero.view.fast/",
+ "setup_questions": [
+ "What is the blog called, and what is it about?",
+ "What are the first three or four posts?",
+ "Warm and editorial, or cool and technical?"
+ ],
+ "photo_terms": [],
+ "sections": [
+ "Prerendered Astro index and post pages from a content collection",
+ "Realtime comment thread on every post, backed by a Zero capsule",
+ "Emoji reactions counted per post, one per reader",
+ "Guest identity with no sign-up, plus a JSON comments endpoint"
+ ],
+ "based_on": "The blog-with-a-comment-section that every static site generator gives up on: Astro renders the words, Zero owns the conversation.",
+ "notes": "Publishes from site/ as a Zero project root, like zero-perfect does — but the capsule is server-only, so Astro's HTML wins the page instead of the generated Zero app shell. Astro builds into site/dist/ and the capsule sources are staged beside that output, so the published root is site/dist. `sf build` does not run framework builds for a Zero project; the sequence is `bun install && bun run build`, then publish site/dist. `sf dev` serves the capsule's endpoints and runtime but has no static-file lane, so it never serves the Astro pages — use `bun run dev` for pages and `sf dev -d dist` for the runtime."
+}
diff --git a/recipes/astro-zero/prompt.md b/recipes/astro-zero/prompt.md
new file mode 100644
index 0000000..cc1357a
--- /dev/null
+++ b/recipes/astro-zero/prompt.md
@@ -0,0 +1,90 @@
+Build me a small, beautiful **blog in Astro** where every post has **realtime
+comments and emoji reactions**, powered by a Spacefast Zero capsule, published as
+one site.
+
+**Before you build, ask me these questions in one message and wait for my answers.
+If I skip anything, choose a sensible default and tell me what you chose:**
+
+1. What's the blog called, and what's it about?
+2. What are the first three or four posts? Titles are enough — you'll write them.
+3. Warm and editorial, or cool and technical?
+
+**Then build it as one Spacefast Zero project:**
+
+- An Astro site in `site/`, static output, posts as a content collection in
+ Markdown. An index page that lists the posts and a page per post.
+- `site/sf.jsonc` declaring the Zero runtime:
+ ```jsonc
+ {
+ "$schema": "https://spacefast.com/schemas/sf.json",
+ "name": "",
+ "access": "public",
+ "runtime": {
+ "kind": "zero",
+ "server": "server/index.ts",
+ // Named but deliberately absent: with no file behind it the capsule is
+ // server-only, ships no app shell of its own, and Astro's HTML stays the
+ // page. That is the whole trick that lets a framework build sit in front
+ // of Zero. (`runtime.client` is still required by the config schema.)
+ "client": "client/index.tsx",
+ },
+ }
+ ```
+ Do not create `client/index.tsx`. If it exists, the generated Zero app shell
+ claims `index.html` and quietly shadows Astro's homepage.
+- `site/server/index.ts` — the capsule: a `comments` table and a `reactions`
+ table, both indexed by post slug; `comments` and `reactions` queries; an
+ `addComment` mutation that trims and length-limits its input and calls
+ `ctx.invalidate("comments")`; a `toggleReaction` mutation that lets one reader
+ hold one emoji per post; and a `GET`/`POST` `/api/comments` endpoint so the
+ thread is readable and writable without a browser.
+- `site/src/components/Margin.tsx` — a Preact island that imports `useQuery`,
+ `useMutation` and `useAuth` from `@spacefast/zero/client`. Mount it
+ `client:only="preact"`; the Zero client belongs to the browser, so the live
+ half of the page should never be rendered at build time. It discovers the
+ runtime from the page's own origin, so its only prop is the post slug. Readers
+ are guests by default; nobody signs up to leave a comment.
+- Keep the input cleanup and the shared types in `site/shared/`, imported by both
+ the island and the server, so the two sides cannot drift.
+- Wire `@spacefast/astro` into `astro.config.mjs` with `mode: "static"`, and put
+ at least one redirect and a couple of response headers through it. The
+ integration merges inline rules with a `_headers` file, applies them in
+ `astro dev` too, and writes the compiled `_redirects`/`_headers` into the build
+ output.
+
+**Design & content notes:**
+
+- Editorial and typographic: a real reading measure, a serif for headings, one
+ accent color, and a dark mode that follows the system.
+- Write the posts. Real paragraphs with a point of view, 500–800 words each —
+ never "lorem ipsum" and never an outline pretending to be an essay.
+- The comment thread is part of the page, not a widget bolted under it: same
+ measure, same type scale, visible focus states, a labeled input, and honest
+ empty and pending states.
+- Keep it accessible (semantic HTML, real labels, keyboard support, good
+ contrast) and responsive down to 320px.
+
+**Add this exact line right before `