diff --git a/CHANGELOG.md b/CHANGELOG.md index eda0569..f67b2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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). 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 - **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..ac314cb 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; } @@ -148,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 new file mode 100644 index 0000000..c8f764f --- /dev/null +++ b/src/admin/editor/taxonomy.ts @@ -0,0 +1,73 @@ +/** + * 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. + * + * 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) 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 40eba85..676bc24 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,13 +108,80 @@ 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() { + // 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(""); + } + 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`; 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(""); @@ -150,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) { @@ -189,7 +261,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 (
@@ -228,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 @@ -283,6 +365,16 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props />
+ {showTaxonomy && taxonomyNames.map((name) => ( + setFmField("taxonomy", withTaxonomyTerms(fmRef.current, 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..ebac0dc --- /dev/null +++ b/tests/admin/editor_taxonomy_test.ts @@ -0,0 +1,62 @@ +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"; + +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"]); +}); + +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"]); +}); 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).