feat(modules): editor modules declared in YAML; News module replaces the List module #155

Merged
dominik.polakovics merged 2 commits from afk/153 into main 2026-10-06 20:03:27 +02:00

Closes #153

What

  • Module files (editor/modules/<name>.yaml, new package internal/editormodule):
    • Loaded strictly and checked against the loaded page types, under the shared sidecardir scan rules.
    • Each load error names its file and appears as its own family in the schema error banner; one bad file never hides the others.
    • Reserved ids, compared case-insensitively: page, view, media, edit, api, internal.
    • A page type belongs to one module; the module first in id order keeps it.
  • Tree: pagetree.Annotate sets containerFor, managedBy and hidden on /api/tree nodes. hideChildPages now hides only managed children, and hides nothing when no loaded module manages them.
  • Endpoints:
    • GET /api/modules lists the loaded modules and their load errors.
    • GET /api/module/entries does search, sort, filters and paging on the server. Datetimes go out as RFC3339 through one shared helper (content.DateRFC3339), native TOML datetimes included.
  • Web:
    • Feature modules come from the server; the rail, route repair, return validation and the error banner know them. Nothing judges a module id before the module list has loaded.
    • In a feature module the page tree is selection-only, and pages that aren't containers are greyed but still expand. With no container selected the module shows "Select a News page in the tree" and selects nothing.
    • The module screen offers list/cards views, search, sort, filters and paging, all kept in the URL, plus a "New " button that uses the shared create dialog.
    • The entry editor has Properties and Content tabs and the toolbar: back to the list, Save, Save & close, View, Publish, Undo, Discard… and the page actions (Rename…, Enable/Disable, Delete…). Its document controls are shared with PagePane through PageEditorChrome.
    • The page module offers "Manage in News" on a News page and "Open in News" on a news item.
    • The List module is removed.
  • harness add fills gaps: re-running it on an installed feature writes only the missing bundle files and never touches an existing path. The receipt records just the written files, and its version becomes the bundle's current one. It reports added; --json stays formatVersion: 1.
  • News bundle: ships editor/modules/news.yaml; manifest version 2.
  • Docs: scaffold/site/AGENTS.md and the README cover all of the above.

Verification

  • make test: web 65 files / 1804 tests and all Go packages pass. go vet ./... and npx tsc --noEmit are clean.
  • In a real browser (Playwright + Chromium) against harness serve, on a site with news, two News pages and a git remote, I went through every acceptance criterion and all passed:
    • the rail, the selection-only tree and the prompt;
    • list and card views; search, sort, filters and paging surviving reload and Back;
    • creating an item; the tabs, Save & close and the element edit page's return;
    • Rename, Enable/Disable, Delete, Publish, Undo and Discard;
    • hiding in the tree, "Manage in News" / "Open in News";
    • deleting the module file; broken and reserved-id module files in the banner;
    • an RFC3339 date for a native TOML datetime.
  • Gap filling, run with the CLI:
    • On a simulated version-1 install it adds editor/modules/news.yaml and reports added with only that file and version 2.
    • When the receipt still lists the file but it was deleted, it rewrites it.
    • A hand-written file at that path is left alone and not recorded.
    • On an up-to-date site it reports already-installed.
    • harness remove news deletes the module file.

Notes

  • Load errors beyond the issue's list (all documented in AGENTS.md): an empty search: [] or edit: []; a page type, filter or tab listed twice; columns on a cards view or card on a list view; a filter field that is select in one listed type and multiselect in another.
  • Entries missing the sort field sort last in both directions; for news that means undated items come last.

🤖 Generated with Claude Code

Closes #153 ## What - **Module files** (`editor/modules/<name>.yaml`, new package `internal/editormodule`): - Loaded strictly and checked against the loaded page types, under the shared `sidecardir` scan rules. - Each load error names its file and appears as its own family in the schema error banner; one bad file never hides the others. - Reserved ids, compared case-insensitively: `page`, `view`, `media`, `edit`, `api`, `internal`. - A page type belongs to one module; the module first in id order keeps it. - **Tree:** `pagetree.Annotate` sets `containerFor`, `managedBy` and `hidden` on `/api/tree` nodes. `hideChildPages` now hides only *managed* children, and hides nothing when no loaded module manages them. - **Endpoints:** - `GET /api/modules` lists the loaded modules and their load errors. - `GET /api/module/entries` does search, sort, filters and paging on the server. Datetimes go out as RFC3339 through one shared helper (`content.DateRFC3339`), native TOML datetimes included. - **Web:** - Feature modules come from the server; the rail, route repair, `return` validation and the error banner know them. Nothing judges a module id before the module list has loaded. - In a feature module the page tree is selection-only, and pages that aren't containers are greyed but still expand. With no container selected the module shows "Select a News page in the tree" and selects nothing. - The module screen offers list/cards views, search, sort, filters and paging, all kept in the URL, plus a "New <type>" button that uses the shared create dialog. - The entry editor has Properties and Content tabs and the toolbar: back to the list, Save, Save & close, View, Publish, Undo, Discard… and the page actions (Rename…, Enable/Disable, Delete…). Its document controls are shared with PagePane through `PageEditorChrome`. - The page module offers "Manage in News" on a News page and "Open in News" on a news item. - The List module is removed. - **`harness add` fills gaps:** re-running it on an installed feature writes only the missing bundle files and never touches an existing path. The receipt records just the written files, and its version becomes the bundle's current one. It reports `added`; `--json` stays `formatVersion: 1`. - **News bundle:** ships `editor/modules/news.yaml`; manifest version 2. - **Docs:** `scaffold/site/AGENTS.md` and the README cover all of the above. ## Verification - `make test`: web 65 files / 1804 tests and all Go packages pass. `go vet ./...` and `npx tsc --noEmit` are clean. - In a real browser (Playwright + Chromium) against `harness serve`, on a site with news, two News pages and a git remote, I went through every acceptance criterion and all passed: - the rail, the selection-only tree and the prompt; - list and card views; search, sort, filters and paging surviving reload and Back; - creating an item; the tabs, Save & close and the element edit page's return; - Rename, Enable/Disable, Delete, Publish, Undo and Discard; - hiding in the tree, "Manage in News" / "Open in News"; - deleting the module file; broken and reserved-id module files in the banner; - an RFC3339 date for a native TOML datetime. - Gap filling, run with the CLI: - On a simulated version-1 install it adds `editor/modules/news.yaml` and reports `added` with only that file and version 2. - When the receipt still lists the file but it was deleted, it rewrites it. - A hand-written file at that path is left alone and not recorded. - On an up-to-date site it reports `already-installed`. - `harness remove news` deletes the module file. ## Notes - Load errors beyond the issue's list (all documented in AGENTS.md): an empty `search: []` or `edit: []`; a page type, filter or tab listed twice; `columns` on a cards view or `card` on a list view; a filter field that is `select` in one listed type and `multiselect` in another. - Entries missing the sort field sort last in both directions; for news that means undated items come last. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(modules): editor modules declared in YAML; News module replaces the List module
All checks were successful
ci / build-and-test (pull_request) Successful in 9m16s
640d8df753
A new sidecar family, editor/modules/<name>.yaml, lets a feature bundle or a
site owner add an editor module: which page types it manages, list/cards
views, search, sort, filters and paging, and the entry editor's tabs.

- internal/editormodule: strict loader validated against the page types,
  with per-file load errors, reserved ids (page, view, media, edit, api,
  internal) and one module per page type.
- pagetree.Annotate marks containers, entries and hidden pages;
  hideChildPages now hides only managed children, and nothing when no
  module manages them.
- GET /api/modules and GET /api/module/entries (server-side search, sort,
  filters, paging; RFC3339 datetimes via one shared conversion).
- Web: modules come from the server (rail, route repair, return
  validation, error banner); selection-only page tree with greyed
  non-containers; module screen with URL-backed state and "New <type>";
  entry editor with Properties/Content tabs, Save/Save & close, View,
  Publish, Undo, Discard and page actions; "Manage in"/"Open in" in the
  page module. The List module is removed; its create dialog is shared.
- harness add on an installed feature fills gaps: writes missing bundle
  files only, records just those, bumps the receipt version, reports added.
- News bundle ships editor/modules/news.yaml (bundle version 2).
- AGENTS.md and README document modules, the tree, hideChildPages, the
  create dialog and gap filling.

Closes #153

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

This was generated by AI while landing a PR.

Validation: FAIL (one blocker)

Signal relied on: CI ci / build-and-test is green (not re-run). The title is Conventional Commits, Closes #153 is present, and the head already contains main (no conflict). The diff was reviewed against #153's spec.

Blocker

After Undo, Discard… or a title-only Rename…, the entry editor's Properties form shows stale values, and the next Save writes them back.

  • Where: web/src/components/EntryEditor.tsx:533-538, with web/src/components/PageSettings.tsx:75 and web/src/stores/pageEditor.ts:249-290.
  • Cause:
    • The settings form stays mounted on purpose, so tab switches keep edits.
    • Its createResource(() => props.page, fetchPageSettings) only re-runs when the page's identity changes, and that identity survives tree refetches.
    • Undo and Discard refresh the page body (mutate / refetch in pageEditor) but not the settings.
    • A rename that keeps the path changes nothing the form watches.
  • Effect:
    • The form keeps showing the old front matter and still reads as clean.
    • Save and Save & close submit every field (EntryEditor.tsx:285-301), so the next save silently reverts the Undo, the Discard or the new title.
    • Renaming the title and then editing any other field is a normal sequence.
  • Reproduced with probe tests:
    • After Undo there was no settings re-fetch (1 → 1). The teaser still showed the pre-undo value, and Save & close posted it.
    • After a same-path rename the Title field kept the old title.
  • Spec: #153 requires Undo, Discard… and Rename… to work from the entry editor. The existing tests only check the API call and the URL.
  • Fix: have the settings form re-fetch after any page operation that keeps the entry open (Undo, a Discard that doesn't remove the page, a same-path rename). Add a test that saves after each one.

Concerns (non-blocking)

  1. The client fetches the module list once and never again (web/src/stores/modules.ts:132), while /api/tree's module annotations (hidden, containerFor, managedBy) are recomputed on every 2 s poll.
    • Running harness add news, or writing a module file, while the editor is open hides the news items in the tree, but the rail gets no News entry until a reload. "Manage in news" then leads to route repair back to /page/….
    • Deleting a module file leaves a stale rail entry.
    • That breaks the rule that a hidden page always has a module that lists it.
    • Fix: refetch the modules alongside the tree poll, or when the annotations change.
  2. A TOML local date counts as "no date" (internal/content/meta.go:87, DateRFC3339).
    • go-toml v2 decodes date = 2026-12-24 as toml.LocalDate, and a datetime without an offset as toml.LocalDateTime. cast.ToTimeE rejects both.
    • Such items lose values.date and sort last under date desc. Offset datetimes work.
    • The page tree's Node.Date already had this gap before this PR, but this helper is now the single place to fix it: handle interface{ AsTime(*time.Location) time.Time } first, as Hugo's htime.ToTimeInDefaultLocationE does.
  3. The search box drops a trailing space (web/src/components/ModuleScreen.tsx:295-319). After the 300 ms debounce it copies the trimmed q back into the input, so typing "summer ", pausing, then "party" gives "summerparty".
  4. A failed plain Save started from the Content tab shows no error (EntryEditor.tsx:285-287). Save & close switches to Properties on failure, but Save doesn't, so the inline errors land on the hidden panel.
  5. Sorting by title panics when a listed page type declares title as number or datetime (internal/server/modules.go:417-426).
    • resolveSort picks the kind from the declared type, but key() always returns a string for title, so compare fails a type assertion.
    • Unlikely config. Fix: check FieldTitle first.
  6. The README overstates the upgrade path (README.md:1397-1399). It says an older installedVersion "is brought up to date by re-running harness add".
    • That's false when no bundle file is missing on disk, for example when the owner hand-wrote editor/modules/news.yaml. add then reports already-installed and the version stays at 1.
    • That behaviour is what #153 asked for; only the sentence needs qualifying.
  7. Test gaps:
    • an older entries response arriving after a newer one (ModuleScreen.tsx:256-283);
    • add's partial-write recording (internal/addcmd/add.go:474);
    • the refusal of a symlinked or non-regular receipt.

Minor

  • internal/editormodule/load.go:474-478: card text: title is rejected unless the type declares title. That matches the spec's wording, but doc.go says "Built-ins count as title=text", and the error message contradicts itself. Align the doc and the message, or allow it.

Checked and correct

  • The loader and its load errors, reserved ids, and ownership of page types across modules.
  • Tree annotation and the narrowed hideChildPages.
  • The endpoints' search, filters, paging and status codes; no path traversal.
  • harness add gap filling: Lstat and O_EXCL safety, receipt merge, already-installed.
  • AGENTS.md and README prose (apart from concern 6).
  • No List-module leftovers.
  • The selection-only tree, route repair and return handling before the module list loads.
  • The PagePane refactor: no regressions found.
> *This was generated by AI while landing a PR.* ## Validation: FAIL (one blocker) **Signal relied on:** CI `ci / build-and-test` is green (not re-run). The title is Conventional Commits, `Closes #153` is present, and the head already contains `main` (no conflict). The diff was reviewed against #153's spec. ### Blocker **After Undo, Discard… or a title-only Rename…, the entry editor's Properties form shows stale values, and the next Save writes them back.** - **Where:** `web/src/components/EntryEditor.tsx:533-538`, with `web/src/components/PageSettings.tsx:75` and `web/src/stores/pageEditor.ts:249-290`. - **Cause:** - The settings form stays mounted on purpose, so tab switches keep edits. - Its `createResource(() => props.page, fetchPageSettings)` only re-runs when the page's identity changes, and that identity survives tree refetches. - Undo and Discard refresh the page body (`mutate` / `refetch` in `pageEditor`) but not the settings. - A rename that keeps the path changes nothing the form watches. - **Effect:** - The form keeps showing the old front matter and still reads as clean. - Save and Save & close submit every field (`EntryEditor.tsx:285-301`), so the next save silently reverts the Undo, the Discard or the new title. - Renaming the title and then editing any other field is a normal sequence. - **Reproduced** with probe tests: - After Undo there was no settings re-fetch (1 → 1). The teaser still showed the pre-undo value, and Save & close posted it. - After a same-path rename the Title field kept the old title. - **Spec:** #153 requires Undo, Discard… and Rename… to work from the entry editor. The existing tests only check the API call and the URL. - **Fix:** have the settings form re-fetch after any page operation that keeps the entry open (Undo, a Discard that doesn't remove the page, a same-path rename). Add a test that saves after each one. ### Concerns (non-blocking) 1. **The client fetches the module list once and never again** (`web/src/stores/modules.ts:132`), while `/api/tree`'s module annotations (`hidden`, `containerFor`, `managedBy`) are recomputed on every 2 s poll. - Running `harness add news`, or writing a module file, while the editor is open hides the news items in the tree, but the rail gets no News entry until a reload. "Manage in news" then leads to route repair back to `/page/…`. - Deleting a module file leaves a stale rail entry. - That breaks the rule that a hidden page always has a module that lists it. - Fix: refetch the modules alongside the tree poll, or when the annotations change. 2. **A TOML local date counts as "no date"** (`internal/content/meta.go:87`, `DateRFC3339`). - go-toml v2 decodes `date = 2026-12-24` as `toml.LocalDate`, and a datetime without an offset as `toml.LocalDateTime`. `cast.ToTimeE` rejects both. - Such items lose `values.date` and sort last under `date desc`. Offset datetimes work. - The page tree's `Node.Date` already had this gap before this PR, but this helper is now the single place to fix it: handle `interface{ AsTime(*time.Location) time.Time }` first, as Hugo's `htime.ToTimeInDefaultLocationE` does. 3. **The search box drops a trailing space** (`web/src/components/ModuleScreen.tsx:295-319`). After the 300 ms debounce it copies the trimmed `q` back into the input, so typing "summer ", pausing, then "party" gives "summerparty". 4. **A failed plain Save started from the Content tab shows no error** (`EntryEditor.tsx:285-287`). Save & close switches to Properties on failure, but Save doesn't, so the inline errors land on the hidden panel. 5. **Sorting by `title` panics** when a listed page type declares `title` as `number` or `datetime` (`internal/server/modules.go:417-426`). - `resolveSort` picks the kind from the declared type, but `key()` always returns a string for `title`, so `compare` fails a type assertion. - Unlikely config. Fix: check `FieldTitle` first. 6. **The README overstates the upgrade path** (`README.md:1397-1399`). It says an older `installedVersion` "is brought up to date by re-running `harness add`". - That's false when no bundle file is missing on disk, for example when the owner hand-wrote `editor/modules/news.yaml`. `add` then reports `already-installed` and the version stays at 1. - That behaviour is what #153 asked for; only the sentence needs qualifying. 7. **Test gaps:** - an older entries response arriving after a newer one (`ModuleScreen.tsx:256-283`); - `add`'s partial-write recording (`internal/addcmd/add.go:474`); - the refusal of a symlinked or non-regular receipt. ### Minor - `internal/editormodule/load.go:474-478`: card `text: title` is rejected unless the type declares `title`. That matches the spec's wording, but `doc.go` says "Built-ins count as title=text", and the error message contradicts itself. Align the doc and the message, or allow it. ### Checked and correct - The loader and its load errors, reserved ids, and ownership of page types across modules. - Tree annotation and the narrowed `hideChildPages`. - The endpoints' search, filters, paging and status codes; no path traversal. - `harness add` gap filling: Lstat and O_EXCL safety, receipt merge, `already-installed`. - AGENTS.md and README prose (apart from concern 6). - No List-module leftovers. - The selection-only tree, route repair and `return` handling before the module list loads. - The `PagePane` refactor: no regressions found.
fix(modules): address the #155 review — entry form follows page operations, module list kept fresh
All checks were successful
ci / build-and-test (pull_request) Successful in 8m49s
2b3600e18e
Blocker:
- The entry editor's Properties form re-reads the page after Undo, a
  Discard… that keeps the entry, a rename that keeps its path, and a sync
  (the pageEditor store's `reloads` and a same-path rename counter key the
  form). Before, it kept the old front matter, read as clean, and the next
  Save wrote it back over the operation. A re-read drops unsaved edits, so
  with any, Undo and Rename… first ask in the unsaved-changes dialog, and
  the Discard… confirmation says they go too.

Concerns:
- The module list is polled at the tree's interval, applying only an answer
  that changed (no banner resurfacing, no churn) and never piling on a fetch
  in flight, so the rail, route repair and the banner follow a module file
  added, broken or deleted while the editor is open. The shell mounts a
  module body by id, so a changed declaration updates an open module in
  place instead of remounting it under unsaved edits.
- content.DateRFC3339 reads TOML local dates and datetimes (via AsTime, in
  UTC, as Hugo does); such items keep values.date and sort among the dated.
- The search box no longer eats a space typed before the debounce commits.
- A failed Save started from the Content tab brings Properties forward,
  like Save & close; neither navigates once the editor has been left.
- Sorting by title no longer panics when a listed type declares title as a
  number or datetime.
- README: re-running `harness add` moves installedVersion only when it
  writes a missing file.
- Tests: a late entries answer is dropped; a gap fill's partial write stays
  recorded (the writer is injected, so the test is root-proof); a receipt
  behind a symlinked directory, or itself a symlink, is refused.

Minor:
- A card's `text` may be the built-in title: the placement rule, doc.go and
  the load error now agree, the card shows the entry's title, and AGENTS.md
  and the README say so.

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

[autoland] verdict: pass

This was generated by AI while landing a PR.

Validation: CONCERNS (non-blocking) — approved

Signal relied on: CI ci / build-and-test is green on head 2b3600e (run 140: web build and tests, go vet, go test, go build), so it was not re-run. The title is Conventional Commits, Closes #153 is present, and the head already contains main (3df59bd), so there is no conflict.

The full diff was reviewed in the earlier FAIL pass. This pass reviewed the fix commit 2b3600e against each finding from that pass.

Earlier findings: all addressed

  • Blocker (stale Properties form after Undo, Discard… or a same-path Rename…): fixed.
    • The form now remounts on editor.reloads() (restores plus sync reloads) and on a same-path rename counter. This is the same keyed pattern EditPane already uses.
    • Undo and Rename… go through the unsaved-changes prompt, and the Discard… dialog warns that unsaved edits go too.
    • Tests save after each of these operations.
  • Module list freshness: fixed. The list is polled at the tree's interval, only a changed answer is applied, a failed poll keeps the last good list, and the module body is keyed on its id.
  • TOML local dates: fixed. DateRFC3339 checks for AsTime before falling back to cast.
  • Search box eating a trailing space: fixed.
  • Failed Save from the Content tab: fixed. It now brings Properties forward, and nothing navigates once the editor has been left.
  • Title-sort panic: fixed. FieldTitle is checked first.
  • README upgrade sentence: corrected.
  • Test gaps: filled (late entries answer, partial write with an injected writer, symlinked receipt).
  • Minor (card text: title): the rule, doc.go, the error message, AGENTS.md and the README now agree.

Concern (non-blocking)

Undo, then "Save & continue" in the prompt, reverts the edits the author chose to save.

  • Where: web/src/components/EntryEditor.tsx (afterUnsavedPrompt and undoAction).
  • Cause: Undo is single-step and restores the snapshot before the last one (internal/server/git.go handlePageUndo). Saving takes a new snapshot, so the Undo that follows reverts exactly that save, not the earlier change the author wanted undone.
  • Effect: the author ends up where "Discard" would have left them. The edits are recoverable with a second Undo (redo-by-undo), and this answer is untested: the new test covers only Stay and Discard.
  • Possible follow-up: offer only Stay and Discard for Undo, or reword that choice.
[autoland] verdict: pass > *This was generated by AI while landing a PR.* ## Validation: CONCERNS (non-blocking) — approved **Signal relied on:** CI `ci / build-and-test` is green on head `2b3600e` (run 140: web build and tests, `go vet`, `go test`, `go build`), so it was not re-run. The title is Conventional Commits, `Closes #153` is present, and the head already contains `main` (3df59bd), so there is no conflict. The full diff was reviewed in the earlier FAIL pass. This pass reviewed the fix commit `2b3600e` against each finding from that pass. ### Earlier findings: all addressed - **Blocker (stale Properties form after Undo, Discard… or a same-path Rename…):** fixed. - The form now remounts on `editor.reloads()` (restores plus sync reloads) and on a same-path rename counter. This is the same keyed pattern EditPane already uses. - Undo and Rename… go through the unsaved-changes prompt, and the Discard… dialog warns that unsaved edits go too. - Tests save after each of these operations. - **Module list freshness:** fixed. The list is polled at the tree's interval, only a changed answer is applied, a failed poll keeps the last good list, and the module body is keyed on its id. - **TOML local dates:** fixed. `DateRFC3339` checks for `AsTime` before falling back to cast. - **Search box eating a trailing space:** fixed. - **Failed Save from the Content tab:** fixed. It now brings Properties forward, and nothing navigates once the editor has been left. - **Title-sort panic:** fixed. `FieldTitle` is checked first. - **README upgrade sentence:** corrected. - **Test gaps:** filled (late entries answer, partial write with an injected writer, symlinked receipt). - **Minor (card `text: title`):** the rule, `doc.go`, the error message, AGENTS.md and the README now agree. ### Concern (non-blocking) **Undo, then "Save & continue" in the prompt, reverts the edits the author chose to save.** - **Where:** `web/src/components/EntryEditor.tsx` (`afterUnsavedPrompt` and `undoAction`). - **Cause:** Undo is single-step and restores the snapshot before the last one (`internal/server/git.go` `handlePageUndo`). Saving takes a new snapshot, so the Undo that follows reverts exactly that save, not the earlier change the author wanted undone. - **Effect:** the author ends up where "Discard" would have left them. The edits are recoverable with a second Undo (redo-by-undo), and this answer is untested: the new test covers only Stay and Discard. - **Possible follow-up:** offer only Stay and Discard for Undo, or reword that choice.
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!155
No description provided.