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

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 (203) hide show
  1. package/README.md +119 -2
  2. package/dist/BodyEditor-CAGNfofv.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-KUkv11EB.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 +37 -0
  79. package/dist/screens/EntryEditor.d.ts +13 -0
  80. package/dist/screens/EntryList.d.ts +18 -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/shortcuts-B_JMAvF9.js +4630 -0
  89. package/dist/studio.css +3 -0
  90. package/dist/studio.js +6312 -0
  91. package/dist/types.d.ts +76 -0
  92. package/dist/widgets/basic.d.ts +25 -0
  93. package/dist/widgets/crops.d.ts +43 -0
  94. package/dist/widgets/focal-point.d.ts +15 -0
  95. package/dist/widgets/geo-map.d.ts +10 -0
  96. package/dist/widgets/geo.d.ts +16 -0
  97. package/dist/widgets/group.d.ts +41 -0
  98. package/dist/widgets/icon.d.ts +9 -0
  99. package/dist/widgets/image.d.ts +16 -0
  100. package/dist/widgets/markdown-lazy.d.ts +2 -0
  101. package/dist/widgets/markdown.d.ts +2 -0
  102. package/dist/widgets/reference.d.ts +12 -0
  103. package/dist/widgets/registry.d.ts +32 -0
  104. package/dist/widgets/special.d.ts +20 -0
  105. package/dist/widgets/union.d.ts +36 -0
  106. package/package.json +63 -4
  107. package/src/a11y/FieldNotes.tsx +56 -0
  108. package/src/a11y/NotesDrawer.tsx +81 -0
  109. package/src/a11y/pdf.ts +45 -0
  110. package/src/a11y/run.ts +12 -0
  111. package/src/a11y/useNotes.ts +139 -0
  112. package/src/app/App.tsx +156 -0
  113. package/src/app/Screen.tsx +416 -0
  114. package/src/app/UserMenu.tsx +95 -0
  115. package/src/app/api.ts +176 -0
  116. package/src/app/appearance.ts +54 -0
  117. package/src/app/browser-api.ts +173 -0
  118. package/src/app/collection-icon.ts +15 -0
  119. package/src/app/context.tsx +32 -0
  120. package/src/app/entries-changed.ts +48 -0
  121. package/src/app/file-url.tsx +44 -0
  122. package/src/app/format.ts +39 -0
  123. package/src/app/idb.ts +82 -0
  124. package/src/app/load.ts +40 -0
  125. package/src/app/media.ts +20 -0
  126. package/src/app/navigation.ts +4 -0
  127. package/src/app/pane-context.ts +25 -0
  128. package/src/app/panes.tsx +513 -0
  129. package/src/app/router.ts +158 -0
  130. package/src/body/BodyEditor.tsx +1139 -0
  131. package/src/body/TableView.tsx +700 -0
  132. package/src/body/catalog.ts +198 -0
  133. package/src/body/checks.ts +83 -0
  134. package/src/body/chips.ts +150 -0
  135. package/src/body/commands.ts +191 -0
  136. package/src/body/document.ts +388 -0
  137. package/src/body/insert.tsx +301 -0
  138. package/src/body/nodes.ts +287 -0
  139. package/src/body/reveal.ts +93 -0
  140. package/src/body/scroll.ts +53 -0
  141. package/src/body/settings.tsx +510 -0
  142. package/src/body/shortcuts.ts +31 -0
  143. package/src/body/table.ts +192 -0
  144. package/src/body/ui.ts +77 -0
  145. package/src/body/views.tsx +268 -0
  146. package/src/build.ts +6 -0
  147. package/src/cloud/CloudGate.tsx +245 -0
  148. package/src/cloud/access.ts +52 -0
  149. package/src/cloud/changes.tsx +474 -0
  150. package/src/cloud/connection.ts +281 -0
  151. package/src/cloud/editor.tsx +250 -0
  152. package/src/cloud/session.ts +289 -0
  153. package/src/cloud/storage.ts +212 -0
  154. package/src/editor/Field.tsx +182 -0
  155. package/src/editor/PagePicker.tsx +102 -0
  156. package/src/editor/arrange.ts +98 -0
  157. package/src/editor/context.tsx +31 -0
  158. package/src/editor/copy.ts +30 -0
  159. package/src/editor/reveal.ts +38 -0
  160. package/src/editor/target.ts +104 -0
  161. package/src/editor/values.ts +71 -0
  162. package/src/github/GitHubGate.tsx +261 -0
  163. package/src/github/api.ts +81 -0
  164. package/src/github/cache.ts +64 -0
  165. package/src/github/session.ts +146 -0
  166. package/src/index.ts +10 -0
  167. package/src/media/MediaPicker.tsx +302 -0
  168. package/src/media/file-icon.ts +17 -0
  169. package/src/media/formats.ts +262 -0
  170. package/src/media/hygiene.ts +220 -0
  171. package/src/media/upload.ts +47 -0
  172. package/src/mount.tsx +20 -0
  173. package/src/schedule/ScheduleDialog.tsx +300 -0
  174. package/src/schedule/ScheduleSummary.tsx +59 -0
  175. package/src/schedule/ScheduledView.tsx +294 -0
  176. package/src/schedule/cloud.ts +66 -0
  177. package/src/schedule/now.ts +18 -0
  178. package/src/schedule/times.ts +133 -0
  179. package/src/screens/EntryEditor.tsx +1803 -0
  180. package/src/screens/EntryList.tsx +274 -0
  181. package/src/screens/Home.tsx +131 -0
  182. package/src/screens/ListPane.tsx +459 -0
  183. package/src/screens/MediaBrowser.tsx +253 -0
  184. package/src/screens/NotFound.tsx +19 -0
  185. package/src/screens/PageTree.tsx +718 -0
  186. package/src/screens/Redirects.tsx +522 -0
  187. package/src/screens/Unavailable.tsx +27 -0
  188. package/src/studio.css +163 -0
  189. package/src/types.ts +82 -0
  190. package/src/widgets/basic.tsx +311 -0
  191. package/src/widgets/crops.tsx +399 -0
  192. package/src/widgets/focal-point.tsx +240 -0
  193. package/src/widgets/geo-map.tsx +160 -0
  194. package/src/widgets/geo.tsx +170 -0
  195. package/src/widgets/group.tsx +827 -0
  196. package/src/widgets/icon.tsx +47 -0
  197. package/src/widgets/image.tsx +407 -0
  198. package/src/widgets/markdown-lazy.tsx +22 -0
  199. package/src/widgets/markdown.tsx +267 -0
  200. package/src/widgets/reference.tsx +196 -0
  201. package/src/widgets/registry.ts +104 -0
  202. package/src/widgets/special.tsx +286 -0
  203. package/src/widgets/union.tsx +233 -0
@@ -0,0 +1,510 @@
1
+ import type { Editor } from '@tiptap/core';
2
+ import { useEditorState } from '@tiptap/react';
3
+ import {
4
+ deepEqual,
5
+ editJsxTag,
6
+ editMarkdocTag,
7
+ markdocTagLayout,
8
+ markdocTags,
9
+ jsxTagLayout,
10
+ type DataObject,
11
+ type DataValue,
12
+ type FieldModel,
13
+ } from '@vespercms/core';
14
+ import { Button, Dialog, Select, TextArea, TextField } from '@vespercms/ui';
15
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
16
+ import { EditorContext, useEditor, type EditorState } from '../editor/context.tsx';
17
+ import { Field } from '../editor/Field.tsx';
18
+ import { pathKey, setIn, type ValuePath } from '../editor/values.ts';
19
+ import { fallbackLabel, type BlockKind } from './catalog.ts';
20
+ import { chipInfo, tagValues } from './chips.ts';
21
+ import { sourceLabel } from './document.ts';
22
+ import { useBlockIssues, useBodyUi } from './ui.ts';
23
+
24
+ /*
25
+ * A block's settings (spec §6.5.1, "Settings form"): its attributes as
26
+ * fields, rendered with the same widgets as frontmatter. Changes apply as
27
+ * they're made, splicing only the attribute that changed into the tag's
28
+ * text, so Ctrl+Z undoes them like typing. The same dialog moves and
29
+ * removes the block, so one keystroke (Enter on a selected block) reaches
30
+ * every action.
31
+ *
32
+ * Protected constructs (variables, conditions, unknown tags, raw HTML) get
33
+ * a small text field instead: edited as written, never reformatted.
34
+ */
35
+
36
+ /** A node attribute that holds text. */
37
+ const attrText = (value: unknown): string => (typeof value === 'string' ? value : '');
38
+
39
+ type Target = {
40
+ pos: number;
41
+ type: string;
42
+ attrs: Record<string, unknown>;
43
+ inline: boolean;
44
+ };
45
+
46
+ export function BlockSettings({
47
+ editor,
48
+ pos,
49
+ onClose,
50
+ }: {
51
+ editor: Editor;
52
+ pos: number | null;
53
+ onClose: () => void;
54
+ }) {
55
+ const ui = useBodyUi();
56
+ // Follow the node as the document changes (its attributes, or a move).
57
+ const target = useEditorState({
58
+ editor,
59
+ selector: ({ editor: e }): Target | null => {
60
+ if (pos === null) return null;
61
+ const node = e.state.doc.nodeAt(pos);
62
+ if (!node) return null;
63
+ return { pos, type: node.type.name, attrs: node.attrs, inline: node.isInline };
64
+ },
65
+ equalityFn: (a, b) => JSON.stringify(a) === JSON.stringify(b),
66
+ });
67
+ const view = target ? describe(target, ui) : null;
68
+ return (
69
+ <Dialog
70
+ title={view?.title ?? 'Settings'}
71
+ isOpen={pos !== null && target !== null}
72
+ onOpenChange={(open) => {
73
+ if (!open) onClose();
74
+ }}
75
+ >
76
+ {target && view && (
77
+ <FirstFieldFocus className="flex flex-col gap-6">
78
+ {view.mode === 'tag' && view.kind && (
79
+ <TagForm editor={editor} target={target} kind={view.kind} />
80
+ )}
81
+ {view.mode === 'condition' && <ConditionForm editor={editor} target={target} />}
82
+ {view.mode === 'raw' && <RawForm editor={editor} target={target} label={view.label} />}
83
+ <Actions editor={editor} target={target} onClose={onClose} />
84
+ </FirstFieldFocus>
85
+ )}
86
+ </Dialog>
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Moves focus to the dialog's first field when it opens, so typing goes
92
+ * straight into it rather than to the dialog's Close button.
93
+ */
94
+ function FirstFieldFocus({ className, children }: { className: string; children: ReactNode }) {
95
+ const ref = useRef<HTMLDivElement>(null);
96
+ useEffect(() => {
97
+ // After the opener (a menu, say) has had its turn at restoring focus.
98
+ const timer = setTimeout(() => {
99
+ const first = ref.current?.querySelector<HTMLInputElement>(
100
+ 'input:not([type=hidden]):not([disabled]), textarea:not([disabled]), button:not([disabled])',
101
+ );
102
+ // In a radio group, focus belongs on the chosen option, as Tab would put it.
103
+ const checked =
104
+ first?.type === 'radio'
105
+ ? ref.current?.querySelector<HTMLInputElement>(
106
+ `input[type=radio][name="${CSS.escape(first.name)}"]:checked`,
107
+ )
108
+ : null;
109
+ (checked ?? first)?.focus();
110
+ }, 0);
111
+ return () => {
112
+ clearTimeout(timer);
113
+ };
114
+ }, []);
115
+ return (
116
+ <div ref={ref} className={className}>
117
+ {children}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ type View = { mode: 'tag' | 'condition' | 'raw'; title: string; label: string; kind?: BlockKind };
123
+
124
+ function describe(target: Target, ui: ReturnType<typeof useBodyUi>): View {
125
+ if (target.type === 'sourceBlock') {
126
+ const label = sourceLabel(attrText(target.attrs.blockType));
127
+ return { mode: 'raw', title: `Edit ${label.toLowerCase()} as text`, label };
128
+ }
129
+ const container = ui.containers.kindOf(target.type);
130
+ if (container) {
131
+ if (container.builtin === 'if')
132
+ return { mode: 'condition', title: 'Conditional content', label: container.label };
133
+ return {
134
+ mode: 'tag',
135
+ title: `${container.label} settings`,
136
+ label: container.label,
137
+ kind: container,
138
+ };
139
+ }
140
+ if (target.type === 'vesperCard') {
141
+ const syntax = target.attrs.syntax as 'markdoc' | 'mdx';
142
+ const name = attrText(target.attrs.name);
143
+ const kind = ui.catalog.find(syntax, name);
144
+ if (kind && !kind.builtin)
145
+ return { mode: 'tag', title: `${kind.label} settings`, label: kind.label, kind };
146
+ if (kind?.builtin === 'partial')
147
+ return { mode: 'tag', title: 'Partial settings', label: kind.label, kind };
148
+ const label = kind?.label ?? fallbackLabel(syntax, name);
149
+ return { mode: 'raw', title: `Edit ${label} as text`, label };
150
+ }
151
+ const info = chipInfo(attrText(target.attrs.source));
152
+ const kind = info.name ? ui.catalog.find(info.syntax, info.name) : undefined;
153
+ if (kind && !kind.builtin && (info.kind === 'tag' || info.kind === 'component')) {
154
+ return { mode: 'tag', title: `${kind.label} settings`, label: kind.label, kind };
155
+ }
156
+ const label =
157
+ info.kind === 'variable'
158
+ ? 'Variable'
159
+ : info.kind === 'annotation'
160
+ ? 'Annotation'
161
+ : info.kind === 'expression'
162
+ ? 'Expression'
163
+ : info.name
164
+ ? fallbackLabel(info.syntax, info.name)
165
+ : 'Tag';
166
+ return { mode: 'raw', title: `Edit ${label.toLowerCase()} as text`, label };
167
+ }
168
+
169
+ /** A node's tag text split as opening, the rest, and how to put a new opening back. */
170
+ function partsOf(target: Target): {
171
+ syntax: 'markdoc' | 'mdx';
172
+ open: string;
173
+ rest: string;
174
+ inner?: string;
175
+ close?: string;
176
+ } {
177
+ if (target.type === 'vesperInline') {
178
+ const source = attrText(target.attrs.source);
179
+ const info = chipInfo(source);
180
+ return {
181
+ syntax: info.syntax,
182
+ open: info.open ?? source,
183
+ rest: source.slice((info.open ?? source).length),
184
+ inner: info.inner ?? '',
185
+ close: info.close ?? '',
186
+ };
187
+ }
188
+ const syntax = target.attrs.syntax as 'markdoc' | 'mdx';
189
+ if (target.type === 'vesperCard') {
190
+ const source = attrText(target.attrs.source);
191
+ const end =
192
+ syntax === 'markdoc'
193
+ ? (markdocTags(source)[0]?.end ?? source.length)
194
+ : (jsxTagLayout(source)?.openEnd ?? source.length);
195
+ return { syntax, open: source.slice(0, end), rest: source.slice(end) };
196
+ }
197
+ return { syntax, open: attrText(target.attrs.open), rest: '' };
198
+ }
199
+
200
+ function setAttrs(editor: Editor, target: Target, attrs: Record<string, unknown>): void {
201
+ const tr = editor.state.tr.setNodeMarkup(target.pos, undefined, { ...target.attrs, ...attrs });
202
+ editor.view.dispatch(tr);
203
+ }
204
+
205
+ /** Writes a new opening back into the node it came from. */
206
+ function applyOpening(editor: Editor, target: Target, open: string): void {
207
+ const parts = partsOf(target);
208
+ if (target.type === 'vesperInline' || target.type === 'vesperCard') {
209
+ setAttrs(editor, target, { source: open + parts.rest });
210
+ } else setAttrs(editor, target, { open });
211
+ }
212
+
213
+ function TagForm({ editor, target, kind }: { editor: Editor; target: Target; kind: BlockKind }) {
214
+ const ui = useBodyUi();
215
+ const outer = useEditor();
216
+ const parts = partsOf(target);
217
+ const { values, code } = tagValues(parts.syntax, parts.open);
218
+ const [problem, setProblem] = useState<string | null>(null);
219
+ const fullText =
220
+ target.type === 'vesperInline'
221
+ ? attrText(target.attrs.source)
222
+ : target.type === 'vesperCard'
223
+ ? attrText(target.attrs.source)
224
+ : parts.syntax === 'mdx'
225
+ ? `${parts.open}\n\n${attrText(target.attrs.close)}`
226
+ : `${parts.open}\n${attrText(target.attrs.close)}`;
227
+ const issues = useBlockIssues(fullText);
228
+
229
+ const display = useMemo(() => {
230
+ const out: DataObject = {};
231
+ for (const field of kind.fields) {
232
+ const key = field.path[0] ?? '';
233
+ const value = key in values ? values[key] : field.default;
234
+ if (value !== undefined) out[key] = value as DataValue;
235
+ }
236
+ return out;
237
+ }, [kind.fields, values]);
238
+
239
+ const issueMap = useMemo(() => {
240
+ const map = new Map<string, string[]>();
241
+ for (const issue of issues) {
242
+ if (!issue.attribute || issue.level !== 'error') continue;
243
+ const key = pathKey([issue.attribute]);
244
+ // Under the field, the block's name would only repeat the dialog's title.
245
+ const message = issue.message.replace(/^[^:]+: /, '');
246
+ map.set(key, [...(map.get(key) ?? []), message]);
247
+ }
248
+ return map;
249
+ }, [issues]);
250
+
251
+ const fields =
252
+ kind.builtin === 'partial' ? kind.fields.filter((f) => f.path[0] !== 'file') : kind.fields;
253
+ const state: EditorState = {
254
+ ...outer,
255
+ values: display,
256
+ issues: issueMap,
257
+ disabled: ui.disabled,
258
+ idPrefix: `block${String(target.pos)}`,
259
+ setValue: (path: ValuePath, changed: DataValue) => {
260
+ const key = String(path[0]);
261
+ // A field inside a list or object attribute changes its part of the attribute's value.
262
+ const value = path.length > 1 ? (setIn(display, path, changed)[key] ?? null) : changed;
263
+ const field = kind.fields.find((f) => f.path[0] === key);
264
+ // A value set back to its default is left out of the tag (house style).
265
+ const next =
266
+ field?.default !== undefined && deepEqual(value, field.default) ? undefined : value;
267
+ const edit = parts.syntax === 'markdoc' ? editMarkdocTag : editJsxTag;
268
+ const edited = edit(parts.open, [[key, next]], kind.order);
269
+ if (edited === null) {
270
+ setProblem(
271
+ 'This tag is written in a way the form can’t change. Edit it as Markdoc instead.',
272
+ );
273
+ return;
274
+ }
275
+ setProblem(null);
276
+ applyOpening(editor, target, edited);
277
+ },
278
+ };
279
+
280
+ return (
281
+ <EditorContext.Provider value={state}>
282
+ {kind.description && <p className="text-small text-content-muted">{kind.description}</p>}
283
+ {problem && (
284
+ <p role="alert" className="text-small text-danger">
285
+ {problem}
286
+ </p>
287
+ )}
288
+ {kind.builtin === 'partial' && (
289
+ <PartialFile
290
+ value={typeof values.file === 'string' ? values.file : ''}
291
+ message={issues.find((i) => i.code === 'partial-missing')?.message}
292
+ onChange={(file) => {
293
+ const edited = editMarkdocTag(parts.open, [['file', file]], kind.order);
294
+ if (edited) applyOpening(editor, target, edited);
295
+ }}
296
+ />
297
+ )}
298
+ {fields.map((field: FieldModel) => (
299
+ <Field key={field.path.join('.')} field={field} path={field.path} />
300
+ ))}
301
+ {target.type === 'vesperInline' && parts.close !== '' && (
302
+ <TextField
303
+ label="Text"
304
+ description="What the reader sees."
305
+ value={parts.inner ?? ''}
306
+ isDisabled={ui.disabled}
307
+ onChange={(inner) => {
308
+ setAttrs(editor, target, { source: parts.open + inner + (parts.close ?? '') });
309
+ }}
310
+ />
311
+ )}
312
+ {Object.keys(code).length > 0 && (
313
+ <div className="text-small text-content-muted">
314
+ <p>Set in the page’s code, so kept as written:</p>
315
+ <ul className="list-disc pl-6">
316
+ {Object.entries(code).map(([key, text]) => (
317
+ <li key={key}>
318
+ <code className="font-mono">{key}</code>: <code className="font-mono">{text}</code>
319
+ </li>
320
+ ))}
321
+ </ul>
322
+ </div>
323
+ )}
324
+ </EditorContext.Provider>
325
+ );
326
+ }
327
+
328
+ /** The partial's file: one of the site's partials, stored relative to this entry. */
329
+ function PartialFile({
330
+ value,
331
+ message,
332
+ onChange,
333
+ }: {
334
+ value: string;
335
+ message: string | undefined;
336
+ onChange: (file: string) => void;
337
+ }) {
338
+ const ui = useBodyUi();
339
+ const options = partialOptions(ui.partials ?? [], ui.entryPath, value);
340
+ return (
341
+ <Select
342
+ label="File"
343
+ description="Text kept in its own file and shown here. Files whose names start with an underscore, or that sit in a partials folder, are listed."
344
+ options={options}
345
+ value={value === '' ? null : value}
346
+ onChange={(next) => {
347
+ if (next !== null) onChange(next);
348
+ }}
349
+ isInvalid={message !== undefined}
350
+ errorMessage={message?.replace(/^[^:]+: /, '')}
351
+ isRequired
352
+ isDisabled={ui.disabled}
353
+ />
354
+ );
355
+ }
356
+
357
+ /** Partial-like files, as paths relative to the entry (how the tag stores them), labelled by site path. */
358
+ export function partialOptions(
359
+ files: readonly string[],
360
+ entryPath: string,
361
+ current: string,
362
+ ): { value: string; label: string }[] {
363
+ const options = partialFiles(files, entryPath).map((path) => ({
364
+ value: relativePath(entryPath, path),
365
+ label: path,
366
+ }));
367
+ if (current !== '' && !options.some((o) => o.value === current)) {
368
+ options.unshift({ value: current, label: `${current} (as written)` });
369
+ }
370
+ return options;
371
+ }
372
+
373
+ /**
374
+ * Markdoc files named like partials (`_name.mdoc`, or in a `partials`
375
+ * folder), nearest the entry first: its own folder, then the folders above
376
+ * it, then everything else.
377
+ */
378
+ export function partialFiles(files: readonly string[], entryPath: string): string[] {
379
+ // How far a file is from the entry: the folders between them (`_x.mdoc` 0, `../_x.mdoc` 1).
380
+ const distance = (path: string) => relativePath(entryPath, path).split('/').length - 1;
381
+ return files
382
+ .filter((path) => path !== entryPath && path.endsWith('.mdoc'))
383
+ .filter((path) => /(^|\/)_[^/]+$/.test(path) || /(^|\/)_?partials\//.test(path))
384
+ .sort((a, b) => distance(a) - distance(b) || a.localeCompare(b));
385
+ }
386
+
387
+ /** `to` relative to the folder holding `from`, both site-relative: `_x.mdoc`, `../_x.mdoc`. */
388
+ export function relativePath(from: string, to: string): string {
389
+ const base = from.split('/').slice(0, -1);
390
+ const target = to.split('/');
391
+ let common = 0;
392
+ while (common < base.length && common < target.length - 1 && base[common] === target[common])
393
+ common++;
394
+ return [...Array<string>(base.length - common).fill('..'), ...target.slice(common)].join('/');
395
+ }
396
+
397
+ function ConditionForm({ editor, target }: { editor: Editor; target: Target }) {
398
+ const ui = useBodyUi();
399
+ const open = attrText(target.attrs.open);
400
+ const layout = markdocTagLayout(open);
401
+ const primary = layout?.attributes.find((a) => a.kind === 'primary');
402
+ const [text, setText] = useState(primary ? open.slice(primary.valueStart, primary.valueEnd) : '');
403
+ const [invalid, setInvalid] = useState(false);
404
+ if (!layout || !primary)
405
+ return <RawForm editor={editor} target={target} label="Condition" field="open" />;
406
+ return (
407
+ <TextField
408
+ label="Shown when"
409
+ description='A Markdoc condition, such as $product.beta or equals($tier, "free"). The content inside is shown only when it’s true.'
410
+ value={text}
411
+ isDisabled={ui.disabled}
412
+ isInvalid={invalid}
413
+ errorMessage="That isn’t a condition Markdoc can read, so it wasn’t changed."
414
+ inputClassName="font-mono"
415
+ onChange={(next) => {
416
+ setText(next);
417
+ const edited = open.slice(0, primary.valueStart) + next + open.slice(primary.valueEnd);
418
+ const ok =
419
+ next.trim() !== '' &&
420
+ markdocTagLayout(edited)?.attributes.some((a) => a.kind === 'primary');
421
+ setInvalid(!ok);
422
+ if (ok) setAttrs(editor, target, { open: edited });
423
+ }}
424
+ />
425
+ );
426
+ }
427
+
428
+ /** A protected construct's text, edited as written. */
429
+ function RawForm({
430
+ editor,
431
+ target,
432
+ label,
433
+ field,
434
+ }: {
435
+ editor: Editor;
436
+ target: Target;
437
+ label: string;
438
+ field?: string;
439
+ }) {
440
+ const ui = useBodyUi();
441
+ const key = field ?? (target.type === 'sourceBlock' ? 'text' : 'source');
442
+ const value = attrText(target.attrs[key]);
443
+ return (
444
+ <TextArea
445
+ label={label}
446
+ description="Kept exactly as written: Vesper never reformats it. Change it with care; the site reads it as code."
447
+ value={value}
448
+ rows={Math.min(12, value.split('\n').length + 1)}
449
+ isDisabled={ui.disabled}
450
+ inputClassName="font-mono text-small"
451
+ onChange={(next) => {
452
+ setAttrs(editor, target, { [key]: next });
453
+ }}
454
+ />
455
+ );
456
+ }
457
+
458
+ function Actions({
459
+ editor,
460
+ target,
461
+ onClose,
462
+ }: {
463
+ editor: Editor;
464
+ target: Target;
465
+ onClose: () => void;
466
+ }) {
467
+ const ui = useBodyUi();
468
+ const $pos = editor.state.doc.resolve(target.pos);
469
+ const index = $pos.index();
470
+ const count = $pos.parent.childCount;
471
+ return (
472
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
473
+ <div className="flex flex-wrap gap-2">
474
+ {!target.inline && (
475
+ <>
476
+ <Button
477
+ isDisabled={ui.disabled || index === 0}
478
+ onPress={() => {
479
+ ui.move(target.pos, -1);
480
+ }}
481
+ >
482
+ Move up
483
+ </Button>
484
+ <Button
485
+ isDisabled={ui.disabled || index >= count - 1}
486
+ onPress={() => {
487
+ ui.move(target.pos, 1);
488
+ }}
489
+ >
490
+ Move down
491
+ </Button>
492
+ </>
493
+ )}
494
+ <Button
495
+ variant="quiet"
496
+ isDisabled={ui.disabled}
497
+ onPress={() => {
498
+ onClose();
499
+ ui.remove(target.pos);
500
+ }}
501
+ >
502
+ Remove
503
+ </Button>
504
+ </div>
505
+ <Button variant="primary" onPress={onClose}>
506
+ Done
507
+ </Button>
508
+ </div>
509
+ );
510
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A keyboard shortcut as people read it ("Ctrl+B", "⌘B") and as
3
+ * `aria-keyshortcuts` writes it ("Control+B", "Meta+B"). `Mod` is Command on
4
+ * a Mac and Control elsewhere, as in Tiptap's own keymaps.
5
+ */
6
+ export type Shortcut = { text: string; aria: string };
7
+
8
+ export const isMac =
9
+ typeof navigator !== 'undefined' &&
10
+ /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
11
+
12
+ type Modifier = 'Mod' | 'Alt' | 'Shift';
13
+
14
+ export function shortcut(...keys: [...Modifier[], string]): Shortcut {
15
+ const key = keys.at(-1) ?? '';
16
+ const modifiers = keys.slice(0, -1) as Modifier[];
17
+ if (isMac) {
18
+ const symbols: Record<Modifier, string> = { Alt: '⌥', Shift: '⇧', Mod: '⌘' };
19
+ // Apple's order: Option, Shift, Command, then the key.
20
+ const order = (['Alt', 'Shift', 'Mod'] as const).filter((m) => modifiers.includes(m));
21
+ return {
22
+ text: `${order.map((m) => symbols[m]).join('')}${key}`,
23
+ aria: [...order.map((m) => (m === 'Mod' ? 'Meta' : m)), key].join('+'),
24
+ };
25
+ }
26
+ const named = modifiers.map((m) => (m === 'Mod' ? 'Ctrl' : m));
27
+ return {
28
+ text: [...named, key].join('+'),
29
+ aria: [...modifiers.map((m) => (m === 'Mod' ? 'Control' : m)), key].join('+'),
30
+ };
31
+ }