Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` 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
Expand Down
6 changes: 6 additions & 0 deletions src/admin/admin-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions src/admin/editor/taxonomy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Pure helpers for the page editor's taxonomy-terms control.
*
* Terms live in frontmatter as `taxonomy.<name>: [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<string, unknown>;

function asTaxonomyObject(fm: Fm): Record<string, unknown> | null | undefined {
const t = fm.taxonomy;
if (t === undefined || t === null) return undefined;
if (typeof t === "object" && !Array.isArray(t)) return t as Record<string, unknown>;
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<string, unknown> | null {
const current = asTaxonomyObject(fm);
const next: Record<string, unknown> = { ...(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];
}
102 changes: 97 additions & 5 deletions src/admin/islands/PageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { useState, useEffect, useRef } from "preact/hooks";
import { addTerm, taxonomyEditable, taxonomyTerms, withTaxonomyTerms } from "../editor/taxonomy.ts";

interface BpField {
type: string;
Expand All @@ -26,6 +27,8 @@ interface PageData {
language?: string;
translations?: Array<{ lang: string; sourcePath: string; exists: boolean }>;
blueprint?: { title: string; fields: Record<string, BpField> } | null;
/** The site's taxonomies → terms already in use (suggestions). */
taxonomies?: Record<string, string[]>;
revisionCount?: number;
}

Expand All @@ -49,8 +52,10 @@ const HARDCODED_FIELD_KEYS = ["title", "template", "slug", "published", "date",
export function unmanagedFrontmatterKeys(
fm: Record<string, unknown>,
blueprintFields?: Record<string, BpField>,
/** 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));
}

Expand Down Expand Up @@ -103,13 +108,80 @@ function pagePathUrlSegment(pagePath: string): string {
return pagePath.split("/").map(encodeURIComponent).join("/");
}

/** Add/remove terms of one taxonomy; writes `taxonomy.<name>` 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 (
<div class="form-group">
<label>{name}</label>
<div class="tax-note">This page stores {name} in a form the editor does not change (not a plain list). Edit the file to change it.</div>
</div>
);
}
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 (
<div class="form-group">
<label>{name}</label>
<div class="tax-terms">
{terms.map((t) => (
<span class="tax-chip" key={t}>
{t}
<button type="button" aria-label={`Remove ${t}`} onClick={() => onChange(terms.filter((x) => x !== t))}>×</button>
</span>
))}
</div>
<input
type="text"
list={listId}
value={draft}
placeholder={`Add ${name}…`}
onInput={(e) => setDraft((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
}
}}
onBlur={commit}
/>
<datalist id={listId}>
{suggestions.filter((s) => !terms.includes(s)).map((s) => <option key={s} value={s} />)}
</datalist>
</div>
);
}

export default function PageEditor({ pagePath, prefix, embedded = false }: Props) {
const apiBase = `${prefix}/api`;

const [loading, setLoading] = useState(true);
const [page, setPage] = useState<PageData | null>(null);
const [rawContent, setRawContent] = useState("");
const [fm, setFm] = useState<Record<string, unknown>>({});
const [fm, setFmState] = useState<Record<string, unknown>>({});
// 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<Record<string, unknown>>({});
function setFm(next: Record<string, unknown> | ((prev: Record<string, unknown>) => Record<string, unknown>)) {
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("");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -189,7 +261,13 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props
return <div class="s-d36754cf">{error || "Page not found."}</div>;
}

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 (
<div class="editor-layout">
Expand Down Expand Up @@ -228,7 +306,11 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props
>
History{page.revisionCount ? ` (${page.revisionCount})` : ""}
</a>
{dirty && <span class="toolbar-dirty">Unsaved</span>}
{/* 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). */}
<span class={dirty ? "toolbar-dirty" : "toolbar-dirty toolbar-dirty-idle"} aria-hidden={dirty ? undefined : "true"}>Unsaved</span>
<button type="button" class="btn btn-sm btn-primary" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save"}
</button>
Expand Down Expand Up @@ -283,6 +365,16 @@ export default function PageEditor({ pagePath, prefix, embedded = false }: Props
/>
</div>

{showTaxonomy && taxonomyNames.map((name) => (
<TaxonomyField
key={name}
name={name}
terms={taxonomyTerms(fm, name)}
suggestions={page.taxonomies?.[name] ?? []}
onChange={(terms) => setFmField("taxonomy", withTaxonomyTerms(fmRef.current, name, terms))}
/>
))}

{/* Blueprint extra fields */}
{page.blueprint && Object.entries(page.blueprint.fields).map(([key, field]) => (
<div class="form-group" key={key}>
Expand Down
11 changes: 11 additions & 0 deletions src/admin/routes/api/pages/[...path]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
62 changes: 62 additions & 0 deletions tests/admin/editor_taxonomy_test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
2 changes: 1 addition & 1 deletion tests/admin/pages_edit_blueprint_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading