feat: element CRUD — picker, delete, drag & drop, nesting with placement rules, undo #23

Closed
dominik.polakovics wants to merge 4 commits from afk/6 into main

Structural editing of a page's element tree, end to end. Closes #6

What landed

Placement model (internal/element, new internal/placement)

  • Sidecar placement blocks are now typed and strictly validated: allowedIn (parent element names, plus the reserved page token for the document top level) and allowedAt (page types); absent lists mean unrestricted.
  • A page's type resolves Hugo-style: front matter type, else top-level section, else page (YAML/TOML/JSON front matter supported).
  • Per-page-type element allowlists live in editor/pagetypes/<type>.yaml (elements: [...]); no declaration means everything is allowed.
  • A container is any element whose body is markdown. The rules engine answers insert (all axes) and move (parent axis only) questions and is unit-tested without HTTP.

Server (internal/server)

  • POST /api/page/mutate applies one insert/delete/move op addressed by child-index paths. Placement rules are enforced server-side: violations are 422 with a user-facing message and the file is untouched; stale addresses are 409. Inserts carry schema defaults; deletes drop the whole subtree; moves use pre-mutation addressing with exact index adjustment, and adjacent text runs merge afterwards so canonical serialization never rejects the result. Every success rewrites only that page's file atomically through the canonical serializer.
  • Page responses now include pageType and allowedElements; mutate responses include the new/moved node's path.
  • POST /api/page/undo reverts the last structural mutation (single step, server-wide), byte-verifying the file hasn't changed externally; param saves never create undo steps.

Web

  • Insert affordances (after any row, inside containers, top level) open a picker grouped by schema group that offers only the types allowed at that position — page-type axes from the server, allowedIn filtering client-side, server authoritative regardless.
  • Delete asks for confirmation, naming the element and warning when children exist.
  • Drag & drop reorders top-level elements and moves elements into/out of columns (including empty containers), with insertion indicators on valid targets and a visual no-drop rejection on invalid ones; disallowed drops send no request.
  • Undo button reverts the last structural mutation; mutation errors surface in the pane.

Sample site — grid/column container pair with templates and sidecars, placement declarations on hero/notice, a posts allowlist excluding the grid, and a nested-grid sample on the home page.

Verification

  • go vet ./..., go test ./... (25 new server tests incl. index-adjustment, text-merge regression, undo matrix), gofmt clean.
  • Web: 61 vitest tests (picker/insert/delete/undo flows, DnD unit + DOM tests, rules helpers), tsc --noEmit, vite build.
  • Live end-to-end against the built harness + real Hugo: allowed/disallowed inserts, move + undo (file restored byte-identical), delete of an inserted subtree (file byte-identical to pre-insert), Hugo rebuilding cleanly throughout, _index.md round-trips byte-identical through the serializer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip

Structural editing of a page's element tree, end to end. Closes #6 ## What landed **Placement model** (`internal/element`, new `internal/placement`) - Sidecar `placement` blocks are now typed and strictly validated: `allowedIn` (parent element names, plus the reserved `page` token for the document top level) and `allowedAt` (page types); absent lists mean unrestricted. - A page's type resolves Hugo-style: front matter `type`, else top-level section, else `page` (YAML/TOML/JSON front matter supported). - Per-page-type element allowlists live in `editor/pagetypes/<type>.yaml` (`elements: [...]`); no declaration means everything is allowed. - A container is any element whose `body` is `markdown`. The rules engine answers insert (all axes) and move (parent axis only) questions and is unit-tested without HTTP. **Server** (`internal/server`) - `POST /api/page/mutate` applies one `insert`/`delete`/`move` op addressed by child-index paths. Placement rules are enforced server-side: violations are 422 with a user-facing message and the file is untouched; stale addresses are 409. Inserts carry schema defaults; deletes drop the whole subtree; moves use pre-mutation addressing with exact index adjustment, and adjacent text runs merge afterwards so canonical serialization never rejects the result. Every success rewrites only that page's file atomically through the canonical serializer. - Page responses now include `pageType` and `allowedElements`; mutate responses include the new/moved node's `path`. - `POST /api/page/undo` reverts the last structural mutation (single step, server-wide), byte-verifying the file hasn't changed externally; param saves never create undo steps. **Web** - Insert affordances (after any row, inside containers, top level) open a picker grouped by schema `group` that offers only the types allowed at that position — page-type axes from the server, `allowedIn` filtering client-side, server authoritative regardless. - Delete asks for confirmation, naming the element and warning when children exist. - Drag & drop reorders top-level elements and moves elements into/out of columns (including empty containers), with insertion indicators on valid targets and a visual no-drop rejection on invalid ones; disallowed drops send no request. - Undo button reverts the last structural mutation; mutation errors surface in the pane. **Sample site** — grid/column container pair with templates and sidecars, placement declarations on hero/notice, a `posts` allowlist excluding the grid, and a nested-grid sample on the home page. ## Verification - `go vet ./...`, `go test ./...` (25 new server tests incl. index-adjustment, text-merge regression, undo matrix), `gofmt` clean. - Web: 61 vitest tests (picker/insert/delete/undo flows, DnD unit + DOM tests, rules helpers), `tsc --noEmit`, `vite build`. - Live end-to-end against the built harness + real Hugo: allowed/disallowed inserts, move + undo (file restored byte-identical), delete of an inserted subtree (file byte-identical to pre-insert), Hugo rebuilding cleanly throughout, `_index.md` round-trips byte-identical through the serializer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip
Sidecar placement blocks are now parsed strictly into allowedIn (parent
element names, with the reserved "page" top-level token) and allowedAt
(page types); absent lists mean unrestricted. A new internal/placement
package resolves a page's type (front matter type, else top-level
section, else "page"), loads per-page-type element allowlists from
editor/pagetypes/<type>.yaml, and answers insert/move placement
questions for both the server and future callers, without HTTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip
The sample site gains a grid/column container pair (templates plus
sidecars), placement rules on hero (page only) and notice (page or
column), a posts page-type allowlist that excludes the grid, and a
nested-grid sample on the home page — enough to exercise every allowed
and disallowed structural-editing case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip
POST /api/page/mutate applies one insert/delete/move to a page's
element tree: placement rules are enforced authoritatively (422 with
an actionable message, file untouched), addresses that no longer
resolve are 409s, and every successful mutation re-serializes the
canonical document and rewrites only that page's file. Move addresses
refer to the pre-mutation tree; adjacent text runs merge after the
structural change so serialization never sees adjacent text nodes.
Page responses now carry pageType and allowedElements, and
POST /api/page/undo reverts the last mutation (single step,
byte-verified against external edits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip
feat(web): element picker, delete with confirm, drag & drop, single-step undo
All checks were successful
ci / build-and-test (pull_request) Successful in 1m7s
7cb4e5dd45
The elements pane gains structural editing: insert affordances (after
any row, inside containers, at the top level) opening a picker that
offers only the types the placement rules allow at that position,
grouped by schema group; delete with a confirmation naming the element
and warning about nested children; drag & drop reordering and
into/out-of-container moves with insertion indicators and visual
rejection of disallowed targets; and an Undo button for the last
structural mutation. Placement filtering lives in pure, unit-tested
helpers (rules.ts) mirroring the server's semantics; the server stays
authoritative and mutation errors surface in the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mewza68Wq8JKj6H27Aw9ip
All checks were successful
ci / build-and-test (pull_request) Successful in 1m7s

Pull request closed

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!23
No description provided.