smoodly 0.0.27 → 0.0.29

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 (96) hide show
  1. package/README.md +75 -3
  2. package/dist/admin/client-ops.js +2 -0
  3. package/dist/admin/drift-copy.d.ts +19 -0
  4. package/dist/admin/drift-copy.js +89 -0
  5. package/dist/admin/drift.d.ts +76 -0
  6. package/dist/admin/drift.js +264 -0
  7. package/dist/admin/editor/EditorView.js +70 -15
  8. package/dist/admin/editor/InlineRichtext.js +3 -1
  9. package/dist/admin/editor/Outline.d.ts +7 -0
  10. package/dist/admin/editor/Outline.js +8 -5
  11. package/dist/admin/editor/PageInspector.d.ts +9 -1
  12. package/dist/admin/editor/PageInspector.js +4 -2
  13. package/dist/admin/editor/PageSettings.d.ts +4 -1
  14. package/dist/admin/editor/PageSettings.js +33 -15
  15. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  16. package/dist/admin/editor/UnknownNode.js +8 -0
  17. package/dist/admin/field-paths.d.ts +3 -0
  18. package/dist/admin/field-paths.js +26 -0
  19. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  20. package/dist/admin/forms/OrphanFields.js +11 -0
  21. package/dist/admin/forms/RefPicker.js +6 -2
  22. package/dist/admin/forms/RichtextElementCard.js +9 -3
  23. package/dist/admin/forms/RichtextWidget.js +3 -1
  24. package/dist/admin/forms/TagsField.js +1 -1
  25. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  26. package/dist/admin/forms/richtext-form-context.js +5 -0
  27. package/dist/admin/health.d.ts +23 -0
  28. package/dist/admin/health.js +42 -0
  29. package/dist/admin/ops-impl.js +124 -14
  30. package/dist/admin/ops.d.ts +53 -1
  31. package/dist/admin/redirect-path.d.ts +6 -0
  32. package/dist/admin/redirect-path.js +33 -0
  33. package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
  34. package/dist/admin/shell/AddRedirectModal.js +29 -0
  35. package/dist/admin/shell/AdminApp.js +2 -3
  36. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  37. package/dist/admin/shell/ContentHealthPage.js +37 -0
  38. package/dist/admin/shell/EntriesList.js +4 -4
  39. package/dist/admin/shell/EntryForm.js +32 -7
  40. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  41. package/dist/admin/shell/ErrorPane.js +5 -4
  42. package/dist/admin/shell/GlobalForm.js +22 -2
  43. package/dist/admin/shell/IconRail.d.ts +1 -1
  44. package/dist/admin/shell/IconRail.js +3 -2
  45. package/dist/admin/shell/PagesList.js +9 -7
  46. package/dist/admin/shell/RedirectsPage.d.ts +11 -0
  47. package/dist/admin/shell/RedirectsPage.js +75 -0
  48. package/dist/admin/shell/SettingsView.d.ts +4 -2
  49. package/dist/admin/shell/SettingsView.js +22 -14
  50. package/dist/admin/shell/health-list.d.ts +27 -0
  51. package/dist/admin/shell/health-list.js +47 -0
  52. package/dist/admin/shell/live-url.d.ts +10 -2
  53. package/dist/admin/shell/live-url.js +9 -4
  54. package/dist/admin/shell/members-list.js +1 -1
  55. package/dist/admin/shell/redirects-list.d.ts +12 -0
  56. package/dist/admin/shell/redirects-list.js +15 -0
  57. package/dist/admin/shell/settings.d.ts +16 -3
  58. package/dist/admin/shell/settings.js +16 -9
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/Combobox.d.ts +8 -2
  62. package/dist/admin/ui/Combobox.js +48 -16
  63. package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
  64. package/dist/admin/ui/ConfirmDialog.js +5 -4
  65. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  66. package/dist/admin/ui/DriftNotice.js +10 -0
  67. package/dist/admin/ui/combo-box.d.ts +13 -0
  68. package/dist/admin/ui/combo-box.js +8 -0
  69. package/dist/admin/ui/confirm.d.ts +21 -2
  70. package/dist/admin/ui/confirm.js +26 -9
  71. package/dist/admin/ui/theme.js +32 -3
  72. package/dist/admin/validate.js +15 -47
  73. package/dist/entry-store.d.ts +31 -6
  74. package/dist/entry-store.js +39 -11
  75. package/dist/index.d.ts +2 -2
  76. package/dist/next/page-renderer.js +28 -9
  77. package/dist/path-index.d.ts +28 -4
  78. package/dist/path-index.js +37 -4
  79. package/dist/paths.d.ts +16 -0
  80. package/dist/paths.js +9 -0
  81. package/dist/render.js +7 -2
  82. package/dist/schema-version.d.ts +1 -1
  83. package/dist/schema-version.js +1 -1
  84. package/dist/site.d.ts +4 -1
  85. package/dist/site.js +3 -1
  86. package/dist/sql.js +19 -1
  87. package/dist/store.d.ts +39 -5
  88. package/dist/store.js +32 -8
  89. package/dist/supabase-entry-store.d.ts +9 -3
  90. package/dist/supabase-entry-store.js +39 -13
  91. package/dist/supabase-path-index.d.ts +10 -3
  92. package/dist/supabase-path-index.js +50 -17
  93. package/dist/supabase-store.d.ts +15 -3
  94. package/dist/supabase-store.js +42 -8
  95. package/migrations/20260926150000_smoodly_v4.sql +41 -0
  96. package/package.json +1 -1
@@ -11,13 +11,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  // document's element nodes. Collapse is component state only: a card
12
12
  // starts collapsed (2026-09-09) unless it holds nothing yet — a freshly
13
13
  // inserted element opens, so its fields can be filled straight away.
14
+ // Drift (spec 2026-09-27 editor safety §5): an unregistered element's card
15
+ // takes the broken stripe and lists its stored fields read-only; a
16
+ // registered one the field's toolset leaves out says so in its head.
14
17
  import { useState } from "react";
15
18
  import { NodeViewWrapper } from "@tiptap/react";
16
19
  import { isHidden } from "../../conditions.js";
17
- import { IconButton, SectionLabel } from "../ui/primitives.js";
20
+ import { IconButton, SectionLabel, cx } from "../ui/primitives.js";
18
21
  import { FieldWidget } from "./FieldWidget.js";
19
22
  import { useElements } from "./elements-context.js";
20
- import { cardClass, useRichtextForm } from "./richtext-form-context.js";
23
+ import { cardClass, elementCardState, useRichtextForm } from "./richtext-form-context.js";
21
24
  import { elementIndexAt } from "./richtext-insert.js";
22
25
  /** No field holds a value yet — the state right after an insert. */
23
26
  function isBlank(fields) {
@@ -31,6 +34,9 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
31
34
  const fields = (node.attrs.fields && typeof node.attrs.fields === "object" ? node.attrs.fields : {});
32
35
  const settings = (node.attrs.settings && typeof node.attrs.settings === "object" ? node.attrs.settings : {});
33
36
  const [open, setOpen] = useState(() => isBlank(fields));
37
+ // As drift decides it: an element whose contexts dropped "richtext" is outside every toolset.
38
+ const allowed = form.elements?.filter((n) => elements.find((e) => e.name === n)?.contexts.includes("richtext"));
39
+ const state = elementCardState(name, Boolean(element), allowed);
34
40
  const pos = getPos();
35
41
  const index = typeof pos === "number" ? elementIndexAt(editor.state.doc, pos) : 0;
36
42
  const base = `${form.path}.elements.${index}`;
@@ -39,5 +45,5 @@ export function RichtextElementCard({ node, editor, getPos, updateAttributes, de
39
45
  const scope = { fields, settings };
40
46
  const visibleFields = Object.entries(element?.fields ?? {}).filter(([, d]) => !isHidden(d, { self: fields, ...scope }));
41
47
  const visibleSettings = Object.entries(element?.settings ?? {}).filter(([, d]) => !isHidden(d, { self: settings, ...scope }));
42
- return (_jsxs(NodeViewWrapper, { className: cardClass(selected, form.canvas), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
48
+ return (_jsxs(NodeViewWrapper, { className: cx(cardClass(selected, form.canvas), state.unknown && "sm-rtcard--broken"), "data-theme": form.canvas ? "light" : undefined, children: [_jsxs("div", { className: "sm-rtcard__head", "data-drag-handle": true, onClick: select, children: [_jsx("span", { className: "sm-rtcard__icon", "aria-hidden": "true", children: element?.icon ?? "▤" }), _jsx("span", { className: "sm-rtcard__title", children: element?.title ?? `Unknown element "${name}"` }), state.outside && _jsx("span", { className: "sm-hint", children: "Not in this field's toolset" }), _jsx(IconButton, { label: open ? "Collapse" : "Expand", onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); }, children: open ? "▾" : "▸" }), _jsx(IconButton, { label: "Delete element", onClick: (e) => { e.stopPropagation(); deleteNode(); }, children: "\u00D7" })] }), open && !element && (_jsx("div", { className: "sm-rtcard__body", contentEditable: false, children: Object.entries(fields).map(([key, value]) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: key }), _jsx("span", { className: "sm-orphans__value", children: typeof value === "string" ? value : JSON.stringify(value) })] }, key))) })), open && element && (_jsxs("div", { className: "sm-rtcard__body", contentEditable: false, children: [visibleFields.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: fields[key], errors: form.errors, path: `${base}.${key}`, scope: scope, onChange: (v) => updateAttributes({ fields: { ...fields, [key]: v } }) }, key))), visibleSettings.length > 0 && _jsx(SectionLabel, { caps: true, children: "Settings" }), visibleSettings.map(([key, d]) => (_jsx(FieldWidget, { fieldKey: key, descriptor: d, value: settings[key], errors: form.errors, path: `${base}.settings.${key}`, scope: scope, onChange: (v) => updateAttributes({ settings: { ...settings, [key]: v } }) }, `setting-${key}`)))] }))] }));
43
49
  }
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // flag, so its own cards stay admin-styled.
9
9
  import { useMemo } from "react";
10
10
  import { EditorContent } from "@tiptap/react";
11
+ import { allowedOf } from "../../toolset.js";
11
12
  import { RichtextToolbar } from "./RichtextToolbar.js";
12
13
  import { RichtextInsertMenu } from "./RichtextInsertMenu.js";
13
14
  import { RichtextLinkRow, RichtextMenu } from "./RichtextPopovers.js";
@@ -17,7 +18,8 @@ import { useRichtextEditor } from "./useRichtextEditor.js";
17
18
  export function RichtextWidget({ value, toolset, onChange, errors, path = "" }) {
18
19
  const rt = useRichtextEditor({ value, toolset, onChange, contentClass: "sm-richtext__content" });
19
20
  const parent = useRichtextForm();
20
- const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas }), [errors, path, parent.canvas]);
21
+ const allowed = useMemo(() => allowedOf(toolset).elements, [toolset]);
22
+ const formInfo = useMemo(() => ({ errors, path, canvas: parent.canvas, elements: allowed }), [errors, path, parent.canvas, allowed]);
21
23
  return (_jsx(RichtextFormProvider, { value: formInfo, children: _jsxs("div", { className: "sm-richtext", children: [_jsxs("div", { className: "sm-richtext__head", children: [_jsx(RichtextToolbar, { allowed: rt.allowed, active: rt.active, canInsert: rt.canInsert, onCommand: rt.run }), rt.menu.open && _jsx(RichtextMenu, { menu: rt.menu })] }), rt.link.open && _jsx(RichtextLinkRow, { link: rt.link }), _jsxs("div", { className: "sm-richtext__body", children: [_jsx(EditorContent, { editor: rt.editor }), rt.slash && (_jsx("div", { className: "sm-richtext__slash", style: { top: rt.slash.state.rect ? rt.slash.state.rect.bottom - (editorTop(rt.editor) ?? 0) + 4 : 0 }, children: _jsx(RichtextInsertMenu, { items: rt.slash.state.items, active: rt.slash.active, onPick: (item) => rt.slash?.state.pick(item) }) }))] }), rt.picker.open && _jsx(MediaPicker, { kinds: ["image"], onPick: rt.picker.pick, onClose: rt.picker.close })] }) }));
22
24
  }
23
25
  /** The editor's top in viewport coordinates, to place the slash menu relative to the body wrapper. */
@@ -27,7 +27,7 @@ export function TagsField({ value, onChange, max, suggestions, label, initialAdd
27
27
  timer.current = setTimeout(() => setFlash(null), 600);
28
28
  }
29
29
  };
30
- return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
30
+ return (_jsxs("div", { className: "sm-tags", children: [tags.map((t, i) => (_jsxs("span", { className: cx("sm-tag", flash === t && "sm-tag--flash"), children: [t, _jsx("button", { type: "button", className: "sm-tag__x", "aria-label": `Remove ${t}`, onClick: () => onChange(tags.filter((_, j) => j !== i)), children: "\u00D7" })] }, t))), adding && !atMax && (_jsx(Combobox, { className: "sm-combo--tag", listClassName: "sm-combo__list--tag", label: `Add to ${label}`, placeholder: "Add a tag", autoFocus: true, keepOpen: true, initialOpen: true, options: (suggestions ?? []).filter((s) => !tags.some((t) => sameTag(t, s))).map((s) => ({ value: s, label: s })), onPick: commit, onSubmit: commit, createRow: suggestions !== undefined, onQueryChange: (next) => { if (!next.includes(","))
31
31
  return next; commit(next); return ""; }, onInputKey: (e, query) => {
32
32
  if (e.key !== "Backspace" || query !== "" || tags.length === 0)
33
33
  return false;
@@ -4,6 +4,8 @@ export type RichtextFormInfo = {
4
4
  path: string;
5
5
  /** Mounted in the canvas: cards carry the admin's classes and the light theme (spec 2026-09-10 §5). */
6
6
  canvas?: boolean;
7
+ /** The field toolset's insertable elements — a card outside them says so (spec 2026-09-27 editor safety §5). */
8
+ elements?: string[];
7
9
  };
8
10
  export declare function RichtextFormProvider({ value, children }: {
9
11
  value: RichtextFormInfo;
@@ -14,3 +16,9 @@ export declare function useRichtextForm(): RichtextFormInfo;
14
16
  * (`sm-admin`, light — the page stays light) and opts out of Tailwind
15
17
  * typography (`not-prose`), which would otherwise style its internals. */
16
18
  export declare function cardClass(selected: boolean, canvas: boolean | undefined): string;
19
+ /** What an element card marks (spec 2026-09-27 editor safety §5): an unregistered name, or a
20
+ * registered one the field's toolset leaves out — the toolset gates insertion only, so it renders. */
21
+ export declare function elementCardState(name: string, known: boolean, allowed: string[] | undefined): {
22
+ unknown: boolean;
23
+ outside: boolean;
24
+ };
@@ -16,3 +16,8 @@ export function useRichtextForm() { return useContext(Ctx); }
16
16
  export function cardClass(selected, canvas) {
17
17
  return ["sm-rtcard", selected && "sm-rtcard--selected", canvas && "sm-admin not-prose"].filter(Boolean).join(" ");
18
18
  }
19
+ /** What an element card marks (spec 2026-09-27 editor safety §5): an unregistered name, or a
20
+ * registered one the field's toolset leaves out — the toolset gates insertion only, so it renders. */
21
+ export function elementCardState(name, known, allowed) {
22
+ return { unknown: !known, outside: known && allowed !== undefined && !allowed.includes(name) };
23
+ }
@@ -0,0 +1,23 @@
1
+ import type { AdminRegistry } from "./serialize.ts";
2
+ import type { PageScan } from "../store.ts";
3
+ import { type EntryScan } from "../entry-store.ts";
4
+ import { type VersionedFinding } from "./drift.ts";
5
+ export type HealthDocKind = "page" | "entry" | "global";
6
+ export type HealthFinding = VersionedFinding & {
7
+ locale: string;
8
+ };
9
+ export type HealthDocument = {
10
+ kind: HealthDocKind;
11
+ id: string;
12
+ title: string;
13
+ collection: string | null;
14
+ findings: HealthFinding[];
15
+ };
16
+ export type HealthReport = {
17
+ documents: HealthDocument[];
18
+ collections: {
19
+ name: string;
20
+ count: number;
21
+ }[];
22
+ };
23
+ export declare function buildHealthReport(registry: AdminRegistry, pages: PageScan[], entries: EntryScan[]): HealthReport;
@@ -0,0 +1,42 @@
1
+ import { entryFieldsIn } from "../entry-store.js";
2
+ import { entryDrift, mergeVersions, pageDrift } from "./drift.js";
3
+ export function buildHealthReport(registry, pages, entries) {
4
+ const documents = [];
5
+ const def = registry.locales.default;
6
+ for (const scan of pages) {
7
+ const findings = [];
8
+ for (const locale of Object.keys(scan.record.locales)) {
9
+ const draft = pageDrift(registry, scan.record.template, scan.draft[locale] ?? null);
10
+ const live = scan.live[locale] ? pageDrift(registry, scan.record.template, scan.live[locale]) : [];
11
+ for (const f of mergeVersions(draft, live))
12
+ findings.push({ ...f, locale });
13
+ }
14
+ if (findings.length === 0)
15
+ continue;
16
+ const row = scan.record.locales[def] ?? Object.values(scan.record.locales)[0];
17
+ documents.push({ kind: "page", id: scan.record.id, title: row?.title ?? scan.record.id, collection: null, findings });
18
+ }
19
+ const unknown = new Map();
20
+ for (const { record, live } of entries) {
21
+ const collection = registry.collections.find((c) => c.name === record.collection);
22
+ const item = registry.globals.find((g) => g.name === record.collection);
23
+ if (!collection && !item) {
24
+ unknown.set(record.collection, (unknown.get(record.collection) ?? 0) + 1);
25
+ continue;
26
+ }
27
+ const findings = [];
28
+ for (const [locale, row] of Object.entries(record.locales)) {
29
+ const draft = entryDrift(registry, record.collection, { fields: entryFieldsIn(record, locale), shared: record.fields, own: row.fields });
30
+ const lived = live[locale] ? entryDrift(registry, record.collection, { fields: live[locale] }) : [];
31
+ for (const f of mergeVersions(draft, lived))
32
+ findings.push({ ...f, locale });
33
+ }
34
+ if (findings.length === 0)
35
+ continue;
36
+ const locale = record.locales[def] ? def : Object.keys(record.locales)[0];
37
+ const titleValue = collection?.titleField && locale ? entryFieldsIn(record, locale)[collection.titleField] : undefined;
38
+ const title = item ? item.title : typeof titleValue === "string" && titleValue !== "" ? titleValue : record.id;
39
+ documents.push({ kind: item ? "global" : "entry", id: record.id, title, collection: record.collection, findings });
40
+ }
41
+ return { documents, collections: [...unknown].map(([name, count]) => ({ name, count })) };
42
+ }
@@ -7,10 +7,15 @@ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, global
7
7
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
8
8
  import { ensureGlobalItems } from "./global-items.js";
9
9
  import { validateCustomNode, validateFields, validateNode } from "./validate.js";
10
+ import { serializeAdminConfig } from "./serialize.js";
11
+ import { entryDrift, pageDrift } from "./drift.js";
12
+ import { publishErrors } from "./drift-copy.js";
13
+ import { buildHealthReport } from "./health.js";
10
14
  import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
11
15
  import { SETTINGS_KEY, CSS_KEY } from "../global.js";
12
16
  import { withDefaults } from "../field-defaults.js";
13
17
  import { elementSchemas } from "../richtext-walk.js";
18
+ import { REDIRECT_PATH_MESSAGE, redirectPathProblem } from "./redirect-path.js";
14
19
  import { SCHEMA_VERSION } from "../schema-version.js";
15
20
  import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
16
21
  const ok = (data) => ({ ok: true, data });
@@ -65,6 +70,8 @@ const classify = (e) => {
65
70
  return err("not_found", message);
66
71
  if (message.includes("cannot take a slug"))
67
72
  return err("validation", message);
73
+ if (message.includes("is a live address"))
74
+ return err("validation", message);
68
75
  // Ahead of the generic "already exists" check below: the singleton
69
76
  // refusal's message ("...already exists — it is a singleton.") contains
70
77
  // "already exists" as a substring, so it must classify here first or it
@@ -109,12 +116,23 @@ export function createAdminOps(deps) {
109
116
  const by = deps.actor?.email ?? undefined;
110
117
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
111
118
  // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
119
+ // The serialized registry drift walks (spec 2026-09-27 editor safety §2) — the admin client's shape.
120
+ let adminRegistryMemo = null;
121
+ const adminRegistry = () => (adminRegistryMemo ?? (adminRegistryMemo = serializeAdminConfig(config)));
112
122
  const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, settings: e.schema.settings })));
113
123
  // The custom section (spec 2026-09-14 §6) validates like a section, over its own schema.
114
124
  const sectionFields = (type) => (type === CUSTOM_NODE_TYPE ? customSchema(config.custom.toolset) : config.registry.sections?.find((s) => s.schema.name === type)?.schema);
115
125
  const globalItem = (name) => config.registry.globals?.find((s) => s.name === name) ?? null;
116
126
  /** One row per registration; null when materialization could not create it. */
117
127
  const globalRecord = async (name) => (await entries.list(name))[0] ?? null;
128
+ /** The published snapshot's drift for the editors' live-only line (spec 2026-09-27 editor safety §5). */
129
+ const liveEntryFindings = async (name, record, locale) => {
130
+ const row = record.locales[locale];
131
+ if (row?.status !== "published" || !row.publishedVersionId)
132
+ return [];
133
+ const snap = await entries.getVersion(name, row.publishedVersionId);
134
+ return snap ? entryDrift(adminRegistry(), name, { fields: snap.fields }) : [];
135
+ };
118
136
  const fanOut = async (entryId) => {
119
137
  if (!effects?.revalidate)
120
138
  return;
@@ -208,6 +226,22 @@ export function createAdminOps(deps) {
208
226
  const def = config.locales.default;
209
227
  /** The name messages use for a node: its default-locale slug, else any locale's, else the id. */
210
228
  const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
229
+ /** "kind:id" → title in the locale (+ collection for an entry): every page with a template, every entry of a routed collection. */
230
+ const targetTitles = async (locale) => {
231
+ const out = new Map();
232
+ for (const p of await pages.listPages()) {
233
+ if (p.template === null)
234
+ continue;
235
+ out.set(`page:${p.id}`, { title: p.locales[locale]?.title ?? nameOf(p), collection: null });
236
+ }
237
+ for (const c of config.registry.collections ?? []) {
238
+ if (!c.path)
239
+ continue;
240
+ for (const e of await entries.list(c.name))
241
+ out.set(`entry:${e.id}`, { title: entryTitleIn(e, locale, c), collection: c.name });
242
+ }
243
+ return out;
244
+ };
211
245
  const assets = deps.assets;
212
246
  const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
213
247
  const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
@@ -292,12 +326,15 @@ export function createAdminOps(deps) {
292
326
  const draft = record.template === null || !record.locales[locale] ? null : await pages.getDraftTree(id, locale);
293
327
  const paths = await pages.pathsOf(id);
294
328
  const urls = Object.fromEntries(Object.entries(paths).map(([l, path]) => [l, href(path, l)]));
329
+ const redirects = await pages.redirectsOf(id);
295
330
  // A locale can only be added where the parent already has it; a
296
331
  // root has no parent, so every supported locale is open.
297
332
  const parentLocales = record.parentId === null
298
333
  ? config.locales.supported
299
334
  : Object.keys((await pages.getPage(record.parentId))?.locales ?? {});
300
- return ok({ record, locale, draft, paths, urls, parentLocales });
335
+ const liveTree = record.locales[locale]?.publishedVersionId ? await pages.getPublishedTree(id, locale) : null;
336
+ const liveFindings = liveTree ? pageDrift(adminRegistry(), record.template, liveTree) : [];
337
+ return ok({ record, locale, draft, paths, urls, redirects, parentLocales, liveFindings });
301
338
  }),
302
339
  create: (input) => guard(async () => {
303
340
  const unsupported = supportedLocale(input.locale);
@@ -367,7 +404,10 @@ export function createAdminOps(deps) {
367
404
  : err("blocked", `smoodly: "${row.slug}" has no draft to publish.`);
368
405
  }
369
406
  const fieldErrors = [];
370
- for (const nodes of Object.values(draft.zones)) {
407
+ // Only the page type's zones (spec 2026-09-27 editor safety §1): a removed zone's nodes never block.
408
+ const shapeZones = Object.keys(openTemplate(record.template)?.schema?.zones ?? {});
409
+ for (const zone of shapeZones) {
410
+ const nodes = Array.isArray(draft.zones[zone]) ? draft.zones[zone] : [];
371
411
  for (const node of nodes) {
372
412
  const schema = sectionFields(node.type);
373
413
  if (!schema?.fields)
@@ -386,8 +426,9 @@ export function createAdminOps(deps) {
386
426
  for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
387
427
  fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
388
428
  }
389
- if (fieldErrors.length > 0) {
390
- return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
429
+ const refused = publishErrors(fieldErrors, pageDrift(adminRegistry(), record.template, draft));
430
+ if (refused.length > 0) {
431
+ return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed problems first.`, refused);
391
432
  }
392
433
  const published = await pages.publish(id, locale, by, token);
393
434
  // The parent's cached unit lists its published children.
@@ -494,7 +535,8 @@ export function createAdminOps(deps) {
494
535
  return unsupported;
495
536
  if (record.parentId === null && patch.slug !== undefined && adminSlug(patch.slug))
496
537
  return slugTaken();
497
- const renamed = await pages.rename(id, patch, by);
538
+ const { redirect, ...rest } = patch;
539
+ const renamed = await pages.rename(id, rest, by, { redirect: redirect ?? true });
498
540
  // Every descendant's path changed with this segment; the parent
499
541
  // lists this node's title and path.
500
542
  expirePages([...(await subtreeIds(id)), record.parentId]);
@@ -521,7 +563,8 @@ export function createAdminOps(deps) {
521
563
  if (blocked)
522
564
  return blocked;
523
565
  }
524
- const moved = await pages.move(id, to);
566
+ const { redirect, ...dest } = to;
567
+ const moved = await pages.move(id, dest, { redirect: redirect ?? true });
525
568
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
526
569
  expireTree();
527
570
  return ok(moved);
@@ -607,7 +650,9 @@ export function createAdminOps(deps) {
607
650
  : Object.keys((await entries.get(collection, record.parentId))?.locales ?? {});
608
651
  const paths = await entries.pathsOf(collection, id);
609
652
  const urls = Object.fromEntries(Object.entries(paths).map(([l, p]) => [l, href(p, l)]));
610
- return ok({ record, locale, fields, parentLocales, urls });
653
+ const redirects = await entries.redirectsOf(collection, id);
654
+ const liveFindings = await liveEntryFindings(collection, record, locale);
655
+ return ok({ record, locale, fields, parentLocales, urls, redirects, liveFindings });
611
656
  }),
612
657
  create: (collection, input) => guard(async () => {
613
658
  const unsupported = supportedLocale(input.locale);
@@ -620,7 +665,7 @@ export function createAdminOps(deps) {
620
665
  expireCollection(collection);
621
666
  return ok(created);
622
667
  }),
623
- save: (collection, id, locale, fields, expected) => guard(async () => {
668
+ save: (collection, id, locale, fields, expected, options) => guard(async () => {
624
669
  const unsupported = supportedLocale(locale);
625
670
  if (unsupported)
626
671
  return unsupported;
@@ -630,7 +675,7 @@ export function createAdminOps(deps) {
630
675
  const descriptors = collectionFields(collection);
631
676
  if (!descriptors)
632
677
  return err("not_found", `smoodly: no collection named "${collection}".`);
633
- const entry = await entries.update(collection, id, locale, fields, by, token);
678
+ const entry = await entries.update(collection, id, locale, fields, by, token, { redirect: options?.redirect ?? true });
634
679
  await fanOut(id);
635
680
  expireCollection(collection);
636
681
  return ok(entry);
@@ -652,7 +697,8 @@ export function createAdminOps(deps) {
652
697
  return err("not_found", `smoodly: no entry "${id}" in "${collection}".`);
653
698
  if (!record.locales[locale])
654
699
  return err("not_found", `smoodly: entry "${id}" does not exist in locale "${locale}".`);
655
- const fieldErrors = validateFields(entryFieldsIn(record, locale), descriptors, elementDescs);
700
+ const merged = entryFieldsIn(record, locale);
701
+ const fieldErrors = publishErrors(validateFields(merged, descriptors, elementDescs), entryDrift(adminRegistry(), collection, { fields: merged }));
656
702
  if (fieldErrors.length > 0)
657
703
  return err("validation", "Some fields need attention.", fieldErrors);
658
704
  }
@@ -707,7 +753,8 @@ export function createAdminOps(deps) {
707
753
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
708
754
  delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
709
755
  move: (collection, id, to) => guard(async () => {
710
- const moved = await entries.move(collection, id, to);
756
+ const { redirect, ...dest } = to;
757
+ const moved = await entries.move(collection, id, dest, { redirect: redirect ?? true });
711
758
  if (effects?.revalidate) {
712
759
  for (const entryId of await entrySubtreeIds(collection, id))
713
760
  effects.revalidate(entryTag(entryId));
@@ -752,7 +799,8 @@ export function createAdminOps(deps) {
752
799
  if (!record?.locales[locale])
753
800
  return err("not_found", `smoodly: global item "${name}" does not exist in locale "${locale}".`);
754
801
  const usage = await entries.referencesTo(name, record.id);
755
- return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage });
802
+ const liveFindings = await liveEntryFindings(name, record, locale);
803
+ return ok({ name, record, locale, fields: entryFieldsIn(record, locale), usage, liveFindings });
756
804
  }),
757
805
  save: (name, locale, fields, expected) => guard(async () => {
758
806
  const unsupported = supportedLocale(locale);
@@ -794,8 +842,9 @@ export function createAdminOps(deps) {
794
842
  const { [SETTINGS_KEY]: settings, [CSS_KEY]: css, ...fields } = entryFieldsIn(record, locale);
795
843
  const settingsDescs = item.section !== undefined ? sectionFields(item.section)?.settings : undefined;
796
844
  const fieldErrors = validateNode({ fields, settings: settings !== null && typeof settings === "object" ? settings : {}, css }, { fields: item.fields, settings: settingsDescs }, elementDescs);
797
- if (fieldErrors.length > 0)
798
- return err("validation", "Some fields need attention.", fieldErrors);
845
+ const refused = publishErrors(fieldErrors, entryDrift(adminRegistry(), name, { fields: entryFieldsIn(record, locale) }));
846
+ if (refused.length > 0)
847
+ return err("validation", "Some fields need attention.", refused);
799
848
  }
800
849
  const changed = await entries.setStatus(name, record.id, locale, status, by, token);
801
850
  // see save: the fan-out carries the item's own tag.
@@ -973,6 +1022,67 @@ export function createAdminOps(deps) {
973
1022
  return ok(null);
974
1023
  }),
975
1024
  },
1025
+ redirects: {
1026
+ list: (locale) => guard(async () => {
1027
+ const unsupported = supportedLocale(locale);
1028
+ if (unsupported)
1029
+ return unsupported;
1030
+ const rows = await pages.listRedirects(locale);
1031
+ const titles = await targetTitles(locale);
1032
+ const items = [];
1033
+ for (const r of rows) {
1034
+ const t = titles.get(`${r.kind}:${r.id}`);
1035
+ const currentPath = (await pages.targetPaths({ kind: r.kind, id: r.id }))[locale] ?? null;
1036
+ items.push({ locale, path: r.path, target: { kind: r.kind, id: r.id }, collection: t?.collection ?? null, title: t?.title ?? r.id, currentPath, url: currentPath ? href(currentPath, locale) : null });
1037
+ }
1038
+ return ok(items.sort((a, b) => a.path.localeCompare(b.path)));
1039
+ }),
1040
+ targets: (locale) => guard(async () => {
1041
+ const unsupported = supportedLocale(locale);
1042
+ if (unsupported)
1043
+ return unsupported;
1044
+ const titles = await targetTitles(locale);
1045
+ const out = [];
1046
+ for (const r of await pages.listLive(locale)) {
1047
+ const t = titles.get(`${r.kind}:${r.id}`);
1048
+ if (!t)
1049
+ continue; // a folder, or an entry outside a routed collection
1050
+ out.push({ target: { kind: r.kind, id: r.id }, collection: t.collection, title: t.title, path: r.path });
1051
+ }
1052
+ return ok(out.sort((a, b) => a.path.localeCompare(b.path)));
1053
+ }),
1054
+ add: (locale, path, target) => guard(async () => {
1055
+ const unsupported = supportedLocale(locale);
1056
+ if (unsupported)
1057
+ return unsupported;
1058
+ const problem = redirectPathProblem(path, { adminPath: config.admin.path, multilingual: config.locales.supported.length > 1 });
1059
+ if (problem)
1060
+ return err("validation", REDIRECT_PATH_MESSAGE[problem]);
1061
+ const titles = await targetTitles(locale);
1062
+ if (!titles.has(`${target.kind}:${target.id}`))
1063
+ return err("not_found", `smoodly: no ${target.kind} "${target.id}" to redirect to.`);
1064
+ const holder = await pages.resolvePath(locale, path);
1065
+ if (holder && !holder.redirect) {
1066
+ const owner = titles.get(`${holder.kind}:${holder.id}`)?.title ?? holder.id;
1067
+ return err("validation", `smoodly: "${path}" is the address of "${owner}".`);
1068
+ }
1069
+ await pages.addRedirect({ kind: target.kind, id: target.id, locale, path });
1070
+ return ok(null);
1071
+ }),
1072
+ remove: (locale, path) => guard(async () => {
1073
+ const unsupported = supportedLocale(locale);
1074
+ if (unsupported)
1075
+ return unsupported;
1076
+ await pages.removeRedirect(locale, path);
1077
+ return ok(null);
1078
+ }),
1079
+ },
1080
+ health: {
1081
+ scan: () => guard(async () => {
1082
+ const [trees, rows] = await Promise.all([pages.scanTrees(), entries.scanEntries()]);
1083
+ return ok(buildHealthReport(adminRegistry(), trees, rows));
1084
+ }),
1085
+ },
976
1086
  schema: {
977
1087
  status: () => guard(async () => ok({ database: await pages.schemaVersion(), required: SCHEMA_VERSION })),
978
1088
  },
@@ -7,6 +7,9 @@ import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts
7
7
  import type { MemberRole } from "../permissions.ts";
8
8
  import type { FieldError } from "./validate.ts";
9
9
  import type { DraftToken } from "../draft-token.ts";
10
+ import type { PathTarget } from "../paths.ts";
11
+ import type { Finding } from "./drift.ts";
12
+ import type { HealthReport } from "./health.ts";
10
13
  export type OpError = {
11
14
  ok: false;
12
15
  code: "not_found" | "conflict" | "blocked" | "validation" | "internal" | "unauthorized";
@@ -44,11 +47,15 @@ export type PageGetResult = {
44
47
  paths: Record<string, string>;
45
48
  /** locale → site URL, `href` applied server-side (the canvas and "View live" use these). */
46
49
  urls: Record<string, string>;
50
+ /** locale → the page's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
51
+ redirects: Record<string, string[]>;
47
52
  /** The locales the PARENT exists in — a locale can only be added here
48
53
  * once the parent has it (DESIGN.md, Localization), so the editor
49
54
  * disables the rest with a reason. Every supported locale for a root
50
55
  * page: a root can be added to any locale. */
51
56
  parentLocales: string[];
57
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
58
+ liveFindings: Finding[];
52
59
  };
53
60
  export type EntryCreateInput = {
54
61
  /** The one locale the entry is created in; others are added with addLocale. */
@@ -65,8 +72,12 @@ export type EntryGetResult = {
65
72
  fields: Record<string, unknown> | null;
66
73
  /** locale → site URL (`href` applied server-side) for the locales the entry has a path in; empty for a collection without a path. */
67
74
  urls: Record<string, string>;
75
+ /** locale → the entry's old addresses that redirect to it (spec 2026-09-26 redirects §6). */
76
+ redirects: Record<string, string[]>;
68
77
  /** The locales the PARENT exists in (every supported locale for a root) — the form disables the rest with a reason. */
69
78
  parentLocales: string[];
79
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
80
+ liveFindings: Finding[];
70
81
  };
71
82
  export type GlobalListItem = {
72
83
  name: string;
@@ -81,6 +92,8 @@ export type GlobalGetResult = {
81
92
  fields: Record<string, unknown>;
82
93
  /** Who links it: "used on N pages". */
83
94
  usage: EntryUsage[];
95
+ /** The live version's drift (spec 2026-09-27 editor safety §5) — the editors' "live only" notice line. Empty when nothing is published. */
96
+ liveFindings: Finding[];
84
97
  };
85
98
  /** A history row: the version id and when its content last changed (spec 2026-09-13 history §5). */
86
99
  export type VersionSummary = {
@@ -109,6 +122,23 @@ export type SchemaStatus = {
109
122
  database: number | null;
110
123
  required: number;
111
124
  };
125
+ /** A redirect row with its target joined (spec 2026-09-26 redirects §6): `currentPath`/`url` null when the target has no live path in the locale. */
126
+ export type RedirectItem = {
127
+ locale: string;
128
+ path: string;
129
+ target: PathTarget;
130
+ collection: string | null;
131
+ title: string;
132
+ currentPath: string | null;
133
+ url: string | null;
134
+ };
135
+ /** A page or entry live in the locale, for the Add dialog's type-ahead. */
136
+ export type RedirectTargetItem = {
137
+ target: PathTarget;
138
+ collection: string | null;
139
+ title: string;
140
+ path: string;
141
+ };
112
142
  export type AdminOps = {
113
143
  pages: {
114
144
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -127,14 +157,17 @@ export type AdminOps = {
127
157
  /** The draft back to the published version (§4); `blocked` when nothing is published in the locale. */
128
158
  discardDraft(id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<PageRecord>>;
129
159
  delete(id: string): Promise<OpResult<null>>;
160
+ /** `redirect` (default true) keeps a published row's old address as a redirect (spec 2026-09-26 redirects §1). */
130
161
  rename(id: string, patch: {
131
162
  locale: string;
132
163
  slug?: string;
133
164
  title?: string;
165
+ redirect?: boolean;
134
166
  }): Promise<OpResult<PageRecord>>;
135
167
  move(id: string, to: {
136
168
  parentId: string | null;
137
169
  index?: number;
170
+ redirect?: boolean;
138
171
  }): Promise<OpResult<PageRecord>>;
139
172
  /** "Add page here": a folder becomes a page. */
140
173
  setTemplate(id: string, template: string): Promise<OpResult<PageRecord>>;
@@ -159,7 +192,9 @@ export type AdminOps = {
159
192
  get(collection: string, id: string, locale: string): Promise<OpResult<EntryGetResult>>;
160
193
  create(collection: string, input: EntryCreateInput): Promise<OpResult<EntryRecord>>;
161
194
  /** `expected` as on pages: the token the client last saw; `undefined` skips the check. */
162
- save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
195
+ save(collection: string, id: string, locale: string, fields: Record<string, unknown>, expected?: DraftToken | null, options?: {
196
+ redirect?: boolean;
197
+ }): Promise<OpResult<EntryRecord>>;
163
198
  setStatus(collection: string, id: string, locale: string, status: "draft" | "published", expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
164
199
  /** The live row back to the published snapshot (§4); `blocked` when the locale has no published version. */
165
200
  discardDraft(collection: string, id: string, locale: string, expected?: DraftToken | null): Promise<OpResult<EntryRecord>>;
@@ -167,6 +202,7 @@ export type AdminOps = {
167
202
  move(collection: string, id: string, to: {
168
203
  parentId: string | null;
169
204
  index?: number;
205
+ redirect?: boolean;
170
206
  }): Promise<OpResult<EntryRecord>>;
171
207
  /** Copies `from`'s localized fields and slug as the new locale's draft (spec 2026-09-06 §7). Expires nothing. */
172
208
  addLocale(collection: string, id: string, locale: string, options: {
@@ -225,6 +261,22 @@ export type AdminOps = {
225
261
  /** Admins only, self-host only; the same two guards. The Auth account stays. */
226
262
  remove(userId: string): Promise<OpResult<null>>;
227
263
  };
264
+ /** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
265
+ redirects: {
266
+ /** The locale's redirect rows with their targets' titles and current URLs, sorted by old address. */
267
+ list(locale: string): Promise<OpResult<RedirectItem[]>>;
268
+ /** Every page with a template and every routed entry live in the locale, for the Add dialog's type-ahead. */
269
+ targets(locale: string): Promise<OpResult<RedirectTargetItem[]>>;
270
+ /** Validates the old address (redirect-path.ts), refuses a live address naming its owner, `not_found` for an unknown target. */
271
+ add(locale: string, path: string, target: PathTarget): Promise<OpResult<null>>;
272
+ /** Deletes a redirect row; a live row is never touched. */
273
+ remove(locale: string, path: string): Promise<OpResult<null>>;
274
+ };
275
+ /** Content health (spec 2026-09-27 editor safety §3): read-only, every member. */
276
+ health: {
277
+ /** Every page, entry and global item against the registry, draft and live; unregistered collections counted. */
278
+ scan(): Promise<OpResult<HealthReport>>;
279
+ };
228
280
  schema: {
229
281
  /** Every member. The shell calls it once on mount and blocks when behind. */
230
282
  status(): Promise<OpResult<SchemaStatus>>;
@@ -0,0 +1,6 @@
1
+ export type RedirectPathProblem = "empty" | "leading-slash" | "root" | "trailing-slash" | "double-slash" | "whitespace" | "query-or-hash" | "admin" | "extension";
2
+ export declare const REDIRECT_PATH_MESSAGE: Record<RedirectPathProblem, string>;
3
+ export declare function redirectPathProblem(path: string, opts: {
4
+ adminPath: string;
5
+ multilingual: boolean;
6
+ }): RedirectPathProblem | null;
@@ -0,0 +1,33 @@
1
+ export const REDIRECT_PATH_MESSAGE = {
2
+ empty: "Enter the old address.",
3
+ "leading-slash": "Start with a slash: /old-address.",
4
+ root: "The site root cannot redirect.",
5
+ "trailing-slash": "Leave out the trailing slash.",
6
+ "double-slash": "An address cannot have an empty part.",
7
+ whitespace: "An address cannot contain spaces.",
8
+ "query-or-hash": "Leave out the query string and the hash: they are not part of the address.",
9
+ admin: "That is the admin's address.",
10
+ extension: "An address with a file extension never reaches Smoodly on a multilingual site — redirect it in next.config instead.",
11
+ };
12
+ export function redirectPathProblem(path, opts) {
13
+ if (path === "")
14
+ return "empty";
15
+ if (/\s/.test(path))
16
+ return "whitespace";
17
+ if (/[?#]/.test(path))
18
+ return "query-or-hash";
19
+ if (!path.startsWith("/"))
20
+ return "leading-slash";
21
+ if (path === "/")
22
+ return "root";
23
+ if (path.endsWith("/"))
24
+ return "trailing-slash";
25
+ if (path.includes("//"))
26
+ return "double-slash";
27
+ if (path === opts.adminPath || path.startsWith(`${opts.adminPath}/`))
28
+ return "admin";
29
+ const last = path.slice(path.lastIndexOf("/") + 1);
30
+ if (opts.multilingual && /\.[A-Za-z0-9]+$/.test(last))
31
+ return "extension";
32
+ return null;
33
+ }
@@ -0,0 +1,11 @@
1
+ import type { PathTarget } from "../../paths.ts";
2
+ import type { RedirectTargetItem } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ export declare function AddRedirectModal({ locale, registry, targets, onClose, onAdd }: {
5
+ locale: string;
6
+ registry: AdminRegistry;
7
+ targets: RedirectTargetItem[];
8
+ onClose(): void;
9
+ /** Resolves to an error message, or null when the redirect landed (the caller closes). */
10
+ onAdd(path: string, target: PathTarget): Promise<string | null>;
11
+ }): import("react").JSX.Element;