@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,231 @@
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
+ ## 3. A form component
81
+
82
+ Generic and schema-driven, which is the useful starting point when the fields
83
+ come from `.scribe.yml`. Adapt or replace the markup freely.
84
+
85
+ ```astro
86
+ ---
87
+ // src/components/ScribeForm.astro
88
+ export interface Props {
89
+ site: string;
90
+ collection: string;
91
+ turnstileSiteKey: string;
92
+ fields: Array<{ name: string; label?: string; type: string; required?: boolean }>;
93
+ apiBase?: string;
94
+ submitLabel?: string;
95
+ /** Values the page knows that the visitor shouldn't retype, e.g. which event this is. */
96
+ prefill?: Record<string, string>;
97
+ }
98
+
99
+ const {
100
+ site, collection, turnstileSiteKey, fields,
101
+ apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io',
102
+ submitLabel = 'Send',
103
+ prefill = {},
104
+ } = Astro.props;
105
+
106
+ // Scoped to the collection so two forms can share a page.
107
+ const formId = `scribe-form-${collection}`;
108
+
109
+ function labelFor(f: { name: string; label?: string }) {
110
+ return f.label ?? f.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
111
+ .replace(/^./, (c) => c.toUpperCase());
112
+ }
113
+ // Control comes from the declared type. Name-based guessing is only for the
114
+ // keyboard hint on an email/phone input — never for required-ness or the control.
115
+ function inputType(f: { name: string; type: string }) {
116
+ if (f.type === 'number') return 'number';
117
+ if (/mail/i.test(f.name)) return 'email';
118
+ if (/phone|mobile|tel|cell/i.test(f.name)) return 'tel';
119
+ return 'text';
120
+ }
121
+ ---
122
+ <form id={formId} data-site={site} data-collection={collection} data-endpoint={`${apiBase}/api/submissions`}>
123
+ {fields.map((f) => (
124
+ prefill[f.name] !== undefined ? (
125
+ <input type="hidden" name={f.name} value={prefill[f.name]} />
126
+ ) : f.type === 'boolean' ? (
127
+ <label class="checkbox">
128
+ <input name={f.name} type="checkbox" /> {labelFor(f)}
129
+ </label>
130
+ ) : f.type === 'text' ? (
131
+ <label>{labelFor(f)}<textarea name={f.name} required={f.required} rows="4"></textarea></label>
132
+ ) : (
133
+ <label>{labelFor(f)}<input name={f.name} type={inputType(f)} required={f.required} /></label>
134
+ )
135
+ ))}
136
+ <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
137
+ <button type="submit">{submitLabel}</button>
138
+ <p class="form-status" role="status" aria-live="polite"></p>
139
+ </form>
140
+
141
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
142
+ <script is:inline define:vars={{ formId }}>
143
+ (function () {
144
+ const form = document.getElementById(formId);
145
+ if (!form) return;
146
+ const statusEl = form.querySelector('.form-status');
147
+ const say = (m) => { if (statusEl) statusEl.textContent = m; };
148
+
149
+ form.addEventListener('submit', async (event) => {
150
+ event.preventDefault();
151
+
152
+ // Built per control rather than from FormData, so a checkbox sends a real
153
+ // boolean and an unchecked box sends `false` instead of being omitted.
154
+ const payload = {};
155
+ for (const el of form.elements) {
156
+ if (!el.name) continue;
157
+ if (el.type === 'checkbox') payload[el.name] = el.checked;
158
+ else if (el.type === 'number') payload[el.name] = el.value === '' ? null : Number(el.value);
159
+ else payload[el.name] = el.value;
160
+ }
161
+
162
+ const url = `${form.dataset.endpoint}?site=${encodeURIComponent(form.dataset.site)}`
163
+ + `&collection=${encodeURIComponent(form.dataset.collection)}`;
164
+
165
+ say('Sending…');
166
+ try {
167
+ const res = await fetch(url, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify(payload),
171
+ });
172
+ if (!res.ok) {
173
+ // The server's copy is written for the visitor on 400/503; prefer it.
174
+ const data = await res.json().catch(() => ({}));
175
+ say(data.error || "Sorry — we couldn't send that. Please try again.");
176
+ return;
177
+ }
178
+ form.reset();
179
+ say('Thanks — we got it.');
180
+ } catch {
181
+ say("Sorry — we couldn't reach us just now. Please call or email instead.");
182
+ }
183
+ });
184
+ })();
185
+ </script>
186
+ ```
187
+
188
+ Note `cf-turnstile-response`: the Turnstile widget injects a hidden input with
189
+ that name into the form, so the loop above picks it up automatically. If you
190
+ build the payload by hand, include it explicitly.
191
+
192
+ ## 4. Put it on a page
193
+
194
+ Placement is entirely yours — the schema says nothing about where a form goes.
195
+ Read the collection out of the schema and pass its fields in:
196
+
197
+ ```astro
198
+ ---
199
+ // src/pages/events/[...slug].astro
200
+ import ScribeForm from '../../components/ScribeForm.astro';
201
+ import { loadScribeSchema } from '../../lib/scribe'; // see schema.md
202
+
203
+ const schema = loadScribeSchema();
204
+ const rsvps = schema.collections.find((c) => c.name === 'rsvps');
205
+ const { event } = Astro.props;
206
+ ---
207
+ <h2>Coming along?</h2>
208
+ <ScribeForm
209
+ site={schema.site}
210
+ collection="rsvps"
211
+ turnstileSiteKey={schema.turnstileSiteKey}
212
+ fields={rsvps.fields}
213
+ submitLabel="Send my RSVP"
214
+ prefill={{ eventTitle: event.data.title }}
215
+ />
216
+ ```
217
+
218
+ `prefill` is how a form on a detail page captures its context — the visitor
219
+ never types which event they're replying to, and the submission arrives in the
220
+ inbox already saying so.
221
+
222
+ ## Gotchas
223
+
224
+ - **Two forms, one page:** fine, as long as element ids are unique. The snippet
225
+ above scopes everything to `scribe-form-<collection>`.
226
+ - **`notify:` does not send email.** It's parsed and logged by a console mailer
227
+ only. Don't promise a client notifications (RFC 0009 is the real thing).
228
+ - **Rate limiting is per Site and IP, not per form**, so hammering one form while
229
+ testing will also throttle the others.
230
+ - **An unchecked checkbox in `FormData` is absent, not `false`.** The payload
231
+ loop above avoids this; if you use `FormData`, add a hidden companion input.
@@ -0,0 +1,99 @@
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
+ collections: ScribeCollection[];
49
+ }
50
+
51
+ let cached: ScribeSchema | undefined;
52
+
53
+ /** Build-time only — reads the repo's own `.scribe.yml`. */
54
+ export function loadScribeSchema(): ScribeSchema {
55
+ // Resolve from the working directory, NOT from `import.meta.url`. Astro
56
+ // bundles this module into `dist/.prerender/chunks/`, so a module-relative
57
+ // path looks for `.scribe.yml` next to the bundle and fails the build with
58
+ // `ENOENT: .../dist/.scribe.yml`.
59
+ cached ??= parse(readFileSync(resolve(process.cwd(), '.scribe.yml'), 'utf-8')) as ScribeSchema;
60
+ return cached;
61
+ }
62
+
63
+ export const formCollections = () => loadScribeSchema().collections.filter((c) => c.form === true);
64
+ export const contentCollections = () => loadScribeSchema().collections.filter((c) => c.form !== true);
65
+ export const collection = (name: string) =>
66
+ loadScribeSchema().collections.find((c) => c.name === name);
67
+ ```
68
+
69
+ Needs `yaml` in the Site's dependencies (a `devDependency` is fine — this only
70
+ runs at build time, and CI installs dev deps before `npm run build`).
71
+
72
+ ## What the keys mean
73
+
74
+ | Key | Meaning |
75
+ |---|---|
76
+ | `site` | the Site slug — what you pass as `?site=` when submitting a form |
77
+ | `siteName` | display name |
78
+ | `turnstileSiteKey` | public Turnstile key for forms |
79
+ | `collections[].path` | where content files live, relative to the repo root |
80
+ | `collections[].format` | `yaml-frontmatter` (markdown with a body) or `yaml` (data only) |
81
+ | `collections[].form` | `true` means submissions, not files — **no content to render** |
82
+ | `fields[].isBody` | this field is the markdown body, not frontmatter |
83
+ | `fields[].hidden` | internal; don't render it, don't put it on a form |
84
+ | `view.primary` / `view.sort` | the editor's preferred title and sort field |
85
+
86
+ A `form: true` collection has **no files in the repo** — its submissions are rows
87
+ in Scribe's database, readable in the admin or via
88
+ `scribe submission list`/`export`. Don't try to render it as content.
89
+
90
+ ## From the CLI instead
91
+
92
+ Useful when you want the schema without a checkout, or to inspect it while
93
+ working:
94
+
95
+ ```
96
+ scribe schema show <site> # readable summary
97
+ scribe schema show <site> rsvps # one collection
98
+ scribe schema show <site> --json # raw, for piping
99
+ ```