refactor: deepen the element mutation engine into internal/elemops #118

Merged
dominik.polakovics merged 5 commits from afk/111 into main 2026-07-18 15:29:06 +02:00

Closes #111

Extracts element-tree semantic mutation out of the HTTP server package into a new module, internal/elemops — a sibling of internal/content and internal/element, named after the pageops/mediaops convention. Behavior-preserving throughout: on-disk bytes, HTTP statuses, error messages, and orderings are unchanged; the full existing test suite (Go + frontend) passes without modification and served as the byte-for-byte compatibility net.

What moved, in five reviewable commits

  1. 900311d — uid stamping (StampUIDs/RestampUIDs), the slot-wrapper model, and the placement-rule engine move to elemops; the server keeps IO and layout-view assembly.
  2. 073236d — the rte/markdown body validator (goldmark policy checker), element creation (NewShortcode/BlockForSchema), and the pure lookup/summary walkers move; the server keeps the HTTP endpoints.
  3. 9765062 — the deepening step: elemops gains the validated-edit save contract. ApplyEdit absorbs coerce → param merge → items/body child composition (the four-way switch moves verbatim with its compatibility comments); WouldClobberBody is the body-save guard predicate; MergeParams (shortcode params) and FrontMatterChanges (front-matter keys) are the contract's two sinks sharing one delete-on-absent + <name>Target link-companion semantics. handleSavePage shrinks to parse → validate → apply → write; pageops.SaveSettings delegates its hand-rolled coerce/apply + companion loop to the same module.
  4. 1c5a12c — the duplicated front-matter readers (server.metaString / pageops.metaLookup) fold into content.MetaLookup + content.MetaString on the read half of the metadata seam, with unit tests.
  5. 0968270 — 39 direct in-package unit tests through the new module's interface: four-way composition (incl. byte-preserving text coalescing and the legacy self-closing upgrade), uid echo/dedup/stamping, slot wrapping, param merging and companion round-trips, FrontMatterChanges change-set shape — no HTTP server, no tempdir.

Acceptance criteria

  • Four-way composition, uid stamping, slot wrapping, param merging covered by direct unit tests through the module interface
  • No duplicated coerce/apply or <name>Target companion logic in handleSavePage / SaveSettings — both delegate to elemops
  • No semantic shaping free functions remain in internal/server (remaining free functions are HTTP/structural or wire-struct view assembly)
  • go test ./... fully green, unchanged (content roundtrip/fidelity/fuzz + all HTTP-level tests); frontend suite 871/871
  • scaffold/site/AGENTS.md checked: the drift test passes and no documented canonicalization rule changed (pure move)

Out of scope, per the issue: on-disk format, HTTP API surface, atomic-write engine (#116), sidecar-loader consolidation (#114), effective-page-type unification (#115).

🤖 Generated with Claude Code

Closes #111 Extracts element-tree *semantic* mutation out of the HTTP server package into a new module, `internal/elemops` — a sibling of `internal/content` and `internal/element`, named after the `pageops`/`mediaops` convention. Behavior-preserving throughout: on-disk bytes, HTTP statuses, error messages, and orderings are unchanged; the full existing test suite (Go + frontend) passes without modification and served as the byte-for-byte compatibility net. ## What moved, in five reviewable commits 1. **900311d** — uid stamping (`StampUIDs`/`RestampUIDs`), the slot-wrapper model, and the placement-rule engine move to `elemops`; the server keeps IO and layout-view assembly. 2. **073236d** — the rte/markdown body validator (goldmark policy checker), element creation (`NewShortcode`/`BlockForSchema`), and the pure lookup/summary walkers move; the server keeps the HTTP endpoints. 3. **9765062** — the deepening step: `elemops` gains the validated-edit save contract. `ApplyEdit` absorbs coerce → param merge → items/body child composition (the four-way switch moves verbatim with its compatibility comments); `WouldClobberBody` is the body-save guard predicate; `MergeParams` (shortcode params) and `FrontMatterChanges` (front-matter keys) are the contract's two sinks sharing one delete-on-absent + `<name>Target` link-companion semantics. `handleSavePage` shrinks to parse → validate → apply → write; `pageops.SaveSettings` delegates its hand-rolled coerce/apply + companion loop to the same module. 4. **1c5a12c** — the duplicated front-matter readers (`server.metaString` / `pageops.metaLookup`) fold into `content.MetaLookup` + `content.MetaString` on the read half of the metadata seam, with unit tests. 5. **0968270** — 39 direct in-package unit tests through the new module's interface: four-way composition (incl. byte-preserving text coalescing and the legacy self-closing upgrade), uid echo/dedup/stamping, slot wrapping, param merging and companion round-trips, `FrontMatterChanges` change-set shape — no HTTP server, no tempdir. ## Acceptance criteria - [x] Four-way composition, uid stamping, slot wrapping, param merging covered by direct unit tests through the module interface - [x] No duplicated coerce/apply or `<name>Target` companion logic in `handleSavePage` / `SaveSettings` — both delegate to `elemops` - [x] No semantic shaping free functions remain in `internal/server` (remaining free functions are HTTP/structural or wire-struct view assembly) - [x] `go test ./...` fully green, unchanged (content roundtrip/fidelity/fuzz + all HTTP-level tests); frontend suite 871/871 - [x] `scaffold/site/AGENTS.md` checked: the drift test passes and no documented canonicalization rule changed (pure move) Out of scope, per the issue: on-disk format, HTTP API surface, atomic-write engine (#116), sidecar-loader consolidation (#114), effective-page-type unification (#115). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Create internal/elemops, the semantic element-tree shaping engine, as a
sibling of internal/content and internal/element (issue #111). This first
slice moves the three self-contained rule families out of internal/server:

- uid.go: NewUID, ShortcodeUID, SetShortcodeUID, StampUIDs, RestampUIDs
- slots.go: the slot-wrapper model (SlotShortcode, SlotWrapperName,
  TopLevelSlot, SlotIsEmpty, EnsureSlotWrapper, LiveSlotWrapper,
  LayoutSlotMsg, SlotElementsMsg, DestSlot, AllowedElements,
  ReservedSlotRefusal)
- placement.go: PlacementPage, PlacementMsg, FindPagetype

Pure mechanical move: bodies and messages are byte-identical, doc comments
travel with their functions, and internal/server keeps only IO and view
assembly (pageLayout, layoutView, ancestry, unusedContent) as thin callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second slice of issue #111: internal/elemops gains

- body.go: ParseAndValidateBody and the goldmark policy checker
  (bodyChecker), moved whole with the SECURITY rationale
- create.go: NewShortcode, BlockForSchema, HiddenRefusal,
  NotInsertableRefusal, zeroParamValue
- lookup.go: ResolveShortcode, FindSchema
- summary.go: ElementSummary, FindShortcodeByUID, LinkParamValues

internal/server keeps only the HTTP layer (handleElementBody,
parseIndexPath, handlers) as thin callers. Pure mechanical move: bodies,
messages, and validation semantics are byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third slice of issue #111 — the deepening step. internal/elemops/save.go
now holds the one apply contract both save paths delegate to:

- ApplyEdit applies a validated element edit: coerces values, merges
  params, and composes items/body children — the four-way composition
  switch moves verbatim with its byte-for-byte compatibility rules
- WouldClobberBody is the body-save guard predicate (nested block
  elements must be edited via element CRUD, never flattened)
- MergeParams (shortcode params) and FrontMatterChanges (front-matter
  keys) are the contract's two sinks, sharing one delete-on-absent and
  <name>Target link-companion semantics

server.handleSavePage shrinks to parse -> validate -> apply -> write, and
pageops.SaveSettings feeds applyToFile from elemops.FrontMatterChanges,
removing its hand-rolled coerce/apply and companion set/delete loop.
Behavior-preserving: statuses, messages, orderings, and on-disk bytes
are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fourth slice of issue #111: the two duplicated case-insensitive
front-matter map readers — server.metaString and pageops.metaLookup —
become one pair of helpers on the read half of the metadata seam:
content.MetaLookup (exact match wins over a case-folded one, Hugo's own
front-matter key rule) and content.MetaString on top of it. All eight
production call sites and the pageops test helpers now share the single
implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test(elemops): direct unit tests for the element mutation engine
All checks were successful
ci / build-and-test (pull_request) Successful in 8m21s
0968270789
Fifth slice of issue #111, acceptance criterion 1: the four-way items/body
composition, uid stamping, slot wrapping, param merging, and the
FrontMatterChanges sink are now covered by direct in-package tests through
the module's interface — no HTTP server, no tempdir. 39 tests pin among
other things: byte-preserving text coalescing (serialize-clean), the
legacy self-closing upgrade on any items or body save, item uid
echo/dedup/fresh stamping, the disabled-flag byte-compat rule, link
companion set/delete through both sinks, slot wrapper first-wins and
liveness, and that StampUIDs validates presence and uniqueness, not shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PASS

Verification signal relied on: the repo's own Forgejo Actions CI (ci / build-and-test) — success, 8m21s, run 103. Not re-run locally; it already covers go test ./... (content roundtrip/fidelity/fuzz, the HTTP-level tests) and the internal/element/agentsdoc_test.go AGENTS.md drift test, which are exactly the byte-for-byte compatibility net this refactor leans on.

Checked:

  • State / conventions — open, non-draft, head afk/111. Title is Conventional Commits (refactor:).
  • AFK contract — body carries a working Closes #111; branch matches the issue, so the merge releases the run's claim.
  • Mergeability — merges cleanly into main, no conflict resolution needed.
  • Scope vs the issue — the diff touches only internal/{elemops,server,pageops,content}. Nothing in the issue's out-of-scope list is touched: no on-disk shortcode format change, no HTTP API surface change, no atomic-write engine (#116), no sidecar-loader consolidation (#114), no effective-page-type unification (#115), no web frontend.
  • Acceptance criterion "no semantic shaping free functions remain in internal/server" — confirmed by enumerating every remaining free function in the package; what is left is HTTP/structural (decodeBody, requirePost, writeJSONError, media/publish helpers) or wire-struct view assembly (layoutFields, decodeFrontMatter, unusedContent). No unqualified metaString / placementPage / slotShortcode / newShortcode / partitionItems / mergeParams references survive in non-test files.

Behaviour-preservation review — two independent source-level audits, line-by-line against origin/main, targeted at the parts that are not pure moves and are therefore where a regression would actually hide:

  1. pageops.SaveSettings delegating to the shared contract. Previously it had its own hand-rolled coerce/apply + <name>Target companion loop; it now calls elemops.FrontMatterChanges. Verified identical: same CoerceValues, same iteration order (schema.Params is the old fields slice, so emitted []content.Change ordering is unchanged), same set-when-present / delete-when-absent rule (neither version ever sets-to-empty), same link-companion change emitted immediately after its base field.
  2. The metaString / metaLookup twins folded into content.MetaLookup + content.MetaString. Verified the twins were already semantically identical, and — importantly — that no caller crossed over: every pageops call site still gets the raw-any lookup and does its own cast, every server call site still gets the string-coercing one. Nil, missing-key, and non-string values all behave as before; no trimming or coercion was introduced on either side. The case-variant-key map-iteration quirk is pre-existing and now documented.
  3. The four-way items/body composition switch moved verbatim into ApplyEdit, including the legacy self-closing→paired upgrade, the newline text-node coalescing on items extraction, and "a body-capable element is canonically a block even with an empty body".
  4. One deliberate reordering was checked and cleared: ApplyEdit runs MergeParams before PartitionItems, where the old handler partitioned first. MergeParams only writes sc.Params and never sc.Children, and coalesceText copies rather than mutates text nodes, so both partitions see identical input. Likewise WouldClobberBody re-derives rest instead of reusing it — same predicate, same short-circuit order.
  5. MergeParams, WouldClobberBody, uid stamping, the slot-wrapper family, the placement engine, the goldmark rte/body policy checker, element creation, and the summary/lookup walkers are byte-identical modulo package clause, export renames, and doc comments. Every error string and HTTP status in handleSavePage is unchanged in wording, condition, and check ordering. The only altered string is an unreachable crypto/rand unavailable panic prefix (server:elemops:).

Verdict: PASS — no behaviour differences found. Held at the merge gate for explicit human confirmation.

> *This was generated by AI while landing a PR.* ## Landing audit — PASS **Verification signal relied on:** the repo's own Forgejo Actions CI (`ci / build-and-test`) — **success**, 8m21s, run 103. Not re-run locally; it already covers `go test ./...` (content roundtrip/fidelity/fuzz, the HTTP-level tests) and the `internal/element/agentsdoc_test.go` AGENTS.md drift test, which are exactly the byte-for-byte compatibility net this refactor leans on. **Checked:** - **State / conventions** — open, non-draft, head `afk/111`. Title is Conventional Commits (`refactor:`). - **AFK contract** — body carries a working `Closes #111`; branch matches the issue, so the merge releases the run's claim. - **Mergeability** — merges cleanly into `main`, no conflict resolution needed. - **Scope vs the issue** — the diff touches only `internal/{elemops,server,pageops,content}`. Nothing in the issue's out-of-scope list is touched: no on-disk shortcode format change, no HTTP API surface change, no atomic-write engine (#116), no sidecar-loader consolidation (#114), no effective-page-type unification (#115), no web frontend. - **Acceptance criterion "no semantic shaping free functions remain in `internal/server`"** — confirmed by enumerating every remaining free function in the package; what is left is HTTP/structural (`decodeBody`, `requirePost`, `writeJSONError`, media/publish helpers) or wire-struct view assembly (`layoutFields`, `decodeFrontMatter`, `unusedContent`). No unqualified `metaString` / `placementPage` / `slotShortcode` / `newShortcode` / `partitionItems` / `mergeParams` references survive in non-test files. **Behaviour-preservation review** — two independent source-level audits, line-by-line against `origin/main`, targeted at the parts that are *not* pure moves and are therefore where a regression would actually hide: 1. **`pageops.SaveSettings` delegating to the shared contract.** Previously it had its own hand-rolled coerce/apply + `<name>Target` companion loop; it now calls `elemops.FrontMatterChanges`. Verified identical: same `CoerceValues`, same iteration order (`schema.Params` *is* the old `fields` slice, so emitted `[]content.Change` ordering is unchanged), same set-when-present / delete-when-absent rule (neither version ever sets-to-empty), same link-companion change emitted immediately after its base field. 2. **The `metaString` / `metaLookup` twins folded into `content.MetaLookup` + `content.MetaString`.** Verified the twins were already semantically identical, and — importantly — that **no caller crossed over**: every `pageops` call site still gets the raw-`any` lookup and does its own cast, every `server` call site still gets the string-coercing one. Nil, missing-key, and non-string values all behave as before; no trimming or coercion was introduced on either side. The case-variant-key map-iteration quirk is pre-existing and now documented. 3. **The four-way items/body composition switch** moved verbatim into `ApplyEdit`, including the legacy self-closing→paired upgrade, the newline text-node coalescing on items extraction, and "a body-capable element is canonically a block even with an empty body". 4. **One deliberate reordering was checked and cleared:** `ApplyEdit` runs `MergeParams` *before* `PartitionItems`, where the old handler partitioned first. `MergeParams` only writes `sc.Params` and never `sc.Children`, and `coalesceText` copies rather than mutates text nodes, so both partitions see identical input. Likewise `WouldClobberBody` re-derives `rest` instead of reusing it — same predicate, same short-circuit order. 5. **`MergeParams`, `WouldClobberBody`, uid stamping, the slot-wrapper family, the placement engine, the goldmark rte/body policy checker, element creation, and the summary/lookup walkers** are byte-identical modulo package clause, export renames, and doc comments. Every error string and HTTP status in `handleSavePage` is unchanged in wording, condition, and check ordering. The only altered string is an unreachable `crypto/rand unavailable` panic prefix (`server:` → `elemops:`). **Verdict: PASS** — no behaviour differences found. Held at the merge gate for explicit human confirmation.
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!118
No description provided.