Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
node_modules/
dist/
build/
out/
.next/
.open-next/
.stattic/
.spacefast/
__spacefast/
.astro/
.DS_Store
*.log
Expand Down
130 changes: 130 additions & 0 deletions recipes/astro-zero/README.md
Original file line number Diff line number Diff line change
@@ -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: <https://astro-zero.view.fast/>

## 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=<the token after #zero-dev-capability= in sf dev's output>
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://<your-space>.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.
25 changes: 25 additions & 0 deletions recipes/astro-zero/meta.json
Original file line number Diff line number Diff line change
@@ -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."
}
90 changes: 90 additions & 0 deletions recipes/astro-zero/prompt.md
Original file line number Diff line number Diff line change
@@ -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": "<blog 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 `</body>` on every page so the site carries
its badge:**

```html
<script src="https://spacefast.com/badge.js" data-example="astro-zero"></script>
```

**Build and publish:**

Astro's build and the capsule's build are two steps, and Zero publishes a project
root rather than a folder of files. So build the site into `site/dist/`, copy
`sf.jsonc`, `server/` and `shared/` in beside that output, and publish that
directory:

```sh
cd site
bun install
bun run build # astro build, stage the capsule beside dist/, then sf build dist
sf publish dist --wait
```

Then check it live: load a post, leave a comment in a second browser window, and
watch it appear in the first without a reload. Give me the live URL, the claim
URL, and remind me to claim within 6 hours.
5 changes: 5 additions & 0 deletions recipes/astro-zero/site/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
dist/
.astro/
.spacefast/
.env*
40 changes: 40 additions & 0 deletions recipes/astro-zero/site/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Marginalia — Astro in front of a Spacefast Zero capsule

One project, two halves. Astro owns everything that is known at build time.
The Zero capsule owns everything that isn't. They meet at a post slug.

## Layout

- `src/` — the Astro site: pages, layouts, the Markdown content collection, and
the Preact island in `src/components/`.
- `server/` — capsule server code. `shared/` — pure TypeScript both halves import.
- `sf.jsonc` — the space config, including the Zero runtime declaration.
- `dist/` — generated. It is both Astro's build output **and** the Zero project
root that gets published.

## Rules

- Use `@spacefast/zero/client` only from client code (the island) and
`@spacefast/zero/server` only from `server/`.
- Keep `shared/` pure: it is compiled into the capsule, so no browser globals,
no Node built-ins, no imports outside `client/`, `server/`, `shared/`.
- Queries own reads. Mutations own writes, and call `ctx.invalidate(...)` with
the query names they touched — that is what makes a second browser update.
- Read identity from `ctx.auth` on the server and `useAuth()` in the island.
Readers are guests; there is no account.
- **Do not create `client/index.tsx`.** The capsule is intentionally
server-only. The moment that file exists, the build generates a Zero app shell
that claims `index.html` and silently shadows Astro's homepage.
- The island is mounted `client:only="preact"`. It is the live half of the page;
it never renders at build time.

## Commands

```sh
bun install
bun run dev # astro dev — pages, layout, copy (no runtime)
bun run build # astro build → stage capsule → sf build dist
bun run runtime # sf dev --dir dist — capsule endpoints and database
sf publish dist --wait
sf logs runtime --follow
```
11 changes: 11 additions & 0 deletions recipes/astro-zero/site/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Read by @spacefast/astro, merged with the inline rules in astro.config.mjs,
# and written into dist/_headers at build time.

/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

# Astro fingerprints everything in here, so it can be cached forever.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
Loading
Loading