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

Merged
dominik.polakovics merged 1 commit from afk/322 into main 2026-10-20 17:39:33 +02:00

Closes #322

What ships

Engine: the headless page-type key

  • editor/pagetypes/<name>.yaml accepts headless: true. A value that is not a bool is a load error.
  • harness sidecar and harness migrate keep the key canonical. It sits after singleton in the key order.
  • Creation: a page created with a headless type gets build: {list: never, render: never} after the type's defaults. This applies in the tree, in a module and for the home page.
  • Translations: each translation gets the same key, because a translation is a Hugo page of its own.
  • One owner: the rule lives in internal/pagetype/headless.go (HeadlessBuild, CreationChanges, BuildChange, LacksBuild).
  • Writing the map: internal/content can now write a flat map[string]string: a YAML block mapping, a TOML inline table, or a JSON object.
  • Tree node: a headless page keeps its permalink, which is what page params and link pickers store, so it stays pickable. It gets address: "" and headless: true, so View and the preview see no address.
  • harness check: warns, naming the file, on a page or translation of a headless type whose front matter lacks build.
  • Reserved name: a field or default named build on a headless type is a load error.
  • Hugo integration test: proves the issue's assumptions on Hugo 0.167.0, in two languages. There is no output file, the page is in no .Pages, and site.GetPage plus .Content renders it.

Editor

  • On a headless page, View is disabled with the reason "This page has no address: it is never published on its own". This holds in the Page module and in a module's entry editor. The rule lives in ViewButton and reads the server's headless verdict.
  • The preview pane keeps showing what it showed.

Bundle scaffold/features/forms (40 files)

  • The forms and form page types, both headless. forms is a singleton, hidden, with children: [form]. form allows only the eight field elements.
  • The Forms module. German language files for every sidecar.
  • The eight field elements, each a form-field-* or form-text, placeable only on a form page.
  • The form element, with form (a page param, pageType: form) and action.
  • partials/forms/spec.html returns the spec slice in document order, with no duplicates. A store guard on the input name keeps it duplicate-free.
  • partials/forms/render.html draws the form:
    • the hidden fields first;
    • the intro, then the fields named f-<uid>;
    • the off-screen website honeypot, then the submit button;
    • <key>-sent and <key>-error with the :target reveal;
    • maxlength caps of 200, 254, 50 and 4000, and autocomplete on email and phone.
  • These cases render an HTML comment and no form: more than 40 fields, a missing form, an empty action.
  • Theme:
    • The shared rules now have one owner each: ce/uid.html, ce/bool.html, backendlayout/body.html and backendlayout/links.html. ce/attrs.html, ce/disabled.html and backendlayout/render.html call them.
    • The forms-form__* CSS lives in theme.css.
    • The backendlayout/render.html fallback now emits each slot name once. It used to repeat a slot when the same name appeared twice.

Docs and skills:

  • AGENTS.md documents headless under "Page types" and adds the bundle to "Feature bundles".
  • docs/features.md has a new section, "The forms bundle". It covers the store convention, the spec contract and the caps.
  • docs/architecture.md, docs/api.md and docs/harness.md are updated, plus the README's bundle list.
  • The theme README section "Hosted contact form" now covers both forms.
  • Skills updated: add-element, add-page-type, setup-site, edit-content, migrate-site, and redo-theme's references.

Where this differs from the issue

  • Select options are separated by |, not one per line. The engine refuses a line break in any string-shaped param, textarea included (ruleNoLineBreaks). Without an engine change, an editor-saved option list cannot hold newlines. The template splits on both | and line breaks. Blank options are dropped. A duplicate option produces an HTML comment, not an error.
  • "No permalink" is implemented as an empty address. In this codebase permalink is the stored-link form that page params hold, so it has to stay. address is what View and the preview read.
  • submitLabel and successMessage are translate: true, as well as intro. The Decisions say the texts translate with the page.
  • deckle no longer ships a contact-form element, so there was nothing to keep untouched.
  • Field elements carry no ce-frame and no c-<uid> anchor: they are cells of the form grid on a page with no address. When one form is placed twice on a page, render.html prefixes field ids with the placing element's key so ids stay unique.

Verification

  • go build ./..., go vet ./... and gofmt -l . are clean.
  • go test ./... passes with Hugo 0.167.0 on PATH. The Hugo integration tests ran rather than skipping.
  • web: npm run build, tsc --noEmit and npm test pass (132 files, 3361 tests).
  • Hugo build test (internal/theme/forms_test.go):
    • a form with every field type, a disabled field, a hand-written field without a uid, and duplicate select options;
    • one page placing the form twice;
    • a 41-field form and a missing form;
    • checks the markup, that the form pages have no output, and the spec against a golden file (testdata/forms/spec.golden.json).
  • Other tests: harness add forms/check/remove round trips (cmd/harness/forms_test.go, internal/removecmd/forms_test.go), and placement shows exactly the eight field elements on a form page.
  • Checked by hand: on a fresh harness init site, harness add forms then harness check reports ok.
  • Not done: the browser check with harness serve. The behaviour is covered by the vitest tests on the docheader, the entry editor and the preview pane.

🤖 Generated with Claude Code

Closes #322 ## What ships **Engine: the `headless` page-type key** - `editor/pagetypes/<name>.yaml` accepts `headless: true`. A value that is not a bool is a load error. - `harness sidecar` and `harness migrate` keep the key canonical. It sits after `singleton` in the key order. - **Creation:** a page created with a headless type gets `build: {list: never, render: never}` after the type's `defaults`. This applies in the tree, in a module and for the home page. - **Translations:** each translation gets the same key, because a translation is a Hugo page of its own. - **One owner:** the rule lives in `internal/pagetype/headless.go` (`HeadlessBuild`, `CreationChanges`, `BuildChange`, `LacksBuild`). - **Writing the map:** `internal/content` can now write a flat `map[string]string`: a YAML block mapping, a TOML inline table, or a JSON object. - **Tree node:** a headless page keeps its `permalink`, which is what `page` params and link pickers store, so it stays pickable. It gets `address: ""` and `headless: true`, so View and the preview see no address. - **`harness check`:** warns, naming the file, on a page or translation of a headless type whose front matter lacks `build`. - **Reserved name:** a field or default named `build` on a headless type is a load error. - **Hugo integration test:** proves the issue's assumptions on Hugo 0.167.0, in two languages. There is no output file, the page is in no `.Pages`, and `site.GetPage` plus `.Content` renders it. **Editor** - On a headless page, View is disabled with the reason "This page has no address: it is never published on its own". This holds in the Page module and in a module's entry editor. The rule lives in `ViewButton` and reads the server's `headless` verdict. - The preview pane keeps showing what it showed. **Bundle `scaffold/features/forms`** (40 files) - The `forms` and `form` page types, both headless. `forms` is a singleton, hidden, with `children: [form]`. `form` allows only the eight field elements. - The Forms module. German language files for every sidecar. - The eight field elements, each a `form-field-*` or `form-text`, placeable only on a form page. - The `form` element, with `form` (a page param, `pageType: form`) and `action`. - `partials/forms/spec.html` returns the spec slice in document order, with no duplicates. A store guard on the input name keeps it duplicate-free. - `partials/forms/render.html` draws the form: - the hidden fields first; - the intro, then the fields named `f-<uid>`; - the off-screen `website` honeypot, then the submit button; - `<key>-sent` and `<key>-error` with the `:target` reveal; - `maxlength` caps of 200, 254, 50 and 4000, and `autocomplete` on email and phone. - These cases render an HTML comment and no form: more than 40 fields, a missing form, an empty action. - **Theme:** - The shared rules now have one owner each: `ce/uid.html`, `ce/bool.html`, `backendlayout/body.html` and `backendlayout/links.html`. `ce/attrs.html`, `ce/disabled.html` and `backendlayout/render.html` call them. - The `forms-form__*` CSS lives in `theme.css`. - The `backendlayout/render.html` fallback now emits each slot name once. It used to repeat a slot when the same name appeared twice. **Docs and skills:** - AGENTS.md documents `headless` under "Page types" and adds the bundle to "Feature bundles". - `docs/features.md` has a new section, "The forms bundle". It covers the store convention, the spec contract and the caps. - `docs/architecture.md`, `docs/api.md` and `docs/harness.md` are updated, plus the README's bundle list. - The theme README section "Hosted contact form" now covers both forms. - Skills updated: add-element, add-page-type, setup-site, edit-content, migrate-site, and redo-theme's references. ## Where this differs from the issue - **Select options are separated by `|`, not one per line.** The engine refuses a line break in any string-shaped param, textarea included (`ruleNoLineBreaks`). Without an engine change, an editor-saved option list cannot hold newlines. The template splits on both `|` and line breaks. Blank options are dropped. A duplicate option produces an HTML comment, not an error. - **"No permalink"** is implemented as an empty `address`. In this codebase `permalink` is the stored-link form that `page` params hold, so it has to stay. `address` is what View and the preview read. - `submitLabel` and `successMessage` are `translate: true`, as well as `intro`. The Decisions say the texts translate with the page. - deckle no longer ships a `contact-form` element, so there was nothing to keep untouched. - Field elements carry no `ce-frame` and no `c-<uid>` anchor: they are cells of the form grid on a page with no address. When one form is placed twice on a page, `render.html` prefixes field ids with the placing element's key so ids stay unique. ## Verification - `go build ./...`, `go vet ./...` and `gofmt -l .` are clean. - `go test ./...` passes with Hugo 0.167.0 on PATH. The Hugo integration tests ran rather than skipping. - `web`: `npm run build`, `tsc --noEmit` and `npm test` pass (132 files, 3361 tests). - **Hugo build test** (`internal/theme/forms_test.go`): - a form with every field type, a disabled field, a hand-written field without a uid, and duplicate select options; - one page placing the form twice; - a 41-field form and a missing form; - checks the markup, that the form pages have no output, and the spec against a golden file (`testdata/forms/spec.golden.json`). - Other tests: `harness add forms`/`check`/`remove` round trips (`cmd/harness/forms_test.go`, `internal/removecmd/forms_test.go`), and placement shows exactly the eight field elements on a form page. - **Checked by hand:** on a fresh `harness init` site, `harness add forms` then `harness check` reports `ok`. - **Not done:** the browser check with `harness serve`. The behaviour is covered by the vitest tests on the docheader, the entry editor and the preview pane. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(forms): forms bundle with headless form pages, field elements and a Form element
All checks were successful
ci / build-and-test (pull_request) Successful in 4m1s
ci / release (pull_request) Has been skipped
97976b6d08
Engine: page types accept `headless: true`. A page created with such a
type, and each translation of it, gets `build: {list: never, render:
never}`; its tree node keeps its permalink but has no address and
carries `headless: true`; `harness check` warns on a headless page file
lacking the build key. content.Apply writes flat string maps.

Editor: the View button is disabled with "This page has no address" on
a headless page in the Page module and in a module's entry editor.

Bundle scaffold/features/forms: the headless `forms` and `form` page
types, the Forms module, eight field elements, the `form` element, and
the forms/spec.html and forms/render.html partials that return a form's
field spec and draw it. Theme: ce/uid.html, ce/bool.html,
backendlayout/body.html and backendlayout/links.html now own rules the
bundle and the theme share; the forms-form classes live in theme.css.

Docs: AGENTS.md (headless, the bundle), docs/features.md, architecture,
api, harness, the theme README and the affected skills.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS — landable as-is.

Signal relied on: CI aggregate success (ci / build-and-test: web build + vitest, go vet, go test with Hugo 0.167.0 installed so the Hugo integration tests in internal/theme/forms_test.go and headless_test.go ran, go build). gofmt checked locally on the changed Go files: clean. Head is current with main (merge-base = main), no conflict.

Conventions: Conventional Commits title; Closes #322 present; diff matches #322. The divergences are documented and justified: select options are separated by | because ruleNoLineBreaks (internal/element/fieldtype.go:657) refuses line breaks in textarea params, and a headless node keeps its permalink with address: "" because page params store the permalink. The theme refactor (ce/uid.html, ce/bool.html, backendlayout/body.html, backendlayout/links.html) gives each shared rule one owner, as CLAUDE.md requires, and does not restate them. The headless rule has one owner in internal/pagetype/headless.go. The client reads the server's headless verdict. AGENTS.md, docs/features.md, docs/api.md, docs/harness.md, docs/architecture.md, the theme README and the affected skills are updated together.

Spot-checked locally: a later front-matter save keeps the build map canonical in YAML and TOML.

[autoland] verdict: pass PASS — landable as-is. Signal relied on: CI aggregate `success` (ci / build-and-test: web build + vitest, go vet, go test with Hugo 0.167.0 installed so the Hugo integration tests in internal/theme/forms_test.go and headless_test.go ran, go build). gofmt checked locally on the changed Go files: clean. Head is current with main (merge-base = main), no conflict. Conventions: Conventional Commits title; `Closes #322` present; diff matches #322. The divergences are documented and justified: select options are separated by `|` because ruleNoLineBreaks (internal/element/fieldtype.go:657) refuses line breaks in textarea params, and a headless node keeps its permalink with `address: ""` because page params store the permalink. The theme refactor (ce/uid.html, ce/bool.html, backendlayout/body.html, backendlayout/links.html) gives each shared rule one owner, as CLAUDE.md requires, and does not restate them. The headless rule has one owner in internal/pagetype/headless.go. The client reads the server's `headless` verdict. AGENTS.md, docs/features.md, docs/api.md, docs/harness.md, docs/architecture.md, the theme README and the affected skills are updated together. Spot-checked locally: a later front-matter save keeps the `build` map canonical in YAML and TOML.
Sign in to join this conversation.
No reviewers
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!323
No description provided.