From 3084344d24bc8ba62d7f43a9316188d4264600d9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 11 Nov 2026 21:12:55 +0100 Subject: [PATCH 1/3] feat: edit taxonomy terms in the page editor Closes #31 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 +++ src/admin/admin-css.ts | 5 ++ src/admin/editor/taxonomy.ts | 63 +++++++++++++++ src/admin/islands/PageEditor.tsx | 81 ++++++++++++++++++- src/admin/routes/api/pages/[...path]/index.ts | 11 +++ tests/admin/editor_taxonomy_test.ts | 49 +++++++++++ 6 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/admin/editor/taxonomy.ts create mode 100644 tests/admin/editor_taxonomy_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eda0569..bb3ad1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Added + +- **The page editor can edit taxonomy terms (#31).** For each taxonomy the site defines (`site.taxonomies`), the + sidebar shows the page's terms as removable chips with an input to add more (Enter, comma or leaving the field), and + offers the terms already used on the site as suggestions. It writes `taxonomy.` in the frontmatter, works without + a blueprint, and a blueprint that declares its own `taxonomy` field still wins. Values it does not understand (a bare + string, an object, numbers) are shown as not editable and left untouched. `GET /admin/api/pages/:path` now also + returns `taxonomies` (name → terms in use, most used first). + ### Fixed - **Signing in no longer ends the account's other admin sessions (#30).** Login revoked every existing session of the diff --git a/src/admin/admin-css.ts b/src/admin/admin-css.ts index 5002816..f1d0cb0 100644 --- a/src/admin/admin-css.ts +++ b/src/admin/admin-css.ts @@ -108,6 +108,11 @@ export function adminCss(rtl: boolean): string { /* Badges */ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } + .tax-terms { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; } + .tax-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px 2px 8px; border-radius: 4px; font-size: 12px; background: var(--bg); border: 1px solid var(--border); } + .tax-chip button { border: 0; background: none; cursor: pointer; padding: 0 4px; color: var(--text-muted); font-size: 14px; line-height: 1; } + .tax-chip button:hover { color: var(--text); } + .tax-note { font-size: 12px; color: var(--text-muted); } .badge-md { background: #ebf8ff; color: #2b6cb0; } .badge-mdx { background: #f0fff4; color: #276749; } .badge-tsx { background: #faf5ff; color: #6b46c1; } diff --git a/src/admin/editor/taxonomy.ts b/src/admin/editor/taxonomy.ts new file mode 100644 index 0000000..440504f --- /dev/null +++ b/src/admin/editor/taxonomy.ts @@ -0,0 +1,63 @@ +/** + * Pure helpers for the page editor's taxonomy-terms control. + * + * Terms live in frontmatter as `taxonomy.: [slug, ...]`. The control only + * edits what it understands: a list of strings under an object `taxonomy`. + * Anything else (a bare string, a nested object, a `taxonomy` that is not an + * object) is reported as not editable so the editor leaves it untouched. + */ + +type Fm = Record; + +function asTaxonomyObject(fm: Fm): Record | null | undefined { + const t = fm.taxonomy; + if (t === undefined || t === null) return undefined; + if (typeof t === "object" && !Array.isArray(t)) return t as Record; + return null; // present but not an object +} + +/** + * The page's terms for taxonomy `name`: a list (empty when none are set), or + * `null` when the stored value is not a plain list of strings and must not be + * rewritten. + */ +export function taxonomyTerms(fm: Fm, name: string): string[] | null { + const tax = asTaxonomyObject(fm); + if (tax === null) return null; + if (tax === undefined || !(name in tax)) return []; + const v = tax[name]; + if (v === undefined || v === null) return []; + if (Array.isArray(v) && v.every((x) => typeof x === "string")) return v as string[]; + return null; +} + +/** True when `fm.taxonomy` can be edited at all (absent, or an object). */ +export function taxonomyEditable(fm: Fm): boolean { + return asTaxonomyObject(fm) !== null; +} + +/** + * The new `taxonomy` value after setting `name` to `terms`, keeping every other + * taxonomy as it was. An empty list removes the name; an empty result is + * `null`, which the page PUT handler treats as "delete the key". + */ +export function withTaxonomyTerms(fm: Fm, name: string, terms: string[]): Record | null { + const current = asTaxonomyObject(fm); + const next: Record = { ...(current ?? {}) }; + if (terms.length === 0) delete next[name]; + else next[name] = terms; + return Object.keys(next).length === 0 ? null : next; +} + +/** Trim a typed term; null when nothing is left. */ +export function normalizeTerm(raw: string): string | null { + const t = raw.trim().replace(/,+$/, "").trim(); + return t === "" ? null : t; +} + +/** `terms` plus `raw` when it is a new non-empty term, else the same list. */ +export function addTerm(terms: string[], raw: string): string[] { + const t = normalizeTerm(raw); + if (t === null || terms.includes(t)) return terms; + return [...terms, t]; +} diff --git a/src/admin/islands/PageEditor.tsx b/src/admin/islands/PageEditor.tsx index 40eba85..aa3a303 100644 --- a/src/admin/islands/PageEditor.tsx +++ b/src/admin/islands/PageEditor.tsx @@ -5,6 +5,7 @@ */ import { useState, useEffect, useRef } from "preact/hooks"; +import { addTerm, taxonomyEditable, taxonomyTerms, withTaxonomyTerms } from "../editor/taxonomy.ts"; interface BpField { type: string; @@ -26,6 +27,8 @@ interface PageData { language?: string; translations?: Array<{ lang: string; sourcePath: string; exists: boolean }>; blueprint?: { title: string; fields: Record } | null; + /** The site's taxonomies → terms already in use (suggestions). */ + taxonomies?: Record; revisionCount?: number; } @@ -49,8 +52,10 @@ const HARDCODED_FIELD_KEYS = ["title", "template", "slug", "published", "date", export function unmanagedFrontmatterKeys( fm: Record, blueprintFields?: Record, + /** Further keys that have a built-in control (e.g. `taxonomy` when the site declares taxonomies). */ + managedKeys: string[] = [], ): string[] { - const known = new Set([...HARDCODED_FIELD_KEYS, ...Object.keys(blueprintFields ?? {})]); + const known = new Set([...HARDCODED_FIELD_KEYS, ...Object.keys(blueprintFields ?? {}), ...managedKeys]); return Object.keys(fm).filter((k) => !known.has(k)); } @@ -103,6 +108,62 @@ function pagePathUrlSegment(pagePath: string): string { return pagePath.split("/").map(encodeURIComponent).join("/"); } +/** Add/remove terms of one taxonomy; writes `taxonomy.` through `onChange`. */ +function TaxonomyField( + { name, terms, suggestions, onChange }: { + name: string; + terms: string[] | null; + suggestions: string[]; + onChange: (terms: string[]) => void; + }, +) { + const [draft, setDraft] = useState(""); + const listId = `tax-suggest-${name}`; + if (terms === null) { + return ( +
+ +
This page stores {name} in a form the editor does not change (not a plain list). Edit the file to change it.
+
+ ); + } + function commit() { + const next = addTerm(terms!, draft); + if (next !== terms) onChange(next); + setDraft(""); + } + return ( +
+ +
+ {terms.map((t) => ( + + {t} + + + ))} +
+ setDraft((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commit(); + } + }} + onBlur={commit} + /> + + {suggestions.filter((s) => !terms.includes(s)).map((s) => +
+ ); +} + export default function PageEditor({ pagePath, prefix, embedded = false }: Props) { const apiBase = `${prefix}/api`; @@ -189,7 +250,13 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props return
{error || "Page not found."}
; } - const unmanagedKeys = unmanagedFrontmatterKeys(fm, page.blueprint?.fields); + // Built-in taxonomy control: one per site taxonomy, unless the blueprint + // declares its own `taxonomy` field (blueprint fields win), and only while + // `taxonomy` is something it can edit. + const taxonomyNames = Object.keys(page.taxonomies ?? {}); + const showTaxonomy = taxonomyNames.length > 0 && !(page.blueprint && "taxonomy" in page.blueprint.fields) && + taxonomyEditable(fm); + const unmanagedKeys = unmanagedFrontmatterKeys(fm, page.blueprint?.fields, showTaxonomy ? ["taxonomy"] : []); return (
@@ -283,6 +350,16 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props />
+ {showTaxonomy && taxonomyNames.map((name) => ( + setFmField("taxonomy", withTaxonomyTerms(fm, name, terms))} + /> + ))} + {/* Blueprint extra fields */} {page.blueprint && Object.entries(page.blueprint.fields).map(([key, field]) => (
diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index e9121c5..ffad551 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -57,6 +57,17 @@ export const handler = { template: page.template, frontmatter: page.frontmatter, rawContent: page.rawContent, media: page.media.map((m) => ({ name: m.name, url: m.url, type: m.type, size: m.size })), blueprint, + // The site's taxonomies and the terms already in use, for the + // editor's taxonomy control (most-used first). + taxonomies: Object.fromEntries( + (ctx.state.adminContext.config.site.taxonomies ?? []).map((name) => [ + name, + Object.entries(engine.taxonomyMap[name] ?? {}) + .sort((x, y) => y[1].length - x[1].length || x[0].localeCompare(y[0])) + .slice(0, 500) + .map(([term]) => term), + ]), + ), }); } catch (err) { return serverError(err); diff --git a/tests/admin/editor_taxonomy_test.ts b/tests/admin/editor_taxonomy_test.ts new file mode 100644 index 0000000..dc0d0ab --- /dev/null +++ b/tests/admin/editor_taxonomy_test.ts @@ -0,0 +1,49 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { addTerm, normalizeTerm, taxonomyEditable, taxonomyTerms, withTaxonomyTerms } from "../../src/admin/editor/taxonomy.ts"; +import { unmanagedFrontmatterKeys } from "../../src/admin/islands/PageEditor.tsx"; + +Deno.test("taxonomyTerms: list, absent and non-list values", () => { + assertEquals(taxonomyTerms({ taxonomy: { tag: ["a", "b"] } }, "tag"), ["a", "b"]); + assertEquals(taxonomyTerms({}, "tag"), []); + assertEquals(taxonomyTerms({ taxonomy: { category: ["x"] } }, "tag"), []); + assertEquals(taxonomyTerms({ taxonomy: { tag: null } }, "tag"), []); + // not a plain list of strings: must not be edited + assertEquals(taxonomyTerms({ taxonomy: { tag: "solo" } }, "tag"), null); + assertEquals(taxonomyTerms({ taxonomy: { tag: [1, 2] } }, "tag"), null); + assertEquals(taxonomyTerms({ taxonomy: { tag: { a: 1 } } }, "tag"), null); + assertEquals(taxonomyTerms({ taxonomy: "oops" }, "tag"), null); +}); + +Deno.test("taxonomyEditable: absent or object only", () => { + assertEquals(taxonomyEditable({}), true); + assertEquals(taxonomyEditable({ taxonomy: null }), true); + assertEquals(taxonomyEditable({ taxonomy: { tag: ["a"] } }), true); + assertEquals(taxonomyEditable({ taxonomy: ["a"] }), false); + assertEquals(taxonomyEditable({ taxonomy: "a" }), false); +}); + +Deno.test("withTaxonomyTerms: keeps other taxonomies and unknown values", () => { + const fm = { taxonomy: { tag: ["a"], category: "keep-as-is", other: [1] } }; + assertEquals(withTaxonomyTerms(fm, "tag", ["a", "b"]), { tag: ["a", "b"], category: "keep-as-is", other: [1] }); + assertEquals(withTaxonomyTerms(fm, "tag", []), { category: "keep-as-is", other: [1] }); +}); + +Deno.test("withTaxonomyTerms: creates the object, and null when it ends up empty", () => { + assertEquals(withTaxonomyTerms({}, "tag", ["a"]), { tag: ["a"] }); + assertEquals(withTaxonomyTerms({ taxonomy: { tag: ["a"] } }, "tag", []), null); +}); + +Deno.test("addTerm / normalizeTerm: trim, dedupe, ignore blanks", () => { + assertEquals(normalizeTerm(" x, "), "x"); + assertEquals(normalizeTerm(" "), null); + assertEquals(addTerm(["a"], " b "), ["a", "b"]); + const same = ["a"]; + assertEquals(addTerm(same, "a") === same, true); + assertEquals(addTerm(same, " ") === same, true); +}); + +Deno.test("unmanagedFrontmatterKeys: taxonomy is managed only when the control is shown", () => { + const fm = { title: "x", taxonomy: { tag: ["a"] }, extra: 1 }; + assertEquals(unmanagedFrontmatterKeys(fm), ["taxonomy", "extra"]); + assertEquals(unmanagedFrontmatterKeys(fm, undefined, ["taxonomy"]), ["extra"]); +}); From 72ef0260ce00dcb962b3c701b4c1ffe4475225fa Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 11:15:59 +0100 Subject: [PATCH 2/3] fix(page-editor): taxonomy terms match case-insensitively; Save works right after typing a term addTerm matches existing terms case-insensitively (core keys terms by the raw string) and reuses the spelling already used on the site. A pasted 'a, b' becomes two terms. The Unsaved badge is always laid out so the blur that dirties the page no longer moves Save out from under the click, and save() reads the latest frontmatter from a ref. Co-Authored-By: Claude Sonnet 5.5 --- src/admin/admin-css.ts | 1 + src/admin/editor/taxonomy.ts | 18 ++++++++++++++---- src/admin/islands/PageEditor.tsx | 25 ++++++++++++++++++++----- tests/admin/editor_taxonomy_test.ts | 15 ++++++++++++++- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/admin/admin-css.ts b/src/admin/admin-css.ts index f1d0cb0..ac314cb 100644 --- a/src/admin/admin-css.ts +++ b/src/admin/admin-css.ts @@ -153,6 +153,7 @@ export function adminCss(rtl: boolean): string { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 4px; padding: 2px 8px; } + .toolbar-dirty-idle { visibility: hidden; } .editor-body { display: flex; flex: 1; min-height: 0; } .editor-sidebar { width: 260px; flex-shrink: 0; overflow-y: auto; diff --git a/src/admin/editor/taxonomy.ts b/src/admin/editor/taxonomy.ts index 440504f..c8f764f 100644 --- a/src/admin/editor/taxonomy.ts +++ b/src/admin/editor/taxonomy.ts @@ -55,9 +55,19 @@ export function normalizeTerm(raw: string): string | null { return t === "" ? null : t; } -/** `terms` plus `raw` when it is a new non-empty term, else the same list. */ -export function addTerm(terms: string[], raw: string): string[] { +/** + * `terms` plus `raw` when it is a new non-empty term, else the same list. + * + * Core keys terms by the raw string, case-sensitively, so "Foo" and "foo" would + * be two separate terms. To avoid accidental duplicates the match is + * case-insensitive: a term already on the page is a no-op, and a hit in + * `known` (terms used elsewhere on the site) reuses that spelling. + */ +export function addTerm(terms: string[], raw: string, known: string[] = []): string[] { const t = normalizeTerm(raw); - if (t === null || terms.includes(t)) return terms; - return [...terms, t]; + if (t === null) return terms; + const lower = t.toLowerCase(); + if (terms.some((x) => x.toLowerCase() === lower)) return terms; + const existing = known.find((x) => x === t) ?? known.find((x) => x.toLowerCase() === lower); + return [...terms, existing ?? t]; } diff --git a/src/admin/islands/PageEditor.tsx b/src/admin/islands/PageEditor.tsx index aa3a303..676bc24 100644 --- a/src/admin/islands/PageEditor.tsx +++ b/src/admin/islands/PageEditor.tsx @@ -128,7 +128,8 @@ function TaxonomyField( ); } function commit() { - const next = addTerm(terms!, draft); + // A pasted "a, b" or text typed past a comma becomes several terms. + const next = draft.split(",").reduce((acc, part) => addTerm(acc, part, suggestions), terms!); if (next !== terms) onChange(next); setDraft(""); } @@ -170,7 +171,17 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props const [loading, setLoading] = useState(true); const [page, setPage] = useState(null); const [rawContent, setRawContent] = useState(""); - const [fm, setFm] = useState>({}); + const [fm, setFmState] = useState>({}); + // Latest frontmatter, updated synchronously. save() reads this rather than + // the `fm` of the render it was created in: a click on Save that follows + // directly on the blur that committed a typed taxonomy term can run before + // Preact has re-rendered, and would otherwise save the stale value. + const fmRef = useRef>({}); + function setFm(next: Record | ((prev: Record) => Record)) { + const value = typeof next === "function" ? next(fmRef.current) : next; + fmRef.current = value; + setFmState(value); + } const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); @@ -211,7 +222,7 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props headers: { "Content-Type": "application/json", "X-CSRF-Token": getCsrf() }, body: JSON.stringify({ content: rawContent, - frontmatter: { ...fm, title: fm.title ?? page.title }, + frontmatter: { ...fmRef.current, title: fmRef.current.title ?? page.title }, }), }); if (!res.ok) { @@ -295,7 +306,11 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props > History{page.revisionCount ? ` (${page.revisionCount})` : ""} - {dirty && Unsaved} + {/* Always rendered (hidden when clean): inserting it on the first change + re-wraps the toolbar and moves Save out from under a click that + started with the blur that made the page dirty (e.g. typing a + taxonomy term, then clicking Save without pressing Enter). */} + Unsaved @@ -356,7 +371,7 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props name={name} terms={taxonomyTerms(fm, name)} suggestions={page.taxonomies?.[name] ?? []} - onChange={(terms) => setFmField("taxonomy", withTaxonomyTerms(fm, name, terms))} + onChange={(terms) => setFmField("taxonomy", withTaxonomyTerms(fmRef.current, name, terms))} /> ))} diff --git a/tests/admin/editor_taxonomy_test.ts b/tests/admin/editor_taxonomy_test.ts index dc0d0ab..ebac0dc 100644 --- a/tests/admin/editor_taxonomy_test.ts +++ b/tests/admin/editor_taxonomy_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { assertEquals } from "@std/assert"; import { addTerm, normalizeTerm, taxonomyEditable, taxonomyTerms, withTaxonomyTerms } from "../../src/admin/editor/taxonomy.ts"; import { unmanagedFrontmatterKeys } from "../../src/admin/islands/PageEditor.tsx"; @@ -47,3 +47,16 @@ Deno.test("unmanagedFrontmatterKeys: taxonomy is managed only when the control i assertEquals(unmanagedFrontmatterKeys(fm), ["taxonomy", "extra"]); assertEquals(unmanagedFrontmatterKeys(fm, undefined, ["taxonomy"]), ["extra"]); }); + +Deno.test("addTerm: matches case-insensitively and reuses the existing spelling", () => { + // already on the page: no change, same list instance + const on = ["Foo"]; + assertEquals(addTerm(on, "foo") === on, true); + // known elsewhere on the site: reuse that spelling + assertEquals(addTerm([], "foo", ["Foo", "bar"]), ["Foo"]); + assertEquals(addTerm(["x"], " BAR ", ["bar"]), ["x", "bar"]); + // exact match wins over a case variant + assertEquals(addTerm([], "foo", ["Foo", "foo"]), ["foo"]); + // unknown: kept as typed + assertEquals(addTerm([], "Baz", ["foo"]), ["Baz"]); +}); From c559f5da556be6dbdda5faef012f0b0f67615201 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 11:16:40 +0100 Subject: [PATCH 3/3] test: give the page-GET test context a site config; changelog for the term matching and Save fix Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 ++++++- tests/admin/pages_edit_blueprint_test.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3ad1e..f67b2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,12 @@ follows [Semantic Versioning](https://semver.org). offers the terms already used on the site as suggestions. It writes `taxonomy.` in the frontmatter, works without a blueprint, and a blueprint that declares its own `taxonomy` field still wins. Values it does not understand (a bare string, an object, numbers) are shown as not editable and left untouched. `GET /admin/api/pages/:path` now also - returns `taxonomies` (name → terms in use, most used first). + returns `taxonomies` (name → terms in use, most used first). Terms are case-sensitive in core, so the control + matches what you type case-insensitively against the page's terms and the site's existing ones and reuses the + existing spelling instead of creating "Foo" next to "foo"; a pasted "a, b" becomes two terms. +- **Save no longer misses a click that follows typing in the editor.** The "Unsaved" badge now keeps its place in the + toolbar, so the blur that makes the page dirty cannot move Save out from under the click, and Save always sends the + latest frontmatter. ### Fixed diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index 08db562..23f7148 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -64,7 +64,7 @@ function makeCtx(opts: { services: makeServices(null, { getBlueprints: () => engine.blueprints as never }), engine, storage: opts.storage, - config: { system: { content: { dir: "content" } }, plugins: {} }, + config: { system: { content: { dir: "content" } }, site: {}, plugins: {} }, // authz.check() always allows — this test exercises page-edit // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now).