@plutocms/docs 0.0.0-stage → 0.15.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 (61) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +178 -0
  4. package/FEATURES.md +30 -0
  5. package/README.md +39 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -0
  10. package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
  11. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
  12. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
  13. package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +85 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -0
  16. package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
  17. package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
  18. package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
  19. package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
  20. package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
  21. package/app/components/PlutoContentPreview.vue +35 -0
  22. package/app/composables/pluto-block-editor.ts +443 -0
  23. package/app/composables/pluto-content-edit-link.ts +19 -0
  24. package/app/composables/pluto-content-preview.ts +29 -0
  25. package/app/composables/pluto-content-runtime.ts +237 -0
  26. package/app/pages/_pluto/preview.vue +555 -0
  27. package/app/plugins/pluto-docs.ts +50 -0
  28. package/i18n/locales/en.json +253 -0
  29. package/i18n/locales/pt-BR.json +289 -0
  30. package/modules/pluto-docs/index.ts +123 -0
  31. package/modules/pluto-docs/manifest.ts +159 -0
  32. package/modules/pluto-docs/options.ts +224 -0
  33. package/nuxt.config.ts +36 -0
  34. package/package.json +55 -4
  35. package/server/handlers/navigation.get.ts +7 -0
  36. package/server/handlers/page.get.ts +7 -0
  37. package/server/handlers/surround.get.ts +7 -0
  38. package/server/plugins/pluto-docs.ts +25 -0
  39. package/server/utils/pluto-docs-runtime.ts +504 -0
  40. package/shared/types/docs.d.ts +40 -0
  41. package/shared/types/pluto-manifest.d.ts +6 -0
  42. package/shared/utils/blocks/editor.ts +357 -0
  43. package/shared/utils/blocks/history.ts +94 -0
  44. package/shared/utils/blocks/mdc-props.ts +321 -0
  45. package/shared/utils/blocks/operations.ts +692 -0
  46. package/shared/utils/blocks/parse.ts +444 -0
  47. package/shared/utils/blocks/presets.ts +383 -0
  48. package/shared/utils/blocks/preview-marks.ts +98 -0
  49. package/shared/utils/blocks/preview-protocol.ts +195 -0
  50. package/shared/utils/blocks/registry.ts +163 -0
  51. package/shared/utils/blocks/serialize.ts +107 -0
  52. package/shared/utils/blocks/tree.ts +85 -0
  53. package/shared/utils/blocks/types.ts +165 -0
  54. package/shared/utils/content-route.ts +40 -0
  55. package/shared/utils/docs-type.ts +290 -0
  56. package/shared/utils/mdc-snippets.ts +114 -0
  57. package/shared/utils/navigation-config.ts +19 -0
  58. package/shared/utils/runtime-content.ts +403 -0
  59. package/tailwind.lint.css +10 -0
  60. package/tsconfig.json +18 -0
  61. package/vitest.config.ts +7 -0
@@ -0,0 +1,383 @@
1
+ // The blocks that work with no site config: the Nuxt UI prose components and
2
+ // the Nuxt UI page components that a Markdown page can use. Pure data. Each
3
+ // label and description is an i18n key of the layer, in the group
4
+ // docs.blocks.<name>. A prop or a slot name shared by many blocks has
5
+ // one key in the groups props and slots of the same namespace.
6
+
7
+ import type {
8
+ PlutoBlockDefinition,
9
+ PlutoBlockPropDefinition,
10
+ PlutoBlockPropOption,
11
+ PlutoBlockPropType,
12
+ PlutoBlockSlotContent,
13
+ PlutoBlockSlotDefinition,
14
+ } from './types'
15
+ import { MDC_SNIPPETS } from '../mdc-snippets'
16
+
17
+ /** The group ids of the palette, in order. A site can use any other text as a group. */
18
+ export const BLOCK_GROUPS = [
19
+ 'text',
20
+ 'callouts',
21
+ 'layout',
22
+ 'code',
23
+ 'landing',
24
+ 'site',
25
+ ] as const
26
+
27
+ /** The labels of the three built-in kinds of block. */
28
+ export const BLOCK_KIND_LABELS = {
29
+ text: 'docs.blocks.kinds.text',
30
+ code: 'docs.blocks.kinds.code',
31
+ raw: 'docs.blocks.kinds.raw',
32
+ } as const
33
+
34
+ export function groupLabelKey(group: string): string {
35
+ return (BLOCK_GROUPS as readonly string[]).includes(group)
36
+ ? `docs.blocks.groups.${group}`
37
+ : group
38
+ }
39
+
40
+ const COLORS = [
41
+ 'primary',
42
+ 'secondary',
43
+ 'success',
44
+ 'info',
45
+ 'warning',
46
+ 'error',
47
+ 'neutral',
48
+ ]
49
+ const TARGETS = ['_self', '_blank']
50
+
51
+ function prop(
52
+ type: PlutoBlockPropType,
53
+ name: string,
54
+ extra: Partial<PlutoBlockPropDefinition> = {}
55
+ ): PlutoBlockPropDefinition {
56
+ return { type, label: `docs.blocks.props.${name}`, ...extra }
57
+ }
58
+
59
+ function options(values: string[]): PlutoBlockPropOption[] {
60
+ return values.map((value) => ({ value }))
61
+ }
62
+
63
+ function slot(
64
+ name: string,
65
+ content: PlutoBlockSlotContent,
66
+ accepts?: string[]
67
+ ): PlutoBlockSlotDefinition {
68
+ return {
69
+ label: `docs.blocks.slots.${name}`,
70
+ content,
71
+ ...(accepts ? { accepts } : {}),
72
+ }
73
+ }
74
+
75
+ function snippet(id: string): string {
76
+ return MDC_SNIPPETS.find((item) => item.id === id)!.text
77
+ }
78
+
79
+ function block(
80
+ name: string,
81
+ group: string,
82
+ icon: string,
83
+ extra: Partial<PlutoBlockDefinition> = {}
84
+ ): PlutoBlockDefinition {
85
+ return {
86
+ name,
87
+ label: `docs.blocks.${name}.label`,
88
+ description: `docs.blocks.${name}.description`,
89
+ icon,
90
+ group,
91
+ ...extra,
92
+ }
93
+ }
94
+
95
+ const calloutProps = {
96
+ icon: prop('icon', 'icon'),
97
+ to: prop('link', 'to', { advanced: true }),
98
+ target: prop('select', 'target', {
99
+ options: options(TARGETS),
100
+ advanced: true,
101
+ }),
102
+ }
103
+
104
+ const DEFINITIONS: PlutoBlockDefinition[] = [
105
+ block('note', 'callouts', 'i-lucide-info', {
106
+ props: { ...calloutProps },
107
+ slots: { default: slot('default', 'rich') },
108
+ insert: snippet('note'),
109
+ }),
110
+ block('tip', 'callouts', 'i-lucide-lightbulb', {
111
+ props: { ...calloutProps },
112
+ slots: { default: slot('default', 'rich') },
113
+ insert: snippet('tip'),
114
+ }),
115
+ block('warning', 'callouts', 'i-lucide-triangle-alert', {
116
+ props: { ...calloutProps },
117
+ slots: { default: slot('default', 'rich') },
118
+ insert: snippet('warning'),
119
+ }),
120
+ block('caution', 'callouts', 'i-lucide-octagon-alert', {
121
+ props: { ...calloutProps },
122
+ slots: { default: slot('default', 'rich') },
123
+ insert: snippet('caution'),
124
+ }),
125
+ block('callout', 'callouts', 'i-lucide-message-square-quote', {
126
+ props: {
127
+ icon: prop('icon', 'icon'),
128
+ color: prop('select', 'color', { options: options(COLORS) }),
129
+ to: prop('link', 'to', { advanced: true }),
130
+ target: prop('select', 'target', {
131
+ options: options(TARGETS),
132
+ advanced: true,
133
+ }),
134
+ },
135
+ slots: { default: slot('default', 'rich') },
136
+ insert: snippet('callout'),
137
+ }),
138
+ block('card', 'layout', 'i-lucide-square', {
139
+ props: {
140
+ title: prop('string', 'title'),
141
+ icon: prop('icon', 'icon'),
142
+ color: prop('select', 'color', { options: options(COLORS) }),
143
+ to: prop('link', 'to'),
144
+ target: prop('select', 'target', {
145
+ options: options(TARGETS),
146
+ advanced: true,
147
+ }),
148
+ },
149
+ slots: { default: slot('default', 'rich') },
150
+ insert:
151
+ '::card\n---\ntitle: Title\nicon: i-lucide-box\n---\nText of the card.\n::',
152
+ }),
153
+ block('card-group', 'layout', 'i-lucide-layout-grid', {
154
+ slots: { default: slot('default', 'blocks', ['card']) },
155
+ insert: snippet('card-group'),
156
+ }),
157
+ block('steps', 'layout', 'i-lucide-list-ordered', {
158
+ props: {
159
+ level: prop('select', 'level', { options: options(['2', '3', '4']) }),
160
+ },
161
+ slots: { default: slot('default', 'rich') },
162
+ insert: snippet('steps'),
163
+ }),
164
+ block('accordion', 'layout', 'i-lucide-chevrons-up-down', {
165
+ props: {
166
+ type: prop('select', 'accordionType', {
167
+ options: options(['single', 'multiple']),
168
+ }),
169
+ },
170
+ slots: { default: slot('default', 'blocks', ['accordion-item']) },
171
+ insert: snippet('accordion'),
172
+ }),
173
+ block('accordion-item', 'layout', 'i-lucide-chevron-down', {
174
+ props: {
175
+ label: prop('string', 'label'),
176
+ icon: prop('icon', 'icon'),
177
+ description: prop('string', 'description'),
178
+ },
179
+ slots: { default: slot('default', 'rich') },
180
+ insert:
181
+ '::accordion-item{label="Question" icon="i-lucide-circle-help"}\nAnswer.\n::',
182
+ }),
183
+ block('tabs', 'layout', 'i-lucide-panel-top', {
184
+ props: { sync: prop('string', 'sync', { advanced: true }) },
185
+ slots: { default: slot('default', 'blocks', ['tabs-item']) },
186
+ insert: snippet('tabs'),
187
+ }),
188
+ block('tabs-item', 'layout', 'i-lucide-panel-top-open', {
189
+ props: {
190
+ label: prop('string', 'label'),
191
+ icon: prop('icon', 'icon'),
192
+ description: prop('string', 'description'),
193
+ },
194
+ slots: { default: slot('default', 'rich') },
195
+ insert:
196
+ '::tabs-item{label="Tab" icon="i-lucide-file"}\nText of the tab.\n::',
197
+ }),
198
+ block('collapsible', 'layout', 'i-lucide-chevrons-down-up', {
199
+ props: {
200
+ name: prop('string', 'name'),
201
+ openText: prop('string', 'openText', { advanced: true }),
202
+ closeText: prop('string', 'closeText', { advanced: true }),
203
+ },
204
+ slots: { default: slot('default', 'rich') },
205
+ insert: '::collapsible\nHidden text.\n::',
206
+ }),
207
+ block('field-group', 'layout', 'i-lucide-list-tree', {
208
+ slots: { default: slot('default', 'blocks', ['field']) },
209
+ insert: snippet('field-group'),
210
+ }),
211
+ block('field', 'layout', 'i-lucide-text-cursor-input', {
212
+ props: {
213
+ name: prop('string', 'name'),
214
+ type: prop('string', 'fieldType'),
215
+ required: prop('boolean', 'required'),
216
+ description: prop('string', 'description'),
217
+ },
218
+ slots: { default: slot('default', 'rich') },
219
+ insert:
220
+ '::field{name="name" type="string" required}\nText of the field.\n::',
221
+ }),
222
+ block('code-group', 'code', 'i-lucide-square-code', {
223
+ props: { sync: prop('string', 'sync', { advanced: true }) },
224
+ slots: { default: slot('default', 'blocks') },
225
+ insert: snippet('code-group'),
226
+ }),
227
+ block('code-collapse', 'code', 'i-lucide-fold-vertical', {
228
+ props: { name: prop('string', 'name') },
229
+ slots: { default: slot('default', 'blocks') },
230
+ insert: '::code-collapse\n```ts [file.ts]\nconst value = 1\n```\n::',
231
+ }),
232
+ // Inline components. A Markdown page uses them inside a text, so the palette does not list them.
233
+ block('badge', 'text', 'i-lucide-badge', {
234
+ hidden: true,
235
+ props: {
236
+ label: prop('string', 'label'),
237
+ color: prop('select', 'color', { options: options(COLORS) }),
238
+ },
239
+ }),
240
+ block('kbd', 'text', 'i-lucide-keyboard', {
241
+ hidden: true,
242
+ props: { value: prop('string', 'value') },
243
+ }),
244
+ block('icon', 'text', 'i-lucide-smile', {
245
+ hidden: true,
246
+ props: { name: prop('icon', 'name') },
247
+ }),
248
+ block('u-page-hero', 'landing', 'i-lucide-panel-top', {
249
+ props: {
250
+ headline: prop('string', 'headline'),
251
+ title: prop('string', 'title'),
252
+ description: prop('text', 'description'),
253
+ orientation: prop('select', 'orientation', {
254
+ options: options(['vertical', 'horizontal']),
255
+ }),
256
+ reverse: prop('boolean', 'reverse'),
257
+ },
258
+ slots: {
259
+ headline: slot('headline', 'inline'),
260
+ title: slot('title', 'inline'),
261
+ description: slot('description', 'rich'),
262
+ links: slot('links', 'blocks', ['u-button']),
263
+ default: slot('default', 'blocks'),
264
+ },
265
+ insert:
266
+ '::u-page-hero\n#title\nYour title\n\n#description\nA short text about the page.\n::',
267
+ }),
268
+ block('u-page-section', 'landing', 'i-lucide-layout-template', {
269
+ props: {
270
+ headline: prop('string', 'headline'),
271
+ icon: prop('icon', 'icon'),
272
+ title: prop('string', 'title'),
273
+ description: prop('text', 'description'),
274
+ orientation: prop('select', 'orientation', {
275
+ options: options(['vertical', 'horizontal']),
276
+ }),
277
+ reverse: prop('boolean', 'reverse'),
278
+ },
279
+ slots: {
280
+ headline: slot('headline', 'inline'),
281
+ title: slot('title', 'inline'),
282
+ description: slot('description', 'rich'),
283
+ features: slot('features', 'blocks', ['u-page-feature']),
284
+ default: slot('default', 'blocks'),
285
+ },
286
+ insert:
287
+ '::u-page-section\n#title\nYour title\n\n#description\nA short text about the section.\n::',
288
+ }),
289
+ block('u-page-feature', 'landing', 'i-lucide-sparkle', {
290
+ props: {
291
+ title: prop('string', 'title'),
292
+ description: prop('text', 'description'),
293
+ icon: prop('icon', 'icon'),
294
+ to: prop('link', 'to'),
295
+ orientation: prop('select', 'orientation', {
296
+ options: options(['horizontal', 'vertical']),
297
+ }),
298
+ },
299
+ slots: {
300
+ title: slot('title', 'inline'),
301
+ description: slot('description', 'rich'),
302
+ },
303
+ insert:
304
+ '::u-page-feature\n---\nicon: i-lucide-sparkle\n---\n#title\nFeature\n\n#description\nA short text about the feature.\n::',
305
+ }),
306
+ block('u-page-card', 'landing', 'i-lucide-square-stack', {
307
+ props: {
308
+ title: prop('string', 'title'),
309
+ description: prop('text', 'description'),
310
+ icon: prop('icon', 'icon'),
311
+ to: prop('link', 'to'),
312
+ spotlight: prop('boolean', 'spotlight'),
313
+ orientation: prop('select', 'orientation', {
314
+ options: options(['vertical', 'horizontal']),
315
+ }),
316
+ },
317
+ slots: { default: slot('default', 'blocks') },
318
+ insert:
319
+ '::u-page-card\n---\ntitle: Title\ndescription: A short text.\nicon: i-lucide-box\n---\n::',
320
+ }),
321
+ block('u-page-grid', 'landing', 'i-lucide-grid-3x3', {
322
+ slots: { default: slot('default', 'blocks', ['u-page-card']) },
323
+ insert:
324
+ '::u-page-grid\n :::u-page-card\n ---\n title: Title\n description: A short text.\n ---\n :::\n::',
325
+ }),
326
+ block('u-page-c-t-a', 'landing', 'i-lucide-megaphone', {
327
+ props: {
328
+ title: prop('string', 'title'),
329
+ description: prop('text', 'description'),
330
+ variant: prop('select', 'variant', {
331
+ options: options(['outline', 'solid', 'soft', 'subtle', 'naked']),
332
+ }),
333
+ orientation: prop('select', 'orientation', {
334
+ options: options(['vertical', 'horizontal']),
335
+ }),
336
+ reverse: prop('boolean', 'reverse'),
337
+ },
338
+ slots: {
339
+ links: slot('links', 'blocks', ['u-button']),
340
+ default: slot('default', 'blocks'),
341
+ },
342
+ insert:
343
+ '::u-page-c-t-a\n---\ntitle: Ready to start?\ndescription: A short text.\n---\n::',
344
+ }),
345
+ block('u-button', 'landing', 'i-lucide-mouse-pointer-click', {
346
+ props: {
347
+ // A Markdown page often writes the text as the default slot (`:::u-button` ... `Get started`).
348
+ label: prop('string', 'label', { slot: 'default' }),
349
+ to: prop('link', 'to'),
350
+ icon: prop('icon', 'icon'),
351
+ color: prop('select', 'color', { options: options(COLORS) }),
352
+ variant: prop('select', 'variant', {
353
+ options: options([
354
+ 'solid',
355
+ 'outline',
356
+ 'soft',
357
+ 'subtle',
358
+ 'ghost',
359
+ 'link',
360
+ ]),
361
+ }),
362
+ size: prop('select', 'size', {
363
+ options: options(['xs', 'sm', 'md', 'lg', 'xl']),
364
+ }),
365
+ target: prop('select', 'target', {
366
+ options: options(TARGETS),
367
+ advanced: true,
368
+ }),
369
+ },
370
+ insert: '::u-button{label="Button" to="/"}\n::',
371
+ }),
372
+ ]
373
+
374
+ /** The presets by component name. */
375
+ export const BLOCK_PRESETS: Readonly<Record<string, PlutoBlockDefinition>> =
376
+ Object.freeze(
377
+ Object.fromEntries(
378
+ DEFINITIONS.map((definition) => [definition.name, definition])
379
+ )
380
+ )
381
+
382
+ /** The props that no preset lists, and that the panel keeps out of the main section. */
383
+ export const HIDDEN_PROP_NAMES = ['ui', 'as'] as const
@@ -0,0 +1,98 @@
1
+ // Marks the blocks in the parsed body of the preview page, so a click in the
2
+ // preview selects the block under the pointer, an inner block too. Pure: no
3
+ // DOM, no Nuxt. The nodes are the MDC AST of `parseMarkdown`.
4
+
5
+ import type { PlutoPreviewChild } from './preview-protocol'
6
+
7
+ /** The attribute that holds the block id on an element of the preview. */
8
+ export const PREVIEW_BLOCK_ATTRIBUTE = 'data-pluto-block'
9
+
10
+ /** The part of an MDC node that the marks read and write. */
11
+ export interface PlutoMarkNode {
12
+ type: string
13
+ tag?: string
14
+ props?: Record<string, unknown>
15
+ children?: PlutoMarkNode[]
16
+ }
17
+
18
+ /** `UPageSection` and `u-page-section` are the same component. */
19
+ function normalize(name: string): string {
20
+ return name
21
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
22
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
23
+ .toLowerCase()
24
+ }
25
+
26
+ /**
27
+ * The elements where an inner block of a component can be: its direct
28
+ * children (the default slot) and the children of its `template` elements
29
+ * (the named slots), in source order. An inline component inside a paragraph
30
+ * is never here, so it cannot take the place of an inner block.
31
+ */
32
+ function slotElements(node: PlutoMarkNode): PlutoMarkNode[] {
33
+ const elements: PlutoMarkNode[] = []
34
+ for (const child of node.children ?? []) {
35
+ if (child.type !== 'element') {
36
+ continue
37
+ }
38
+ if (child.tag === 'template') {
39
+ elements.push(
40
+ ...(child.children ?? []).filter((inner) => inner.type === 'element')
41
+ )
42
+ } else {
43
+ elements.push(child)
44
+ }
45
+ }
46
+ return elements
47
+ }
48
+
49
+ /**
50
+ * Puts the ids of `children` on the matching component elements under `node`.
51
+ * The children are in source order over all slots, so the match goes in
52
+ * order, by component name. An element with another name (a paragraph, a
53
+ * list) is skipped. It changes the props of the matched elements in place,
54
+ * so call it on a body from a new parse. Returns the number of marked
55
+ * elements, at all levels.
56
+ */
57
+ export function markPreviewChildren(
58
+ node: PlutoMarkNode,
59
+ children: PlutoPreviewChild[]
60
+ ): number {
61
+ let marked = 0
62
+ let next = 0
63
+ for (const element of slotElements(node)) {
64
+ const child = children[next]
65
+ if (!child) {
66
+ break
67
+ }
68
+ if (element.tag && normalize(element.tag) === normalize(child.name)) {
69
+ element.props = { ...element.props, [PREVIEW_BLOCK_ATTRIBUTE]: child.id }
70
+ marked += 1 + markPreviewChildren(element, child.children ?? [])
71
+ next++
72
+ }
73
+ }
74
+ return marked
75
+ }
76
+
77
+ /** The number of children in a tree, at all levels. */
78
+ export function countPreviewChildren(
79
+ children: PlutoPreviewChild[] | undefined
80
+ ): number {
81
+ return (children ?? []).reduce(
82
+ (total, child) => total + 1 + countPreviewChildren(child.children),
83
+ 0
84
+ )
85
+ }
86
+
87
+ /** The parent id of each child in a tree. A child of a top-level block has that block as parent. */
88
+ export function previewParents(
89
+ blockId: string,
90
+ children: PlutoPreviewChild[] | undefined,
91
+ parents = new Map<string, string>()
92
+ ): Map<string, string> {
93
+ for (const child of children ?? []) {
94
+ parents.set(child.id, blockId)
95
+ previewParents(child.id, child.children, parents)
96
+ }
97
+ return parents
98
+ }
@@ -0,0 +1,195 @@
1
+ // The messages between the editor and the preview frame (`/_pluto/preview`).
2
+ // Both sides check the origin and the shape of each message. Types and
3
+ // validators only: no DOM, no Nuxt.
4
+
5
+ export const PREVIEW_PATH = '/_pluto/preview'
6
+
7
+ /** The version of the protocol. Change it when a message changes in a way that breaks the other side. */
8
+ export const PREVIEW_PROTOCOL_VERSION = 1
9
+
10
+ /** Every message has this `source`, so the frame ignores the messages of other scripts. */
11
+ export const PREVIEW_SOURCE = 'pluto-preview'
12
+
13
+ /** An inner component of a block, so the preview can mark it and a click can select it. */
14
+ export interface PlutoPreviewChild {
15
+ id: string
16
+ /** The component name, as in the Markdown (`card`, `u-page-feature`). */
17
+ name: string
18
+ /** The label for the outline in the preview. */
19
+ label?: string
20
+ /** The inner components of this component, in source order over all slots. */
21
+ children?: PlutoPreviewChild[]
22
+ }
23
+
24
+ export interface PlutoPreviewBlock {
25
+ id: string
26
+ markdown: string
27
+ /** `false` shows a placeholder card instead of the block. */
28
+ preview?: boolean
29
+ /** The label for the placeholder card and the outline. */
30
+ label?: string
31
+ /** The inner components of a component block, in source order over all slots. */
32
+ children?: PlutoPreviewChild[]
33
+ }
34
+
35
+ interface Envelope<T extends string> {
36
+ source: typeof PREVIEW_SOURCE
37
+ version: typeof PREVIEW_PROTOCOL_VERSION
38
+ type: T
39
+ }
40
+
41
+ // The editor to the frame
42
+
43
+ export interface PlutoPreviewRender extends Envelope<'render'> {
44
+ frontmatter: Record<string, unknown>
45
+ blocks: PlutoPreviewBlock[]
46
+ selectedId?: string | null
47
+ }
48
+
49
+ export interface PlutoPreviewSelect extends Envelope<'select'> {
50
+ /** `null` clears the selection. */
51
+ id: string | null
52
+ }
53
+
54
+ export interface PlutoPreviewScrollTo extends Envelope<'scroll-to'> {
55
+ id: string
56
+ }
57
+
58
+ export type PlutoPreviewToFrame =
59
+ PlutoPreviewRender | PlutoPreviewSelect | PlutoPreviewScrollTo
60
+
61
+ // The frame to the editor
62
+
63
+ export type PlutoPreviewReady = Envelope<'ready'>
64
+
65
+ export interface PlutoPreviewHover extends Envelope<'hover'> {
66
+ id: string | null
67
+ }
68
+
69
+ export interface PlutoPreviewError extends Envelope<'error'> {
70
+ /** The block that failed, or none for an error of the whole page. */
71
+ id?: string
72
+ message: string
73
+ }
74
+
75
+ export type PlutoPreviewFromFrame =
76
+ PlutoPreviewReady | PlutoPreviewSelect | PlutoPreviewHover | PlutoPreviewError
77
+
78
+ export type PlutoPreviewMessage = PlutoPreviewToFrame | PlutoPreviewFromFrame
79
+
80
+ export type PlutoPreviewDirection = 'to-frame' | 'from-frame'
81
+
82
+ export function previewMessage<T extends PlutoPreviewMessage['type']>(
83
+ type: T,
84
+ body: Omit<Extract<PlutoPreviewMessage, { type: T }>, keyof Envelope<T>>
85
+ ): Extract<PlutoPreviewMessage, { type: T }> {
86
+ return {
87
+ source: PREVIEW_SOURCE,
88
+ version: PREVIEW_PROTOCOL_VERSION,
89
+ type,
90
+ ...body,
91
+ } as Extract<PlutoPreviewMessage, { type: T }>
92
+ }
93
+
94
+ function isRecord(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
96
+ }
97
+
98
+ function isNullableString(value: unknown): value is string | null {
99
+ return value === null || typeof value === 'string'
100
+ }
101
+
102
+ // A real page nests a few levels. The limit stops a message that nests without end.
103
+ const MAX_CHILD_DEPTH = 16
104
+
105
+ function isChildList(
106
+ value: unknown,
107
+ depth: number
108
+ ): value is PlutoPreviewChild[] {
109
+ return (
110
+ Array.isArray(value) &&
111
+ depth <= MAX_CHILD_DEPTH &&
112
+ value.every(
113
+ (child) =>
114
+ isRecord(child) &&
115
+ typeof child.id === 'string' &&
116
+ typeof child.name === 'string' &&
117
+ (child.label === undefined || typeof child.label === 'string') &&
118
+ (child.children === undefined || isChildList(child.children, depth + 1))
119
+ )
120
+ )
121
+ }
122
+
123
+ function isBlock(value: unknown): value is PlutoPreviewBlock {
124
+ return (
125
+ isRecord(value) &&
126
+ typeof value.id === 'string' &&
127
+ typeof value.markdown === 'string' &&
128
+ (value.preview === undefined || typeof value.preview === 'boolean') &&
129
+ (value.label === undefined || typeof value.label === 'string') &&
130
+ (value.children === undefined || isChildList(value.children, 1))
131
+ )
132
+ }
133
+
134
+ const TO_FRAME = new Set(['render', 'select', 'scroll-to'])
135
+ const FROM_FRAME = new Set(['ready', 'select', 'hover', 'error'])
136
+
137
+ /**
138
+ * Checks the shape of a message that arrives from `postMessage`. It returns
139
+ * the typed message, or `undefined` for anything else: wrong source, wrong
140
+ * version, wrong direction, or a wrong field.
141
+ */
142
+ export function parsePreviewMessage(
143
+ data: unknown,
144
+ direction: PlutoPreviewDirection
145
+ ): PlutoPreviewMessage | undefined {
146
+ if (
147
+ !isRecord(data) ||
148
+ data.source !== PREVIEW_SOURCE ||
149
+ data.version !== PREVIEW_PROTOCOL_VERSION ||
150
+ typeof data.type !== 'string'
151
+ ) {
152
+ return undefined
153
+ }
154
+ if (!(direction === 'to-frame' ? TO_FRAME : FROM_FRAME).has(data.type)) {
155
+ return undefined
156
+ }
157
+ switch (data.type) {
158
+ case 'render':
159
+ return isRecord(data.frontmatter) &&
160
+ Array.isArray(data.blocks) &&
161
+ data.blocks.every(isBlock) &&
162
+ (data.selectedId === undefined || isNullableString(data.selectedId))
163
+ ? (data as unknown as PlutoPreviewRender)
164
+ : undefined
165
+ case 'select':
166
+ case 'hover':
167
+ return isNullableString(data.id) &&
168
+ (data.type === 'hover' ||
169
+ direction === 'to-frame' ||
170
+ typeof data.id === 'string')
171
+ ? (data as unknown as PlutoPreviewSelect)
172
+ : undefined
173
+ case 'scroll-to':
174
+ return typeof data.id === 'string'
175
+ ? (data as unknown as PlutoPreviewScrollTo)
176
+ : undefined
177
+ case 'ready':
178
+ return data as unknown as PlutoPreviewReady
179
+ case 'error':
180
+ return typeof data.message === 'string' &&
181
+ (data.id === undefined || typeof data.id === 'string')
182
+ ? (data as unknown as PlutoPreviewError)
183
+ : undefined
184
+ default:
185
+ return undefined
186
+ }
187
+ }
188
+
189
+ /** True when the origin of a message is the expected one. The preview is same-origin, so `expected` is `location.origin`. */
190
+ export function isTrustedPreviewOrigin(
191
+ origin: string,
192
+ expected: string
193
+ ): boolean {
194
+ return origin !== '' && origin !== 'null' && origin === expected
195
+ }