@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.
- package/README.md +74 -4
- package/dist/commands/schema.d.ts.map +1 -1
- package/dist/commands/schema.js +51 -0
- package/dist/commands/schema.js.map +1 -1
- package/dist/commands/submission.d.ts.map +1 -1
- package/dist/commands/submission.js +74 -22
- package/dist/commands/submission.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 +62 -11
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +66 -9
- package/dist/lib/client.js.map +1 -1
- 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/package.json +3 -1
- package/skills/scribe-site-builder/SKILL.md +373 -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 +122 -0
- package/skills/scribe-site-builder/recipes/forms.md +231 -0
- package/skills/scribe-site-builder/recipes/schema.md +99 -0
|
@@ -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
|
+
```
|