diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e87c04..2367a18 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -112,7 +112,13 @@ jobs: - name: Build working-directory: examples/${{ matrix.slug }} run: | - if [ -f site/package.json ]; then + runtime=$(jq -r '.runtime // "static"' meta.json) + if [ "$runtime" = "zero" ]; then + cd site + bun install --frozen-lockfile + sf build + test -f .spacefast/zero/public/index.html + elif [ -f site/package.json ]; then cd site bun install --frozen-lockfile || bun install bun run build @@ -121,12 +127,17 @@ jobs: id: dir working-directory: examples/${{ matrix.slug }} run: | - if [ -d site/dist ]; then dir=site/dist + runtime=$(jq -r '.runtime // "static"' meta.json) + if [ "$runtime" = "zero" ]; then + dir=site + elif [ -d site/dist ]; then dir=site/dist elif [ -d site/build ]; then dir=site/build else dir=site fi echo "dir=$dir" >> "$GITHUB_OUTPUT" - bun ../../scripts/validate-publish-dir.mjs "${{ matrix.slug }}" "$dir" + if [ "$runtime" = "static" ]; then + bun ../../scripts/validate-publish-dir.mjs "${{ matrix.slug }}" "$dir" + fi - name: Publish Spacefast space if: github.event_name != 'pull_request' working-directory: examples/${{ matrix.slug }} diff --git a/README.md b/README.md index 511e5ac..1b0361e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spacefast Recipes -Forty-eight polished, copy-paste recipes — for a band site, a calorie tracker, a +Forty-nine polished, copy-paste recipes — for a band site, a calorie tracker, a playable game, a restaurant menu, a technical plan, and more. Each recipe ships with the live website its prompt produces. @@ -48,7 +48,9 @@ GitHub Actions (`.github/workflows/publish.yml`) validates the full catalog, publishes the JSON feed to GitHub Pages, and rebuilds and publishes changed recipe outputs to their existing Spacefast spaces. It authenticates with the team-owned `SPACEFAST_DEPLOY_KEY` repository secret. Static recipe outputs are -published as-is; recipes with a `package.json` are built first. +published as-is; recipes with a `package.json` are built first. A Spacefast Zero +project declares `"runtime": "zero"` in `meta.json`; the workflow compiles it +and publishes its `site/` project root so the server artifact is included. To add a recipe: copy `TEMPLATE.md` into `examples//prompt.md`, fill it in, drop the site in `examples//site/`, and add `meta.json` using diff --git a/examples/zero-perfect/README.md b/examples/zero-perfect/README.md new file mode 100644 index 0000000..cf02c53 --- /dev/null +++ b/examples/zero-perfect/README.md @@ -0,0 +1,21 @@ +# 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/examples/zero-perfect/meta.json b/examples/zero-perfect/meta.json new file mode 100644 index 0000000..2e0f340 --- /dev/null +++ b/examples/zero-perfect/meta.json @@ -0,0 +1,23 @@ +{ + "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/examples/zero-perfect/prompt.md b/examples/zero-perfect/prompt.md new file mode 100644 index 0000000..98b8957 --- /dev/null +++ b/examples/zero-perfect/prompt.md @@ -0,0 +1,23 @@ +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/examples/zero-perfect/site/.gitignore b/examples/zero-perfect/site/.gitignore new file mode 100644 index 0000000..f4e11fd --- /dev/null +++ b/examples/zero-perfect/site/.gitignore @@ -0,0 +1,8 @@ +.spacefast/ +node_modules/ +dist/ +.env* +.spacefast/state.json +/.spacefast-* +.spacefast/.spacefast-* +.spacefast/.space-link-*.tmp diff --git a/examples/zero-perfect/site/AGENTS.md b/examples/zero-perfect/site/AGENTS.md new file mode 100644 index 0000000..42901c2 --- /dev/null +++ b/examples/zero-perfect/site/AGENTS.md @@ -0,0 +1,23 @@ +# Spacefast Zero App Instructions + +This directory is for a Spacefast Zero capsule. + +## Rules + +- Client code lives in `client/`. +- Server code lives in `server/`. +- Shared pure TypeScript lives in `shared/`. +- Use `@spacefast/zero/client` only from client code. +- Use `@spacefast/zero/server` only from server code. +- Use queries for client reads and mutations for user-driven writes. +- Use `ctx.auth` for server auth and `useAuth()` for client auth. +- Read server-only variables through `ctx.env`; set them in `.env.server`. +- Do not import Node built-ins from capsule code. + +## Commands + +```sh +sf dev +sf publish +sf logs runtime --follow +``` diff --git a/examples/zero-perfect/site/bun.lock b/examples/zero-perfect/site/bun.lock new file mode 100644 index 0000000..9cd159c --- /dev/null +++ b/examples/zero-perfect/site/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "zero-perfect", + "dependencies": { + "@spacefast/zero": "^0.0.19", + "preact": "^10.29.7", + }, + "devDependencies": { + "typescript": "^6.0.3", + }, + }, + }, + "packages": { + "@spacefast/zero": ["@spacefast/zero@0.0.19", "", { "dependencies": { "preact": "^10.29.7" } }, "sha512-5L27gQ0dkjunSn0Lx7dKVyokl14VEMPK5MZOzz65UZIvbhkAap9a/8835mb4LzYmRTTG6XlGvTq/QP5XmLZRlQ=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + } +} diff --git a/examples/zero-perfect/site/client/index.tsx b/examples/zero-perfect/site/client/index.tsx new file mode 100644 index 0000000..a0567e2 --- /dev/null +++ b/examples/zero-perfect/site/client/index.tsx @@ -0,0 +1,102 @@ +import { + Link, + Route, + Router, + Routes, + SignInWithGravatar, + SignOut, + useAuth, + useMutation, + useQuery, +} from "@spacefast/zero/client"; +import { useEffect, useState } from "preact/hooks"; + +import { cleanTodoText, type Todo } from "../shared/todo"; + +function RecipeBadge() { + useEffect(() => { + if (document.querySelector('script[data-example="zero-perfect"]')) return; + const script = document.createElement("script"); + script.src = "https://spacefast.com/badge.js"; + script.dataset.example = "zero-perfect"; + document.body.append(script); + return () => script.remove(); + }, []); + return null; +} + +function TodoPage() { + const todos = useQuery("todos"); + const addTodo = useMutation<[text: string], void>("addTodo"); + + async function onSubmit(event: SubmitEvent) { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + const data = new FormData(form); + const text = cleanTodoText(String(data.get("text") ?? "")); + if (!text) return; + await addTodo(text); + form.reset(); + } + + return ( +
+

Zero Perfect

+

Realtime via Cast

+
void onSubmit(event)}> + + +
+
    + {todos.map((todo) => ( +
  • {todo.text}
  • + ))} +
+
+ ); +} + +function StatusPage() { + const [status, setStatus] = useState("not checked"); + async function checkStatus() { + const response = await fetch("/api/status"); + setStatus(response.ok ? await response.text() : "error " + response.status); + } + return ( +
+

Status

+ +

endpoint: {status}

+
+ ); +} + +export function App() { + const auth = useAuth(); + return ( + + +
+
+ {auth.isLoading ? "checking session" : auth.displayName} + {!auth.isLoading && auth.isGuest ? ( + + ) : !auth.isLoading ? ( + + ) : null} +
+ + + } /> + } /> +

Not found

Back to todos} /> +
+
+
+ ); +} diff --git a/examples/zero-perfect/site/package.json b/examples/zero-perfect/site/package.json new file mode 100644 index 0000000..d4b597a --- /dev/null +++ b/examples/zero-perfect/site/package.json @@ -0,0 +1,12 @@ +{ + "name": "zero-perfect", + "private": true, + "type": "module", + "dependencies": { + "@spacefast/zero": "^0.0.19", + "preact": "^10.29.7" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/examples/zero-perfect/site/server/index.ts b/examples/zero-perfect/site/server/index.ts new file mode 100644 index 0000000..4187105 --- /dev/null +++ b/examples/zero-perfect/site/server/index.ts @@ -0,0 +1,32 @@ +import { boolean, capsule, endpoint, mutation, query, string, table, text } from "@spacefast/zero/server"; + +import { cleanTodoText } from "../shared/todo"; + +export default capsule({ + name: "Zero Perfect", + schema: { + todos: table({ + text: string(), + done: boolean().default(false), + ownerId: string(), + }), + }, + queries: { + todos: query((ctx) => + ctx.db.todos + .where("ownerId", ctx.auth.userId) + .orderBy("createdAt", "desc") + .all() + ), + }, + mutations: { + addTodo: mutation((ctx, text: string) => { + const cleanText = cleanTodoText(text); + if (!cleanText) return; + ctx.db.todos.insert({ text: cleanText, done: false, ownerId: ctx.auth.userId }); + }), + }, + endpoints: { + status: endpoint({ method: "GET", path: "/api/status" }, () => text("ok")), + }, +}); diff --git a/examples/zero-perfect/site/sf.jsonc b/examples/zero-perfect/site/sf.jsonc new file mode 100644 index 0000000..3db5f47 --- /dev/null +++ b/examples/zero-perfect/site/sf.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "https://spacefast.com/schemas/sf.json", + "name": "Zero Perfect", + "access": "public", + "runtime": { + "kind": "zero", + "server": "server/index.ts", + "client": "client/index.tsx", + }, +} diff --git a/examples/zero-perfect/site/shared/todo.ts b/examples/zero-perfect/site/shared/todo.ts new file mode 100644 index 0000000..9ff7f2a --- /dev/null +++ b/examples/zero-perfect/site/shared/todo.ts @@ -0,0 +1,12 @@ +export type Todo = { + id: string; + text: string; + done: boolean; + ownerId: string; + createdAt: string; + updatedAt: string; +}; + +export function cleanTodoText(value: string): string { + return value.trim().slice(0, 160); +} diff --git a/meta.example.json b/meta.example.json index 2cf5bc9..16a9a64 100644 --- a/meta.example.json +++ b/meta.example.json @@ -4,6 +4,7 @@ "slug": "example-slug", "vertical": "Personal & creative", "tech": "plain HTML", + "runtime": "static", "summary": "One sentence describing what the recipe builds.", "order": 100, "live_url": "https://example-slug.view.fast/", diff --git a/scripts/build-manifest.mjs b/scripts/build-manifest.mjs index 167cc81..7a2e682 100644 --- a/scripts/build-manifest.mjs +++ b/scripts/build-manifest.mjs @@ -56,6 +56,11 @@ for (const slug of slugs) { meta.publish_slug === undefined ? slug : requiredString(meta.publish_slug, "publish_slug", slug); + const runtime = + meta.runtime === undefined ? "static" : requiredString(meta.runtime, "runtime", slug); + if (runtime !== "static" && runtime !== "zero") { + throw new Error(`${slug}/meta.json: runtime must be "static" or "zero"`); + } items.push({ slug, publishSlug, @@ -65,6 +70,7 @@ for (const slug of slugs) { tech: requiredString(meta.tech, "tech", slug), summary: requiredString(meta.summary, "summary", slug), order, + ...(runtime === "static" ? {} : { runtime }), liveUrl, previewUrl: liveUrl, setupQuestions: requiredStrings(meta.setup_questions, "setup_questions", slug), diff --git a/scripts/catalog.test.mjs b/scripts/catalog.test.mjs index 56ec16e..2d83eb4 100644 --- a/scripts/catalog.test.mjs +++ b/scripts/catalog.test.mjs @@ -58,6 +58,18 @@ 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(),