smoodly 0.0.13 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +75 -15
  2. package/dist/admin/editor/EditorView.js +12 -10
  3. package/dist/admin/editor/PageInspector.d.ts +21 -0
  4. package/dist/admin/editor/PageInspector.js +24 -0
  5. package/dist/admin/editor/PageSettings.d.ts +1 -3
  6. package/dist/admin/editor/PageSettings.js +7 -6
  7. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  8. package/dist/admin/editor/SeoSettings.js +15 -0
  9. package/dist/admin/forms/FieldWidget.d.ts +1 -1
  10. package/dist/admin/forms/FieldWidget.js +24 -4
  11. package/dist/admin/ops-impl.js +72 -11
  12. package/dist/admin/serialize.d.ts +4 -0
  13. package/dist/admin/serialize.js +2 -0
  14. package/dist/admin/tree-ops.d.ts +2 -0
  15. package/dist/admin/tree-ops.js +5 -1
  16. package/dist/admin/ui/primitives.d.ts +9 -0
  17. package/dist/admin/ui/primitives.js +6 -0
  18. package/dist/admin/ui/resize.d.ts +1 -1
  19. package/dist/admin/ui/resize.js +1 -1
  20. package/dist/admin/ui/theme.js +16 -1
  21. package/dist/admin/validate.js +24 -3
  22. package/dist/collections.d.ts +6 -5
  23. package/dist/entry-store.d.ts +18 -1
  24. package/dist/entry-store.js +27 -2
  25. package/dist/field-defaults.d.ts +8 -0
  26. package/dist/field-defaults.js +18 -0
  27. package/dist/fields.d.ts +26 -1
  28. package/dist/fields.js +31 -1
  29. package/dist/index.d.ts +8 -6
  30. package/dist/index.js +5 -3
  31. package/dist/next/index.d.ts +2 -0
  32. package/dist/next/index.js +1 -0
  33. package/dist/next/meta.d.ts +7 -3
  34. package/dist/next/meta.js +18 -9
  35. package/dist/next/page-renderer.js +1 -1
  36. package/dist/next/queries.d.ts +36 -0
  37. package/dist/next/queries.js +87 -0
  38. package/dist/page-tree.d.ts +3 -0
  39. package/dist/page-tree.js +6 -0
  40. package/dist/page.d.ts +11 -3
  41. package/dist/page.js +26 -0
  42. package/dist/pairing.d.ts +2 -1
  43. package/dist/pairing.js +2 -9
  44. package/dist/render.d.ts +14 -7
  45. package/dist/render.js +2 -2
  46. package/dist/resolve.d.ts +4 -0
  47. package/dist/resolve.js +8 -0
  48. package/dist/revalidate.d.ts +6 -0
  49. package/dist/revalidate.js +6 -0
  50. package/dist/shared.d.ts +7 -7
  51. package/dist/site.d.ts +23 -1
  52. package/dist/site.js +83 -19
  53. package/dist/store.d.ts +4 -0
  54. package/dist/store.js +9 -0
  55. package/dist/supabase-entry-store.js +32 -10
  56. package/dist/supabase-store.d.ts +1 -0
  57. package/dist/supabase-store.js +13 -0
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -255,9 +255,12 @@ Implemented:
255
255
  collection list. The editor
256
256
  is keyed by id, with a locale switcher loading that locale's own tree
257
257
  and pointing the canvas at `urls[locale]` and, while no block is
258
- selected, page settings (`admin/editor/PageSettings.tsx`: the title and
259
- slug of the ACTIVE locale through `pages.rename`; SEO/meta fields stay
260
- OQ #17). The entry form prefixes its slug field with the collection's
258
+ selected, page settings (`admin/editor/PageInspector.tsx`, spec
259
+ 2026-09-10 page fields: **SEO & Social** — title, description, social
260
+ image, hide from search engines, under the tree's `meta` — and
261
+ **Settings** — the ACTIVE locale's title and slug through `pages.rename`,
262
+ the template's page fields under the tree's `fields`, remove from
263
+ locale). The entry form prefixes its slug field with the collection's
261
264
  mount path. The serialized
262
265
  registry carries `collections[].path`/`depth`, `pages[].slugs` and
263
266
  `pageDepth`, and `defineConfig` validates `homePageSlug` — a real
@@ -278,12 +281,27 @@ Implemented:
278
281
  is the two in sequence — `locate` then `load` — for a caller that just
279
282
  wants the hit. A folder (null template), an
280
283
  unpublished record outside draft mode, or a missing one is `null`.
281
- On top of it the published-only content API for menus, sitemaps and
282
- custom routes: `getPagesTree({ locale })` (nested
283
- `{ id, title, path, hasPage, children }`), `getPage(pathOrId)`,
284
- `getEntries(collection, { locale, parent?, order? })`,
285
- `getEntry(collection, idOrPath)` (an id, a path, or a bare slug naming a
286
- root entry) and `getEntriesTree(collection)`.
284
+ On top of it the content API for menus, sitemaps and custom routes,
285
+ published-only unless `draft: true` (spec 2026-09-11):
286
+ `getPagesTree({ locale, draft? })` (nested
287
+ `{ id, title, path, hasPage, fields, children }`), `getPage(pathOrId)`,
288
+ `getEntries(collection, { locale, parent?, order?, where?, limit?, draft? })`
289
+ — `where` is equality / any-of on shared fields and `limit` a SQL limit,
290
+ both run by the store — `getEntry(collection, idOrPath)` (an id, a
291
+ path, or a bare slug naming a root entry) and `getEntriesTree(collection)`.
292
+ In the app, read through `q` (next bullet) rather than the site layer.
293
+ - `createSmoodlyQueries` (`smoodly/next`, spec 2026-09-11) — the read
294
+ API the scaffold exports as `q`: `getPageTree({ locale })`,
295
+ `getPage(pathOrId)`, `getEntries("articles", { where, order, limit,
296
+ parent })`, `getEntry("articles", idOrPath)` (an id or a path — a bare
297
+ slug is refused, since its unit would sit under a tag no op fires) and
298
+ `getShared("header")`. Typed by the registry (names are checked, fields
299
+ typed), the live draft inside the editor preview, and outside it one
300
+ `unstable_cache` unit per distinct call under a tag the admin ops
301
+ expire: `pages:tree` (every page op that changes the listing),
302
+ `entries:<collection>` (every entry write), `page:<id>`, `entry:<id>`,
303
+ `shared:<name>`. `filterPageTree(items, keep)` prunes a tree for a nav
304
+ (a dropped parent drops its subtree).
287
305
  - `smoodly/next` — `createPageRenderer`, the site-route seam over that
288
306
  layer, bound once by the app like `createAdminOpHandler`. The two route
289
307
  files call `renderSmoodlyPath(segments, { locale?, searchParams? })` and
@@ -340,7 +358,8 @@ Implemented:
340
358
  registers a visual item, `smoodly.shared({ name, title, fields })` an
341
359
  object item; one registration materializes as a singleton entry in
342
360
  every supported locale. `site.getShared(item, { locale })` and the Next
343
- glue's `getSmoodlyShared(item, { locale })` fetch it code-side; the
361
+ glue's `q.getShared("footer", { locale })` fetch it code-side (the
362
+ renderer's `getSmoodlyShared(item, { locale })` still works); the
344
363
  editor places a visual item in a zone from the "+" picker as a linked
345
364
  `{ type: "shared", fields: { item, ref } }` node the resolve pass
346
365
  rewrites into the section, and `z.locked(footer)` binds a zone to one.
@@ -407,6 +426,52 @@ after both are on the registry. From the repo root:
407
426
  `prepublishOnly` rebuilds each package. The Claude Code skill
408
427
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
409
428
 
429
+ ## Follow-ups (logged 2026-09-11, queries spec)
430
+
431
+ - `where` on localized fields (a join on the locale row), ranges and
432
+ negation; any-of over an array-valued field (any-of compares the text
433
+ form of the field, so it covers selects, refs and numbers; on a
434
+ checkboxes, refList or list field `site.getEntries` refuses it rather
435
+ than match nothing — both adapters agree on the text form, so a
436
+ one-element array never matches its element).
437
+ - `where` filters a versioned collection's LIVE row, not its published
438
+ snapshot: an entry whose published version had `kind: "x"` and whose
439
+ draft changed it to `"y"` drops out of `where: { kind: "x" }` before
440
+ the change is published. The published gate is per locale row, the
441
+ filter is on the node's shared fields. Fix by filtering the snapshot
442
+ (a join on the version row) or by documenting that a versioned
443
+ collection's shared fields are live for filtering.
444
+ - A GIN index on `entries.fields` when a customer's collection is large
445
+ enough to need it; additive, no migration.
446
+ - OQ 26: whether page units should carry `pages:tree` too, replacing
447
+ the neighbour list.
448
+ - Scripts and the seed write through the stores and expire nothing; a
449
+ `revalidate` hook for out-of-band writers.
450
+ - `getEntriesTree` has no `q` counterpart yet; add one when a site
451
+ needs a nested collection nav.
452
+ - `getEntry` by id goes through `site.getEntry`, which lists the whole
453
+ collection to find one row; a primary-key read first would be cheaper
454
+ on the uncached path (draft mode, first miss).
455
+
456
+ ## Follow-ups (logged 2026-09-10, page fields and SEO spec)
457
+
458
+ - SEO for entry pages: entries have no `meta`; a `titleField`-based
459
+ title and a description field are the next step.
460
+ - A `styles` block on pages, when a case needs presentation separate
461
+ from content.
462
+ - Refs in page fields, once the refs index walks them.
463
+ - A canonical URL field and a search-result preview snippet in the
464
+ SEO & Social tab.
465
+ - Configurable SEO set (`meta: { seo: { twitter: true } }`) if the four
466
+ values prove too few.
467
+ - Link fields are raw: an image in a page field reaches a nav link as
468
+ `{ asset, alt }`, not a URL. Resolve on demand if a nav ever wants one.
469
+ - The social image URL goes through the default `next` loader at 1200px;
470
+ the cloud's transform loader (OQ 28) should serve a real 1200×630.
471
+ - Page-field labels derive from the key (`showInNavigation` → "Show In
472
+ Navigation"), as every field's do; a `.label()` builder option would
473
+ read better for long keys.
474
+
410
475
  ## Follow-ups (logged 2026-09-10, inline editing spec)
411
476
 
412
477
  - Shared placements ignore field clicks: their fields live in the shared
@@ -713,9 +778,6 @@ the toolbar instead of pushing the content.
713
778
  in the single-language scaffold). Adding `locale` to `PageContext` and
714
779
  `EntryPageProps` would remove that dependency and let sections that
715
780
  format dates stay synchronous.
716
- - The content API is published-only, so the Articles index in the editor
717
- canvas lists published articles while the hero above it is the draft.
718
- A draft-aware `getEntries` for draft mode is the fix if that confuses.
719
781
  - The image widget edits the URL only; `alt` is set by the seed and cannot
720
782
  be changed in the admin.
721
783
  - A per-locale collection path or fixed slug must name the default locale;
@@ -923,8 +985,6 @@ the toolbar instead of pushing the content.
923
985
  - Nested entries have no admin UI beyond `entries.move`: nothing creates
924
986
  a child entry or shows the entry tree, and the entry form's slug prefix
925
987
  is the collection's mount base only, not the parent chain.
926
- - SEO/meta fields in page settings — DESIGN.md OQ #17. Title and slug per
927
- locale shipped; nothing writes `PageTree.meta` yet.
928
988
  - `pages.get` makes four sequential store round trips for a non-root page
929
989
  (`getPage`, `getDraftTree`, `pathsOf`, and the parent read behind
930
990
  `parentLocales`; three for a root, which has no parent to read), and the
@@ -20,10 +20,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
20
20
  // The pieces live beside it — `useCanvasBridge` (geometry and messages),
21
21
  // `usePageLocale` (switch/add/remove), `CanvasOverlay` and
22
22
  // `overlay-geometry` (the chrome above the iframe), `InsertPicker`,
23
- // `Outline`, `PageSettings`, `NoticePane`.
23
+ // `Outline`, `PageInspector`, `NoticePane`.
24
24
  //
25
25
  // Chrome follows the design system's visual-editor screen: top bar, toned
26
- // canvas around a white page, 300px inspector holding fields then outline.
26
+ // canvas around a white page, 360px inspector holding fields then outline.
27
27
  //
28
28
  // Core code: no next/* imports live here — the bridge is the only piece
29
29
  // that knows about Next, and it ships to the site, not the admin.
@@ -33,14 +33,14 @@ import { descriptorAt, getAtPath } from "../field-paths.js";
33
33
  import { FieldWidget } from "../forms/FieldWidget.js";
34
34
  import { revealField } from "../forms/reveal.js";
35
35
  import { tabOf, tabsFor } from "../forms/tabs.js";
36
- import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
36
+ import { allowedSections, canInsert, fillLockedZones, findNode, insertNode, moveNode, moveNodeTo, newNodeId, nodeFromSample, patchNode, patchNodePath, patchPage, placementNode, removeNode, zoneOf, } from "../tree-ops.js";
37
37
  import { useDraftAutosave } from "./autosave.js";
38
38
  import { CanvasOverlay } from "./CanvasOverlay.js";
39
39
  import { InlineRichtext } from "./InlineRichtext.js";
40
40
  import { InsertPicker } from "./InsertPicker.js";
41
41
  import { NoticePane } from "./NoticePane.js";
42
42
  import { Outline } from "./Outline.js";
43
- import { PageSettings } from "./PageSettings.js";
43
+ import { PageInspector } from "./PageInspector.js";
44
44
  import { localeFromUrl } from "./locale-url.js";
45
45
  import { plusButtonsFor } from "./overlay-geometry.js";
46
46
  import { useCanvasBridge } from "./useCanvasBridge.js";
@@ -438,6 +438,8 @@ export function EditorView({ ops, registry, id }) {
438
438
  const doMove = (id, dir) => mutate((t) => moveNode(t, id, dir));
439
439
  const doMoveTo = (id, index) => mutate((t) => moveNodeTo(t, id, index));
440
440
  const doPatch = (id, ns, key, value) => mutate((t) => patchNode(t, id, ns, key, value));
441
+ /** A page-level value (page fields, SEO meta) — same dirty/debounce path as a node's (spec 2026-09-10 page fields §5). */
442
+ const doPatchPage = (ns, key, value) => mutate((t) => patchPage(t, ns, key, value));
441
443
  // ── publish ──────────────────────────────────────────────────────────
442
444
  const doPublish = async () => {
443
445
  setBanner(null);
@@ -539,16 +541,16 @@ export function EditorView({ ops, registry, id }) {
539
541
  if (!node || !section)
540
542
  return null;
541
543
  return (_jsx(InlineRichtext, { mount: inline.richtext, pageWidth: pageWidth, value: getAtPath(node.fields, session.path), toolset: descriptorAt(section, session.path)?.toolset, onChange: (doc) => { inline.markChanged(); mutate((t) => patchNodePath(t, session.node, session.path, doc)); }, onEscape: inline.end }));
542
- })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : (_jsx("div", { className: "sm-inspector__head", children: _jsx("span", { className: "sm-label", children: "Nothing selected \u2014 page settings below" }) })), _jsxs("div", { className: "sm-inspector__body", children: [!selected && record && (_jsx(_Fragment, { children: _jsx(PageSettings, { record: record, locale: locale, registry: registry, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onRemoveLocale: Object.keys(record.locales).length > 1 &&
543
- !isFixedNode(record, registry) &&
544
- !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
545
- ? () => { void doRemoveLocale(); }
546
- : undefined }) })), selected && selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
544
+ })()] }) })] }), _jsx(ResizeHandle, {}), _jsxs("aside", { className: "sm-inspector", ref: inspectorRef, children: [_jsxs("div", { className: "sm-card", children: [selected && (selectedSection || selectedShared) ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sm-inspector__head", children: [_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-blockglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: selectedSection?.title ?? selectedShared?.title })] }), _jsx(IconButton, { label: "Clear selection", onClick: () => setSelectedId(null), children: "\u00D7" })] }), tabs.length > 0 && (_jsx(Tabs, { tabs: tabs.map((t) => t.tab), value: activeTab, onChange: (t) => setTab(t) }))] })) : record ? (_jsx(PageInspector, { record: record, locale: locale, registry: registry, tree: tree, path: paths[locale], fixed: isFixedNode(record, registry), onRename: onRename, onPatch: doPatchPage, onRemoveLocale: Object.keys(record.locales).length > 1 &&
545
+ !isFixedNode(record, registry) &&
546
+ !(record.parentId === null && record.locales[registry.locales.default]?.slug === registry.homePageSlug)
547
+ ? () => { void doRemoveLocale(); }
548
+ : undefined })) : null, selected && (selectedSection || selectedShared) && (_jsxs("div", { className: "sm-inspector__body", children: [selectedSection && entries.map(({ key, ns, descriptor }) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: selected[ns]?.[key], refOptions: refOptionsFor(refOptions, refTargets, selectedSection, ns, key, descriptor), inlinePath: ns === "fields" && inline.session?.node === selected.id ? inline.session.path : undefined, onEditHere: () => { const p = inline.session?.path; inline.end(); if (p)
547
549
  reveal(p, selectedSection); }, onChange: (value) => {
548
550
  doPatch(selected.id, ns, key, value);
549
551
  if (ns === "fields")
550
552
  syncTexts(selected.id, selectedSection, { ...selected.fields, [key]: value });
551
- } }, `${selected.id}.${ns}.${key}`))), selected && selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] })] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
553
+ } }, `${selected.id}.${ns}.${key}`))), selectedShared && (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 10 }, children: [_jsx("p", { className: "sm-hint", children: "This is shared content. Editing it changes every page it is placed on." }), _jsx(RouteLink, { className: "sm-nav-item", href: `${BASE}/shared/${encodeURIComponent(selectedShared.name)}?locale=${encodeURIComponent(locale)}`, children: "Edit shared item \u2192" })] }))] }))] }), _jsx("div", { className: "sm-card", children: _jsx(Outline, { selectedId: selectedId, onSelect: select, onMove: doMove, onMoveTo: doMoveTo, onRemove: doRemove, zones: zoneOrder.map((zone) => ({
552
554
  zone, locked: policyOf(zone)?.kind === "locked", canTake: zoneCanTake(zone),
553
555
  nodes: nodesIn(zone).map((node) => ({ node, label: labelOf(node) })),
554
556
  })), onAddFirst: (zone) => {
@@ -0,0 +1,21 @@
1
+ import type { PageTree } from "../../render.tsx";
2
+ import type { PageRecord } from "../../store.ts";
3
+ import type { AdminPageTemplate, AdminRegistry } from "../serialize.ts";
4
+ export type PageTab = "SEO & Social" | "Settings";
5
+ export declare function pageTabsFor(template: AdminPageTemplate | undefined): PageTab[];
6
+ export declare function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }: {
7
+ record: PageRecord;
8
+ locale: string;
9
+ registry: AdminRegistry;
10
+ tree: PageTree | null;
11
+ /** The page's path in this locale (the `paths` index). */
12
+ path: string | undefined;
13
+ fixed: boolean;
14
+ onRename(patch: {
15
+ title?: string;
16
+ slug?: string;
17
+ }): Promise<string | null>;
18
+ /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
19
+ onRemoveLocale?: () => void;
20
+ onPatch(ns: "fields" | "meta", key: string, value: unknown): void;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The inspector with nothing selected (spec 2026-09-10 page fields §5):
4
+ // the page's title as the head, then SEO & Social (first and default when
5
+ // the template enables it) and Settings — title, slug, the template's
6
+ // page fields, remove from locale. Page tabs keep their own state and
7
+ // reset when the page changes.
8
+ import { useEffect, useState } from "react";
9
+ import { FieldWidget } from "../forms/FieldWidget.js";
10
+ import { Button, Tabs } from "../ui/primitives.js";
11
+ import { PageSettings } from "./PageSettings.js";
12
+ import { SeoSettings } from "./SeoSettings.js";
13
+ export function pageTabsFor(template) {
14
+ return template?.seo ? ["SEO & Social", "Settings"] : ["Settings"];
15
+ }
16
+ export function PageInspector({ record, locale, registry, tree, path, fixed, onRename, onRemoveLocale, onPatch }) {
17
+ const template = registry.pages.find((p) => p.name === record.template);
18
+ const tabs = pageTabsFor(template);
19
+ const [tab, setTab] = useState(tabs[0]);
20
+ useEffect(() => { setTab(pageTabsFor(template)[0]); }, [record.id]); // eslint-disable-line react-hooks/exhaustive-deps
21
+ const active = tabs.includes(tab) ? tab : tabs[0];
22
+ const title = record.locales[locale]?.title ?? "";
23
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "sm-inspector__head", children: _jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8, minWidth: 0 }, children: [_jsx("span", { className: "sm-pageglyph" }), _jsx("span", { className: "sm-title", style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: title })] }) }), tabs.length > 1 && _jsx(Tabs, { tabs: tabs, value: active, onChange: setTab }), _jsx("div", { className: "sm-inspector__body", children: active === "SEO & Social" ? (_jsx(SeoSettings, { meta: tree?.meta ?? {}, onPatch: (key, value) => onPatch("meta", key, value) })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(PageSettings, { record: record, locale: locale, registry: registry, path: path, fixed: fixed, onRename: onRename }), Object.entries(template?.fields ?? {}).map(([key, descriptor]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: descriptor, value: tree?.fields?.[key], onChange: (value) => onPatch("fields", key, value) }, key))), onRemoveLocale && _jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] })] })) })] }));
24
+ }
@@ -1,6 +1,6 @@
1
1
  import type { PageRecord } from "../../store.ts";
2
2
  import type { AdminRegistry } from "../serialize.ts";
3
- export declare function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }: {
3
+ export declare function PageSettings({ record, locale, registry, path, fixed, onRename }: {
4
4
  record: PageRecord;
5
5
  locale: string;
6
6
  registry: AdminRegistry;
@@ -12,6 +12,4 @@ export declare function PageSettings({ record, locale, registry, path, fixed, on
12
12
  title?: string;
13
13
  slug?: string;
14
14
  }): Promise<string | null>;
15
- /** Absent when the locale cannot be removed: a fixed node, the site root, or the page's last locale. */
16
- onRemoveLocale?: () => void;
17
15
  }): import("react").JSX.Element | null;
@@ -3,12 +3,13 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  // Page settings (spec 2026-09-05 §7; per locale since spec 2026-09-06
4
4
  // §6): the ACTIVE locale's title and slug, saved through pages.rename,
5
5
  // and "Remove from this locale". The switcher in the canvas meta row
6
- // selects the locale, so there are no per-locale tabs here. SEO/meta fields are OQ
7
- // #17's own design pass. Rendered in the inspector while no block is
8
- // selected.
6
+ // selects the locale, so there are no per-locale tabs here. Rendered on
7
+ // the Settings tab of PageInspector while no block is selected; the SEO
8
+ // tab and the remove-from-locale button live there (spec 2026-09-10 page
9
+ // fields §5).
9
10
  import { useEffect, useState } from "react";
10
- import { Button, Field, SectionLabel } from "../ui/primitives.js";
11
- export function PageSettings({ record, locale, registry, path, fixed, onRename, onRemoveLocale }) {
11
+ import { Field, SectionLabel } from "../ui/primitives.js";
12
+ export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
12
13
  const row = record.locales[locale];
13
14
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
14
15
  const [error, setError] = useState(null);
@@ -36,5 +37,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename,
36
37
  void commit(key);
37
38
  }
38
39
  };
39
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), onRemoveLocale && (_jsxs(Button, { variant: "secondary", onClick: onRemoveLocale, children: ["Remove from ", locale] }))] }));
40
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) })] }));
40
41
  }
@@ -0,0 +1,7 @@
1
+ export declare const SEO_TITLE_MAX = 60;
2
+ export declare const SEO_DESCRIPTION_MAX = 160;
3
+ export type SeoKey = "title" | "description" | "ogImage" | "noindex";
4
+ export declare function SeoSettings({ meta, onPatch }: {
5
+ meta: Record<string, unknown>;
6
+ onPatch(key: SeoKey, value: unknown): void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
+ import { MediaWidget } from "../forms/MediaWidget.js";
4
+ import { Field, Switch } from "../ui/primitives.js";
5
+ export const SEO_TITLE_MAX = 60;
6
+ export const SEO_DESCRIPTION_MAX = 160;
7
+ const str = (v) => (typeof v === "string" ? v : "");
8
+ function Counter({ n, max }) {
9
+ return _jsxs("span", { className: n > max ? "sm-counter--over" : undefined, children: [n, "/", max] });
10
+ }
11
+ export function SeoSettings({ meta, onPatch }) {
12
+ const title = str(meta.title);
13
+ const description = str(meta.description);
14
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Title", counter: _jsx(Counter, { n: title.length, max: SEO_TITLE_MAX }), hint: "Used in search results and link previews. Empty: the page title.", children: _jsx("input", { className: "sm-input", value: title, onChange: (e) => onPatch("title", e.target.value) }) }), _jsx(Field, { label: "Description", counter: _jsx(Counter, { n: description.length, max: SEO_DESCRIPTION_MAX }), children: _jsx("textarea", { className: "sm-textarea", rows: 3, value: description, onChange: (e) => onPatch("description", e.target.value) }) }), _jsx(Field, { label: "Social image", hint: "1200\u00D7630 recommended", asDiv: true, children: _jsx(MediaWidget, { descriptor: { type: "image" }, value: meta.ogImage, onChange: (v) => onPatch("ogImage", v) }) }), _jsx(Field, { label: "Hide from search engines", asDiv: true, children: _jsx(Switch, { checked: meta.noindex === true, onChange: (v) => onPatch("noindex", v), label: "Hide from search engines" }) })] }));
15
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
3
+ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
4
4
  descriptor: AdminField;
5
5
  value: unknown;
6
6
  onChange(value: unknown): void;
@@ -3,12 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { RichtextWidget } from "./RichtextWidget.js";
4
4
  import { MediaWidget } from "./MediaWidget.js";
5
5
  import { ListField } from "./ListField.js";
6
- import { Button, Field, IconButton } from "../ui/primitives.js";
6
+ import { optionsOf } from "../../fields.js";
7
+ import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
7
8
  import { patchObject } from "./list.js";
8
9
  const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
9
- export function FieldWidget({ descriptor: d, value, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
10
+ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
10
11
  const path = pathProp ?? fieldKey;
11
12
  const message = errors?.[path];
13
+ // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
14
+ const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
12
15
  const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
13
16
  let control;
14
17
  let counter;
@@ -27,9 +30,26 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
27
30
  case "number":
28
31
  control = (_jsx("input", { className: "sm-input", type: "number", value: value == null ? "" : String(value), onChange: (e) => onChange(e.target.value === "" ? undefined : Number(e.target.value)) }));
29
32
  break;
33
+ case "boolean":
34
+ control = _jsx(Switch, { checked: value === true, onChange: onChange });
35
+ break;
30
36
  case "select": {
31
- const options = Array.isArray(d.options) ? d.options : [];
32
- control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o, children: o }, o))] }));
37
+ const options = optionsOf(d.options);
38
+ control = (_jsxs("select", { className: "sm-select", value: String(value ?? ""), onChange: (e) => onChange(e.target.value), children: [d.optional === true && _jsx("option", { value: "", children: "\u2014" }), options.map((o) => _jsx("option", { value: o.value, children: o.label }, o.value))] }));
39
+ break;
40
+ }
41
+ case "radio": {
42
+ asDiv = true; // several inputs: never inside one implicit label
43
+ control = (_jsx("div", { className: "sm-choices", role: "radiogroup", children: optionsOf(d.options).map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "radio", name: path, value: o.value, checked: value === o.value, onChange: () => onChange(o.value) }), o.label] }, o.value))) }));
44
+ break;
45
+ }
46
+ case "checkboxes": {
47
+ asDiv = true;
48
+ const chosen = Array.isArray(value) ? value : [];
49
+ const all = optionsOf(d.options);
50
+ // Stored in option order, whatever order the boxes were ticked in.
51
+ const toggle = (v, on) => onChange(all.map((o) => o.value).filter((x) => (x === v ? on : chosen.includes(x))));
52
+ control = (_jsx("div", { className: "sm-choices", children: all.map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "checkbox", checked: chosen.includes(o.value), onChange: (e) => toggle(o.value, e.target.checked) }), o.label] }, o.value))) }));
33
53
  break;
34
54
  }
35
55
  case "richtext":
@@ -1,7 +1,7 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { ASSET_COLLECTION } from "../media.js";
3
3
  import { BASE } from "./shell/base.js";
4
- import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
4
+ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
5
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
6
  import { ensureSharedItems } from "./shared-items.js";
7
7
  import { validateFields } from "./validate.js";
@@ -122,6 +122,36 @@ export function createAdminOps(deps) {
122
122
  effects.revalidate(sharedTag(item.name));
123
123
  }
124
124
  };
125
+ /** An asset's metadata (alt, title, caption, credit) is read wherever media
126
+ * resolves (spec 2026-09-08 §9): the entry and page units that reference the
127
+ * asset, directly or through an entry, the shared items among those rows,
128
+ * and the list units of every referencing entry's collection. The walk is
129
+ * the entry fan-out's, seeded with the asset's id — which is no entry, so
130
+ * it gets no tag of its own. */
131
+ const expireAssetUsers = async (assetId) => {
132
+ if (!effects?.revalidate)
133
+ return;
134
+ const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
135
+ const users = entryIds.filter((id) => id !== assetId);
136
+ for (const id of users)
137
+ effects.revalidate(entryTag(id));
138
+ for (const id of pageIds)
139
+ effects.revalidate(pageTag(id));
140
+ const touched = new Set(users);
141
+ for (const item of config.registry.shared ?? []) {
142
+ const row = (await entries.list(item.name))[0];
143
+ if (row && touched.has(row.id))
144
+ effects.revalidate(sharedTag(item.name));
145
+ }
146
+ for (const c of config.registry.collections ?? []) {
147
+ for (const id of users) {
148
+ if (await entries.get(c.name, id)) {
149
+ effects.revalidate(collectionTag(c.name));
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ };
125
155
  /** Expire the cached units a tree change touched, in order, once each. */
126
156
  const expirePages = (ids) => {
127
157
  if (!effects?.revalidate)
@@ -129,6 +159,10 @@ export function createAdminOps(deps) {
129
159
  for (const id of new Set(ids.filter((x) => typeof x === "string")))
130
160
  effects.revalidate(pageTag(id));
131
161
  };
162
+ /** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
163
+ const expireTree = () => effects?.revalidate?.(pagesTreeTag());
164
+ /** A collection's list units: every entry write, since a non-versioned collection's published read is its live row. */
165
+ const expireCollection = (name) => effects?.revalidate?.(collectionTag(name));
132
166
  /** A node and every descendant, node first, in listPages() order. */
133
167
  const subtreeIds = async (id) => {
134
168
  const all = await pages.listPages();
@@ -244,10 +278,12 @@ export function createAdminOps(deps) {
244
278
  // A root create competes with the code-owned slugs: make sure they exist first.
245
279
  await ensureFixedNodes(pages, config);
246
280
  }
247
- return ok(await pages.createPage({
281
+ const created = await pages.createPage({
248
282
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
249
283
  ...(input.title !== undefined ? { title: input.title } : {}),
250
- }));
284
+ });
285
+ expireTree();
286
+ return ok(created);
251
287
  }),
252
288
  saveDraft: (id, locale, tree) => guard(async () => {
253
289
  const unsupported = supportedLocale(locale);
@@ -286,12 +322,18 @@ export function createAdminOps(deps) {
286
322
  }
287
323
  }
288
324
  }
325
+ // The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
326
+ const pageDescs = openTemplate(record.template)?.schema?.fields ?? {};
327
+ for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
328
+ fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
329
+ }
289
330
  if (fieldErrors.length > 0) {
290
331
  return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
291
332
  }
292
333
  const published = await pages.publish(id, locale);
293
334
  // The parent's cached unit lists its published children.
294
335
  expirePages([published.id, published.parentId]);
336
+ expireTree();
295
337
  return ok(published);
296
338
  }),
297
339
  delete: (id) => guard(async () => {
@@ -307,6 +349,7 @@ export function createAdminOps(deps) {
307
349
  // The cached unit is keyed by (locale, path) but tagged by the id
308
350
  // found at the first miss; the parent's unit lists its children.
309
351
  expirePages([id, record.parentId]);
352
+ expireTree();
310
353
  return ok(null);
311
354
  }),
312
355
  rename: (id, patch) => guard(async () => {
@@ -325,6 +368,7 @@ export function createAdminOps(deps) {
325
368
  // Every descendant's path changed with this segment; the parent
326
369
  // lists this node's title and path.
327
370
  expirePages([...(await subtreeIds(id)), record.parentId]);
371
+ expireTree();
328
372
  return ok(renamed);
329
373
  }),
330
374
  move: (id, to) => guard(async () => {
@@ -347,6 +391,7 @@ export function createAdminOps(deps) {
347
391
  }
348
392
  const moved = await pages.move(id, to);
349
393
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
394
+ expireTree();
350
395
  return ok(moved);
351
396
  }),
352
397
  setTemplate: (id, template) => guard(async () => {
@@ -363,7 +408,9 @@ export function createAdminOps(deps) {
363
408
  if (registration.schema.slug !== undefined) {
364
409
  return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
365
410
  }
366
- return ok(await pages.setTemplate(id, template));
411
+ const withTemplate = await pages.setTemplate(id, template);
412
+ expireTree();
413
+ return ok(withTemplate);
367
414
  }),
368
415
  addLocale: (id, locale, options) => guard(async () => {
369
416
  const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
@@ -371,8 +418,10 @@ export function createAdminOps(deps) {
371
418
  return unsupported;
372
419
  if (!(await pages.getPage(id)))
373
420
  return err("not_found", `smoodly: no page "${id}".`);
374
- // A new locale is a draft: nothing public changes, nothing to expire.
375
- return ok(await pages.addLocale(id, locale, { from: options.from }));
421
+ // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
422
+ const added = await pages.addLocale(id, locale, { from: options.from });
423
+ expireTree();
424
+ return ok(added);
376
425
  }),
377
426
  removeLocale: (id, locale) => guard(async () => {
378
427
  const unsupported = supportedLocale(locale);
@@ -388,6 +437,7 @@ export function createAdminOps(deps) {
388
437
  const removed = await pages.removeLocale(id, locale);
389
438
  // The locale may have been published: its unit and the parent's listing go.
390
439
  expirePages([id, record.parentId]);
440
+ expireTree();
391
441
  return ok(removed);
392
442
  }),
393
443
  },
@@ -417,7 +467,9 @@ export function createAdminOps(deps) {
417
467
  const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
418
468
  if (fieldErrors.length > 0)
419
469
  return err("validation", "Some fields need attention.", fieldErrors);
420
- return ok(await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null }));
470
+ const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
471
+ expireCollection(collection);
472
+ return ok(created);
421
473
  }),
422
474
  save: (collection, id, locale, fields) => guard(async () => {
423
475
  const unsupported = supportedLocale(locale);
@@ -431,6 +483,7 @@ export function createAdminOps(deps) {
431
483
  return err("validation", "Some fields need attention.", fieldErrors);
432
484
  const entry = await entries.update(collection, id, locale, fields);
433
485
  await fanOut(id);
486
+ expireCollection(collection);
434
487
  return ok(entry);
435
488
  }),
436
489
  setStatus: (collection, id, locale, status) => guard(async () => {
@@ -439,24 +492,29 @@ export function createAdminOps(deps) {
439
492
  return unsupported;
440
493
  const entry = await entries.setStatus(collection, id, locale, status);
441
494
  await fanOut(id);
495
+ expireCollection(collection);
442
496
  return ok(entry);
443
497
  }),
444
498
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
445
- delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), ok(null))),
499
+ delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
446
500
  move: (collection, id, to) => guard(async () => {
447
501
  const moved = await entries.move(collection, id, to);
448
502
  if (effects?.revalidate) {
449
503
  for (const entryId of await entrySubtreeIds(collection, id))
450
504
  effects.revalidate(entryTag(entryId));
451
505
  }
506
+ expireCollection(collection);
452
507
  return ok(moved);
453
508
  }),
454
- // A new locale starts as a draft: nothing public changes, nothing to expire.
509
+ // A new locale starts as a draft: nothing public changes, but the collection's
510
+ // list units are expired anyway (spec 2026-09-11 §2) so they can never go stale.
455
511
  addLocale: (collection, id, locale, options) => guard(async () => {
456
512
  const unsupported = supportedLocale(locale);
457
513
  if (unsupported)
458
514
  return unsupported;
459
- return ok(await entries.addLocale(collection, id, locale, { from: options.from }));
515
+ const added = await entries.addLocale(collection, id, locale, { from: options.from });
516
+ expireCollection(collection);
517
+ return ok(added);
460
518
  }),
461
519
  removeLocale: (collection, id, locale) => guard(async () => {
462
520
  const unsupported = supportedLocale(locale);
@@ -464,6 +522,7 @@ export function createAdminOps(deps) {
464
522
  return unsupported;
465
523
  const entry = await entries.removeLocale(collection, id, locale);
466
524
  await fanOut(id);
525
+ expireCollection(collection);
467
526
  return ok(entry);
468
527
  }),
469
528
  },
@@ -560,7 +619,9 @@ export function createAdminOps(deps) {
560
619
  return err("validation", `smoodly: ${key} must be text.`);
561
620
  patch[key] = v;
562
621
  }
563
- return ok(await assets.update(id, patch));
622
+ const updated = await assets.update(id, patch);
623
+ await expireAssetUsers(id);
624
+ return ok(updated);
564
625
  }),
565
626
  delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
566
627
  usage: (id) => guard(async () => {
@@ -38,6 +38,10 @@ export type AdminPageTemplate = {
38
38
  /** The per-locale segments, only when the registration declares an object. */
39
39
  slugs?: Record<string, string>;
40
40
  zones: Record<string, ZonePolicy>;
41
+ /** The template's page fields (spec 2026-09-10 page fields §5), edited on the Settings tab. */
42
+ fields: Record<string, AdminField>;
43
+ /** `meta: { seo: true }` — the SEO & Social tab exists for this template. */
44
+ seo: boolean;
41
45
  };
42
46
  export type AdminShared = {
43
47
  name: string;
@@ -69,6 +69,8 @@ export function serializeAdminConfig(config) {
69
69
  ...(slug !== undefined ? { slug } : {}),
70
70
  ...(typeof p.slug === "object" && p.slug !== null ? { slugs: { ...p.slug } } : {}),
71
71
  zones: p.zones ?? {},
72
+ fields: serializeMap(p.fields),
73
+ seo: p.meta?.seo === true,
72
74
  };
73
75
  }),
74
76
  shared: (config.registry.shared ?? []).map((s) => ({
@@ -32,3 +32,5 @@ export declare function nodeFromSample(section: Pick<AdminSection, "name" | "fie
32
32
  * schema. Returns the same tree when nothing changed. */
33
33
  export declare function fillLockedZones(tree: PageTree, policies: Record<string, ZonePolicy>, sections: AdminSection[], sharedIds?: Record<string, string>): PageTree;
34
34
  export declare function newNodeId(type: string): string;
35
+ /** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
36
+ export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;