@scribe-platform/cli 0.2.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ ---
2
+ name: scribe-site-builder
3
+ description: Guides building a website on Scribe CMS end to end — designing the Astro site freely against Scribe as its backend, editing the `.scribe.yml` schema, adding one or many forms, the local dev loop, pushing content, and deploying — using the `scribe` CLI and the recipes in `recipes/`. Also the reference for working an existing Scribe site (adding fields/collections, publishing content, triaging form submissions, deleting a site). Use whenever the user wants to build/create/start a new website or site with Scribe, add a form to a site, work on an existing Scribe site, or asks about the `scribe` CLI.
4
+ ---
5
+
6
+ # Building a site with Scribe
7
+
8
+ Source of truth: this file is the merged, agent-facing version of
9
+ `docs/BUILDER-GUIDE.md` and `docs/AGENT-GUIDE.md` in the
10
+ `ai-xerro-agent/projects/scribe` repo. If those docs and this one ever
11
+ disagree, the repo docs win — this file may be behind.
12
+
13
+ ## Recipes — read the one you need
14
+
15
+ Per RFC 0010 the Astro site is **yours to design**. Scribe doesn't supply a
16
+ template you must conform to; it supplies patterns. Load the relevant file
17
+ instead of reading all of them:
18
+
19
+ | Recipe | Use it when |
20
+ |---|---|
21
+ | [`recipes/schema.md`](./recipes/schema.md) | you need the Site's collections and field types |
22
+ | [`recipes/collections.md`](./recipes/collections.md) | rendering editor-managed content |
23
+ | [`recipes/forms.md`](./recipes/forms.md) | adding one or more forms |
24
+ | [`recipes/deploy.md`](./recipes/deploy.md) | build/deploy setup or debugging |
25
+
26
+ [`recipes/README.md`](./recipes/README.md) lists the five things that are a hard
27
+ contract (because Scribe writes into the repo) as opposed to advice.
28
+
29
+ ## Mental model
30
+
31
+ - **A site = a GitHub repo.** Schema, content, and the Astro source all live in one
32
+ repo. The admin app and the `scribe` CLI are both just clients that read/write
33
+ files in it via the GitHub API; a local `git clone` is a third client reading/writing
34
+ the same files via git.
35
+ - **Form submissions are the exception — they live in MongoDB, not the repo** (RFC
36
+ 0004). A Visitor's name, email and message used to be committed as a YAML file into
37
+ the site's public repo, which published them to the open internet. Nothing writes
38
+ submissions to a repo any more: a submission is a Mongo document with a status and
39
+ an append-only event history, and `content/<form-collection>/` doesn't exist.
40
+ - **`.scribe.yml`** at the repo root is the schema — every collection (content type)
41
+ and its fields, plus which collections are forms. Prefer the CLI over hand-editing,
42
+ because it validates before committing and because the admin treats the committed
43
+ file as authoritative: `scribe schema show <site>` to read,
44
+ `scribe schema apply <site> --file <spec>` to create or replace a whole collection,
45
+ `scribe schema add`/`remove` for a bare stub or a removal. Hand-editing still works
46
+ for a one-line tweak in a clone.
47
+ - **A Site can have many forms.** Each `form: true` collection gets its own inbox,
48
+ its own workflow states and its own nav badge in the admin, and posts to
49
+ `/api/submissions?site=…&collection=…`. See `recipes/forms.md`.
50
+ - **No merge tooling.** Admin-UI edits and local edits both land as commits on the
51
+ same repo. If they touch the same file, resolve it with plain `git pull` / merge —
52
+ there's no `scribe` conflict command.
53
+ - **`scripts/generate-config.mjs` is legacy.** Sites scaffolded before RFC 0010 have
54
+ it, and it regenerates `src/content.config.ts` and `src/site-config.ts` on
55
+ `predev`/`prebuild` — so in those repos, don't hand-edit those two files, it will
56
+ overwrite you. It flattens the schema lossily (field *names* with no types, and
57
+ only the **first** `form: true` collection), so a site that needs real types or a
58
+ second form should read `.scribe.yml` directly instead — see `recipes/schema.md`.
59
+ New sites shouldn't add it.
60
+
61
+ ## Setup (once)
62
+
63
+ - **CLI**: confirm `scribe --help` works. If it's not found, `npm link` from
64
+ `packages/cli` in the `ai-xerro-agent/projects/scribe` repo.
65
+ - **Login**: `scribe login --token <key>` — mint the key from the
66
+ admin UI (sign in → account menu → **API Tokens** → New token). Defaults to the
67
+ hosted admin at `https://scribe.weaverdigital.io`; add `--api-url <url>` only to
68
+ point at a different instance (a local dev/Docker admin). Writes
69
+ `~/.scribe/config.json`; every other command reads from there, no per-invocation
70
+ `--token` flag. If a command fails with a raw `fetch failed`, that's not an API
71
+ error — it means `apiUrl` in that config doesn't point at anything listening;
72
+ re-run `scribe login` with the right URL.
73
+
74
+ ## Keeping Scribe up to date
75
+
76
+ `scribe update` updates the CLI **and** reinstalls this skill, which ships inside
77
+ the `@scribe-platform/cli` npm package:
78
+
79
+ ```
80
+ scribe update
81
+ ```
82
+
83
+ Two things to know:
84
+
85
+ - **Tell the user to restart Claude Code afterwards.** Claude Code only discovers
86
+ skills at session start, so the session that ran the update is still reading the
87
+ old copy of this file. If you need the new instructions in the current session,
88
+ read `~/.claude/skills/scribe-site-builder/SKILL.md` from disk instead of waiting
89
+ for a restart.
90
+ - **It only works for a global npm install** (`npm install -g
91
+ @scribe-platform/cli`). In a dev checkout of the scribe repo it refuses, and if
92
+ `~/.claude/skills/scribe-site-builder` is a symlink into a checkout it leaves it
93
+ alone — that's an author editing the skill in place.
94
+
95
+ Run it when the user says there's a new version, or when a CLI command fails in a
96
+ way that suggests this skill is behind the API (an option the CLI doesn't
97
+ recognise, a command that no longer exists).
98
+
99
+ ## Workflow: building a new site
100
+
101
+ 1. **Scaffold.**
102
+ ```
103
+ scribe site add --slug my-site
104
+ ```
105
+ Creates the GitHub repo, pushes the Astro starter template
106
+ (home/blog/contact pages, a default `.scribe.yml` with `posts` and `inquiries`
107
+ collections), and registers the site with the admin — one call.
108
+ `--name` sets a display name (defaults to the slug); `--form-collection` renames
109
+ the default contact-form collection (defaults to `inquiries`).
110
+
111
+ `--domain` and `--turnstile-key` are both optional and normally omitted. The
112
+ domain defaults to the `scribe-platform.github.io` Pages host every new site is
113
+ served from, and the Turnstile site key defaults to the shared widget configured
114
+ on the admin app — one widget covers all sites, scoped by its allowed-domain
115
+ list rather than by key. Pass them only to override those defaults. The real
116
+ customer domain comes later, at step 8.
117
+
118
+ 2. **Clone it locally.**
119
+ ```
120
+ scribe site clone my-site
121
+ ```
122
+ Plain `git clone`. Nothing Scribe-specific happens locally — `npm run dev` works,
123
+ `.scribe.yml` is just a file in the repo root.
124
+
125
+ 3. **Define/edit the schema.** Hand-edit `.scribe.yml` — add fields under
126
+ `collections[].fields[]` (see field reference below). Once saved,
127
+ `npm run dev` / `npm run build` locally regenerate the Astro content types
128
+ automatically (via `predev`/`prebuild`) — no separate sync step. A `git push` of
129
+ `.scribe.yml` is enough to reach the admin, which reads it live from GitHub,
130
+ cached **8 minutes** per repo — after a schema push, the admin UI may lag up to
131
+ 8 minutes before showing it.
132
+
133
+ 4. **Build content locally.** Normal Astro dev loop — content files live under
134
+ `content/<collection>/*` per the collection's `path`/`format`.
135
+
136
+ 5. **Push to Scribe.** Two ways:
137
+ - `git push` — commits schema and/or content straight to the repo. Normal path.
138
+ - `scribe content publish <site> <file>` — pushes one local file through the
139
+ API, validating it against the schema server-side first (a raw `git push`
140
+ skips that validation).
141
+
142
+ 6. **Admin-UI edits, pulled back down.** The admin UI writes commits to the same
143
+ repo — `git pull` locally to pick them up. Conflicts resolve as ordinary git
144
+ merge conflicts, nothing purpose-built.
145
+
146
+ 7. **Deploy.** Any push to the repo's default branch runs `.github/workflows/deploy.yml`
147
+ automatically (build + GitHub Pages). `scribe build trigger my-site` dispatches
148
+ the same workflow on demand without a new commit — debounced to once per 5
149
+ minutes per site, so repeat calls in that window are a silent no-op.
150
+
151
+ 8. **Domain cutover — only when a customer's own hostname is ready.** Skip this
152
+ entirely for demo sites; the GitHub Pages default already works.
153
+
154
+ Check what DNS the domain needs first. This changes nothing:
155
+ ```
156
+ scribe site promote my-site --domain example.com --dns-only
157
+ ```
158
+ It prints the records to add at the registrar. An apex domain needs the four A
159
+ records; a subdomain needs only the CNAME. **In Cloudflare the record must be
160
+ DNS-only (grey cloud), not proxied** — the orange-cloud proxy blocks the
161
+ challenge GitHub uses to issue the site's certificate.
162
+
163
+ Once the records are in place and resolving:
164
+ ```
165
+ scribe site promote my-site --domain example.com
166
+ ```
167
+ This does the whole cutover in one go: writes `public/CNAME`, registers the
168
+ domain with GitHub Pages, points the contact form at the admin app, updates the
169
+ hostname the public form endpoint accepts submissions from, adds the domain to
170
+ the Turnstile widget, enforces HTTPS, and redeploys.
171
+
172
+ It **refuses to run until DNS actually points at GitHub Pages**, because the
173
+ cutover kills the old project-page URL — if DNS isn't ready the site is
174
+ unreachable at both addresses. Fix the DNS and run it again rather than reaching
175
+ for `--force`.
176
+
177
+ **Re-running is safe and is the fix for anything that goes wrong.** Every step
178
+ checks the current state before writing, so a run that fails halfway is repaired
179
+ by running the same command again. If it reports that GitHub is still issuing
180
+ the certificate, wait a few minutes and re-run to finish — until then `http://`
181
+ won't serve the site.
182
+
183
+ If it prints a `Warning:` about the Turnstile widget, the domain list did **not**
184
+ sync — fix it in the Cloudflare dashboard, or the contact form silently rejects
185
+ real visitors on that domain.
186
+
187
+ `scribe site update my-site --domain example.com` only edits the stored record
188
+ and the Turnstile widget. Use it to correct a domain, never to cut a site over.
189
+
190
+ ## Working an existing site
191
+
192
+ ```
193
+ scribe content list <site> [--collection <name>] [--json]
194
+ scribe content read <site> <path> [--json]
195
+ scribe content publish <site> <file> # validates against schema
196
+ scribe content delete <site> <path>
197
+ scribe content versions <site> <path> [--json] # commit history
198
+ scribe content revert <site> <path> --sha <sha> # checks out old version as new commit
199
+
200
+ scribe schema show <site> [collection] [--json] # read the schema
201
+ scribe schema apply <site> --file <spec|-> # create/replace a whole collection
202
+ scribe schema add <site> <collection-name> [--path <path>] # bare stub only, no fields
203
+ scribe schema remove <site> <collection-name> # deletes its folder too
204
+
205
+ scribe submission list <site> <collection> [--status <status>] [--page <n>] [--json]
206
+ scribe submission update <site> <collection> <id> [--status <s>] [--note <n>]
207
+ scribe submission export <site> <collection> [--format csv|json] [--status <s>] [--out <file>]
208
+ scribe submission delete <site> <collection> <id> [--yes] # staff only
209
+ ```
210
+
211
+ - `scribe schema apply` is the way to add a form (or any collection with fields): it
212
+ takes a YAML/JSON file describing **one whole collection**, upserts it by `name`,
213
+ and validates through the admin's own parser first — so a reserved `status` field,
214
+ an `image` field on a form collection, or a malformed workflow is reported before
215
+ anything is committed. `--file -` reads from a pipe. Full worked example in
216
+ [`recipes/forms.md`](./recipes/forms.md).
217
+ - `scribe schema add` only appends a bare `{name, path}` stub with no fields; it's for
218
+ starting a content collection interactively. There is still no *field*-level
219
+ command — `schema apply` replaces the whole collection, so include every field.
220
+ - `scribe content publish` reads a **local file at the given repo-relative path**,
221
+ parsing `---`-delimited YAML/JSON frontmatter (or a bare YAML/JSON object with no
222
+ body).
223
+ - `scribe submission list` paginates (1-based `--page`); `--json` gives the whole page
224
+ object, so `total` tells you whether there's a page 2.
225
+ - **`<id>` is a Mongo ObjectId**, as printed by `submission list` — not a filename and
226
+ not a repo path. There's no path inference any more; submissions aren't files.
227
+ - `--note` **appends** to the submission's history. It never overwrites an earlier
228
+ note, and it leaves the submission's own `updatedAt` alone — a note is something
229
+ someone wrote *about* the lead, not a change to it.
230
+ - `submission export` writes to stdout unless `--out` is given, so it pipes
231
+ (`scribe submission export my-site inquiries > leads.csv`). CSV is for a
232
+ spreadsheet and is lossy on purpose: its columns come from the collection's schema,
233
+ so the header row is identical between exports even when a batch left a field blank,
234
+ and it carries no history and no visitor IP or user agent. `--format json` is
235
+ lossless, with each submission's events nested under it.
236
+ - `submission delete` is permanent, cascades the submission's history, and needs a
237
+ **staff** role — a site user's API key gets a 403. It prompts for the id unless
238
+ `--yes` is passed. There is deliberately no equivalent in the admin UI, so don't go
239
+ looking for a delete button on the inbox.
240
+
241
+ ## Images — never hotlink, always commit the file
242
+
243
+ **A `type: image` field is just a string.** Nothing in Scribe validates that its
244
+ value is an uploaded image — the schema doesn't know or care whether you write
245
+ `/uploads/some-photo.jpg` or `https://images.unsplash.com/photo-xyz`. When you (the
246
+ agent) are hand-authoring content files directly in a cloned repo — which is the
247
+ normal CLI workflow — **do not put an external image URL (Unsplash or otherwise)
248
+ into an image field's value.** It looks fine at first glance and then quietly means
249
+ the site depends forever on a third-party host it doesn't control, the image was
250
+ never actually reviewed/chosen for that specific listing, and it breaks the "this
251
+ site's content lives in this repo" model the rest of Scribe assumes.
252
+
253
+ Instead, every image an agent puts into content must be a **real file committed to
254
+ the site's own repo**, the same way the admin's own upload feature does it:
255
+
256
+ 1. Get the actual image bytes — download it (a real stock photo, something the
257
+ user supplied, whatever the source) to a local temp file.
258
+ 2. Save it into the cloned repo at `public/uploads/<name>`, with a name unique
259
+ enough not to collide (mirror the admin's own convention:
260
+ `<timestamp>-<safe-filename>`, letters/digits/`._-` only).
261
+ 3. Reference it in the content file's frontmatter as `/uploads/<name>` — a
262
+ repo-relative path, not the original external URL.
263
+ 4. Commit and push (`public/uploads/...` + the content file) together, same as any
264
+ other content change. There's no `scribe` CLI command for this — it's a plain
265
+ file in the repo, same as everything else.
266
+
267
+ This applies to `list: true` image fields too — one committed file per array entry,
268
+ same convention. A single-image field (`type: image`, no `list`) and a list one both
269
+ end up as `/uploads/...` paths; only the shape (string vs. array of strings)
270
+ differs.
271
+
272
+ The one case where a bare external URL is fine: the user explicitly says to leave a
273
+ placeholder / link out to somewhere else on purpose. Don't default to that — treat
274
+ "add a photo of X" as "go get a real image and commit it," not "find any URL that
275
+ renders."
276
+
277
+ ## Deleting a site
278
+
279
+ ```
280
+ scribe site delete <slug> [--yes]
281
+ ```
282
+
283
+ Permanent — deletes the GitHub repo (schema, content, Astro source) and every Mongo
284
+ record for the site, which since RFC 0004 includes its submissions and their event
285
+ history. No undo. Prompts to retype the
286
+ slug unless `--yes` is passed. Requires an **unrestricted** API key (no
287
+ `siteSlugs` restriction) — same rule as `site add`; a key scoped to only that
288
+ site is rejected. Same action is in the admin UI: account menu → **Settings**
289
+ (super-admin only) → **Danger zone**.
290
+
291
+ Deleting also drops the site's domain from the Turnstile widget — but only if no
292
+ other site still uses that domain, so removing one demo never breaks the contact
293
+ form on the rest. A `Warning:` in the output means that cleanup didn't happen and
294
+ the stale domain is still on the widget; harmless, but tidy it up in Cloudflare.
295
+
296
+ Side effect worth knowing: if an API key's `siteSlugs` was only that one site,
297
+ deleting it leaves that key with an empty `siteSlugs` array — which the API treats
298
+ as **unrestricted** (every site), not inert. A key scoped to exactly the site you
299
+ delete comes out of it more powerful, not revoked.
300
+
301
+ ## `.scribe.yml` field reference
302
+
303
+ Per collection: `name`, `label`, `path`, `format` (`yaml-frontmatter` for normal
304
+ content; also
305
+ `json-frontmatter`/`toml-frontmatter`/`yaml`/`json`/`toml`/`datagrid`/`code`/`raw`),
306
+ `fields[]`, `view` (`{primary, sort, fields, search, default}` — controls the admin
307
+ list view), and the Scribe extensions `description` (shown under the title on the
308
+ collection's list/inbox page in the admin), `form: true` (renders as an inbox),
309
+ `workflow: {states}` (form collections only), `notify: [...]` (recipients on new
310
+ submissions).
311
+
312
+ On a **form collection** specifically, since RFC 0004 moved submissions into Mongo:
313
+
314
+ - **Many per site is supported and normal.** Every `form: true` collection is an
315
+ independent inbox with its own workflow, badge and `?collection=` value; a schema
316
+ with two of them needs no special handling. The rate limiter is the one shared
317
+ thing — it's keyed per Site and IP, not per form.
318
+ - **Submitted values are coerced to the declared type** before validation, so a
319
+ form posting `FormData` works: a checkbox's `"on"` becomes `true`, `"4"` becomes
320
+ `4`. A value that can't be coerced still fails with a readable message. JSON
321
+ bodies, url-encoded and multipart are all accepted.
322
+ - `path` and `format` are **ignored** — there are no files to lay out. Leave them;
323
+ they're harmless, and the parser doesn't read them for a form collection.
324
+ - `workflow` takes `{states: [...]}` only. The old `field:` key is gone, and so is the
325
+ legacy shorthand where `states` was a list of bare strings — a schema still written
326
+ that way now fails to parse rather than being silently guessed at. Each state is an
327
+ object: `{name, label, kind, hidden}`, where `kind` is `needs-you` | `waiting` |
328
+ `closed` (it drives the inbox's nav badge, which counts `needs-you`) and `hidden`
329
+ keeps a state like `spam` out of the default view while leaving it selectable.
330
+ - **`status` and `createdAt` are reserved field names** and can't be declared in
331
+ `fields[]`. `status` is a real column on the submission and `submittedAt` replaces
332
+ `createdAt`; both render as columns the schema doesn't have to mention.
333
+ - **An `image` field is rejected** on a form collection. The old path uploaded a
334
+ Visitor's file straight into the public repo, and a Mongo document is the wrong place
335
+ for a blob. Deferred to RFC 0007 (object storage).
336
+ - **`notify:` does not send email.** It runs, but through a `ConsoleMailer` that only
337
+ logs — no address has ever received anything. Real delivery is RFC 0009. Don't
338
+ promise a client they'll be emailed on a new submission; the inbox and its
339
+ `needs-you` badge are the actual notification today.
340
+
341
+ Per field: `name`, `type`, `label`, `required`, `default`, `pattern`, `hidden`,
342
+ `readonly`, `isBody` (this field is the markdown body, not frontmatter), `list`
343
+ (`true` or `{min, max, collapsible}`), `options` (for `type: select`). For
344
+ `type: image` fields specifically, see **Images** above — its value (or each
345
+ value in the array, if `list`) must be a committed `/uploads/...` path, not an
346
+ external URL.
347
+
348
+ **Field `type` — stick to the implemented list.** The admin has a working editor
349
+ for: `string`, `text`, `rich-text`, `number`, `boolean`, `date`, `image`, `select`.
350
+ The type enum also accepts `block`, `code`, `file`, `object`, `reference`, `uuid` —
351
+ these parse fine but the admin has no editor for them yet, so a field using one
352
+ falls back to a raw/unstyled view. Don't reach for them unless that's specifically
353
+ acceptable for this site.
354
+
355
+ **Quote date- and time-like values in `string` fields.** When hand-writing a
356
+ content file, `when: 2026-09-28` or `at: 12:30` is read by Astro's YAML loader as
357
+ a Date and a number, not a string — so a field declared `type: string` fails its
358
+ generated `z.string()` schema and **the site build breaks**. Write
359
+ `when: "2026-09-28"`. Fields declared `type: date` are immune (they generate
360
+ `z.coerce.date()`, which takes either form), and anything written by the admin or
361
+ by `scribe content publish` is quoted for you. This only bites on raw hand-edited
362
+ files committed with `git push`.
363
+
364
+ ## Gotchas
365
+
366
+ - Schema changes are instant locally, but cached **8 minutes** in the admin/API
367
+ after a push — don't chase "why hasn't this shown up yet" faster than that.
368
+ - `scribe site clone` is a thin `git clone` wrapper — once cloned, it's an ordinary
369
+ git repo, nothing more.
370
+ - A raw `git push` of content skips schema validation; `scribe content publish`
371
+ doesn't.
372
+ - There's no merge-conflict tooling anywhere in the CLI — resolve like any git
373
+ conflict.
@@ -0,0 +1,31 @@
1
+ # Recipes
2
+
3
+ Patterns for building an Astro site against Scribe as the backend. Per
4
+ [RFC 0010](../../../docs/proposals/0010-agent-authored-forms.md) these are
5
+ **advice, not enforcement** — read the one you need, adapt it to the site you're
6
+ building, and ignore the rest. There is no template to conform to and no
7
+ generated config to keep in sync.
8
+
9
+ | Recipe | Use it when |
10
+ |---|---|
11
+ | [`schema.md`](./schema.md) | you need to know what collections and fields a Site has |
12
+ | [`collections.md`](./collections.md) | rendering content (posts, products, events) that editors manage in Scribe |
13
+ | [`forms.md`](./forms.md) | putting one or more forms on the Site |
14
+ | [`deploy.md`](./deploy.md) | setting up or debugging the build and deploy |
15
+
16
+ ## The part that isn't advice
17
+
18
+ Scribe doesn't only serve the Site — it **writes into the Site's repo** and
19
+ triggers its build. The admin commits content files and uploaded images, writes
20
+ `public/CNAME` when a domain is verified, and `scribe build trigger` dispatches
21
+ the deploy workflow. So these five things are a contract, not a preference:
22
+
23
+ 1. Content lives at each collection's `path`, in its `format`.
24
+ 2. Images live under `public/` at the path stored in the item's frontmatter.
25
+ 3. The presence of `public/CNAME` decides Astro's `base`.
26
+ 4. The deploy workflow accepts `workflow_dispatch`.
27
+ 5. Forms POST to `/api/submissions?site=<slug>&collection=<name>`.
28
+
29
+ Get one of these wrong and the failure shows up *after* an editor hits publish,
30
+ on the live site, not while you're building. Everything else — routing, styling,
31
+ page structure, which pages exist, where a form goes — is yours.
@@ -0,0 +1,87 @@
1
+ # Recipe: rendering content collections
2
+
3
+ A content collection (anything without `form: true`) is a folder of files the
4
+ admin writes directly into the repo. Your job is to read them from the `path` the
5
+ schema declares, in the `format` it declares. That part isn't negotiable — the
6
+ admin commits those files and expects them there. How you route, style and lay
7
+ them out is entirely yours.
8
+
9
+ ## The two formats
10
+
11
+ - **`yaml-frontmatter`** — markdown: `---` frontmatter, then a body. The body is
12
+ whichever field has `isBody: true`.
13
+ - **`yaml`** — a plain `.yml` data file, no body.
14
+
15
+ Files are named `<slug>.md` / `<slug>.yml`, and the slug is the URL slug.
16
+
17
+ ## With Astro content collections
18
+
19
+ Glob the schema's paths. Astro wants its own `src/content.config.ts`, so keep the
20
+ loader list in step with `.scribe.yml` rather than hardcoding one Site's names:
21
+
22
+ ```ts
23
+ // src/content.config.ts
24
+ import { defineCollection, z } from 'astro:content';
25
+ import { glob } from 'astro/loaders';
26
+
27
+ const cars = defineCollection({
28
+ loader: glob({ pattern: '**/*.md', base: './content/cars' }),
29
+ // Loose on purpose: an editor adding a field in Scribe shouldn't fail the
30
+ // build before you've had a chance to render it.
31
+ schema: z.object({
32
+ title: z.string(),
33
+ make: z.string().optional(),
34
+ year: z.number().optional(),
35
+ price: z.number().optional(),
36
+ images: z.array(z.string()).default([]),
37
+ active: z.boolean().default(true),
38
+ }).passthrough(),
39
+ });
40
+
41
+ export const collections = { cars };
42
+ ```
43
+
44
+ Then render as usual with `getCollection('cars')`.
45
+
46
+ Keep the zod schema **permissive** (`.passthrough()`, `.optional()`). A strict
47
+ schema turns "an editor added a field" into a failed deploy, and the editor has
48
+ no way to see why.
49
+
50
+ ## Images
51
+
52
+ Uploaded images are committed under `public/` and the frontmatter stores the
53
+ **absolute site path** — `/images/cars/bugatti-chiron.jpg`. Two consequences:
54
+
55
+ - Don't use `astro:assets` `Image` on them; they aren't in `src/`. A plain `<img
56
+ src={...}>` is correct.
57
+ - On a project page (no custom domain) the Astro `base` is `/<repo>`, so prefix
58
+ stored paths with it or they 404 in the demo:
59
+
60
+ ```astro
61
+ ---
62
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
63
+ ---
64
+ <img src={`${base}${car.data.images[0]}`} alt={car.data.title} />
65
+ ```
66
+
67
+ This is the single most common way a demo site looks broken while the custom
68
+ domain version looks fine.
69
+
70
+ ## Don't hardcode one Client's collections
71
+
72
+ The same code gets reused across Sites whose collections are named differently.
73
+ Drive pages off the schema where it's cheap:
74
+
75
+ ```astro
76
+ ---
77
+ import { contentCollections } from '../lib/scribe';
78
+ const sections = contentCollections().filter((c) => c.name !== 'posts');
79
+ ---
80
+ {sections.map((c) => <a href={`/${c.name}`}>{c.label ?? c.name}</a>)}
81
+ ```
82
+
83
+ ## Local content
84
+
85
+ `scribe content pull <site>` fetches the live content into the repo so you can
86
+ build against real data. A Site also usually keeps a `content/` folder committed,
87
+ which is what the deploy builds from.
@@ -0,0 +1,122 @@
1
+ # Recipe: build and deploy
2
+
3
+ Sites deploy to GitHub Pages from their own repo. Two parts of this are a
4
+ contract with Scribe rather than a preference: the workflow must accept
5
+ `workflow_dispatch`, and the Astro `base` must follow `public/CNAME`.
6
+
7
+ ## Why `workflow_dispatch` matters
8
+
9
+ When an editor publishes in the admin, Scribe commits the content file and then
10
+ calls `scribe build trigger` → `dispatchWorkflow`. Without
11
+ `workflow_dispatch: {}` the dispatch fails and **the editor's change never goes
12
+ live** even though the admin says it saved. Keep it even if you also build on
13
+ push.
14
+
15
+ ## Why the base path follows CNAME
16
+
17
+ A bare project page lives at `https://<org>.github.io/<repo>/`, so Astro needs
18
+ `base: '/<repo>'`. Once a custom domain is verified, the admin writes
19
+ `public/CNAME` and the site serves from the root, so `base` must become `/`.
20
+ Deciding this in the workflow means a domain going live needs no code change.
21
+
22
+ ```yaml
23
+ # .github/workflows/deploy.yml
24
+ name: Deploy to GitHub Pages
25
+
26
+ on:
27
+ push:
28
+ branches: [main]
29
+ workflow_dispatch: {} # required — Scribe dispatches this on publish
30
+
31
+ permissions:
32
+ contents: read
33
+ pages: write
34
+ id-token: write
35
+
36
+ concurrency:
37
+ group: pages
38
+ cancel-in-progress: true
39
+
40
+ jobs:
41
+ build:
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: actions/setup-node@v4
46
+ with:
47
+ node-version: 22
48
+ - run: npm install
49
+
50
+ - name: Compute Pages base path
51
+ id: base
52
+ run: |
53
+ if [ -f public/CNAME ]; then
54
+ echo "path=/" >> "$GITHUB_OUTPUT"
55
+ else
56
+ echo "path=/${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT"
57
+ fi
58
+
59
+ - run: npm run build
60
+ env:
61
+ PUBLIC_SCRIBE_API_URL: ${{ vars.PUBLIC_SCRIBE_API_URL }}
62
+ PUBLIC_BASE_PATH: ${{ steps.base.outputs.path }}
63
+
64
+ - uses: actions/upload-pages-artifact@v3
65
+ with:
66
+ path: dist
67
+
68
+ deploy:
69
+ needs: build
70
+ runs-on: ubuntu-latest
71
+ environment:
72
+ name: github-pages
73
+ steps:
74
+ - id: deployment
75
+ uses: actions/deploy-pages@v4
76
+ ```
77
+
78
+ And in `astro.config.mjs`:
79
+
80
+ ```js
81
+ export default defineConfig({
82
+ base: process.env.PUBLIC_BASE_PATH || '/',
83
+ // ...
84
+ });
85
+ ```
86
+
87
+ ## `PUBLIC_SCRIBE_API_URL`
88
+
89
+ Where forms post. It's a **repo variable**, and GitHub resolves an unset
90
+ `vars.*` to an empty string rather than leaving it undefined — so `??` won't
91
+ fall back. Use `||`:
92
+
93
+ ```ts
94
+ const apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io';
95
+ ```
96
+
97
+ Any `PUBLIC_*` value is inlined into the client bundle at build time, so changing
98
+ it needs a rebuild, not just a redeploy.
99
+
100
+ ## Local loop
101
+
102
+ ```
103
+ npm install
104
+ npm run dev # iterate on design
105
+ npm run build && npm run preview
106
+ ```
107
+
108
+ To test forms locally, point `PUBLIC_SCRIBE_API_URL` at a local admin
109
+ (`http://localhost:3040`) — but note the endpoint only allows CORS from the
110
+ Site's registered domain, so use the admin's own host or expect to test
111
+ submissions against the deployed site.
112
+
113
+ ## Checklist when a deploy looks wrong
114
+
115
+ - Images 404 on the demo URL but work on the custom domain → missing `base`
116
+ prefix (see `collections.md`).
117
+ - Editor published but the site is stale → `workflow_dispatch` missing, or the
118
+ workflow failed; check the Actions tab.
119
+ - Form says "Failed to fetch" → `PUBLIC_SCRIBE_API_URL` baked in wrong, or the
120
+ origin isn't the Site's registered domain.
121
+ - Build fails after an editor added a field → the content zod schema is too
122
+ strict (see `collections.md`).