@bettercms-ai/mcp 0.83.0 → 0.83.2

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.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,245 +1,245 @@
1
- ---
2
- title: "Forms and interactions"
3
- description: "Attaching, publishing and verifying forms, and interactive sections: menus, accordions, modals and consent."
4
- ---
5
-
6
- # Forms and interactive components
7
-
8
- **Outcome.** Interactive sections keep working after componentization, and editors change the
9
- intended content without touching unsafe implementation controls.
10
-
11
- Doctrine: `new_form` prompt, `CM-72`. Component props and slots: [components.md](components.md)
12
- (its §1 shows where a `form` prop actually renders).
13
-
14
- ## Contents
15
-
16
- - [Inventory behaviour](#inventory-behaviour) · [Pattern contracts](#pattern-contracts)
17
- - [Forms: the rules](#forms-the-rules) · [Editor UX](#editor-ux) · [Acceptance](#acceptance)
18
- - Forms as code: [pipeline](#1-pipeline) · [field types](#2-field-types-and-server-checks) ·
19
- [MCP versus dashboard](#3-mcp-versus-dashboard) · [self-hosted wiring](#4-wiring-a-self-hosted-site) ·
20
- [per-rung facts](#5-per-rung-facts) · [traps](#6-traps)
21
-
22
- ## Inventory behaviour
23
-
24
- For each interaction record its markup, state management, dependencies, events, destinations,
25
- responsive behaviour, accessibility semantics and persistence. Then sort what it exposes:
26
-
27
- - **Content-configurable:** labels, headings, body, images, links, items, variants.
28
- - **Behaviour-configurable:** a closed choice such as initial open state, autoplay, modal
29
- size, display frequency, or which form.
30
- - **Code-owned:** search algorithms, authentication, payments, booking, complex validation,
31
- server actions, experiments.
32
-
33
- Code-owned behaviour never becomes a free text field, and never a promise that editors can
34
- change it safely.
35
-
36
- ## Pattern contracts
37
-
38
- | Pattern | Editor model | Runtime checks |
39
- |---|---|---|
40
- | Navigation / mobile menu | global layout component; repeatable link groups; logo; explicit CTA | keyboard and touch, focus return, active route, breakpoint, skip link |
41
- | Accordion / FAQ | one section with a repeatable Question + narrowed rich-text Answer | button trigger, `aria-expanded`, panel association, focus, single or multi open |
42
- | Tabs | repeatable label + panel, or a nested component per panel when layouts differ | stable ids, arrow-key navigation, selected state, responsive fallback |
43
- | Modal / dialog | trigger + modal content in a slot or section; size as a variant | focus trap, Escape, focus return, scroll lock, accessible name, reduced motion |
44
- | Pop-up / promo bar | sitewide or page component; message, CTA, variant, closed display rules | frequency and dismiss persistence, mobile layout, consent |
45
- | Carousel | repeatable items or nested slides; limited motion controls | pause, keyboard, announcements, reduced motion, no hidden content loss |
46
- | Cookie consent | global component with policy links and category labels | consent storage, script gating, change-choice path; legal settings need owner review |
47
- | Search / filter | labels and options editable; query and index logic code-owned | empty, loading and error states, URL state, keyboard, result accuracy |
48
-
49
- For a complex one-off component, keep the working code and expose only an intentional prop
50
- contract. A nested child is edited through a `slot` prop or its own instance; the edit must
51
- address the child instance, never the parent definition. A `slot` holds one nested
52
- component, or a list of them with `config.multiple`; a fixed set of different children is the
53
- section's ordered children. A write that would nest a component inside itself is refused
54
- (`component_cycle`).
55
-
56
- ## Forms: the rules
57
-
58
- Detect each form's fields, labels, types, required states, validation, consent copy, spam
59
- controls, endpoint, uploads, confirmation and error states, notifications and integrations.
60
-
61
- - **A form is a Form document, and a section references it.** Create it as a draft
62
- (`create_form`) and place it with a `form` block `{ formId }` inside a section. To make WHICH
63
- form editable per placement, declare a `form` prop on the component targeting that block's
64
- `props`. The prop stores `{ formId }` and nothing else: never re-model the questions as
65
- component props, or the same fields live in two places and drift at the first edit.
66
- - **Draft until published.** A draft form renders nothing live and every public submission is
67
- refused with 403. Publish it with `publish_form` (it needs the publish permission) only after
68
- its destination and consent behaviour are tested.
69
- - **Spam controls** are per form: Turnstile on or off and a honeypot field name. The Turnstile
70
- secret key is dashboard-only.
71
- - **Self-hosted renderers** render only forms whose `status` is `"published"` and post to
72
- `${apiUrl}/api/v1/forms/public/:formId/submissions` through the SDK's `submitForm`, never to the
73
- forms worker directly and never with an API key in the browser.
74
- - **Integrations** are configured in the dashboard. A submission classified as spam is stored
75
- and triggers none of them.
76
-
77
- Classify each form:
78
-
79
- 1. **Native:** BetterCMS owns fields, validation, submissions and delivery.
80
- 2. **Connected provider:** BetterCMS owns the presentation or the reference; a provider owns
81
- delivery. Keep provider ids; the owner sets up the credentials.
82
- 3. **Custom action:** keep the existing code path until an adapter passes the same tests.
83
-
84
- A form rendering is not a working integration. Require a test submission that shows the
85
- expected record (`list_form_submissions`), the notification, the failure state, spam handling
86
- and consent handling.
87
-
88
- ## Editor UX
89
-
90
- Label fields the way editors think: `Question`, `Answer`, `Add FAQ item`; `Open modal`,
91
- `Modal title`, `Modal body`, `Close label`. CSS selectors, callbacks, endpoint URLs and secret
92
- keys stay out of editor fields. Expose curated variants and behaviour switches whose effect is
93
- obvious. An empty optional field should remove its element cleanly.
94
-
95
- ## Acceptance
96
-
97
- - Interactions work with keyboard, touch, screen-reader semantics, mobile layouts and reduced
98
- motion where relevant.
99
- - Editing a label, body or item changes the intended instance without breaking its state.
100
- - Nested elements keep their order, ids and edit scope.
101
- - Every form is `draft`, `ready for verification` or `verified live`; only the last has an
102
- end-to-end submission receipt.
103
- - Consent-dependent scripts do not run before consent is given.
104
-
105
- ## Forms as code
106
-
107
- The pipeline with the code that enforces each hop.
108
-
109
- ### 1. Pipeline
110
-
111
- | # | Hop | Code |
112
- |---|---|---|
113
- | 1 | Define: `create_form` / `update_form`, or the dashboard. Postgres `forms` row, `status` defaults to `draft` | `packages/db/src/schema/forms.ts:35` |
114
- | 2 | Publish: `publish_form`, or the dashboard Forms tab. Both need publish permission | `src/routes/management/forms.ts:276`, `src/routes/admin/forms.ts:529`, `:530` |
115
- | 3 | Place: a `form` block `{ formId }` inside a section | `packages/types/src/block.ts:253`, `:257` |
116
- | 4 | Render: hosted renderer, Next `BcmsForm`, or Astro `BcmsForm.astro` | `packages/next/src/form.tsx:74` |
117
- | 5 | Submit: `POST /api/v1/forms/public/:formId/submissions` with `{ data, "cf-turnstile-response"? }` | `src/routes/delivery/forms-public.ts:48`, `src/lib/validation/schemas/forms.ts:297`, `:298` |
118
- | 6a | Ingress: rate limit per client IP (10), before anything else | `src/routes/delivery/forms-public.ts:61`, `src/lib/forms/submit-gate.ts:23` |
119
- | 6b | Ingress: form lookup, 404 when missing; draft form, 403 | `src/routes/delivery/forms-public.ts:65`, `:71` |
120
- | 6c | Ingress: Turnstile; 403 on a failed token; skipped when not configured | `src/routes/delivery/forms-public.ts:80`, `:89` |
121
- | 6d | Ingress: field validation, 400 `{ code: "FORM_FIELD_INVALID", errors, issues }` | `src/lib/forms/submit-gate.ts:75` |
122
- | 6e | Ingress: spam classification (honeypot) | `src/lib/forms/spam.ts:40` |
123
- | 6f | Ingress: rate limit per workspace (100 per 60 s), one bucket shared with the API-key route | `src/lib/forms/submit-gate.ts:80` |
124
- | 7 | Store: forms worker writes the submission to D1 (`FORMS_DB`); CRM intents to `crm_intents` | `workers/bcms-forms-worker/src/index.ts:2`, `workers/bcms-forms-worker/migrations/0004_crm_intents.sql:1` |
125
- | 8 | Inbox side effects (non-spam only): contact upsert, integrations, notification | `src/routes/delivery/forms-public.ts:120`, `:144`, `:158` |
126
- | 9 | CRM delivery drain: `POST /api/v1/internal/crm/drain` | `src/index.ts:976`, `src/routes/internal/crm.ts:4` |
127
- | 10 | Read: `list_form_submissions`, dashboard inbox | `packages/mcp/src/tools.ts:1600` |
128
-
129
- ### 2. Field types and server checks
130
-
131
- Thirteen input types exist (`src/lib/validation/schemas/forms.ts`), plus `step`, a divider. No file upload.
132
-
133
- | Type | The server checks |
134
- |---|---|
135
- | `email` | Address shape; optional `emailPolicy: "business"` rule |
136
- | `number` | Numeric; optional `min` / `max` rules |
137
- | `select`, `radio`, `checkboxes` | Value is one of the listed options; a field saved with an empty options list accepts nothing |
138
- | `consent` | Must be truthy |
139
- | `phone` | Digits, spaces, `+ ( ) -`; stricter with `phoneFormat: "e164"` |
140
- | `text` / `textarea` | At most 1000 / 10000 characters; optional `pattern` rule |
141
- | `url` | Starts with `http://` or `https://` |
142
- | `date` | `YYYY-MM-DD` |
143
- | `checkbox`, `hidden` | Nothing beyond `required` |
144
- | `step` | Nothing: a divider is never required and never submitted; a value sent under its key is refused with rule `step` |
145
-
146
- Any field with `required` is checked for presence. Every value must be a plain value or a flat
147
- list of them; a nested object is refused with rule `scalar`. Each refusal is
148
- `FORM_FIELD_INVALID` with `issues: [{ field, rule, message }]`. Keys that are not fields are not
149
- refused.
150
-
151
- The API-key submit route runs the same validation, spam check and workspace limit, but no per-IP
152
- limit (`src/routes/delivery/forms.ts:106`).
153
-
154
- Definition refusals (`create_form` / `update_form`) carry `FORM_DEFINITION_INVALID`: duplicate or
155
- over-long keys, too many fields or options, and a `showIf` that names a field not on the form.
156
- That last one is refused on create and kept as sent on update.
157
-
158
- Rules:
159
-
160
- - Send only the form's field keys (plus the honeypot and Turnstile token), each as a plain value.
161
- - Read `issues[].field` and `issues[].rule` to fix a refused submission; retrying unchanged returns
162
- the same refusal.
163
-
164
- #### Steps
165
-
166
- A multi-step form is one form with `step` fields in `fields`: `{ key, type: "step", label }` opens
167
- a page named `label`, and the fields before the first `step` are page 1. A step takes only `key`
168
- and `label` (`step_has_input_keys`), needs a field after it (`step_empty`), and a form has at most
169
- ten (`step_limit`). The visitor sees one page at a time with Back and Next, and the form posts
170
- once, at the end, as one submission whose body never carries a step key. Without JavaScript every
171
- page shows at once, as one long form. Map a source form's `fieldset` pages, or a "Next" button that
172
- only reveals the next group, to `step` fields; keep one `required` per real question, not per page.
173
-
174
- ### 3. MCP versus dashboard
175
-
176
- | Operation | Where |
177
- |---|---|
178
- | List, read, create, update, delete forms | MCP: `list_forms`, `get_form`, `create_form`, `update_form`, `delete_form` |
179
- | Read and delete submissions | MCP: `list_form_submissions`, `delete_form_submission` |
180
- | Publish | MCP: `publish_form`. Unpublish: dashboard Forms tab |
181
- | Mark a derived form as not a form (a search box, a widget), or restore it | MCP: `update_form { ignored: true }` / `{ ignored: false }` |
182
- | Integrations (Google Sheets, Slack, webhook, email, HubSpot, Salesforce) | Dashboard |
183
- | Turnstile on/off, honeypot field name, notification emails | MCP: `create_form` / `update_form` (`turnstileEnabled`, `honeypotField`, `notifyEmails`). The Turnstile secret key: dashboard only; sending `turnstileSecretKey` through the API is refused with 403 `FORM_SECRET_DASHBOARD_ONLY` |
184
-
185
- `update_form` with `fields` replaces the whole array: send every field you want to keep.
186
-
187
- Setup imports two kinds of form already ignored, each kept as a restorable row: the site's own
188
- sign-in, register and password forms, and a shop's add-to-basket, buy-now or checkout form (it
189
- posts to the cart and asks only a quantity or a variant). Both stay the site's code. An imported
190
- form is named for what it is for: its `aria-label`, its one legend, a heading inside it or in its
191
- own band, else its purpose ("Newsletter signup", "Contact"). Rename it with `update_form` if the
192
- site calls it something else.
193
-
194
- **An imported form's words are edited in the form, and written into the built page.** On a new
195
- import, setup binds each part of the built `<form>` to its form row: field labels, placeholders,
196
- help lines, select and radio options, and the submit button's text. Change one with `update_form`
197
- (`fields[].label`, `placeholder`, `helpText`, `options`, or `submitLabel`) and the next content
198
- apply writes it into every built page that draws the form, with no rebuild. The built form
199
- carries the stamps:
200
-
201
- | Stamp | On | Says |
202
- |---|---|---|
203
- | `data-bcms-form-bound="<formId>"` | the `<form>` | Its parts are this form's. Never `data-bcms-form`: that one means the form posts to BetterCMS. |
204
- | `data-bcms-form-part` | a part | `label`, `help`, `option` or `submit`, as `BcmsForm` names them. |
205
- | `data-bcms-form-field="<key>"` | a part or a control | The field it belongs to. |
206
- | `data-bcms-form-value="form:<formId>:<path>"` | a part shown as text | The address written: `fields.<key>.label`, `fields.<key>.helpText`, `fields.<key>.options.<i>`, `submitLabel`. A placeholder is a `data-bcms-props` entry (`form:<formId>:fields.<key>.placeholder`). |
207
-
208
- Only words are written. The lane adds and removes no element and never touches `name`, `id`,
209
- `for`, `action`, `value`, a validation attribute or a hidden input, so the site's own script and
210
- endpoint keep working. An option added to the row has no element until the site's code is
211
- rebuilt, and is reported as not on the page. A fieldset's `<legend>` and an
212
- `<input type="submit">` stay the code's. A form setup ignored (sign-in, basket) and a form
213
- already wired with `data-bcms-form` take no stamp. The submit button belongs to the form: its
214
- text is `submitLabel`, never a page field.
215
-
216
- ### 4. Wiring a self-hosted site
217
-
218
- - **Next**: `getForm('Name')` (or an id) reads the form from `bcms-content.json`; render
219
- `<BcmsForm form={form} />`. It renders the honeypot input under the form's own
220
- `honeypotField` name, reads the `cf-turnstile-response` token when a Turnstile widget is on the
221
- page, and submits through the SDK `submitForm`.
222
- - **Astro**: `BcmsForm.astro` renders the same hidden honeypot input and sends
223
- `cf-turnstile-response` when present.
224
- - Neither component checks `status`. Render a form only when `form.status === "published"`.
225
- - The honeypot name is per form. Never hardcode it; read `form.honeypotField`.
226
-
227
- ### 5. Per-rung facts
228
-
229
- | Situation | Answer |
230
- |---|---|
231
- | `FORMS_WORKER_URL` set but blank on the API | 503 `FORMS_WORKER_NOT_CONFIGURED`. A blank never falls back to the default worker |
232
- | Worker without `FORMS_SHARED_SECRET` | 503 `FORMS_WORKER_SECRET_UNSET` on data routes |
233
- | No Turnstile secret for the form or env | Token not verified; the submission continues |
234
- | Rate-limit store unavailable | Limits allow the request |
235
- | Management delete of a submission not in that form | 404 `FORM_SUBMISSION_NOT_FOUND` |
236
-
237
- ### 6. Traps
238
-
239
- | Symptom | Cause | Rule |
240
- |---|---|---|
241
- | Every submission 403 | Form still draft | Call `publish_form` |
242
- | Submissions 503 on a dev or preview API | Worker URL blank or worker secret unset | Report the code to the owner; do not change the site |
243
- | Lead stored but no contact, integration or notification | Classified as spam (honeypot filled); side effects run for inbox leads only | Keep the honeypot input hidden and empty |
244
- | Submission 400 `FORM_FIELD_INVALID` rule `scalar` | A field value sent as an object | Send plain values; checkboxes as a flat list |
245
- | Webhook integration never fires | Target is not a public http(s) host | Use a public https endpoint |
1
+ ---
2
+ title: "Forms and interactions"
3
+ description: "Attaching, publishing and verifying forms, and interactive sections: menus, accordions, modals and consent."
4
+ ---
5
+
6
+ # Forms and interactive components
7
+
8
+ **Outcome.** Interactive sections keep working after componentization, and editors change the
9
+ intended content without touching unsafe implementation controls.
10
+
11
+ Doctrine: `new_form` prompt, `CM-72`. Component props and slots: [components.md](components.md)
12
+ (its §1 shows where a `form` prop actually renders).
13
+
14
+ ## Contents
15
+
16
+ - [Inventory behaviour](#inventory-behaviour) · [Pattern contracts](#pattern-contracts)
17
+ - [Forms: the rules](#forms-the-rules) · [Editor UX](#editor-ux) · [Acceptance](#acceptance)
18
+ - Forms as code: [pipeline](#1-pipeline) · [field types](#2-field-types-and-server-checks) ·
19
+ [MCP versus dashboard](#3-mcp-versus-dashboard) · [self-hosted wiring](#4-wiring-a-self-hosted-site) ·
20
+ [per-rung facts](#5-per-rung-facts) · [traps](#6-traps)
21
+
22
+ ## Inventory behaviour
23
+
24
+ For each interaction record its markup, state management, dependencies, events, destinations,
25
+ responsive behaviour, accessibility semantics and persistence. Then sort what it exposes:
26
+
27
+ - **Content-configurable:** labels, headings, body, images, links, items, variants.
28
+ - **Behaviour-configurable:** a closed choice such as initial open state, autoplay, modal
29
+ size, display frequency, or which form.
30
+ - **Code-owned:** search algorithms, authentication, payments, booking, complex validation,
31
+ server actions, experiments.
32
+
33
+ Code-owned behaviour never becomes a free text field, and never a promise that editors can
34
+ change it safely.
35
+
36
+ ## Pattern contracts
37
+
38
+ | Pattern | Editor model | Runtime checks |
39
+ |---|---|---|
40
+ | Navigation / mobile menu | global layout component; repeatable link groups; logo; explicit CTA | keyboard and touch, focus return, active route, breakpoint, skip link |
41
+ | Accordion / FAQ | one section with a repeatable Question + narrowed rich-text Answer | button trigger, `aria-expanded`, panel association, focus, single or multi open |
42
+ | Tabs | repeatable label + panel, or a nested component per panel when layouts differ | stable ids, arrow-key navigation, selected state, responsive fallback |
43
+ | Modal / dialog | trigger + modal content in a slot or section; size as a variant | focus trap, Escape, focus return, scroll lock, accessible name, reduced motion |
44
+ | Pop-up / promo bar | sitewide or page component; message, CTA, variant, closed display rules | frequency and dismiss persistence, mobile layout, consent |
45
+ | Carousel | repeatable items or nested slides; limited motion controls | pause, keyboard, announcements, reduced motion, no hidden content loss |
46
+ | Cookie consent | global component with policy links and category labels | consent storage, script gating, change-choice path; legal settings need owner review |
47
+ | Search / filter | labels and options editable; query and index logic code-owned | empty, loading and error states, URL state, keyboard, result accuracy |
48
+
49
+ For a complex one-off component, keep the working code and expose only an intentional prop
50
+ contract. A nested child is edited through a `slot` prop or its own instance; the edit must
51
+ address the child instance, never the parent definition. A `slot` holds one nested
52
+ component, or a list of them with `config.multiple`; a fixed set of different children is the
53
+ section's ordered children. A write that would nest a component inside itself is refused
54
+ (`component_cycle`).
55
+
56
+ ## Forms: the rules
57
+
58
+ Detect each form's fields, labels, types, required states, validation, consent copy, spam
59
+ controls, endpoint, uploads, confirmation and error states, notifications and integrations.
60
+
61
+ - **A form is a Form document, and a section references it.** Create it as a draft
62
+ (`create_form`) and place it with a `form` block `{ formId }` inside a section. To make WHICH
63
+ form editable per placement, declare a `form` prop on the component targeting that block's
64
+ `props`. The prop stores `{ formId }` and nothing else: never re-model the questions as
65
+ component props, or the same fields live in two places and drift at the first edit.
66
+ - **Draft until published.** A draft form renders nothing live and every public submission is
67
+ refused with 403. Publish it with `publish_form` (it needs the publish permission) only after
68
+ its destination and consent behaviour are tested.
69
+ - **Spam controls** are per form: Turnstile on or off and a honeypot field name. The Turnstile
70
+ secret key is dashboard-only.
71
+ - **Self-hosted renderers** render only forms whose `status` is `"published"` and post to
72
+ `${apiUrl}/api/v1/forms/public/:formId/submissions` through the SDK's `submitForm`, never to the
73
+ forms worker directly and never with an API key in the browser.
74
+ - **Integrations** are configured in the dashboard. A submission classified as spam is stored
75
+ and triggers none of them.
76
+
77
+ Classify each form:
78
+
79
+ 1. **Native:** BetterCMS owns fields, validation, submissions and delivery.
80
+ 2. **Connected provider:** BetterCMS owns the presentation or the reference; a provider owns
81
+ delivery. Keep provider ids; the owner sets up the credentials.
82
+ 3. **Custom action:** keep the existing code path until an adapter passes the same tests.
83
+
84
+ A form rendering is not a working integration. Require a test submission that shows the
85
+ expected record (`list_form_submissions`), the notification, the failure state, spam handling
86
+ and consent handling.
87
+
88
+ ## Editor UX
89
+
90
+ Label fields the way editors think: `Question`, `Answer`, `Add FAQ item`; `Open modal`,
91
+ `Modal title`, `Modal body`, `Close label`. CSS selectors, callbacks, endpoint URLs and secret
92
+ keys stay out of editor fields. Expose curated variants and behaviour switches whose effect is
93
+ obvious. An empty optional field should remove its element cleanly.
94
+
95
+ ## Acceptance
96
+
97
+ - Interactions work with keyboard, touch, screen-reader semantics, mobile layouts and reduced
98
+ motion where relevant.
99
+ - Editing a label, body or item changes the intended instance without breaking its state.
100
+ - Nested elements keep their order, ids and edit scope.
101
+ - Every form is `draft`, `ready for verification` or `verified live`; only the last has an
102
+ end-to-end submission receipt.
103
+ - Consent-dependent scripts do not run before consent is given.
104
+
105
+ ## Forms as code
106
+
107
+ The pipeline with the code that enforces each hop.
108
+
109
+ ### 1. Pipeline
110
+
111
+ | # | Hop | Code |
112
+ |---|---|---|
113
+ | 1 | Define: `create_form` / `update_form`, or the dashboard. Postgres `forms` row, `status` defaults to `draft` | `packages/db/src/schema/forms.ts:35` |
114
+ | 2 | Publish: `publish_form`, or the dashboard Forms tab. Both need publish permission | `src/routes/management/forms.ts:276`, `src/routes/admin/forms.ts:529`, `:530` |
115
+ | 3 | Place: a `form` block `{ formId }` inside a section | `packages/types/src/block.ts:253`, `:257` |
116
+ | 4 | Render: hosted renderer, Next `BcmsForm`, or Astro `BcmsForm.astro` | `packages/next/src/form.tsx:74` |
117
+ | 5 | Submit: `POST /api/v1/forms/public/:formId/submissions` with `{ data, "cf-turnstile-response"? }` | `src/routes/delivery/forms-public.ts:48`, `src/lib/validation/schemas/forms.ts:297`, `:298` |
118
+ | 6a | Ingress: rate limit per client IP (10), before anything else | `src/routes/delivery/forms-public.ts:61`, `src/lib/forms/submit-gate.ts:23` |
119
+ | 6b | Ingress: form lookup, 404 when missing; draft form, 403 | `src/routes/delivery/forms-public.ts:65`, `:71` |
120
+ | 6c | Ingress: Turnstile; 403 on a failed token; skipped when not configured | `src/routes/delivery/forms-public.ts:80`, `:89` |
121
+ | 6d | Ingress: field validation, 400 `{ code: "FORM_FIELD_INVALID", errors, issues }` | `src/lib/forms/submit-gate.ts:75` |
122
+ | 6e | Ingress: spam classification (honeypot) | `src/lib/forms/spam.ts:40` |
123
+ | 6f | Ingress: rate limit per workspace (100 per 60 s), one bucket shared with the API-key route | `src/lib/forms/submit-gate.ts:80` |
124
+ | 7 | Store: forms worker writes the submission to D1 (`FORMS_DB`); CRM intents to `crm_intents` | `workers/bcms-forms-worker/src/index.ts:2`, `workers/bcms-forms-worker/migrations/0004_crm_intents.sql:1` |
125
+ | 8 | Inbox side effects (non-spam only): contact upsert, integrations, notification | `src/routes/delivery/forms-public.ts:120`, `:144`, `:158` |
126
+ | 9 | CRM delivery drain: `POST /api/v1/internal/crm/drain` | `src/index.ts:976`, `src/routes/internal/crm.ts:4` |
127
+ | 10 | Read: `list_form_submissions`, dashboard inbox | `packages/mcp/src/tools.ts:1600` |
128
+
129
+ ### 2. Field types and server checks
130
+
131
+ Thirteen input types exist (`src/lib/validation/schemas/forms.ts`), plus `step`, a divider. No file upload.
132
+
133
+ | Type | The server checks |
134
+ |---|---|
135
+ | `email` | Address shape; optional `emailPolicy: "business"` rule |
136
+ | `number` | Numeric; optional `min` / `max` rules |
137
+ | `select`, `radio`, `checkboxes` | Value is one of the listed options; a field saved with an empty options list accepts nothing |
138
+ | `consent` | Must be truthy |
139
+ | `phone` | Digits, spaces, `+ ( ) -`; stricter with `phoneFormat: "e164"` |
140
+ | `text` / `textarea` | At most 1000 / 10000 characters; optional `pattern` rule |
141
+ | `url` | Starts with `http://` or `https://` |
142
+ | `date` | `YYYY-MM-DD` |
143
+ | `checkbox`, `hidden` | Nothing beyond `required` |
144
+ | `step` | Nothing: a divider is never required and never submitted; a value sent under its key is refused with rule `step` |
145
+
146
+ Any field with `required` is checked for presence. Every value must be a plain value or a flat
147
+ list of them; a nested object is refused with rule `scalar`. Each refusal is
148
+ `FORM_FIELD_INVALID` with `issues: [{ field, rule, message }]`. Keys that are not fields are not
149
+ refused.
150
+
151
+ The API-key submit route runs the same validation, spam check and workspace limit, but no per-IP
152
+ limit (`src/routes/delivery/forms.ts:106`).
153
+
154
+ Definition refusals (`create_form` / `update_form`) carry `FORM_DEFINITION_INVALID`: duplicate or
155
+ over-long keys, too many fields or options, and a `showIf` that names a field not on the form.
156
+ That last one is refused on create and kept as sent on update.
157
+
158
+ Rules:
159
+
160
+ - Send only the form's field keys (plus the honeypot and Turnstile token), each as a plain value.
161
+ - Read `issues[].field` and `issues[].rule` to fix a refused submission; retrying unchanged returns
162
+ the same refusal.
163
+
164
+ #### Steps
165
+
166
+ A multi-step form is one form with `step` fields in `fields`: `{ key, type: "step", label }` opens
167
+ a page named `label`, and the fields before the first `step` are page 1. A step takes only `key`
168
+ and `label` (`step_has_input_keys`), needs a field after it (`step_empty`), and a form has at most
169
+ ten (`step_limit`). The visitor sees one page at a time with Back and Next, and the form posts
170
+ once, at the end, as one submission whose body never carries a step key. Without JavaScript every
171
+ page shows at once, as one long form. Map a source form's `fieldset` pages, or a "Next" button that
172
+ only reveals the next group, to `step` fields; keep one `required` per real question, not per page.
173
+
174
+ ### 3. MCP versus dashboard
175
+
176
+ | Operation | Where |
177
+ |---|---|
178
+ | List, read, create, update, delete forms | MCP: `list_forms`, `get_form`, `create_form`, `update_form`, `delete_form` |
179
+ | Read and delete submissions | MCP: `list_form_submissions`, `delete_form_submission` |
180
+ | Publish | MCP: `publish_form`. Unpublish: dashboard Forms tab |
181
+ | Mark a derived form as not a form (a search box, a widget), or restore it | MCP: `update_form { ignored: true }` / `{ ignored: false }` |
182
+ | Integrations (Google Sheets, Slack, webhook, email, HubSpot, Salesforce) | Dashboard |
183
+ | Turnstile on/off, honeypot field name, notification emails | MCP: `create_form` / `update_form` (`turnstileEnabled`, `honeypotField`, `notifyEmails`). The Turnstile secret key: dashboard only; sending `turnstileSecretKey` through the API is refused with 403 `FORM_SECRET_DASHBOARD_ONLY` |
184
+
185
+ `update_form` with `fields` replaces the whole array: send every field you want to keep.
186
+
187
+ Setup imports two kinds of form already ignored, each kept as a restorable row: the site's own
188
+ sign-in, register and password forms, and a shop's add-to-basket, buy-now or checkout form (it
189
+ posts to the cart and asks only a quantity or a variant). Both stay the site's code. An imported
190
+ form is named for what it is for: its `aria-label`, its one legend, a heading inside it or in its
191
+ own band, else its purpose ("Newsletter signup", "Contact"). Rename it with `update_form` if the
192
+ site calls it something else.
193
+
194
+ **An imported form's words are edited in the form, and written into the built page.** On a new
195
+ import, setup binds each part of the built `<form>` to its form row: field labels, placeholders,
196
+ help lines, select and radio options, and the submit button's text. Change one with `update_form`
197
+ (`fields[].label`, `placeholder`, `helpText`, `options`, or `submitLabel`) and the next content
198
+ apply writes it into every built page that draws the form, with no rebuild. The built form
199
+ carries the stamps:
200
+
201
+ | Stamp | On | Says |
202
+ |---|---|---|
203
+ | `data-bcms-form-bound="<formId>"` | the `<form>` | Its parts are this form's. Never `data-bcms-form`: that one means the form posts to BetterCMS. |
204
+ | `data-bcms-form-part` | a part | `label`, `help`, `option` or `submit`, as `BcmsForm` names them. |
205
+ | `data-bcms-form-field="<key>"` | a part or a control | The field it belongs to. |
206
+ | `data-bcms-form-value="form:<formId>:<path>"` | a part shown as text | The address written: `fields.<key>.label`, `fields.<key>.helpText`, `fields.<key>.options.<i>`, `submitLabel`. A placeholder is a `data-bcms-props` entry (`form:<formId>:fields.<key>.placeholder`). |
207
+
208
+ Only words are written. The lane adds and removes no element and never touches `name`, `id`,
209
+ `for`, `action`, `value`, a validation attribute or a hidden input, so the site's own script and
210
+ endpoint keep working. An option added to the row has no element until the site's code is
211
+ rebuilt, and is reported as not on the page. A fieldset's `<legend>` and an
212
+ `<input type="submit">` stay the code's. A form setup ignored (sign-in, basket) and a form
213
+ already wired with `data-bcms-form` take no stamp. The submit button belongs to the form: its
214
+ text is `submitLabel`, never a page field.
215
+
216
+ ### 4. Wiring a self-hosted site
217
+
218
+ - **Next**: `getForm('Name')` (or an id) reads the form from `bcms-content.json`; render
219
+ `<BcmsForm form={form} />`. It renders the honeypot input under the form's own
220
+ `honeypotField` name, reads the `cf-turnstile-response` token when a Turnstile widget is on the
221
+ page, and submits through the SDK `submitForm`.
222
+ - **Astro**: `BcmsForm.astro` renders the same hidden honeypot input and sends
223
+ `cf-turnstile-response` when present.
224
+ - Neither component checks `status`. Render a form only when `form.status === "published"`.
225
+ - The honeypot name is per form. Never hardcode it; read `form.honeypotField`.
226
+
227
+ ### 5. Per-rung facts
228
+
229
+ | Situation | Answer |
230
+ |---|---|
231
+ | `FORMS_WORKER_URL` set but blank on the API | 503 `FORMS_WORKER_NOT_CONFIGURED`. A blank never falls back to the default worker |
232
+ | Worker without `FORMS_SHARED_SECRET` | 503 `FORMS_WORKER_SECRET_UNSET` on data routes |
233
+ | No Turnstile secret for the form or env | Token not verified; the submission continues |
234
+ | Rate-limit store unavailable | Limits allow the request |
235
+ | Management delete of a submission not in that form | 404 `FORM_SUBMISSION_NOT_FOUND` |
236
+
237
+ ### 6. Traps
238
+
239
+ | Symptom | Cause | Rule |
240
+ |---|---|---|
241
+ | Every submission 403 | Form still draft | Call `publish_form` |
242
+ | Submissions 503 on a dev or preview API | Worker URL blank or worker secret unset | Report the code to the owner; do not change the site |
243
+ | Lead stored but no contact, integration or notification | Classified as spam (honeypot filled); side effects run for inbox leads only | Keep the honeypot input hidden and empty |
244
+ | Submission 400 `FORM_FIELD_INVALID` rule `scalar` | A field value sent as an object | Send plain values; checkboxes as a flat list |
245
+ | Webhook integration never fires | Target is not a public http(s) host | Use a public https endpoint |