@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,198 @@
1
+ import {
2
+ humanize,
3
+ jsxClosing,
4
+ jsxOpening,
5
+ markdocClosing,
6
+ markdocOpening,
7
+ type BodyFormat,
8
+ type FieldModel,
9
+ type Manifest,
10
+ } from '@vespercms/core';
11
+
12
+ /*
13
+ * What the writing view knows how to show as a block (spec §6.5.1, §6.5.2):
14
+ * the site's Markdoc tags, Markdoc's own `partial` and `if`, and declared
15
+ * MDX components. Anything else is a protected source chip.
16
+ */
17
+
18
+ export type BlockShape = 'container' | 'card' | 'inline';
19
+
20
+ export type BlockKind = {
21
+ /** `markdoc:callout`, `mdx:Callout`. */
22
+ key: string;
23
+ syntax: 'markdoc' | 'mdx';
24
+ name: string;
25
+ label: string;
26
+ summary?: string;
27
+ description?: string;
28
+ /** How a new one is inserted at a block position, or null when it can only go inline. */
29
+ block: Exclude<BlockShape, 'inline'> | null;
30
+ /** Whether it can go inside a line of text. */
31
+ inline: boolean;
32
+ /** Written without content (`/%}`, `/>`). */
33
+ selfClosing: boolean;
34
+ /** Markdoc's allowed child node types (containers); absent means anything. */
35
+ children?: string[];
36
+ /** The settings form. */
37
+ fields: FieldModel[];
38
+ /** Attribute order, for placing new attributes and writing new tags. */
39
+ order: string[];
40
+ /** Built-in constructs the studio handles itself: partials, conditions and pipe tables. */
41
+ builtin?: 'partial' | 'if' | 'else' | 'table';
42
+ };
43
+
44
+ export type Catalog = {
45
+ format: BodyFormat;
46
+ kinds: Map<string, BlockKind>;
47
+ /** The kind for a tag or component name in this body's syntax. */
48
+ find: (syntax: 'markdoc' | 'mdx', name: string) => BlockKind | undefined;
49
+ };
50
+
51
+ const PARTIAL_FIELD: FieldModel = {
52
+ path: ['file'],
53
+ kind: 'string',
54
+ required: true,
55
+ label: 'File',
56
+ description: 'The partial’s file, shown here as part of this page.',
57
+ };
58
+
59
+ export function catalogOf(manifest: Manifest, format: BodyFormat): Catalog {
60
+ const kinds = new Map<string, BlockKind>();
61
+ if (format === 'mdoc') {
62
+ for (const tag of manifest.markdoc?.tags ?? []) {
63
+ const block: BlockKind['block'] =
64
+ tag.inline === true ? null : tag.selfClosing ? 'card' : 'container';
65
+ // A tag that doesn't say may go either way in Markdoc. Offered inline,
66
+ // it's one that neither holds blocks nor stands alone as a card.
67
+ const inline =
68
+ tag.inline === true || (tag.inline === undefined && !tag.selfClosing && !tag.children);
69
+ kinds.set(`markdoc:${tag.name}`, {
70
+ key: `markdoc:${tag.name}`,
71
+ syntax: 'markdoc',
72
+ name: tag.name,
73
+ label: tag.label,
74
+ ...(tag.summary ? { summary: tag.summary } : {}),
75
+ ...(tag.description ? { description: tag.description } : {}),
76
+ block,
77
+ inline,
78
+ selfClosing: tag.selfClosing,
79
+ ...(tag.children ? { children: tag.children } : {}),
80
+ fields: tag.fields,
81
+ order: tag.attributes.map((a) => a.name),
82
+ });
83
+ }
84
+ if (!kinds.has('markdoc:partial')) {
85
+ kinds.set('markdoc:partial', {
86
+ key: 'markdoc:partial',
87
+ syntax: 'markdoc',
88
+ name: 'partial',
89
+ label: 'Partial',
90
+ summary: 'file',
91
+ description: 'Text kept in its own file and shown here.',
92
+ block: 'card',
93
+ inline: false,
94
+ selfClosing: true,
95
+ fields: [PARTIAL_FIELD],
96
+ order: ['file', 'variables'],
97
+ builtin: 'partial',
98
+ });
99
+ }
100
+ kinds.set('markdoc:if', {
101
+ key: 'markdoc:if',
102
+ syntax: 'markdoc',
103
+ name: 'if',
104
+ label: 'Conditional content',
105
+ block: 'container',
106
+ inline: false,
107
+ selfClosing: false,
108
+ fields: [],
109
+ order: [],
110
+ builtin: 'if',
111
+ });
112
+ kinds.set('markdoc:else', {
113
+ key: 'markdoc:else',
114
+ syntax: 'markdoc',
115
+ name: 'else',
116
+ label: 'Otherwise',
117
+ block: 'card',
118
+ inline: false,
119
+ selfClosing: true,
120
+ fields: [],
121
+ order: [],
122
+ builtin: 'else',
123
+ });
124
+ }
125
+ if (format === 'mdx') {
126
+ for (const component of manifest.mdx?.components ?? []) {
127
+ kinds.set(`mdx:${component.name}`, {
128
+ key: `mdx:${component.name}`,
129
+ syntax: 'mdx',
130
+ name: component.name,
131
+ label: component.label,
132
+ ...(component.summary ? { summary: component.summary } : {}),
133
+ ...(component.description ? { description: component.description } : {}),
134
+ block: component.inline ? null : component.children ? 'container' : 'card',
135
+ inline: component.inline,
136
+ selfClosing: !component.children,
137
+ fields: component.fields ?? [],
138
+ order: (component.fields ?? []).map((f) => f.path[0] ?? ''),
139
+ });
140
+ }
141
+ }
142
+ return {
143
+ format,
144
+ kinds,
145
+ find: (syntax, name) => kinds.get(`${syntax}:${name}`),
146
+ };
147
+ }
148
+
149
+ /** Kinds a person can insert (built-in conditions are protected, so they aren't offered). */
150
+ /**
151
+ * A pipe table, insertable in every format (PLAN.md, call T4). It isn't one
152
+ * of the catalog's kinds, so Markdoc's own `{% table %}` is never taken for it.
153
+ */
154
+ export const TABLE_KIND: BlockKind = {
155
+ key: 'table',
156
+ syntax: 'markdoc',
157
+ name: 'table',
158
+ label: 'Table',
159
+ block: 'card',
160
+ inline: false,
161
+ selfClosing: true,
162
+ fields: [],
163
+ order: [],
164
+ builtin: 'table',
165
+ };
166
+
167
+ export function insertable(catalog: Catalog): BlockKind[] {
168
+ return [
169
+ ...[...catalog.kinds.values()].filter(
170
+ (kind) => kind.builtin !== 'if' && kind.builtin !== 'else',
171
+ ),
172
+ TABLE_KIND,
173
+ ];
174
+ }
175
+
176
+ /** A new tag's text in house style: attributes in schema order, empty and default values left out. */
177
+ export function newTagText(
178
+ kind: BlockKind,
179
+ attributes: Readonly<Record<string, unknown>>,
180
+ shape: BlockShape,
181
+ inner = '',
182
+ ): { open: string; close: string; whole: string } {
183
+ const pairs = kind.order.map((name) => [name, attributes[name]] as const);
184
+ const selfClosing = shape === 'card' || (shape === 'inline' && kind.selfClosing);
185
+ if (kind.syntax === 'markdoc') {
186
+ const open = markdocOpening(kind.name, pairs, selfClosing);
187
+ const close = selfClosing ? '' : markdocClosing(kind.name);
188
+ return { open, close, whole: shape === 'inline' ? open + inner + close : open };
189
+ }
190
+ const open = jsxOpening(kind.name, pairs, selfClosing);
191
+ const close = selfClosing ? '' : jsxClosing(kind.name);
192
+ return { open, close, whole: shape === 'inline' ? open + inner + close : open };
193
+ }
194
+
195
+ /** A friendly name for a tag nobody described: the humanized name, or the component's own. */
196
+ export function fallbackLabel(syntax: 'markdoc' | 'mdx', name: string): string {
197
+ return syntax === 'mdx' ? name : humanize(name);
198
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ issuesFromZod,
3
+ markdocConfigFromManifest,
4
+ partialChecker,
5
+ validateMarkdocBody,
6
+ validateMdxBody,
7
+ type BodyFormat,
8
+ type BodyIssue,
9
+ type Manifest,
10
+ type MdxComponentCheck,
11
+ } from '@vespercms/core';
12
+
13
+ /*
14
+ * Body validation for the studio (spec §6.5.1, "Validation"): Markdoc with
15
+ * the config rebuilt from the manifest (the site's real config checks every
16
+ * save again, in the dev API), and MDX props with the components' own Zod
17
+ * schemas. Markdoc, micromark and Zod are large, so this module, like the
18
+ * body editor, loads the first time an entry with a body is opened.
19
+ */
20
+
21
+ export type BodyChecks = {
22
+ /** Validates a whole body, or one block's text, as the site's build would. */
23
+ validate: (text: string) => Promise<BodyIssue[]>;
24
+ /** The same, synchronously, for Markdoc (MDX props schemas load on first use). */
25
+ validateSync: (text: string) => BodyIssue[] | null;
26
+ };
27
+
28
+ type ZodResult = { success: boolean; error?: { issues: unknown[] } };
29
+ type PropsSchema = { safeParseAsync?: (value: unknown, params?: object) => Promise<ZodResult> };
30
+
31
+ export function bodyChecks(options: {
32
+ manifest: Manifest;
33
+ format: BodyFormat;
34
+ /** Declared MDX components' Zod props schemas, loaded when first needed. */
35
+ loadMdxProps: () => Promise<Readonly<Record<string, unknown>>>;
36
+ partials: readonly string[] | null;
37
+ entryPath: string;
38
+ }): BodyChecks {
39
+ const { manifest, format, partials, entryPath } = options;
40
+ if (format === 'mdoc') {
41
+ const config = markdocConfigFromManifest(manifest.markdoc);
42
+ const run = (text: string) =>
43
+ validateMarkdocBody(text, {
44
+ config,
45
+ ...(manifest.markdoc
46
+ ? { parse: manifest.markdoc.options, tags: manifest.markdoc.tags }
47
+ : {}),
48
+ ...(partials ? { partialExists: partialChecker(entryPath, partials) } : {}),
49
+ });
50
+ return { validate: (text) => Promise.resolve(run(text)), validateSync: run };
51
+ }
52
+ if (format === 'mdx' && (manifest.mdx?.components.length ?? 0) > 0) {
53
+ let checks: Promise<Map<string, MdxComponentCheck>> | null = null;
54
+ const load = () => {
55
+ checks ??= options.loadMdxProps().then((schemas) => {
56
+ const map = new Map<string, MdxComponentCheck>();
57
+ for (const component of manifest.mdx?.components ?? []) {
58
+ const schema = schemas[component.name] as PropsSchema | undefined;
59
+ const parse = schema?.safeParseAsync?.bind(schema);
60
+ if (!parse) continue;
61
+ map.set(component.name, {
62
+ label: component.label,
63
+ fields: component.fields,
64
+ validate: async (props) => {
65
+ const result = await parse(props, { reportInput: true });
66
+ return result.success ? [] : issuesFromZod(result.error?.issues ?? []);
67
+ },
68
+ });
69
+ }
70
+ return map;
71
+ });
72
+ return checks;
73
+ };
74
+ return {
75
+ validate: async (text) => {
76
+ const map = await load();
77
+ return map.size > 0 ? validateMdxBody(text, map) : [];
78
+ },
79
+ validateSync: () => null,
80
+ };
81
+ }
82
+ return { validate: () => Promise.resolve([]), validateSync: () => [] };
83
+ }
@@ -0,0 +1,150 @@
1
+ import { jsxTagLayout, markdocTags, type InlineChip } from '@vespercms/core';
2
+
3
+ /*
4
+ * Inline chips (an inline tag, a variable, an annotation, inline JSX) inside
5
+ * Markdown blocks. The Markdown parser and serializer see each chip as one
6
+ * private-use placeholder holding the chip's source as hex, so marks can
7
+ * span chips and nothing ever escapes or rewraps them. After serializing,
8
+ * placeholders turn back into each chip's current source.
9
+ */
10
+
11
+ const OPEN = '';
12
+ const CLOSE = '';
13
+ const PLACEHOLDER = /([0-9a-f]*)/g;
14
+ const encoder = new TextEncoder();
15
+ const decoder = new TextDecoder();
16
+
17
+ export function chipPlaceholder(source: string): string {
18
+ let hex = '';
19
+ for (const byte of encoder.encode(source)) hex += byte.toString(16).padStart(2, '0');
20
+ return `${OPEN}${hex}${CLOSE}`;
21
+ }
22
+
23
+ export function decodeHex(hex: string): string {
24
+ const bytes = new Uint8Array(hex.length / 2);
25
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
26
+ return decoder.decode(bytes);
27
+ }
28
+
29
+ /** Text with every placeholder replaced by the chip source it holds. */
30
+ export function restoreChips(text: string): string {
31
+ return text.replace(PLACEHOLDER, (_, hex: string) => decodeHex(hex));
32
+ }
33
+
34
+ /** A block's text with its chips (offsets relative to the text) replaced by placeholders. */
35
+ export function withPlaceholders(
36
+ text: string,
37
+ chips: readonly { start: number; end: number }[],
38
+ ): string {
39
+ let out = '';
40
+ let cursor = 0;
41
+ for (const chip of chips) {
42
+ out += text.slice(cursor, chip.start) + chipPlaceholder(text.slice(chip.start, chip.end));
43
+ cursor = chip.end;
44
+ }
45
+ return out + text.slice(cursor);
46
+ }
47
+
48
+ /** True when the text holds a private-use character the placeholders rely on. */
49
+ export function hasPlaceholderCharacters(text: string): boolean {
50
+ return text.includes(OPEN) || text.includes(CLOSE);
51
+ }
52
+
53
+ export type TagValues = {
54
+ /** Attribute values the settings form can edit. */
55
+ values: Record<string, unknown>;
56
+ /** Attributes set by code (variables, functions, JSX expressions): shown, never edited. */
57
+ code: Record<string, string>;
58
+ };
59
+
60
+ /** A tag's attributes, read from its opening (Markdoc) or its opening or whole element (MDX). */
61
+ export function tagValues(syntax: 'markdoc' | 'mdx', text: string): TagValues {
62
+ const values: Record<string, unknown> = {};
63
+ const code: Record<string, string> = {};
64
+ if (syntax === 'markdoc') {
65
+ const [token] = markdocTags(text);
66
+ for (const attribute of token?.attributes ?? []) {
67
+ if (attribute.type !== 'attribute' || attribute.name === 'primary') continue;
68
+ if (isMarkdocCode(attribute.value)) code[attribute.name] = describeCode(attribute.value);
69
+ else values[attribute.name] = attribute.value;
70
+ }
71
+ return { values, code };
72
+ }
73
+ const layout = jsxTagLayout(text);
74
+ for (const attribute of layout?.attributes ?? []) {
75
+ if (!attribute.name) continue;
76
+ if (attribute.readable) values[attribute.name] = attribute.value;
77
+ else code[attribute.name] = text.slice(attribute.start, attribute.end);
78
+ }
79
+ return { values, code };
80
+ }
81
+
82
+ function isMarkdocCode(value: unknown): boolean {
83
+ if (typeof value !== 'object' || value === null) return false;
84
+ if ('$$mdtype' in value) return true;
85
+ return Object.values(value).some(isMarkdocCode);
86
+ }
87
+
88
+ function describeCode(value: unknown): string {
89
+ const node = value as { $$mdtype?: string; path?: unknown[]; name?: string };
90
+ if (node.$$mdtype === 'Variable' && Array.isArray(node.path)) return `$${node.path.join('.')}`;
91
+ if (node.$$mdtype === 'Function' && node.name) return `${node.name}(…)`;
92
+ return 'a value set by code';
93
+ }
94
+
95
+ export type ChipInfo = {
96
+ syntax: 'markdoc' | 'mdx';
97
+ kind: InlineChip['kind'];
98
+ name?: string;
99
+ /** For tags and components: the opening, the content between, and the closing. */
100
+ open?: string;
101
+ inner?: string;
102
+ close?: string;
103
+ };
104
+
105
+ /** What a chip's source is: its kind, name and parts. */
106
+ export function chipInfo(source: string): ChipInfo {
107
+ if (source.startsWith('{%')) {
108
+ const tokens = markdocTags(source);
109
+ const first = tokens[0];
110
+ const last = tokens.at(-1);
111
+ if (!first) return { syntax: 'markdoc', kind: 'variable' };
112
+ if (first.kind === 'variable') return { syntax: 'markdoc', kind: 'variable' };
113
+ if (first.kind === 'annotation') return { syntax: 'markdoc', kind: 'annotation' };
114
+ const name = first.name;
115
+ if (first.kind === 'tag') {
116
+ return {
117
+ syntax: 'markdoc',
118
+ kind: 'tag',
119
+ ...(name ? { name } : {}),
120
+ open: source,
121
+ inner: '',
122
+ close: '',
123
+ };
124
+ }
125
+ const closeStart =
126
+ last && last.kind === 'tag_close' && last.end === source.length ? last.start : source.length;
127
+ return {
128
+ syntax: 'markdoc',
129
+ kind: 'tag',
130
+ ...(name ? { name } : {}),
131
+ open: source.slice(0, first.end),
132
+ inner: source.slice(first.end, closeStart),
133
+ close: source.slice(closeStart),
134
+ };
135
+ }
136
+ if (source.startsWith('<')) {
137
+ const layout = jsxTagLayout(source);
138
+ if (!layout) return { syntax: 'mdx', kind: 'component' };
139
+ const close = layout.selfClosing ? '' : (/<\/[^>]*>$/.exec(source)?.[0] ?? '');
140
+ return {
141
+ syntax: 'mdx',
142
+ kind: 'component',
143
+ name: layout.name,
144
+ open: source.slice(0, layout.openEnd),
145
+ inner: source.slice(layout.openEnd, source.length - close.length),
146
+ close,
147
+ };
148
+ }
149
+ return { syntax: 'mdx', kind: 'expression' };
150
+ }
@@ -0,0 +1,191 @@
1
+ import type { Editor, JSONContent } from '@tiptap/core';
2
+ import { Fragment } from '@tiptap/pm/model';
3
+ import { NodeSelection, TextSelection, type EditorState } from '@tiptap/pm/state';
4
+ import type { BlockKind, BlockShape } from './catalog.ts';
5
+ import { newTagText } from './catalog.ts';
6
+ import type { ContainerTypes } from './nodes.ts';
7
+ import { newTableValue } from './table.ts';
8
+
9
+ /*
10
+ * Keyboard-reachable block operations (spec §6.5.1, "Accessibility of
11
+ * blocks"): every tag can be inserted, configured, moved and removed
12
+ * without a mouse.
13
+ */
14
+
15
+ /** Node types that are blocks the studio manages: tags, components and protected blocks. */
16
+ export function isManaged(type: string, containers: ContainerTypes): boolean {
17
+ return (
18
+ type === 'vesperCard' ||
19
+ type === 'vesperInline' ||
20
+ type === 'vesperTable' ||
21
+ type === 'sourceBlock' ||
22
+ containers.kindOf(type) !== undefined
23
+ );
24
+ }
25
+
26
+ /** The managed node the selection is on (a node selection), or null. */
27
+ export function selectedNode(
28
+ editor: Editor,
29
+ containers: ContainerTypes,
30
+ ): { pos: number; type: string } | null {
31
+ const { selection } = editor.state;
32
+ if (selection instanceof NodeSelection && isManaged(selection.node.type.name, containers)) {
33
+ return { pos: selection.from, type: selection.node.type.name };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** The innermost container the cursor is inside, or null. */
39
+ export function enclosingContainer(editor: Editor, containers: ContainerTypes): number | null {
40
+ const { $from } = editor.state.selection;
41
+ for (let depth = $from.depth; depth > 0; depth--) {
42
+ if (containers.kindOf($from.node(depth).type.name)) return $from.before(depth);
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Selects the block at `pos` as a whole, so its frame shows focus and Enter opens its settings. */
48
+ export function selectBlock(editor: Editor, pos: number): void {
49
+ const tr = editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, pos));
50
+ editor.view.dispatch(tr.scrollIntoView());
51
+ editor.view.focus();
52
+ }
53
+
54
+ /** Moves the block at `pos` past its previous or next sibling. Returns its new position, or null. */
55
+ export function moveBlock(editor: Editor, pos: number, direction: -1 | 1): number | null {
56
+ const { state } = editor;
57
+ const $pos = state.doc.resolve(pos);
58
+ const node = state.doc.nodeAt(pos);
59
+ if (!node || node.isInline) return null;
60
+ const parent = $pos.parent;
61
+ const index = $pos.index();
62
+ const sibling = index + direction;
63
+ if (sibling < 0 || sibling >= parent.childCount) return null;
64
+ const other = parent.child(sibling);
65
+ const tr = state.tr;
66
+ let target: number;
67
+ if (direction === -1) {
68
+ const otherPos = pos - other.nodeSize;
69
+ tr.delete(pos, pos + node.nodeSize).insert(otherPos, node);
70
+ target = otherPos;
71
+ } else {
72
+ tr.delete(pos, pos + node.nodeSize);
73
+ const after = pos + other.nodeSize;
74
+ tr.insert(after, node);
75
+ target = after;
76
+ }
77
+ tr.setSelection(NodeSelection.create(tr.doc, target)).scrollIntoView();
78
+ editor.view.dispatch(tr);
79
+ editor.view.focus();
80
+ return target;
81
+ }
82
+
83
+ /** Removes the block or chip at `pos`, leaving the cursor where it was. */
84
+ export function removeBlock(editor: Editor, pos: number): void {
85
+ const node = editor.state.doc.nodeAt(pos);
86
+ if (!node) return;
87
+ const tr = editor.state.tr.delete(pos, pos + node.nodeSize);
88
+ const at = Math.min(pos, tr.doc.content.size);
89
+ tr.setSelection(TextSelection.near(tr.doc.resolve(at)));
90
+ editor.view.dispatch(tr.scrollIntoView());
91
+ editor.view.focus();
92
+ }
93
+
94
+ /** The editor node a new block of `kind` starts as. */
95
+ export function newNode(
96
+ kind: BlockKind,
97
+ shape: BlockShape,
98
+ containers: ContainerTypes,
99
+ attributes: Readonly<Record<string, unknown>> = {},
100
+ inner = '',
101
+ ): JSONContent {
102
+ if (kind.builtin === 'table') {
103
+ return { type: 'vesperTable', attrs: { value: newTableValue() } };
104
+ }
105
+ const text = newTagText(kind, attributes, shape, inner);
106
+ if (shape === 'inline') return { type: 'vesperInline', attrs: { source: text.whole } };
107
+ const container = shape === 'container' ? containers.typeOf(kind) : undefined;
108
+ if (container) {
109
+ return {
110
+ type: container,
111
+ attrs: { syntax: kind.syntax, name: kind.name, open: text.open, close: text.close },
112
+ content: [{ type: 'paragraph' }],
113
+ };
114
+ }
115
+ return {
116
+ type: 'vesperCard',
117
+ attrs: { syntax: kind.syntax, name: kind.name, source: text.whole },
118
+ };
119
+ }
120
+
121
+ /** Where a kind can go at the cursor: as a block, inline, or not at all. Reads only the state. */
122
+ export function placementAt(
123
+ state: EditorState,
124
+ kind: BlockKind,
125
+ containers: ContainerTypes,
126
+ ): BlockShape | null {
127
+ const { $from } = state.selection;
128
+ const block = $from.parent;
129
+ if (!block.isTextblock) return null;
130
+ // An empty line (or one holding only the "/" being typed) takes a block; a line of text, a chip.
131
+ const emptyLine = block.type.name === 'paragraph' && /^\/?[^\s]*$/.test(block.textContent);
132
+ // Asked of the schema directly: inserting through ProseMirror would make
133
+ // a block "fit" by splitting the frame it's in, which isn't what fits here.
134
+ const fitsBlock = (shape: BlockShape) => {
135
+ const depth = $from.depth - 1;
136
+ if (depth < 0) return false;
137
+ const node = state.schema.nodeFromJSON(newNode(kind, shape, containers));
138
+ const parent = $from.node(depth);
139
+ const index = $from.index(depth);
140
+ return emptyLine
141
+ ? parent.canReplace(index, index + 1, Fragment.from(node))
142
+ : parent.canReplaceWith(index + 1, index + 1, node.type);
143
+ };
144
+ if (kind.block && (emptyLine || !kind.inline) && fitsBlock(kind.block)) return kind.block;
145
+ const chip = state.schema.nodes.vesperInline;
146
+ if (kind.inline && chip && block.canReplaceWith($from.index(), $from.index(), chip)) {
147
+ return 'inline';
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /**
153
+ * Inserts a new block or chip of `kind` at the cursor (replacing `range`,
154
+ * the "/" text, when given) and returns its position.
155
+ */
156
+ export function insertBlock(
157
+ editor: Editor,
158
+ kind: BlockKind,
159
+ shape: BlockShape,
160
+ containers: ContainerTypes,
161
+ range?: { from: number; to: number },
162
+ attributes: Readonly<Record<string, unknown>> = {},
163
+ ): number | null {
164
+ const node = newNode(kind, shape, containers, attributes);
165
+ let chain = editor.chain().focus();
166
+ if (range) chain = chain.deleteRange(range);
167
+ const before = range ? range.from : editor.state.selection.from;
168
+ if (!chain.insertContent(node).run()) return null;
169
+ // Find the node just inserted: the first of its type at or after where the cursor was.
170
+ const found: { pos: number | null } = { pos: null };
171
+ editor.state.doc.nodesBetween(
172
+ Math.max(0, before - 2),
173
+ editor.state.doc.content.size,
174
+ (child, pos) => {
175
+ if (found.pos !== null) return false;
176
+ if (
177
+ child.type.name === node.type &&
178
+ JSON.stringify(child.attrs.source ?? child.attrs.open) ===
179
+ JSON.stringify(node.attrs?.source ?? node.attrs?.open)
180
+ ) {
181
+ found.pos = pos;
182
+ return false;
183
+ }
184
+ return true;
185
+ },
186
+ );
187
+ if (found.pos !== null && shape === 'container') {
188
+ editor.commands.setTextSelection(found.pos + 2);
189
+ }
190
+ return found.pos;
191
+ }