@scribe-platform/cli 0.4.0 → 0.6.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.
- package/README.md +43 -4
- package/dist/commands/site.d.ts.map +1 -1
- package/dist/commands/site.js +95 -0
- package/dist/commands/site.js.map +1 -1
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +93 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/client.d.ts +37 -0
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +6 -6
- package/dist/lib/client.js.map +1 -1
- package/dist/lib/deploy-wait.d.ts +41 -0
- package/dist/lib/deploy-wait.d.ts.map +1 -0
- package/dist/lib/deploy-wait.js +22 -0
- package/dist/lib/deploy-wait.js.map +1 -0
- package/dist/lib/skill.d.ts +45 -0
- package/dist/lib/skill.d.ts.map +1 -0
- package/dist/lib/skill.js +70 -0
- package/dist/lib/skill.js.map +1 -0
- package/dist/lib/verify-report.d.ts +5 -0
- package/dist/lib/verify-report.d.ts.map +1 -0
- package/dist/lib/verify-report.js +54 -0
- package/dist/lib/verify-report.js.map +1 -0
- package/package.json +3 -1
- package/skills/scribe-site-builder/SKILL.md +390 -0
- package/skills/scribe-site-builder/recipes/README.md +31 -0
- package/skills/scribe-site-builder/recipes/collections.md +87 -0
- package/skills/scribe-site-builder/recipes/deploy.md +126 -0
- package/skills/scribe-site-builder/recipes/forms.md +318 -0
- package/skills/scribe-site-builder/recipes/schema.md +141 -0
|
@@ -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,126 @@
|
|
|
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
|
+
Both are checked against the deployed output by `scribe site verify <site>`, along
|
|
8
|
+
with the tracker (`schema.md`) and the two form contracts (`forms.md`) — see
|
|
9
|
+
RFC 0024. Run it after a deploy rather than reading this list and hoping.
|
|
10
|
+
|
|
11
|
+
## Why `workflow_dispatch` matters
|
|
12
|
+
|
|
13
|
+
When an editor publishes in the admin, Scribe commits the content file and then
|
|
14
|
+
calls `scribe build trigger` → `dispatchWorkflow`. Without
|
|
15
|
+
`workflow_dispatch: {}` the dispatch fails and **the editor's change never goes
|
|
16
|
+
live** even though the admin says it saved. Keep it even if you also build on
|
|
17
|
+
push.
|
|
18
|
+
|
|
19
|
+
## Why the base path follows CNAME
|
|
20
|
+
|
|
21
|
+
A bare project page lives at `https://<org>.github.io/<repo>/`, so Astro needs
|
|
22
|
+
`base: '/<repo>'`. Once a custom domain is verified, the admin writes
|
|
23
|
+
`public/CNAME` and the site serves from the root, so `base` must become `/`.
|
|
24
|
+
Deciding this in the workflow means a domain going live needs no code change.
|
|
25
|
+
|
|
26
|
+
```yaml
|
|
27
|
+
# .github/workflows/deploy.yml
|
|
28
|
+
name: Deploy to GitHub Pages
|
|
29
|
+
|
|
30
|
+
on:
|
|
31
|
+
push:
|
|
32
|
+
branches: [main]
|
|
33
|
+
workflow_dispatch: {} # required — Scribe dispatches this on publish
|
|
34
|
+
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
pages: write
|
|
38
|
+
id-token: write
|
|
39
|
+
|
|
40
|
+
concurrency:
|
|
41
|
+
group: pages
|
|
42
|
+
cancel-in-progress: true
|
|
43
|
+
|
|
44
|
+
jobs:
|
|
45
|
+
build:
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
- uses: actions/setup-node@v4
|
|
50
|
+
with:
|
|
51
|
+
node-version: 22
|
|
52
|
+
- run: npm install
|
|
53
|
+
|
|
54
|
+
- name: Compute Pages base path
|
|
55
|
+
id: base
|
|
56
|
+
run: |
|
|
57
|
+
if [ -f public/CNAME ]; then
|
|
58
|
+
echo "path=/" >> "$GITHUB_OUTPUT"
|
|
59
|
+
else
|
|
60
|
+
echo "path=/${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
- run: npm run build
|
|
64
|
+
env:
|
|
65
|
+
PUBLIC_SCRIBE_API_URL: ${{ vars.PUBLIC_SCRIBE_API_URL }}
|
|
66
|
+
PUBLIC_BASE_PATH: ${{ steps.base.outputs.path }}
|
|
67
|
+
|
|
68
|
+
- uses: actions/upload-pages-artifact@v3
|
|
69
|
+
with:
|
|
70
|
+
path: dist
|
|
71
|
+
|
|
72
|
+
deploy:
|
|
73
|
+
needs: build
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
environment:
|
|
76
|
+
name: github-pages
|
|
77
|
+
steps:
|
|
78
|
+
- id: deployment
|
|
79
|
+
uses: actions/deploy-pages@v4
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
And in `astro.config.mjs`:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
export default defineConfig({
|
|
86
|
+
base: process.env.PUBLIC_BASE_PATH || '/',
|
|
87
|
+
// ...
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## `PUBLIC_SCRIBE_API_URL`
|
|
92
|
+
|
|
93
|
+
Where forms post. It's a **repo variable**, and GitHub resolves an unset
|
|
94
|
+
`vars.*` to an empty string rather than leaving it undefined — so `??` won't
|
|
95
|
+
fall back. Use `||`:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io';
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Any `PUBLIC_*` value is inlined into the client bundle at build time, so changing
|
|
102
|
+
it needs a rebuild, not just a redeploy.
|
|
103
|
+
|
|
104
|
+
## Local loop
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
npm install
|
|
108
|
+
npm run dev # iterate on design
|
|
109
|
+
npm run build && npm run preview
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
To test forms locally, point `PUBLIC_SCRIBE_API_URL` at a local admin
|
|
113
|
+
(`http://localhost:3040`) — but note the endpoint only allows CORS from the
|
|
114
|
+
Site's registered domain, so use the admin's own host or expect to test
|
|
115
|
+
submissions against the deployed site.
|
|
116
|
+
|
|
117
|
+
## Checklist when a deploy looks wrong
|
|
118
|
+
|
|
119
|
+
- Images 404 on the demo URL but work on the custom domain → missing `base`
|
|
120
|
+
prefix (see `collections.md`).
|
|
121
|
+
- Editor published but the site is stale → `workflow_dispatch` missing, or the
|
|
122
|
+
workflow failed; check the Actions tab.
|
|
123
|
+
- Form says "Failed to fetch" → `PUBLIC_SCRIBE_API_URL` baked in wrong, or the
|
|
124
|
+
origin isn't the Site's registered domain.
|
|
125
|
+
- Build fails after an editor added a field → the content zod schema is too
|
|
126
|
+
strict (see `collections.md`).
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# Recipe: forms
|
|
2
|
+
|
|
3
|
+
A Site can have **as many forms as it likes**. Each one is a collection in
|
|
4
|
+
`.scribe.yml` with `form: true`, each gets its own inbox and its own workflow in
|
|
5
|
+
the admin, and each posts to the same endpoint with a different `collection`.
|
|
6
|
+
|
|
7
|
+
Design the form however you want. Only the POST contract below is fixed.
|
|
8
|
+
|
|
9
|
+
## 1. Declare the collection
|
|
10
|
+
|
|
11
|
+
Write a spec file and apply it — don't hand-edit `.scribe.yml` in the repo, since
|
|
12
|
+
the admin treats the committed file as the source of truth and will overwrite
|
|
13
|
+
you:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
# rsvps.yml
|
|
17
|
+
name: rsvps
|
|
18
|
+
label: Event RSVPs
|
|
19
|
+
description: Replies to the invitations on the events page.
|
|
20
|
+
path: content/rsvps
|
|
21
|
+
format: yaml
|
|
22
|
+
form: true
|
|
23
|
+
fields:
|
|
24
|
+
- { name: fullName, type: string, required: true }
|
|
25
|
+
- { name: email, type: string, required: true }
|
|
26
|
+
- { name: mobile, type: string, label: Mobile }
|
|
27
|
+
- { name: eventTitle, type: string, label: Event, required: true }
|
|
28
|
+
- { name: guests, type: number }
|
|
29
|
+
- { name: attending, type: boolean, label: I'll be there }
|
|
30
|
+
- { name: dietaryNotes, type: text }
|
|
31
|
+
workflow:
|
|
32
|
+
states:
|
|
33
|
+
- { name: pending, label: Awaiting reply, kind: needs-you }
|
|
34
|
+
- { name: waitlist, label: Waitlisted, kind: waiting }
|
|
35
|
+
- { name: confirmed, label: Confirmed, kind: closed }
|
|
36
|
+
- { name: declined, label: Declined, kind: closed }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
scribe schema apply <site> --file rsvps.yml # or --file - to pipe it in
|
|
41
|
+
scribe schema show <site> rsvps # read it back
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Rules the apply will enforce for you:
|
|
45
|
+
|
|
46
|
+
- `status` and `createdAt` are **reserved** — they're columns on the submission,
|
|
47
|
+
not fields you declare.
|
|
48
|
+
- A form collection may **not** declare an `image` field; a visitor-submitted
|
|
49
|
+
file has nowhere to be stored yet (RFC 0007).
|
|
50
|
+
- `workflow.states` is a list of `{ name, label, kind }` where `kind` is
|
|
51
|
+
`needs-you`, `waiting` or `closed`. A new submission starts in the first
|
|
52
|
+
`needs-you` state, so order matters. `hidden: true` keeps a state out of the
|
|
53
|
+
filter dropdown.
|
|
54
|
+
- `path` and `format` are ignored for a form collection (submissions live in the
|
|
55
|
+
database), but the parser still wants them.
|
|
56
|
+
|
|
57
|
+
## 2. POST contract
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
POST /api/submissions?site=<slug>&collection=<name>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- **Body:** JSON, or `application/x-www-form-urlencoded`, or
|
|
64
|
+
`multipart/form-data`. Values are coerced to the declared types, so a checkbox
|
|
65
|
+
posting `"on"` becomes `true` and `"4"` becomes `4`. Posting real JSON types is
|
|
66
|
+
still better — it's unambiguous.
|
|
67
|
+
- **Turnstile is required.** Send the widget's token as `cf-turnstile-response`.
|
|
68
|
+
A missing or empty token is a **401**, always — there is no bypass.
|
|
69
|
+
- **Responses:** `200 {ok:true}`; `400` with a human-readable `error` for a
|
|
70
|
+
validation failure; `401` verification failed; `429` rate limited (per site and
|
|
71
|
+
IP, **shared across all of that Site's forms**); `503` with visitor-facing copy
|
|
72
|
+
in `error` when the database is unreachable — show that message as-is, it's
|
|
73
|
+
written for them; `502` when the Site's `.scribe.yml` is unusable.
|
|
74
|
+
- **CORS** is allowed only from the Site's registered domain, so test from the
|
|
75
|
+
real host (or `localhost` against a local admin).
|
|
76
|
+
|
|
77
|
+
Never report success on a non-2xx. A form that says "Thanks!" after a 503 loses
|
|
78
|
+
the message.
|
|
79
|
+
|
|
80
|
+
## 2b. Three things about a form that are contract, not style
|
|
81
|
+
|
|
82
|
+
Design the markup however you like. These three are checked by
|
|
83
|
+
`scribe site verify` (RFC 0024), because each one fails **silently** — nothing
|
|
84
|
+
errors, the build is green, and the page looks right to whoever just deployed it.
|
|
85
|
+
|
|
86
|
+
| Contract | How to satisfy it |
|
|
87
|
+
|---|---|
|
|
88
|
+
| The form says which collection it posts to | `data-collection="<name>"` on the `<form>`, or `collection=<name>` in its `data-endpoint`/`action` |
|
|
89
|
+
| Turnstile protects it | Cloudflare's script tag, plus a `cf-turnstile` element whose `data-sitekey` is **this Site's** key |
|
|
90
|
+
| It never reports a false success | the retry queue in §3, declared with `data-scribe-retry` on the `<form>` |
|
|
91
|
+
|
|
92
|
+
The first is how Scribe finds your form in the built HTML at all. It is also
|
|
93
|
+
already load-bearing — your submit handler reads it to build the POST URL — so
|
|
94
|
+
there is no extra attribute to remember.
|
|
95
|
+
|
|
96
|
+
`data-scribe-retry` is the one attribute that exists purely as a claim, because
|
|
97
|
+
"this form queues a failed submission" has no other signature in the output. Put
|
|
98
|
+
it on the `<form>` in the same component that implements the queue, and don't put
|
|
99
|
+
it on a form that doesn't: it is read as an assertion that the behaviour below is
|
|
100
|
+
present.
|
|
101
|
+
|
|
102
|
+
A widget keyed to a **different** Site's `turnstileSiteKey` is as bad as no
|
|
103
|
+
widget — the endpoint verifies the token against this Site's secret and 401s it.
|
|
104
|
+
|
|
105
|
+
## 3. A form component
|
|
106
|
+
|
|
107
|
+
Generic and schema-driven, which is the useful starting point when the fields
|
|
108
|
+
come from `.scribe.yml`. Adapt or replace the markup freely.
|
|
109
|
+
|
|
110
|
+
```astro
|
|
111
|
+
---
|
|
112
|
+
// src/components/ScribeForm.astro
|
|
113
|
+
export interface Props {
|
|
114
|
+
site: string;
|
|
115
|
+
collection: string;
|
|
116
|
+
turnstileSiteKey: string;
|
|
117
|
+
fields: Array<{ name: string; label?: string; type: string; required?: boolean }>;
|
|
118
|
+
apiBase?: string;
|
|
119
|
+
submitLabel?: string;
|
|
120
|
+
/** Values the page knows that the visitor shouldn't retype, e.g. which event this is. */
|
|
121
|
+
prefill?: Record<string, string>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const {
|
|
125
|
+
site, collection, turnstileSiteKey, fields,
|
|
126
|
+
apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io',
|
|
127
|
+
submitLabel = 'Send',
|
|
128
|
+
prefill = {},
|
|
129
|
+
} = Astro.props;
|
|
130
|
+
|
|
131
|
+
// Scoped to the collection so two forms can share a page.
|
|
132
|
+
const formId = `scribe-form-${collection}`;
|
|
133
|
+
|
|
134
|
+
function labelFor(f: { name: string; label?: string }) {
|
|
135
|
+
return f.label ?? f.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
|
|
136
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
137
|
+
}
|
|
138
|
+
// Control comes from the declared type. Name-based guessing is only for the
|
|
139
|
+
// keyboard hint on an email/phone input — never for required-ness or the control.
|
|
140
|
+
function inputType(f: { name: string; type: string }) {
|
|
141
|
+
if (f.type === 'number') return 'number';
|
|
142
|
+
if (/mail/i.test(f.name)) return 'email';
|
|
143
|
+
if (/phone|mobile|tel|cell/i.test(f.name)) return 'tel';
|
|
144
|
+
return 'text';
|
|
145
|
+
}
|
|
146
|
+
---
|
|
147
|
+
<form id={formId} data-site={site} data-collection={collection} data-endpoint={`${apiBase}/api/submissions`} data-scribe-retry>
|
|
148
|
+
{fields.map((f) => (
|
|
149
|
+
prefill[f.name] !== undefined ? (
|
|
150
|
+
<input type="hidden" name={f.name} value={prefill[f.name]} />
|
|
151
|
+
) : f.type === 'boolean' ? (
|
|
152
|
+
<label class="checkbox">
|
|
153
|
+
<input name={f.name} type="checkbox" /> {labelFor(f)}
|
|
154
|
+
</label>
|
|
155
|
+
) : f.type === 'text' ? (
|
|
156
|
+
<label>{labelFor(f)}<textarea name={f.name} required={f.required} rows="4"></textarea></label>
|
|
157
|
+
) : (
|
|
158
|
+
<label>{labelFor(f)}<input name={f.name} type={inputType(f)} required={f.required} /></label>
|
|
159
|
+
)
|
|
160
|
+
))}
|
|
161
|
+
<div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
|
|
162
|
+
<button type="submit">{submitLabel}</button>
|
|
163
|
+
<p class="form-status" role="status" aria-live="polite"></p>
|
|
164
|
+
</form>
|
|
165
|
+
|
|
166
|
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
167
|
+
<script is:inline define:vars={{ formId }}>
|
|
168
|
+
(function () {
|
|
169
|
+
const form = document.getElementById(formId);
|
|
170
|
+
if (!form) return;
|
|
171
|
+
const statusEl = form.querySelector('.form-status');
|
|
172
|
+
const say = (m) => { if (statusEl) statusEl.textContent = m; };
|
|
173
|
+
|
|
174
|
+
const { site, collection, endpoint } = form.dataset;
|
|
175
|
+
const storageKey = `scribe_pending_submission_${site}_${collection}`;
|
|
176
|
+
|
|
177
|
+
// Last resorts only. The endpoint puts visitor-facing copy in the response
|
|
178
|
+
// body (RFC 0004) so the server can change what a Visitor reads without
|
|
179
|
+
// rebuilding every Site. These two cover the cases where there is no body:
|
|
180
|
+
// the request never arrived, or it came back with nothing usable.
|
|
181
|
+
const UNREACHABLE = "We couldn't send that yet — it's saved and will retry.";
|
|
182
|
+
const GENERIC = "We couldn't send that. Please try again, or call or email us instead.";
|
|
183
|
+
|
|
184
|
+
const readPending = () => { try { return JSON.parse(localStorage.getItem(storageKey)) ?? null; } catch { return null; } };
|
|
185
|
+
const savePending = (p) => { try { localStorage.setItem(storageKey, JSON.stringify(p)); } catch {} };
|
|
186
|
+
const clearPending = () => { try { localStorage.removeItem(storageKey); } catch {} };
|
|
187
|
+
|
|
188
|
+
// Built per control rather than from FormData, so a checkbox sends a real
|
|
189
|
+
// boolean and an unchecked box sends `false` instead of being omitted.
|
|
190
|
+
function collect() {
|
|
191
|
+
const payload = {};
|
|
192
|
+
for (const el of form.elements) {
|
|
193
|
+
if (!el.name) continue;
|
|
194
|
+
if (el.type === 'checkbox') payload[el.name] = el.checked;
|
|
195
|
+
else if (el.type === 'number') payload[el.name] = el.value === '' ? null : Number(el.value);
|
|
196
|
+
else payload[el.name] = el.value;
|
|
197
|
+
}
|
|
198
|
+
return payload;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function send(payload) {
|
|
202
|
+
const url = `${endpoint}?site=${encodeURIComponent(site)}&collection=${encodeURIComponent(collection)}`;
|
|
203
|
+
const res = await fetch(url, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
body: JSON.stringify(payload),
|
|
207
|
+
});
|
|
208
|
+
if (res.ok) return;
|
|
209
|
+
// A non-2xx means the server answered and said why, which is a different
|
|
210
|
+
// thing from never reaching it. `answered` picks the message; `retryable`
|
|
211
|
+
// decides whether a later attempt could plausibly land.
|
|
212
|
+
const body = await res.json().catch(() => ({}));
|
|
213
|
+
const error = new Error((body.error ?? '').trim() || GENERIC);
|
|
214
|
+
error.answered = true;
|
|
215
|
+
error.retryable = res.status >= 500;
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
form.addEventListener('submit', async (event) => {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
const payload = collect();
|
|
222
|
+
say('Sending…');
|
|
223
|
+
try {
|
|
224
|
+
await send(payload);
|
|
225
|
+
clearPending();
|
|
226
|
+
form.reset();
|
|
227
|
+
say('Thanks — we got it.');
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// Never claim a success the server didn't give, and never paper over
|
|
230
|
+
// its message with ours.
|
|
231
|
+
if (err.answered) {
|
|
232
|
+
if (err.retryable) savePending(payload);
|
|
233
|
+
say(err.message);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
savePending(payload);
|
|
237
|
+
say(UNREACHABLE);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Replay on the next page load, which is what makes the queue worth having.
|
|
242
|
+
(async function retryPending() {
|
|
243
|
+
const pending = readPending();
|
|
244
|
+
if (!pending) return;
|
|
245
|
+
try {
|
|
246
|
+
await send(pending);
|
|
247
|
+
clearPending();
|
|
248
|
+
say('Thanks — we got it.');
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// Replaying a rejection (a validation error, a rate limit) on every
|
|
251
|
+
// page load would loop forever, so drop it and show what the server
|
|
252
|
+
// said. Anything else stays queued for next time.
|
|
253
|
+
if (err.answered && !err.retryable) {
|
|
254
|
+
clearPending();
|
|
255
|
+
say(err.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
})();
|
|
259
|
+
})();
|
|
260
|
+
</script>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**The retry queue is the part not to skip.** Without it, a submission that fails
|
|
264
|
+
because the Visitor's connection dropped or the database was briefly unreachable
|
|
265
|
+
is gone — and the only person who could tell you is the Visitor, who thinks they
|
|
266
|
+
already got in touch. That is the single most expensive silent failure a Scribe
|
|
267
|
+
Site has, which is why `data-scribe-retry` is checked rather than suggested.
|
|
268
|
+
|
|
269
|
+
One subtlety worth knowing: a replay reuses the **stored payload**, which still
|
|
270
|
+
carries the `cf-turnstile-response` from the original attempt. Turnstile tokens
|
|
271
|
+
are short-lived, so a replay much later is rejected as a 401 — `answered` and not
|
|
272
|
+
`retryable`, so it is dropped with the server's message rather than looping. The
|
|
273
|
+
queue's real job is the retry that happens seconds or minutes later, not days.
|
|
274
|
+
|
|
275
|
+
Note `cf-turnstile-response`: the Turnstile widget injects a hidden input with
|
|
276
|
+
that name into the form, so `collect()` picks it up automatically. If you build
|
|
277
|
+
the payload by hand, include it explicitly.
|
|
278
|
+
|
|
279
|
+
## 4. Put it on a page
|
|
280
|
+
|
|
281
|
+
Placement is entirely yours — the schema says nothing about where a form goes.
|
|
282
|
+
Read the collection out of the schema and pass its fields in:
|
|
283
|
+
|
|
284
|
+
```astro
|
|
285
|
+
---
|
|
286
|
+
// src/pages/events/[...slug].astro
|
|
287
|
+
import ScribeForm from '../../components/ScribeForm.astro';
|
|
288
|
+
import { loadScribeSchema } from '../../lib/scribe'; // see schema.md
|
|
289
|
+
|
|
290
|
+
const schema = loadScribeSchema();
|
|
291
|
+
const rsvps = schema.collections.find((c) => c.name === 'rsvps');
|
|
292
|
+
const { event } = Astro.props;
|
|
293
|
+
---
|
|
294
|
+
<h2>Coming along?</h2>
|
|
295
|
+
<ScribeForm
|
|
296
|
+
site={schema.site}
|
|
297
|
+
collection="rsvps"
|
|
298
|
+
turnstileSiteKey={schema.turnstileSiteKey}
|
|
299
|
+
fields={rsvps.fields}
|
|
300
|
+
submitLabel="Send my RSVP"
|
|
301
|
+
prefill={{ eventTitle: event.data.title }}
|
|
302
|
+
/>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`prefill` is how a form on a detail page captures its context — the visitor
|
|
306
|
+
never types which event they're replying to, and the submission arrives in the
|
|
307
|
+
inbox already saying so.
|
|
308
|
+
|
|
309
|
+
## Gotchas
|
|
310
|
+
|
|
311
|
+
- **Two forms, one page:** fine, as long as element ids are unique. The snippet
|
|
312
|
+
above scopes everything to `scribe-form-<collection>`.
|
|
313
|
+
- **`notify:` does not send email.** It's parsed and logged by a console mailer
|
|
314
|
+
only. Don't promise a client notifications (RFC 0009 is the real thing).
|
|
315
|
+
- **Rate limiting is per Site and IP, not per form**, so hammering one form while
|
|
316
|
+
testing will also throttle the others.
|
|
317
|
+
- **An unchecked checkbox in `FormData` is absent, not `false`.** The payload
|
|
318
|
+
loop above avoids this; if you use `FormData`, add a hidden companion input.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Recipe: reading the schema
|
|
2
|
+
|
|
3
|
+
`.scribe.yml` sits in the Site's repo root and is the contract between Scribe and
|
|
4
|
+
the Site: it names the collections, their fields, where content lives, and which
|
|
5
|
+
collections are forms. The admin keeps it current — editors change it through the
|
|
6
|
+
UI and the CLI writes to it — so the committed copy is authoritative at build
|
|
7
|
+
time.
|
|
8
|
+
|
|
9
|
+
**Read it directly. Don't generate a config file from it.** Older sites have a
|
|
10
|
+
`scripts/generate-config.mjs` that flattened the schema into a `site-config.ts`;
|
|
11
|
+
that layer lost field types and could only ever represent one form, which is why
|
|
12
|
+
RFC 0010 drops it.
|
|
13
|
+
|
|
14
|
+
## A loader
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// src/lib/scribe.ts
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { parse } from 'yaml';
|
|
21
|
+
|
|
22
|
+
export interface ScribeField {
|
|
23
|
+
name: string;
|
|
24
|
+
type: 'string' | 'text' | 'number' | 'boolean' | 'date' | 'select' | 'rich-text' | 'image';
|
|
25
|
+
label?: string;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
hidden?: boolean;
|
|
28
|
+
isBody?: boolean;
|
|
29
|
+
options?: Array<string | { value: string; label?: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScribeCollection {
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
format?: 'yaml' | 'yaml-frontmatter';
|
|
36
|
+
label?: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
form?: boolean;
|
|
39
|
+
fields: ScribeField[];
|
|
40
|
+
view?: { primary?: string; sort?: string; fields?: string[] };
|
|
41
|
+
workflow?: { states?: Array<{ name: string; label?: string; kind?: string; hidden?: boolean }> };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ScribeSchema {
|
|
45
|
+
site: string;
|
|
46
|
+
siteName?: string;
|
|
47
|
+
turnstileSiteKey?: string;
|
|
48
|
+
umamiWebsiteId?: string;
|
|
49
|
+
umamiScriptUrl?: string;
|
|
50
|
+
collections: ScribeCollection[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let cached: ScribeSchema | undefined;
|
|
54
|
+
|
|
55
|
+
/** Build-time only — reads the repo's own `.scribe.yml`. */
|
|
56
|
+
export function loadScribeSchema(): ScribeSchema {
|
|
57
|
+
// Resolve from the working directory, NOT from `import.meta.url`. Astro
|
|
58
|
+
// bundles this module into `dist/.prerender/chunks/`, so a module-relative
|
|
59
|
+
// path looks for `.scribe.yml` next to the bundle and fails the build with
|
|
60
|
+
// `ENOENT: .../dist/.scribe.yml`.
|
|
61
|
+
cached ??= parse(readFileSync(resolve(process.cwd(), '.scribe.yml'), 'utf-8')) as ScribeSchema;
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const formCollections = () => loadScribeSchema().collections.filter((c) => c.form === true);
|
|
66
|
+
export const contentCollections = () => loadScribeSchema().collections.filter((c) => c.form !== true);
|
|
67
|
+
export const collection = (name: string) =>
|
|
68
|
+
loadScribeSchema().collections.find((c) => c.name === name);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Needs `yaml` in the Site's dependencies (a `devDependency` is fine — this only
|
|
72
|
+
runs at build time, and CI installs dev deps before `npm run build`).
|
|
73
|
+
|
|
74
|
+
## What the keys mean
|
|
75
|
+
|
|
76
|
+
| Key | Meaning |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `site` | the Site slug — what you pass as `?site=` when submitting a form |
|
|
79
|
+
| `siteName` | display name |
|
|
80
|
+
| `turnstileSiteKey` | public Turnstile key for forms |
|
|
81
|
+
| `umamiWebsiteId` | analytics id for this Site — **render it, see below** |
|
|
82
|
+
| `umamiScriptUrl` | where the analytics tracker is served from |
|
|
83
|
+
| `collections[].path` | where content files live, relative to the repo root |
|
|
84
|
+
| `collections[].format` | `yaml-frontmatter` (markdown with a body) or `yaml` (data only) |
|
|
85
|
+
| `collections[].form` | `true` means submissions, not files — **no content to render** |
|
|
86
|
+
| `fields[].isBody` | this field is the markdown body, not frontmatter |
|
|
87
|
+
| `fields[].hidden` | internal; don't render it, don't put it on a form |
|
|
88
|
+
| `view.primary` / `view.sort` | the editor's preferred title and sort field |
|
|
89
|
+
|
|
90
|
+
A `form: true` collection has **no files in the repo** — its submissions are rows
|
|
91
|
+
in Scribe's database, readable in the admin or via
|
|
92
|
+
`scribe submission list`/`export`. Don't try to render it as content.
|
|
93
|
+
|
|
94
|
+
## Analytics — put the tracker in your layout
|
|
95
|
+
|
|
96
|
+
Scribe writes `umamiWebsiteId` and `umamiScriptUrl` into `.scribe.yml` when it
|
|
97
|
+
provisions a Site (ADR 0001). **Nothing renders them for you** — a Site whose
|
|
98
|
+
layout omits this snippet collects no visitor data at all, and the Client's
|
|
99
|
+
monthly Report has an empty website section.
|
|
100
|
+
|
|
101
|
+
In the `<head>` of your base layout:
|
|
102
|
+
|
|
103
|
+
```astro
|
|
104
|
+
---
|
|
105
|
+
import { loadScribeSchema } from '../lib/scribe';
|
|
106
|
+
const { umamiWebsiteId, umamiScriptUrl } = loadScribeSchema();
|
|
107
|
+
---
|
|
108
|
+
{umamiWebsiteId && umamiScriptUrl && (
|
|
109
|
+
<script defer src={umamiScriptUrl} data-website-id={umamiWebsiteId}></script>
|
|
110
|
+
)}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both guards matter: a Site provisioned before analytics existed, or one where
|
|
114
|
+
registration failed, has neither key, and a `<script>` with no id is a request
|
|
115
|
+
that reports nothing.
|
|
116
|
+
|
|
117
|
+
**This is the one thing here you cannot fix later.** Analytics data is not
|
|
118
|
+
retroactive — Umami only knows about visits that happened while the script was
|
|
119
|
+
on the page. A month shipped without it is a month that stays empty forever, so
|
|
120
|
+
add it when you build the layout, not when someone asks for a Report.
|
|
121
|
+
|
|
122
|
+
`scribe site verify <site>` checks the **deployed pages** for it, every page it
|
|
123
|
+
can reach rather than just the homepage — which is the point. A Site can have
|
|
124
|
+
the tracker in one layout and serve some pages through another, and the repo
|
|
125
|
+
looks fine either way. Rod's Auto Body renders through `Site.astro` while still
|
|
126
|
+
carrying an unused `Base.astro`; patching the obvious file there would have
|
|
127
|
+
produced a clean commit, a green deploy, and no tracking at all.
|
|
128
|
+
|
|
129
|
+
Cookieless, so it needs no consent banner, and `defer` keeps it off the
|
|
130
|
+
critical path.
|
|
131
|
+
|
|
132
|
+
## From the CLI instead
|
|
133
|
+
|
|
134
|
+
Useful when you want the schema without a checkout, or to inspect it while
|
|
135
|
+
working:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
scribe schema show <site> # readable summary
|
|
139
|
+
scribe schema show <site> rsvps # one collection
|
|
140
|
+
scribe schema show <site> --json # raw, for piping
|
|
141
|
+
```
|