@vespercms/admin 0.0.0-stage → 0.1.0-next.1

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 (207) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-_mkh0BSQ.js +3047 -0
  3. package/dist/a11y/FieldNotes.d.ts +23 -0
  4. package/dist/a11y/NotesDrawer.d.ts +23 -0
  5. package/dist/a11y/pdf.d.ts +3 -0
  6. package/dist/a11y/run.d.ts +3 -0
  7. package/dist/a11y/useNotes.d.ts +28 -0
  8. package/dist/app/App.d.ts +2 -0
  9. package/dist/app/Screen.d.ts +88 -0
  10. package/dist/app/UserMenu.d.ts +7 -0
  11. package/dist/app/api.d.ts +75 -0
  12. package/dist/app/appearance.d.ts +10 -0
  13. package/dist/app/browser-api.d.ts +17 -0
  14. package/dist/app/collection-icon.d.ts +12 -0
  15. package/dist/app/context.d.ts +23 -0
  16. package/dist/app/entries-changed.d.ts +11 -0
  17. package/dist/app/file-url.d.ts +13 -0
  18. package/dist/app/format.d.ts +4 -0
  19. package/dist/app/idb.d.ts +7 -0
  20. package/dist/app/load.d.ts +16 -0
  21. package/dist/app/media.d.ts +5 -0
  22. package/dist/app/navigation.d.ts +4 -0
  23. package/dist/app/pane-context.d.ts +19 -0
  24. package/dist/app/panes.d.ts +14 -0
  25. package/dist/app/router.d.ts +62 -0
  26. package/dist/body/BodyEditor.d.ts +42 -0
  27. package/dist/body/TableView.d.ts +2 -0
  28. package/dist/body/catalog.d.ts +47 -0
  29. package/dist/body/checks.d.ts +15 -0
  30. package/dist/body/chips.d.ts +31 -0
  31. package/dist/body/commands.d.ts +31 -0
  32. package/dist/body/document.d.ts +42 -0
  33. package/dist/body/insert.d.ts +69 -0
  34. package/dist/body/nodes.d.ts +38 -0
  35. package/dist/body/reveal.d.ts +13 -0
  36. package/dist/body/scroll.d.ts +16 -0
  37. package/dist/body/settings.d.ts +19 -0
  38. package/dist/body/shortcuts.d.ts +13 -0
  39. package/dist/body/table.d.ts +31 -0
  40. package/dist/body/ui.d.ts +30 -0
  41. package/dist/body/views.d.ts +9 -0
  42. package/dist/checks-C-0xDu-I.js +57 -0
  43. package/dist/cloud/CloudGate.d.ts +14 -0
  44. package/dist/cloud/access.d.ts +14 -0
  45. package/dist/cloud/changes.d.ts +26 -0
  46. package/dist/cloud/connection.d.ts +100 -0
  47. package/dist/cloud/editor.d.ts +44 -0
  48. package/dist/cloud/session.d.ts +62 -0
  49. package/dist/cloud/storage.d.ts +45 -0
  50. package/dist/editor/Field.d.ts +24 -0
  51. package/dist/editor/PagePicker.d.ts +20 -0
  52. package/dist/editor/arrange.d.ts +13 -0
  53. package/dist/editor/context.d.ts +23 -0
  54. package/dist/editor/copy.d.ts +9 -0
  55. package/dist/editor/reveal.d.ts +7 -0
  56. package/dist/editor/target.d.ts +21 -0
  57. package/dist/editor/values.d.ts +18 -0
  58. package/dist/geo-map-CT3OGD7O.js +110 -0
  59. package/dist/github/GitHubGate.d.ts +15 -0
  60. package/dist/github/api.d.ts +20 -0
  61. package/dist/github/cache.d.ts +2 -0
  62. package/dist/github/session.d.ts +35 -0
  63. package/dist/index.d.ts +3 -0
  64. package/dist/markdown-4fVeej3c.js +211 -0
  65. package/dist/media/MediaPicker.d.ts +28 -0
  66. package/dist/media/file-icon.d.ts +3 -0
  67. package/dist/media/formats.d.ts +23 -0
  68. package/dist/media/hygiene.d.ts +37 -0
  69. package/dist/media/upload.d.ts +31 -0
  70. package/dist/mount.d.ts +3 -0
  71. package/dist/pdf-m5s_uXJ3.js +24 -0
  72. package/dist/run-y4XRkg17.js +7 -0
  73. package/dist/schedule/ScheduleDialog.d.ts +28 -0
  74. package/dist/schedule/ScheduleSummary.d.ts +12 -0
  75. package/dist/schedule/ScheduledView.d.ts +8 -0
  76. package/dist/schedule/cloud.d.ts +21 -0
  77. package/dist/schedule/now.d.ts +5 -0
  78. package/dist/schedule/times.d.ts +35 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +16 -0
  81. package/dist/screens/Home.d.ts +6 -0
  82. package/dist/screens/ListPane.d.ts +6 -0
  83. package/dist/screens/MediaBrowser.d.ts +7 -0
  84. package/dist/screens/NotFound.d.ts +3 -0
  85. package/dist/screens/PageTree.d.ts +10 -0
  86. package/dist/screens/Redirects.d.ts +1 -0
  87. package/dist/screens/Unavailable.d.ts +8 -0
  88. package/dist/screens/status.d.ts +36 -0
  89. package/dist/shortcuts-CarKeDqp.js +4666 -0
  90. package/dist/studio.css +3 -0
  91. package/dist/studio.js +6449 -0
  92. package/dist/types.d.ts +76 -0
  93. package/dist/widgets/basic.d.ts +25 -0
  94. package/dist/widgets/blank-date.d.ts +8 -0
  95. package/dist/widgets/crops.d.ts +43 -0
  96. package/dist/widgets/focal-point.d.ts +15 -0
  97. package/dist/widgets/geo-map.d.ts +10 -0
  98. package/dist/widgets/geo.d.ts +16 -0
  99. package/dist/widgets/group.d.ts +41 -0
  100. package/dist/widgets/icon.d.ts +9 -0
  101. package/dist/widgets/image.d.ts +16 -0
  102. package/dist/widgets/markdown-lazy.d.ts +2 -0
  103. package/dist/widgets/markdown.d.ts +2 -0
  104. package/dist/widgets/reference.d.ts +12 -0
  105. package/dist/widgets/registry.d.ts +32 -0
  106. package/dist/widgets/special.d.ts +20 -0
  107. package/dist/widgets/union.d.ts +36 -0
  108. package/package.json +63 -4
  109. package/src/a11y/FieldNotes.tsx +56 -0
  110. package/src/a11y/NotesDrawer.tsx +81 -0
  111. package/src/a11y/pdf.ts +45 -0
  112. package/src/a11y/run.ts +12 -0
  113. package/src/a11y/useNotes.ts +139 -0
  114. package/src/app/App.tsx +156 -0
  115. package/src/app/Screen.tsx +416 -0
  116. package/src/app/UserMenu.tsx +95 -0
  117. package/src/app/api.ts +176 -0
  118. package/src/app/appearance.ts +54 -0
  119. package/src/app/browser-api.ts +173 -0
  120. package/src/app/collection-icon.ts +15 -0
  121. package/src/app/context.tsx +32 -0
  122. package/src/app/entries-changed.ts +48 -0
  123. package/src/app/file-url.tsx +44 -0
  124. package/src/app/format.ts +39 -0
  125. package/src/app/idb.ts +82 -0
  126. package/src/app/load.ts +40 -0
  127. package/src/app/media.ts +20 -0
  128. package/src/app/navigation.ts +4 -0
  129. package/src/app/pane-context.ts +25 -0
  130. package/src/app/panes.tsx +513 -0
  131. package/src/app/router.ts +158 -0
  132. package/src/body/BodyEditor.tsx +1139 -0
  133. package/src/body/TableView.tsx +700 -0
  134. package/src/body/catalog.ts +198 -0
  135. package/src/body/checks.ts +83 -0
  136. package/src/body/chips.ts +150 -0
  137. package/src/body/commands.ts +191 -0
  138. package/src/body/document.ts +388 -0
  139. package/src/body/insert.tsx +301 -0
  140. package/src/body/nodes.ts +287 -0
  141. package/src/body/reveal.ts +93 -0
  142. package/src/body/scroll.ts +53 -0
  143. package/src/body/settings.tsx +510 -0
  144. package/src/body/shortcuts.ts +31 -0
  145. package/src/body/table.ts +192 -0
  146. package/src/body/ui.ts +77 -0
  147. package/src/body/views.tsx +268 -0
  148. package/src/build.ts +6 -0
  149. package/src/cloud/CloudGate.tsx +245 -0
  150. package/src/cloud/access.ts +52 -0
  151. package/src/cloud/changes.tsx +474 -0
  152. package/src/cloud/connection.ts +281 -0
  153. package/src/cloud/editor.tsx +250 -0
  154. package/src/cloud/session.ts +289 -0
  155. package/src/cloud/storage.ts +212 -0
  156. package/src/editor/Field.tsx +182 -0
  157. package/src/editor/PagePicker.tsx +102 -0
  158. package/src/editor/arrange.ts +98 -0
  159. package/src/editor/context.tsx +31 -0
  160. package/src/editor/copy.ts +30 -0
  161. package/src/editor/reveal.ts +38 -0
  162. package/src/editor/target.ts +104 -0
  163. package/src/editor/values.ts +71 -0
  164. package/src/github/GitHubGate.tsx +298 -0
  165. package/src/github/api.ts +81 -0
  166. package/src/github/cache.ts +64 -0
  167. package/src/github/session.ts +146 -0
  168. package/src/index.ts +10 -0
  169. package/src/media/MediaPicker.tsx +302 -0
  170. package/src/media/file-icon.ts +17 -0
  171. package/src/media/formats.ts +262 -0
  172. package/src/media/hygiene.ts +220 -0
  173. package/src/media/upload.ts +47 -0
  174. package/src/mount.tsx +20 -0
  175. package/src/schedule/ScheduleDialog.tsx +303 -0
  176. package/src/schedule/ScheduleSummary.tsx +59 -0
  177. package/src/schedule/ScheduledView.tsx +294 -0
  178. package/src/schedule/cloud.ts +66 -0
  179. package/src/schedule/now.ts +18 -0
  180. package/src/schedule/times.ts +122 -0
  181. package/src/screens/EntryEditor.tsx +1817 -0
  182. package/src/screens/EntryList.tsx +272 -0
  183. package/src/screens/Home.tsx +131 -0
  184. package/src/screens/ListPane.tsx +477 -0
  185. package/src/screens/MediaBrowser.tsx +253 -0
  186. package/src/screens/NotFound.tsx +19 -0
  187. package/src/screens/PageTree.tsx +738 -0
  188. package/src/screens/Redirects.tsx +522 -0
  189. package/src/screens/Unavailable.tsx +27 -0
  190. package/src/screens/status.tsx +104 -0
  191. package/src/studio.css +168 -0
  192. package/src/types.ts +82 -0
  193. package/src/widgets/basic.tsx +315 -0
  194. package/src/widgets/blank-date.ts +32 -0
  195. package/src/widgets/crops.tsx +399 -0
  196. package/src/widgets/focal-point.tsx +240 -0
  197. package/src/widgets/geo-map.tsx +160 -0
  198. package/src/widgets/geo.tsx +170 -0
  199. package/src/widgets/group.tsx +827 -0
  200. package/src/widgets/icon.tsx +47 -0
  201. package/src/widgets/image.tsx +407 -0
  202. package/src/widgets/markdown-lazy.tsx +22 -0
  203. package/src/widgets/markdown.tsx +267 -0
  204. package/src/widgets/reference.tsx +196 -0
  205. package/src/widgets/registry.ts +104 -0
  206. package/src/widgets/special.tsx +290 -0
  207. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,388 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+ import type { MarkdownManager } from '@tiptap/markdown';
3
+ import {
4
+ editTable,
5
+ parseBody,
6
+ type BodyBlock,
7
+ type BodyFormat,
8
+ type BodyItem,
9
+ type MarkdocParseOptions,
10
+ type ParsedBody,
11
+ type TableEdit,
12
+ type TableValue,
13
+ } from '@vespercms/core';
14
+ import type { Catalog } from './catalog.ts';
15
+ import { hasPlaceholderCharacters, restoreChips, withPlaceholders } from './chips.ts';
16
+ import { WRITABLE_NODES, type ContainerTypes } from './nodes.ts';
17
+ import { cellFits, editorValue, savedValue } from './table.ts';
18
+
19
+ /*
20
+ * The body as editor nodes, and back (DECISIONS.md: "The body editor splices
21
+ * changed blocks into the original text"). Each block becomes one node
22
+ * tagged with its path: Markdown the writing view can show faithfully, a
23
+ * tag or component the site declares (a container with editable children,
24
+ * or a card), or a protected source block. On save, a node whose content
25
+ * didn't change copies its original bytes, a container re-writes its
26
+ * opening only if its attributes changed, and only changed or new blocks
27
+ * are written by the Markdown serializer.
28
+ */
29
+
30
+ export type BodyContext = {
31
+ format: BodyFormat;
32
+ markdown: MarkdownManager;
33
+ catalog: Catalog;
34
+ containers: ContainerTypes;
35
+ markdoc?: MarkdocParseOptions;
36
+ };
37
+
38
+ export type BodyDocument = {
39
+ /** The text the document was built from, and its blocks. */
40
+ parsed: ParsedBody;
41
+ doc: JSONContent;
42
+ };
43
+
44
+ /** Builds the editor document for a body. */
45
+ export function buildDocument(body: string, context: BodyContext): BodyDocument {
46
+ const parsed = parseBody(
47
+ body,
48
+ context.format,
49
+ context.markdoc ? { markdoc: context.markdoc } : {},
50
+ );
51
+ const content = nodesOf(parsed.text, parsed.blocks, null, '', context, null);
52
+ return {
53
+ parsed,
54
+ doc: { type: 'doc', content: content.length > 0 ? content : [{ type: 'paragraph' }] },
55
+ };
56
+ }
57
+
58
+ const pathOf = (parent: string | null, index: number) =>
59
+ parent === null ? String(index) : `${parent}.${String(index)}`;
60
+
61
+ function nodesOf(
62
+ text: string,
63
+ blocks: readonly BodyBlock[],
64
+ parent: string | null,
65
+ indent: string,
66
+ context: BodyContext,
67
+ allows: ((type: string) => boolean) | null,
68
+ ): JSONContent[] {
69
+ return blocks.map((block, index) => {
70
+ const path = pathOf(parent, index);
71
+ const node = nodeOf(text, block, path, indent, context);
72
+ return allows && !allows(node.type ?? '') ? sourceNode(text, block, path) : node;
73
+ });
74
+ }
75
+
76
+ function sourceNode(text: string, block: BodyBlock, path: string): JSONContent {
77
+ return {
78
+ type: 'sourceBlock',
79
+ attrs: { vesperBlock: path, text: text.slice(block.start, block.end), blockType: block.type },
80
+ };
81
+ }
82
+
83
+ function nodeOf(
84
+ text: string,
85
+ block: BodyBlock,
86
+ path: string,
87
+ indent: string,
88
+ context: BodyContext,
89
+ ): JSONContent {
90
+ const raw = text.slice(block.start, block.end);
91
+ if (block.kind === 'markdown' && !hasPlaceholderCharacters(raw)) {
92
+ const chips = (block.chips ?? []).map((c) => ({
93
+ start: c.start - block.start,
94
+ end: c.end - block.start,
95
+ }));
96
+ const source = dedent(withPlaceholders(raw, chips), indent);
97
+ const node = source === null ? null : writableNode(context.markdown, source);
98
+ if (node) return { ...node, attrs: { ...node.attrs, vesperBlock: path } };
99
+ return sourceNode(text, block, path);
100
+ }
101
+ if (block.kind === 'table' && block.table) {
102
+ const value = editorValue(text, block.table);
103
+ // Every cell must be one the writing view writes back, or the whole table is kept as written.
104
+ const fits = value.rows.every((row) =>
105
+ row.cells.every(
106
+ (cell) => !hasPlaceholderCharacters(restoreChips(cell)) && cellFits(context.markdown, cell),
107
+ ),
108
+ );
109
+ if (fits) return { type: 'vesperTable', attrs: { vesperBlock: path, value } };
110
+ return sourceNode(text, block, path);
111
+ }
112
+ const tag = block.tag;
113
+ const kind = block.kind === 'tag' && tag ? context.catalog.find(tag.syntax, tag.name) : undefined;
114
+ if (!tag || !kind) return sourceNode(text, block, path);
115
+
116
+ const containerType = context.containers.typeOf(kind);
117
+ if (containerType && tag.children && tag.closeStart !== undefined) {
118
+ const children = nodesOf(text, tag.children, path, tag.indent ?? '', context, (type) =>
119
+ context.containers.allows(kind, type),
120
+ );
121
+ return {
122
+ type: containerType,
123
+ attrs: {
124
+ vesperBlock: path,
125
+ syntax: tag.syntax,
126
+ name: tag.name,
127
+ open: text.slice(block.start, tag.openEnd),
128
+ close: text.slice(tag.closeStart, block.end),
129
+ },
130
+ content: children.length > 0 ? children : [{ type: 'paragraph' }],
131
+ };
132
+ }
133
+ return {
134
+ type: 'vesperCard',
135
+ attrs: { vesperBlock: path, syntax: tag.syntax, name: tag.name, source: raw },
136
+ };
137
+ }
138
+
139
+ /** Text with `indent` taken off every line after the first; null when a line isn't indented so. */
140
+ export function dedent(text: string, indent: string): string | null {
141
+ if (indent === '') return text;
142
+ const lines = text.split('\n');
143
+ const out: string[] = [];
144
+ for (const [i, line] of lines.entries()) {
145
+ if (i === 0) out.push(line);
146
+ else if (line.trim() === '') out.push(line.trim());
147
+ else if (line.startsWith(indent)) out.push(line.slice(indent.length));
148
+ else return null;
149
+ }
150
+ return out.join('\n');
151
+ }
152
+
153
+ /**
154
+ * The one editor node a Markdown block becomes, or null when the writing
155
+ * view can't own it: it isn't exactly one supported node, or saving it would
156
+ * read back differently (a lossy parse would show the editor something
157
+ * other than what the site renders).
158
+ */
159
+ function writableNode(markdown: MarkdownManager, text: string): JSONContent | null {
160
+ try {
161
+ const nodes = markdown.parse(text).content ?? [];
162
+ const [node] = nodes;
163
+ if (nodes.length !== 1 || !node?.type || !WRITABLE_NODES.has(node.type)) return null;
164
+ const again =
165
+ markdown.parse(markdown.serialize({ type: 'doc', content: [node] })).content ?? [];
166
+ return again.length === 1 && JSON.stringify(again[0]) === JSON.stringify(node) ? node : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ const isContainer = (node: JSONContent, containers: ContainerTypes) =>
173
+ node.type !== undefined && containers.kindOf(node.type) !== undefined;
174
+
175
+ /** How every node with a path first looked: its JSON, or for a container its opening. */
176
+ export function snapshot(doc: JSONContent, containers: ContainerTypes): Map<string, string> {
177
+ const initial = new Map<string, string>();
178
+ const visit = (nodes: readonly JSONContent[] | undefined) => {
179
+ for (const node of nodes ?? []) {
180
+ const path = node.attrs?.vesperBlock as unknown;
181
+ if (typeof path === 'string' && !initial.has(path)) {
182
+ initial.set(
183
+ path,
184
+ isContainer(node, containers)
185
+ ? JSON.stringify({ open: node.attrs?.open as unknown })
186
+ : JSON.stringify(node),
187
+ );
188
+ }
189
+ if (isContainer(node, containers)) visit(node.content);
190
+ }
191
+ };
192
+ visit(doc.content);
193
+ return initial;
194
+ }
195
+
196
+ /** The index of `path` within `parent`, when it's a direct child of it there. */
197
+ function childIndex(path: unknown, parent: string | null): number | null {
198
+ if (typeof path !== 'string') return null;
199
+ const dot = path.lastIndexOf('.');
200
+ const parentPart = dot === -1 ? null : path.slice(0, dot);
201
+ if (parentPart !== parent) return null;
202
+ return Number(path.slice(dot + 1));
203
+ }
204
+
205
+ type ItemsContext = Pick<BodyContext, 'markdown' | 'containers'> & {
206
+ parsed?: ParsedBody;
207
+ /** How the body parses, for checking a changed table reads back. */
208
+ format?: BodyFormat;
209
+ markdoc?: MarkdocParseOptions;
210
+ };
211
+
212
+ /** The edited body as items: unchanged blocks keep their bytes, changed ones are written. */
213
+ export function bodyItems(
214
+ doc: JSONContent,
215
+ initial: ReadonlyMap<string, string>,
216
+ context: ItemsContext,
217
+ ): BodyItem[] {
218
+ return itemsOf(doc.content, null, initial, context);
219
+ }
220
+
221
+ /** The original block at a path ("3", "3.1"), and the indentation of the container holding it. */
222
+ function blockAt(parsed: ParsedBody, path: string): { block: BodyBlock; indent: string } | null {
223
+ let blocks: readonly BodyBlock[] = parsed.blocks;
224
+ let indent = '';
225
+ const parts = path.split('.');
226
+ for (const [i, part] of parts.entries()) {
227
+ const block = blocks[Number(part)];
228
+ if (!block) return null;
229
+ if (i === parts.length - 1) return { block, indent };
230
+ if (!block.tag?.children) return null;
231
+ blocks = block.tag.children;
232
+ indent = block.tag.indent ?? '';
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /** Each chip's source, in order, and the node with every chip's source blanked. */
238
+ function chipShape(node: JSONContent): { sources: string[]; shape: string } {
239
+ const sources: string[] = [];
240
+ const blank = (n: JSONContent): JSONContent => {
241
+ if (n.type === 'vesperInline') {
242
+ sources.push(String(n.attrs?.source ?? ''));
243
+ return { ...n, attrs: { ...n.attrs, source: '' } };
244
+ }
245
+ return n.content ? { ...n, content: n.content.map(blank) } : n;
246
+ };
247
+ return { sources, shape: JSON.stringify(blank(node)) };
248
+ }
249
+
250
+ /**
251
+ * When a Markdown block's only change is inside its chips (an inline tag's
252
+ * settings, a variable edited as text), the chips' new text is spliced into
253
+ * the block's original text, so nothing else in it is re-serialized: editing
254
+ * one tag changes only that tag's text. Null when anything else changed.
255
+ */
256
+ function chipSplice(
257
+ node: JSONContent,
258
+ path: string,
259
+ initial: ReadonlyMap<string, string>,
260
+ parsed: ParsedBody | undefined,
261
+ ): string | null {
262
+ const before = initial.get(path);
263
+ const found = parsed ? blockAt(parsed, path) : null;
264
+ if (!before || !parsed || !found?.block.chips) return null;
265
+ const was = chipShape(JSON.parse(before) as JSONContent);
266
+ const now = chipShape(node);
267
+ const chips = found.block.chips;
268
+ if (was.shape !== now.shape || now.sources.length !== chips.length) return null;
269
+ let text = parsed.text.slice(found.block.start, found.block.end);
270
+ // Right to left, so earlier offsets stay valid.
271
+ for (let i = chips.length - 1; i >= 0; i--) {
272
+ const chip = chips[i];
273
+ if (!chip) return null;
274
+ const from = chip.start - found.block.start;
275
+ const to = chip.end - found.block.start;
276
+ text = text.slice(0, from) + (now.sources[i] ?? '') + text.slice(to);
277
+ }
278
+ // The assembler indents lines after the first like their container's content.
279
+ return dedent(text, found.indent);
280
+ }
281
+
282
+ function itemsOf(
283
+ nodes: readonly JSONContent[] | undefined,
284
+ parent: string | null,
285
+ initial: ReadonlyMap<string, string>,
286
+ context: ItemsContext,
287
+ ): BodyItem[] {
288
+ const items: BodyItem[] = [];
289
+ for (const node of nodes ?? []) {
290
+ const path = node.attrs?.vesperBlock as unknown;
291
+ const index = childIndex(path, parent);
292
+ if (isContainer(node, context.containers)) {
293
+ const open = String(node.attrs?.open ?? '');
294
+ if (index !== null && typeof path === 'string') {
295
+ const before = initial.get(path);
296
+ const children = itemsOf(node.content, path, initial, context);
297
+ items.push({
298
+ replaces: index,
299
+ ...(before !== JSON.stringify({ open }) ? { open } : {}),
300
+ children,
301
+ });
302
+ } else {
303
+ items.push({
304
+ open,
305
+ close: String(node.attrs?.close ?? ''),
306
+ children: itemsOf(node.content, null, initial, context),
307
+ });
308
+ }
309
+ continue;
310
+ }
311
+ if (index !== null && typeof path === 'string' && initial.get(path) === JSON.stringify(node)) {
312
+ items.push({ keep: index });
313
+ continue;
314
+ }
315
+ if (node.type === 'vesperTable') {
316
+ // The table view checked this change when it was made, so it can't be refused here.
317
+ const edit = tableEdit(
318
+ typeof path === 'string' ? path : null,
319
+ node.attrs?.value as TableValue,
320
+ context,
321
+ );
322
+ if (!edit.ok) throw new Error(edit.reason);
323
+ items.push(index === null ? { text: edit.text } : { text: edit.text, replaces: index });
324
+ continue;
325
+ }
326
+ const spliced =
327
+ index !== null && typeof path === 'string'
328
+ ? chipSplice(node, path, initial, context.parsed)
329
+ : null;
330
+ const text = spliced ?? nodeText(node, context.markdown);
331
+ // An empty paragraph (the line the cursor sits on) writes nothing.
332
+ if (text === '' && index === null) continue;
333
+ items.push(index === null ? { text } : { text, replaces: index });
334
+ }
335
+ return items;
336
+ }
337
+
338
+ /**
339
+ * A table's text as it will be saved: spliced into the table it was loaded
340
+ * from (`path`), or written new. Refused when it wouldn't read back as this
341
+ * table.
342
+ */
343
+ export function tableEdit(
344
+ path: string | null,
345
+ value: TableValue,
346
+ context: ItemsContext,
347
+ ): TableEdit {
348
+ if (!context.format) throw new Error('Saving a table needs the body’s format.');
349
+ const options = {
350
+ format: context.format,
351
+ ...(context.markdoc ? { markdoc: context.markdoc } : {}),
352
+ };
353
+ const found = path !== null && context.parsed ? blockAt(context.parsed, path) : null;
354
+ const table = found?.block.table;
355
+ const source =
356
+ found && table && context.parsed
357
+ ? { text: context.parsed.text, table, indent: found.indent }
358
+ : null;
359
+ return editTable(source, savedValue(value), options);
360
+ }
361
+
362
+ /** A node's text as it will be saved. */
363
+ export function nodeText(node: JSONContent, markdown: MarkdownManager): string {
364
+ if (node.type === 'sourceBlock') return String(node.attrs?.text ?? '');
365
+ if (node.type === 'vesperCard') return String(node.attrs?.source ?? '');
366
+ const { vesperBlock: _vesperBlock, ...attrs } = node.attrs ?? {};
367
+ return restoreChips(
368
+ markdown.serialize({ type: 'doc', content: [{ ...node, attrs }] }).replace(/\s+$/, ''),
369
+ );
370
+ }
371
+
372
+ /** A plain-language name for a protected block, from its parser type. */
373
+ export function sourceLabel(blockType: string): string {
374
+ const labels: Record<string, string> = {
375
+ mdxjsEsm: 'Import',
376
+ mdxJsxFlowElement: 'Component',
377
+ mdxFlowExpression: 'Expression',
378
+ html: 'HTML',
379
+ html_block: 'HTML',
380
+ comment: 'Comment',
381
+ definition: 'Link reference',
382
+ footnoteDefinition: 'Footnote',
383
+ table: 'Table',
384
+ tag: 'Markdoc tag',
385
+ unparsed: 'Body that couldn’t be read',
386
+ };
387
+ return labels[blockType] ?? 'Formatted text';
388
+ }
@@ -0,0 +1,301 @@
1
+ import { Extension, type Editor } from '@tiptap/core';
2
+ import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state';
3
+ import { useEditorState } from '@tiptap/react';
4
+ import { Menu, cx, popoverClass } from '@vespercms/ui';
5
+ import { useId } from 'react';
6
+ import { insertable, type BlockKind, type BlockShape, type Catalog } from './catalog.ts';
7
+ import { placementAt } from './commands.ts';
8
+ import type { ContainerTypes } from './nodes.ts';
9
+
10
+ /*
11
+ * Inserting tags (spec §6.5.1, "Inserting"): typing "/" at the start of a
12
+ * line or after a space opens a list of what fits at the cursor, filtered as
13
+ * you type; the toolbar's Insert button offers the same list as a menu.
14
+ *
15
+ * The "/" list follows the combobox pattern: focus stays in the text, which
16
+ * becomes a combobox while the list is open (aria-expanded, aria-controls,
17
+ * aria-activedescendant), and the arrow keys move through the options.
18
+ */
19
+
20
+ export type InsertChoice = { kind: BlockKind; shape: BlockShape };
21
+
22
+ type SlashState = { active: false } | { active: true; from: number; index: number };
23
+
24
+ export const slashKey = new PluginKey<SlashState>('vesperSlash');
25
+
26
+ /** What can be inserted at the cursor right now, by friendly name. */
27
+ export function choicesAt(
28
+ state: EditorState,
29
+ catalog: Catalog,
30
+ containers: ContainerTypes,
31
+ ): InsertChoice[] {
32
+ return insertable(catalog)
33
+ .flatMap((kind) => {
34
+ const shape = placementAt(state, kind, containers);
35
+ return shape ? [{ kind, shape }] : [];
36
+ })
37
+ .sort((a, b) => a.kind.label.localeCompare(b.kind.label));
38
+ }
39
+
40
+ export function slashQuery(state: EditorState): string | null {
41
+ const slash = slashKey.getState(state);
42
+ if (!slash?.active) return null;
43
+ return state.doc.textBetween(slash.from + 1, state.selection.from, '\n');
44
+ }
45
+
46
+ export function filterChoices(choices: readonly InsertChoice[], query: string): InsertChoice[] {
47
+ const q = query.toLowerCase();
48
+ return choices.filter(
49
+ (c) => c.kind.label.toLowerCase().includes(q) || c.kind.name.toLowerCase().includes(q),
50
+ );
51
+ }
52
+
53
+ export type SlashOptions = {
54
+ listboxId: string;
55
+ optionId: (index: number) => string;
56
+ /** The options for the current query (computed by the caller, which knows the catalog). */
57
+ choices: (state: EditorState) => InsertChoice[];
58
+ choose: (choice: InsertChoice, range: { from: number; to: number }) => void;
59
+ disabled: () => boolean;
60
+ };
61
+
62
+ /**
63
+ * The "/" plugin: opens on "/" typed at a line's start or after a space in a
64
+ * paragraph, closes on Escape, a space, or the cursor leaving the query, and
65
+ * sets the editor's role and ARIA state while it's open.
66
+ */
67
+ export function SlashCommands(options: SlashOptions) {
68
+ return Extension.create({
69
+ name: 'vesperSlash',
70
+ // Ahead of every other key handler, so Enter, Escape and the arrows
71
+ // belong to the list while it's open.
72
+ priority: 1000,
73
+ addProseMirrorPlugins() {
74
+ return [
75
+ new Plugin<SlashState>({
76
+ key: slashKey,
77
+ state: {
78
+ init: () => ({ active: false }),
79
+ apply(tr, previous, _old, next) {
80
+ const meta = tr.getMeta(slashKey) as SlashState | { index: number } | undefined;
81
+ let state: SlashState = previous;
82
+ // A list opened by this transaction already points into the new document.
83
+ const opened = meta !== undefined && 'active' in meta;
84
+ if (opened) state = meta;
85
+ else if (meta && state.active) state = { ...state, index: meta.index };
86
+ if (!state.active) return state;
87
+ const from = opened ? state.from : tr.mapping.map(state.from, -1);
88
+ const { selection } = next;
89
+ const text = next.doc.textBetween(from, Math.max(from, selection.from), '\n');
90
+ const still =
91
+ selection.empty &&
92
+ selection.from > from &&
93
+ next.doc.resolve(from).parent === selection.$from.parent &&
94
+ text.startsWith('/') &&
95
+ !/\s/.test(text);
96
+ return still ? { ...state, from } : { active: false };
97
+ },
98
+ },
99
+ props: {
100
+ handleTextInput(view, from, to, text) {
101
+ if (text !== '/' || options.disabled()) return false;
102
+ const $from = view.state.doc.resolve(from);
103
+ if ($from.parent.type.name !== 'paragraph') return false;
104
+ const before = $from.parent.textBetween(0, $from.parentOffset, '\n');
105
+ if (before !== '' && !/\s$/.test(before)) return false;
106
+ view.dispatch(
107
+ view.state.tr
108
+ .insertText('/', from, to)
109
+ .setMeta(slashKey, { active: true, from, index: 0 }),
110
+ );
111
+ return true;
112
+ },
113
+ handleKeyDown(view, event) {
114
+ const state = slashKey.getState(view.state);
115
+ if (!state?.active) return false;
116
+ const query = slashQuery(view.state) ?? '';
117
+ const list = filterChoices(options.choices(view.state), query);
118
+ if (event.key === 'Escape') {
119
+ view.dispatch(view.state.tr.setMeta(slashKey, { active: false }));
120
+ return true;
121
+ }
122
+ if (list.length === 0) return false;
123
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
124
+ const step = event.key === 'ArrowDown' ? 1 : -1;
125
+ const index = (state.index + step + list.length) % list.length;
126
+ view.dispatch(view.state.tr.setMeta(slashKey, { index }));
127
+ return true;
128
+ }
129
+ if (event.key === 'Enter' || event.key === 'Tab') {
130
+ const choice = list[Math.min(state.index, list.length - 1)];
131
+ if (!choice) return false;
132
+ const range = { from: state.from, to: view.state.selection.from };
133
+ view.dispatch(view.state.tr.setMeta(slashKey, { active: false }));
134
+ options.choose(choice, range);
135
+ return true;
136
+ }
137
+ return false;
138
+ },
139
+ },
140
+ }),
141
+ ];
142
+ },
143
+ });
144
+ }
145
+
146
+ /**
147
+ * The text's role and ARIA state: a multi-line textbox, or a combobox while
148
+ * the "/" list is open. Given to the editor through `editorProps.attributes`,
149
+ * which is the only place that can replace Tiptap's own `role="textbox"`.
150
+ */
151
+ export function slashAttributes(
152
+ state: EditorState,
153
+ options: Pick<SlashOptions, 'listboxId' | 'optionId'> & { count: (query: string) => number },
154
+ ): Record<string, string> {
155
+ const slash = slashKey.getState(state);
156
+ if (!slash?.active) return { role: 'textbox', 'aria-multiline': 'true' };
157
+ const count = options.count(slashQuery(state) ?? '');
158
+ return {
159
+ role: 'combobox',
160
+ 'aria-expanded': 'true',
161
+ 'aria-controls': options.listboxId,
162
+ 'aria-autocomplete': 'list',
163
+ ...(count > 0
164
+ ? { 'aria-activedescendant': options.optionId(Math.min(slash.index, count - 1)) }
165
+ : {}),
166
+ };
167
+ }
168
+
169
+ /** The "/" list, shown under the cursor while the plugin is open. */
170
+ export function SlashList({
171
+ editor,
172
+ listboxId,
173
+ optionId,
174
+ choices,
175
+ onChoose,
176
+ }: {
177
+ editor: Editor;
178
+ listboxId: string;
179
+ optionId: (index: number) => string;
180
+ choices: (state: EditorState) => InsertChoice[];
181
+ onChoose: (choice: InsertChoice, range: { from: number; to: number }) => void;
182
+ }) {
183
+ const state = useEditorState({
184
+ editor,
185
+ selector: ({ editor: e }) => {
186
+ const slash = slashKey.getState(e.state);
187
+ if (!slash?.active) return null;
188
+ return {
189
+ from: slash.from,
190
+ to: e.state.selection.from,
191
+ index: slash.index,
192
+ query: slashQuery(e.state) ?? '',
193
+ place: placeOf(e, slash.from),
194
+ };
195
+ },
196
+ equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b),
197
+ });
198
+ if (!state) return null;
199
+ const { place } = state;
200
+ const list = filterChoices(choices(editor.state), state.query);
201
+ const active = Math.min(state.index, Math.max(0, list.length - 1));
202
+ return (
203
+ <div
204
+ id={listboxId}
205
+ role="listbox"
206
+ aria-label="Insert a block"
207
+ style={place ? { top: place.top, left: place.left } : undefined}
208
+ className={cx('fixed z-40 max-h-64 min-w-56 overflow-y-auto', popoverClass)}
209
+ >
210
+ {list.length === 0 ? (
211
+ <div
212
+ role="option"
213
+ tabIndex={-1}
214
+ aria-selected="false"
215
+ aria-disabled="true"
216
+ className="px-3 py-2 text-small text-content-muted"
217
+ >
218
+ Nothing fits here
219
+ </div>
220
+ ) : (
221
+ list.map((choice, index) => (
222
+ <div
223
+ key={choice.kind.key}
224
+ id={optionId(index)}
225
+ role="option"
226
+ tabIndex={-1}
227
+ aria-selected={index === active}
228
+ className={cx(
229
+ 'flex min-h-9 cursor-default items-center rounded-md px-3 text-body text-content',
230
+ index === active && 'bg-accent-subtle',
231
+ )}
232
+ onMouseDown={(event) => {
233
+ // Keep focus (and the "/" range) in the text.
234
+ event.preventDefault();
235
+ onChoose(choice, { from: state.from, to: state.to });
236
+ }}
237
+ >
238
+ {choice.kind.label}
239
+ {choice.shape === 'inline' && (
240
+ <span className="text-content-muted"> (in this line)</span>
241
+ )}
242
+ </div>
243
+ ))
244
+ )}
245
+ </div>
246
+ );
247
+ }
248
+
249
+ /** Where the list goes: just under the "/". Null when the view can't say (no layout). */
250
+ function placeOf(editor: Editor, from: number): { top: number; left: number } | null {
251
+ try {
252
+ const coords = editor.view.coordsAtPos(from);
253
+ return { top: coords.bottom + 4, left: coords.left };
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /** The toolbar's Insert menu: the same list, for people who don't use "/". */
260
+ export function InsertMenu({
261
+ editor,
262
+ choices,
263
+ onChoose,
264
+ disabled,
265
+ }: {
266
+ editor: Editor;
267
+ choices: (state: EditorState) => InsertChoice[];
268
+ onChoose: (choice: InsertChoice) => void;
269
+ disabled: boolean;
270
+ }) {
271
+ const list = useEditorState({
272
+ editor,
273
+ selector: ({ editor: e }) =>
274
+ choices(e.state)
275
+ .map((c) => `${c.kind.key}|${c.shape}`)
276
+ .join(','),
277
+ });
278
+ const available = choices(editor.state);
279
+ if (disabled || list === '' || available.length === 0) return null;
280
+ return (
281
+ <Menu
282
+ label="Insert a block"
283
+ triggerText="Insert"
284
+ triggerIcon="add"
285
+ items={available.map((c) => ({
286
+ id: `${c.kind.key}|${c.shape}`,
287
+ label: c.shape === 'inline' ? `${c.kind.label} (in this line)` : c.kind.label,
288
+ }))}
289
+ onAction={(id) => {
290
+ const choice = available.find((c) => `${c.kind.key}|${c.shape}` === id);
291
+ if (choice) onChoose(choice);
292
+ }}
293
+ />
294
+ );
295
+ }
296
+
297
+ /** Stable ids for the "/" listbox and its options. */
298
+ export function useSlashIds(): { listboxId: string; optionId: (index: number) => string } {
299
+ const base = useId().replace(/:/g, '');
300
+ return { listboxId: `${base}-insert`, optionId: (index) => `${base}-insert-${String(index)}` };
301
+ }