smoodly 0.0.28 → 0.0.30

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 (98) hide show
  1. package/README.md +123 -17
  2. package/dist/admin/client-ops.js +1 -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/CanvasWarning.d.ts +5 -0
  8. package/dist/admin/editor/CanvasWarning.js +10 -0
  9. package/dist/admin/editor/EditorView.js +113 -26
  10. package/dist/admin/editor/InlineRichtext.js +3 -1
  11. package/dist/admin/editor/Outline.d.ts +7 -0
  12. package/dist/admin/editor/Outline.js +8 -5
  13. package/dist/admin/editor/PageInspector.d.ts +6 -1
  14. package/dist/admin/editor/PageInspector.js +4 -2
  15. package/dist/admin/editor/PageSettings.js +26 -16
  16. package/dist/admin/editor/UnknownNode.d.ts +7 -0
  17. package/dist/admin/editor/UnknownNode.js +8 -0
  18. package/dist/admin/editor/canvas-diagnosis.d.ts +49 -0
  19. package/dist/admin/editor/canvas-diagnosis.js +106 -0
  20. package/dist/admin/editor/crash-copy.d.ts +7 -0
  21. package/dist/admin/editor/crash-copy.js +13 -0
  22. package/dist/admin/editor/protocol.d.ts +2 -0
  23. package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
  24. package/dist/admin/editor/useCanvasBridge.js +14 -7
  25. package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
  26. package/dist/admin/editor/useCanvasDiagnosis.js +24 -0
  27. package/dist/admin/field-paths.d.ts +3 -0
  28. package/dist/admin/field-paths.js +26 -0
  29. package/dist/admin/forms/OrphanFields.d.ts +8 -0
  30. package/dist/admin/forms/OrphanFields.js +11 -0
  31. package/dist/admin/forms/RefPicker.js +6 -2
  32. package/dist/admin/forms/RichtextElementCard.js +9 -3
  33. package/dist/admin/forms/RichtextWidget.js +3 -1
  34. package/dist/admin/forms/TagsField.js +1 -1
  35. package/dist/admin/forms/richtext-form-context.d.ts +8 -0
  36. package/dist/admin/forms/richtext-form-context.js +5 -0
  37. package/dist/admin/health.d.ts +23 -0
  38. package/dist/admin/health.js +42 -0
  39. package/dist/admin/index.d.ts +1 -0
  40. package/dist/admin/next/bridge.js +4 -2
  41. package/dist/admin/next/index.d.ts +0 -1
  42. package/dist/admin/next/index.js +6 -1
  43. package/dist/admin/ops-impl.js +40 -9
  44. package/dist/admin/ops.d.ts +13 -0
  45. package/dist/admin/shell/AddRedirectModal.js +1 -1
  46. package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
  47. package/dist/admin/shell/ContentHealthPage.js +37 -0
  48. package/dist/admin/shell/EntriesList.js +4 -4
  49. package/dist/admin/shell/EntryForm.js +20 -3
  50. package/dist/admin/shell/ErrorPane.d.ts +4 -2
  51. package/dist/admin/shell/ErrorPane.js +5 -4
  52. package/dist/admin/shell/GlobalForm.js +22 -2
  53. package/dist/admin/shell/PagesList.js +3 -3
  54. package/dist/admin/shell/SettingsView.js +6 -2
  55. package/dist/admin/shell/health-list.d.ts +27 -0
  56. package/dist/admin/shell/health-list.js +47 -0
  57. package/dist/admin/shell/settings.d.ts +4 -0
  58. package/dist/admin/shell/settings.js +5 -2
  59. package/dist/admin/tree-ops.d.ts +4 -0
  60. package/dist/admin/tree-ops.js +15 -1
  61. package/dist/admin/ui/ActionMenu.d.ts +20 -0
  62. package/dist/admin/ui/ActionMenu.js +33 -7
  63. package/dist/admin/ui/Combobox.d.ts +8 -2
  64. package/dist/admin/ui/Combobox.js +48 -16
  65. package/dist/admin/ui/ConfirmDialog.js +3 -3
  66. package/dist/admin/ui/DriftNotice.d.ts +6 -0
  67. package/dist/admin/ui/DriftNotice.js +10 -0
  68. package/dist/admin/ui/combo-box.d.ts +13 -0
  69. package/dist/admin/ui/combo-box.js +8 -0
  70. package/dist/admin/ui/confirm.d.ts +8 -0
  71. package/dist/admin/ui/confirm.js +17 -4
  72. package/dist/admin/ui/theme.js +29 -3
  73. package/dist/admin/validate.js +15 -47
  74. package/dist/custom/CustomSection.d.ts +1 -0
  75. package/dist/custom/CustomSection.js +4 -1
  76. package/dist/entry-store.d.ts +13 -0
  77. package/dist/entry-store.js +26 -3
  78. package/dist/index.d.ts +2 -2
  79. package/dist/marks.d.ts +12 -0
  80. package/dist/marks.js +14 -0
  81. package/dist/next/canvas-boundary.d.ts +16 -0
  82. package/dist/next/canvas-boundary.js +25 -0
  83. package/dist/next/page-renderer.d.ts +3 -1
  84. package/dist/next/page-renderer.js +33 -4
  85. package/dist/pairing.js +10 -3
  86. package/dist/render.d.ts +12 -1
  87. package/dist/render.js +15 -4
  88. package/dist/section-crash.d.ts +11 -0
  89. package/dist/section-crash.js +16 -0
  90. package/dist/section-guard.d.ts +27 -0
  91. package/dist/section-guard.js +41 -0
  92. package/dist/store.d.ts +9 -0
  93. package/dist/store.js +8 -0
  94. package/dist/supabase-entry-store.d.ts +2 -1
  95. package/dist/supabase-entry-store.js +27 -5
  96. package/dist/supabase-store.d.ts +2 -1
  97. package/dist/supabase-store.js +27 -0
  98. package/package.json +1 -1
@@ -0,0 +1,106 @@
1
+ // Why the page editor's canvas stayed silent (spec 2026-09-27
2
+ // shadowed-page detector §3–§5). When the bridge never speaks, the editor
3
+ // fetches the canvas URL once; this module names the cause from the
4
+ // response and words it twice — a sentence for the editor, a line for
5
+ // whoever fixes it. Pure: no React, no store.
6
+ /** The meta Smoodly's page renderer writes in draft mode (§3). */
7
+ export const DRAFT_META_NAME = "smoodly-draft";
8
+ /** The attribute on the <style> the editor bridge server-renders. */
9
+ export const BRIDGE_ATTR = "data-smoodly-bridge";
10
+ /** Read a fetch Response into a probe. A constructed Response has no url; the requested one stands in. */
11
+ export async function probeOf(requested, res) {
12
+ return {
13
+ kind: "response", requested, url: res.url || requested, status: res.status, redirected: res.redirected,
14
+ html: await res.text(),
15
+ frameOptions: res.headers.get("x-frame-options"),
16
+ csp: res.headers.get("content-security-policy"),
17
+ };
18
+ }
19
+ /** The first matching cause, in the spec's order (§4). */
20
+ export function diagnoseCanvas(probe) {
21
+ if (probe.kind === "failed")
22
+ return { kind: "unreachable" };
23
+ if (probe.redirected)
24
+ return { kind: "redirect", to: redirectTarget(probe.url, probe.requested) };
25
+ if (probe.status === 404)
26
+ return { kind: "notFound" };
27
+ if (probe.status >= 500)
28
+ return { kind: "crashed", status: probe.status };
29
+ if (probe.status < 200 || probe.status > 299)
30
+ return { kind: "refused", status: probe.status };
31
+ if (!probe.html.includes(`name="${DRAFT_META_NAME}"`))
32
+ return { kind: "shadowed" };
33
+ if (!probe.html.includes(`${BRIDGE_ATTR}=`))
34
+ return { kind: "noSearchParams" };
35
+ const framing = framingHeader(probe.frameOptions, probe.csp, new URL(probe.requested).origin);
36
+ if (framing)
37
+ return { kind: "framing", ...framing };
38
+ return { kind: "script" };
39
+ }
40
+ /** The redirect's end as a visitor would read it: path + search without the editor parameter, the full URL off-origin. */
41
+ function redirectTarget(url, requested) {
42
+ const u = new URL(url);
43
+ u.searchParams.delete("smoodly-editor");
44
+ return u.origin === new URL(requested).origin ? u.pathname + u.search : u.href;
45
+ }
46
+ /** The header that keeps the canvas iframe out, if any: X-Frame-Options DENY, or a frame-ancestors without the site's own origin. */
47
+ function framingHeader(frameOptions, csp, origin) {
48
+ if (frameOptions?.trim().toLowerCase() === "deny")
49
+ return { header: "X-Frame-Options", value: frameOptions };
50
+ const directive = csp?.split(";").map((d) => d.trim()).find((d) => /^frame-ancestors(\s|$)/i.test(d));
51
+ if (!directive)
52
+ return null;
53
+ const sources = directive.split(/\s+/).slice(1);
54
+ const allows = sources.some((s) => s.toLowerCase() === "'self'" || s === "*" || s.replace(/\/$/, "") === origin);
55
+ return allows ? null : { header: "Content-Security-Policy", value: directive };
56
+ }
57
+ /** The warning's two lines for a cause (§5, verbatim). */
58
+ export function canvasMessage(cause, path) {
59
+ switch (cause.kind) {
60
+ case "shadowed":
61
+ return {
62
+ editor: `This page can't be shown here — another route in the site's code answers ${path}.`,
63
+ developer: `A route other than Smoodly's catch-all answers ${path} (the response has no Smoodly draft marker). Rename the page, or move or remove that route.`,
64
+ };
65
+ case "noSearchParams":
66
+ return {
67
+ editor: "This page can't be shown in the editor — its route is missing a setting.",
68
+ developer: `Smoodly rendered ${path} without the editor bridge. The route file must pass searchParams: renderSmoodlyPath(path, { searchParams }).`,
69
+ };
70
+ case "crashed":
71
+ return {
72
+ editor: "This page failed to render.",
73
+ developer: `${path} answered ${cause.status}. The server log has the error — often the page shape, or a component inside a section, throwing.`,
74
+ };
75
+ case "notFound":
76
+ return {
77
+ editor: `Nothing on the site answers ${path}.`,
78
+ developer: `${path} answered 404. Check that the locale is in the app's routing and that the catch-all route file is in place.`,
79
+ };
80
+ case "redirect":
81
+ return {
82
+ editor: `${path} sends visitors to another address, so this page can't be shown.`,
83
+ developer: `${path} redirects to ${cause.to}. Check middleware and the i18n routing.`,
84
+ };
85
+ case "refused":
86
+ return {
87
+ editor: `The site refused to show ${path}.`,
88
+ developer: `${path} answered ${cause.status}. Middleware or the host refused the request; the draft-mode cookie must reach the page.`,
89
+ };
90
+ case "framing":
91
+ return {
92
+ editor: "The site doesn't allow its pages to be shown inside the editor.",
93
+ developer: `${path} sends ${cause.header}: ${cause.value}, which blocks the canvas iframe. Allow the site's own origin (SAMEORIGIN, frame-ancestors 'self').`,
94
+ };
95
+ case "script":
96
+ return {
97
+ editor: "The page loaded but didn't connect to the editor.",
98
+ developer: `${path} rendered with the editor bridge, but its script never reached the editor. Look for an error in the canvas's browser console.`,
99
+ };
100
+ case "unreachable":
101
+ return {
102
+ editor: "The site didn't answer.",
103
+ developer: `The request for ${path} failed before any response — is the site running? A redirect to another origin fails this way too.`,
104
+ };
105
+ }
106
+ }
@@ -0,0 +1,7 @@
1
+ import type { CrashedSection } from "../../marks.ts";
2
+ export declare function crashLines(crashed: CrashedSection[], titleOf: (id: string) => string): string[];
3
+ export declare function publishCrashConfirm(lines: string[]): {
4
+ title: string;
5
+ body: string;
6
+ confirmLabel: string;
7
+ };
@@ -0,0 +1,13 @@
1
+ export function crashLines(crashed, titleOf) {
2
+ return crashed.map(({ id, live }) => live === "left-out"
3
+ ? `“${titleOf(id)}” failed to render in the preview — on the live site it is left out.`
4
+ : `“${titleOf(id)}” failed to render in the preview — on the live site the whole page would fail to load.`);
5
+ }
6
+ export function publishCrashConfirm(lines) {
7
+ const n = lines.length;
8
+ return {
9
+ title: n === 1 ? "Publish with 1 broken section?" : `Publish with ${n} broken sections?`,
10
+ body: [...lines, `Fix or remove ${n === 1 ? "it" : "them"} in the editor, or publish anyway.`].join(" "),
11
+ confirmLabel: "Publish anyway",
12
+ };
13
+ }
@@ -1,3 +1,4 @@
1
+ import type { CrashedSection } from "../../marks.ts";
1
2
  export type BridgeRect = {
2
3
  id: string;
3
4
  x: number;
@@ -28,6 +29,7 @@ export type FromBridge = {
28
29
  emptyZones: ZoneRect[];
29
30
  fields: FieldRect[];
30
31
  docH: number;
32
+ crashed: CrashedSection[];
31
33
  }
32
34
  /** A click: the node under it, the marked field under it, and where (iframe client coordinates). */
33
35
  | {
@@ -1,5 +1,6 @@
1
1
  import { type RefObject } from "react";
2
2
  import type { BridgeRect, FieldRect, ToBridge, ZoneRect } from "./protocol.ts";
3
+ import type { CrashedSection } from "../../marks.ts";
3
4
  export type Geometry = "waiting" | "ok" | "missing";
4
5
  export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }: {
5
6
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -16,4 +17,6 @@ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHove
16
17
  docH: number;
17
18
  geometry: Geometry;
18
19
  postToCanvas: (m: ToBridge) => void;
20
+ onFrameLoad: () => void;
21
+ crashed: CrashedSection[];
19
22
  };
@@ -1,23 +1,28 @@
1
1
  // The admin side of the editor⇄canvas protocol: the geometry the bridge
2
2
  // reports (node rects, empty zones, document height), select/hover
3
3
  // forwarded from the iframe, and `postToCanvas` for refresh/scroll.
4
- // Geometry silence is survivable — after GEOMETRY_TIMEOUT_MS the overlay
5
- // goes dark, the iframe and the outline keep working.
4
+ // Silence is survivable — GEOMETRY_TIMEOUT_MS after the frame's load
5
+ // with no bridge message, `geometry` turns "missing" and the page editor
6
+ // diagnoses why (spec 2026-09-27 shadowed-page detector §2); the iframe
7
+ // and the outline keep working.
6
8
  import { useCallback, useEffect, useRef, useState } from "react";
7
9
  const GEOMETRY_TIMEOUT_MS = 4000;
8
10
  export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRendered }) {
9
11
  const [rects, setRects] = useState([]);
10
12
  const [emptyZones, setEmptyZones] = useState([]);
11
13
  const [fields, setFields] = useState([]);
14
+ const [crashed, setCrashed] = useState([]);
12
15
  const [docH, setDocH] = useState(900);
13
16
  const [geometry, setGeometry] = useState("waiting");
17
+ // The wait starts at the frame's load, not its src: a slow first compile in `next dev` is not silence.
18
+ const [loaded, setLoaded] = useState(false);
14
19
  // The listener registers once; the handlers are read through refs.
15
20
  const handlers = useRef({ onSelect, onHover, onRendered });
16
21
  handlers.current = { onSelect, onHover, onRendered };
17
22
  // A new canvas URL (a locale switch, a rename) means new geometry — the
18
23
  // previous page's rects are stale the instant the iframe starts loading
19
24
  // a different route, so clear them alongside resetting geometry.
20
- useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); setFields([]); }, [iframeSrc]);
25
+ useEffect(() => { setGeometry("waiting"); setLoaded(false); setRects([]); setEmptyZones([]); setFields([]); setCrashed([]); }, [iframeSrc]);
21
26
  const postToCanvas = useCallback((m) => {
22
27
  iframeRef.current?.contentWindow?.postMessage(m, "*");
23
28
  }, [iframeRef]);
@@ -28,12 +33,13 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
28
33
  return;
29
34
  if (e.source !== iframeRef.current?.contentWindow)
30
35
  return;
36
+ setGeometry("ok"); // any bridge message: Smoodly rendered the canvas with its bridge
31
37
  if (m.kind === "rects") {
32
38
  setRects(m.nodes);
33
39
  setEmptyZones(m.emptyZones);
34
40
  setFields(m.fields);
35
41
  setDocH(m.docH);
36
- setGeometry("ok");
42
+ setCrashed(m.crashed ?? []);
37
43
  }
38
44
  else if (m.kind === "select") {
39
45
  handlers.current.onSelect(m.id, m.path, m.x, m.y);
@@ -49,10 +55,11 @@ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover, onRen
49
55
  return () => window.removeEventListener("message", onMessage);
50
56
  }, [iframeRef]);
51
57
  useEffect(() => {
52
- if (!iframeSrc || geometry !== "waiting")
58
+ if (!iframeSrc || !loaded || geometry !== "waiting")
53
59
  return;
54
60
  const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
55
61
  return () => clearTimeout(t);
56
- }, [iframeSrc, geometry]);
57
- return { rects, emptyZones, fields, docH, geometry, postToCanvas };
62
+ }, [iframeSrc, loaded, geometry]);
63
+ const onFrameLoad = useCallback(() => setLoaded(true), []);
64
+ return { rects, emptyZones, fields, docH, geometry, postToCanvas, onFrameLoad, crashed };
58
65
  }
@@ -0,0 +1,7 @@
1
+ import { type CanvasCause } from "./canvas-diagnosis.ts";
2
+ import type { Geometry } from "./useCanvasBridge.ts";
3
+ export type CanvasDiagnosis = {
4
+ cause: CanvasCause;
5
+ path: string;
6
+ };
7
+ export declare function useCanvasDiagnosis(iframeSrc: string | null, geometry: Geometry): CanvasDiagnosis | null;
@@ -0,0 +1,24 @@
1
+ // The page editor's diagnosis of a silent canvas (spec 2026-09-27
2
+ // shadowed-page detector §4): once the bridge's wait ends in "missing",
3
+ // fetch the canvas URL once — same origin, so the draft-mode cookie rides
4
+ // along and the server renders what the frame got — and name the cause.
5
+ // A new src, or the bridge speaking after all, drops the answer.
6
+ import { useEffect, useState } from "react";
7
+ import { diagnoseCanvas, probeOf } from "./canvas-diagnosis.js";
8
+ export function useCanvasDiagnosis(iframeSrc, geometry) {
9
+ const [diagnosis, setDiagnosis] = useState(null);
10
+ useEffect(() => {
11
+ setDiagnosis(null);
12
+ if (!iframeSrc || geometry !== "missing")
13
+ return;
14
+ let live = true;
15
+ const requested = new URL(iframeSrc, window.location.href);
16
+ fetch(requested.href, { cache: "no-store" })
17
+ .then((res) => probeOf(requested.href, res))
18
+ .catch(() => ({ kind: "failed" }))
19
+ .then((probe) => { if (live)
20
+ setDiagnosis({ cause: diagnoseCanvas(probe), path: requested.pathname }); });
21
+ return () => { live = false; };
22
+ }, [iframeSrc, geometry]);
23
+ return diagnosis;
24
+ }
@@ -11,3 +11,6 @@ export declare function getAtPath(fields: Record<string, unknown> | undefined, p
11
11
  * siblings keep their identity — and keeps arrays arrays. Missing
12
12
  * objects along the way are created. */
13
13
  export declare function setAtPath(fields: Record<string, unknown> | undefined, path: string, value: unknown): Record<string, unknown>;
14
+ /** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
15
+ * Copies only the spine; a missing path returns an equal copy. */
16
+ export declare function unsetAtPath(fields: Record<string, unknown> | undefined, path: string): Record<string, unknown>;
@@ -47,3 +47,29 @@ export function setAtPath(fields, path, value) {
47
47
  };
48
48
  return set(fields ?? {}, splitPath(path));
49
49
  }
50
+ /** `fields` without the key at `path` (spec 2026-09-27 editor safety §5: an orphan's Clear).
51
+ * Copies only the spine; a missing path returns an equal copy. */
52
+ export function unsetAtPath(fields, path) {
53
+ const unset = (obj, segments) => {
54
+ if (obj === null || typeof obj !== "object")
55
+ return obj;
56
+ const [head, ...rest] = segments;
57
+ if (Array.isArray(obj)) {
58
+ const i = Number(head);
59
+ if (rest.length === 0 || !(i in obj))
60
+ return obj;
61
+ const copy = obj.slice();
62
+ copy[i] = unset(obj[i], rest);
63
+ return copy;
64
+ }
65
+ const o = obj;
66
+ if (!(head in o))
67
+ return o;
68
+ if (rest.length === 0) {
69
+ const { [head]: _gone, ...keep } = o;
70
+ return keep;
71
+ }
72
+ return { ...o, [head]: unset(o[head], rest) };
73
+ };
74
+ return unset(fields ?? {}, splitPath(path));
75
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Finding } from "../drift.ts";
3
+ export declare function OrphanFields({ orphans, strip, onClear }: {
4
+ orphans: Finding[];
5
+ /** The node-id prefix to drop from the shown key and from the path Clear gets. */
6
+ strip?: string;
7
+ onClear(path: string): void;
8
+ }): ReactElement | null;
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button } from "../ui/primitives.js";
4
+ const shown = (v) => (typeof v === "string" ? v : JSON.stringify(v));
5
+ export function OrphanFields({ orphans, strip, onClear }) {
6
+ const rows = orphans.filter((f) => f.kind === "orphan-field");
7
+ if (rows.length === 0)
8
+ return null;
9
+ const local = (p) => (strip && p.startsWith(`${strip}.`) ? p.slice(strip.length + 1) : p);
10
+ return (_jsxs("details", { className: "sm-orphans", "data-testid": "orphan-fields", children: [_jsx("summary", { className: "sm-orphans__head", children: "No longer in the schema" }), rows.map((f) => (_jsxs("div", { className: "sm-orphans__row", children: [_jsx("code", { children: local(f.path) }), _jsx("span", { className: "sm-orphans__value", children: shown(f.value) }), _jsx(Button, { variant: "secondary", onClick: () => { void navigator.clipboard?.writeText(shown(f.value)); }, children: "Copy" }), _jsx(Button, { variant: "secondary", onClick: () => onClear(local(f.path)), children: "Clear" })] }, f.path)))] }));
11
+ }
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  // One widget for f.ref and f.refList (spec 2026-09-17 §2): a search input,
4
- // the picked entries under it. A refList's rows drag like a list's.
4
+ // the picked entries under it. A refList's rows drag like a list's; a single
5
+ // ref shows its pick inside the input, open and remove beside it (2026-09-27).
5
6
  import { ExternalLink } from "lucide-react";
6
7
  import { Combobox } from "../ui/Combobox.js";
7
8
  import { Grip, IconButton, cx } from "../ui/primitives.js";
@@ -10,6 +11,7 @@ import { useSortable } from "../ui/useSortable.js";
10
11
  import { useAdminHref } from "../shell/base.js";
11
12
  import { moveItem } from "./list.js";
12
13
  import { pickRef, refCandidates, refIds, refRows, refValue, statusTone } from "./ref-picker.js";
14
+ const ABSENT = "Not available in this locale";
13
15
  const titleCase = (name) => name.replace(/^./, (c) => c.toUpperCase());
14
16
  function StatusDot({ status }) {
15
17
  if (status === "")
@@ -26,5 +28,7 @@ export function RefPicker({ value, onChange, options, multiple, max, target }) {
26
28
  const set = (next) => onChange(refValue(next, multiple));
27
29
  const sortable = useSortable({ count: multiple ? rows.length : 0, onDrop: (from, to) => set(moveItem(ids, from, to)) });
28
30
  const present = (options ?? []).filter((o) => !o.absent).length;
29
- return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsx("span", { className: "sm-refrow__note", children: " \u00B7 Not available in this locale" })] }), _jsx(StatusDot, { status: r.status }), (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
31
+ const openLink = (r) => (r.state === "ok" || r.state === "absent") && (_jsx("a", { className: "sm-iconbtn", href: adminHref(`/${target}/${r.id}`), target: "_blank", rel: "noreferrer", "aria-label": "Open entry", title: "Open entry", children: _jsx(ExternalLink, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) }));
32
+ const single = multiple ? undefined : rows[0];
33
+ return (_jsxs("div", { className: "sm-refpicker", children: [_jsx(Combobox, { label: `Search ${titleCase(target)}`, keepOpen: multiple, options: candidates.map((o) => ({ value: o.id, label: o.title })), disabled: options === undefined || atMax, placeholder: atMax ? `Maximum ${max}` : options === undefined ? "Loading…" : "Type to search", emptyText: present === 0 ? `No entries in ${titleCase(target)} yet` : "Every entry is already picked", renderOption: (o) => (_jsxs(_Fragment, { children: [_jsx("span", { className: "sm-refpicker__title", children: o.label }), _jsx(StatusDot, { status: byId.get(o.value)?.status ?? "" })] })), selected: single && `${single.title}${single.state === "absent" ? ` · ${ABSENT}` : ""}`, suffix: single && (_jsxs(_Fragment, { children: [_jsx(StatusDot, { status: single.status }), openLink(single), _jsx(IconButton, { label: "Remove", onClick: () => set([]), children: "\u00D7" })] })), onPick: (id) => set(pickRef(ids, id, multiple, max)) }), multiple && rows.length > 0 && (_jsx("div", { className: "sm-refpicker__rows", ref: sortable.containerRef, children: rows.map((r, i) => (_jsxs("div", { style: multiple ? sortable.rowStyle(i) : undefined, className: cx("sm-refrow", r.state !== "ok" && "sm-refrow--muted", multiple && sortable.rowClass(i)), children: [multiple && _jsx(Grip, { ...sortable.handleProps(i) }), _jsxs("span", { className: "sm-refpicker__title", children: [r.title, r.state === "absent" && _jsxs("span", { className: "sm-refrow__note", children: [" \u00B7 ", ABSENT] })] }), _jsx(StatusDot, { status: r.status }), openLink(r), _jsx(IconButton, { label: "Remove", onClick: () => set(ids.filter((_, j) => j !== i)), children: "\u00D7" })] }, `${r.id}-${i}`))) }))] }));
30
34
  }
@@ -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,6 +7,7 @@ export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
7
7
  export { makeClientOps } from "./client-ops.ts";
8
8
  export { gateAdminOp, type AdminAuth } from "./auth.ts";
9
9
  export { supabaseAdminAuth } from "./supabase-auth.ts";
10
+ export type { AuthConfig } from "./shell/session.ts";
10
11
  export { serializeAdminConfig } from "./serialize.ts";
11
12
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
12
13
  export * from "./tree-ops.ts";
@@ -14,7 +14,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
14
14
  // offsets map 1:1 onto the overlay.
15
15
  import { useEffect } from "react";
16
16
  import { useRouter } from "next/navigation";
17
- import { BLANK_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
17
+ import { BLANK_ATTR, CRASHED_ATTR, EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, crashedOf, resolveMarkPath } from "../../marks.js";
18
18
  const post = (m) => window.parent.postMessage(m, "*");
19
19
  /** The height of an md-spaced section with nothing inside (3.5rem + 3.5rem). */
20
20
  // The editor sizes the iframe to the reported height; the document's own
@@ -69,7 +69,9 @@ export function EditorBridge() {
69
69
  }
70
70
  // Rounded up from the fractional box, so the frame is never a hair too short.
71
71
  const docH = Math.ceil(Math.max(document.documentElement.scrollHeight, document.documentElement.getBoundingClientRect().height));
72
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH });
72
+ // Crashed sections (spec 2026-09-27 section guard §6): the placeholders the guard and the canvas net left.
73
+ const crashed = crashedOf(all(`[${CRASHED_ATTR}]`));
74
+ post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH, crashed });
73
75
  };
74
76
  const raf = () => requestAnimationFrame(report);
75
77
  const ro = new ResizeObserver(raf);
@@ -6,4 +6,3 @@ export type { MemberRole } from "../../permissions.ts";
6
6
  export type { AuthConfig } from "../shell/session.ts";
7
7
  export type { ContentConfig } from "../../env.ts";
8
8
  export type { AdminAuth } from "../auth.ts";
9
- export { supabaseAdminAuth } from "../supabase-auth.ts";
@@ -1,3 +1,8 @@
1
+ // The admin route group's entry. createSmoodlyAdmin imports the "use
2
+ // client" shell, so any module that imports a value from here puts the
3
+ // whole admin in its route's JavaScript — whether it renders or not.
4
+ // Never import it from code the site's routes reach (smoodly/server.ts
5
+ // included); the auth glue lives in `smoodly/admin`, which has no client
6
+ // code.
1
7
  export { createAdminOpHandler } from "./op-handler.js";
2
8
  export { createSmoodlyAdmin } from "./create-admin.js";
3
- export { supabaseAdminAuth } from "../supabase-auth.js";