From 665233581629fb650b6221cd50ea3ded468ecd6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 8 Nov 2026 22:29:55 +0000 Subject: [PATCH 1/2] Move code-based recipes to top-level examples wp-zero, astro-zero, wp-astro, wp-react, zero-perfect, unfurl, and trailhead are framework integrations, not prompt recipes. Each recipes//site now lives at / with a README written as an example; their meta.json and prompt.md are gone. Pins move from 0.4.1 to 0.8.0 for spacefast, @spacefast/zero, @spacefast/wordpress, @spacefast/astro, and @spacefast/image. endpoint() no longer takes a mode literal; the method decides. wp-zero's build tool calls sf build --skip-build, because sf build 0.8 runs the package build script first and would otherwise re-enter itself. trailhead uses the documented spacefastNextImageConfig import now that the package exports a default condition. The examples workflow builds all seven, discover-recipes skips recipe directories that no longer exist, and the catalog test no longer expects zero-perfect in the recipe feed. --- .github/workflows/publish.yml | 25 ++- README.md | 12 +- .../astro-zero/site => astro-zero}/.gitignore | 0 .../astro-zero/site => astro-zero}/AGENTS.md | 0 astro-zero/README.md | 54 ++++++ .../astro-zero/site => astro-zero}/_headers | 0 .../site => astro-zero}/astro.config.mjs | 0 .../astro-zero/site => astro-zero}/bun.lock | 28 ++- .../site => astro-zero}/package.json | 4 +- .../scripts/stage-capsule.mjs | 0 .../site => astro-zero}/server/index.ts | 4 +- .../astro-zero/site => astro-zero}/sf.jsonc | 0 .../site => astro-zero}/shared/comments.ts | 0 .../src/components/Margin.tsx | 0 .../site => astro-zero}/src/content.config.ts | 0 .../posts/emoji-are-punctuation-now.md | 0 .../static-isnt-the-opposite-of-alive.md | 0 .../the-first-three-hundred-milliseconds.md | 0 .../your-comment-section-is-a-database.md | 0 .../src/layouts/Base.astro | 0 .../site => astro-zero}/src/pages/index.astro | 0 .../src/pages/posts/[slug].astro | 0 .../site => astro-zero}/src/styles/global.css | 0 .../site => astro-zero}/tsconfig.json | 0 recipes/astro-zero/README.md | 130 ------------- recipes/astro-zero/meta.json | 25 --- recipes/astro-zero/prompt.md | 90 --------- recipes/trailhead/README.md | 111 ----------- recipes/trailhead/meta.json | 35 ---- recipes/trailhead/prompt.md | 91 --------- recipes/trailhead/site/next.config.ts | 28 --- recipes/unfurl/README.md | 183 ------------------ recipes/unfurl/meta.json | 29 --- recipes/unfurl/prompt.md | 67 ------- recipes/wp-astro/README.md | 26 --- recipes/wp-astro/meta.json | 30 --- recipes/wp-astro/prompt.md | 58 ------ recipes/wp-react/README.md | 48 ----- recipes/wp-react/meta.json | 30 --- recipes/wp-react/prompt.md | 85 -------- recipes/wp-zero/README.md | 145 -------------- recipes/wp-zero/meta.json | 28 --- recipes/wp-zero/prompt.md | 91 --------- recipes/wp-zero/site/package.json | 19 -- recipes/zero-perfect/README.md | 21 -- recipes/zero-perfect/meta.json | 23 --- recipes/zero-perfect/prompt.md | 23 --- scripts/catalog.test.mjs | 12 -- .../trailhead/site => trailhead}/.gitignore | 0 trailhead/README.md | 49 +++++ .../site => trailhead}/app/globals.css | 0 .../site => trailhead}/app/layout.tsx | 0 .../site => trailhead}/app/not-found.tsx | 0 .../trailhead/site => trailhead}/app/page.tsx | 0 .../app/trails/[slug]/page.tsx | 0 .../trailhead/site => trailhead}/bun.lock | 4 +- .../components/site-chrome.tsx | 0 .../components/trail-card.tsx | 0 .../trailhead/site => trailhead}/data/site.ts | 0 .../site => trailhead}/data/trails.ts | 0 .../lib/spacefast-image-loader.ts | 0 .../trailhead/site => trailhead}/lib/units.ts | 0 trailhead/next.config.ts | 18 ++ .../trailhead/site => trailhead}/package.json | 2 +- .../site => trailhead}/public/_headers | 0 .../site => trailhead}/public/_redirects | 0 .../site => trailhead}/public/favicon.svg | 0 .../site => trailhead}/public/sf.jsonc | 0 .../site => trailhead}/tsconfig.json | 0 {recipes/unfurl/site => unfurl}/.gitignore | 0 unfurl/README.md | 53 +++++ .../site => unfurl}/app/api/unfurl/route.ts | 0 .../unfurl/site => unfurl}/app/globals.css | 0 .../unfurl/site => unfurl}/app/layout.tsx | 0 {recipes/unfurl/site => unfurl}/app/page.tsx | 0 .../unfurl/site => unfurl}/app/u/page.tsx | 0 {recipes/unfurl/site => unfurl}/bun.lock | 0 .../site => unfurl}/components/CardMedia.tsx | 0 .../components/PreviewCard.tsx | 0 {recipes/unfurl/site => unfurl}/lib/guard.ts | 0 {recipes/unfurl/site => unfurl}/lib/parse.ts | 0 {recipes/unfurl/site => unfurl}/lib/unfurl.ts | 0 {recipes/unfurl/site => unfurl}/middleware.ts | 0 .../unfurl/site => unfurl}/next.config.ts | 0 {recipes/unfurl/site => unfurl}/package.json | 0 {recipes/unfurl/site => unfurl}/sf.jsonc | 0 {recipes/unfurl/site => unfurl}/tsconfig.json | 0 .../wp-astro/site => wp-astro}/.gitignore | 1 + wp-astro/README.md | 26 +++ .../site => wp-astro}/astro.config.mjs | 0 {recipes/wp-astro/site => wp-astro}/bun.lock | 0 .../wp-astro/site => wp-astro}/package.json | 0 .../site => wp-astro}/public/favicon.svg | 0 .../src/components/PostCard.astro | 0 .../wp-astro/site => wp-astro}/src/config.ts | 0 .../src/layouts/Layout.astro | 0 .../wp-astro/site => wp-astro}/src/lib/wp.ts | 0 .../site => wp-astro}/src/pages/[slug].astro | 0 .../site => wp-astro}/src/pages/index.astro | 0 .../site => wp-astro}/src/styles/global.css | 0 .../wp-astro/site => wp-astro}/tsconfig.json | 0 .../wp-react/site => wp-react}/.gitignore | 1 + wp-react/README.md | 31 +++ {recipes/wp-react/site => wp-react}/bun.lock | 0 .../wp-react/site => wp-react}/index.html | 0 .../wp-react/site => wp-react}/package.json | 0 .../site => wp-react}/public/_redirects | 0 .../site => wp-react}/public/favicon.svg | 0 .../wp-react/site => wp-react}/src/App.jsx | 0 .../wp-react/site => wp-react}/src/api.js | 0 .../src/components/Header.jsx | 0 .../src/components/PostDetail.jsx | 0 .../src/components/PostList.jsx | 0 .../src/components/common.jsx | 0 .../wp-react/site => wp-react}/src/config.js | 0 .../wp-react/site => wp-react}/src/hooks.js | 0 .../wp-react/site => wp-react}/src/main.jsx | 0 .../wp-react/site => wp-react}/src/styles.css | 0 .../wp-react/site => wp-react}/vite.config.js | 0 {recipes/wp-zero/site => wp-zero}/.gitignore | 0 {recipes/wp-zero/site => wp-zero}/AGENTS.md | 23 ++- wp-zero/README.md | 96 +++++++++ .../wp-zero/site => wp-zero}/assets/search.js | 0 .../site => wp-zero}/assets/styles.css | 0 {recipes/wp-zero/site => wp-zero}/bun.lock | 22 +-- .../wp-zero/site => wp-zero}/client/index.tsx | 0 wp-zero/package.json | 17 ++ .../wp-zero/site => wp-zero}/server/index.ts | 14 +- {recipes/wp-zero/site => wp-zero}/sf.jsonc | 0 .../site => wp-zero}/shared/reactions.ts | 0 .../wp-zero/site => wp-zero}/shared/search.ts | 0 .../site => wp-zero}/test/build.test.ts | 0 .../wp-zero/site => wp-zero}/tools/build.ts | 12 +- .../wp-zero/site => wp-zero}/tools/config.ts | 4 +- .../wp-zero/site => wp-zero}/tools/render.ts | 0 .../site => wp-zero}/tools/wordpress.ts | 0 .../site => zero-perfect}/.gitignore | 0 .../site => zero-perfect}/AGENTS.md | 0 zero-perfect/README.md | 24 +++ .../site => zero-perfect}/bun.lock | 18 +- .../site => zero-perfect}/client/index.tsx | 0 .../site => zero-perfect}/package.json | 2 +- .../site => zero-perfect}/server/index.ts | 2 +- .../site => zero-perfect}/sf.jsonc | 0 .../site => zero-perfect}/shared/todo.ts | 0 145 files changed, 476 insertions(+), 1498 deletions(-) rename {recipes/astro-zero/site => astro-zero}/.gitignore (100%) rename {recipes/astro-zero/site => astro-zero}/AGENTS.md (100%) create mode 100644 astro-zero/README.md rename {recipes/astro-zero/site => astro-zero}/_headers (100%) rename {recipes/astro-zero/site => astro-zero}/astro.config.mjs (100%) rename {recipes/astro-zero/site => astro-zero}/bun.lock (96%) rename {recipes/astro-zero/site => astro-zero}/package.json (87%) rename {recipes/astro-zero/site => astro-zero}/scripts/stage-capsule.mjs (100%) rename {recipes/astro-zero/site => astro-zero}/server/index.ts (98%) rename {recipes/astro-zero/site => astro-zero}/sf.jsonc (100%) rename {recipes/astro-zero/site => astro-zero}/shared/comments.ts (100%) rename {recipes/astro-zero/site => astro-zero}/src/components/Margin.tsx (100%) rename {recipes/astro-zero/site => astro-zero}/src/content.config.ts (100%) rename {recipes/astro-zero/site => astro-zero}/src/content/posts/emoji-are-punctuation-now.md (100%) rename {recipes/astro-zero/site => astro-zero}/src/content/posts/static-isnt-the-opposite-of-alive.md (100%) rename {recipes/astro-zero/site => astro-zero}/src/content/posts/the-first-three-hundred-milliseconds.md (100%) rename {recipes/astro-zero/site => astro-zero}/src/content/posts/your-comment-section-is-a-database.md (100%) rename {recipes/astro-zero/site => astro-zero}/src/layouts/Base.astro (100%) rename {recipes/astro-zero/site => astro-zero}/src/pages/index.astro (100%) rename {recipes/astro-zero/site => astro-zero}/src/pages/posts/[slug].astro (100%) rename {recipes/astro-zero/site => astro-zero}/src/styles/global.css (100%) rename {recipes/astro-zero/site => astro-zero}/tsconfig.json (100%) delete mode 100644 recipes/astro-zero/README.md delete mode 100644 recipes/astro-zero/meta.json delete mode 100644 recipes/astro-zero/prompt.md delete mode 100644 recipes/trailhead/README.md delete mode 100644 recipes/trailhead/meta.json delete mode 100644 recipes/trailhead/prompt.md delete mode 100644 recipes/trailhead/site/next.config.ts delete mode 100644 recipes/unfurl/README.md delete mode 100644 recipes/unfurl/meta.json delete mode 100644 recipes/unfurl/prompt.md delete mode 100644 recipes/wp-astro/README.md delete mode 100644 recipes/wp-astro/meta.json delete mode 100644 recipes/wp-astro/prompt.md delete mode 100644 recipes/wp-react/README.md delete mode 100644 recipes/wp-react/meta.json delete mode 100644 recipes/wp-react/prompt.md delete mode 100644 recipes/wp-zero/README.md delete mode 100644 recipes/wp-zero/meta.json delete mode 100644 recipes/wp-zero/prompt.md delete mode 100644 recipes/wp-zero/site/package.json delete mode 100644 recipes/zero-perfect/README.md delete mode 100644 recipes/zero-perfect/meta.json delete mode 100644 recipes/zero-perfect/prompt.md rename {recipes/trailhead/site => trailhead}/.gitignore (100%) create mode 100644 trailhead/README.md rename {recipes/trailhead/site => trailhead}/app/globals.css (100%) rename {recipes/trailhead/site => trailhead}/app/layout.tsx (100%) rename {recipes/trailhead/site => trailhead}/app/not-found.tsx (100%) rename {recipes/trailhead/site => trailhead}/app/page.tsx (100%) rename {recipes/trailhead/site => trailhead}/app/trails/[slug]/page.tsx (100%) rename {recipes/trailhead/site => trailhead}/bun.lock (98%) rename {recipes/trailhead/site => trailhead}/components/site-chrome.tsx (100%) rename {recipes/trailhead/site => trailhead}/components/trail-card.tsx (100%) rename {recipes/trailhead/site => trailhead}/data/site.ts (100%) rename {recipes/trailhead/site => trailhead}/data/trails.ts (100%) rename {recipes/trailhead/site => trailhead}/lib/spacefast-image-loader.ts (100%) rename {recipes/trailhead/site => trailhead}/lib/units.ts (100%) create mode 100644 trailhead/next.config.ts rename {recipes/trailhead/site => trailhead}/package.json (93%) rename {recipes/trailhead/site => trailhead}/public/_headers (100%) rename {recipes/trailhead/site => trailhead}/public/_redirects (100%) rename {recipes/trailhead/site => trailhead}/public/favicon.svg (100%) rename {recipes/trailhead/site => trailhead}/public/sf.jsonc (100%) rename {recipes/trailhead/site => trailhead}/tsconfig.json (100%) rename {recipes/unfurl/site => unfurl}/.gitignore (100%) create mode 100644 unfurl/README.md rename {recipes/unfurl/site => unfurl}/app/api/unfurl/route.ts (100%) rename {recipes/unfurl/site => unfurl}/app/globals.css (100%) rename {recipes/unfurl/site => unfurl}/app/layout.tsx (100%) rename {recipes/unfurl/site => unfurl}/app/page.tsx (100%) rename {recipes/unfurl/site => unfurl}/app/u/page.tsx (100%) rename {recipes/unfurl/site => unfurl}/bun.lock (100%) rename {recipes/unfurl/site => unfurl}/components/CardMedia.tsx (100%) rename {recipes/unfurl/site => unfurl}/components/PreviewCard.tsx (100%) rename {recipes/unfurl/site => unfurl}/lib/guard.ts (100%) rename {recipes/unfurl/site => unfurl}/lib/parse.ts (100%) rename {recipes/unfurl/site => unfurl}/lib/unfurl.ts (100%) rename {recipes/unfurl/site => unfurl}/middleware.ts (100%) rename {recipes/unfurl/site => unfurl}/next.config.ts (100%) rename {recipes/unfurl/site => unfurl}/package.json (100%) rename {recipes/unfurl/site => unfurl}/sf.jsonc (100%) rename {recipes/unfurl/site => unfurl}/tsconfig.json (100%) rename {recipes/wp-astro/site => wp-astro}/.gitignore (75%) create mode 100644 wp-astro/README.md rename {recipes/wp-astro/site => wp-astro}/astro.config.mjs (100%) rename {recipes/wp-astro/site => wp-astro}/bun.lock (100%) rename {recipes/wp-astro/site => wp-astro}/package.json (100%) rename {recipes/wp-astro/site => wp-astro}/public/favicon.svg (100%) rename {recipes/wp-astro/site => wp-astro}/src/components/PostCard.astro (100%) rename {recipes/wp-astro/site => wp-astro}/src/config.ts (100%) rename {recipes/wp-astro/site => wp-astro}/src/layouts/Layout.astro (100%) rename {recipes/wp-astro/site => wp-astro}/src/lib/wp.ts (100%) rename {recipes/wp-astro/site => wp-astro}/src/pages/[slug].astro (100%) rename {recipes/wp-astro/site => wp-astro}/src/pages/index.astro (100%) rename {recipes/wp-astro/site => wp-astro}/src/styles/global.css (100%) rename {recipes/wp-astro/site => wp-astro}/tsconfig.json (100%) rename {recipes/wp-react/site => wp-react}/.gitignore (76%) create mode 100644 wp-react/README.md rename {recipes/wp-react/site => wp-react}/bun.lock (100%) rename {recipes/wp-react/site => wp-react}/index.html (100%) rename {recipes/wp-react/site => wp-react}/package.json (100%) rename {recipes/wp-react/site => wp-react}/public/_redirects (100%) rename {recipes/wp-react/site => wp-react}/public/favicon.svg (100%) rename {recipes/wp-react/site => wp-react}/src/App.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/api.js (100%) rename {recipes/wp-react/site => wp-react}/src/components/Header.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/components/PostDetail.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/components/PostList.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/components/common.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/config.js (100%) rename {recipes/wp-react/site => wp-react}/src/hooks.js (100%) rename {recipes/wp-react/site => wp-react}/src/main.jsx (100%) rename {recipes/wp-react/site => wp-react}/src/styles.css (100%) rename {recipes/wp-react/site => wp-react}/vite.config.js (100%) rename {recipes/wp-zero/site => wp-zero}/.gitignore (100%) rename {recipes/wp-zero/site => wp-zero}/AGENTS.md (70%) create mode 100644 wp-zero/README.md rename {recipes/wp-zero/site => wp-zero}/assets/search.js (100%) rename {recipes/wp-zero/site => wp-zero}/assets/styles.css (100%) rename {recipes/wp-zero/site => wp-zero}/bun.lock (50%) rename {recipes/wp-zero/site => wp-zero}/client/index.tsx (100%) create mode 100644 wp-zero/package.json rename {recipes/wp-zero/site => wp-zero}/server/index.ts (91%) rename {recipes/wp-zero/site => wp-zero}/sf.jsonc (100%) rename {recipes/wp-zero/site => wp-zero}/shared/reactions.ts (100%) rename {recipes/wp-zero/site => wp-zero}/shared/search.ts (100%) rename {recipes/wp-zero/site => wp-zero}/test/build.test.ts (100%) rename {recipes/wp-zero/site => wp-zero}/tools/build.ts (89%) rename {recipes/wp-zero/site => wp-zero}/tools/config.ts (86%) rename {recipes/wp-zero/site => wp-zero}/tools/render.ts (100%) rename {recipes/wp-zero/site => wp-zero}/tools/wordpress.ts (100%) rename {recipes/zero-perfect/site => zero-perfect}/.gitignore (100%) rename {recipes/zero-perfect/site => zero-perfect}/AGENTS.md (100%) create mode 100644 zero-perfect/README.md rename {recipes/zero-perfect/site => zero-perfect}/bun.lock (54%) rename {recipes/zero-perfect/site => zero-perfect}/client/index.tsx (100%) rename {recipes/zero-perfect/site => zero-perfect}/package.json (84%) rename {recipes/zero-perfect/site => zero-perfect}/server/index.ts (89%) rename {recipes/zero-perfect/site => zero-perfect}/sf.jsonc (100%) rename {recipes/zero-perfect/site => zero-perfect}/shared/todo.ts (100%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c4d63c6..2c016b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,6 +40,13 @@ jobs: - migrate-from-cloudflare-pages - migrate-from-vercel - migrate-from-netlify + - wp-zero + - astro-zero + - wp-astro + - wp-react + - zero-perfect + - unfurl + - trailhead steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 @@ -47,7 +54,7 @@ jobs: with: ruby-version: "3.3" - name: Install Spacefast CLI - run: bun add -g spacefast@0.4.1 + run: bun add -g spacefast@0.8.0 - name: Run app tests working-directory: ${{ matrix.app }} run: | @@ -63,11 +70,19 @@ jobs: run: | artifact="$RUNNER_TEMP/${{ matrix.app }}.tgz" case "${{ matrix.app }}" in - zero|kitchen-sink) + zero|kitchen-sink|zero-perfect) cd "${{ matrix.app }}" sf build --json artifact="$PWD/.spacefast/zero/artifact.json" ;; + astro-zero) + # Astro builds into dist/, the capsule is staged beside it, and + # the package build script already runs `sf build dist`. + cd astro-zero + bun install --frozen-lockfile + bun run build + artifact="$PWD/dist/.spacefast/zero/public/index.html" + ;; migrate-from-netlify) sf publish migrate-from-netlify --dry-run --allow-unsupported-platform-features --json cd migrate-from-netlify @@ -141,8 +156,10 @@ jobs: # A pure rename (R100) moves a file without changing what it serves. changed=$(git diff --name-status -M "$BEFORE" "${{ github.sha }}" \ | awk -F'\t' '$1 != "R100" {print $NF}') + # A recipe directory that no longer exists has nothing to publish. slugs=$(printf '%s\n' "$changed" | sed -n 's#^recipes/\([^/]*\)/site/.*#\1#p' \ - | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))') + | sort -u | while read -r slug; do [ -d "recipes/$slug" ] && echo "$slug"; done \ + | jq -R -s -c 'split("\n") | map(select(length > 0))') echo "slugs=$slugs" >> "$GITHUB_OUTPUT" publish-recipes: @@ -158,7 +175,7 @@ jobs: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - name: Install Spacefast CLI - run: bun add -g spacefast@0.4.1 + run: bun add -g spacefast@0.8.0 - name: Build working-directory: recipes/${{ matrix.slug }} run: | diff --git a/README.md b/README.md index d1ce635..eae0f02 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ sf publish ``` Use `sf build` when you only want to prove the deployable artifact locally. -`sf dev` is the local server for Zero apps (`zero/`, `kitchen-sink/`); for -everything else, use the framework's dev server. +`sf dev` is the local server for Zero apps (`zero/`, `kitchen-sink/`, +`zero-perfect/`, `wp-zero/`); for everything else, use the framework's dev +server. ## Directory map @@ -29,6 +30,13 @@ everything else, use the framework's dev server. | [`nextjs/`](./nextjs/) | Server-rendered Next.js, route handlers, and automatic OpenNext packaging | | [`astro-with-wordpress/`](./astro-with-wordpress/) | Astro built from WordPress through `@spacefast/wordpress` | | [`functions-php/`](./functions-php/) | Native PHP Functions with JSON, form bodies, validation, and verified auth context | +| [`zero-perfect/`](./zero-perfect/) | A minimal Zero app: queries, mutations, guest and Gravatar auth, realtime, routes, and an HTTP endpoint | +| [`wp-zero/`](./wp-zero/) | WordPress rendered to static pages at build time with a Zero capsule for realtime reactions and search | +| [`astro-zero/`](./astro-zero/) | Astro pages in front of a server-only Zero capsule, staged into `dist/` as one project root | +| [`wp-astro/`](./wp-astro/) | Headless WordPress pulled from the public REST API and rendered statically with Astro | +| [`wp-react/`](./wp-react/) | A Vite + React SPA reading WordPress posts in the browser, with the `_redirects` SPA fallback | +| [`unfurl/`](./unfurl/) | Next.js SSR, a route handler, and middleware on the Functions runtime | +| [`trailhead/`](./trailhead/) | A Next.js static export with `@spacefast/image` Site Accelerator images, `_redirects`, and `_headers` | | [`recipes/`](./recipes/) | Canonical prompts, metadata, live outputs, and the public recipe feed | | [`kitchen-sink/`](./kitchen-sink/) | Zero, Pages, PHP, JavaScript, TypeScript, auth, mail, and routing in one project | | [`migrate-from-cloudflare-pages/`](./migrate-from-cloudflare-pages/) | Wrangler build settings and Pages convention files | diff --git a/recipes/astro-zero/site/.gitignore b/astro-zero/.gitignore similarity index 100% rename from recipes/astro-zero/site/.gitignore rename to astro-zero/.gitignore diff --git a/recipes/astro-zero/site/AGENTS.md b/astro-zero/AGENTS.md similarity index 100% rename from recipes/astro-zero/site/AGENTS.md rename to astro-zero/AGENTS.md diff --git a/astro-zero/README.md b/astro-zero/README.md new file mode 100644 index 0000000..3637a81 --- /dev/null +++ b/astro-zero/README.md @@ -0,0 +1,54 @@ +# 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. + +```sh +bun install +bun run dev # astro dev on :4321; pages, layout, 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 +sf publish dist --wait +``` + +`sf publish` is given `dist`, not the project root; see below. + +## The composition + +A Zero Space publishes a project root: the CLI walks it, compiles `server/` +into the version's runtime artifact, and publishes everything else as content. +Astro refuses to build into its own project root. So the two are stacked: +**`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 under `dist/.spacefast/zero/public/`. + +The trick that makes this work is in `sf.jsonc`: `runtime.client` names +`client/index.tsx` and that file does not exist. A capsule with no client entry +is server-only, generates no app shell, and so nothing claims `index.html`; +Astro's homepage survives. The live half of the page is a Preact island Astro +bundles itself, importing `useQuery`/`useMutation`/`useAuth` from +`@spacefast/zero/client`. It takes exactly one prop: the post slug. + +## Important files + +| Surface | Where | +| --- | --- | +| Astro static build, content collection, island | `src/` | +| `@spacefast/astro` integration, `mode: "static"`, inline redirects | `astro.config.mjs` | +| On-disk `_headers`, merged with the inline rules into `dist/` | `_headers` | +| Zero schema, indexes, queries, mutations, `GET`/`POST /api/comments` | `server/index.ts` | +| Pure TypeScript both halves import | `shared/comments.ts` | +| Space config and the server-only runtime declaration | `sf.jsonc` | +| Copies the capsule into `dist/` after `astro build` | `scripts/stage-capsule.mjs` | + +## Notes + +- `sf dev` serves the capsule and its endpoints, not the Astro pages; the two + local servers are separate. Its output prints a private URL with a capability + token that doubles as a bearer token for `curl` against `/api/comments`. +- `astro build` empties `dist/`, which also removes the `.spacefast/` link state + the CLI writes there. Publish with `--space` after a rebuild. +- Every build ships the Zero platform bundle even though the pages never load + it; the island bundles its own copy of the client through Vite. diff --git a/recipes/astro-zero/site/_headers b/astro-zero/_headers similarity index 100% rename from recipes/astro-zero/site/_headers rename to astro-zero/_headers diff --git a/recipes/astro-zero/site/astro.config.mjs b/astro-zero/astro.config.mjs similarity index 100% rename from recipes/astro-zero/site/astro.config.mjs rename to astro-zero/astro.config.mjs diff --git a/recipes/astro-zero/site/bun.lock b/astro-zero/bun.lock similarity index 96% rename from recipes/astro-zero/site/bun.lock rename to astro-zero/bun.lock index 4b212eb..debc8a6 100644 --- a/recipes/astro-zero/site/bun.lock +++ b/astro-zero/bun.lock @@ -6,8 +6,8 @@ "name": "astro-zero", "dependencies": { "@astrojs/preact": "^6.0.4", - "@spacefast/astro": "0.4.1", - "@spacefast/zero": "0.4.1", + "@spacefast/astro": "0.8.0", + "@spacefast/zero": "0.8.0", "astro": "^7.2.9", "preact": "^10.29.8", }, @@ -307,17 +307,17 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@spacefast/adapter-kit": ["@spacefast/adapter-kit@0.4.1", "", { "dependencies": { "@spacefast/build-output": "0.4.1", "@spacefast/routing": "0.4.1" } }, "sha512-c7i01fmqQ3sDTos7T4yb58rZuT8eoNBd/vG1KvOmkL5jPcQhQJqTta/s3qIxnR1OUXTpnGoS3iMDy5YirUdBeA=="], + "@spacefast/adapter-kit": ["@spacefast/adapter-kit@0.8.0", "", { "dependencies": { "@spacefast/build-output": "0.8.0", "@spacefast/routing": "0.8.0" } }, "sha512-SHVemwVxj51VupAVzU0dT/U9RKsTntUni/oC33bOV7YP42y9+svze3V7tzmkL2j+HbIb8M0N4ArCdTw+GYUlZA=="], - "@spacefast/astro": ["@spacefast/astro@0.4.1", "", { "dependencies": { "@spacefast/adapter-kit": "0.4.1", "@spacefast/routing": "0.4.1" }, "peerDependencies": { "astro": ">=6" } }, "sha512-zgNdSnLGFH2q54yJyLOmdnq5iNsSNJ6tOQSd9ew6qRdF4+xAvA5n1jmkKJjoC8fd07ItddpF6RQZQFYh7g4TlA=="], + "@spacefast/astro": ["@spacefast/astro@0.8.0", "", { "dependencies": { "@spacefast/adapter-kit": "0.8.0", "@spacefast/routing": "0.8.0" }, "peerDependencies": { "astro": ">=6" } }, "sha512-VH+UONerKHvmeF0DwSBFvPRaBnNrsLdtJQFqCzllQpyILiKy8BK3Re0J3vVgkQAEmHocvTFvTV2bWZq7z/HGgQ=="], - "@spacefast/build-output": ["@spacefast/build-output@0.4.1", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-jiEzxchECbuIF6VesqbyemnXvmttcZa0X84J7cz9gQ3rEBzEUlEGltq2BDCsNJre190y89bAV+UlO3w2iBf7bA=="], + "@spacefast/build-output": ["@spacefast/build-output@0.8.0", "", { "dependencies": { "zod": "^4.4.3" } }, "sha512-hbaNKntP5n8soVFUoEuZNVHJxzEDeFzK8Sr4kXnZu95WEUfjPdhr40LCsX5TdMkQ/3q+tOLucu/gV60QKFW5Tg=="], - "@spacefast/common": ["@spacefast/common@0.4.1", "", { "dependencies": { "culori": "^4.0.2", "js-yaml": "^4.1.1", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "smol-toml": "^1.7.0", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-rXwQ7ZURLIXFApiTDjdVPvSA30MNgm7UdJGLB2mrALhYeysiFWk1i2ja73bxBfRiEWTY2Cg6DQpxRdgu6uy/MQ=="], + "@spacefast/common": ["@spacefast/common@0.8.0", "", { "dependencies": { "culori": "^4.0.2", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "tldts": "^7.4.13", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-Zxz69l8USfmr/tZt3fo/1eySKcek8JKRCZ6OeB+1ri6dQ3nx5JQoyPGq+5HeR9g5/xeRmwNTIWOfSvsyFTI7lQ=="], - "@spacefast/routing": ["@spacefast/routing@0.4.1", "", { "dependencies": { "@spacefast/common": "0.4.1" } }, "sha512-2XIrtRP0yNeG68u10KGYzhrbAPrBq7V4/zw72ENCD+yZZOWwo8n/D23RDCBcMYeb1/xRm/kWtiLptVKwRQiM/A=="], + "@spacefast/routing": ["@spacefast/routing@0.8.0", "", { "dependencies": { "@spacefast/common": "0.8.0" } }, "sha512-sUT6ax3tb4HwBK+uwkMQ6X4G5hMb56rrgxsh3y+URL9OrsEG2z3gT4DVSwODsBAPquMEzMKAm6+tXmVGB1pl0A=="], - "@spacefast/zero": ["@spacefast/zero@0.4.1", "", { "dependencies": { "@spacefast/common": "0.4.1", "lucide-preact": "^0.575.0", "preact": "^10.29.7" } }, "sha512-K/NyGVExPV95wJJl5iabmqjc9qNMq8AtNztbJbOcgDP4/kbkj+UOMEMAivmiOgjRa5Oy0eZqxuZHwfhzbZjxPA=="], + "@spacefast/zero": ["@spacefast/zero@0.8.0", "", { "dependencies": { "@spacefast/common": "0.8.0", "lucide-preact": "^0.575.0", "preact": "^10.29.7", "zod": "4.3.6" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-/qYdPWP1Ucjyb/2/hCxAE9H5t249uhGeweMzn09xbB/aXZsaPK+E8abUXJUQTMJlSKQDGv9aqTKVBxLkllVcyQ=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -655,6 +655,10 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -709,7 +713,7 @@ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -725,8 +729,14 @@ "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], + "@spacefast/build-output/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@spacefast/common/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "astro/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], diff --git a/recipes/astro-zero/site/package.json b/astro-zero/package.json similarity index 87% rename from recipes/astro-zero/site/package.json rename to astro-zero/package.json index 771225f..729e052 100644 --- a/recipes/astro-zero/site/package.json +++ b/astro-zero/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@astrojs/preact": "^6.0.4", - "@spacefast/astro": "0.4.1", - "@spacefast/zero": "0.4.1", + "@spacefast/astro": "0.8.0", + "@spacefast/zero": "0.8.0", "astro": "^7.2.9", "preact": "^10.29.8" }, diff --git a/recipes/astro-zero/site/scripts/stage-capsule.mjs b/astro-zero/scripts/stage-capsule.mjs similarity index 100% rename from recipes/astro-zero/site/scripts/stage-capsule.mjs rename to astro-zero/scripts/stage-capsule.mjs diff --git a/recipes/astro-zero/site/server/index.ts b/astro-zero/server/index.ts similarity index 98% rename from recipes/astro-zero/site/server/index.ts rename to astro-zero/server/index.ts index ffaf396..c94d4de 100644 --- a/recipes/astro-zero/site/server/index.ts +++ b/astro-zero/server/index.ts @@ -110,7 +110,7 @@ export default capsule({ * The thread without a browser. Same rows the island subscribes to, so an * agent, a feed reader, or a curl one-liner sees exactly what a reader does. */ - listComments: endpoint({ method: "GET", path: "/api/comments", mode: "read" }, async (ctx, req) => { + listComments: endpoint({ method: "GET", path: "/api/comments" }, async (ctx, req) => { const slug = cleanSlug(req.query.get("post") ?? ""); if (!slug) { return json({ error: { code: "post_required", message: "Pass ?post=." } }, { status: 400 }); @@ -137,7 +137,7 @@ export default capsule({ }); }), - createComment: endpoint({ method: "POST", path: "/api/comments", mode: "write" }, async (ctx, req) => { + createComment: endpoint({ method: "POST", path: "/api/comments" }, async (ctx, req) => { const payload = await req.json<{ post?: string; author?: string; body?: string }>().catch(() => null); const slug = cleanSlug(payload?.post ?? ""); const text = cleanBody(payload?.body ?? ""); diff --git a/recipes/astro-zero/site/sf.jsonc b/astro-zero/sf.jsonc similarity index 100% rename from recipes/astro-zero/site/sf.jsonc rename to astro-zero/sf.jsonc diff --git a/recipes/astro-zero/site/shared/comments.ts b/astro-zero/shared/comments.ts similarity index 100% rename from recipes/astro-zero/site/shared/comments.ts rename to astro-zero/shared/comments.ts diff --git a/recipes/astro-zero/site/src/components/Margin.tsx b/astro-zero/src/components/Margin.tsx similarity index 100% rename from recipes/astro-zero/site/src/components/Margin.tsx rename to astro-zero/src/components/Margin.tsx diff --git a/recipes/astro-zero/site/src/content.config.ts b/astro-zero/src/content.config.ts similarity index 100% rename from recipes/astro-zero/site/src/content.config.ts rename to astro-zero/src/content.config.ts diff --git a/recipes/astro-zero/site/src/content/posts/emoji-are-punctuation-now.md b/astro-zero/src/content/posts/emoji-are-punctuation-now.md similarity index 100% rename from recipes/astro-zero/site/src/content/posts/emoji-are-punctuation-now.md rename to astro-zero/src/content/posts/emoji-are-punctuation-now.md diff --git a/recipes/astro-zero/site/src/content/posts/static-isnt-the-opposite-of-alive.md b/astro-zero/src/content/posts/static-isnt-the-opposite-of-alive.md similarity index 100% rename from recipes/astro-zero/site/src/content/posts/static-isnt-the-opposite-of-alive.md rename to astro-zero/src/content/posts/static-isnt-the-opposite-of-alive.md diff --git a/recipes/astro-zero/site/src/content/posts/the-first-three-hundred-milliseconds.md b/astro-zero/src/content/posts/the-first-three-hundred-milliseconds.md similarity index 100% rename from recipes/astro-zero/site/src/content/posts/the-first-three-hundred-milliseconds.md rename to astro-zero/src/content/posts/the-first-three-hundred-milliseconds.md diff --git a/recipes/astro-zero/site/src/content/posts/your-comment-section-is-a-database.md b/astro-zero/src/content/posts/your-comment-section-is-a-database.md similarity index 100% rename from recipes/astro-zero/site/src/content/posts/your-comment-section-is-a-database.md rename to astro-zero/src/content/posts/your-comment-section-is-a-database.md diff --git a/recipes/astro-zero/site/src/layouts/Base.astro b/astro-zero/src/layouts/Base.astro similarity index 100% rename from recipes/astro-zero/site/src/layouts/Base.astro rename to astro-zero/src/layouts/Base.astro diff --git a/recipes/astro-zero/site/src/pages/index.astro b/astro-zero/src/pages/index.astro similarity index 100% rename from recipes/astro-zero/site/src/pages/index.astro rename to astro-zero/src/pages/index.astro diff --git a/recipes/astro-zero/site/src/pages/posts/[slug].astro b/astro-zero/src/pages/posts/[slug].astro similarity index 100% rename from recipes/astro-zero/site/src/pages/posts/[slug].astro rename to astro-zero/src/pages/posts/[slug].astro diff --git a/recipes/astro-zero/site/src/styles/global.css b/astro-zero/src/styles/global.css similarity index 100% rename from recipes/astro-zero/site/src/styles/global.css rename to astro-zero/src/styles/global.css diff --git a/recipes/astro-zero/site/tsconfig.json b/astro-zero/tsconfig.json similarity index 100% rename from recipes/astro-zero/site/tsconfig.json rename to astro-zero/tsconfig.json diff --git a/recipes/astro-zero/README.md b/recipes/astro-zero/README.md deleted file mode 100644 index d1794cd..0000000 --- a/recipes/astro-zero/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# 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 deleted file mode 100644 index eb53983..0000000 --- a/recipes/astro-zero/meta.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 deleted file mode 100644 index cc1357a..0000000 --- a/recipes/astro-zero/prompt.md +++ /dev/null @@ -1,90 +0,0 @@ -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 `` on every page so the site carries -its badge:** - -```html - -``` - -**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. diff --git a/recipes/trailhead/README.md b/recipes/trailhead/README.md deleted file mode 100644 index 9484819..0000000 --- a/recipes/trailhead/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# Trailhead — Next.js static export on Spacefast - -A photo-led hiking guide: an index of six real trails and a generated page for -each one. It exists to be the canonical **Next.js static export** example — -`output: "export"`, `generateStaticParams`, Site Accelerator images, and -`_redirects` / `_headers` shipped as files. - -**Live:** - -## Build and publish - -```bash -cd site -bun install -bun run build # → site/out/ -sf publish out --wait # or the direct multipart API, see ../prompt.md -``` - -`bun run build` writes `site/out/` with a root `index.html`, a directory per -route, `404.html`, and the three convention files (`_redirects`, `_headers`, -`sf.jsonc`) copied straight out of `public/`. - -> **For CI:** the publish directory is `site/out`, not `site/dist` or -> `site/build`. Next's static export target is hard-coded to `out`. - -## What it exercises - -| Platform feature | Where | -| --- | --- | -| `output: "export"` + `trailingSlash: true` | `site/next.config.ts` | -| `generateStaticParams` for per-trail routes | `site/app/trails/[slug]/page.tsx` | -| `@spacefast/image` Next loader → `i0.wp.com` | `site/next.config.ts`, `site/lib/spacefast-image-loader.ts` | -| `siteAcceleratorUrl()` for OG cards | `site/app/layout.tsx`, `site/app/trails/[slug]/page.tsx` | -| `_redirects` (four 301s) | `site/public/_redirects` | -| `_headers` (security + immutable asset cache) | `site/public/_headers` | -| `sf.jsonc` name, meta, 404 fallback | `site/public/sf.jsonc` | - -Every `next/image` source is an **absolute** `https://images.unsplash.com/...` -URL, which is what makes the loader do anything — relative `public/` paths pass -through untouched. The built HTML proves it: - -``` -$ grep -o 'https://i0.wp.com[^" ]*' out/trails/laugavegur/index.html | head -1 -https://i0.wp.com/images.unsplash.com/photo-1726533870778-8be51bf99bb1?quality=80&strip=all&w=640 -``` - -(That's the first entry of the `srcSet`; the full set runs 640w to 3840w, and the -OG card is a `resize=1200,630` crop from the same helper.) - -## Known issue: the documented `next.config.ts` recipe does not build - -The [Next.js guide](https://spacefast.com/docs/frameworks) says to -write: - -```ts -import { spacefastNextImageConfig } from "@spacefast/image/next"; -// ... -loaderFile: require.resolve("@spacefast/image/next-loader"), -``` - -Neither line works with `@spacefast/image@0.2.2`, on Next 15.5 or Next 16.3: - -1. **`ERR_PACKAGE_PATH_NOT_EXPORTED`.** Next compiles `next.config.ts` to - CommonJS, so the config's imports resolve under Node's `require` condition. - The package's `exports` map declares only `types` and `import` for `.`, - `./next` and `./next-loader`, so every reference from the config throws — - static import, dynamic `await import()`, and `require.resolve` alike. A - `require` or `default` condition in the package would fix all three. -2. **Absolute `loaderFile` paths break on Next 16.** Next joins `loaderFile` - onto the project root, so the absolute path `require.resolve` returns becomes - `/project/root/project/root/node_modules/...` and the build fails with - "Specified images.loaderFile does not exist". - -So this example inlines `spacefastNextImageConfig`'s two values (`loader: -"custom"`, `qualities`) and points `loaderFile` at a one-line local re-export, -`lib/spacefast-image-loader.ts`. The bundler resolves the bare specifier there, -which keeps pnpm / Yarn PnP / hoisted workspaces working — the same property -`require.resolve` was there to buy. `next.config.ts` says all of this in -comments; delete them when the package ships a `require` condition. - -## Implementation notes - -- **One data file.** `site/data/trails.ts` holds all six trails; the index, the - detail pages, `generateStaticParams`, and the OG cards all read from it. -- **Numbers are stored once.** Distances and elevations are canonical metric - numbers; `site/lib/units.ts` renders `"5.4 mi / 8.7 km"` from them, and the - hero's totals are summed from the same fields, so the two unit systems cannot - drift apart. -- **No CSS framework, no web fonts.** `app/globals.css` is one hand-written - stylesheet driven by custom properties, including a warm dark mode behind - `prefers-color-scheme`. Nothing is fetched at build time, so the build is - hermetic. -- **No client JavaScript of our own.** Every page is server-rendered at build - time; the only script in the HTML is the shared Spacefast badge, rendered as a - plain ` -``` - -Put it in the root layout so every generated page gets it, and confirm it's in the built HTML — not injected by a client-side script tag helper, which would leave it out of the export. - -**When the site is ready, publish it to Spacefast with the direct API — no account, install, or extra instructions needed:** - -1. Build first (`bun run build` / `npm run build`), then use `./out` as the publish folder. Confirm it has `index.html` at its root, plus `_redirects` and `_headers`, and no secrets, source files, `.git`, or `.spacefast` state. -2. First publish: send every file as multipart form data. This Bash/Zsh block preserves relative paths and works for one file or a whole folder: - ```bash - PUBLISH_ROOT=./out - publish_files=() - while IFS= read -r -d "" file; do - relative=${file#"$PUBLISH_ROOT"/} - publish_files+=(-F "files=@$file;filename=$relative") - done < <(find "$PUBLISH_ROOT" -type f -print0) - curl -sS "${publish_files[@]}" "https://api.spacefast.com/v1/publish?wait=1" - ``` -3. From the `{ "data": ... }` receipt, give me `data.space.liveUrl`, `data.version.immutableUrl`, `data.claim.url`, and `data.claim.expiresAt`. Remind me to claim within 6 hours. Keep `data.claim.token` secret. -4. Save `data.space.id` and `data.claim.token` locally (for example in an ignored, mode-600 `.spacefast/state.json`). For a quick update, rebuild the `publish_files` array above and publish to the same space: - ```bash - SPACEFAST_SPACE_ID= - SPACEFAST_TOKEN= - curl -sS -H "Authorization: Bearer $SPACEFAST_TOKEN" \ - -F "spaceId=$SPACEFAST_SPACE_ID" "${publish_files[@]}" \ - "https://api.spacefast.com/v1/publish?wait=1" - ``` - If an update after claiming returns `space_claimed_credential_available`, exchange the saved claim token once at `POST https://api.spacefast.com/v1/anonymous-claim/exchange`, save `data.credential.accessToken`, and retry with that access token. -5. Then check the live site: `curl -sI /trails/` should show your 301, and the trail pages' image URLs should point at `i0.wp.com`. - -**Optional shortcuts and reference only:** if the `sf` CLI is already installed, `sf publish ./out --wait` does the same job. A zip of the publish folder is also supported, but neither the CLI nor a zip is required. Docs: [Next.js on Spacefast](https://spacefast.com/docs/frameworks) · [redirects](https://spacefast.com/docs/redirects) · [headers and settings](https://spacefast.com/docs/configuration) · [agent setup](https://spacefast.com/setup) · [claiming](https://spacefast.com/help/anonymous-publish) diff --git a/recipes/trailhead/site/next.config.ts b/recipes/trailhead/site/next.config.ts deleted file mode 100644 index 8f14acd..0000000 --- a/recipes/trailhead/site/next.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - // Flat HTML in `out/`. No server, no Node.js runtime, nothing to keep warm. - output: "export", - // `/trails/angels-landing/index.html` instead of `/trails/angels-landing.html`, - // so every route is a directory and links work with or without the slash. - trailingSlash: true, - images: { - // This is `spacefastNextImageConfig` from `@spacefast/image/next`, inlined. - // - // The published guide spreads that export here and resolves the loader with - // `require.resolve("@spacefast/image/next-loader")`. Neither line works with - // @spacefast/image@0.2.2: Next compiles `next.config.ts` to CommonJS, and the - // package's `exports` map only declares an `import` condition, so any - // reference from the config throws ERR_PACKAGE_PATH_NOT_EXPORTED (Next 15 and - // 16 alike). See this example's README. - loader: "custom", - qualities: [50, 60, 75, 85, 100], - // Relative to the project root — Next joins this onto the root directory, so - // an absolute path from `require.resolve` would break too. The file is a - // one-line re-export, which leaves the bare specifier for the bundler to - // resolve and keeps pnpm / Yarn PnP / hoisted workspaces working. - loaderFile: "./lib/spacefast-image-loader.ts", - }, -}; - -export default nextConfig; diff --git a/recipes/unfurl/README.md b/recipes/unfurl/README.md deleted file mode 100644 index aafb65c..0000000 --- a/recipes/unfurl/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Unfurl — link preview inspector - -Paste a URL, get the preview card. The server fetches the page, parses its -OpenGraph and Twitter-card metadata, and renders the card Slack or iMessage -would show. - -**Live:** · **Recipe:** [`prompt.md`](./prompt.md) - -This is the repo's first **Functions runtime** example. It exists because -unfurling genuinely cannot be faked client-side: a browser can't fetch a -cross-origin page and read its `` — CORS stops it — so the work has to -happen on a server. One small app exercises a Route Handler, per-request SSR, -middleware, and the static-first/worker-miss split at once. - -## What runs where - -| Path | Rendering | Served by | -| --- | --- | --- | -| `/` | Prerendered at build time | Disk. The worker never wakes. | -| `/u?url=…` | SSR, per request | Worker | -| `/api/unfurl?url=…` | Route Handler | Worker | -| `middleware.ts` | Every non-asset request | Worker | -| `/_next/static/*` | Build output | Disk | - -`next build` confirms the split: - -``` -Route (app) -┌ ○ / -├ ○ /_not-found -├ ƒ /api/unfurl -└ ƒ /u - -ƒ Proxy (Middleware) - -○ (Static) prerendered as static content -ƒ (Dynamic) server-rendered on demand -``` - -## Key files - -| File | What it holds | -| --- | --- | -| [`site/lib/guard.ts`](./site/lib/guard.ts) | SSRF defense: scheme allowlist, private/loopback/link-local/CGNAT blocking for v4 and v6 (including IPv4-mapped), DNS resolution checks, bare-domain normalization. | -| [`site/lib/unfurl.ts`](./site/lib/unfurl.ts) | The fetch itself: manual redirect walk (3 hops, every hop re-vetted), 512 KB body cap read off the stream, 8 s timeout, content-type check. | -| [`site/lib/parse.ts`](./site/lib/parse.ts) | Dependency-free `` scanner. `og:*` → `twitter:*` → plain HTML, entity decoding, relative-URL resolution, best-icon selection. | -| [`site/app/api/unfurl/route.ts`](./site/app/api/unfurl/route.ts) | `GET /api/unfurl?url=…` → normalized JSON, or `{ error: { code, message } }`. | -| [`site/app/u/page.tsx`](./site/app/u/page.tsx) | The SSR page. Renders the card in the HTML and offers the raw JSON in a `
`. | -| [`site/app/page.tsx`](./site/app/page.tsx) | The static landing page. No data fetching, so Next prerenders it. | -| [`site/middleware.ts`](./site/middleware.ts) | Normalizes a bare-domain `url` param with a 308, stamps security headers. | -| [`site/sf.jsonc`](./site/sf.jsonc) | Optional entry pin: `runtime.kind: "functions"`, `entry: ".open-next/worker.js"`. | - -## Build and publish - -Everything runs from `site/`, with bun. - -```bash -cd site -bun install -bun run dev # http://localhost:3000 -bun run build # verify the static/dynamic split above -``` - -Publish with the CLI. The static curl flow the other recipes use does not -apply here — a Functions project needs the build step: - -```bash -cd site -sf publish --dry-run # preview the plan -sf publish --wait --json -``` - -### What the platform does at publish time - -`sf publish` runs the whole thing locally (`"mode": "local"` in the dry-run -plan) and you do not install or configure any of it: - -1. Detects the Next.js app from `package.json` + a lockfile, and resolves - `bun install --frozen-lockfile` / `bun run build`. -2. Runs `next build` with the Spacefast Next adapter injected via - `NEXT_ADAPTER_PATH`, which reports the resolved output mode and writes - `.spacefast/build.json` — the route table, each route's render class, and - its RSC companion path. -3. On Next 16.3+ with a canonical `"build": "next build"` script, rewrites the - build to `npm run build -- --webpack`, because the OpenNext packager reads - the Webpack `.nft.json` trace that Turbopack doesn't emit. **Don't - customize the build script** — a wrapper opts out of this fix. -4. Bundles the standalone tree with a pinned adapter: - `@opennextjs/cloudflare@1.20.2 build --skipNextBuild - --skipWranglerConfigCheck --dangerouslyUseUnsupportedNextVersion`, writing - `.open-next/worker.js` and `.open-next/assets/`. -5. Materializes the verdict: prerendered pages become real files on disk, - dynamic ones ride a cache seed, and the routing residue lands in generated - `_redirects` / `_headers` sections. -6. Uploads the static half and the worker as **one version**, so they roll - back together. - -There is no `open-next.config.ts` in this project and no adapter in -`package.json` — that is deliberate, and adding either would fight the pinned -build. - -### `sf.jsonc` placement - -`sf.jsonc` goes at the **project root** — beside `package.json`, i.e. `site/`. -It is optional: the CLI auto-detects `.open-next/worker.js` after the build. -Pinning it makes a broken build fail loudly with -`config_runtime_entry_missing` instead of silently falling back to a static -publish. - -## Rules that break the build if you get them wrong - -- **No `output: "export"`** in `next.config.ts`. That is the static-export - path; this app needs a server. -- **No `export const runtime = "edge"`** anywhere. The adapter uses the - Node.js runtime. -- **Don't install `@opennextjs/cloudflare`** and don't write - `open-next.config.ts`. Spacefast pins and runs the adapter for you. -- Keep `"build": "next build"` verbatim, per step 3 above. - -## Verified locally - -Against `bun run dev`, real URLs, real network: - -```console -$ curl -sS "http://localhost:4321/api/unfurl?url=https://github.com/vercel/next.js" -{ - "url": "https://github.com/vercel/next.js", - "finalUrl": "https://github.com/vercel/next.js", - "domain": "github.com", - "status": 200, - "title": "GitHub - vercel/next.js: The React Framework", - "description": "The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.", - "siteName": "GitHub", - "image": "https://repository-images.githubusercontent.com/70107786/4602445c-10a2-4903-a360-c96d70531f67", - "favicon": "https://github.githubassets.com/assets/pinned-octocat-093da3e6fa40.svg", - "themeColor": "#1e2327", - "type": "object", - "empty": false -} -``` - -SSR proof — the card is in the HTML, not fetched by the client: - -```console -$ curl -sS "http://localhost:4321/u?url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js" \ - | grep -oE '

[^<]*

' -

GitHub - vercel/next.js: The React Framework

-``` - -Middleware — bare domain normalized, headers stamped: - -```console -$ curl -sS -o /dev/null -D - "http://localhost:4321/u?url=github.com" -HTTP/1.1 308 Permanent Redirect -location: /u?url=https%3A%2F%2Fgithub.com -referrer-policy: strict-origin-when-cross-origin -x-content-type-options: nosniff -x-unfurl-middleware: 1 -``` - -SSRF guard — every one refused before a socket opens: - -```console -$ curl -sS "…?url=http://localhost:8080/admin" -{"error":{"code":"blocked_host","message":"localhost is a private host."}} # 403 -$ curl -sS "…?url=http://169.254.169.254/latest/meta-data/" -{"error":{"code":"blocked_address","message":"169.254.169.254 is a private address."}} # 403 -$ curl -sS "…?url=http://[::ffff:127.0.0.1]/" -{"error":{"code":"blocked_address","message":"[::ffff:7f00:1] is a private address."}} # 403 -$ curl -sS "…?url=file:///etc/passwd" -{"error":{"code":"unsupported_scheme","message":"file: isn't supported — use http or https."}} # 400 -$ curl -sS "…?url=https://httpbin.org/image/png" -{"error":{"code":"not_html","message":"That URL is image/png, not a web page."}} # 415 -``` - -## Notes - -- `sf publish --dry-run` shows `outputDirectory: out` and no Functions runtime. - The runtime is resolved after `next build` runs, so the real publish is the - source of truth for this path. -- `sf publish --skip-build` is not a shortcut for a Functions project. The - OpenNext bundle is built by `sf publish` itself. -- A build stages its worker bundle under `__spacefast/`. `.gitignore` covers it. diff --git a/recipes/unfurl/meta.json b/recipes/unfurl/meta.json deleted file mode 100644 index b98e74d..0000000 --- a/recipes/unfurl/meta.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "Unfurl — link preview inspector", - "title": "Link preview inspector", - "slug": "unfurl", - "vertical": "Developers & integrations", - "tech": "Next.js SSR (Functions)", - "runtime": "functions", - "summary": "Paste any URL and the server fetches it, parses its OpenGraph and Twitter-card metadata, and renders the social preview card — a Next.js Route Handler plus an SSR page running on the Spacefast Functions runtime.", - "order": 210, - "live_url": "https://unfurl.view.fast/", - "setup_questions": [ - "What should the tool be called (the name in the header and browser tab)?", - "What's the accent color (a hex like #b6ff3d), and should it feel like a dark developer tool or a light consumer app?", - "Which three example URLs should the landing page offer as one-click demos?", - "Should the result page expose the raw JSON from the API alongside the rendered card?" - ], - "photo_terms": [], - "sections": [ - "Static landing page: hero, paste-a-URL form that GETs the SSR route, and one-click example links", - "SSR result page at /u?url=… that server-renders the preview card per request", - "Preview card: og:image with a graceful fallback, favicon, domain, title, description, site name, and canonical link", - "Route Handler at /api/unfurl?url=… returning normalized JSON, with SSRF hardening (scheme allowlist, private-address blocking, redirect cap, size cap, timeout)", - "Distinct error states for a malformed URL, a blocked address, a timeout, an upstream failure, and a page with no metadata", - "middleware.ts that normalizes a bare-domain url param and stamps security headers" - ], - "based_on": "The link-unfurling service every chat app runs behind the scenes — Slack, Discord, and iMessage all fetch a pasted URL server-side and render its OpenGraph card.", - "why_good": "The canonical proof that Spacefast runs a real server. Cross-origin fetching and HTML parsing genuinely cannot happen in the browser, so this recipe cannot be faked with a static site: it exercises a Next.js Route Handler, per-request SSR, and middleware on the Functions runtime in one small app.", - "notes": "Functions runtime project, not a prebuilt static output directory. Build and publish from site/ with the sf CLI: `sf publish` auto-detects the Next.js app and builds it with a pinned OpenNext Cloudflare adapter. Do not add `output: \"export\"` to next.config.ts and do not declare `export const runtime = \"edge\"` anywhere; both break the OpenNext build." -} diff --git a/recipes/unfurl/prompt.md b/recipes/unfurl/prompt.md deleted file mode 100644 index dc69b48..0000000 --- a/recipes/unfurl/prompt.md +++ /dev/null @@ -1,67 +0,0 @@ -Build me a **link preview inspector** as a **Next.js app that runs on a server** — I paste any URL, the server fetches that page, parses its OpenGraph and Twitter-card metadata, and renders the social preview card the way Slack or iMessage would. This one genuinely needs a server: a browser can't fetch a cross-origin page and read its ``, so no amount of client JavaScript can fake it. - -**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 should the tool be called (the name in the header and the browser tab)? -2. What's the accent color (a hex like `#b6ff3d`), and should it feel like a dark developer tool or a light consumer app? -3. Which three example URLs should the landing page offer as one-click demos? -4. Should the result page expose the raw JSON from the API next to the rendered card, or keep it clean? - -**Then build a complete, polished, responsive app with:** - -- A **static landing page** at `/`: a short hero explaining what unfurling is, a single big "paste a URL" form that submits with `method="get"` to the result page, and the example links from my answers. No data fetching here — this page should be prerendered so it serves straight off disk. -- A **Route Handler** at `app/api/unfurl/route.ts` — `GET /api/unfurl?url=…` returns normalized JSON: `{ url, finalUrl, siteName, title, description, image, imageAlt, favicon, themeColor, type, fetchedAt }`. It fetches the target page server-side, reads only the ``, and prefers `og:*`, then `twitter:*`, then plain `` / ``. Resolve relative `og:image` and favicon URLs against the final URL. -- **Harden that handler against SSRF — this is the part that separates a demo from a real service:** - - allow only `http:` and `https:`; reject everything else (`file:`, `data:`, `ftp:`, …), - - reject `localhost`, `*.local`, `*.internal`, bare single-label hostnames, and any IP literal in a private, loopback, link-local, or carrier-grade-NAT range (v4 and v6, including IPv4-mapped v6), - - resolve the hostname and check **every** returned address against the same rules where DNS resolution is available, - - follow redirects **manually**, capped at 3 hops, re-running the address checks on each hop, so a public URL can't bounce you into `169.254.169.254`, - - cap the response body (512 KB is plenty — metadata lives in the `<head>`) and stop reading once `</head>` arrives, - - set an 8-second timeout with `AbortSignal.timeout`, and send an honest descriptive `User-Agent` that identifies the tool. -- A **server-rendered result page** at `/u?url=…` that does the unfurl during the request and renders the card in the HTML — view-source should show the title and description, not an empty shell. This is the page that proves per-request rendering. -- A **preview card that actually looks good**: 16:9 `og:image` with a tasteful gradient-and-initial fallback when there is no image or it fails to load, favicon + domain row, title, description clamped to a few lines, site name and content type as small chips, and the resolved final URL as a link. -- **Real error states**, each with its own message — malformed URL, blocked address, timeout, upstream error status, and "the page loaded but has no metadata at all" (still show the domain and title if there is one). Never a bare stack trace, never a blank card. -- A **`middleware.ts`** that does two small real jobs: normalize a bare-domain `url` param (someone pastes `github.com`, you redirect to `?url=https://github.com`) and stamp security headers (`X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`) on responses. - -**Design & content notes:** - -- Developer-tool polish: a calm dark canvas, one accent color used sparingly for focus rings and the submit button, a mono face for URLs and JSON, generous whitespace, and a card that looks like the thing it's previewing. -- Real content only — the examples must be live URLs that actually unfurl, never "lorem ipsum". -- Keep it accessible: a labelled input, a skip link, `aria-live` on the result region, visible focus states, `alt` text driven by `og:image:alt`, and contrast that passes AA. -- Responsive down to a phone: the card stacks, the image keeps its aspect ratio, long URLs wrap instead of overflowing. - -**Runtime rules that make this build work on Spacefast — get these wrong and the publish fails:** - -- **No `output: "export"`** in `next.config.ts`. That's the static-export path; you need the server. -- **No `export const runtime = "edge"`** anywhere. The adapter uses the Node.js runtime. -- **Don't install an adapter and don't write `open-next.config.ts`.** Spacefast builds the app with a pinned OpenNext Cloudflare adapter at publish time. Keep the project a plain Next.js app. - -**Add this exact line right before `</body>` in the root layout so the app carries its badge:** - -```html -<script src="https://spacefast.com/badge.js" data-example="unfurl"></script> -``` - -**When the app is ready, publish it to Spacefast with the `sf` CLI — no account needed for the first publish:** - -1. Install the CLI if it isn't there: `npm i -g spacefast` (or `bun add -g spacefast`). -2. From the project root — the directory holding `package.json` and `app/` — preview the plan first: - ```bash - sf publish --dry-run - ``` - Check that it reports the `next` framework preset. The Functions runtime is resolved during the real publish, after `next build` runs. -3. Publish for real and wait for it to go live: - ```bash - sf publish --wait --json - ``` - Spacefast uploads the source, runs `next build`, bundles the output with the pinned OpenNext Cloudflare adapter, and ships the static half and the worker as one version. -4. From the `{ "data": … }` receipt, give me `data.space.liveUrl`, `data.version.immutableUrl`, `data.claim.url`, and `data.claim.expiresAt`. Remind me to claim within 6 hours. Keep `data.claim.token` secret — the CLI already saved it under `.spacefast/`, which must stay out of git. -5. Claim the space with `data.claim.url` (or publish while logged in). An unclaimed space may only call a short list of trusted APIs, so until it's claimed every unfurl comes back as a 403 from the target site. -6. Prove it's really running server-side, and show me the output: - ```bash - curl -s "<liveUrl>api/unfurl?url=https://github.com/vercel/next.js" | head -c 600 - curl -s "<liveUrl>u?url=https://github.com/vercel/next.js" | grep -o '<title>[^<]*' - ``` -7. To ship an update later, run `sf publish --wait` again from the same directory. The CLI remembers the space. - -**Optional and reference only:** `sf publish --dry-run --json` prints the resolved plan as JSON if you want to inspect the detected framework and output directory. Docs: [Functions runtime](https://spacefast.com/docs/functions) · [Next.js on Spacefast](https://spacefast.com/docs/frameworks) · [agent setup](https://spacefast.com/setup) · [claiming](https://spacefast.com/help/anonymous-publish) · [updates and rollback](https://spacefast.com/help/versions) diff --git a/recipes/wp-astro/README.md b/recipes/wp-astro/README.md deleted file mode 100644 index 4cd0327..0000000 --- a/recipes/wp-astro/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Headless WordPress + Astro - -A polished editorial blog that runs **WordPress headlessly**: the posts live in a real -WordPress install and are pulled from its public REST API **at build time**, then -rendered to flat static HTML by Astro. There is no server, no PHP, and no database when -a visitor loads a page — WordPress is just the editor, Astro is the renderer, and -Spacefast serves the output. - -The data layer (`src/lib/wp.ts`) makes a single `?_embed` request so each post comes back -with its author, featured image, and categories attached, then normalizes the messy -WordPress shapes (rendered HTML in titles, optional embeds, entity escapes) into a small, -predictable `Post` object. `src/pages/index.astro` renders a full-width lead story plus a -responsive grid of post cards; `src/pages/[slug].astro` uses `getStaticPaths` to bake one -static page per post with its rendered HTML styled as editorial prose. Posts with no -featured image fall back to `jetpack_featured_media_url` and finally to a tasteful tinted -placeholder — never a broken image. - -The three things you'd change are isolated in `src/config.ts`: the WordPress REST API base, -the blog title, and the accent color (everything else stays a calm editorial neutral, and -on-accent text auto-picks black or white for contrast). The demo source is -`https://wordpress.org/news`. Build it with `npm install && npm run build` (output in -`dist/`) and publish that folder to Spacefast. - -**Live:** _not yet published_ - -Build your own with the prompt in [`prompt.md`](./prompt.md). diff --git a/recipes/wp-astro/meta.json b/recipes/wp-astro/meta.json deleted file mode 100644 index d2b1179..0000000 --- a/recipes/wp-astro/meta.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "Headless WordPress + Astro", - "title": "WordPress + Astro", - "slug": "wp-astro", - "vertical": "Developers & integrations", - "tech": "Astro + WP REST API", - "summary": "A static editorial blog whose posts are pulled from a public WordPress REST API at build time and rendered to flat HTML with Astro — headless WordPress, no server at request time.", - "order": 200, - "live_url": "https://wp-astro.view.fast/", - "setup_questions": [ - "What's the WordPress site to pull from (the REST API base, e.g. https://wordpress.org/news/wp-json/wp/v2)?", - "What should the blog be called?", - "Which categories, authors, or content types should be featured or excluded?", - "What's the accent color (a hex like" - ], - "photo_terms": [ - "wordpress dashboard on laptop screen", - "developer writing code editor closeup", - "abstract content management network nodes" - ], - "sections": [ - "Header strip explaining 'Content from WordPress, rendered statically with Astro, hosted on Spacefast' with a source link", - "Blog index: full-width lead story plus a responsive grid of post cards (image, tags, title, excerpt, author, date, reading time)", - "Featured-image handling with a tasteful placeholder for posts that have none", - "Per-post static pages with title, byline, hero image, and the post's rendered HTML as editorial prose", - "Footer crediting the WordPress REST API source, Astro, and Spacefast" - ], - "based_on": "A real headless-CMS setup: WordPress as the editor, Astro as the static front end, pulling posts from the public WordPress REST API at build time (demo source: wordpress.org/news).", - "why_good": "The canonical 'real developer integration' example — proves an agent can wire a live REST API into a static build, normalize messy CMS data, and ship a polished blog with zero backend at request time." -} diff --git a/recipes/wp-astro/prompt.md b/recipes/wp-astro/prompt.md deleted file mode 100644 index 672e5db..0000000 --- a/recipes/wp-astro/prompt.md +++ /dev/null @@ -1,58 +0,0 @@ -Build me a **headless WordPress blog** as an **Astro site** that fetches posts from a public WordPress REST API **at build time** and renders them to static HTML. WordPress stays the place I write; Astro is the renderer; Spacefast hosts the result. No server, no PHP, and no database run when a visitor loads the page. - -**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 WordPress site you want to pull from? Give me the site URL — I'll use its REST API at `/wp-json/wp/v2` (any public WordPress.org-hosted or self-hosted site works; if you're not sure, use `https://wordpress.org/news`). -2. What should the blog be called (the title in the header and browser tab)? -3. Which categories, authors, or content types should be featured or excluded? -4. What's the accent color (a hex like `#0b6e4f`), and should the visual tone feel - like a newspaper, magazine, or personal blog? - -**Then build a complete, polished, responsive site with:** - -- A small build-time data layer that fetches `//posts?_embed&per_page=12` once, during `astro build`, and normalizes each post (title, date, author, featured image, categories, rendered HTML) into a tidy shape the pages consume. Use `?_embed` so the author and featured image come back in the same request — no N+1 follow-ups. -- A blog index: a full-width lead story followed by a responsive grid of post cards, each with featured image, category tags, title, excerpt, author + avatar, date, and reading time. -- A static page per post (`//`) generated with `getStaticPaths`: title, category tags, author + avatar, date, reading time, a featured-image banner, and the post's rendered HTML styled as clean editorial "prose" (headings, figures, blockquotes, lists, code). -- A short header strip that explains: **"Content from WordPress, rendered statically with Astro, hosted on Spacefast."** plus a small "Source: " link. -- A footer crediting the WordPress REST API source, Astro, and Spacefast. - -**Design & content notes:** - -- Editorial-magazine feel: a serif display face for headings (a system serif stack is fine — no heavy web fonts needed), a clean sans for body, generous whitespace, warm off-white background, and the one accent color used for tags, links, and buttons. -- Real content only — every post comes from the live WordPress API, never "lorem ipsum". -- **Handle missing featured images** with a tasteful placeholder (a soft tinted panel with the post's first initial), not a broken image. Some WordPress posts have no featured image — prefer the `_embedded` featured media, fall back to `jetpack_featured_media_url` if present, otherwise show the placeholder. -- Keep WordPress's rendered HTML safe to drop in, and make sure images inside post content keep their aspect ratio (`height: auto`) so nothing stretches. -- Use Astro's static output (`astro build` → `dist/`). No SSR adapter, no runtime fetch. -- Keep it accessible: semantic HTML, a skip link, alt text on images, labelled landmarks, visible focus states, and good contrast (auto-pick black/white text on the accent). - -**Add this exact line right before `` so the site carries its badge:** - -```html - -``` - -**When the site is ready, publish it to Spacefast with the direct API — no account, install, or extra instructions needed:** - -1. Use `./dist` as the publish folder. For no-build projects, put only the finished public files in that dedicated folder. Confirm it has `index.html` at its root and no secrets, source files, `.git`, or `.spacefast` state. -2. First publish: send every file as multipart form data. This Bash/Zsh block preserves relative paths and works for one file or a whole folder: - ```bash - PUBLISH_ROOT=./dist - publish_files=() - while IFS= read -r -d "" file; do - relative=${file#"$PUBLISH_ROOT"/} - publish_files+=(-F "files=@$file;filename=$relative") - done < <(find "$PUBLISH_ROOT" -type f -print0) - curl -sS "${publish_files[@]}" "https://api.spacefast.com/v1/publish?wait=1" - ``` -3. From the `{ "data": ... }` receipt, give me `data.space.liveUrl`, `data.version.immutableUrl`, `data.claim.url`, and `data.claim.expiresAt`. Remind me to claim within 6 hours. Keep `data.claim.token` secret. -4. Save `data.space.id` and `data.claim.token` locally (for example in an ignored, mode-600 `.spacefast/state.json`). For a quick update, rebuild the `publish_files` array above and publish to the same space: - ```bash - SPACEFAST_SPACE_ID= - SPACEFAST_TOKEN= - curl -sS -H "Authorization: Bearer $SPACEFAST_TOKEN" \ - -F "spaceId=$SPACEFAST_SPACE_ID" "${publish_files[@]}" \ - "https://api.spacefast.com/v1/publish?wait=1" - ``` - If an update after claiming returns `space_claimed_credential_available`, exchange the saved claim token once at `POST https://api.spacefast.com/v1/anonymous-claim/exchange`, save `data.credential.accessToken`, and retry with that access token. - -**Optional shortcuts and reference only:** if the `sf` CLI is already installed, `sf publish ./dist --wait` does the same job. A zip of the publish folder is also supported, but neither the CLI nor a zip is required. Docs: [direct agent/API publishing](https://spacefast.com/setup) · [files and folders](https://spacefast.com/help/publishing) · [claiming](https://spacefast.com/help/anonymous-publish) · [updates and rollback](https://spacefast.com/help/versions) diff --git a/recipes/wp-react/README.md b/recipes/wp-react/README.md deleted file mode 100644 index f66596f..0000000 --- a/recipes/wp-react/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# WordPress + React SPA - -A polished, client-side reading app for any WordPress site. It's a Vite + React -single-page app that fetches posts straight from a public, CORS-enabled WordPress -REST API in the browser — no backend, no server-side rendering, no build-time -content. This is the "headless WordPress" pattern: keep editing in WordPress, ship -a fast custom front end. - -The app has two views wired up with a tiny hash router so every post is -deep-linkable: - -- **List** (`#/`) — a responsive card grid showing each post's featured image, - title, excerpt, author, date, and estimated reading time, led by a larger - featured card for the newest post. A debounced search box queries the REST API's - `search` parameter, and Newer/Older pagination follows the `X-WP-TotalPages` - response header. -- **Article** (`#/post/:id`) — the title, author avatar and byline, a hero image - with its caption, and the full post body. The rendered HTML is sanitized with - DOMPurify before it's injected, so nothing in the feed can run script. - -Every async path has a real state: skeleton cards while loading, a friendly empty -state for no results, and a recoverable error panel with a working "Try again" -button for when the API is slow or offline. There's a light/dark toggle that -defaults to the OS preference and persists in `localStorage`. - -Everything that makes it "your" site lives in **`src/config.js`**: the API base -URL, the app title and tagline, and the page size. Point `API_BASE` at your own -`https://your-site.com/wp-json/wp/v2` and the whole app re-skins itself. The -default is `https://wordpress.org/news/wp-json/wp/v2`, an always-on public -WordPress install (the classic `demo.wp-api.org` demo is frequently offline). - -## Run it - -```bash -npm install # or: bun install -npm run dev # local dev server -npm run build # static output in dist/ -``` - -## Publishing notes - -Because it's a single-page app, `public/_redirects` contains `/* /index.html 200`. -Vite copies `public/` into `dist/`, so the SPA fallback ships with the build and -deep links like `/#/post/123` resolve correctly on Spacefast. Publish the `dist/` -folder. - -Built with Vite + React. Content via the WordPress REST API. Made to publish on -[Spacefast](https://spacefast.com). diff --git a/recipes/wp-react/meta.json b/recipes/wp-react/meta.json deleted file mode 100644 index c5b0a7a..0000000 --- a/recipes/wp-react/meta.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "WordPress + React SPA", - "title": "WordPress + React", - "slug": "wp-react", - "vertical": "Developers & integrations", - "tech": "Vite + React + WP REST API", - "summary": "A polished reading app that fetches posts live from a WordPress REST API in the browser — card list, article view, client-side routing, search, and real loading/error states.", - "order": 201, - "live_url": "https://wp-react.view.fast/", - "setup_questions": [ - "What's the WordPress REST API base URL? (e.g. https://your-site.com/wp-json/wp/v2; or use the demo https://wordpress.org/news/wp-json/wp/v2)", - "What should the app be called, and what's a one-line tagline?", - "Which categories, authors, or post types should be searchable or featured?", - "Light theme, dark theme, or a toggle that remembers my choice?" - ], - "photo_terms": [], - "sections": [ - "Post list — responsive card grid with featured image, title, excerpt, author, date, and reading time, led by a larger featured card", - "Post detail — title, author avatar + byline, hero image with caption, and the sanitized rendered article body", - "Client-side hash routing (#/ list, #/post/:id detail) so every post has a deep-linkable URL", - "Debounced search against the REST API's search parameter", - "Newer/Older pagination driven by the X-WP-TotalPages header", - "Loading skeletons, empty state, and a recoverable error panel with Try again", - "Light/dark theme toggle persisted to localStorage", - "SPA fallback (_redirects → /* /index.html 200) so deep links work on static hosting" - ], - "based_on": "A headless-WordPress reading app: a React single-page app that fetches posts from a public, CORS-enabled WordPress REST API straight from the browser — the kind of decoupled front end developers build on top of an existing WP install.", - "notes": "Content is fetched live from the WordPress REST API at runtime, so there are no Unsplash/stock photo terms — images come from each post's _embedded featured media. Default API base is wordpress.org/news (the classic demo.wp-api.org demo is frequently offline). Build with `npm install && npm run build`; publish the dist/ folder.", - "build_verified": true -} diff --git a/recipes/wp-react/prompt.md b/recipes/wp-react/prompt.md deleted file mode 100644 index 350940f..0000000 --- a/recipes/wp-react/prompt.md +++ /dev/null @@ -1,85 +0,0 @@ -Build me a **single-page reading app that pulls posts from a WordPress site over -its public REST API** as a **Vite + React app** (client-side only — the browser -fetches the content at runtime, no backend, no server-side rendering). - -**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 WordPress REST API base URL? (e.g. `https://your-site.com/wp-json/wp/v2`. - If you don't have one, use the always-on public demo `https://wordpress.org/news/wp-json/wp/v2`.) -2. What should the app be called, and what's a one-line tagline? -3. Which categories, authors, or post types should be searchable or featured? -4. Light theme, dark theme, or a toggle that remembers my choice? - -**Then build a complete, polished, responsive reading app with:** - -- A **post list** view: a responsive grid of cards, each with the post's title, - a short excerpt, the author, the date, an estimated reading time, and the - featured image (read from the `_embedded` data so it's one request, not many). - Lead with one larger "featured" card for the newest post. -- A **post detail** view: the title, the author (avatar + name), the date and - reading time, the featured image with its caption, and the full rendered post - body. Render the post HTML with `dangerouslySetInnerHTML`, but **sanitize it - first** (use DOMPurify) so nothing in the feed can run script. -- **Client-side routing** so each post has its own URL. A hash router - (`#/` for the list, `#/post/:id` for a post) keeps deep links working on any - static host. Back/forward and refresh must land on the right view. -- A **search box** that queries the API's `search` parameter (debounced), and - simple **pagination** (Newer / Older) driven by the `X-WP-TotalPages` header. -- Real **loading, empty, and error states**: skeleton cards while a request is in - flight, a friendly "no results" message, and a recoverable error panel with a - working "Try again" button for when the API is slow or down. The app must never - show a blank white screen. - -**Design & content notes:** - -- A calm, editorial reading look: a serif face (Georgia / Iowan) for headlines and - article body for readability, a clean system sans for UI, generous line-height, - and one restrained accent color. Cards lift gently on hover; images fade in. -- Honor my light/dark choice. If I asked for a toggle, default to the OS preference - and persist my pick in `localStorage`. -- The content is real — it comes live from the WordPress REST API at runtime, so - there's no "lorem ipsum" to write. For any post without a featured image, show a - tasteful gradient placeholder instead of a broken image. -- Keep everything configurable from one small `src/config.js` (API base URL, app - title, tagline, page size) so I can point it at a different WordPress site by - changing one line. -- Because this is a single-page app, add a **SPA fallback** so deep links resolve: - put a `_redirects` file in `public/` containing `/* /index.html 200` (Vite - copies `public/` into the build output, so it lands in `dist/`). -- Mobile-first and accessible: semantic HTML, a skip link, labelled inputs, - keyboard support, visible focus styles, good contrast, and `alt` text from the - WordPress media data. Respect `prefers-reduced-motion`. -- Build it with Vite (`npm create vite@latest` / `react` template), then - `npm run build` to produce the static `dist/` you'll publish. - -**Add this exact line right before `` in `index.html` so the site carries its badge:** - -```html - -``` - -**When the site is ready, publish it to Spacefast with the direct API — no account, install, or extra instructions needed:** - -1. Use `./dist` as the publish folder. For no-build projects, put only the finished public files in that dedicated folder. Confirm it has `index.html` at its root and no secrets, source files, `.git`, or `.spacefast` state. -2. First publish: send every file as multipart form data. This Bash/Zsh block preserves relative paths and works for one file or a whole folder: - ```bash - PUBLISH_ROOT=./dist - publish_files=() - while IFS= read -r -d "" file; do - relative=${file#"$PUBLISH_ROOT"/} - publish_files+=(-F "files=@$file;filename=$relative") - done < <(find "$PUBLISH_ROOT" -type f -print0) - curl -sS "${publish_files[@]}" "https://api.spacefast.com/v1/publish?wait=1" - ``` -3. From the `{ "data": ... }` receipt, give me `data.space.liveUrl`, `data.version.immutableUrl`, `data.claim.url`, and `data.claim.expiresAt`. Remind me to claim within 6 hours. Keep `data.claim.token` secret. -4. Save `data.space.id` and `data.claim.token` locally (for example in an ignored, mode-600 `.spacefast/state.json`). For a quick update, rebuild the `publish_files` array above and publish to the same space: - ```bash - SPACEFAST_SPACE_ID= - SPACEFAST_TOKEN= - curl -sS -H "Authorization: Bearer $SPACEFAST_TOKEN" \ - -F "spaceId=$SPACEFAST_SPACE_ID" "${publish_files[@]}" \ - "https://api.spacefast.com/v1/publish?wait=1" - ``` - If an update after claiming returns `space_claimed_credential_available`, exchange the saved claim token once at `POST https://api.spacefast.com/v1/anonymous-claim/exchange`, save `data.credential.accessToken`, and retry with that access token. - -**Optional shortcuts and reference only:** if the `sf` CLI is already installed, `sf publish ./dist --wait` does the same job. A zip of the publish folder is also supported, but neither the CLI nor a zip is required. Docs: [direct agent/API publishing](https://spacefast.com/setup) · [files and folders](https://spacefast.com/help/publishing) · [claiming](https://spacefast.com/help/anonymous-publish) · [updates and rollback](https://spacefast.com/help/versions) diff --git a/recipes/wp-zero/README.md b/recipes/wp-zero/README.md deleted file mode 100644 index 684269d..0000000 --- a/recipes/wp-zero/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# WordPress + Spacefast Zero - -A magazine whose **words come from WordPress at build time** and whose **live -parts come from a Spacefast Zero capsule at request time** — in one project, one -`sf.jsonc`, one publish. - -`wp-astro` shows headless WordPress rendered statically. `zero-perfect` shows the -Zero runtime on its own. This is the one where both are on the same page. - -Live: - -## The architecture, in words - -``` - BUILD TIME REQUEST TIME - ────────── ──────────── - wordpress.org/news - │ @spacefast/wordpress - │ posts.list({ _embed: true }) - ▼ - Article[] ──┬──▶ home.html ──▶ served as a flat file - │ posts/.html ──▶ served as a flat file - │ search-index.json ──▶ served as a flat file - │ - └──▶ shared/search-index.ts ──▶ compiled into the capsule - │ - ├─ GET /api/search?q= - ├─ GET /api/reactions?slug= - ├─ POST /api/react - ├─ query reactions (realtime) - └─ mutation toggleReaction -``` - -Every article page is a real file — view source and the words are there, no -JavaScript required. Two things on it are alive: - -- **Reactions.** `
` sits where the reaction bar - belongs. `sf build` compiles `client/index.tsx` into `/client.js`, which mounts - there. `useQuery("reactions", slug)` subscribes rather than polls, so a - reaction in one browser moves the count in another over Cast. -- **Search.** The masthead's search box is plain `fetch` against `/api/search` — - no framework, because an HTTP endpoint doesn't need one. The index it searches - was compiled from WordPress at build time and lives inside the capsule, so - typing downloads a handful of results instead of a corpus. - -## Run it - -```sh -cd site -bun install -bun run build # fetch WordPress → search index → app shell → static pages -bun test # content normalization, search ranking, reaction tallies -sf dev # the Zero server and client -bun run publish # sf publish --skip-build: capsule + static tree, one version -``` - -Point it at a different WordPress site without editing anything: - -```sh -WORDPRESS_URL=https://example.com bun run build -``` - -Everything else — the publication name, the accent, how many posts to pull — -lives in `site/tools/config.ts`. - -### Exercising the runtime - -```sh -curl "$BASE/api/search?q=release&limit=3" -curl "$BASE/api/reactions?slug=open-weight" -curl -c jar -b jar -X POST "$BASE/api/react" -H 'content-type: application/json' \ - -d '{"slug":"open-weight","emoji":"🔥"}' -``` - -Keep the cookie jar: the guest identity lives in a cookie, so without it every -request is a new reader and the second POST adds a reaction instead of taking -it back. - -`POST /api/react` is declared `mode: "write"`, which is what lets it touch the -database *and* what makes it broadcast — a reaction posted from a terminal moves -an open browser's count without a refresh. - -## Four things that are easy to get wrong - -**1. `bun run build` must run before `sf build`.** It writes both halves of the -publish: `shared/search-index.ts` (which the capsule compiles in) and the static -pages (which the capsule publishes beside itself). CI runs them in that order. - -**2. The generated app shell owns `/index.html`.** A Zero capsule with a client -entry always emits its own `index.html`, and an author file at that path is -dropped with a warning. So the magazine index ships as `home.html` and -`sf.jsonc` says `"index": "home.html"`. With `cleanUrls`, articles live at -`/posts/`. - -**3. `sf publish` needs `--skip-build` here.** A Zero project with a `build` -script in `package.json` sends `sf publish` down the static-site path: it reruns -the build and then looks for a `dist/`, `build/`, or `out/` that a capsule never -produces, and fails with `No build output directory was detected`. -`bun run publish` is `sf publish --skip-build`, which is correct anyway — -`bun run build` already compiled the capsule and rendered the pages. - -**4. Static pages need the platform import map.** `/client.js` imports `preact` -and `@spacefast/zero/client` as bare specifiers, resolved by the import map that -`sf build` writes into its generated shell — and only into that shell. Any other -page that wants the island has to carry the same map, before the module script. -So `tools/build.ts` runs `sf build` once to materialize the shell, lifts the map -out of it, and renders the pages around it. The real `sf build` afterwards picks -those pages up. See `readImportMap()` in `site/tools/build.ts`. - -## Layout - -``` -site/ - sf.jsonc index → home.html, cleanUrls, the Zero runtime block - tools/ build-time only; may use Node built-ins - config.ts WordPress URL, publication name, accent - wordpress.ts the only file that talks to WordPress - render.ts Article[] → HTML - build.ts the orchestrator - server/index.ts the capsule: table, query, mutation, three endpoints - client/index.tsx the reaction island - shared/ - reactions.ts the emoji vocabulary and tallying, used by both sides - search.ts scoring, pure - search-index.ts GENERATED by the build - assets/ - styles.css one stylesheet; the accent arrives as a CSS variable - search.js the search box, no framework - test/build.test.ts CMS-HTML normalization, ranking, tallies -``` - -`home.html`, `posts/`, `search-index.json`, and `shared/search-index.ts` are -generated and gitignored — a content snapshot, not source. Regenerate them, -never edit them. - -## Known rough edges - -- **`sf dev` previews the capsule, not the site.** It serves the Zero app shell - for every path, so the static pages do not appear there. Use it for the server - and the client (`curl` the endpoints); publish to see the magazine. -- **`tools/` gets published.** A Zero capsule treats `client/`, `server/`, - `shared/`, `package.json`, `README.md`, `AGENTS.md` and `test/` as project - files and everything else as site content, so the build scripts ship as - `/tools/*.ts`. Harmless in a public example; worth knowing if you copy the - pattern into a private repo. diff --git a/recipes/wp-zero/meta.json b/recipes/wp-zero/meta.json deleted file mode 100644 index f2711cd..0000000 --- a/recipes/wp-zero/meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Broadsheet — WordPress + Spacefast Zero", - "title": "WordPress + Zero", - "slug": "wp-zero", - "vertical": "Developers & integrations", - "tech": "WordPress + Spacefast Zero", - "runtime": "zero", - "summary": "A magazine whose articles come from a public WordPress REST API at build time and render to static pages, with a Spacefast Zero server behind them for realtime emoji reactions and server-side search over a build-time index.", - "order": 330, - "live_url": "https://wp-zero.view.fast/", - "setup_questions": [ - "Which WordPress site should the articles come from (any public site URL or /wp-json REST root, e.g. https://wordpress.org/news)?", - "What should the publication be called?", - "What's the accent color (a hex like #d9410b)?" - ], - "photo_terms": [], - "sections": [ - "Masthead with the publication name, a server-backed search box, and the WordPress source credited", - "Magazine index: a full-bleed lead story plus a responsive grid of article cards (featured image, categories, title, excerpt, author, date, reading time)", - "Static article pages rendering the post's own WordPress HTML as editorial prose — images, embeds, blockquotes, tables, and code blocks all handled", - "A realtime emoji reaction bar on every article, backed by the Zero database and live across browsers", - "Search results served by GET /api/search from an index compiled into the capsule at build time", - "Colophon explaining which half of the page is static and which half is the runtime" - ], - "based_on": "The setup a real team lands on when WordPress is the editor but the site should be static: content pulled through the @spacefast/wordpress client at build time, rendered to flat HTML, with one Spacefast Zero capsule adding the few things a static file cannot do.", - "why_good": "The flagship composition example — build-time headless WordPress and a request-time Zero server in the same space, one sf.jsonc, one publish. It is the only recipe where static rendering and a live database sit on the same page.", - "notes": "Run and publish from site/. Zero runtime project, not a prebuilt static directory. `bun run build` must run before `sf build`: it fetches WordPress, generates shared/search-index.ts for the capsule, and renders home.html plus posts/*.html into the capsule root. Those generated files are gitignored. Publish with `sf publish --skip-build` (or `bun run publish`) — a Zero project with a `build` script otherwise sends sf publish down the static-site path and it fails looking for a dist/. sf dev previews the Zero server and client only; the static tree is exercised on a published space." -} diff --git a/recipes/wp-zero/prompt.md b/recipes/wp-zero/prompt.md deleted file mode 100644 index 390bae5..0000000 --- a/recipes/wp-zero/prompt.md +++ /dev/null @@ -1,91 +0,0 @@ -Build me a **magazine whose articles come from WordPress but whose interactivity comes -from a server** — headless WordPress rendered to static pages at build time, plus a -Spacefast Zero capsule behind them for realtime reactions and search. One project, one -`sf.jsonc`, one publish. - -**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. Which WordPress site should the articles come from? Any public site URL or `/wp-json` - REST root works — default to `https://wordpress.org/news`. -2. What should the publication be called? -3. What's the accent color (a hex like `#d9410b`)? - -**Build time — pull WordPress, render flat HTML:** - -- Read posts with the `@spacefast/wordpress` client - (`createWordPressClient({ url })`, then `posts.list({ perPage, orderBy: "date", _embed: true })`). - `_embed` returns the author, featured image, and terms in the same request, so there is - no N+1. Fall back to plain `fetch` against `/wp-json/wp/v2` if the package is - unavailable. -- Normalize the messy CMS shapes once: rendered titles carry HTML entities, excerpts carry - markup, a post may have no featured image, and terms arrive as a nested array of - taxonomies. -- Render a magazine index (lead story plus a card grid) and one static page per post. - Article bodies are the post's own WordPress HTML, so handle what a real editor ships: - lazy-loaded images with captions, responsive iframe embeds, blockquotes, tables that can - scroll on a phone, and `
` blocks that do not blow out the column.
-- Emit a search index (JSON) of every post's slug, title, excerpt, and plain-text body, and
-  compile it into the capsule so the server can search it.
-
-**Runtime — one Spacefast Zero capsule:**
-
-- Declare `"runtime": { "kind": "zero", "server": "server/index.ts", "client": "client/index.tsx" }`
-  in `sf.jsonc`.
-- A `reactions` table keyed by post slug, emoji, and `ctx.auth.userId`. The built-in guest
-  session is the identity — nobody signs in to press an emoji.
-- A `reactions` query returning per-emoji counts and which one the current visitor picked,
-  and a `toggleReaction` mutation that adds, switches, or removes. The Zero client
-  subscribes, so a reaction in one browser moves the count in another through Cast.
-- `GET /api/search?q=` searching the build-time index server-side and returning JSON.
-- `GET /api/reactions?slug=` and `POST /api/react` so the same behavior is reachable with
-  `curl`, not only from the page.
-
-**Wiring the two halves together:**
-
-- The generated app shell owns `/index.html`, so serve the magazine index from
-  `home.html` and set `"index": "home.html"` in `sf.jsonc`. Turn on `cleanUrls` so
-  articles live at `/posts/`.
-- Each static page mounts the reaction island into a `
` - placed exactly where the reaction bar belongs, loads `/client.js`, and carries the - platform import map that `sf build` writes into the generated shell — read it out of - `.spacefast/zero/public/index.html` and inline it. That means the build runs `sf build` - once to materialize the shell, renders the pages, and lets the real `sf build` publish - them. -- The search box is plain `fetch` against `/api/search` — no framework needed for an HTTP - endpoint. - -**Design & content notes:** - -- Magazine, not blog template: a real masthead, a strong editorial serif for headlines, a - narrow measure for body copy, generous white space, one accent color used sparingly. -- Responsive and accessible: semantic landmarks, a labeled search input, visible focus - rings, `aria-pressed` on the reaction buttons, live-region announcements for counts, and - contrast that survives the accent color. -- The images and words are real — they come from WordPress. Never invent post content. -- Say plainly, in the footer, which half of each page is static and which half is the - runtime. - -**Add this exact line right before `` on every page so the site carries its badge:** - -```html - -``` - -**Run it, then publish:** - -```sh -cd site -bun install -bun run build # fetches WordPress, generates the index, renders the pages -sf dev # previews the Zero server and client -sf publish --skip-build # the capsule and the static tree, in one version -``` - -`--skip-build` matters: a Zero project with a `build` script sends `sf publish` -down the static-site path, where it reruns the build and then fails looking for a -`dist/` a capsule never produces. Everything is already built by this point. - -Check `GET /api/search?q=` returns results, post a reaction and read the count -back, then open two browsers on the same article and confirm one reaction moves both -counts. diff --git a/recipes/wp-zero/site/package.json b/recipes/wp-zero/site/package.json deleted file mode 100644 index e346a1b..0000000 --- a/recipes/wp-zero/site/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "wp-zero", - "private": true, - "type": "module", - "scripts": { - "build": "bun run tools/build.ts", - "test": "bun test test", - "//publish": "--skip-build: `bun run build` already compiled the capsule and rendered the pages. Without it sf publish reruns the build script and then looks for a dist/ that a Zero project does not have.", - "publish": "sf publish --skip-build" - }, - "dependencies": { - "@spacefast/wordpress": "0.4.1", - "@spacefast/zero": "0.4.1", - "preact": "^10.29.7" - }, - "devDependencies": { - "typescript": "^6.0.3" - } -} diff --git a/recipes/zero-perfect/README.md b/recipes/zero-perfect/README.md deleted file mode 100644 index cf02c53..0000000 --- a/recipes/zero-perfect/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Zero Perfect - -A minimal full-stack Spacefast Zero app that proves the complete runtime path: -database-backed queries and mutations, guest and Gravatar auth, Cast realtime, -client-side routes, and an HTTP endpoint. - -## Run it - -```sh -cd site -bun install -sf dev -``` - -Open `/` for the realtime todo list and `/status` to call `GET /api/status`. - -Publish from `site/` with `sf publish`. Unlike static recipes, the recipe -workflow publishes this Zero project root so the CLI can compile and attach its -server artifact. - -Live: diff --git a/recipes/zero-perfect/meta.json b/recipes/zero-perfect/meta.json deleted file mode 100644 index 2e0f340..0000000 --- a/recipes/zero-perfect/meta.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "Zero Perfect", - "title": "Realtime Zero app", - "slug": "zero-perfect", - "vertical": "Tools, apps & games", - "tech": "Spacefast Zero + Preact", - "runtime": "zero", - "summary": "A minimal full-stack todo app with database-backed queries and mutations, guest and Gravatar auth, Cast realtime, routes, and an HTTP endpoint.", - "order": 310, - "live_url": "https://zero-perfect.view.fast/", - "setup_questions": [ - "What should the app track instead of todos?", - "What should the app be called?" - ], - "photo_terms": [], - "sections": [ - "Realtime database-backed list", - "Guest and Gravatar authentication", - "Status route backed by an HTTP endpoint" - ], - "based_on": "The smallest app that exercises every core Spacefast Zero runtime seam.", - "notes": "Run and publish from site/. This is a Zero runtime project, not a prebuilt static output directory." -} diff --git a/recipes/zero-perfect/prompt.md b/recipes/zero-perfect/prompt.md deleted file mode 100644 index 98b8957..0000000 --- a/recipes/zero-perfect/prompt.md +++ /dev/null @@ -1,23 +0,0 @@ -Build me a small full-stack todo app with Spacefast Zero and Preact. - -Use the Zero capsule model rather than a separate API server: - -- Define a `todos` table with text, completion state, and an owner ID. -- Scope reads to `ctx.auth.userId` and expose them through a `todos` query. -- Add todos through an `addTodo` mutation after trimming and limiting the text. -- Show updates in realtime through the Zero client and Cast. -- Support the built-in guest session and Gravatar sign-in/sign-out controls. -- Add client routes for the todo list and `/status`. -- Back `/status` with a `GET /api/status` Zero endpoint that returns `ok`. -- Keep shared input cleanup and types in a pure TypeScript module. -- Keep the app compact and accessible, with a labeled input, visible focus states, - useful empty/loading states, and responsive layout. - -Use this exact Spacefast badge in the generated document: - -```html - -``` - -Run it locally with `sf dev`, verify both routes and a realtime todo update, then -publish the project root with `sf publish`. diff --git a/scripts/catalog.test.mjs b/scripts/catalog.test.mjs index 58c7b19..f28e4e2 100644 --- a/scripts/catalog.test.mjs +++ b/scripts/catalog.test.mjs @@ -58,18 +58,6 @@ describe("canonical recipe catalog", () => { } }); - test("describes Zero recipes as runtime projects", async () => { - expect(manifestBuild.exitCode).toBe(0); - const manifest = await Bun.file(path.join(root, "dist/manifest.json")).json(); - expect(manifest).toContainEqual( - expect.objectContaining({ - slug: "zero-perfect", - runtime: "zero", - liveUrl: "https://zero-perfect.view.fast/", - }), - ); - }); - test("labels the public feed as recipes while retaining its historical URL", async () => { const document = await inspectHtml( await Bun.file(path.join(root, "dist/index.html")).text(), diff --git a/recipes/trailhead/site/.gitignore b/trailhead/.gitignore similarity index 100% rename from recipes/trailhead/site/.gitignore rename to trailhead/.gitignore diff --git a/trailhead/README.md b/trailhead/README.md new file mode 100644 index 0000000..26f4ed6 --- /dev/null +++ b/trailhead/README.md @@ -0,0 +1,49 @@ +# Trailhead: Next.js static export with @spacefast/image + +A photo-led hiking guide: an index of six real trails and a generated page for +each. It is the Next.js static export example: `output: "export"`, +`generateStaticParams`, Site Accelerator images through `@spacefast/image`, and +`_redirects` / `_headers` shipped as files. + +```sh +bun install +bun run dev +sf publish +``` + +The CLI detects Next.js, runs `bun run build`, and publishes `out/`: a root +`index.html`, a directory per route, `404.html`, and the convention files +(`_redirects`, `_headers`, `sf.jsonc`) copied from `public/`. + +## Important files + +| Platform feature | Where | +| --- | --- | +| `output: "export"` + `trailingSlash: true` | `next.config.ts` | +| `spacefastNextImageConfig` from `@spacefast/image/next` | `next.config.ts` | +| Loader file Next bundles with the client | `lib/spacefast-image-loader.ts` | +| `generateStaticParams` for per-trail routes | `app/trails/[slug]/page.tsx` | +| `siteAcceleratorUrl()` for OG cards | `app/layout.tsx`, `app/trails/[slug]/page.tsx` | +| `_redirects` (four 301s) | `public/_redirects` | +| `_headers` (security + immutable asset cache) | `public/_headers` | +| `sf.jsonc` name, meta, 404 fallback | `public/sf.jsonc` | + +Every `next/image` source is an absolute `https://images.unsplash.com/...` URL, +which is what makes the loader do anything; relative `public/` paths pass +through untouched. The built HTML proves it: + +``` +$ grep -o 'https://i0.wp.com[^" ]*' out/trails/laugavegur/index.html | head -1 +https://i0.wp.com/images.unsplash.com/photo-1726533870778-8be51bf99bb1?quality=80&strip=all&w=640 +``` + +## Implementation notes + +- `data/trails.ts` is the one data file; the index, detail pages, + `generateStaticParams`, and OG cards all read from it. +- Distances and elevations are stored once as metric numbers; `lib/units.ts` + renders both unit systems from them so they cannot drift. +- One hand-written stylesheet, no web fonts, nothing fetched at build time, so + the build is hermetic. +- Figures use `aspect-ratio: 3 / 2` with `object-fit: cover`, so nothing shifts + as photos land. diff --git a/recipes/trailhead/site/app/globals.css b/trailhead/app/globals.css similarity index 100% rename from recipes/trailhead/site/app/globals.css rename to trailhead/app/globals.css diff --git a/recipes/trailhead/site/app/layout.tsx b/trailhead/app/layout.tsx similarity index 100% rename from recipes/trailhead/site/app/layout.tsx rename to trailhead/app/layout.tsx diff --git a/recipes/trailhead/site/app/not-found.tsx b/trailhead/app/not-found.tsx similarity index 100% rename from recipes/trailhead/site/app/not-found.tsx rename to trailhead/app/not-found.tsx diff --git a/recipes/trailhead/site/app/page.tsx b/trailhead/app/page.tsx similarity index 100% rename from recipes/trailhead/site/app/page.tsx rename to trailhead/app/page.tsx diff --git a/recipes/trailhead/site/app/trails/[slug]/page.tsx b/trailhead/app/trails/[slug]/page.tsx similarity index 100% rename from recipes/trailhead/site/app/trails/[slug]/page.tsx rename to trailhead/app/trails/[slug]/page.tsx diff --git a/recipes/trailhead/site/bun.lock b/trailhead/bun.lock similarity index 98% rename from recipes/trailhead/site/bun.lock rename to trailhead/bun.lock index 2b2e0d3..eabf342 100644 --- a/recipes/trailhead/site/bun.lock +++ b/trailhead/bun.lock @@ -5,7 +5,7 @@ "": { "name": "trailhead", "dependencies": { - "@spacefast/image": "0.4.1", + "@spacefast/image": "0.8.0", "next": "^16.3.3", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -93,7 +93,7 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ=="], - "@spacefast/image": ["@spacefast/image@0.4.1", "", {}, "sha512-7NdVj6k1A3gBMHsf3wU/CHqBIAl1YjfMGhHodIVIEIb2pclwp7cISYabdSiwdWvLo1MtlgwODltMuCCIJxKwTQ=="], + "@spacefast/image": ["@spacefast/image@0.8.0", "", {}, "sha512-d23SKmgYZFJAZBnm/tFCCQmqMf/tGhCIk5r0CRuGW0wMgG8R6AEX4KRefXQJZxXshhZ5Wgb2INoIJCxYZT7BWw=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], diff --git a/recipes/trailhead/site/components/site-chrome.tsx b/trailhead/components/site-chrome.tsx similarity index 100% rename from recipes/trailhead/site/components/site-chrome.tsx rename to trailhead/components/site-chrome.tsx diff --git a/recipes/trailhead/site/components/trail-card.tsx b/trailhead/components/trail-card.tsx similarity index 100% rename from recipes/trailhead/site/components/trail-card.tsx rename to trailhead/components/trail-card.tsx diff --git a/recipes/trailhead/site/data/site.ts b/trailhead/data/site.ts similarity index 100% rename from recipes/trailhead/site/data/site.ts rename to trailhead/data/site.ts diff --git a/recipes/trailhead/site/data/trails.ts b/trailhead/data/trails.ts similarity index 100% rename from recipes/trailhead/site/data/trails.ts rename to trailhead/data/trails.ts diff --git a/recipes/trailhead/site/lib/spacefast-image-loader.ts b/trailhead/lib/spacefast-image-loader.ts similarity index 100% rename from recipes/trailhead/site/lib/spacefast-image-loader.ts rename to trailhead/lib/spacefast-image-loader.ts diff --git a/recipes/trailhead/site/lib/units.ts b/trailhead/lib/units.ts similarity index 100% rename from recipes/trailhead/site/lib/units.ts rename to trailhead/lib/units.ts diff --git a/trailhead/next.config.ts b/trailhead/next.config.ts new file mode 100644 index 0000000..204f50a --- /dev/null +++ b/trailhead/next.config.ts @@ -0,0 +1,18 @@ +import type { NextConfig } from "next"; +import { spacefastNextImageConfig } from "@spacefast/image/next"; + +const nextConfig: NextConfig = { + // Flat HTML in `out/`. No server, no Node.js runtime, nothing to keep warm. + output: "export", + // `/trails/angels-landing/index.html` instead of `/trails/angels-landing.html`, + // so every route is a directory and links work with or without the slash. + trailingSlash: true, + images: { + ...spacefastNextImageConfig, + // Next resolves `loaderFile` against the project root and bundles it with + // the client; the file is a one-line re-export of the package's loader. + loaderFile: "./lib/spacefast-image-loader.ts", + }, +}; + +export default nextConfig; diff --git a/recipes/trailhead/site/package.json b/trailhead/package.json similarity index 93% rename from recipes/trailhead/site/package.json rename to trailhead/package.json index 82ab5f4..8bb33c0 100644 --- a/recipes/trailhead/site/package.json +++ b/trailhead/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@spacefast/image": "0.4.1", + "@spacefast/image": "0.8.0", "next": "^16.3.3", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/recipes/trailhead/site/public/_headers b/trailhead/public/_headers similarity index 100% rename from recipes/trailhead/site/public/_headers rename to trailhead/public/_headers diff --git a/recipes/trailhead/site/public/_redirects b/trailhead/public/_redirects similarity index 100% rename from recipes/trailhead/site/public/_redirects rename to trailhead/public/_redirects diff --git a/recipes/trailhead/site/public/favicon.svg b/trailhead/public/favicon.svg similarity index 100% rename from recipes/trailhead/site/public/favicon.svg rename to trailhead/public/favicon.svg diff --git a/recipes/trailhead/site/public/sf.jsonc b/trailhead/public/sf.jsonc similarity index 100% rename from recipes/trailhead/site/public/sf.jsonc rename to trailhead/public/sf.jsonc diff --git a/recipes/trailhead/site/tsconfig.json b/trailhead/tsconfig.json similarity index 100% rename from recipes/trailhead/site/tsconfig.json rename to trailhead/tsconfig.json diff --git a/recipes/unfurl/site/.gitignore b/unfurl/.gitignore similarity index 100% rename from recipes/unfurl/site/.gitignore rename to unfurl/.gitignore diff --git a/unfurl/README.md b/unfurl/README.md new file mode 100644 index 0000000..089b20e --- /dev/null +++ b/unfurl/README.md @@ -0,0 +1,53 @@ +# Unfurl: link previews on the Functions runtime + +Paste a URL, get the preview card. The server fetches the page, parses its +OpenGraph and Twitter-card metadata, and renders the card Slack or iMessage +would show. Unfurling cannot be faked client-side (CORS stops a browser from +reading a cross-origin ``), so this proves a real server: a Next.js Route +Handler, per-request SSR, and middleware on the Spacefast Functions runtime. + +```sh +bun install +bun run dev # http://localhost:3000 +sf publish +``` + +## What runs where + +| Path | Rendering | Served by | +| --- | --- | --- | +| `/` | Prerendered at build time | Disk; the worker never wakes | +| `/u?url=…` | SSR, per request | Worker | +| `/api/unfurl?url=…` | Route Handler | Worker | +| `middleware.ts` | Every non-asset request | Worker | +| `/_next/static/*` | Build output | Disk | + +## Important files + +| File | What it holds | +| --- | --- | +| `lib/guard.ts` | SSRF defense: scheme allowlist, private/loopback/link-local/CGNAT blocking for v4 and v6 (including IPv4-mapped), DNS checks, bare-domain normalization. | +| `lib/unfurl.ts` | The fetch: manual redirect walk (3 hops, every hop re-vetted), 512 KB body cap, 8 s timeout, content-type check. | +| `lib/parse.ts` | Dependency-free `` scanner: `og:*` → `twitter:*` → plain HTML, entity decoding, relative-URL resolution. | +| `app/api/unfurl/route.ts` | `GET /api/unfurl?url=…` → normalized JSON, or `{ error: { code, message } }`. | +| `app/u/page.tsx` | The SSR page; renders the card in the HTML. | +| `middleware.ts` | Normalizes a bare-domain `url` param with a 308 and stamps security headers. | +| `sf.jsonc` | Optional pin: `runtime.kind: "functions"`, `entry: ".open-next/worker.js"`. Turns a broken build into a loud `config_runtime_entry_missing` instead of a silent static publish. | + +## How the publish works + +`sf publish` detects the Next.js app, runs `next build` with the Spacefast Next +adapter injected, bundles the standalone output with a pinned OpenNext adapter +into `.open-next/`, and uploads the static half and the worker as one version. +`bun run build` on its own is only a sanity build. + +Rules that break the build: + +- No `output: "export"` in `next.config.ts`; this app needs a server. +- No `export const runtime = "edge"` anywhere; the adapter uses Node.js. +- Do not install `@opennextjs/cloudflare` or write `open-next.config.ts`; the + CLI pins and runs the adapter. +- Keep `"build": "next build"` verbatim. + +An unclaimed Space may only reach a short list of trusted hosts, so until the +Space is claimed most unfurls come back as a 403 from the target site. diff --git a/recipes/unfurl/site/app/api/unfurl/route.ts b/unfurl/app/api/unfurl/route.ts similarity index 100% rename from recipes/unfurl/site/app/api/unfurl/route.ts rename to unfurl/app/api/unfurl/route.ts diff --git a/recipes/unfurl/site/app/globals.css b/unfurl/app/globals.css similarity index 100% rename from recipes/unfurl/site/app/globals.css rename to unfurl/app/globals.css diff --git a/recipes/unfurl/site/app/layout.tsx b/unfurl/app/layout.tsx similarity index 100% rename from recipes/unfurl/site/app/layout.tsx rename to unfurl/app/layout.tsx diff --git a/recipes/unfurl/site/app/page.tsx b/unfurl/app/page.tsx similarity index 100% rename from recipes/unfurl/site/app/page.tsx rename to unfurl/app/page.tsx diff --git a/recipes/unfurl/site/app/u/page.tsx b/unfurl/app/u/page.tsx similarity index 100% rename from recipes/unfurl/site/app/u/page.tsx rename to unfurl/app/u/page.tsx diff --git a/recipes/unfurl/site/bun.lock b/unfurl/bun.lock similarity index 100% rename from recipes/unfurl/site/bun.lock rename to unfurl/bun.lock diff --git a/recipes/unfurl/site/components/CardMedia.tsx b/unfurl/components/CardMedia.tsx similarity index 100% rename from recipes/unfurl/site/components/CardMedia.tsx rename to unfurl/components/CardMedia.tsx diff --git a/recipes/unfurl/site/components/PreviewCard.tsx b/unfurl/components/PreviewCard.tsx similarity index 100% rename from recipes/unfurl/site/components/PreviewCard.tsx rename to unfurl/components/PreviewCard.tsx diff --git a/recipes/unfurl/site/lib/guard.ts b/unfurl/lib/guard.ts similarity index 100% rename from recipes/unfurl/site/lib/guard.ts rename to unfurl/lib/guard.ts diff --git a/recipes/unfurl/site/lib/parse.ts b/unfurl/lib/parse.ts similarity index 100% rename from recipes/unfurl/site/lib/parse.ts rename to unfurl/lib/parse.ts diff --git a/recipes/unfurl/site/lib/unfurl.ts b/unfurl/lib/unfurl.ts similarity index 100% rename from recipes/unfurl/site/lib/unfurl.ts rename to unfurl/lib/unfurl.ts diff --git a/recipes/unfurl/site/middleware.ts b/unfurl/middleware.ts similarity index 100% rename from recipes/unfurl/site/middleware.ts rename to unfurl/middleware.ts diff --git a/recipes/unfurl/site/next.config.ts b/unfurl/next.config.ts similarity index 100% rename from recipes/unfurl/site/next.config.ts rename to unfurl/next.config.ts diff --git a/recipes/unfurl/site/package.json b/unfurl/package.json similarity index 100% rename from recipes/unfurl/site/package.json rename to unfurl/package.json diff --git a/recipes/unfurl/site/sf.jsonc b/unfurl/sf.jsonc similarity index 100% rename from recipes/unfurl/site/sf.jsonc rename to unfurl/sf.jsonc diff --git a/recipes/unfurl/site/tsconfig.json b/unfurl/tsconfig.json similarity index 100% rename from recipes/unfurl/site/tsconfig.json rename to unfurl/tsconfig.json diff --git a/recipes/wp-astro/site/.gitignore b/wp-astro/.gitignore similarity index 75% rename from recipes/wp-astro/site/.gitignore rename to wp-astro/.gitignore index 97f38f7..60b9eed 100644 --- a/recipes/wp-astro/site/.gitignore +++ b/wp-astro/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .astro/ .gstack/ +.spacefast/ diff --git a/wp-astro/README.md b/wp-astro/README.md new file mode 100644 index 0000000..11a1cfa --- /dev/null +++ b/wp-astro/README.md @@ -0,0 +1,26 @@ +# Headless WordPress + Astro + +An editorial blog that runs WordPress headlessly: the posts live in a real +WordPress install and are pulled from its public REST API at build time, then +rendered to flat HTML by Astro. No server, PHP, or database at request time. + +```sh +bun install +bun run dev +sf publish +``` + +The CLI detects Astro, runs `bun run build`, and publishes `dist/`. + +Important files: + +- `src/lib/wp.ts` makes one `?_embed` request so each post arrives with its + author, featured image, and categories, then normalizes the WordPress shapes + (rendered HTML in titles, optional embeds, entity escapes) into a small `Post`. +- `src/pages/index.astro` renders a lead story plus a card grid; + `src/pages/[slug].astro` uses `getStaticPaths` to bake one page per post. +- `src/config.ts` holds the three things to change: the REST API base (the + demo is `https://wordpress.org/news`), the blog title, and the accent color. + +Posts with no featured image fall back to `jetpack_featured_media_url` and then +to a tinted placeholder, never a broken image. diff --git a/recipes/wp-astro/site/astro.config.mjs b/wp-astro/astro.config.mjs similarity index 100% rename from recipes/wp-astro/site/astro.config.mjs rename to wp-astro/astro.config.mjs diff --git a/recipes/wp-astro/site/bun.lock b/wp-astro/bun.lock similarity index 100% rename from recipes/wp-astro/site/bun.lock rename to wp-astro/bun.lock diff --git a/recipes/wp-astro/site/package.json b/wp-astro/package.json similarity index 100% rename from recipes/wp-astro/site/package.json rename to wp-astro/package.json diff --git a/recipes/wp-astro/site/public/favicon.svg b/wp-astro/public/favicon.svg similarity index 100% rename from recipes/wp-astro/site/public/favicon.svg rename to wp-astro/public/favicon.svg diff --git a/recipes/wp-astro/site/src/components/PostCard.astro b/wp-astro/src/components/PostCard.astro similarity index 100% rename from recipes/wp-astro/site/src/components/PostCard.astro rename to wp-astro/src/components/PostCard.astro diff --git a/recipes/wp-astro/site/src/config.ts b/wp-astro/src/config.ts similarity index 100% rename from recipes/wp-astro/site/src/config.ts rename to wp-astro/src/config.ts diff --git a/recipes/wp-astro/site/src/layouts/Layout.astro b/wp-astro/src/layouts/Layout.astro similarity index 100% rename from recipes/wp-astro/site/src/layouts/Layout.astro rename to wp-astro/src/layouts/Layout.astro diff --git a/recipes/wp-astro/site/src/lib/wp.ts b/wp-astro/src/lib/wp.ts similarity index 100% rename from recipes/wp-astro/site/src/lib/wp.ts rename to wp-astro/src/lib/wp.ts diff --git a/recipes/wp-astro/site/src/pages/[slug].astro b/wp-astro/src/pages/[slug].astro similarity index 100% rename from recipes/wp-astro/site/src/pages/[slug].astro rename to wp-astro/src/pages/[slug].astro diff --git a/recipes/wp-astro/site/src/pages/index.astro b/wp-astro/src/pages/index.astro similarity index 100% rename from recipes/wp-astro/site/src/pages/index.astro rename to wp-astro/src/pages/index.astro diff --git a/recipes/wp-astro/site/src/styles/global.css b/wp-astro/src/styles/global.css similarity index 100% rename from recipes/wp-astro/site/src/styles/global.css rename to wp-astro/src/styles/global.css diff --git a/recipes/wp-astro/site/tsconfig.json b/wp-astro/tsconfig.json similarity index 100% rename from recipes/wp-astro/site/tsconfig.json rename to wp-astro/tsconfig.json diff --git a/recipes/wp-react/site/.gitignore b/wp-react/.gitignore similarity index 76% rename from recipes/wp-react/site/.gitignore rename to wp-react/.gitignore index b518b92..3f0d797 100644 --- a/recipes/wp-react/site/.gitignore +++ b/wp-react/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ *.local .DS_Store +.spacefast/ diff --git a/wp-react/README.md b/wp-react/README.md new file mode 100644 index 0000000..01575b4 --- /dev/null +++ b/wp-react/README.md @@ -0,0 +1,31 @@ +# WordPress + React SPA + +A client-side reading app for any WordPress site: a Vite + React single-page +app that fetches posts from a public, CORS-enabled WordPress REST API in the +browser. No backend, no server-side rendering, no build-time content. + +```sh +bun install +bun run dev +sf publish +``` + +The CLI detects Vite, runs `bun run build`, and publishes `dist/`. + +Two views, wired with a small hash router so every post is deep-linkable: + +- **List** (`#/`): a card grid with featured image, title, excerpt, author, + date, and reading time. A debounced search box uses the REST API's `search` + parameter; Newer/Older pagination follows the `X-WP-TotalPages` header. +- **Article** (`#/post/:id`): title, byline, hero image, and the post body, + sanitized with DOMPurify before it is injected. + +Every async path has a real state: skeletons while loading, an empty state, and +a recoverable error panel. A light/dark toggle persists in `localStorage`. + +Important files: + +- `src/config.js` holds the API base URL, title, tagline, and page size. The + default is `https://wordpress.org/news/wp-json/wp/v2`. +- `public/_redirects` contains `/* /index.html 200`, the SPA fallback. Vite + copies `public/` into `dist/`, so deep links resolve on Spacefast. diff --git a/recipes/wp-react/site/bun.lock b/wp-react/bun.lock similarity index 100% rename from recipes/wp-react/site/bun.lock rename to wp-react/bun.lock diff --git a/recipes/wp-react/site/index.html b/wp-react/index.html similarity index 100% rename from recipes/wp-react/site/index.html rename to wp-react/index.html diff --git a/recipes/wp-react/site/package.json b/wp-react/package.json similarity index 100% rename from recipes/wp-react/site/package.json rename to wp-react/package.json diff --git a/recipes/wp-react/site/public/_redirects b/wp-react/public/_redirects similarity index 100% rename from recipes/wp-react/site/public/_redirects rename to wp-react/public/_redirects diff --git a/recipes/wp-react/site/public/favicon.svg b/wp-react/public/favicon.svg similarity index 100% rename from recipes/wp-react/site/public/favicon.svg rename to wp-react/public/favicon.svg diff --git a/recipes/wp-react/site/src/App.jsx b/wp-react/src/App.jsx similarity index 100% rename from recipes/wp-react/site/src/App.jsx rename to wp-react/src/App.jsx diff --git a/recipes/wp-react/site/src/api.js b/wp-react/src/api.js similarity index 100% rename from recipes/wp-react/site/src/api.js rename to wp-react/src/api.js diff --git a/recipes/wp-react/site/src/components/Header.jsx b/wp-react/src/components/Header.jsx similarity index 100% rename from recipes/wp-react/site/src/components/Header.jsx rename to wp-react/src/components/Header.jsx diff --git a/recipes/wp-react/site/src/components/PostDetail.jsx b/wp-react/src/components/PostDetail.jsx similarity index 100% rename from recipes/wp-react/site/src/components/PostDetail.jsx rename to wp-react/src/components/PostDetail.jsx diff --git a/recipes/wp-react/site/src/components/PostList.jsx b/wp-react/src/components/PostList.jsx similarity index 100% rename from recipes/wp-react/site/src/components/PostList.jsx rename to wp-react/src/components/PostList.jsx diff --git a/recipes/wp-react/site/src/components/common.jsx b/wp-react/src/components/common.jsx similarity index 100% rename from recipes/wp-react/site/src/components/common.jsx rename to wp-react/src/components/common.jsx diff --git a/recipes/wp-react/site/src/config.js b/wp-react/src/config.js similarity index 100% rename from recipes/wp-react/site/src/config.js rename to wp-react/src/config.js diff --git a/recipes/wp-react/site/src/hooks.js b/wp-react/src/hooks.js similarity index 100% rename from recipes/wp-react/site/src/hooks.js rename to wp-react/src/hooks.js diff --git a/recipes/wp-react/site/src/main.jsx b/wp-react/src/main.jsx similarity index 100% rename from recipes/wp-react/site/src/main.jsx rename to wp-react/src/main.jsx diff --git a/recipes/wp-react/site/src/styles.css b/wp-react/src/styles.css similarity index 100% rename from recipes/wp-react/site/src/styles.css rename to wp-react/src/styles.css diff --git a/recipes/wp-react/site/vite.config.js b/wp-react/vite.config.js similarity index 100% rename from recipes/wp-react/site/vite.config.js rename to wp-react/vite.config.js diff --git a/recipes/wp-zero/site/.gitignore b/wp-zero/.gitignore similarity index 100% rename from recipes/wp-zero/site/.gitignore rename to wp-zero/.gitignore diff --git a/recipes/wp-zero/site/AGENTS.md b/wp-zero/AGENTS.md similarity index 70% rename from recipes/wp-zero/site/AGENTS.md rename to wp-zero/AGENTS.md index d0a441c..954dadd 100644 --- a/recipes/wp-zero/site/AGENTS.md +++ b/wp-zero/AGENTS.md @@ -4,14 +4,14 @@ Two halves in one project. Know which half you are editing. ## Build time (`tools/`) -- `tools/config.ts` holds the three answers the recipe asks for: WordPress URL, - publication name, accent. +- `tools/config.ts` holds the three settings: WordPress URL, publication name, + accent. - `tools/wordpress.ts` is the only file that talks to WordPress. It uses `@spacefast/wordpress` and normalizes the REST shapes into `Article`. - `tools/render.ts` turns `Article[]` into HTML. No data fetching here. - `tools/build.ts` orchestrates: fetch → write `shared/search-index.ts` → - `sf build` (to materialize the app shell) → render `home.html` and - `posts/*.html`. + `sf build --skip-build` (to materialize the app shell) → render `home.html` + and `posts/*.html`. - Nothing under `tools/` is capsule code. It runs in Bun and may use Node built-ins. @@ -21,23 +21,22 @@ Two halves in one project. Know which half you are editing. TypeScript in `shared/`. - Use `@spacefast/zero/client` only from client code and `@spacefast/zero/server` only from server code. -- Queries own reads. Mutations own writes and realtime invalidation. A `write` - endpoint also broadcasts. +- Queries own reads. Mutations own writes and realtime invalidation. A `POST` + endpoint is a write and also broadcasts. - `ctx.auth.userId` is the visitor identity. The guest session is the whole auth - story here — nobody signs in to press an emoji. + story here; nobody signs in to press an emoji. - Do not import Node built-ins from capsule code. - `shared/search-index.ts` is generated. Never hand-edit it. ## Rules that are easy to get wrong -- `bun run build` must run before `sf build`. It writes the capsule's search - index and the static pages the capsule publishes beside itself. +- `sf build` and `sf publish` run `bun run build` (the package build script) + before compiling the capsule. The script's own `sf build --skip-build` call + must keep `--skip-build`, or the CLI runs the build script from inside itself. - The generated app shell owns `/index.html`. The magazine index is `home.html`, wired up through `"index": "home.html"` in `sf.jsonc`. - Static pages carry the platform import map copied out of the generated shell. If a page stops hydrating, that map is the first thing to check. -- Publish with `bun run publish` (`sf publish --skip-build`). A bare - `sf publish` reruns the build script and then fails looking for a `dist/`. ## Commands @@ -46,5 +45,5 @@ bun install bun run build bun test sf dev -bun run publish +sf publish ``` diff --git a/wp-zero/README.md b/wp-zero/README.md new file mode 100644 index 0000000..43ec7e4 --- /dev/null +++ b/wp-zero/README.md @@ -0,0 +1,96 @@ +# WordPress + Spacefast Zero + +A magazine whose words come from WordPress at build time and whose live parts +come from a Spacefast Zero capsule at request time: one project, one +`sf.jsonc`, one publish. `wp-astro` is headless WordPress rendered statically +and `zero-perfect` is the Zero runtime on its own; this example puts both on +the same page. + +```sh +bun install +bun run build # fetch WordPress → search index → app shell → static pages +bun test # content normalization, search ranking, reaction tallies +sf dev # the Zero server and client; publish to see the pages +sf publish +``` + +Point it at another WordPress site with `WORDPRESS_URL=https://example.com bun run build`. +The publication name, accent, and post count live in `tools/config.ts`. + +## How it fits together + +``` + BUILD TIME REQUEST TIME + ────────── ──────────── + wordpress.org/news + │ @spacefast/wordpress + │ posts.list({ _embed: true }) + ▼ + Article[] ──┬──▶ home.html ──▶ served as a flat file + │ posts/.html ──▶ served as a flat file + │ search-index.json ──▶ served as a flat file + │ + └──▶ shared/search-index.ts ──▶ compiled into the capsule + │ + ├─ GET /api/search?q= + ├─ GET /api/reactions?slug= + ├─ POST /api/react + ├─ query reactions (realtime) + └─ mutation toggleReaction +``` + +Every article page is a real file; view source and the words are there. Two +things on it are alive: + +- **Reactions.** `
` is where `client/index.tsx` + mounts. `useQuery("reactions", slug)` subscribes rather than polls, so a + reaction in one browser moves the count in another over Cast. +- **Search.** The masthead's search box is plain `fetch` against `/api/search`. + The index it searches was compiled from WordPress at build time and lives + inside the capsule, so typing downloads a handful of results, not a corpus. + +## Important files + +- `sf.jsonc` sets `"index": "home.html"`, `cleanUrls`, and the Zero runtime block. +- `tools/` is build-time only and may use Node built-ins: `config.ts`, + `wordpress.ts` (the only file that talks to WordPress, through + `@spacefast/wordpress`), `render.ts`, and the orchestrator `build.ts`. +- `server/index.ts` is the capsule: one table, a realtime query, a mutation, + and three HTTP endpoints. The `POST` endpoint is a write, so it can touch the + database and it broadcasts; a reaction posted with `curl` moves an open + browser's count. +- `client/index.tsx` is the reaction island. +- `shared/` holds `reactions.ts`, `search.ts`, and the generated `search-index.ts`. +- `test/build.test.ts` covers CMS-HTML normalization, ranking, and tallies. + +`home.html`, `posts/`, `search-index.json`, and `shared/search-index.ts` are +generated and gitignored. Regenerate them, never edit them. + +## Things that are easy to get wrong + +1. **The generated app shell owns `/index.html`.** A Zero capsule with a client + entry always emits its own `index.html`, so the magazine index ships as + `home.html` and `sf.jsonc` makes it the homepage. +2. **`sf build` and `sf publish` run `bun run build` first**, because it is the + package's build script, then compile the capsule and publish the rendered + pages beside it. Inside that script, `tools/build.ts` runs + `sf build --skip-build` once to materialize the app shell; `--skip-build` is + what keeps the CLI from running the build script again from inside itself. +3. **Static pages need the platform import map.** `/client.js` imports `preact` + and `@spacefast/zero/client` as bare specifiers, resolved by the import map + the CLI writes into its generated shell. `tools/build.ts` lifts that map out + of the shell and renders every page around it; see `readImportMap()`. +4. **`sf dev` previews the capsule, not the site.** It serves the app shell for + every path. Use it for the server and the client; publish to see the + magazine. + +Exercising the runtime against a published Space: + +```sh +curl "$BASE/api/search?q=release&limit=3" +curl -c jar -b jar -X POST "$BASE/api/react" -H 'content-type: application/json' \ + -d '{"slug":"open-weight","emoji":"🔥"}' +``` + +Keep the cookie jar: the guest identity lives in a cookie, so without it every +request is a new reader. diff --git a/recipes/wp-zero/site/assets/search.js b/wp-zero/assets/search.js similarity index 100% rename from recipes/wp-zero/site/assets/search.js rename to wp-zero/assets/search.js diff --git a/recipes/wp-zero/site/assets/styles.css b/wp-zero/assets/styles.css similarity index 100% rename from recipes/wp-zero/site/assets/styles.css rename to wp-zero/assets/styles.css diff --git a/recipes/wp-zero/site/bun.lock b/wp-zero/bun.lock similarity index 50% rename from recipes/wp-zero/site/bun.lock rename to wp-zero/bun.lock index 12137a2..36e5bec 100644 --- a/recipes/wp-zero/site/bun.lock +++ b/wp-zero/bun.lock @@ -5,8 +5,8 @@ "": { "name": "wp-zero", "dependencies": { - "@spacefast/wordpress": "0.4.1", - "@spacefast/zero": "0.4.1", + "@spacefast/wordpress": "0.8.0", + "@spacefast/zero": "0.8.0", "preact": "^10.29.7", }, "devDependencies": { @@ -15,20 +15,16 @@ }, }, "packages": { - "@spacefast/common": ["@spacefast/common@0.4.1", "", { "dependencies": { "culori": "^4.0.2", "js-yaml": "^4.1.1", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "smol-toml": "^1.7.0", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-rXwQ7ZURLIXFApiTDjdVPvSA30MNgm7UdJGLB2mrALhYeysiFWk1i2ja73bxBfRiEWTY2Cg6DQpxRdgu6uy/MQ=="], + "@spacefast/common": ["@spacefast/common@0.8.0", "", { "dependencies": { "culori": "^4.0.2", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "tldts": "^7.4.13", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-Zxz69l8USfmr/tZt3fo/1eySKcek8JKRCZ6OeB+1ri6dQ3nx5JQoyPGq+5HeR9g5/xeRmwNTIWOfSvsyFTI7lQ=="], - "@spacefast/wordpress": ["@spacefast/wordpress@0.4.1", "", {}, "sha512-NW6P9eGMAbtUA1VlBDUKjqOZdipF7BDzDyM5fE3gRNY+b8ShNjgn58H39yjqnuN5b/MHvAeLcOcNSCikwSEXrA=="], + "@spacefast/wordpress": ["@spacefast/wordpress@0.8.0", "", {}, "sha512-uYGmPmRAlM8HyGIcCyy3T6rlKghSXydgoFOXxk0a66Z3R96V04LSBWYDm4rMvUiiTCtfO8XbZYZGFJLk9TjA+g=="], - "@spacefast/zero": ["@spacefast/zero@0.4.1", "", { "dependencies": { "@spacefast/common": "0.4.1", "lucide-preact": "^0.575.0", "preact": "^10.29.7" } }, "sha512-K/NyGVExPV95wJJl5iabmqjc9qNMq8AtNztbJbOcgDP4/kbkj+UOMEMAivmiOgjRa5Oy0eZqxuZHwfhzbZjxPA=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@spacefast/zero": ["@spacefast/zero@0.8.0", "", { "dependencies": { "@spacefast/common": "0.8.0", "lucide-preact": "^0.575.0", "preact": "^10.29.7", "zod": "4.3.6" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-/qYdPWP1Ucjyb/2/hCxAE9H5t249uhGeweMzn09xbB/aXZsaPK+E8abUXJUQTMJlSKQDGv9aqTKVBxLkllVcyQ=="], "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], - "lucide-preact": ["lucide-preact@0.575.0", "", { "peerDependencies": { "preact": "^10.27.2" } }, "sha512-W8JZyQEkYv6DlbRrEgmZxVWFKL3zjoyEkFOOSxiX2VEU6Gou8cOqXZ5IAGmqAL4KiPx1tWgGT9awNjAH7MFknw=="], "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], @@ -39,10 +35,14 @@ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], - "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@spacefast/common/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], } } diff --git a/recipes/wp-zero/site/client/index.tsx b/wp-zero/client/index.tsx similarity index 100% rename from recipes/wp-zero/site/client/index.tsx rename to wp-zero/client/index.tsx diff --git a/wp-zero/package.json b/wp-zero/package.json new file mode 100644 index 0000000..c238dd7 --- /dev/null +++ b/wp-zero/package.json @@ -0,0 +1,17 @@ +{ + "name": "wp-zero", + "private": true, + "type": "module", + "scripts": { + "build": "bun run tools/build.ts", + "test": "bun test test" + }, + "dependencies": { + "@spacefast/wordpress": "0.8.0", + "@spacefast/zero": "0.8.0", + "preact": "^10.29.7" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/recipes/wp-zero/site/server/index.ts b/wp-zero/server/index.ts similarity index 91% rename from recipes/wp-zero/site/server/index.ts rename to wp-zero/server/index.ts index b64027b..c45b9df 100644 --- a/recipes/wp-zero/site/server/index.ts +++ b/wp-zero/server/index.ts @@ -10,7 +10,7 @@ // server-side so the browser downloads results, not a corpus. // // The HTTP endpoints exist so the same behavior is reachable with `curl`, not -// only from a page that has hydrated. A `write` endpoint broadcasts realtime +// only from a page that has hydrated. A write (POST) endpoint broadcasts realtime // invalidation the same way a mutation does, so a reaction posted from a // terminal moves the count in an open browser. @@ -90,7 +90,7 @@ export default capsule({ * to render straight into a results list, and the index never crosses the * wire. */ - search: endpoint({ method: "GET", path: "/api/search", mode: "read" }, (_ctx, request) => { + search: endpoint({ method: "GET", path: "/api/search" }, (_ctx, request) => { const q = cleanQuery(request.query.get("q")); const limit = Number.parseInt(request.query.get("limit") ?? "", 10); const index = { @@ -108,7 +108,7 @@ export default capsule({ /** The same tallies the island subscribes to, for anything that speaks HTTP. */ reactionCounts: endpoint( - { method: "GET", path: "/api/reactions", mode: "read" }, + { method: "GET", path: "/api/reactions" }, async (ctx, request) => { const slug = cleanSlug(request.query.get("slug")); if (!slug) return json({ error: "Pass ?slug=." }, { status: 422 }); @@ -120,11 +120,11 @@ export default capsule({ ), /** - * `mode: "write"` is load-bearing twice over: it is what lets an endpoint - * touch the database at all, and what makes the write broadcast — so a - * reaction posted from a terminal moves an open browser's count. + * A POST endpoint is a write: it can touch the database, and the write + * broadcasts — so a reaction posted from a terminal moves an open + * browser's count. (`readOnly` exists to override the method's default.) */ - react: endpoint({ method: "POST", path: "/api/react", mode: "write" }, async (ctx, request) => { + react: endpoint({ method: "POST", path: "/api/react" }, async (ctx, request) => { const payload = (await request.json().catch(() => null)) as { slug?: unknown; emoji?: unknown; diff --git a/recipes/wp-zero/site/sf.jsonc b/wp-zero/sf.jsonc similarity index 100% rename from recipes/wp-zero/site/sf.jsonc rename to wp-zero/sf.jsonc diff --git a/recipes/wp-zero/site/shared/reactions.ts b/wp-zero/shared/reactions.ts similarity index 100% rename from recipes/wp-zero/site/shared/reactions.ts rename to wp-zero/shared/reactions.ts diff --git a/recipes/wp-zero/site/shared/search.ts b/wp-zero/shared/search.ts similarity index 100% rename from recipes/wp-zero/site/shared/search.ts rename to wp-zero/shared/search.ts diff --git a/recipes/wp-zero/site/test/build.test.ts b/wp-zero/test/build.test.ts similarity index 100% rename from recipes/wp-zero/site/test/build.test.ts rename to wp-zero/test/build.test.ts diff --git a/recipes/wp-zero/site/tools/build.ts b/wp-zero/tools/build.ts similarity index 89% rename from recipes/wp-zero/site/tools/build.ts rename to wp-zero/tools/build.ts index c95be1c..48f1b7a 100644 --- a/recipes/wp-zero/site/tools/build.ts +++ b/wp-zero/tools/build.ts @@ -1,18 +1,20 @@ #!/usr/bin/env bun // -// The build. Run it before `sf build` — it produces both halves of the publish. +// The build. `sf build` and `sf publish` run it as the package build script +// before compiling the capsule — it produces both halves of the publish. // // 1. Pull posts from WordPress. // 2. Write shared/search-index.ts, which the capsule compiles in so // GET /api/search can answer without a database or a call to WordPress. -// 3. Run `sf build` once, to materialize the generated Zero app shell. +// 3. Run `sf build --skip-build` once, to materialize the generated Zero app +// shell without re-entering this script. // 4. Read the platform import map out of that shell and render home.html and // posts/*.html around it, so every article page can mount the island. // // Step 3 looks odd and is deliberate: the app shell is the only place the // content-addressed platform module paths exist, and a static page that wants // /client.js needs the same map. Running the compile first is cheaper than -// guessing. The real `sf build` afterwards picks up the pages this wrote and +// guessing. The outer `sf build` afterwards picks up the pages this wrote and // publishes them beside the capsule. import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -74,7 +76,7 @@ async function readImportMap(): Promise { } async function compileCapsule() { - const result = Bun.spawnSync(["sf", "build"], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const result = Bun.spawnSync(["sf", "build", "--skip-build"], { cwd: root, stdout: "pipe", stderr: "pipe" }); if (result.exitCode !== 0) { throw new Error( `\`sf build\` failed while materializing the app shell:\n${result.stderr.toString()}`, @@ -125,4 +127,4 @@ await Promise.all( console.log( `${SITE_NAME}: home.html + ${articles.length} article pages in ${Date.now() - started}ms`, ); -console.log("Next: `sf build` to compile and stage, or `sf publish` to put it live."); +console.log("The outer `sf build` or `sf publish` now compiles the capsule and stages these pages."); diff --git a/recipes/wp-zero/site/tools/config.ts b/wp-zero/tools/config.ts similarity index 86% rename from recipes/wp-zero/site/tools/config.ts rename to wp-zero/tools/config.ts index f5e6753..5a669ac 100644 --- a/recipes/wp-zero/site/tools/config.ts +++ b/wp-zero/tools/config.ts @@ -1,4 +1,4 @@ -// The three answers the recipe asks for. Change them, run `bun run build`, and +// The three settings worth changing. Change them, run `bun run build`, and // the whole site re-fetches and re-skins. /** @@ -21,5 +21,5 @@ export const ACCENT = "#d9410b"; /** How many posts to pull at build time. WordPress caps `per_page` at 100. */ export const POST_LIMIT = 18; -/** The recipe slug, for the shared Spacefast badge. */ +/** The example slug, for the shared Spacefast badge. */ export const BADGE_SLUG = "wp-zero"; diff --git a/recipes/wp-zero/site/tools/render.ts b/wp-zero/tools/render.ts similarity index 100% rename from recipes/wp-zero/site/tools/render.ts rename to wp-zero/tools/render.ts diff --git a/recipes/wp-zero/site/tools/wordpress.ts b/wp-zero/tools/wordpress.ts similarity index 100% rename from recipes/wp-zero/site/tools/wordpress.ts rename to wp-zero/tools/wordpress.ts diff --git a/recipes/zero-perfect/site/.gitignore b/zero-perfect/.gitignore similarity index 100% rename from recipes/zero-perfect/site/.gitignore rename to zero-perfect/.gitignore diff --git a/recipes/zero-perfect/site/AGENTS.md b/zero-perfect/AGENTS.md similarity index 100% rename from recipes/zero-perfect/site/AGENTS.md rename to zero-perfect/AGENTS.md diff --git a/zero-perfect/README.md b/zero-perfect/README.md new file mode 100644 index 0000000..bc05c7b --- /dev/null +++ b/zero-perfect/README.md @@ -0,0 +1,24 @@ +# Zero Perfect + +The smallest full-stack Spacefast Zero app that walks the complete runtime +path: database-backed queries and mutations, guest and Gravatar auth, Cast +realtime, client-side routes, and an HTTP endpoint. + +```sh +bun install +sf dev +sf build +sf publish +``` + +Open `/` for the realtime todo list and `/status` to call `GET /api/status`. + +Important files: + +- `sf.jsonc` declares the Zero runtime with its server and client entries. +- `server/index.ts` holds the table, queries, mutations, and the endpoint. +- `client/index.tsx` is the Preact app with its routes. +- `shared/` is pure TypeScript both halves import. + +This is a Zero project root, not a prebuilt output directory: `sf publish` +compiles the capsule and attaches its server artifact to the version. diff --git a/recipes/zero-perfect/site/bun.lock b/zero-perfect/bun.lock similarity index 54% rename from recipes/zero-perfect/site/bun.lock rename to zero-perfect/bun.lock index 2eb02f3..8070bae 100644 --- a/recipes/zero-perfect/site/bun.lock +++ b/zero-perfect/bun.lock @@ -5,7 +5,7 @@ "": { "name": "zero-perfect", "dependencies": { - "@spacefast/zero": "0.4.1", + "@spacefast/zero": "0.8.0", "preact": "^10.29.7", }, "devDependencies": { @@ -14,18 +14,14 @@ }, }, "packages": { - "@spacefast/common": ["@spacefast/common@0.4.1", "", { "dependencies": { "culori": "^4.0.2", "js-yaml": "^4.1.1", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "smol-toml": "^1.7.0", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-rXwQ7ZURLIXFApiTDjdVPvSA30MNgm7UdJGLB2mrALhYeysiFWk1i2ja73bxBfRiEWTY2Cg6DQpxRdgu6uy/MQ=="], + "@spacefast/common": ["@spacefast/common@0.8.0", "", { "dependencies": { "culori": "^4.0.2", "mime": "^4.1.0", "oauth4webapi": "3.8.7", "parse5": "^7.3.0", "tldts": "^7.4.13", "zod": "^4.4.3" }, "peerDependencies": { "@tanstack/react-query": "^5.102.8" }, "optionalPeers": ["@tanstack/react-query"] }, "sha512-Zxz69l8USfmr/tZt3fo/1eySKcek8JKRCZ6OeB+1ri6dQ3nx5JQoyPGq+5HeR9g5/xeRmwNTIWOfSvsyFTI7lQ=="], - "@spacefast/zero": ["@spacefast/zero@0.4.1", "", { "dependencies": { "@spacefast/common": "0.4.1", "lucide-preact": "^0.575.0", "preact": "^10.29.7" } }, "sha512-K/NyGVExPV95wJJl5iabmqjc9qNMq8AtNztbJbOcgDP4/kbkj+UOMEMAivmiOgjRa5Oy0eZqxuZHwfhzbZjxPA=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@spacefast/zero": ["@spacefast/zero@0.8.0", "", { "dependencies": { "@spacefast/common": "0.8.0", "lucide-preact": "^0.575.0", "preact": "^10.29.7", "zod": "4.3.6" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-/qYdPWP1Ucjyb/2/hCxAE9H5t249uhGeweMzn09xbB/aXZsaPK+E8abUXJUQTMJlSKQDGv9aqTKVBxLkllVcyQ=="], "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], - "lucide-preact": ["lucide-preact@0.575.0", "", { "peerDependencies": { "preact": "^10.27.2" } }, "sha512-W8JZyQEkYv6DlbRrEgmZxVWFKL3zjoyEkFOOSxiX2VEU6Gou8cOqXZ5IAGmqAL4KiPx1tWgGT9awNjAH7MFknw=="], "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], @@ -36,10 +32,14 @@ "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], - "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.4.20", "", {}, "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@spacefast/common/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], } } diff --git a/recipes/zero-perfect/site/client/index.tsx b/zero-perfect/client/index.tsx similarity index 100% rename from recipes/zero-perfect/site/client/index.tsx rename to zero-perfect/client/index.tsx diff --git a/recipes/zero-perfect/site/package.json b/zero-perfect/package.json similarity index 84% rename from recipes/zero-perfect/site/package.json rename to zero-perfect/package.json index c054429..7ac2e59 100644 --- a/recipes/zero-perfect/site/package.json +++ b/zero-perfect/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "@spacefast/zero": "0.4.1", + "@spacefast/zero": "0.8.0", "preact": "^10.29.7" }, "devDependencies": { diff --git a/recipes/zero-perfect/site/server/index.ts b/zero-perfect/server/index.ts similarity index 89% rename from recipes/zero-perfect/site/server/index.ts rename to zero-perfect/server/index.ts index 943a74b..34e63ca 100644 --- a/recipes/zero-perfect/site/server/index.ts +++ b/zero-perfect/server/index.ts @@ -27,6 +27,6 @@ export default capsule({ }), }, endpoints: { - status: endpoint({ mode: "read", method: "GET", path: "/api/status" }, () => text("ok")), + status: endpoint({ method: "GET", path: "/api/status" }, () => text("ok")), }, }); diff --git a/recipes/zero-perfect/site/sf.jsonc b/zero-perfect/sf.jsonc similarity index 100% rename from recipes/zero-perfect/site/sf.jsonc rename to zero-perfect/sf.jsonc diff --git a/recipes/zero-perfect/site/shared/todo.ts b/zero-perfect/shared/todo.ts similarity index 100% rename from recipes/zero-perfect/site/shared/todo.ts rename to zero-perfect/shared/todo.ts From fd8f789c24633affb421e2aa85e79b87e0691ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Sun, 8 Nov 2026 22:40:57 +0000 Subject: [PATCH 2/2] Keep discover-recipes alive when a changed recipe is gone The existence check ran as the loop body's last command, so under the runner's bash -e a deleted slug in last position ended the step. Also name the sign-in zero-perfect actually offers and show the full REST base for wp-astro. --- .github/workflows/publish.yml | 2 +- README.md | 5 +++-- wp-astro/README.md | 3 ++- wp-astro/src/config.ts | 2 +- zero-perfect/README.md | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2c016b1..1121dff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -158,7 +158,7 @@ jobs: | awk -F'\t' '$1 != "R100" {print $NF}') # A recipe directory that no longer exists has nothing to publish. slugs=$(printf '%s\n' "$changed" | sed -n 's#^recipes/\([^/]*\)/site/.*#\1#p' \ - | sort -u | while read -r slug; do [ -d "recipes/$slug" ] && echo "$slug"; done \ + | sort -u | while read -r slug; do if [ -d "recipes/$slug" ]; then echo "$slug"; fi; done \ | jq -R -s -c 'split("\n") | map(select(length > 0))') echo "slugs=$slugs" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index eae0f02..6439781 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ sf publish Use `sf build` when you only want to prove the deployable artifact locally. `sf dev` is the local server for Zero apps (`zero/`, `kitchen-sink/`, `zero-perfect/`, `wp-zero/`); for everything else, use the framework's dev -server. +server. `astro-zero/` needs both: Astro's dev server for the pages and +`sf dev --dir dist` for the capsule. ## Directory map @@ -30,7 +31,7 @@ server. | [`nextjs/`](./nextjs/) | Server-rendered Next.js, route handlers, and automatic OpenNext packaging | | [`astro-with-wordpress/`](./astro-with-wordpress/) | Astro built from WordPress through `@spacefast/wordpress` | | [`functions-php/`](./functions-php/) | Native PHP Functions with JSON, form bodies, validation, and verified auth context | -| [`zero-perfect/`](./zero-perfect/) | A minimal Zero app: queries, mutations, guest and Gravatar auth, realtime, routes, and an HTTP endpoint | +| [`zero-perfect/`](./zero-perfect/) | A minimal Zero app: queries, mutations, guest and Google sign-in, realtime, routes, and an HTTP endpoint | | [`wp-zero/`](./wp-zero/) | WordPress rendered to static pages at build time with a Zero capsule for realtime reactions and search | | [`astro-zero/`](./astro-zero/) | Astro pages in front of a server-only Zero capsule, staged into `dist/` as one project root | | [`wp-astro/`](./wp-astro/) | Headless WordPress pulled from the public REST API and rendered statically with Astro | diff --git a/wp-astro/README.md b/wp-astro/README.md index 11a1cfa..7a978bc 100644 --- a/wp-astro/README.md +++ b/wp-astro/README.md @@ -20,7 +20,8 @@ Important files: - `src/pages/index.astro` renders a lead story plus a card grid; `src/pages/[slug].astro` uses `getStaticPaths` to bake one page per post. - `src/config.ts` holds the three things to change: the REST API base (the - demo is `https://wordpress.org/news`), the blog title, and the accent color. + demo is `https://wordpress.org/news/wp-json/wp/v2`), the blog title, and the + accent color. Posts with no featured image fall back to `jetpack_featured_media_url` and then to a tinted placeholder, never a broken image. diff --git a/wp-astro/src/config.ts b/wp-astro/src/config.ts index b490c55..a21d42f 100644 --- a/wp-astro/src/config.ts +++ b/wp-astro/src/config.ts @@ -1,5 +1,5 @@ // ───────────────────────────────────────────────────────────────────────────── -// Site config — the three things the prompt asks you for. +// Site config — the three things worth changing. // // 1. WP_API_BASE The REST API base of any public WordPress site. It's just the // site URL with `/wp-json/wp/v2` on the end. Self-hosted WP and diff --git a/zero-perfect/README.md b/zero-perfect/README.md index bc05c7b..8b1d966 100644 --- a/zero-perfect/README.md +++ b/zero-perfect/README.md @@ -1,8 +1,8 @@ # Zero Perfect The smallest full-stack Spacefast Zero app that walks the complete runtime -path: database-backed queries and mutations, guest and Gravatar auth, Cast -realtime, client-side routes, and an HTTP endpoint. +path: database-backed queries and mutations, guest identity with Google +sign-in, Cast realtime, client-side routes, and an HTTP endpoint. ```sh bun install