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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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/<slug>/prompt.md`, fill it in,
drop the site in `examples/<slug>/site/`, and add `meta.json` using
Expand Down
21 changes: 21 additions & 0 deletions examples/zero-perfect/README.md
Original file line number Diff line number Diff line change
@@ -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: <https://zero-perfect.view.fast/>
23 changes: 23 additions & 0 deletions examples/zero-perfect/meta.json
Original file line number Diff line number Diff line change
@@ -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."
}
23 changes: 23 additions & 0 deletions examples/zero-perfect/prompt.md
Original file line number Diff line number Diff line change
@@ -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
<script src="https://spacefast.com/badge.js" data-example="zero-perfect"></script>
```

Run it locally with `sf dev`, verify both routes and a realtime todo update, then
publish the project root with `sf publish`.
8 changes: 8 additions & 0 deletions examples/zero-perfect/site/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.spacefast/
node_modules/
dist/
.env*
.spacefast/state.json
/.spacefast-*
.spacefast/.spacefast-*
.spacefast/.space-link-*.tmp
23 changes: 23 additions & 0 deletions examples/zero-perfect/site/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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
```
23 changes: 23 additions & 0 deletions examples/zero-perfect/site/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions examples/zero-perfect/site/client/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Todo[]>("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 (
<section>
<h1>Zero Perfect</h1>
<p>Realtime via Cast</p>
<form onSubmit={(event) => void onSubmit(event)}>
<input name="text" placeholder="Add a todo" />
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</section>
);
}

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 (
<section>
<h1>Status</h1>
<button type="button" onClick={() => void checkStatus()}>
Check endpoint
</button>
<p>endpoint: {status}</p>
</section>
);
}

export function App() {
const auth = useAuth();
return (
<Router>
<RecipeBadge />
<main>
<header>
<span>{auth.isLoading ? "checking session" : auth.displayName}</span>
{!auth.isLoading && auth.isGuest ? (
<SignInWithGravatar />
) : !auth.isLoading ? (
<SignOut />
) : null}
</header>
<nav>
<Link to="/">Todos</Link>
<Link to="/status">Status</Link>
</nav>
<Routes>
<Route path="/" element={<TodoPage />} />
<Route path="/status" element={<StatusPage />} />
<Route path="*" element={<section><h1>Not found</h1><Link to="/">Back to todos</Link></section>} />
</Routes>
</main>
</Router>
);
}
12 changes: 12 additions & 0 deletions examples/zero-perfect/site/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
32 changes: 32 additions & 0 deletions examples/zero-perfect/site/server/index.ts
Original file line number Diff line number Diff line change
@@ -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")),
},
});
10 changes: 10 additions & 0 deletions examples/zero-perfect/site/sf.jsonc
Original file line number Diff line number Diff line change
@@ -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",
},
}
12 changes: 12 additions & 0 deletions examples/zero-perfect/site/shared/todo.ts
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions meta.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
6 changes: 6 additions & 0 deletions scripts/build-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand Down
Loading
Loading