@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.
- package/.gitattributes +1 -0
- package/.prettierignore +5 -0
- package/CHANGELOG.md +178 -0
- package/FEATURES.md +30 -0
- package/README.md +39 -2
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +85 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
- package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
- package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
- package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
- package/app/components/PlutoContentPreview.vue +35 -0
- package/app/composables/pluto-block-editor.ts +443 -0
- package/app/composables/pluto-content-edit-link.ts +19 -0
- package/app/composables/pluto-content-preview.ts +29 -0
- package/app/composables/pluto-content-runtime.ts +237 -0
- package/app/pages/_pluto/preview.vue +555 -0
- package/app/plugins/pluto-docs.ts +50 -0
- package/i18n/locales/en.json +253 -0
- package/i18n/locales/pt-BR.json +289 -0
- package/modules/pluto-docs/index.ts +123 -0
- package/modules/pluto-docs/manifest.ts +159 -0
- package/modules/pluto-docs/options.ts +224 -0
- package/nuxt.config.ts +36 -0
- package/package.json +55 -4
- package/server/handlers/navigation.get.ts +7 -0
- package/server/handlers/page.get.ts +7 -0
- package/server/handlers/surround.get.ts +7 -0
- package/server/plugins/pluto-docs.ts +25 -0
- package/server/utils/pluto-docs-runtime.ts +504 -0
- package/shared/types/docs.d.ts +40 -0
- package/shared/types/pluto-manifest.d.ts +6 -0
- package/shared/utils/blocks/editor.ts +357 -0
- package/shared/utils/blocks/history.ts +94 -0
- package/shared/utils/blocks/mdc-props.ts +321 -0
- package/shared/utils/blocks/operations.ts +692 -0
- package/shared/utils/blocks/parse.ts +444 -0
- package/shared/utils/blocks/presets.ts +383 -0
- package/shared/utils/blocks/preview-marks.ts +98 -0
- package/shared/utils/blocks/preview-protocol.ts +195 -0
- package/shared/utils/blocks/registry.ts +163 -0
- package/shared/utils/blocks/serialize.ts +107 -0
- package/shared/utils/blocks/tree.ts +85 -0
- package/shared/utils/blocks/types.ts +165 -0
- package/shared/utils/content-route.ts +40 -0
- package/shared/utils/docs-type.ts +290 -0
- package/shared/utils/mdc-snippets.ts +114 -0
- package/shared/utils/navigation-config.ts +19 -0
- package/shared/utils/runtime-content.ts +403 -0
- package/tailwind.lint.css +10 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|