Field types defined once in Go: one definition reads, checks and writes every value #163

Closed
opened 2026-10-07 15:21:37 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Summary

Each of the eleven field types gets one definition in Go that owns everything about the type: what a sidecar may declare for it, how a value in any spelling Hugo reads becomes the type's standard value, which errors it raises, and how it is written to a file. Every server path that reads, checks or writes a field value goes through it. Today this knowledge is spread over per-type switches in five packages, and they disagree or get skipped in the places listed below.

This is the server half of the change the maintainer asked for in #158's triage. The browser half (server-described forms) is #164.

Current behavior

Verified on main in triage (throwaway tests, since deleted):

  • Declaring is already shared. Element params and page-type fields both go through element.ParseParams, and an unknown type is a load error. But a datetime default: must be exact RFC3339: 2026-07-10 (quoted or not), 2026-07-10 09:00:00 and 2026-07-10T09:00:00 all fail to load, while front matter accepts any spelling Hugo reads since #151.
  • Checking a submitted value uses Schema.ValidateValues and CoerceValues for element saves (item rows included), properties saves and page create. The site checks (CheckPageParams, CheckCategoryParams) are separate functions that each save path has to call:
    • page create (coerceSeededFields) runs the category check but not the page check;
    • the rich-text body save (elemops.ParseAndValidateBody) checks the body policy and which inline elements are allowed, but never the inline elements' params: no type check, no site check.
  • Reading a stored value has one switch per reader: pageops.fieldValue (properties), server.entryValue (module list) and pagetree (the page date). They disagree:
    • a multiselect written as a single string (tags: events): the module list reads ["events"], properties read [], and the next properties save deletes it;
    • an unreadable number (count: abc): properties read 0 and a save writes 0; the module list leaves it out.
  • Writing goes through CoerceValues, then the shortcode sink (MergeParams; categories comma-joined in shortcodeValue) or the front-matter sink (FrontMatterChanges). A new element gets its defaults and zeros per type in elemops.NewShortcode. A link's <name>Target companion is handled in seven Go files.
  • Modules and the element list decide per type, in their own code, which fields can be a card's image, title, text or tags, and which can be searched, sorted or filtered (internal/editormodule/load.go, the sorter in internal/server/modules.go), and which params label an element in the element list (elemops.ElementSummary).

Desired behavior

  • One definition per type. Each of the eleven types is defined once, in one Go package, and owns:

    • declaring: which sidecar keys it takes (options, pageType, default), which sidecar lists may use it (multiselect only in page-type fields), and name binding (categories);
    • reading: the sanitizer below;
    • checking: required, options, and the site checks (the page exists and has the required page type; the categories exist);
    • writing: the stored form in front matter and in a shortcode param, and a new element's initial value;
    • what modules and the element list may do with it: card roles, search, sort, filter, summary.

    The link type owns its <name>Target companion.

  • One sanitizer per type. It takes a value in any spelling Hugo reads, from a file, a submitted save or a sidecar default:, and returns the type's standard value, or "unreadable". What "unreadable" means depends on where the value came from:

    • stored in a file: no value. The properties form shows the field's default, else empty, and the module list leaves it out. A read never fails. A properties save writes what the form holds, so an unreadable stored value is replaced by the default, or removed, on the next save (#151's rule).
    • submitted in a save: a field error.
    • a sidecar default:: a load error naming the file.
  • Every server path uses it, so none can skip a step: sidecar loading, the properties read, the module list, the page date, element saves (item rows included), properties saves, page create, and the rich-text body save. The body save now sanitizes and checks every inline element's params against that element's sidecar, and an error names the inline element and the param.

  • Standard values stay as today, except:

    • datetime: any spelling content.DateRFC3339 reads, from any source, sidecar defaults included. A value without an offset is read as UTC. It is written as a quoted RFC3339 string.
    • multiselect: a single string reads as a one-item list (properties read it as empty before).
    • number: an unreadable value is "no value" (properties read it as 0 before).
  • Everything else behaves as today. The browser is not changed: it keeps its own checks until #164.

Decisions

  • One definition per type, one file per type behind one Go interface (maintainer: "one surface where possible types are defined. there the input sanitation should run, and errors should be raised … elements should only be able to use the types provided by this"). A type that lacks a job doesn't compile, and adding a type means adding one file.
  • One sanitizer for every input. What Hugo reads is accepted everywhere, and the editor writes one standard form. This extends #151's rule for front-matter dates to every type and every source (maintainer: one standard everywhere).
  • "Unreadable" depends on the source. Reads never fail on odd data (#151's rule); a save must not accept a value it can't store; a bad sidecar fails when it loads rather than on its first save.
  • The caller hands the type package a lookup of the site's pages and categories, so the package stays free of the content tree and no save path can forget the site checks.
  • A single string in a multiselect is a one-item list. Hugo reads a single taxonomy term the same way, the module list already does, and reading it as empty loses data on the next save.
  • An unreadable number is no value, not 0. 0 is a real value, and writing it silently changes content.
  • Server only. The browser half is #164, so this issue changes behavior only where listed above.
  • No migration: there are no existing sites (maintainer, 2026-10-06).

Key interfaces

  • element.FieldType, validFieldTypes, ParseParams / buildParam, normalizeDefault (internal/element).
  • Schema.ValidateValues, Schema.CoerceValues (internal/element/values.go).
  • fieldValue, ReadSettings, SaveSettings (internal/pageops/settings.go); coerceSeededFields (internal/pageops/create.go); CheckPageParams, CheckCategoryParams (internal/pageops).
  • entryValue, stringList and the module sorter (internal/server/modules.go); the field rules in internal/editormodule/load.go.
  • NewShortcode, MergeParams, shortcodeValue, FrontMatterChanges, ParseAndValidateBody, ElementSummary (internal/elemops).
  • content.DateRFC3339 (internal/content/meta.go); the page date in internal/pagetree.
  • The element save handler handleSavePage (internal/server/server.go), where each path's own site-check calls collapse into the shared check.
  • The AGENTS.md drift test (internal/element/agentsdoc_test.go) reads the type list from wherever it now lives.

Acceptance criteria

  • Each type is defined in one file of one package. Outside that package, no Go code switches on a field type to declare, read, check, write, summarize, search, sort or filter a value.
  • A type that lacks one of its jobs doesn't compile, and a test fails when the list of type names and the defined types differ.
  • A datetime default: written as 2026-07-10, "2026-07-10", 2026-07-10 09:00:00 or 2026-07-10T09:00:00 loads, for element params and page-type fields alike, and is served as RFC3339. default: next tuesday is a load error naming the file.
  • tags: events in a multiselect field reads as ["events"] in properties and in the module list, and a properties save keeps it.
  • count: abc in a number field reads as the field's default, else empty, in properties, and the module list leaves it out. A properties save that doesn't touch it never writes 0.
  • A rich-text body save is rejected, with an error naming the inline element and the param, when an inline element has a select value outside its options, lacks a required param, names a missing page, or names an unknown category. A hand-written start="2026-07-10" in an inline element is accepted and stored as RFC3339.
  • Page create rejects a page field that names a missing page or a page of the wrong type, as a properties save does.
  • A submitted datetime in any spelling Hugo reads is accepted by element saves, properties saves and page create, and stored as RFC3339.
  • A link's "open in new tab" setting behaves as before everywhere, and its stored keys are unchanged.
  • AGENTS.md (and the README where it covers them) describes the changed rules: datetime defaults in any spelling, a single string in a multiselect, and body saves checking inline elements (CLAUDE.md rule).
  • All existing Go and web tests pass, changed only where a behavior listed above changed.

How to verify

  • make test. It builds web/dist first, which web/embed.go needs; a bare go test ./... fails in a fresh worktree. The theme's Hugo build tests skip without hugo on PATH; CI installs extended 0.163.3.
  • Table-driven Go tests per type: spellings in, standard value or "unreadable" out, for each source (file, submitted, default).
  • Regression tests for each case reproduced above: the defaults, the multiselect and number reads, body saves with bad inline params, and page create with a bad page field.

Assumptions to check

  • That content.DateRFC3339 reads the same spellings as Hugo's time.AsTime (both build on spf13/cast), before AGENTS.md says "any spelling Hugo reads" for defaults.
  • That Hugo reads a single string in a front-matter list field as one value, as it does for a taxonomy term.
  • That deckle-hosted still calls none of the endpoints this changes. In triage its snapshot called none of the page, element, settings or module endpoints.

Out of scope

  • The browser: its checks, conversions and type list, server-described forms, field checks on focus lost, and reading element params on the server (#164).
  • Honoring the site's timeZone for values without an offset.
  • New field types, and letting a sidecar choose a widget (like TYPO3's renderType).
  • deckle-hosted.

Blocked by

None - can start immediately

> *This was generated by AI during triage.* ## Summary Each of the eleven field types gets one definition in Go that owns everything about the type: what a sidecar may declare for it, how a value in any spelling Hugo reads becomes the type's standard value, which errors it raises, and how it is written to a file. Every server path that reads, checks or writes a field value goes through it. Today this knowledge is spread over per-type switches in five packages, and they disagree or get skipped in the places listed below. This is the server half of the change the maintainer asked for in #158's triage. The browser half (server-described forms) is #164. ## Current behavior Verified on `main` in triage (throwaway tests, since deleted): - **Declaring is already shared.** Element params and page-type fields both go through `element.ParseParams`, and an unknown type is a load error. But a `datetime` `default:` must be exact RFC3339: `2026-07-10` (quoted or not), `2026-07-10 09:00:00` and `2026-07-10T09:00:00` all fail to load, while front matter accepts any spelling Hugo reads since #151. - **Checking a submitted value** uses `Schema.ValidateValues` and `CoerceValues` for element saves (item rows included), properties saves and page create. The site checks (`CheckPageParams`, `CheckCategoryParams`) are separate functions that each save path has to call: - page create (`coerceSeededFields`) runs the category check but not the page check; - the rich-text body save (`elemops.ParseAndValidateBody`) checks the body policy and which inline elements are allowed, but never the inline elements' params: no type check, no site check. - **Reading a stored value** has one switch per reader: `pageops.fieldValue` (properties), `server.entryValue` (module list) and `pagetree` (the page date). They disagree: - a multiselect written as a single string (`tags: events`): the module list reads `["events"]`, properties read `[]`, and the next properties save deletes it; - an unreadable number (`count: abc`): properties read `0` and a save writes `0`; the module list leaves it out. - **Writing** goes through `CoerceValues`, then the shortcode sink (`MergeParams`; categories comma-joined in `shortcodeValue`) or the front-matter sink (`FrontMatterChanges`). A new element gets its defaults and zeros per type in `elemops.NewShortcode`. A link's `<name>Target` companion is handled in seven Go files. - **Modules and the element list** decide per type, in their own code, which fields can be a card's image, title, text or tags, and which can be searched, sorted or filtered (`internal/editormodule/load.go`, the sorter in `internal/server/modules.go`), and which params label an element in the element list (`elemops.ElementSummary`). ## Desired behavior - **One definition per type.** Each of the eleven types is defined once, in one Go package, and owns: - declaring: which sidecar keys it takes (`options`, `pageType`, `default`), which sidecar lists may use it (`multiselect` only in page-type fields), and name binding (`categories`); - reading: the sanitizer below; - checking: required, options, and the site checks (the page exists and has the required page type; the categories exist); - writing: the stored form in front matter and in a shortcode param, and a new element's initial value; - what modules and the element list may do with it: card roles, search, sort, filter, summary. The link type owns its `<name>Target` companion. - **One sanitizer per type.** It takes a value in any spelling Hugo reads, from a file, a submitted save or a sidecar `default:`, and returns the type's standard value, or "unreadable". What "unreadable" means depends on where the value came from: - stored in a file: no value. The properties form shows the field's default, else empty, and the module list leaves it out. A read never fails. A properties save writes what the form holds, so an unreadable stored value is replaced by the default, or removed, on the next save (#151's rule). - submitted in a save: a field error. - a sidecar `default:`: a load error naming the file. - **Every server path uses it**, so none can skip a step: sidecar loading, the properties read, the module list, the page date, element saves (item rows included), properties saves, page create, and the rich-text body save. The body save now sanitizes and checks every inline element's params against that element's sidecar, and an error names the inline element and the param. - **Standard values** stay as today, except: - `datetime`: any spelling `content.DateRFC3339` reads, from any source, sidecar defaults included. A value without an offset is read as UTC. It is written as a quoted RFC3339 string. - `multiselect`: a single string reads as a one-item list (properties read it as empty before). - `number`: an unreadable value is "no value" (properties read it as `0` before). - Everything else behaves as today. The browser is not changed: it keeps its own checks until #164. ## Decisions - **One definition per type, one file per type behind one Go interface** (maintainer: "one surface where possible types are defined. there the input sanitation should run, and errors should be raised … elements should only be able to use the types provided by this"). A type that lacks a job doesn't compile, and adding a type means adding one file. - **One sanitizer for every input.** What Hugo reads is accepted everywhere, and the editor writes one standard form. This extends #151's rule for front-matter dates to every type and every source (maintainer: one standard everywhere). - **"Unreadable" depends on the source.** Reads never fail on odd data (#151's rule); a save must not accept a value it can't store; a bad sidecar fails when it loads rather than on its first save. - **The caller hands the type package a lookup of the site's pages and categories**, so the package stays free of the content tree and no save path can forget the site checks. - **A single string in a multiselect is a one-item list.** Hugo reads a single taxonomy term the same way, the module list already does, and reading it as empty loses data on the next save. - **An unreadable number is no value, not `0`.** `0` is a real value, and writing it silently changes content. - **Server only.** The browser half is #164, so this issue changes behavior only where listed above. - No migration: there are no existing sites (maintainer, 2026-10-06). ## Key interfaces - `element.FieldType`, `validFieldTypes`, `ParseParams` / `buildParam`, `normalizeDefault` (`internal/element`). - `Schema.ValidateValues`, `Schema.CoerceValues` (`internal/element/values.go`). - `fieldValue`, `ReadSettings`, `SaveSettings` (`internal/pageops/settings.go`); `coerceSeededFields` (`internal/pageops/create.go`); `CheckPageParams`, `CheckCategoryParams` (`internal/pageops`). - `entryValue`, `stringList` and the module sorter (`internal/server/modules.go`); the field rules in `internal/editormodule/load.go`. - `NewShortcode`, `MergeParams`, `shortcodeValue`, `FrontMatterChanges`, `ParseAndValidateBody`, `ElementSummary` (`internal/elemops`). - `content.DateRFC3339` (`internal/content/meta.go`); the page date in `internal/pagetree`. - The element save handler `handleSavePage` (`internal/server/server.go`), where each path's own site-check calls collapse into the shared check. - The AGENTS.md drift test (`internal/element/agentsdoc_test.go`) reads the type list from wherever it now lives. ## Acceptance criteria - [ ] Each type is defined in one file of one package. Outside that package, no Go code switches on a field type to declare, read, check, write, summarize, search, sort or filter a value. - [ ] A type that lacks one of its jobs doesn't compile, and a test fails when the list of type names and the defined types differ. - [ ] A `datetime` `default:` written as `2026-07-10`, `"2026-07-10"`, `2026-07-10 09:00:00` or `2026-07-10T09:00:00` loads, for element params and page-type fields alike, and is served as RFC3339. `default: next tuesday` is a load error naming the file. - [ ] `tags: events` in a multiselect field reads as `["events"]` in properties and in the module list, and a properties save keeps it. - [ ] `count: abc` in a number field reads as the field's default, else empty, in properties, and the module list leaves it out. A properties save that doesn't touch it never writes `0`. - [ ] A rich-text body save is rejected, with an error naming the inline element and the param, when an inline element has a select value outside its options, lacks a required param, names a missing page, or names an unknown category. A hand-written `start="2026-07-10"` in an inline element is accepted and stored as RFC3339. - [ ] Page create rejects a `page` field that names a missing page or a page of the wrong type, as a properties save does. - [ ] A submitted `datetime` in any spelling Hugo reads is accepted by element saves, properties saves and page create, and stored as RFC3339. - [ ] A link's "open in new tab" setting behaves as before everywhere, and its stored keys are unchanged. - [ ] AGENTS.md (and the README where it covers them) describes the changed rules: `datetime` defaults in any spelling, a single string in a multiselect, and body saves checking inline elements (CLAUDE.md rule). - [ ] All existing Go and web tests pass, changed only where a behavior listed above changed. ## How to verify - `make test`. It builds `web/dist` first, which `web/embed.go` needs; a bare `go test ./...` fails in a fresh worktree. The theme's Hugo build tests skip without `hugo` on PATH; CI installs extended 0.163.3. - Table-driven Go tests per type: spellings in, standard value or "unreadable" out, for each source (file, submitted, default). - Regression tests for each case reproduced above: the defaults, the multiselect and number reads, body saves with bad inline params, and page create with a bad `page` field. ## Assumptions to check - That `content.DateRFC3339` reads the same spellings as Hugo's `time.AsTime` (both build on spf13/cast), before AGENTS.md says "any spelling Hugo reads" for defaults. - That Hugo reads a single string in a front-matter list field as one value, as it does for a taxonomy term. - That deckle-hosted still calls none of the endpoints this changes. In triage its snapshot called none of the page, element, settings or module endpoints. ## Out of scope - The browser: its checks, conversions and type list, server-described forms, field checks on focus lost, and reading element params on the server (#164). - Honoring the site's `timeZone` for values without an offset. - New field types, and letting a sidecar choose a widget (like TYPO3's `renderType`). - deckle-hosted. ## 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#163
No description provided.