smoodly 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +294 -40
  2. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  3. package/dist/admin/editor/CanvasOverlay.js +9 -11
  4. package/dist/admin/editor/EditorView.js +135 -28
  5. package/dist/admin/editor/FloatingToolbar.d.ts +16 -0
  6. package/dist/admin/editor/FloatingToolbar.js +14 -0
  7. package/dist/admin/editor/InlineRichtext.d.ts +9 -0
  8. package/dist/admin/editor/InlineRichtext.js +50 -0
  9. package/dist/admin/editor/Outline.d.ts +16 -10
  10. package/dist/admin/editor/Outline.js +13 -10
  11. package/dist/admin/editor/PageInspector.d.ts +21 -0
  12. package/dist/admin/editor/PageInspector.js +24 -0
  13. package/dist/admin/editor/PageSettings.d.ts +1 -3
  14. package/dist/admin/editor/PageSettings.js +7 -6
  15. package/dist/admin/editor/SeoSettings.d.ts +7 -0
  16. package/dist/admin/editor/SeoSettings.js +15 -0
  17. package/dist/admin/editor/inline-dom.d.ts +27 -0
  18. package/dist/admin/editor/inline-dom.js +43 -0
  19. package/dist/admin/editor/inline-session.d.ts +30 -0
  20. package/dist/admin/editor/inline-session.js +19 -0
  21. package/dist/admin/editor/protocol.d.ts +22 -1
  22. package/dist/admin/editor/useCanvasBridge.d.ts +8 -4
  23. package/dist/admin/editor/useCanvasBridge.js +12 -7
  24. package/dist/admin/editor/useInlineSession.d.ts +46 -0
  25. package/dist/admin/editor/useInlineSession.js +244 -0
  26. package/dist/admin/field-paths.d.ts +13 -0
  27. package/dist/admin/field-paths.js +49 -0
  28. package/dist/admin/forms/FieldWidget.d.ts +7 -2
  29. package/dist/admin/forms/FieldWidget.js +35 -43
  30. package/dist/admin/forms/ListField.d.ts +10 -0
  31. package/dist/admin/forms/ListField.js +42 -0
  32. package/dist/admin/forms/MediaWidget.js +10 -3
  33. package/dist/admin/forms/RichtextElementCard.d.ts +3 -0
  34. package/dist/admin/forms/RichtextElementCard.js +37 -0
  35. package/dist/admin/forms/RichtextImageCard.d.ts +3 -0
  36. package/dist/admin/forms/RichtextImageCard.js +42 -0
  37. package/dist/admin/forms/RichtextInsertMenu.d.ts +6 -0
  38. package/dist/admin/forms/RichtextInsertMenu.js +6 -0
  39. package/dist/admin/forms/RichtextPopovers.d.ts +7 -0
  40. package/dist/admin/forms/RichtextPopovers.js +25 -0
  41. package/dist/admin/forms/RichtextToolbar.d.ts +4 -2
  42. package/dist/admin/forms/RichtextToolbar.js +4 -2
  43. package/dist/admin/forms/RichtextWidget.d.ts +4 -1
  44. package/dist/admin/forms/RichtextWidget.js +23 -120
  45. package/dist/admin/forms/elements-context.d.ts +7 -0
  46. package/dist/admin/forms/elements-context.js +13 -0
  47. package/dist/admin/forms/reveal.d.ts +5 -0
  48. package/dist/admin/forms/reveal.js +47 -0
  49. package/dist/admin/forms/richtext-extensions.d.ts +8 -0
  50. package/dist/admin/forms/richtext-extensions.js +11 -0
  51. package/dist/admin/forms/richtext-form-context.d.ts +16 -0
  52. package/dist/admin/forms/richtext-form-context.js +18 -0
  53. package/dist/admin/forms/richtext-insert.d.ts +38 -0
  54. package/dist/admin/forms/richtext-insert.js +37 -0
  55. package/dist/admin/forms/richtext-nodes.d.ts +37 -0
  56. package/dist/admin/forms/richtext-nodes.js +85 -0
  57. package/dist/admin/forms/richtext-paste.js +5 -0
  58. package/dist/admin/forms/tabs.d.ts +3 -0
  59. package/dist/admin/forms/tabs.js +6 -0
  60. package/dist/admin/forms/useRichtextEditor.d.ts +51 -0
  61. package/dist/admin/forms/useRichtextEditor.js +281 -0
  62. package/dist/admin/next/bridge.js +51 -20
  63. package/dist/admin/ops-impl.js +85 -19
  64. package/dist/admin/richtext-doc.d.ts +3 -0
  65. package/dist/admin/richtext-doc.js +12 -0
  66. package/dist/admin/serialize.d.ts +11 -0
  67. package/dist/admin/serialize.js +18 -1
  68. package/dist/admin/shell/AdminApp.js +2 -1
  69. package/dist/admin/shell/EntriesList.js +34 -12
  70. package/dist/admin/shell/EntryForm.js +22 -15
  71. package/dist/admin/shell/IconRail.js +6 -5
  72. package/dist/admin/shell/ListView.d.ts +9 -2
  73. package/dist/admin/shell/ListView.js +7 -6
  74. package/dist/admin/shell/LoginView.js +2 -2
  75. package/dist/admin/shell/PagesList.js +53 -24
  76. package/dist/admin/shell/SharedForm.js +17 -11
  77. package/dist/admin/shell/SharedList.js +5 -3
  78. package/dist/admin/shell/entries-list.d.ts +4 -0
  79. package/dist/admin/shell/entries-list.js +6 -0
  80. package/dist/admin/shell/pages-tree.d.ts +24 -0
  81. package/dist/admin/shell/pages-tree.js +59 -1
  82. package/dist/admin/tree-ops.d.ts +11 -1
  83. package/dist/admin/tree-ops.js +32 -1
  84. package/dist/admin/ui/LocaleSwitcher.d.ts +10 -0
  85. package/dist/admin/ui/LocaleSwitcher.js +28 -8
  86. package/dist/admin/ui/Modal.js +14 -2
  87. package/dist/admin/ui/ResizeHandle.d.ts +1 -0
  88. package/dist/admin/ui/ResizeHandle.js +127 -0
  89. package/dist/admin/ui/primitives.d.ts +38 -4
  90. package/dist/admin/ui/primitives.js +35 -10
  91. package/dist/admin/ui/resize.d.ts +16 -0
  92. package/dist/admin/ui/resize.js +23 -0
  93. package/dist/admin/ui/sortable.d.ts +23 -0
  94. package/dist/admin/ui/sortable.js +57 -0
  95. package/dist/admin/ui/theme.d.ts +3 -1
  96. package/dist/admin/ui/theme.js +527 -165
  97. package/dist/admin/ui/useSortable.d.ts +35 -0
  98. package/dist/admin/ui/useSortable.js +220 -0
  99. package/dist/admin/validate.d.ts +6 -1
  100. package/dist/admin/validate.js +52 -12
  101. package/dist/collections.d.ts +6 -5
  102. package/dist/config.d.ts +1 -0
  103. package/dist/config.js +36 -0
  104. package/dist/entry-store.d.ts +23 -3
  105. package/dist/entry-store.js +31 -5
  106. package/dist/field-defaults.d.ts +8 -0
  107. package/dist/field-defaults.js +18 -0
  108. package/dist/fields.d.ts +30 -1
  109. package/dist/fields.js +33 -3
  110. package/dist/image/SmoodlyImage.d.ts +4 -1
  111. package/dist/image/SmoodlyImage.js +2 -2
  112. package/dist/index.d.ts +16 -7
  113. package/dist/index.js +10 -4
  114. package/dist/marks.d.ts +53 -0
  115. package/dist/marks.js +43 -0
  116. package/dist/media.d.ts +3 -2
  117. package/dist/media.js +48 -6
  118. package/dist/next/index.d.ts +2 -0
  119. package/dist/next/index.js +1 -0
  120. package/dist/next/meta.d.ts +7 -3
  121. package/dist/next/meta.js +18 -9
  122. package/dist/next/page-renderer.js +1 -1
  123. package/dist/next/queries.d.ts +36 -0
  124. package/dist/next/queries.js +87 -0
  125. package/dist/page-tree.d.ts +3 -0
  126. package/dist/page-tree.js +6 -0
  127. package/dist/page.d.ts +11 -3
  128. package/dist/page.js +26 -0
  129. package/dist/pairing.d.ts +2 -1
  130. package/dist/pairing.js +2 -9
  131. package/dist/refs.d.ts +2 -2
  132. package/dist/refs.js +33 -12
  133. package/dist/render.d.ts +14 -7
  134. package/dist/render.js +4 -3
  135. package/dist/resolve.d.ts +12 -2
  136. package/dist/resolve.js +16 -7
  137. package/dist/revalidate.d.ts +6 -0
  138. package/dist/revalidate.js +6 -0
  139. package/dist/richtext/index.d.ts +12 -0
  140. package/dist/richtext/index.js +9 -0
  141. package/dist/richtext-render.d.ts +22 -2
  142. package/dist/richtext-render.js +40 -13
  143. package/dist/richtext-walk.d.ts +44 -0
  144. package/dist/richtext-walk.js +46 -0
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +13 -1
  147. package/dist/section-wrapper.js +2 -1
  148. package/dist/shared.d.ts +7 -7
  149. package/dist/site.d.ts +25 -1
  150. package/dist/site.js +89 -22
  151. package/dist/store.d.ts +7 -1
  152. package/dist/store.js +13 -4
  153. package/dist/supabase-entry-store.d.ts +1 -0
  154. package/dist/supabase-entry-store.js +34 -11
  155. package/dist/supabase-store.d.ts +1 -0
  156. package/dist/supabase-store.js +14 -1
  157. package/dist/toolset.d.ts +7 -1
  158. package/dist/toolset.js +13 -4
  159. package/package.json +6 -1
@@ -0,0 +1,281 @@
1
+ "use client";
2
+ // The richtext editor behind both mounts (widget spec 2026-09-08 §3;
3
+ // inline editing spec 2026-09-10 §5): the TipTap instance over the full
4
+ // schema with the toolset gating creation, the toolbar's active state,
5
+ // the link popover, the "+" menu, the slash menu and the image picker.
6
+ // The sidebar widget and the canvas editor render this state in their own
7
+ // layouts. The editor owns its document and emits TipTap JSON on every
8
+ // change; the parent stores the emitted document by reference, so a value
9
+ // that is not the last emitted document is a change from outside and
10
+ // re-seeds the editor. Block nodes render as cards, bound once through
11
+ // setNodeViews.
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
+ import { useEditor, useEditorState } from "@tiptap/react";
14
+ import { Slice } from "@tiptap/pm/model";
15
+ import { allowedOf } from "../../toolset.js";
16
+ import { plainDoc } from "../richtext-doc.js";
17
+ import { richtextExtensions } from "./richtext-extensions.js";
18
+ import { gatePastedSlice } from "./richtext-paste.js";
19
+ import { INACTIVE } from "./RichtextToolbar.js";
20
+ import { elementNode, filterItems, imageNode, insertItems } from "./richtext-insert.js";
21
+ import { insertBlock, setNodeViews } from "./richtext-nodes.js";
22
+ import { RichtextImageCard } from "./RichtextImageCard.js";
23
+ import { RichtextElementCard } from "./RichtextElementCard.js";
24
+ import { useElements } from "./elements-context.js";
25
+ import { useAssets } from "./assets-context.js";
26
+ setNodeViews({ image: RichtextImageCard, element: RichtextElementCard });
27
+ const EMPTY = { type: "doc", content: [{ type: "paragraph" }] };
28
+ const LEVELS = [1, 2, 3, 4, 5, 6];
29
+ const isDoc = (v) => !!v && typeof v === "object" && v.type === "doc";
30
+ export function useRichtextEditor({ value, toolset, onChange, contentClass, onEscape }) {
31
+ const allowed = useMemo(() => allowedOf(toolset), [toolset]);
32
+ const elements = useElements();
33
+ const assets = useAssets();
34
+ const items = useMemo(() => insertItems(allowed, elements), [allowed, elements]);
35
+ // The parent's callbacks close over its render; the editor's handlers are
36
+ // created once, so read the latest through refs.
37
+ const onChangeRef = useRef(onChange);
38
+ onChangeRef.current = onChange;
39
+ const onEscapeRef = useRef(onEscape);
40
+ onEscapeRef.current = onEscape;
41
+ // The last document this editor emitted — anything else arriving as
42
+ // `value` came from outside and re-seeds the editor.
43
+ const emitted = useRef(value);
44
+ const [link, setLink] = useState({ open: false, href: "" });
45
+ const openLinkRef = useRef(() => { });
46
+ // The "+" popover, the slash menu and the image picker.
47
+ const [menu, setMenu] = useState({ open: false, query: "", active: 0 });
48
+ const [slash, setSlash] = useState(null);
49
+ const [slashActive, setSlashActive] = useState(0);
50
+ const slashRef = useRef({ state: null, active: 0, dismissed: false });
51
+ const [picker, setPicker] = useState(false);
52
+ // The slash extension is created once; it reaches this render's insert through the ref.
53
+ const insertRef = useRef(() => { });
54
+ // Where the next insert goes: recorded when the "+" menu opens (the menu
55
+ // and the picker take focus), read back at pick time. Null = the caret.
56
+ const anchorRef = useRef(null);
57
+ const editor = useEditor({
58
+ immediatelyRender: false,
59
+ extensions: richtextExtensions(allowed, {
60
+ onLink: () => openLinkRef.current(),
61
+ slash: {
62
+ elements,
63
+ onState: (state) => {
64
+ const r = slashRef.current;
65
+ if (state === null) {
66
+ r.dismissed = false;
67
+ r.state = null;
68
+ setSlash(null);
69
+ return;
70
+ }
71
+ if (r.state === null) {
72
+ r.dismissed = false;
73
+ r.active = 0;
74
+ }
75
+ r.state = state;
76
+ r.active = Math.min(r.active, Math.max(0, state.items.length - 1));
77
+ setSlashActive(r.active);
78
+ setSlash(r.dismissed ? null : state);
79
+ },
80
+ onKeyDown: (event) => {
81
+ const r = slashRef.current;
82
+ if (!r.state || r.dismissed)
83
+ return false;
84
+ const n = Math.max(1, r.state.items.length);
85
+ if (event.key === "Escape") {
86
+ r.dismissed = true;
87
+ setSlash(null);
88
+ return true;
89
+ }
90
+ if (event.key === "ArrowDown") {
91
+ r.active = (r.active + 1) % n;
92
+ setSlashActive(r.active);
93
+ return true;
94
+ }
95
+ if (event.key === "ArrowUp") {
96
+ r.active = (r.active - 1 + n) % n;
97
+ setSlashActive(r.active);
98
+ return true;
99
+ }
100
+ if (event.key === "Enter") {
101
+ const item = r.state.items[r.active];
102
+ if (item)
103
+ r.state.pick(item);
104
+ return true;
105
+ }
106
+ return false;
107
+ },
108
+ onPick: (item) => insertRef.current(item),
109
+ },
110
+ }),
111
+ content: isDoc(value) ? value : EMPTY,
112
+ editorProps: {
113
+ attributes: { class: contentClass },
114
+ transformPasted: (slice, view) => Slice.fromJSON(view.state.schema, gatePastedSlice(slice.toJSON(), allowed)),
115
+ // Direct editor props run before plugin props, so leave Escape to
116
+ // the slash plugin while its menu is up; otherwise the canvas editor
117
+ // ends its session on it.
118
+ handleKeyDown: (_view, event) => {
119
+ if (event.key !== "Escape" || !onEscapeRef.current)
120
+ return false;
121
+ const r = slashRef.current;
122
+ if (r.state && !r.dismissed)
123
+ return false;
124
+ onEscapeRef.current();
125
+ return true;
126
+ },
127
+ },
128
+ onUpdate: ({ editor }) => {
129
+ const doc = plainDoc(editor.getJSON());
130
+ emitted.current = doc;
131
+ onChangeRef.current(doc);
132
+ },
133
+ }, [allowed, elements]);
134
+ useEffect(() => {
135
+ if (!editor || value === emitted.current)
136
+ return;
137
+ emitted.current = value;
138
+ editor.commands.setContent(isDoc(value) ? value : EMPTY, { emitUpdate: false });
139
+ }, [editor, value]);
140
+ const active = useEditorState({
141
+ editor,
142
+ selector: ({ editor }) => editor
143
+ ? {
144
+ bold: editor.isActive("bold"),
145
+ italic: editor.isActive("italic"),
146
+ link: editor.isActive("link"),
147
+ bulletList: editor.isActive("bulletList"),
148
+ orderedList: editor.isActive("orderedList"),
149
+ blockquote: editor.isActive("blockquote"),
150
+ heading: LEVELS.find((level) => editor.isActive("heading", { level })) ?? null,
151
+ }
152
+ : INACTIVE,
153
+ });
154
+ /** One insert path for the "+" menu and the slash menu. An image needs the picker first. */
155
+ const insert = (item) => {
156
+ setMenu({ open: false, query: "", active: 0 });
157
+ if (!editor)
158
+ return;
159
+ // The "+" menu recorded its anchor; the slash command runs at the caret (its "/" just deleted).
160
+ const at = anchorRef.current ?? editor.state.selection.to;
161
+ if (item.kind === "image") {
162
+ anchorRef.current = at;
163
+ setPicker(true);
164
+ return;
165
+ }
166
+ anchorRef.current = null;
167
+ insertBlock(editor, elementNode(item.element), at);
168
+ };
169
+ insertRef.current = insert;
170
+ const pickImage = (record) => {
171
+ assets.remember(record);
172
+ setPicker(false);
173
+ const at = anchorRef.current ?? undefined;
174
+ anchorRef.current = null;
175
+ if (editor)
176
+ insertBlock(editor, imageNode(record.id), at);
177
+ };
178
+ const closePicker = () => {
179
+ setPicker(false);
180
+ const at = anchorRef.current ?? undefined;
181
+ anchorRef.current = null;
182
+ editor?.commands.focus(at);
183
+ };
184
+ // Read the caret's link from the editor at open time, not from the last
185
+ // render: a selection change and the press can land in the same tick.
186
+ const openLink = () => setLink({ open: true, href: editor?.getAttributes("link").href ?? "" });
187
+ openLinkRef.current = openLink;
188
+ const closeLink = () => {
189
+ setLink({ open: false, href: "" });
190
+ editor?.commands.focus();
191
+ };
192
+ const applyLink = () => {
193
+ if (!editor)
194
+ return;
195
+ const href = link.href.trim();
196
+ const chain = editor.chain().focus().extendMarkRange("link");
197
+ if (href === "")
198
+ chain.unsetLink().run();
199
+ else
200
+ chain.setLink({ href }).run();
201
+ setLink({ open: false, href: "" });
202
+ };
203
+ const removeLink = () => {
204
+ editor?.chain().focus().extendMarkRange("link").unsetLink().run();
205
+ setLink({ open: false, href: "" });
206
+ };
207
+ const run = (command) => {
208
+ if (!editor)
209
+ return;
210
+ if (command === "link") {
211
+ openLink();
212
+ return;
213
+ }
214
+ if (command === "insert") {
215
+ anchorRef.current = menu.open ? null : editor.state.selection.to;
216
+ setMenu((m) => ({ open: !m.open, query: "", active: 0 }));
217
+ return;
218
+ }
219
+ const chain = editor.chain().focus();
220
+ if (typeof command === "object")
221
+ chain.toggleHeading({ level: command.heading }).run();
222
+ else if (command === "bold")
223
+ chain.toggleBold().run();
224
+ else if (command === "italic")
225
+ chain.toggleItalic().run();
226
+ else if (command === "bulletList")
227
+ chain.toggleBulletList().run();
228
+ else if (command === "orderedList")
229
+ chain.toggleOrderedList().run();
230
+ else if (command === "blockquote")
231
+ chain.toggleBlockquote().run();
232
+ };
233
+ const menuItems = filterItems(items, menu.query);
234
+ const closeMenu = () => {
235
+ setMenu({ open: false, query: "", active: 0 });
236
+ const at = anchorRef.current ?? undefined;
237
+ anchorRef.current = null;
238
+ editor?.commands.focus(at);
239
+ };
240
+ // The menu floats over the content, so focus leaving it (a click
241
+ // anywhere else) closes it — without stealing focus back to the editor.
242
+ const dismissMenu = (e) => {
243
+ if (e.currentTarget.contains(e.relatedTarget))
244
+ return;
245
+ setMenu({ open: false, query: "", active: 0 });
246
+ anchorRef.current = null;
247
+ };
248
+ const onMenuKeyDown = (e) => {
249
+ const n = Math.max(1, menuItems.length);
250
+ if (e.key === "Escape") {
251
+ e.preventDefault();
252
+ e.stopPropagation();
253
+ closeMenu();
254
+ }
255
+ if (e.key === "ArrowDown") {
256
+ e.preventDefault();
257
+ setMenu((m) => ({ ...m, active: (m.active + 1) % n }));
258
+ }
259
+ if (e.key === "ArrowUp") {
260
+ e.preventDefault();
261
+ setMenu((m) => ({ ...m, active: (m.active - 1 + n) % n }));
262
+ }
263
+ if (e.key === "Enter") {
264
+ e.preventDefault();
265
+ const item = menuItems[menu.active];
266
+ if (item)
267
+ insert(item);
268
+ }
269
+ };
270
+ return {
271
+ editor, allowed, active: active ?? INACTIVE, canInsert: items.length > 0, run,
272
+ link: { open: link.open, href: link.href, setHref: (href) => setLink({ open: true, href }), apply: applyLink, remove: removeLink, close: closeLink },
273
+ menu: {
274
+ open: menu.open, query: menu.query, active: menu.active, items: menuItems,
275
+ setQuery: (query) => setMenu({ open: true, query, active: 0 }), onKeyDown: onMenuKeyDown,
276
+ pick: insert, close: closeMenu, dismiss: dismissMenu,
277
+ },
278
+ slash: slash ? { state: slash, active: slashActive } : null,
279
+ picker: { open: picker, pick: pickImage, close: closePicker },
280
+ };
281
+ }
@@ -1,41 +1,72 @@
1
1
  "use client";
2
2
  // Runs INSIDE the draft-route iframe (glue adds it only when draft mode +
3
- // the editor param are on). Reports geometry, forwards select/hover,
4
- // executes refresh/scroll — the admin overlay draws all chrome. Rects are
5
- // in document coordinates: the iframe is sized to the full document height
6
- // by the editor, so client rects + scroll offsets map 1:1 onto the overlay.
3
+ // the editor param are on). Reports geometry — section nodes, empty zones
4
+ // and, since spec 2026-09-10, every marked field with its resolved path —
5
+ // forwards select/hover with the field under the pointer, executes
6
+ // refresh/scroll, and says when a render has committed. The admin overlay
7
+ // draws all chrome. Rects are in document coordinates: the iframe is sized
8
+ // to the full document height by the editor, so client rects + scroll
9
+ // offsets map 1:1 onto the overlay.
7
10
  import { useEffect } from "react";
8
11
  import { useRouter } from "next/navigation";
12
+ import { EDITOR_ATTR, FIELD_ATTR, NODE_ATTR, ZONE_ATTR, resolveMarkPath } from "../../marks.js";
13
+ const post = (m) => window.parent.postMessage(m, "*");
9
14
  export function EditorBridge() {
10
15
  const router = useRouter();
16
+ // Every commit of this component — the mount, then each RSC refresh,
17
+ // which re-renders the route — tells the editor the new markup is in
18
+ // the DOM (spec 2026-09-10 §5: the inline richtext editor lets go of
19
+ // its container only then).
20
+ useEffect(() => { post({ source: "smoodly", kind: "rendered" }); });
11
21
  useEffect(() => {
12
- const post = (m) => window.parent.postMessage(m, "*");
22
+ const docRect = (el) => {
23
+ const r = el.getBoundingClientRect();
24
+ return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
25
+ };
26
+ const all = (selector) => Array.from(document.querySelectorAll(selector));
27
+ const inEditor = (el) => Boolean(el?.closest?.(`[${EDITOR_ATTR}]`));
13
28
  const report = () => {
14
29
  // Array.from, not spread: the package's lib set is ES2020 + DOM
15
30
  // (no DOM.Iterable), so a NodeList isn't statically iterable here.
16
- const nodes = Array.from(document.querySelectorAll("[data-smoodly-node]")).map((el) => {
17
- const r = el.getBoundingClientRect();
18
- return { id: el.dataset.smoodlyNode, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
19
- });
20
- const emptyZones = Array.from(document.querySelectorAll("[data-smoodly-zone]")).map((el) => {
21
- const r = el.getBoundingClientRect();
22
- return { zone: el.dataset.smoodlyZone, x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
23
- });
24
- post({ source: "smoodly", kind: "rects", nodes, emptyZones, docH: document.documentElement.scrollHeight });
31
+ const nodes = all(`[${NODE_ATTR}]`).map((el) => ({ id: el.getAttribute(NODE_ATTR), ...docRect(el) }));
32
+ const emptyZones = all(`[${ZONE_ATTR}]`).map((el) => ({ zone: el.getAttribute(ZONE_ATTR), ...docRect(el) }));
33
+ const fields = [];
34
+ for (const el of all(`[${FIELD_ATTR}]`)) {
35
+ // A mark inside the admin's own editor root (a nested element card) is not the page's.
36
+ if (inEditor(el))
37
+ continue;
38
+ const mark = resolveMarkPath(el);
39
+ if (mark)
40
+ fields.push({ ...mark, ...docRect(el) });
41
+ }
42
+ post({ source: "smoodly", kind: "rects", nodes, emptyZones, fields, docH: document.documentElement.scrollHeight });
25
43
  };
26
44
  const raf = () => requestAnimationFrame(report);
27
45
  const ro = new ResizeObserver(raf);
28
46
  ro.observe(document.documentElement);
47
+ // characterData too: a text session edits a text node in place, and the outline must follow.
29
48
  const mo = new MutationObserver(raf);
30
- mo.observe(document.body, { subtree: true, childList: true, attributes: true });
31
- const nodeIdAt = (target) => target?.closest?.("[data-smoodly-node]")?.dataset.smoodlyNode ?? null;
49
+ mo.observe(document.body, { subtree: true, childList: true, attributes: true, characterData: true });
50
+ const elementOf = (target) => target;
51
+ const nodeIdAt = (target) => elementOf(target)?.closest?.(`[${NODE_ATTR}]`)?.getAttribute(NODE_ATTR) ?? null;
52
+ const pathAt = (target) => {
53
+ const el = elementOf(target)?.closest?.(`[${FIELD_ATTR}]`);
54
+ return el ? resolveMarkPath(el)?.path ?? null : null;
55
+ };
32
56
  const onClick = (e) => {
33
57
  // The canvas must never navigate away from under the editor.
34
- if (e.target?.closest?.("a"))
58
+ if (elementOf(e.target)?.closest?.("a"))
35
59
  e.preventDefault();
36
- post({ source: "smoodly", kind: "select", id: nodeIdAt(e.target) });
60
+ // Inside its own editor root the admin owns every click.
61
+ if (inEditor(elementOf(e.target)))
62
+ return;
63
+ post({ source: "smoodly", kind: "select", id: nodeIdAt(e.target), path: pathAt(e.target), x: e.clientX, y: e.clientY });
64
+ };
65
+ const onMove = (e) => {
66
+ if (inEditor(elementOf(e.target)))
67
+ return;
68
+ post({ source: "smoodly", kind: "hover", id: nodeIdAt(e.target), path: pathAt(e.target) });
37
69
  };
38
- const onMove = (e) => post({ source: "smoodly", kind: "hover", id: nodeIdAt(e.target) });
39
70
  const onMessage = (e) => {
40
71
  const m = e.data;
41
72
  if (m?.source !== "smoodly")
@@ -44,7 +75,7 @@ export function EditorBridge() {
44
75
  router.refresh();
45
76
  if (m.kind === "scroll") {
46
77
  document
47
- .querySelector(`[data-smoodly-node="${CSS.escape(m.id)}"]`)
78
+ .querySelector(`[${NODE_ATTR}="${CSS.escape(m.id)}"]`)
48
79
  ?.scrollIntoView({ behavior: "smooth", block: "center" });
49
80
  }
50
81
  };
@@ -1,10 +1,11 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { ASSET_COLLECTION } from "../media.js";
3
3
  import { BASE } from "./shell/base.js";
4
- import { affectedTargets, entryTag, pageTag, sharedTag } from "../revalidate.js";
4
+ import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, sharedTag } from "../revalidate.js";
5
5
  import { ensureFixedNodes, fixedNodeOf } from "./fixed-nodes.js";
6
6
  import { ensureSharedItems } from "./shared-items.js";
7
7
  import { validateFields } from "./validate.js";
8
+ import { elementSchemas } from "../richtext-walk.js";
8
9
  const ok = (data) => ({ ok: true, data });
9
10
  const err = (code, message, fields) => ({ ok: false, code, message, ...(fields ? { fields } : {}) });
10
11
  /** Own-property, non-array plain object — same shape guard the tree checks
@@ -88,6 +89,8 @@ export function createAdminOps(deps) {
88
89
  const { config, pages, entries, effects } = deps;
89
90
  const href = deps.href ?? ((path) => path);
90
91
  const collectionFields = (name) => config.registry.collections?.find((c) => c.name === name)?.fields ?? null;
92
+ // Element nodes inside richtext validate against these (spec 2026-09-08 block nodes §2).
93
+ const elementDescs = elementSchemas((config.registry.elements ?? []).map((e) => ({ name: e.schema.name, fields: e.schema.fields ?? {}, styles: e.schema.styles })));
91
94
  const sectionFields = (type) => config.registry.sections?.find((s) => s.schema.name === type)?.schema;
92
95
  const sharedItem = (name) => config.registry.shared?.find((s) => s.name === name) ?? null;
93
96
  /** One row per registration; null when materialization could not create it. */
@@ -119,6 +122,36 @@ export function createAdminOps(deps) {
119
122
  effects.revalidate(sharedTag(item.name));
120
123
  }
121
124
  };
125
+ /** An asset's metadata (alt, title, caption, credit) is read wherever media
126
+ * resolves (spec 2026-09-08 §9): the entry and page units that reference the
127
+ * asset, directly or through an entry, the shared items among those rows,
128
+ * and the list units of every referencing entry's collection. The walk is
129
+ * the entry fan-out's, seeded with the asset's id — which is no entry, so
130
+ * it gets no tag of its own. */
131
+ const expireAssetUsers = async (assetId) => {
132
+ if (!effects?.revalidate)
133
+ return;
134
+ const { pageIds, entryIds } = await affectedTargets({ pages, entries }, assetId);
135
+ const users = entryIds.filter((id) => id !== assetId);
136
+ for (const id of users)
137
+ effects.revalidate(entryTag(id));
138
+ for (const id of pageIds)
139
+ effects.revalidate(pageTag(id));
140
+ const touched = new Set(users);
141
+ for (const item of config.registry.shared ?? []) {
142
+ const row = (await entries.list(item.name))[0];
143
+ if (row && touched.has(row.id))
144
+ effects.revalidate(sharedTag(item.name));
145
+ }
146
+ for (const c of config.registry.collections ?? []) {
147
+ for (const id of users) {
148
+ if (await entries.get(c.name, id)) {
149
+ effects.revalidate(collectionTag(c.name));
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ };
122
155
  /** Expire the cached units a tree change touched, in order, once each. */
123
156
  const expirePages = (ids) => {
124
157
  if (!effects?.revalidate)
@@ -126,6 +159,10 @@ export function createAdminOps(deps) {
126
159
  for (const id of new Set(ids.filter((x) => typeof x === "string")))
127
160
  effects.revalidate(pageTag(id));
128
161
  };
162
+ /** The tree listing's unit (spec 2026-09-11 §2): every page op that changes what it shows. */
163
+ const expireTree = () => effects?.revalidate?.(pagesTreeTag());
164
+ /** A collection's list units: every entry write, since a non-versioned collection's published read is its live row. */
165
+ const expireCollection = (name) => effects?.revalidate?.(collectionTag(name));
129
166
  /** A node and every descendant, node first, in listPages() order. */
130
167
  const subtreeIds = async (id) => {
131
168
  const all = await pages.listPages();
@@ -241,10 +278,12 @@ export function createAdminOps(deps) {
241
278
  // A root create competes with the code-owned slugs: make sure they exist first.
242
279
  await ensureFixedNodes(pages, config);
243
280
  }
244
- return ok(await pages.createPage({
281
+ const created = await pages.createPage({
245
282
  locale: input.locale, slug: input.slug, template: input.template, parentId: input.parentId,
246
283
  ...(input.title !== undefined ? { title: input.title } : {}),
247
- }));
284
+ });
285
+ expireTree();
286
+ return ok(created);
248
287
  }),
249
288
  saveDraft: (id, locale, tree) => guard(async () => {
250
289
  const unsupported = supportedLocale(locale);
@@ -278,17 +317,23 @@ export function createAdminOps(deps) {
278
317
  const schema = sectionFields(node.type);
279
318
  if (!schema?.fields)
280
319
  continue;
281
- for (const fe of validateFields(node.fields ?? {}, schema.fields)) {
320
+ for (const fe of validateFields(node.fields ?? {}, schema.fields, elementDescs)) {
282
321
  fieldErrors.push({ field: `${node.id}.${fe.field}`, message: fe.message });
283
322
  }
284
323
  }
285
324
  }
325
+ // The page's own fields (spec 2026-09-10 page fields §2); SEO meta is never validated.
326
+ const pageDescs = openTemplate(record.template)?.schema?.fields ?? {};
327
+ for (const fe of validateFields(draft.fields ?? {}, pageDescs, elementDescs)) {
328
+ fieldErrors.push({ field: `page.fields.${fe.field}`, message: fe.message });
329
+ }
286
330
  if (fieldErrors.length > 0) {
287
331
  return err("validation", `smoodly: cannot publish "${row.slug}" — fix the listed fields first.`, fieldErrors);
288
332
  }
289
333
  const published = await pages.publish(id, locale);
290
334
  // The parent's cached unit lists its published children.
291
335
  expirePages([published.id, published.parentId]);
336
+ expireTree();
292
337
  return ok(published);
293
338
  }),
294
339
  delete: (id) => guard(async () => {
@@ -304,6 +349,7 @@ export function createAdminOps(deps) {
304
349
  // The cached unit is keyed by (locale, path) but tagged by the id
305
350
  // found at the first miss; the parent's unit lists its children.
306
351
  expirePages([id, record.parentId]);
352
+ expireTree();
307
353
  return ok(null);
308
354
  }),
309
355
  rename: (id, patch) => guard(async () => {
@@ -322,17 +368,20 @@ export function createAdminOps(deps) {
322
368
  // Every descendant's path changed with this segment; the parent
323
369
  // lists this node's title and path.
324
370
  expirePages([...(await subtreeIds(id)), record.parentId]);
371
+ expireTree();
325
372
  return ok(renamed);
326
373
  }),
327
374
  move: (id, to) => guard(async () => {
328
375
  const record = await pages.getPage(id);
329
376
  if (!record)
330
377
  return err("not_found", `smoodly: no page "${id}".`);
331
- if (fixedNodeOf(record, config))
332
- return owned(record, "it stays at the root");
333
- if (isHomeNode(record))
334
- return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
335
378
  if (to.parentId !== null) {
379
+ // Code owns a fixed node's URL, never the root's listing order:
380
+ // reparenting is refused, reordering among the root siblings is not.
381
+ if (fixedNodeOf(record, config))
382
+ return owned(record, "it stays at the root");
383
+ if (isHomeNode(record))
384
+ return err("blocked", `smoodly: "${nameOf(record)}" is the site root — it stays at the root.`);
336
385
  const parent = await pages.getPage(to.parentId);
337
386
  if (!parent)
338
387
  return err("not_found", `smoodly: no page "${to.parentId}".`);
@@ -342,6 +391,7 @@ export function createAdminOps(deps) {
342
391
  }
343
392
  const moved = await pages.move(id, to);
344
393
  expirePages([...(await subtreeIds(id)), record.parentId, moved.parentId]);
394
+ expireTree();
345
395
  return ok(moved);
346
396
  }),
347
397
  setTemplate: (id, template) => guard(async () => {
@@ -358,7 +408,9 @@ export function createAdminOps(deps) {
358
408
  if (registration.schema.slug !== undefined) {
359
409
  return err("validation", `smoodly: page template "${template}" is fixed — Smoodly creates that page for you.`);
360
410
  }
361
- return ok(await pages.setTemplate(id, template));
411
+ const withTemplate = await pages.setTemplate(id, template);
412
+ expireTree();
413
+ return ok(withTemplate);
362
414
  }),
363
415
  addLocale: (id, locale, options) => guard(async () => {
364
416
  const unsupported = supportedLocale(locale) ?? supportedLocale(options?.from);
@@ -366,8 +418,10 @@ export function createAdminOps(deps) {
366
418
  return unsupported;
367
419
  if (!(await pages.getPage(id)))
368
420
  return err("not_found", `smoodly: no page "${id}".`);
369
- // A new locale is a draft: nothing public changes, nothing to expire.
370
- return ok(await pages.addLocale(id, locale, { from: options.from }));
421
+ // A new locale is a draft: nothing public changes but the tree listing (spec 2026-09-11 §2).
422
+ const added = await pages.addLocale(id, locale, { from: options.from });
423
+ expireTree();
424
+ return ok(added);
371
425
  }),
372
426
  removeLocale: (id, locale) => guard(async () => {
373
427
  const unsupported = supportedLocale(locale);
@@ -383,6 +437,7 @@ export function createAdminOps(deps) {
383
437
  const removed = await pages.removeLocale(id, locale);
384
438
  // The locale may have been published: its unit and the parent's listing go.
385
439
  expirePages([id, record.parentId]);
440
+ expireTree();
386
441
  return ok(removed);
387
442
  }),
388
443
  },
@@ -409,10 +464,12 @@ export function createAdminOps(deps) {
409
464
  const descriptors = collectionFields(collection);
410
465
  if (!descriptors)
411
466
  return err("not_found", `smoodly: no collection named "${collection}".`);
412
- const fieldErrors = validateFields(input.fields, descriptors);
467
+ const fieldErrors = validateFields(input.fields, descriptors, elementDescs);
413
468
  if (fieldErrors.length > 0)
414
469
  return err("validation", "Some fields need attention.", fieldErrors);
415
- return ok(await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null }));
470
+ const created = await entries.create(collection, input.fields, { locale: input.locale, parentId: input.parentId ?? null });
471
+ expireCollection(collection);
472
+ return ok(created);
416
473
  }),
417
474
  save: (collection, id, locale, fields) => guard(async () => {
418
475
  const unsupported = supportedLocale(locale);
@@ -421,11 +478,12 @@ export function createAdminOps(deps) {
421
478
  const descriptors = collectionFields(collection);
422
479
  if (!descriptors)
423
480
  return err("not_found", `smoodly: no collection named "${collection}".`);
424
- const fieldErrors = validateFields(fields, descriptors);
481
+ const fieldErrors = validateFields(fields, descriptors, elementDescs);
425
482
  if (fieldErrors.length > 0)
426
483
  return err("validation", "Some fields need attention.", fieldErrors);
427
484
  const entry = await entries.update(collection, id, locale, fields);
428
485
  await fanOut(id);
486
+ expireCollection(collection);
429
487
  return ok(entry);
430
488
  }),
431
489
  setStatus: (collection, id, locale, status) => guard(async () => {
@@ -434,24 +492,29 @@ export function createAdminOps(deps) {
434
492
  return unsupported;
435
493
  const entry = await entries.setStatus(collection, id, locale, status);
436
494
  await fanOut(id);
495
+ expireCollection(collection);
437
496
  return ok(entry);
438
497
  }),
439
498
  // The tag expires only after the delete succeeded; a blocked delete expires nothing.
440
- delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), ok(null))),
499
+ delete: (collection, id) => guard(async () => (await entries.delete(collection, id), await fanOut(id), expireCollection(collection), ok(null))),
441
500
  move: (collection, id, to) => guard(async () => {
442
501
  const moved = await entries.move(collection, id, to);
443
502
  if (effects?.revalidate) {
444
503
  for (const entryId of await entrySubtreeIds(collection, id))
445
504
  effects.revalidate(entryTag(entryId));
446
505
  }
506
+ expireCollection(collection);
447
507
  return ok(moved);
448
508
  }),
449
- // A new locale starts as a draft: nothing public changes, nothing to expire.
509
+ // A new locale starts as a draft: nothing public changes, but the collection's
510
+ // list units are expired anyway (spec 2026-09-11 §2) so they can never go stale.
450
511
  addLocale: (collection, id, locale, options) => guard(async () => {
451
512
  const unsupported = supportedLocale(locale);
452
513
  if (unsupported)
453
514
  return unsupported;
454
- return ok(await entries.addLocale(collection, id, locale, { from: options.from }));
515
+ const added = await entries.addLocale(collection, id, locale, { from: options.from });
516
+ expireCollection(collection);
517
+ return ok(added);
455
518
  }),
456
519
  removeLocale: (collection, id, locale) => guard(async () => {
457
520
  const unsupported = supportedLocale(locale);
@@ -459,6 +522,7 @@ export function createAdminOps(deps) {
459
522
  return unsupported;
460
523
  const entry = await entries.removeLocale(collection, id, locale);
461
524
  await fanOut(id);
525
+ expireCollection(collection);
462
526
  return ok(entry);
463
527
  }),
464
528
  },
@@ -488,7 +552,7 @@ export function createAdminOps(deps) {
488
552
  const item = sharedItem(name);
489
553
  if (!item)
490
554
  return err("not_found", `smoodly: no shared item named "${name}".`);
491
- const fieldErrors = validateFields(fields, item.fields);
555
+ const fieldErrors = validateFields(fields, item.fields, elementDescs);
492
556
  if (fieldErrors.length > 0)
493
557
  return err("validation", "Some fields need attention.", fieldErrors);
494
558
  const record = await sharedRecord(name);
@@ -555,7 +619,9 @@ export function createAdminOps(deps) {
555
619
  return err("validation", `smoodly: ${key} must be text.`);
556
620
  patch[key] = v;
557
621
  }
558
- return ok(await assets.update(id, patch));
622
+ const updated = await assets.update(id, patch);
623
+ await expireAssetUsers(id);
624
+ return ok(updated);
559
625
  }),
560
626
  delete: (id) => guard(async () => (assets ? (await assets.delete(id), ok(null)) : noMedia())),
561
627
  usage: (id) => guard(async () => {
@@ -1,5 +1,8 @@
1
1
  /** True when any text node in the tree has non-whitespace text. */
2
2
  export declare function hasText(doc: unknown): boolean;
3
+ /** True when the document has text, or an image or element block (spec
4
+ * 2026-09-08 block nodes §2): a picture alone is content. */
5
+ export declare function hasContent(doc: unknown): boolean;
3
6
  /** The document as plain JSON. ProseMirror builds node attrs with a null
4
7
  * prototype, which React's server-action encoding refuses to serialize
5
8
  * (it becomes a temporary reference and the server's structuredClone