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.
- package/README.md +75 -3
- package/dist/admin/client-ops.js +2 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/EditorView.js +70 -15
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +9 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.d.ts +4 -1
- package/dist/admin/editor/PageSettings.js +33 -15
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/ops-impl.js +124 -14
- package/dist/admin/ops.d.ts +53 -1
- package/dist/admin/redirect-path.d.ts +6 -0
- package/dist/admin/redirect-path.js +33 -0
- package/dist/admin/shell/AddRedirectModal.d.ts +11 -0
- package/dist/admin/shell/AddRedirectModal.js +29 -0
- package/dist/admin/shell/AdminApp.js +2 -3
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +32 -7
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/IconRail.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +3 -2
- package/dist/admin/shell/PagesList.js +9 -7
- package/dist/admin/shell/RedirectsPage.d.ts +11 -0
- package/dist/admin/shell/RedirectsPage.js +75 -0
- package/dist/admin/shell/SettingsView.d.ts +4 -2
- package/dist/admin/shell/SettingsView.js +22 -14
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/live-url.d.ts +10 -2
- package/dist/admin/shell/live-url.js +9 -4
- package/dist/admin/shell/members-list.js +1 -1
- package/dist/admin/shell/redirects-list.d.ts +12 -0
- package/dist/admin/shell/redirects-list.js +15 -0
- package/dist/admin/shell/settings.d.ts +16 -3
- package/dist/admin/shell/settings.js +16 -9
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.d.ts +1 -1
- package/dist/admin/ui/ConfirmDialog.js +5 -4
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +21 -2
- package/dist/admin/ui/confirm.js +26 -9
- package/dist/admin/ui/theme.js +32 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +31 -6
- package/dist/entry-store.js +39 -11
- package/dist/index.d.ts +2 -2
- package/dist/next/page-renderer.js +28 -9
- package/dist/path-index.d.ts +28 -4
- package/dist/path-index.js +37 -4
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +9 -0
- package/dist/render.js +7 -2
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.d.ts +4 -1
- package/dist/site.js +3 -1
- package/dist/sql.js +19 -1
- package/dist/store.d.ts +39 -5
- package/dist/store.js +32 -8
- package/dist/supabase-entry-store.d.ts +9 -3
- package/dist/supabase-entry-store.js +39 -13
- package/dist/supabase-path-index.d.ts +10 -3
- package/dist/supabase-path-index.js +50 -17
- package/dist/supabase-store.d.ts +15 -3
- package/dist/supabase-store.js +42 -8
- package/migrations/20260926150000_smoodly_v4.sql +41 -0
- 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
|
|
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
|
+
}
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
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
|
},
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -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
|
|
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;
|