feat(forms): forms bundle — headless form pages, field elements, a Forms module, and a standalone form element #322

Closed
opened 2026-10-20 16:52:20 +02:00 by dominik.polakovics · 0 comments

Summary

A forms feature bundle: forms are headless pages built in a Forms module out of field elements, any page picks one with a form element, and a partial renders a form page and returns its field spec, so a consumer such as deckle-hosted can sign and validate it. One engine addition carries it: a headless: true page-type key. The shape is TYPO3's form builder on deckle's own editor.

Current behavior

deckle ships a contact-form element with fixed fields that posts to a developer-configured URL. There is no way for an editor to compose a form. The pieces a builder needs exist already: page types with children, elements allowlists, fields and defaults; editor modules listing pages of a type; element placement by page type (allowedAt); the page param type restricted by pageType; child-to-parent data through the page store, as cards/card do. What is missing: page-type defaults accept scalars and string lists only, so the build front-matter map that makes a page headless cannot be stamped, and the editor has no notion of a page without an address beyond an untranslated page (the View button is disabled when a tree node carries no permalink).

Design and reasons: deckle-hosted issue #73 (the research), its recommendation comment of 2026-10-20 and the addendum on headless pages; the hosted half is deckle-hosted #154, which consumes the spec this bundle produces.

Desired behavior

The headless page-type key (engine)

  • editor/pagetypes/<name>.yaml accepts headless: true (default false). A page created with such a type gets the front matter build: {render: never, list: never} written at creation, beside the type's defaults. Hugo then builds the page and writes no output for it, and no page collection lists it; site.GetPage still finds it and its .Content renders.
  • A tree node of a page whose type is headless carries no permalink, so the View button is disabled (reason text like the untranslated page's: "this page has no address") and the preview pane keeps showing what it showed. The page stays in the tree, in modules, in the page picker and in publish.
  • harness check warns on a page of a headless type whose front matter lacks the build key (hand-written content), naming the file; harness sidecar/migrate keep the key canonical like every other page-type key. A headless type is otherwise an ordinary type: children, elements, fields, defaults, modules all apply.
  • Documented in AGENTS.md's page-type schema and in docs/architecture.md where tree nodes and permalinks are described.

The bundle scaffold/features/forms

  • Manifest: label "Forms", one-sentence description, version 1, allow: [{pageType: forms, childOf: home}], hints telling the editor to create the Forms page in the tree, build forms in the Forms module and place the Form element on a page.
  • Page type forms: label "Forms", singleton, headless: true, children: [form], hideChildPages: true, defaults: {hidden: true} so the theme's navigation never lists it even by accident.
  • Page type form: label "Form", headless: true, elements: the eight elements below, fields: intro (textarea, translate), submitLabel (text, default "Send"), successMessage (text, default "Thank you, your message has been sent."). The title is the form's name. German language files for both types, as the news bundle has.
  • Module editor/modules/forms.yaml: pageTypes [form], a list view with columns [title, status], search on title, edit: [properties, content]. German language file.
  • Field elements, group content, category "Form fields", placement: {allowedIn: [page], allowedAt: [form]}, each with a German language file:
    • form-field-text: label (text, required), required (bool, default true), help (text).
    • form-field-email: as text, plus replyTo (bool, default false, label "Replies go to this address").
    • form-field-phone: as text, with required default false.
    • form-field-textarea: as text.
    • form-field-select: as text, plus options (textarea, required, one option per line; blank lines ignored, duplicates a render-time warning in the HTML comment, no error).
    • form-field-checkbox: label (text, required; the sentence beside the box), required (bool, default false).
    • form-field-consent: body policy rte with allow: [bold, italic, link] (the sentence beside the box, like deckle-hosted's contact form, so it can link the privacy page), always required, no required param; empty body renders a default sentence from i18n.
    • form-text: body markdown, a static explanation block between fields; contributes no field.
  • Every field shortcode renders the theme's ce-form__field markup (label, control, help text, required attribute, maxlength matching the caps below, autocomplete for email and phone) with the input name f-<uid> (the element's uid; hand-written content without a uid uses f- plus the element's ordinal path from ce/path.html with / written as -), honours disabled through ce/disabled.html, and pushes its spec entry onto the page's store under one key, in document order and without duplicates when the content is rendered more than once (guard on the name). The store convention is documented in docs/features.md.
  • Partial partials/forms/spec.html: takes the form page, renders its .Content to collect, and returns the field spec: a slice of maps with exactly these keys, in document order: name, type (one of text, email, phone, textarea, select, checkbox, consent), label (as rendered, trimmed, at most 200 runes), required (bool, always present, always true for consent), options (select only, 1 to 50 distinct strings of 1 to 100 runes), replyTo (email only, only when true). Disabled fields are absent. This is the contract deckle-hosted #154 signs; its manifest wraps this slice as fields beside form (the page's canonical pretty URL), title, lang and v: 1, serialised with jsonify.
  • Partial partials/forms/render.html: takes a dict with form (the form page), action (string), hidden (a slice of {name, value} dicts rendered first inside the form, in order, may be empty), key (the placing element's uid or fallback key, for ids), and renders: the hidden result paragraphs <key>-sent (the form's success message) and <key>-error (a fixed i18n error text) with the :target reveal style, as the hosted contact form does; the <form method="post" action="…"> with the hidden fields, the intro, the form page's rendered content (the fields), the honeypot website field hidden off-screen last, and the submit button with the form's label. Caps mirrored as maxlength: text 200, email 254, phone 50, textarea 4000.
  • Element form (label "Form", icon lucide:clipboard-list, category Forms, allowedIn: [page]): params form (page, pageType: form, required) and action (text, required, the URL the form posts to). Its shortcode calls the render partial with the hidden field page (the placing page's relative permalink) and the element's uid as key. A template comment says the fields and the result fragments are the contract a backend must honour and points at the spec partial. Hand-written content with a form that no longer exists renders an HTML comment, not a broken form. German language file.
  • deckle's existing contact-form element is untouched.
  • Docs: docs/features.md gets a "The forms bundle" section (what it ships, the store convention, the two partials and the spec contract verbatim, the standalone element, how a consumer adds its own element); AGENTS.md "Feature bundles" lists it; the add-element skill's references are unchanged unless they enumerate bundles.

Caps (shared verbatim with deckle-hosted #154)

Submitted values are the consumer's to validate; the bundle mirrors them in the markup: text 200 runes, email 254, phone 50, textarea 4000, select must equal one option, checkbox and consent are yes when checked and absent otherwise; required means present and non-blank, for checkbox and consent the value yes. A form has at most 40 fields; the render partial renders an HTML comment and no form beyond that.

Decisions

  • Forms are pages, fields are elements — every editor affordance (module, translations, undo, history, reorder, placement) comes for free, and a form is reused by any number of pages. Rejected: fields as item rows of the placing element (no reuse), a form definition in a control plane's dashboard (not usable standalone, labels outside deckle's translations), a new sidecar-editing module kind (largest change).
  • A headless: true page-type key, not a hugo.toml cascade and not map-valued defaults — the editor must know the page has no address to disable View, and a fixed, named behaviour is easier to check than arbitrary maps.
  • Input names derive from the element's uid — a renamed label keeps the field's identity in the consumer's mail and in any stored spec.
  • The form page owns intro, submit label and success message — reuse stays coherent and the texts translate with the page; the placing element only picks.
  • The spec partial returns data and the render partial renders — a consumer (deckle-hosted) needs the spec before rendering to sign it and inject hidden fields; two partials keep both consumers to the same markup.
  • The store push is guarded against duplicates and keeps document order — Hugo may render a page's content more than once, and the spec must be stable.
  • Consent is its own element with an rte body — the sentence usually links the privacy page, and a text param cannot carry a link (the hosted contact form's reasoning).
  • Caps are mirrored, not owned, by the bundle — the backend that receives the form owns them; the markup only keeps honest browsers inside them.
  • Result fragments <key>-sent/<key>-error and the honeypot website are part of the rendered contract — they are what deckle-hosted's backend already redirects to and discards on, so any backend can follow the same convention without JavaScript.

Key interfaces

  • Page-type sidecar schema (internal/pagetype): the headless key, its validation, canonical form, migration; page creation stamping the build map; the tree payload's permalink for headless pages (internal/pagetree); harness check's warning.
  • The editor's View button (PageEditorChrome, ViewButton.unavailable) and permalinkForPage — should already follow an empty permalink; verify the module entry editor's View does too.
  • scaffold/features/forms/ — manifest, page types, module, elements, shortcodes, partials, i18n, language files, laid out like scaffold/features/news/.
  • The embedded-bundle validation test and the scaffold canonical tests, which must cover the new bundle.
  • Starting hints: scaffold/features/news for a bundle with page types and a module; shortcodes/cards.html and partials/ce/card.html for the store push between child and parent; deckle-hosted's features/bundles/embedded/contact-form/layouts/shortcodes/hosted-contact-form.html for the form markup, result reveal and honeypot to mirror.

Acceptance criteria

  • A page type with headless: true loads; a page created with it carries build: {render: never, list: never}; hugo builds the site without an output file for it; site.GetPage on its path returns the page and its content renders from another page's template.
  • A headless page's tree node has no permalink; the View button is disabled with a reason on it in the Page module and in a module's entry editor; it is still listed in the tree, in its module and in the page picker of a page param with its type; publish succeeds with it.
  • harness check warns on a page of a headless type that lacks the build key; harness migrate writes the key canonically; the key is rejected with a load error when it is not a bool.
  • harness add forms on a scaffolded site installs the bundle, the Forms page can be created under home, the Forms module appears in the rail, "New form" creates a form, and the eight field elements are the only insertable elements on a form page.
  • The form element renders a working form without JavaScript: labels, controls with f-<uid> names, required and maxlength attributes, the honeypot last, the submit button, the hidden result paragraphs; it posts to the action URL with the page hidden field first.
  • forms/spec.html returns the slice described, in document order, without duplicates when the form is placed on two pages or rendered twice, with disabled fields absent, with options only on selects and replyTo only on a flagged email field.
  • A form with more than 40 fields renders a comment and no form; a form element whose form page is missing renders a comment.
  • Every sidecar passes harness check, has a German language file, and the scaffold's canonical tests pass; harness remove forms leaves the site as it was apart from content the editor created.
  • docs/features.md documents the bundle, the store convention and the spec contract; AGENTS.md documents headless and lists the bundle; docs/architecture.md mentions a headless page's missing permalink where tree nodes are described.

How to verify

  • go test ./..., go vet ./..., the frontend tests and the repository's linters pass.
  • A Hugo build test in the engine's test fixtures (the scaffold tests run a real Hugo where they can): a site with the bundle, one form with every field type, one page placing it twice; assert the rendered markup, the absence of the form page's output, and the spec the partial returns, as a golden file.
  • Manually, with harness serve: build a form in the Forms module, translate it, disable a field, place the element, open the page in the preview, confirm the View button is disabled on the form page and the preview iframe stays put.

Assumptions to check

  • Hugo 0.167.0 renders .Content of a build: {render: never, list: never} page when another page's template asks for it, and site.GetPage finds it; the Hugo docs describe headless bundles this way.
  • The store push from a shortcode rendered through another page's .Content lands on that page's store and is readable right after, in document order.
  • jsonify of the returned slice is stable (Go sorts map keys); the hosted consumer relies on it.
  • The tree payload already carries an empty permalink for an untranslated page and the View button's disabled path works from that; if the module entry editor's View is wired differently, it needs the same treatment.
  • The front-matter key is build in this Hugo version (_build is the old spelling).

Out of scope

  • Any backend: validation, mail, signing (deckle-hosted #154 does that for hosted sites).
  • Number, date, file upload, radio or multi-select fields.
  • Multi-step forms, conditions, JavaScript enhancement, inline validation messages.
  • Changing deckle's existing contact-form element.
  • Map-valued page-type defaults in general.

Blocked by

None - can start immediately

## Summary A `forms` feature bundle: forms are headless pages built in a Forms module out of field elements, any page picks one with a `form` element, and a partial renders a form page and returns its field spec, so a consumer such as deckle-hosted can sign and validate it. One engine addition carries it: a `headless: true` page-type key. The shape is TYPO3's form builder on deckle's own editor. ## Current behavior deckle ships a `contact-form` element with fixed fields that posts to a developer-configured URL. There is no way for an editor to compose a form. The pieces a builder needs exist already: page types with `children`, `elements` allowlists, `fields` and `defaults`; editor modules listing pages of a type; element placement by page type (`allowedAt`); the `page` param type restricted by `pageType`; child-to-parent data through the page store, as `cards`/`card` do. What is missing: page-type `defaults` accept scalars and string lists only, so the `build` front-matter map that makes a page headless cannot be stamped, and the editor has no notion of a page without an address beyond an untranslated page (the View button is disabled when a tree node carries no permalink). Design and reasons: deckle-hosted issue #73 (the research), its recommendation comment of 2026-10-20 and the addendum on headless pages; the hosted half is deckle-hosted #154, which consumes the spec this bundle produces. ## Desired behavior ### The `headless` page-type key (engine) - `editor/pagetypes/<name>.yaml` accepts `headless: true` (default false). A page created with such a type gets the front matter `build: {render: never, list: never}` written at creation, beside the type's `defaults`. Hugo then builds the page and writes no output for it, and no page collection lists it; `site.GetPage` still finds it and its `.Content` renders. - A tree node of a page whose type is headless carries no permalink, so the View button is disabled (reason text like the untranslated page's: "this page has no address") and the preview pane keeps showing what it showed. The page stays in the tree, in modules, in the page picker and in publish. - `harness check` warns on a page of a headless type whose front matter lacks the `build` key (hand-written content), naming the file; `harness sidecar`/`migrate` keep the key canonical like every other page-type key. A headless type is otherwise an ordinary type: `children`, `elements`, `fields`, `defaults`, modules all apply. - Documented in AGENTS.md's page-type schema and in docs/architecture.md where tree nodes and permalinks are described. ### The bundle `scaffold/features/forms` - Manifest: label "Forms", one-sentence description, version 1, `allow: [{pageType: forms, childOf: home}]`, hints telling the editor to create the Forms page in the tree, build forms in the Forms module and place the Form element on a page. - Page type `forms`: label "Forms", singleton, `headless: true`, `children: [form]`, `hideChildPages: true`, `defaults: {hidden: true}` so the theme's navigation never lists it even by accident. - Page type `form`: label "Form", `headless: true`, `elements:` the eight elements below, fields: `intro` (textarea, translate), `submitLabel` (text, default "Send"), `successMessage` (text, default "Thank you, your message has been sent."). The title is the form's name. German language files for both types, as the news bundle has. - Module `editor/modules/forms.yaml`: pageTypes `[form]`, a list view with columns `[title, status]`, search on title, `edit: [properties, content]`. German language file. - Field elements, group content, category "Form fields", `placement: {allowedIn: [page], allowedAt: [form]}`, each with a German language file: - `form-field-text`: `label` (text, required), `required` (bool, default true), `help` (text). - `form-field-email`: as text, plus `replyTo` (bool, default false, label "Replies go to this address"). - `form-field-phone`: as text, with `required` default false. - `form-field-textarea`: as text. - `form-field-select`: as text, plus `options` (textarea, required, one option per line; blank lines ignored, duplicates a render-time warning in the HTML comment, no error). - `form-field-checkbox`: `label` (text, required; the sentence beside the box), `required` (bool, default false). - `form-field-consent`: body policy rte with `allow: [bold, italic, link]` (the sentence beside the box, like deckle-hosted's contact form, so it can link the privacy page), always required, no `required` param; empty body renders a default sentence from i18n. - `form-text`: body markdown, a static explanation block between fields; contributes no field. - Every field shortcode renders the theme's `ce-form__field` markup (label, control, help text, `required` attribute, `maxlength` matching the caps below, `autocomplete` for email and phone) with the input name `f-<uid>` (the element's uid; hand-written content without a uid uses `f-` plus the element's ordinal path from `ce/path.html` with `/` written as `-`), honours `disabled` through `ce/disabled.html`, and pushes its spec entry onto the page's store under one key, in document order and without duplicates when the content is rendered more than once (guard on the name). The store convention is documented in docs/features.md. - Partial `partials/forms/spec.html`: takes the form page, renders its `.Content` to collect, and returns the field spec: a slice of maps with exactly these keys, in document order: `name`, `type` (one of `text`, `email`, `phone`, `textarea`, `select`, `checkbox`, `consent`), `label` (as rendered, trimmed, at most 200 runes), `required` (bool, always present, always true for consent), `options` (select only, 1 to 50 distinct strings of 1 to 100 runes), `replyTo` (email only, only when true). Disabled fields are absent. This is the contract deckle-hosted #154 signs; its manifest wraps this slice as `fields` beside `form` (the page's canonical pretty URL), `title`, `lang` and `v: 1`, serialised with `jsonify`. - Partial `partials/forms/render.html`: takes a dict with `form` (the form page), `action` (string), `hidden` (a slice of `{name, value}` dicts rendered first inside the form, in order, may be empty), `key` (the placing element's uid or fallback key, for ids), and renders: the hidden result paragraphs `<key>-sent` (the form's success message) and `<key>-error` (a fixed i18n error text) with the `:target` reveal style, as the hosted contact form does; the `<form method="post" action="…">` with the hidden fields, the intro, the form page's rendered content (the fields), the honeypot `website` field hidden off-screen last, and the submit button with the form's label. Caps mirrored as `maxlength`: text 200, email 254, phone 50, textarea 4000. - Element `form` (label "Form", icon lucide:clipboard-list, category Forms, `allowedIn: [page]`): params `form` (page, `pageType: form`, required) and `action` (text, required, the URL the form posts to). Its shortcode calls the render partial with the hidden field `page` (the placing page's relative permalink) and the element's uid as `key`. A template comment says the fields and the result fragments are the contract a backend must honour and points at the spec partial. Hand-written content with a form that no longer exists renders an HTML comment, not a broken form. German language file. - deckle's existing `contact-form` element is untouched. - Docs: docs/features.md gets a "The forms bundle" section (what it ships, the store convention, the two partials and the spec contract verbatim, the standalone element, how a consumer adds its own element); AGENTS.md "Feature bundles" lists it; the add-element skill's references are unchanged unless they enumerate bundles. ### Caps (shared verbatim with deckle-hosted #154) Submitted values are the consumer's to validate; the bundle mirrors them in the markup: `text` 200 runes, `email` 254, `phone` 50, `textarea` 4000, `select` must equal one option, `checkbox` and `consent` are `yes` when checked and absent otherwise; required means present and non-blank, for `checkbox` and `consent` the value `yes`. A form has at most 40 fields; the render partial renders an HTML comment and no form beyond that. ## Decisions - Forms are pages, fields are elements — every editor affordance (module, translations, undo, history, reorder, placement) comes for free, and a form is reused by any number of pages. Rejected: fields as item rows of the placing element (no reuse), a form definition in a control plane's dashboard (not usable standalone, labels outside deckle's translations), a new sidecar-editing module kind (largest change). - A `headless: true` page-type key, not a hugo.toml cascade and not map-valued `defaults` — the editor must know the page has no address to disable View, and a fixed, named behaviour is easier to check than arbitrary maps. - Input names derive from the element's uid — a renamed label keeps the field's identity in the consumer's mail and in any stored spec. - The form page owns intro, submit label and success message — reuse stays coherent and the texts translate with the page; the placing element only picks. - The spec partial returns data and the render partial renders — a consumer (deckle-hosted) needs the spec before rendering to sign it and inject hidden fields; two partials keep both consumers to the same markup. - The store push is guarded against duplicates and keeps document order — Hugo may render a page's content more than once, and the spec must be stable. - Consent is its own element with an rte body — the sentence usually links the privacy page, and a text param cannot carry a link (the hosted contact form's reasoning). - Caps are mirrored, not owned, by the bundle — the backend that receives the form owns them; the markup only keeps honest browsers inside them. - Result fragments `<key>-sent`/`<key>-error` and the honeypot `website` are part of the rendered contract — they are what deckle-hosted's backend already redirects to and discards on, so any backend can follow the same convention without JavaScript. ## Key interfaces - Page-type sidecar schema (`internal/pagetype`): the `headless` key, its validation, canonical form, migration; page creation stamping the `build` map; the tree payload's `permalink` for headless pages (`internal/pagetree`); `harness check`'s warning. - The editor's View button (`PageEditorChrome`, `ViewButton.unavailable`) and `permalinkForPage` — should already follow an empty permalink; verify the module entry editor's View does too. - `scaffold/features/forms/` — manifest, page types, module, elements, shortcodes, partials, i18n, language files, laid out like `scaffold/features/news/`. - The embedded-bundle validation test and the scaffold canonical tests, which must cover the new bundle. - Starting hints: `scaffold/features/news` for a bundle with page types and a module; `shortcodes/cards.html` and `partials/ce/card.html` for the store push between child and parent; deckle-hosted's `features/bundles/embedded/contact-form/layouts/shortcodes/hosted-contact-form.html` for the form markup, result reveal and honeypot to mirror. ## Acceptance criteria - [ ] A page type with `headless: true` loads; a page created with it carries `build: {render: never, list: never}`; `hugo` builds the site without an output file for it; `site.GetPage` on its path returns the page and its content renders from another page's template. - [ ] A headless page's tree node has no permalink; the View button is disabled with a reason on it in the Page module and in a module's entry editor; it is still listed in the tree, in its module and in the page picker of a `page` param with its type; publish succeeds with it. - [ ] `harness check` warns on a page of a headless type that lacks the `build` key; `harness migrate` writes the key canonically; the key is rejected with a load error when it is not a bool. - [ ] `harness add forms` on a scaffolded site installs the bundle, the Forms page can be created under home, the Forms module appears in the rail, "New form" creates a form, and the eight field elements are the only insertable elements on a form page. - [ ] The `form` element renders a working form without JavaScript: labels, controls with `f-<uid>` names, required and maxlength attributes, the honeypot last, the submit button, the hidden result paragraphs; it posts to the action URL with the `page` hidden field first. - [ ] `forms/spec.html` returns the slice described, in document order, without duplicates when the form is placed on two pages or rendered twice, with disabled fields absent, with `options` only on selects and `replyTo` only on a flagged email field. - [ ] A form with more than 40 fields renders a comment and no form; a `form` element whose form page is missing renders a comment. - [ ] Every sidecar passes `harness check`, has a German language file, and the scaffold's canonical tests pass; `harness remove forms` leaves the site as it was apart from content the editor created. - [ ] docs/features.md documents the bundle, the store convention and the spec contract; AGENTS.md documents `headless` and lists the bundle; docs/architecture.md mentions a headless page's missing permalink where tree nodes are described. ## How to verify - `go test ./...`, `go vet ./...`, the frontend tests and the repository's linters pass. - A Hugo build test in the engine's test fixtures (the scaffold tests run a real Hugo where they can): a site with the bundle, one form with every field type, one page placing it twice; assert the rendered markup, the absence of the form page's output, and the spec the partial returns, as a golden file. - Manually, with `harness serve`: build a form in the Forms module, translate it, disable a field, place the element, open the page in the preview, confirm the View button is disabled on the form page and the preview iframe stays put. ## Assumptions to check - Hugo 0.167.0 renders `.Content` of a `build: {render: never, list: never}` page when another page's template asks for it, and `site.GetPage` finds it; the Hugo docs describe headless bundles this way. - The store push from a shortcode rendered through another page's `.Content` lands on that page's store and is readable right after, in document order. - `jsonify` of the returned slice is stable (Go sorts map keys); the hosted consumer relies on it. - The tree payload already carries an empty permalink for an untranslated page and the View button's disabled path works from that; if the module entry editor's View is wired differently, it needs the same treatment. - The front-matter key is `build` in this Hugo version (`_build` is the old spelling). ## Out of scope - Any backend: validation, mail, signing (deckle-hosted #154 does that for hosted sites). - Number, date, file upload, radio or multi-select fields. - Multi-step forms, conditions, JavaScript enhancement, inline validation messages. - Changing deckle's existing `contact-form` element. - Map-valued page-type `defaults` in general. ## Blocked by None - can start immediately
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#322
No description provided.