smoodly 0.0.9 → 0.0.10

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 (61) hide show
  1. package/README.md +94 -18
  2. package/dist/admin/editor/CanvasOverlay.d.ts +21 -0
  3. package/dist/admin/editor/CanvasOverlay.js +39 -0
  4. package/dist/admin/editor/EditorView.js +56 -237
  5. package/dist/admin/editor/InsertPicker.d.ts +18 -0
  6. package/dist/admin/editor/InsertPicker.js +18 -0
  7. package/dist/admin/editor/NoticePane.d.ts +4 -0
  8. package/dist/admin/editor/NoticePane.js +4 -0
  9. package/dist/admin/editor/Outline.d.ts +18 -0
  10. package/dist/admin/editor/Outline.js +12 -0
  11. package/dist/admin/editor/locale-url.d.ts +12 -0
  12. package/dist/admin/editor/locale-url.js +32 -0
  13. package/dist/admin/editor/overlay-geometry.d.ts +14 -0
  14. package/dist/admin/editor/overlay-geometry.js +27 -0
  15. package/dist/admin/editor/useCanvasBridge.d.ts +15 -0
  16. package/dist/admin/editor/useCanvasBridge.js +53 -0
  17. package/dist/admin/editor/usePageLocale.d.ts +23 -0
  18. package/dist/admin/editor/usePageLocale.js +88 -0
  19. package/dist/admin/forms/FieldWidget.js +4 -22
  20. package/dist/admin/forms/RichtextToolbar.d.ts +20 -0
  21. package/dist/admin/forms/RichtextToolbar.js +37 -0
  22. package/dist/admin/forms/RichtextWidget.d.ts +5 -0
  23. package/dist/admin/forms/RichtextWidget.js +123 -0
  24. package/dist/admin/forms/richtext-extensions.d.ts +5 -0
  25. package/dist/admin/forms/richtext-extensions.js +74 -0
  26. package/dist/admin/forms/richtext-paste.d.ts +22 -0
  27. package/dist/admin/forms/richtext-paste.js +59 -0
  28. package/dist/admin/index.d.ts +0 -1
  29. package/dist/admin/index.js +0 -1
  30. package/dist/admin/next/create-admin.d.ts +3 -7
  31. package/dist/admin/next/create-admin.js +14 -5
  32. package/dist/admin/richtext-doc.d.ts +7 -0
  33. package/dist/admin/richtext-doc.js +18 -0
  34. package/dist/admin/shell/AdminApp.d.ts +7 -2
  35. package/dist/admin/shell/AdminApp.js +12 -6
  36. package/dist/admin/shell/EntriesList.js +3 -2
  37. package/dist/admin/shell/EntryForm.js +8 -24
  38. package/dist/admin/shell/ListView.js +3 -1
  39. package/dist/admin/shell/PagesList.js +3 -2
  40. package/dist/admin/shell/RouteLink.d.ts +4 -0
  41. package/dist/admin/shell/RouteLink.js +12 -0
  42. package/dist/admin/shell/SharedForm.js +3 -21
  43. package/dist/admin/shell/router.d.ts +37 -0
  44. package/dist/admin/shell/router.js +84 -0
  45. package/dist/admin/shell/session.js +3 -1
  46. package/dist/admin/ui/primitives.d.ts +2 -1
  47. package/dist/admin/ui/primitives.js +5 -3
  48. package/dist/admin/ui/theme.d.ts +1 -1
  49. package/dist/admin/ui/theme.js +27 -1
  50. package/dist/admin/validate.js +10 -0
  51. package/dist/collections.d.ts +0 -14
  52. package/dist/collections.js +0 -10
  53. package/dist/fields.d.ts +2 -1
  54. package/dist/fields.js +2 -1
  55. package/dist/index.d.ts +4 -2
  56. package/dist/index.js +3 -1
  57. package/dist/toolset.d.ts +30 -0
  58. package/dist/toolset.js +49 -0
  59. package/package.json +11 -1
  60. package/dist/admin/richtext.d.ts +0 -16
  61. package/dist/admin/richtext.js +0 -98
@@ -0,0 +1,15 @@
1
+ import { type RefObject } from "react";
2
+ import type { BridgeRect, ToBridge, ZoneRect } from "./protocol.ts";
3
+ export type Geometry = "waiting" | "ok" | "missing";
4
+ export declare function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }: {
5
+ iframeRef: RefObject<HTMLIFrameElement | null>;
6
+ iframeSrc: string | null;
7
+ onSelect(id: string | null): void;
8
+ onHover(id: string | null): void;
9
+ }): {
10
+ rects: BridgeRect[];
11
+ emptyZones: ZoneRect[];
12
+ docH: number;
13
+ geometry: Geometry;
14
+ postToCanvas: (m: ToBridge) => void;
15
+ };
@@ -0,0 +1,53 @@
1
+ // The admin side of the editor⇄canvas protocol: the geometry the bridge
2
+ // reports (node rects, empty zones, document height), select/hover
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.
6
+ import { useCallback, useEffect, useRef, useState } from "react";
7
+ const GEOMETRY_TIMEOUT_MS = 4000;
8
+ export function useCanvasBridge({ iframeRef, iframeSrc, onSelect, onHover }) {
9
+ const [rects, setRects] = useState([]);
10
+ const [emptyZones, setEmptyZones] = useState([]);
11
+ const [docH, setDocH] = useState(900);
12
+ const [geometry, setGeometry] = useState("waiting");
13
+ // The listener registers once; the handlers are read through refs.
14
+ const handlers = useRef({ onSelect, onHover });
15
+ handlers.current = { onSelect, onHover };
16
+ // A new canvas URL (a locale switch, a rename) means new geometry — the
17
+ // previous page's rects are stale the instant the iframe starts loading
18
+ // a different route, so clear them alongside resetting geometry.
19
+ useEffect(() => { setGeometry("waiting"); setRects([]); setEmptyZones([]); }, [iframeSrc]);
20
+ const postToCanvas = useCallback((m) => {
21
+ iframeRef.current?.contentWindow?.postMessage(m, "*");
22
+ }, [iframeRef]);
23
+ useEffect(() => {
24
+ const onMessage = (e) => {
25
+ const m = e.data;
26
+ if (m?.source !== "smoodly")
27
+ return;
28
+ if (e.source !== iframeRef.current?.contentWindow)
29
+ return;
30
+ if (m.kind === "rects") {
31
+ setRects(m.nodes);
32
+ setEmptyZones(m.emptyZones);
33
+ setDocH(m.docH);
34
+ setGeometry("ok");
35
+ }
36
+ else if (m.kind === "select") {
37
+ handlers.current.onSelect(m.id);
38
+ }
39
+ else if (m.kind === "hover") {
40
+ handlers.current.onHover(m.id);
41
+ }
42
+ };
43
+ window.addEventListener("message", onMessage);
44
+ return () => window.removeEventListener("message", onMessage);
45
+ }, [iframeRef]);
46
+ useEffect(() => {
47
+ if (!iframeSrc || geometry !== "waiting")
48
+ return;
49
+ const t = setTimeout(() => setGeometry((g) => (g === "waiting" ? "missing" : g)), GEOMETRY_TIMEOUT_MS);
50
+ return () => clearTimeout(t);
51
+ }, [iframeSrc, geometry]);
52
+ return { rects, emptyZones, docH, geometry, postToCanvas };
53
+ }
@@ -0,0 +1,23 @@
1
+ import type { PageRecord } from "../../store.ts";
2
+ import type { AdminOps } from "../ops.ts";
3
+ import type { AdminRegistry } from "../serialize.ts";
4
+ export declare function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }: {
5
+ registry: AdminRegistry;
6
+ ops: AdminOps;
7
+ id: string;
8
+ record: PageRecord | null;
9
+ setRecord(record: PageRecord): void;
10
+ /** Flush the pending autosave; false when it could not land. */
11
+ flush(): Promise<boolean>;
12
+ /** Show an error banner. */
13
+ fail(text: string): void;
14
+ clearBanner(): void;
15
+ /** Reload the editor for the current locale. */
16
+ reload(): void;
17
+ }): {
18
+ locale: string;
19
+ adding: boolean;
20
+ switchLocale: (next: string) => Promise<void>;
21
+ doAddLocale: (target: string) => Promise<void>;
22
+ doRemoveLocale: () => Promise<void>;
23
+ };
@@ -0,0 +1,88 @@
1
+ // The editor's locale lifecycle: which locale is being edited (from the
2
+ // URL), switching, adding this page to a locale, removing it from one.
3
+ // Every transition flushes the pending autosave first — a save landing
4
+ // after the switch would persist THIS tree under the new locale.
5
+ import { useState } from "react";
6
+ import { sourceLocaleOf } from "../shell/pages-tree.js";
7
+ import { localeFromUrl, writeLocaleToUrl } from "./locale-url.js";
8
+ export function usePageLocale({ registry, ops, id, record, setRecord, flush, fail, clearBanner, reload }) {
9
+ const [locale, setLocale] = useState(() => localeFromUrl(registry));
10
+ const [adding, setAdding] = useState(false);
11
+ const switchLocale = async (next) => {
12
+ if (next === locale)
13
+ return;
14
+ const flushed = await flush();
15
+ if (!flushed) {
16
+ fail("Couldn't save your latest changes — switch cancelled.");
17
+ return;
18
+ }
19
+ writeLocaleToUrl(next);
20
+ clearBanner();
21
+ setLocale(next);
22
+ };
23
+ /** Add a locale, copying the tree on screen (or the record's source locale when this one is absent). */
24
+ const doAddLocale = async (target) => {
25
+ if (!record || adding)
26
+ return;
27
+ const from = record.locales[locale] ? locale : sourceLocaleOf(record, registry);
28
+ if (from === locale) {
29
+ const flushed = await flush();
30
+ if (!flushed) {
31
+ fail("Couldn't save your latest changes — add cancelled.");
32
+ return;
33
+ }
34
+ }
35
+ setAdding(true);
36
+ let result;
37
+ try {
38
+ result = await ops.pages.addLocale(id, target, { from });
39
+ }
40
+ catch {
41
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
42
+ }
43
+ setAdding(false);
44
+ if (!result.ok) {
45
+ fail(result.message);
46
+ return;
47
+ }
48
+ // The record now HAS the locale; keep it, or the pane below would go
49
+ // on offering "doesn't exist yet" until the reload lands.
50
+ setRecord(result.data);
51
+ if (target === locale)
52
+ reload();
53
+ else
54
+ void switchLocale(target);
55
+ };
56
+ const doRemoveLocale = async () => {
57
+ if (!record)
58
+ return;
59
+ const remaining = Object.keys(record.locales).filter((l) => l !== locale);
60
+ if (remaining.length === 0)
61
+ return;
62
+ if (!window.confirm(`Remove this page from ${locale}? Its ${locale} content and history are deleted.`))
63
+ return;
64
+ // Let a pending save land before the row goes, so no save races the
65
+ // delete — and if it cannot land, stop: a retry mid-flight would
66
+ // recreate the very draft the removal is deleting.
67
+ const flushed = await flush();
68
+ if (!flushed) {
69
+ fail("Couldn't save your latest changes — removal cancelled.");
70
+ return;
71
+ }
72
+ let result;
73
+ try {
74
+ result = await ops.pages.removeLocale(id, locale);
75
+ }
76
+ catch {
77
+ result = { ok: false, code: "internal", message: "Couldn't reach the server." };
78
+ }
79
+ if (!result.ok) {
80
+ fail(result.message);
81
+ return;
82
+ }
83
+ const next = remaining.includes(registry.locales.default) ? registry.locales.default : remaining[0];
84
+ writeLocaleToUrl(next);
85
+ setLocale(next);
86
+ };
87
+ return { locale, adding, switchLocale, doAddLocale, doRemoveLocale };
88
+ }
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  // Objects and lists recurse through this same component; the list's
7
7
  // logic lives in ./list.ts as pure functions.
8
8
  import { useState } from "react";
9
- import { plainToRichtext, richtextToPlain } from "../richtext.js";
9
+ import { RichtextWidget } from "./RichtextWidget.js";
10
10
  import { Button, Field, IconButton } from "../ui/primitives.js";
11
11
  import { addItem, itemSummary, moveIndex, moveItem, patchObject, removeIndex, removeItem } from "./list.js";
12
12
  const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
@@ -41,7 +41,9 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
41
41
  break;
42
42
  }
43
43
  case "richtext":
44
- control = _jsx(RichtextArea, { value: value, onChange: onChange });
44
+ // The toolbar holds buttons, so this control can never sit in a <label>.
45
+ asDiv = true;
46
+ control = _jsx(RichtextWidget, { value: value, toolset: d.toolset, onChange: onChange });
45
47
  break;
46
48
  case "image":
47
49
  case "video":
@@ -110,23 +112,3 @@ export function FieldWidget({ descriptor: d, value, onChange, errors, path: path
110
112
  function ListRow({ index, count, summary, open, onToggle, onMove, onRemove, children }) {
111
113
  return (_jsxs("div", { className: "sm-listrow", children: [_jsxs("div", { className: "sm-listrow__head", children: [_jsxs("button", { type: "button", className: "sm-listrow__toggle", "aria-expanded": open, onClick: onToggle, children: [_jsx("span", { "aria-hidden": "true", children: open ? "▾" : "▸" }), _jsx("span", { className: "sm-listrow__summary", children: summary })] }), _jsx(IconButton, { label: "Move up", disabled: index === 0, onClick: () => onMove(index - 1), children: "\u2191" }), _jsx(IconButton, { label: "Move down", disabled: index === count - 1, onClick: () => onMove(index + 1), children: "\u2193" }), _jsx(IconButton, { label: "Remove", onClick: onRemove, children: "\u00D7" })] }), open && _jsx("div", { className: "sm-listrow__body", children: children })] }));
112
114
  }
113
- /** The interim richtext widget owns its text: the line syntax drops a trailing
114
- * newline on the round trip, so deriving the value from the document each
115
- * render would snap the caret back after Enter at the end of the text. Re-seeds
116
- * when the document changes underneath it (load, locale switch). The parent must
117
- * store the emitted document by reference — a clone or a server copy handed back
118
- * would re-seed the text and bring the caret snap back. */
119
- function RichtextArea({ value, onChange }) {
120
- const [text, setText] = useState(() => richtextToPlain(value));
121
- const [seen, setSeen] = useState(value);
122
- if (value !== seen) {
123
- setSeen(value);
124
- setText(richtextToPlain(value));
125
- }
126
- return (_jsx("textarea", { className: "sm-textarea", rows: 5, value: text, onChange: (e) => {
127
- const doc = plainToRichtext(e.target.value);
128
- setText(e.target.value);
129
- setSeen(doc);
130
- onChange(doc);
131
- } }));
132
- }
@@ -0,0 +1,20 @@
1
+ import type { Allowed } from "../../toolset.ts";
2
+ export type ToolbarCommand = "bold" | "italic" | "link" | "bulletList" | "orderedList" | "blockquote" | {
3
+ heading: number;
4
+ };
5
+ export type ActiveState = {
6
+ bold: boolean;
7
+ italic: boolean;
8
+ link: boolean;
9
+ bulletList: boolean;
10
+ orderedList: boolean;
11
+ blockquote: boolean;
12
+ /** The active heading level, or null in a paragraph. */
13
+ heading: number | null;
14
+ };
15
+ export declare const INACTIVE: ActiveState;
16
+ export declare function RichtextToolbar({ allowed, active, onCommand }: {
17
+ allowed: Allowed;
18
+ active: ActiveState;
19
+ onCommand(command: ToolbarCommand): void;
20
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Bold, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, Link, List, ListOrdered, Quote } from "lucide-react";
4
+ import { IconButton } from "../ui/primitives.js";
5
+ export const INACTIVE = {
6
+ bold: false, italic: false, link: false, bulletList: false, orderedList: false, blockquote: false, heading: null,
7
+ };
8
+ const HEADING_ICONS = { 1: Heading1, 2: Heading2, 3: Heading3, 4: Heading4, 5: Heading5, 6: Heading6 };
9
+ const ICON = 14;
10
+ /** Keep the editor's selection: a toolbar press must not blur the content. */
11
+ const keepFocus = (e) => e.preventDefault();
12
+ export function RichtextToolbar({ allowed, active, onCommand }) {
13
+ const button = (label, pressed, command, Icon) => (_jsx(IconButton, { label: label, "aria-pressed": pressed, onMouseDown: keepFocus, onClick: () => onCommand(command), children: _jsx(Icon, { size: ICON, "aria-hidden": "true" }) }, label));
14
+ const groups = [];
15
+ const marks = [
16
+ allowed.marks.has("bold") && button("Bold", active.bold, "bold", Bold),
17
+ allowed.marks.has("italic") && button("Italic", active.italic, "italic", Italic),
18
+ allowed.marks.has("link") && button("Link", active.link, "link", Link),
19
+ ].filter(Boolean);
20
+ if (marks.length > 0)
21
+ groups.push(marks);
22
+ const headings = allowed.headingLevels
23
+ .filter((l) => l >= 1 && l <= 6)
24
+ .map((l) => button(`Heading ${l}`, active.heading === l, { heading: l }, HEADING_ICONS[l]));
25
+ if (headings.length > 0)
26
+ groups.push(headings);
27
+ const blocks = [
28
+ allowed.nodes.has("bulletList") && button("Bullet list", active.bulletList, "bulletList", List),
29
+ allowed.nodes.has("orderedList") && button("Numbered list", active.orderedList, "orderedList", ListOrdered),
30
+ allowed.nodes.has("blockquote") && button("Quote", active.blockquote, "blockquote", Quote),
31
+ ].filter(Boolean);
32
+ if (blocks.length > 0)
33
+ groups.push(blocks);
34
+ if (groups.length === 0)
35
+ return null;
36
+ return (_jsx("div", { className: "sm-richtext__bar", role: "toolbar", "aria-label": "Formatting", children: groups.map((group, i) => (_jsxs("span", { style: { display: "contents" }, children: [i > 0 && _jsx("span", { className: "sm-richtext__sep", "aria-hidden": "true" }), group] }, i))) }));
37
+ }
@@ -0,0 +1,5 @@
1
+ export declare function RichtextWidget({ value, toolset, onChange }: {
2
+ value: unknown;
3
+ toolset: unknown;
4
+ onChange(doc: unknown): void;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,123 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The richtext field widget (spec 2026-09-08 §3): a TipTap editor under a
4
+ // fixed toolbar. The editor owns its document and emits TipTap JSON on
5
+ // every change; the parent stores the emitted document by reference, so
6
+ // a value that is not the last emitted document is a change from outside
7
+ // (load, locale switch, a list row moving) and re-seeds the editor. The
8
+ // schema is always full; `allowed` gates creation (extensions, paste,
9
+ // toolbar). Only this file and richtext-extensions.ts import TipTap.
10
+ import { useEffect, useMemo, useRef, useState } from "react";
11
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
12
+ import { Slice } from "@tiptap/pm/model";
13
+ import { allowedOf } from "../../toolset.js";
14
+ import { plainDoc } from "../richtext-doc.js";
15
+ import { Button } from "../ui/primitives.js";
16
+ import { richtextExtensions } from "./richtext-extensions.js";
17
+ import { gatePastedSlice } from "./richtext-paste.js";
18
+ import { INACTIVE, RichtextToolbar } from "./RichtextToolbar.js";
19
+ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
20
+ const LEVELS = [1, 2, 3, 4, 5, 6];
21
+ const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
22
+ export function RichtextWidget({ value, toolset, onChange }) {
23
+ const allowed = useMemo(() => allowedOf(toolset), [toolset]);
24
+ // The parent's onChange closes over its render; the editor's onUpdate is
25
+ // created once, so read the latest through a ref.
26
+ const onChangeRef = useRef(onChange);
27
+ onChangeRef.current = onChange;
28
+ // The last document this widget emitted — anything else arriving as
29
+ // `value` came from outside and re-seeds the editor.
30
+ const emitted = useRef(value);
31
+ const [link, setLink] = useState({ open: false, href: "" });
32
+ const openLinkRef = useRef(() => { });
33
+ const editor = useEditor({
34
+ immediatelyRender: false,
35
+ extensions: richtextExtensions(allowed, { onLink: () => openLinkRef.current() }),
36
+ content: isDoc(value) ? value : EMPTY,
37
+ editorProps: {
38
+ attributes: { class: "sm-richtext__content" },
39
+ transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
40
+ },
41
+ onUpdate: ({ editor }) => {
42
+ const doc = plainDoc(editor.getJSON());
43
+ emitted.current = doc;
44
+ onChangeRef.current(doc);
45
+ },
46
+ }, [allowed]);
47
+ useEffect(() => {
48
+ if (!editor || value === emitted.current)
49
+ return;
50
+ emitted.current = value;
51
+ editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
52
+ }, [editor, value]);
53
+ const active = useEditorState({
54
+ editor,
55
+ selector: ({ editor }) => editor
56
+ ? {
57
+ bold: editor.isActive("bold"),
58
+ italic: editor.isActive("italic"),
59
+ link: editor.isActive("link"),
60
+ bulletList: editor.isActive("bulletList"),
61
+ orderedList: editor.isActive("orderedList"),
62
+ blockquote: editor.isActive("blockquote"),
63
+ heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
64
+ }
65
+ : INACTIVE,
66
+ });
67
+ // Read the caret's link from the editor at open time, not from the last
68
+ // render: a selection change and the press can land in the same tick.
69
+ const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
70
+ openLinkRef.current = openLink;
71
+ const closeLink = () => {
72
+ setLink({ open: false, href: "" });
73
+ editor?.commands.focus();
74
+ };
75
+ const applyLink = () => {
76
+ if (!editor)
77
+ return;
78
+ const href = link.href.trim();
79
+ const chain = editor.chain().focus().extendMarkRange("link");
80
+ if (href === "")
81
+ chain.unsetLink().run();
82
+ else
83
+ chain.setLink({ href }).run();
84
+ setLink({ open: false, href: "" });
85
+ };
86
+ const removeLink = () => {
87
+ editor?.chain().focus().extendMarkRange("link").unsetLink().run();
88
+ setLink({ open: false, href: "" });
89
+ };
90
+ const run = (command) => {
91
+ if (!editor)
92
+ return;
93
+ if (command === "link") {
94
+ openLink();
95
+ return;
96
+ }
97
+ const chain = editor.chain().focus();
98
+ if (typeof command === "object")
99
+ chain.toggleHeading({ level: command.heading }).run();
100
+ else if (command === "bold")
101
+ chain.toggleBold().run();
102
+ else if (command === "italic")
103
+ chain.toggleItalic().run();
104
+ else if (command === "bulletList")
105
+ chain.toggleBulletList().run();
106
+ else if (command === "orderedList")
107
+ chain.toggleOrderedList().run();
108
+ else if (command === "blockquote")
109
+ chain.toggleBlockquote().run();
110
+ };
111
+ return (_jsxs("div", { className: "sm-richtext", children: [_jsx(RichtextToolbar, { allowed: allowed, active: active ?? INACTIVE, onCommand: run }), link.open && (_jsxs("div", { className: "sm-richtext__link", children: [_jsx("input", { className: "sm-input", placeholder: "https://\u2026 or /path", value: link.href, autoFocus: true, "aria-label": "Link URL", onChange: (e) => setLink({ open: true, href: e.target.value }), onKeyDown: (e) => {
112
+ // Escape must not reach the editor's window handler, which clears the block selection.
113
+ if (e.key === "Enter") {
114
+ e.preventDefault();
115
+ applyLink();
116
+ }
117
+ if (e.key === "Escape") {
118
+ e.preventDefault();
119
+ e.stopPropagation();
120
+ closeLink();
121
+ }
122
+ } }), _jsx(Button, { onClick: applyLink, children: "Apply" }), _jsx(Button, { variant: "secondary", onClick: removeLink, children: "Remove" })] })), _jsx(EditorContent, { editor: editor })] }));
123
+ }
@@ -0,0 +1,5 @@
1
+ import { type AnyExtension } from "@tiptap/core";
2
+ import type { Allowed } from "../../toolset.ts";
3
+ export declare function richtextExtensions(allowed: Allowed, hooks: {
4
+ onLink(): void;
5
+ }): AnyExtension[];
@@ -0,0 +1,74 @@
1
+ // The editor's extension set (spec 2026-09-08 §2): always the FULL schema
2
+ // of what `RichText` draws, so content a toolset forbids survives the
3
+ // round trip; the toolset gates only creation — input rules, paste rules
4
+ // and keyboard shortcuts here, the toolbar in RichtextToolbar, paste in
5
+ // richtext-paste. StarterKit supplies document, paragraph, text, hard
6
+ // break, list item, list keymap and undo/redo; every gated node or mark
7
+ // is switched off there and added back extended.
8
+ import { Extension, textblockTypeInputRule } from "@tiptap/core";
9
+ import StarterKit from "@tiptap/starter-kit";
10
+ import Heading from "@tiptap/extension-heading";
11
+ import Bold from "@tiptap/extension-bold";
12
+ import Italic from "@tiptap/extension-italic";
13
+ import Link from "@tiptap/extension-link";
14
+ import Blockquote from "@tiptap/extension-blockquote";
15
+ import { BulletList, OrderedList } from "@tiptap/extension-list";
16
+ /** Keep the mark in the schema; add no way to create it when `on` is false. */
17
+ function gateMarkConfig(on) {
18
+ return {
19
+ addInputRules() { return on ? this.parent?.() ?? [] : []; },
20
+ addPasteRules() { return on ? this.parent?.() ?? [] : []; },
21
+ addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
22
+ };
23
+ }
24
+ function gateNodeConfig(on) {
25
+ return {
26
+ addInputRules() { return on ? this.parent?.() ?? [] : []; },
27
+ addPasteRules() { return on ? this.parent?.() ?? [] : []; },
28
+ addKeyboardShortcuts() { return on ? this.parent?.() ?? {} : {}; },
29
+ };
30
+ }
31
+ const ALL_LEVELS = [1, 2, 3, 4, 5, 6];
32
+ export function richtextExtensions(allowed, hooks) {
33
+ const link = allowed.marks.has("link");
34
+ const levels = allowed.headingLevels;
35
+ return [
36
+ StarterKit.configure({
37
+ heading: false, bold: false, italic: false, link: false, blockquote: false,
38
+ bulletList: false, orderedList: false,
39
+ code: false, codeBlock: false, strike: false, underline: false, horizontalRule: false,
40
+ dropcursor: false, gapcursor: false, trailingNode: false,
41
+ }),
42
+ Heading.extend({
43
+ // Exact `#` counts, and only the allowed levels; `# ` does nothing when h1 is forbidden.
44
+ addInputRules() {
45
+ return levels.map((level) => textblockTypeInputRule({ find: new RegExp(`^(#{${level}})\\s$`), type: this.type, getAttributes: { level } }));
46
+ },
47
+ addKeyboardShortcuts() {
48
+ return Object.fromEntries(levels.map((level) => [`Mod-Alt-${level}`, () => this.editor.commands.toggleHeading({ level })]));
49
+ },
50
+ }).configure({ levels: ALL_LEVELS }),
51
+ Bold.extend(gateMarkConfig(allowed.marks.has("bold"))),
52
+ Italic.extend(gateMarkConfig(allowed.marks.has("italic"))),
53
+ Link.extend({
54
+ ...gateMarkConfig(link),
55
+ // href only (spec §3): no target, rel or class in the stored JSON.
56
+ addAttributes() { return { href: { default: null } }; },
57
+ }).configure({
58
+ openOnClick: false,
59
+ autolink: link,
60
+ linkOnPaste: link,
61
+ // Site-relative targets are links too; the site renderer's scheme filter is the safety net.
62
+ isAllowedUri: (url, ctx) => /^[/#?.]/.test(url) || ctx.defaultValidate(url),
63
+ }),
64
+ Blockquote.extend(gateNodeConfig(allowed.nodes.has("blockquote"))),
65
+ BulletList.extend(gateNodeConfig(allowed.nodes.has("bulletList"))),
66
+ OrderedList.extend(gateNodeConfig(allowed.nodes.has("orderedList"))),
67
+ Extension.create({
68
+ name: "smoodlyLinkShortcut",
69
+ addKeyboardShortcuts() {
70
+ return link ? { "Mod-k": () => { hooks.onLink(); return true; } } : {};
71
+ },
72
+ }),
73
+ ];
74
+ }
@@ -0,0 +1,22 @@
1
+ import type { Allowed } from "../../toolset.ts";
2
+ export type PmJSON = {
3
+ type: string;
4
+ attrs?: Record<string, unknown>;
5
+ content?: PmJSON[];
6
+ text?: string;
7
+ marks?: {
8
+ type: string;
9
+ attrs?: Record<string, unknown>;
10
+ }[];
11
+ };
12
+ /** ProseMirror's Slice.toJSON() shape. */
13
+ export type SliceJSON = {
14
+ content?: PmJSON[] | null;
15
+ openStart?: number;
16
+ openEnd?: number;
17
+ };
18
+ /** The allowed level closest to `level`; the smaller number on a tie;
19
+ * null when no heading is allowed. */
20
+ export declare function clampLevel(level: number, levels: number[]): number | null;
21
+ export declare function gateNodes(nodes: PmJSON[], allowed: Allowed): PmJSON[];
22
+ export declare function gatePastedSlice(slice: SliceJSON, allowed: Allowed): SliceJSON;
@@ -0,0 +1,59 @@
1
+ /** The allowed level closest to `level`; the smaller number on a tie;
2
+ * null when no heading is allowed. */
3
+ export function clampLevel(level, levels) {
4
+ if (levels.length === 0)
5
+ return null;
6
+ let best = levels[0];
7
+ for (const l of levels) {
8
+ const d = Math.abs(l - level);
9
+ const bd = Math.abs(best - level);
10
+ if (d < bd || (d === bd && l < best))
11
+ best = l;
12
+ }
13
+ return best;
14
+ }
15
+ export function gateNodes(nodes, allowed) {
16
+ return nodes.flatMap((n) => gateNode(n, allowed));
17
+ }
18
+ function gateNode(n, allowed) {
19
+ if (n.type === "text") {
20
+ const marks = (n.marks ?? []).filter((m) => allowed.marks.has(m.type));
21
+ const { marks: _drop, ...rest } = n;
22
+ return [marks.length > 0 ? { ...rest, marks } : rest];
23
+ }
24
+ const content = n.content ? gateNodes(n.content, allowed) : undefined;
25
+ const withContent = (node) => (content ? { ...node, content } : node);
26
+ if (n.type === "heading") {
27
+ const level = clampLevel(Number(n.attrs?.level ?? 1), allowed.headingLevels);
28
+ if (level === null)
29
+ return [withContent({ type: "paragraph" })];
30
+ return [withContent({ ...n, attrs: { ...n.attrs, level } })];
31
+ }
32
+ if (n.type === "blockquote" && !allowed.nodes.has("blockquote"))
33
+ return content ?? [];
34
+ if ((n.type === "bulletList" && !allowed.nodes.has("bulletList")) || (n.type === "orderedList" && !allowed.nodes.has("orderedList"))) {
35
+ return (content ?? []).flatMap((item) => item.content ?? []);
36
+ }
37
+ return [withContent(n)];
38
+ }
39
+ /** Depth of the first (or last) child chain, counting every non-text node. */
40
+ function edgeDepth(nodes, side) {
41
+ let depth = 0;
42
+ let list = nodes;
43
+ while (list.length > 0) {
44
+ const n = side === "first" ? list[0] : list[list.length - 1];
45
+ if (n.type === "text")
46
+ break;
47
+ depth++;
48
+ list = n.content ?? [];
49
+ }
50
+ return depth;
51
+ }
52
+ export function gatePastedSlice(slice, allowed) {
53
+ const content = gateNodes(slice.content ?? [], allowed);
54
+ return {
55
+ content,
56
+ openStart: Math.min(slice.openStart ?? 0, edgeDepth(content, "first")),
57
+ openEnd: Math.min(slice.openEnd ?? 0, edgeDepth(content, "last")),
58
+ };
59
+ }
@@ -9,4 +9,3 @@ export { serializeAdminConfig } from "./serialize.ts";
9
9
  export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminShared, AdminField } from "./serialize.ts";
10
10
  export * from "./tree-ops.ts";
11
11
  export { validateFields, type FieldError } from "./validate.ts";
12
- export { plainToRichtext, richtextToPlain } from "./richtext.ts";
@@ -7,4 +7,3 @@ export { supabaseAdminAuth } from "./supabase-auth.js";
7
7
  export { serializeAdminConfig } from "./serialize.js";
8
8
  export * from "./tree-ops.js";
9
9
  export { validateFields } from "./validate.js";
10
- export { plainToRichtext, richtextToPlain } from "./richtext.js";
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import type { ResolvedConfig } from "../../config.ts";
3
3
  import type { AdminOpAction } from "../ops.ts";
4
4
  import type { AuthConfig } from "../shell/session.ts";
@@ -7,12 +7,8 @@ export declare function createSmoodlyAdmin(input: {
7
7
  op: AdminOpAction;
8
8
  auth: () => AuthConfig;
9
9
  }): {
10
- AdminPage: ({ params }: {
11
- params: Promise<{
12
- segments?: string[];
13
- }>;
14
- }) => Promise<ReactElement>;
10
+ AdminPage: () => null;
15
11
  AdminLayout: ({ children }: {
16
12
  children: ReactNode;
17
- }) => ReactElement;
13
+ }) => import("react").JSX.Element;
18
14
  };
@@ -4,16 +4,25 @@ import { AdminApp } from "../shell/AdminApp.js";
4
4
  import { AdminStyles } from "../ui/theme.js";
5
5
  export function createSmoodlyAdmin(input) {
6
6
  const registry = serializeAdminConfig(input.config);
7
- async function AdminPage({ params }) {
8
- const { segments = [] } = await params;
9
- return _jsx(AdminApp, { registry: registry, segments: segments, op: input.op, auth: input.auth() });
7
+ /** The catch-all page only makes every /admin URL exist. The shell is
8
+ * mounted by the layout, not here: a server action that revalidates
9
+ * (publish, delete, a save of published content) answers with a
10
+ * re-rendered tree for the URL it was posted to, and Next remounts a
11
+ * page whose dynamic segment changed since the document load, while a
12
+ * layout persists (spec 2026-09-08 client routing, §1 amendment). */
13
+ function AdminPage() {
14
+ return null;
10
15
  }
11
16
  /** The admin owns the viewport: the whole UI is a fixed three-column
12
17
  * shell that scrolls internally, never the host document. The design
13
18
  * system's tokens ride along in one <style> — the host app imports
14
- * nothing (see ui/theme.tsx). */
19
+ * nothing (see ui/theme.tsx). The shell reads its route from the URL
20
+ * on mount (shell/router.ts); `children` is the page's nothing.
21
+ * suppressHydrationWarning: the theme boot script sets `data-theme`
22
+ * on this element before React hydrates, on purpose (no white flash
23
+ * for a dark-mode editor), so the attribute differs from the server's. */
15
24
  function AdminLayout({ children }) {
16
- return (_jsxs("div", { className: "sm-admin", children: [_jsx(AdminStyles, {}), children] }));
25
+ return (_jsxs("div", { className: "sm-admin", suppressHydrationWarning: true, children: [_jsx(AdminStyles, {}), _jsx(AdminApp, { registry: registry, op: input.op, auth: input.auth() }), children] }));
17
26
  }
18
27
  return { AdminPage, AdminLayout };
19
28
  }