feat(forms): forms bundle — headless form pages, field elements, a Forms module, and a standalone form element #322
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#322
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
A
formsfeature bundle: forms are headless pages built in a Forms module out of field elements, any page picks one with aformelement, 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: aheadless: truepage-type key. The shape is TYPO3's form builder on deckle's own editor.Current behavior
deckle ships a
contact-formelement 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 withchildren,elementsallowlists,fieldsanddefaults; editor modules listing pages of a type; element placement by page type (allowedAt); thepageparam type restricted bypageType; child-to-parent data through the page store, ascards/carddo. What is missing: page-typedefaultsaccept scalars and string lists only, so thebuildfront-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
headlesspage-type key (engine)editor/pagetypes/<name>.yamlacceptsheadless: true(default false). A page created with such a type gets the front matterbuild: {render: never, list: never}written at creation, beside the type'sdefaults. Hugo then builds the page and writes no output for it, and no page collection lists it;site.GetPagestill finds it and its.Contentrenders.harness checkwarns on a page of a headless type whose front matter lacks thebuildkey (hand-written content), naming the file;harness sidecar/migratekeep the key canonical like every other page-type key. A headless type is otherwise an ordinary type:children,elements,fields,defaults, modules all apply.The bundle
scaffold/features/formsallow: [{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.forms: label "Forms", singleton,headless: true,children: [form],hideChildPages: true,defaults: {hidden: true}so the theme's navigation never lists it even by accident.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.editor/modules/forms.yaml: pageTypes[form], a list view with columns[title, status], search on title,edit: [properties, content]. German language file.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, plusreplyTo(bool, default false, label "Replies go to this address").form-field-phone: as text, withrequireddefault false.form-field-textarea: as text.form-field-select: as text, plusoptions(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 withallow: [bold, italic, link](the sentence beside the box, like deckle-hosted's contact form, so it can link the privacy page), always required, norequiredparam; empty body renders a default sentence from i18n.form-text: body markdown, a static explanation block between fields; contributes no field.ce-form__fieldmarkup (label, control, help text,requiredattribute,maxlengthmatching the caps below,autocompletefor email and phone) with the input namef-<uid>(the element's uid; hand-written content without a uid usesf-plus the element's ordinal path fromce/path.htmlwith/written as-), honoursdisabledthroughce/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.partials/forms/spec.html: takes the form page, renders its.Contentto collect, and returns the field spec: a slice of maps with exactly these keys, in document order:name,type(one oftext,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 asfieldsbesideform(the page's canonical pretty URL),title,langandv: 1, serialised withjsonify.partials/forms/render.html: takes a dict withform(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:targetreveal 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 honeypotwebsitefield hidden off-screen last, and the submit button with the form's label. Caps mirrored asmaxlength: text 200, email 254, phone 50, textarea 4000.form(label "Form", icon lucide:clipboard-list, category Forms,allowedIn: [page]): paramsform(page,pageType: form, required) andaction(text, required, the URL the form posts to). Its shortcode calls the render partial with the hidden fieldpage(the placing page's relative permalink) and the element's uid askey. 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.contact-formelement is untouched.Caps (shared verbatim with deckle-hosted #154)
Submitted values are the consumer's to validate; the bundle mirrors them in the markup:
text200 runes,email254,phone50,textarea4000,selectmust equal one option,checkboxandconsentareyeswhen checked and absent otherwise; required means present and non-blank, forcheckboxandconsentthe valueyes. A form has at most 40 fields; the render partial renders an HTML comment and no form beyond that.Decisions
headless: truepage-type key, not a hugo.toml cascade and not map-valueddefaults— the editor must know the page has no address to disable View, and a fixed, named behaviour is easier to check than arbitrary maps.<key>-sent/<key>-errorand the honeypotwebsiteare 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
internal/pagetype): theheadlesskey, its validation, canonical form, migration; page creation stamping thebuildmap; the tree payload'spermalinkfor headless pages (internal/pagetree);harness check's warning.PageEditorChrome,ViewButton.unavailable) andpermalinkForPage— 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 likescaffold/features/news/.scaffold/features/newsfor a bundle with page types and a module;shortcodes/cards.htmlandpartials/ce/card.htmlfor the store push between child and parent; deckle-hosted'sfeatures/bundles/embedded/contact-form/layouts/shortcodes/hosted-contact-form.htmlfor the form markup, result reveal and honeypot to mirror.Acceptance criteria
headless: trueloads; a page created with it carriesbuild: {render: never, list: never};hugobuilds the site without an output file for it;site.GetPageon its path returns the page and its content renders from another page's template.pageparam with its type; publish succeeds with it.harness checkwarns on a page of a headless type that lacks thebuildkey;harness migratewrites the key canonically; the key is rejected with a load error when it is not a bool.harness add formson 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.formelement renders a working form without JavaScript: labels, controls withf-<uid>names, required and maxlength attributes, the honeypot last, the submit button, the hidden result paragraphs; it posts to the action URL with thepagehidden field first.forms/spec.htmlreturns the slice described, in document order, without duplicates when the form is placed on two pages or rendered twice, with disabled fields absent, withoptionsonly on selects andreplyToonly on a flagged email field.formelement whose form page is missing renders a comment.harness check, has a German language file, and the scaffold's canonical tests pass;harness remove formsleaves the site as it was apart from content the editor created.headlessand 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.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
.Contentof abuild: {render: never, list: never}page when another page's template asks for it, andsite.GetPagefinds it; the Hugo docs describe headless bundles this way..Contentlands on that page's store and is readable right after, in document order.jsonifyof the returned slice is stable (Go sorts map keys); the hosted consumer relies on it.buildin this Hugo version (_buildis the old spelling).Out of scope
contact-formelement.defaultsin general.Blocked by
None - can start immediately