feat(categories): categories bundle managed in the editor as Hugo term pages #160

Merged
dominik.polakovics merged 2 commits from afk/152 into main 2026-10-07 09:04:05 +02:00

Closes #152

What

Categories become a site-wide feature, managed in the editor. A category is a Hugo term page, content/categories/<slug>/_index.md. Pages store its slug, and its title is the name shown everywhere.

Categories bundle (harness add categories)

  • What it ships:
    • the /categories/ page (hidden: true; Hugo also leaves taxonomy pages out of site.Home.Pages);
    • the categories and category page types;
    • the Categories module (Properties tab only, sorted by title);
    • taxonomy.html and term.html;
    • its own pager.
  • hugo.toml edits: the disableKinds, taxonomies and outputs steps moved here from news. The step IDs are unchanged.
  • Category pages: term.html draws each page with .Render "category-entry". Hugo resolves layouts/news-item/category-entry.html for news items, so they show as news cards; other pages fall back to the bundle's default (title link plus summary).
  • Removing: harness remove categories succeeds without --purge right after an add. The removecmd scan no longer counts an intact page the bundle shipped as content in use. Categories created beneath it still count, and --purge deletes them.

categories field type

  • Where it's allowed: page-type fields: and element params:.
  • Load errors: the type must be named categories, and a field named categories must have this type. It takes no options: or default:. Each violation is a load error that names the file.
  • Wire and storage: the client always sends a list. Front matter stores a list; shortcode params store comma-joined slugs.
  • Server checks: saves reject unknown slugs with a 422 (page properties, create, element and item saves). Page settings and module entries drop stale slugs.
  • New endpoint: GET /api/categories.
  • Web:
    • a CategoriesField widget shows titles and stores slugs;
    • every save path waits for a fresh category list, so valid slugs are never dropped before the list loads and stale slugs are never sent;
    • module columns, tags and filters show titles and filter by slug.

Rename and delete

  • Rename: the slug rewrite runs inside the same atomic relocate as the alias and link rewrites. It covers front-matter lists and categories params on any shortcode at any depth.
  • Delete: removes the slug everywhere; an emptied key or param is removed. Edits are precomputed and restored if a write fails. It returns {deleted, changed}.
  • Delete preview: GET /api/page/delete-plan returns the same counts, and the confirmation dialog states how many pages will change.
  • Guards:
    • the /categories/ slug is pinned on rename;
    • categories cannot be moved;
    • categories have no Enable/Disable;
    • new categories are created live, not as drafts (a draft term page loses its title in production builds).

News (version 4)

  • news-item and "Latest news" use the new type. "Latest news" shows items in any of the chosen categories, applies the limit after filtering, and shows all news when none are chosen.
  • No option lists to keep in sync, no taxonomy edits, no /categories/ templates.
  • One shared news-card.html partial draws every news card.

Docs

scaffold/site/AGENTS.md and the README cover:

  • the field type and its naming rule;
  • the bundle;
  • the one-line opt-in for other page types;
  • what rename and delete do;
  • the news changes.

Verification

  • go vet ./... and go test ./... pass with Hugo 0.163.3 on PATH. The theme, add and remove Hugo build tests ran, including the categories-only site, the term page for a category with no pages, drafts excluded, and /categories/ not in the nav.
  • cd web && npm run build && npm test passes (1853 tests), and tsc --noEmit is clean.
  • End to end against harness serve on a fresh site with categories and news, through the HTTP API and a Hugo build:
    • add and remove leave the site byte-identical to a fresh init;
    • create, assign, filter, rename (the old URL redirects) and delete all behave as specified, with delete-plan counts matching the actual delete;
    • load errors appear for each of the four rules.
  • The editor UI was not exercised in a browser. The UI behaviour is covered by vitest: titles, stale-slug handling, no Enable/Disable on categories, and the count line in the delete dialog.

🤖 Generated with Claude Code

Closes #152 ## What Categories become a site-wide feature, managed in the editor. A category is a Hugo term page, `content/categories/<slug>/_index.md`. Pages store its slug, and its title is the name shown everywhere. ### Categories bundle (`harness add categories`) - **What it ships:** - the `/categories/` page (`hidden: true`; Hugo also leaves taxonomy pages out of `site.Home.Pages`); - the `categories` and `category` page types; - the Categories module (Properties tab only, sorted by title); - `taxonomy.html` and `term.html`; - its own pager. - **`hugo.toml` edits:** the `disableKinds`, `taxonomies` and `outputs` steps moved here from news. The step IDs are unchanged. - **Category pages:** `term.html` draws each page with `.Render "category-entry"`. Hugo resolves `layouts/news-item/category-entry.html` for news items, so they show as news cards; other pages fall back to the bundle's default (title link plus summary). - **Removing:** `harness remove categories` succeeds without `--purge` right after an add. The removecmd scan no longer counts an intact page the bundle shipped as content in use. Categories created beneath it still count, and `--purge` deletes them. ### `categories` field type - **Where it's allowed:** page-type `fields:` and element `params:`. - **Load errors:** the type must be named `categories`, and a field named `categories` must have this type. It takes no `options:` or `default:`. Each violation is a load error that names the file. - **Wire and storage:** the client always sends a list. Front matter stores a list; shortcode params store comma-joined slugs. - **Server checks:** saves reject unknown slugs with a 422 (page properties, create, element and item saves). Page settings and module entries drop stale slugs. - **New endpoint:** `GET /api/categories`. - **Web:** - a `CategoriesField` widget shows titles and stores slugs; - every save path waits for a fresh category list, so valid slugs are never dropped before the list loads and stale slugs are never sent; - module columns, tags and filters show titles and filter by slug. ### Rename and delete - **Rename:** the slug rewrite runs inside the same atomic relocate as the alias and link rewrites. It covers front-matter lists and `categories` params on any shortcode at any depth. - **Delete:** removes the slug everywhere; an emptied key or param is removed. Edits are precomputed and restored if a write fails. It returns `{deleted, changed}`. - **Delete preview:** `GET /api/page/delete-plan` returns the same counts, and the confirmation dialog states how many pages will change. - **Guards:** - the `/categories/` slug is pinned on rename; - categories cannot be moved; - categories have no Enable/Disable; - new categories are created live, not as drafts (a draft term page loses its title in production builds). ### News (version 4) - `news-item` and "Latest news" use the new type. "Latest news" shows items in any of the chosen categories, applies the limit after filtering, and shows all news when none are chosen. - No option lists to keep in sync, no taxonomy edits, no `/categories/` templates. - One shared `news-card.html` partial draws every news card. ### Docs `scaffold/site/AGENTS.md` and the README cover: - the field type and its naming rule; - the bundle; - the one-line opt-in for other page types; - what rename and delete do; - the news changes. ## Verification - `go vet ./...` and `go test ./...` pass with Hugo 0.163.3 on PATH. The theme, add and remove Hugo build tests ran, including the categories-only site, the term page for a category with no pages, drafts excluded, and `/categories/` not in the nav. - `cd web && npm run build && npm test` passes (1853 tests), and `tsc --noEmit` is clean. - End to end against `harness serve` on a fresh site with categories and news, through the HTTP API and a Hugo build: - add and remove leave the site byte-identical to a fresh init; - create, assign, filter, rename (the old URL redirects) and delete all behave as specified, with delete-plan counts matching the actual delete; - load errors appear for each of the four rules. - The editor UI was not exercised in a browser. The UI behaviour is covered by vitest: titles, stale-slug handling, no Enable/Disable on categories, and the count line in the delete dialog. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(categories): categories bundle managed in the editor as Hugo term pages
All checks were successful
ci / build-and-test (pull_request) Successful in 9m26s
629834bcee
Categories become a site-wide feature instead of a news detail. A category
is a Hugo term page, content/categories/<slug>/_index.md: pages store its
slug, its title is the name shown in the editor and on the site.

- New `categories` feature bundle: the /categories/ page (kept out of the
  nav), the categories and category page types, the Categories module
  (Properties-only entries, sorted by title), the /categories/ and
  /categories/<slug>/ templates (pages drawn through a `category-entry`
  content view), and the taxonomy hugo.toml edits moved over from news.
  `harness remove categories` succeeds without --purge right after an add:
  an intact shipped page is not content in use.
- New `categories` field type for page-type fields and element params,
  bound to the name `categories` both ways, no options/default. Front
  matter stores a list, shortcode params comma-joined slugs. Saves reject
  unknown slugs; stale slugs are hidden and dropped at the next save.
  GET /api/categories lists the site's categories.
- Rename rewrites the slug in every page's front matter and every
  element's categories param alongside the alias and link rewrites;
  delete removes it everywhere (emptied key/param removed) and the
  confirmation states how many pages change (GET /api/page/delete-plan).
  The /categories/ slug is pinned, categories cannot be moved, have no
  Enable/Disable, and are created live rather than as drafts.
- Editor modules accept the type in columns, card tags and filters,
  showing category titles and filtering by slug.
- News (v4): news-item and "Latest news" use the new type ("Latest news"
  shows items in any chosen category, limit after filtering, none = all);
  no hand-kept option lists, taxonomy edits or /categories/ templates.
  News cards come from one shared news-card partial.
- AGENTS.md and README document the field type, the bundle, the opt-in,
  rename/delete and the news changes.

Closes #152

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.

Verdict: CONCERNS (non-blocking), approved; awaiting the maintainer's go-ahead to merge.

Signal: CI ci / build-and-test is green (go vet, go test with Hugo 0.163.3, web build and vitest). It was not re-run. The head already contains main, and there is no conflict.

Checked:

  • The full diff, against #152's contract and acceptance criteria. The Go engine and server, the web frontend, and the scaffold, docs and add/remove code were each reviewed; the reviewers ran scratch tests, built and ran the harness, and ran Hugo builds.
  • Rename and delete rewrites: they are atomic with the relocate and roll back on a failed write.
  • Delete-plan counts match the actual delete.
  • Load rules: all four.
  • Unknown slugs are rejected with a 422 on every save path.
  • Guards: the /categories/ slug stays pinned, categories can't be moved, and new categories are created live, not as drafts.
  • Templates: taxonomy.html, term.html, category-entry and the news-list filter.
  • harness remove categories succeeds without --purge right after an add.
  • News installed without categories builds and serves.
  • AGENTS.md and the README match the code.

Concerns (non-blocking, worth follow-up issues):

  1. harness remove categories --purge doesn't report the pages it rewrites.

    • internal/removecmd/purge.go:224 discards DeleteResult.Changed.
    • Deleting a category page now strips its slug from every page and element, but the blocked preview, the JSON usage and PurgeReport list only the category pages.
    • This contradicts the package doc (run.go:35: "the full preview of what a purge would do").
    • Nothing is lost that the docs don't describe. This is a reporting gap.
  2. A slow category list can still commit after Cancel. When a dialog has a categories field, submit now waits for GET /api/categories, with no guard while it waits.

    • web/src/rte/RichTextField.tsx:658 (chip dialog): Cancel or Escape during the load still inserts the chip, and a double-click inserts two.
    • web/src/components/CreateDialog.tsx:141: Cancel during the load still creates the page.
    • The shipped bundles don't trigger it; custom sidecars can.

Minor (below the bar, noted only):

  • A hand-edited scalar categories: \"a, b\" is split into two slugs by the editor (categoryrewrite.go:244, modules.go:378), but Hugo reads it as one term.
  • The module list doesn't trim entries the way the properties form does (modules.go:383).
  • Adding news, then categories, then removing news, then categories leaves hugo.toml one blank line short of a fresh init.
[autoland] verdict: pass > *This was generated by AI while landing a PR.* **Verdict: CONCERNS (non-blocking), approved; awaiting the maintainer's go-ahead to merge.** **Signal:** CI `ci / build-and-test` is green (go vet, go test with Hugo 0.163.3, web build and vitest). It was not re-run. The head already contains `main`, and there is no conflict. **Checked:** - The full diff, against #152's contract and acceptance criteria. The Go engine and server, the web frontend, and the scaffold, docs and add/remove code were each reviewed; the reviewers ran scratch tests, built and ran the harness, and ran Hugo builds. - Rename and delete rewrites: they are atomic with the relocate and roll back on a failed write. - Delete-plan counts match the actual delete. - Load rules: all four. - Unknown slugs are rejected with a 422 on every save path. - Guards: the `/categories/` slug stays pinned, categories can't be moved, and new categories are created live, not as drafts. - Templates: `taxonomy.html`, `term.html`, `category-entry` and the news-list filter. - `harness remove categories` succeeds without `--purge` right after an add. - News installed without categories builds and serves. - AGENTS.md and the README match the code. **Concerns (non-blocking, worth follow-up issues):** 1. **`harness remove categories --purge` doesn't report the pages it rewrites.** - `internal/removecmd/purge.go:224` discards `DeleteResult.Changed`. - Deleting a category page now strips its slug from every page and element, but the blocked preview, the JSON `usage` and `PurgeReport` list only the category pages. - This contradicts the package doc (`run.go:35`: "the full preview of what a purge would do"). - Nothing is lost that the docs don't describe. This is a reporting gap. 2. **A slow category list can still commit after Cancel.** When a dialog has a `categories` field, submit now waits for `GET /api/categories`, with no guard while it waits. - `web/src/rte/RichTextField.tsx:658` (chip dialog): Cancel or Escape during the load still inserts the chip, and a double-click inserts two. - `web/src/components/CreateDialog.tsx:141`: Cancel during the load still creates the page. - The shipped bundles don't trigger it; custom sidecars can. **Minor (below the bar, noted only):** - A hand-edited scalar `categories: \"a, b\"` is split into two slugs by the editor (`categoryrewrite.go:244`, `modules.go:378`), but Hugo reads it as one term. - The module list doesn't trim entries the way the properties form does (`modules.go:383`). - Adding news, then categories, then removing news, then categories leaves `hugo.toml` one blank line short of a fresh init.
- harness remove: deleting a category removes its slug from every remaining
  page, but the blocked preview and the purge report listed only the
  category pages. pageops.DeleteResult now names the rewritten pages
  (ChangedPages); the scan asks DeletePlan per root and reports them as
  ContentUsage.CategoriesRemovedFrom, Purge reports what the deletes
  actually rewrote (PurgeReport.CategoriesRemovedFrom), and the CLI prints
  them in text and as usage.categoriesRemovedFrom in --json.
- Purge now writes its element strips before deleting the roots: the
  category rewrite reads the pages from disk, so a strip computed earlier
  would otherwise write a removed slug back (a feature owning both category
  pages and element types).
- Chip dialog and create dialog: a commit waiting for the category list
  does nothing once the dialog has closed (Cancel, Escape, the backdrop,
  Remove), and the chip dialog's Insert/Save holds while it waits, so a
  double-click inserts one chip.
- AGENTS.md and the README document the new list.

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.

Re-validation of ce5b511. Verdict: PASS (approved); awaiting the maintainer's go-ahead to merge.

Signal: CI ci / build-and-test is green on ce5b511 (run 145). It was not re-run. The head still contains main, and there is no conflict.

Both concerns from the previous review are resolved:

  1. Purge reporting. pageops.DeleteResult gains ChangedPages (the HTTP API still sends only the counts).
    • The blocked preview gets the list from pageops.DeletePlan per root, and the purge result reports what the delete actually rewrote. Pages inside the purge set are left out.
    • The list is shown in the text output and as categoriesRemovedFrom in --json.
    • Strips are now written before the deletes, so pageops.Delete computes its category rewrites from the stripped bytes. In the old order, a strip could write a removed slug back.
    • Covered by TestPurgeStripsBeforeDeletingCategories, TestRunCategoriesRemovedFrom and TestRemoveCmdCategoriesRemovedFrom.
    • README and AGENTS.md are updated to match.
  2. Dialog commits after Cancel.
    • Both CreateDialog and ChipDialog set a closed flag on cleanup and commit nothing after the category list loads if it is set.
    • ChipDialog also holds its button while it waits, so a double-click inserts one chip.
    • Covered by three new vitest cases.

Minor (below the bar, unchanged from before):

  • A hand-edited scalar categories: \"a, b\" is split into two slugs by the editor, but Hugo reads it as one term.
  • The module list doesn't trim entries the way the properties form does.
  • Adding news, then categories, then removing news, then categories leaves hugo.toml one blank line short of a fresh init.
[autoland] verdict: pass > *This was generated by AI while landing a PR.* **Re-validation of `ce5b511`. Verdict: PASS (approved); awaiting the maintainer's go-ahead to merge.** **Signal:** CI `ci / build-and-test` is green on `ce5b511` (run 145). It was not re-run. The head still contains `main`, and there is no conflict. **Both concerns from the previous review are resolved:** 1. **Purge reporting.** `pageops.DeleteResult` gains `ChangedPages` (the HTTP API still sends only the counts). - The blocked preview gets the list from `pageops.DeletePlan` per root, and the purge result reports what the delete actually rewrote. Pages inside the purge set are left out. - The list is shown in the text output and as `categoriesRemovedFrom` in `--json`. - Strips are now written before the deletes, so `pageops.Delete` computes its category rewrites from the stripped bytes. In the old order, a strip could write a removed slug back. - Covered by `TestPurgeStripsBeforeDeletingCategories`, `TestRunCategoriesRemovedFrom` and `TestRemoveCmdCategoriesRemovedFrom`. - README and AGENTS.md are updated to match. 2. **Dialog commits after Cancel.** - Both `CreateDialog` and `ChipDialog` set a `closed` flag on cleanup and commit nothing after the category list loads if it is set. - `ChipDialog` also holds its button while it waits, so a double-click inserts one chip. - Covered by three new vitest cases. **Minor (below the bar, unchanged from before):** - A hand-edited scalar `categories: \"a, b\"` is split into two slugs by the editor, but Hugo reads it as one term. - The module list doesn't trim entries the way the properties form does. - Adding news, then categories, then removing news, then categories leaves `hugo.toml` one blank line short of a fresh init.
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!160
No description provided.