@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.
- package/README.md +86 -86
- package/SKILL.md +160 -160
- package/dist/index.js +38 -9
- package/dist/index.js.map +1 -1
- package/dist/kit-catalog.json +1509 -1509
- package/dist/skills/bettercms/SKILL.md +206 -206
- package/dist/skills/bettercms/references/accessibility.md +171 -171
- package/dist/skills/bettercms/references/animation.md +296 -296
- package/dist/skills/bettercms/references/astro.md +148 -148
- package/dist/skills/bettercms/references/bindings.md +366 -366
- package/dist/skills/bettercms/references/componentize.md +185 -185
- package/dist/skills/bettercms/references/components.md +415 -415
- package/dist/skills/bettercms/references/content-experiments.md +62 -62
- package/dist/skills/bettercms/references/content-modeling.md +382 -382
- package/dist/skills/bettercms/references/detection.md +465 -465
- package/dist/skills/bettercms/references/dock-input.md +121 -121
- package/dist/skills/bettercms/references/editor-chrome.md +92 -92
- package/dist/skills/bettercms/references/external-content.md +78 -78
- package/dist/skills/bettercms/references/finish-site.md +377 -377
- package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
- package/dist/skills/bettercms/references/frameworks.md +159 -159
- package/dist/skills/bettercms/references/getting-started.md +156 -156
- package/dist/skills/bettercms/references/integrations.md +364 -364
- package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
- package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
- package/dist/skills/bettercms/references/localization.md +85 -85
- package/dist/skills/bettercms/references/media-and-brand.md +181 -181
- package/dist/skills/bettercms/references/next.md +189 -189
- package/dist/skills/bettercms/references/other-frameworks.md +85 -85
- package/dist/skills/bettercms/references/portable-text.md +213 -213
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
- package/dist/skills/bettercms/references/publishing.md +289 -289
- package/dist/skills/bettercms/references/seo.md +357 -357
- package/dist/skills/bettercms/references/shopify-headless.md +52 -52
- package/dist/skills/bettercms/references/site-blueprints.md +895 -895
- package/dist/skills/bettercms/references/site-review.md +74 -74
- package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
- package/dist/skills/bettercms/references/worked-examples.md +885 -885
- package/dist/skills/bettercms/references/write-limits.md +217 -217
- 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 |
|