@plutocms/docs 0.0.0-stage → 0.16.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 +186 -0
- package/FEATURES.md +34 -0
- package/README.md +40 -2
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +165 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +161 -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 +86 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +232 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +269 -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 +459 -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/blocks.ts +217 -0
- package/modules/pluto-docs/index.ts +230 -0
- package/modules/pluto-docs/manifest.ts +159 -0
- package/modules/pluto-docs/options.ts +258 -0
- package/nuxt.config.ts +36 -0
- package/package.json +56 -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-blocks.d.ts +10 -0
- package/shared/types/pluto-manifest.d.ts +6 -0
- package/shared/utils/blocks/component-meta.ts +285 -0
- package/shared/utils/blocks/define.ts +15 -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 +384 -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 +295 -0
- package/shared/utils/blocks/serialize.ts +107 -0
- package/shared/utils/blocks/tree.ts +85 -0
- package/shared/utils/blocks/types.ts +195 -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,285 @@
|
|
|
1
|
+
// Make a block definition from the meta of a Vue component and the optional
|
|
2
|
+
// `definePlutoBlock({...})` call in its file. Pure: the build step (a later
|
|
3
|
+
// change) parses the files with `nuxt-component-meta` and passes the result in.
|
|
4
|
+
// The result is the "component layer" of one component. Opt-in: the build step
|
|
5
|
+
// calls this only for a component that has the macro.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
PlutoBlockPatch,
|
|
9
|
+
PlutoBlockPropDefinition,
|
|
10
|
+
PlutoBlockPropPatch,
|
|
11
|
+
PlutoBlockSlotDefinition,
|
|
12
|
+
} from './types'
|
|
13
|
+
import { BLOCK_COLORS, BLOCK_PRESETS } from './presets'
|
|
14
|
+
import { blockKey, mergeBlockPatch } from './registry'
|
|
15
|
+
|
|
16
|
+
// The parts of the output of `getComponentMeta` that this file reads. The real output has more.
|
|
17
|
+
export interface PlutoMetaSchema {
|
|
18
|
+
kind?: string
|
|
19
|
+
type?: string
|
|
20
|
+
/** For `enum`: the members as text, by index. For `object`: the properties by name. */
|
|
21
|
+
schema?: Record<string, unknown> | string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PlutoMetaProp {
|
|
25
|
+
name: string
|
|
26
|
+
/** The type as text, for example `"slow" | "fast" | undefined`. */
|
|
27
|
+
type: string
|
|
28
|
+
schema?: PlutoMetaSchema | string
|
|
29
|
+
/** The default as source text: `300`, `"normal"`, `false`. */
|
|
30
|
+
default?: string
|
|
31
|
+
/** The JSDoc text of the prop. */
|
|
32
|
+
description?: string
|
|
33
|
+
required?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PlutoMetaSlot {
|
|
37
|
+
name: string
|
|
38
|
+
type?: string
|
|
39
|
+
schema?: PlutoMetaSchema | string
|
|
40
|
+
description?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PlutoComponentMeta {
|
|
44
|
+
props?: PlutoMetaProp[]
|
|
45
|
+
slots?: PlutoMetaSlot[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The block key of a component: `PlutoHero` gives `pluto-hero`, `UPageCTA` gives `u-page-c-t-a`. */
|
|
49
|
+
export function componentBlockName(pascalName: string): string {
|
|
50
|
+
return blockKey(pascalName)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Type to widget ---------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Split a union at its top-level bars, so a bar inside quotes or brackets stays. */
|
|
56
|
+
function splitUnion(type: string): string[] {
|
|
57
|
+
const parts: string[] = []
|
|
58
|
+
let depth = 0
|
|
59
|
+
let quote = ''
|
|
60
|
+
let current = ''
|
|
61
|
+
for (const char of type) {
|
|
62
|
+
if (quote) {
|
|
63
|
+
if (char === quote) {
|
|
64
|
+
quote = ''
|
|
65
|
+
}
|
|
66
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
67
|
+
quote = char
|
|
68
|
+
} else if ('{[(<'.includes(char)) {
|
|
69
|
+
depth++
|
|
70
|
+
} else if ('}])>'.includes(char)) {
|
|
71
|
+
depth--
|
|
72
|
+
} else if (char === '|' && depth === 0) {
|
|
73
|
+
parts.push(current.trim())
|
|
74
|
+
current = ''
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
current += char
|
|
78
|
+
}
|
|
79
|
+
parts.push(current.trim())
|
|
80
|
+
return parts.filter(Boolean)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The members of the type without `undefined` and `null`. */
|
|
84
|
+
function typeMembers(prop: PlutoMetaProp): string[] {
|
|
85
|
+
const schema = prop.schema
|
|
86
|
+
const members =
|
|
87
|
+
typeof schema === 'object' &&
|
|
88
|
+
schema.kind === 'enum' &&
|
|
89
|
+
typeof schema.schema === 'object'
|
|
90
|
+
? Object.values(schema.schema).map(String)
|
|
91
|
+
: splitUnion(prop.type)
|
|
92
|
+
return members.filter((member) => member !== 'undefined' && member !== 'null')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const STRING_LITERAL = /^(["'`])(.*)\1$/
|
|
96
|
+
|
|
97
|
+
function stringLiteral(member: string): string | undefined {
|
|
98
|
+
return STRING_LITERAL.exec(member)?.[2]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isIconName(name: string): boolean {
|
|
102
|
+
return name === 'icon' || name.endsWith('Icon')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The widget for a prop, or `undefined` when no widget fits. */
|
|
106
|
+
function widgetFor(
|
|
107
|
+
name: string,
|
|
108
|
+
members: string[]
|
|
109
|
+
): Pick<PlutoBlockPropDefinition, 'type' | 'options'> | undefined {
|
|
110
|
+
if (members.length === 0) {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
const literals = members.map(stringLiteral)
|
|
114
|
+
if (literals.every((value) => value !== undefined)) {
|
|
115
|
+
const options = (literals as string[]).map((value) => ({ value }))
|
|
116
|
+
return (literals as string[]).every((value) => BLOCK_COLORS.includes(value))
|
|
117
|
+
? { type: 'color', options }
|
|
118
|
+
: { type: 'select', options }
|
|
119
|
+
}
|
|
120
|
+
const kinds = new Set(
|
|
121
|
+
members.map((member) => stringLiteral(member) ?? member)
|
|
122
|
+
)
|
|
123
|
+
const isBoolean = members.every((member) =>
|
|
124
|
+
['boolean', 'true', 'false'].includes(member)
|
|
125
|
+
)
|
|
126
|
+
if (isBoolean) {
|
|
127
|
+
return { type: 'boolean' }
|
|
128
|
+
}
|
|
129
|
+
if (members.length === 1 && members[0] === 'number') {
|
|
130
|
+
return { type: 'number' }
|
|
131
|
+
}
|
|
132
|
+
const textLike = members.every(
|
|
133
|
+
(member) =>
|
|
134
|
+
stringLiteral(member) !== undefined ||
|
|
135
|
+
member === 'string' ||
|
|
136
|
+
member === 'number'
|
|
137
|
+
)
|
|
138
|
+
if (textLike && members.includes('string')) {
|
|
139
|
+
return {
|
|
140
|
+
type: isIconName(name) ? 'icon' : linkName(name) ? 'link' : 'string',
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
linkName(name) &&
|
|
145
|
+
(kinds.has('string') || kinds.has('RouteLocationRaw'))
|
|
146
|
+
) {
|
|
147
|
+
return { type: 'link' }
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function linkName(name: string): boolean {
|
|
153
|
+
return name === 'to' || name === 'href'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A simple default from source text: a number, a boolean or a string. Anything else gives `undefined`. */
|
|
157
|
+
export function parseDefault(
|
|
158
|
+
source: string | undefined
|
|
159
|
+
): string | number | boolean | undefined {
|
|
160
|
+
if (source === undefined) {
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
const text = source.trim()
|
|
164
|
+
if (text === 'true') {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
if (text === 'false') {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
if (/^-?\d+(?:\.\d+)?$/.test(text)) {
|
|
171
|
+
return Number(text)
|
|
172
|
+
}
|
|
173
|
+
const literal = STRING_LITERAL.exec(text)
|
|
174
|
+
return literal && !literal[2]!.includes(literal[1]!) ? literal[2] : undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- Shared labels ----------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function sharedKeys(
|
|
180
|
+
pick: (block: (typeof BLOCK_PRESETS)[string]) => Array<string | undefined>,
|
|
181
|
+
prefix: string
|
|
182
|
+
): Set<string> {
|
|
183
|
+
const names = new Set<string>()
|
|
184
|
+
for (const preset of Object.values(BLOCK_PRESETS)) {
|
|
185
|
+
for (const label of pick(preset)) {
|
|
186
|
+
if (label?.startsWith(prefix)) {
|
|
187
|
+
names.add(label.slice(prefix.length))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return names
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const PROP_PREFIX = 'docs.blocks.props.'
|
|
195
|
+
const SLOT_PREFIX = 'docs.blocks.slots.'
|
|
196
|
+
let sharedProps: Set<string> | undefined
|
|
197
|
+
let sharedSlots: Set<string> | undefined
|
|
198
|
+
|
|
199
|
+
/** The shared label key of a prop (`icon`, `to`, `title`...) when the presets have one. */
|
|
200
|
+
function propLabelKey(name: string): string | undefined {
|
|
201
|
+
sharedProps ??= sharedKeys(
|
|
202
|
+
(preset) => Object.values(preset.props ?? {}).map((prop) => prop.label),
|
|
203
|
+
PROP_PREFIX
|
|
204
|
+
)
|
|
205
|
+
return sharedProps.has(name) ? `${PROP_PREFIX}${name}` : undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function slotLabelKey(name: string): string | undefined {
|
|
209
|
+
sharedSlots ??= sharedKeys(
|
|
210
|
+
(preset) => Object.values(preset.slots ?? {}).map((slot) => slot.label),
|
|
211
|
+
SLOT_PREFIX
|
|
212
|
+
)
|
|
213
|
+
return sharedSlots.has(name) ? `${SLOT_PREFIX}${name}` : undefined
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- The block --------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function propFromMeta(prop: PlutoMetaProp): PlutoBlockPropPatch | undefined {
|
|
219
|
+
const widget = widgetFor(prop.name, typeMembers(prop))
|
|
220
|
+
if (!widget) {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
const label = propLabelKey(prop.name)
|
|
224
|
+
const fallback = parseDefault(prop.default)
|
|
225
|
+
return {
|
|
226
|
+
...widget,
|
|
227
|
+
...(label ? { label } : {}),
|
|
228
|
+
...(prop.description ? { description: prop.description.trim() } : {}),
|
|
229
|
+
...(fallback !== undefined ? { default: fallback } : {}),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** `mdc-unwrap` on a `<slot>` shows as the binding `mdcUnwrap` in the meta. */
|
|
234
|
+
function hasUnwrap(slot: PlutoMetaSlot): boolean {
|
|
235
|
+
const schema = slot.schema
|
|
236
|
+
return (
|
|
237
|
+
(typeof schema === 'object' &&
|
|
238
|
+
typeof schema.schema === 'object' &&
|
|
239
|
+
'mdcUnwrap' in schema.schema) ||
|
|
240
|
+
/\bmdcUnwrap\b/.test(slot.type ?? '')
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function slotFromMeta(slot: PlutoMetaSlot): PlutoBlockSlotDefinition {
|
|
245
|
+
const label = slotLabelKey(slot.name)
|
|
246
|
+
return {
|
|
247
|
+
...(label ? { label } : {}),
|
|
248
|
+
...(hasUnwrap(slot) ? { content: 'inline' as const } : {}),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The component layer of one component: the props and slots of its meta, joined with the object of
|
|
254
|
+
* its `definePlutoBlock({...})` call. The macro wins, key by key (`false` removes a prop or a slot).
|
|
255
|
+
* `name` is the component name in Pascal case or kebab case. The result has the block key for the
|
|
256
|
+
* `PlutoBlocksConfig` of the component layer.
|
|
257
|
+
*
|
|
258
|
+
* Props that no widget fits (an object, an array, a function, a number literal) are left out. A
|
|
259
|
+
* page that sets one still shows it in Advanced. The macro can give such a prop a widget.
|
|
260
|
+
*/
|
|
261
|
+
export function blockFromComponentMeta(
|
|
262
|
+
name: string,
|
|
263
|
+
meta: PlutoComponentMeta,
|
|
264
|
+
macro?: PlutoBlockPatch | false
|
|
265
|
+
): { name: string; definition: PlutoBlockPatch } {
|
|
266
|
+
const props: Record<string, PlutoBlockPropPatch> = {}
|
|
267
|
+
for (const prop of meta.props ?? []) {
|
|
268
|
+
const definition = propFromMeta(prop)
|
|
269
|
+
if (definition) {
|
|
270
|
+
props[prop.name] = definition
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const slots: Record<string, PlutoBlockSlotDefinition> = {}
|
|
274
|
+
for (const slot of meta.slots ?? []) {
|
|
275
|
+
slots[slot.name] = slotFromMeta(slot)
|
|
276
|
+
}
|
|
277
|
+
const fromMeta: PlutoBlockPatch = {
|
|
278
|
+
...(Object.keys(props).length ? { props } : {}),
|
|
279
|
+
...(Object.keys(slots).length ? { slots } : {}),
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
name: componentBlockName(name),
|
|
283
|
+
definition: mergeBlockPatch(fromMeta, macro),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PlutoBlockPatch } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Describes a block in `app.config.ts`, or in a component file of `components/content/`.
|
|
5
|
+
* The module adds it as an auto-import, so a component file needs no import line.
|
|
6
|
+
*
|
|
7
|
+
* At run time it does nothing: it returns its argument. The build reads the call in the file and
|
|
8
|
+
* makes the block definition from it. See the block-model skill. With a generic,
|
|
9
|
+
* `definePlutoBlock<Props>({...})` checks that the keys of `props` are props of the component.
|
|
10
|
+
*/
|
|
11
|
+
export function definePlutoBlock<
|
|
12
|
+
Props extends object = Record<string, unknown>,
|
|
13
|
+
>(definition: PlutoBlockPatch<Props>): PlutoBlockPatch<Props> {
|
|
14
|
+
return definition
|
|
15
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Pure helpers for the block editor UI: the label, icon and excerpt of a block,
|
|
2
|
+
// the Markdown of a slot, the blocks sent to the preview, and the palette
|
|
3
|
+
// entries. No Nuxt import, so plain Vitest tests them.
|
|
4
|
+
|
|
5
|
+
import type { PlutoPreviewBlock, PlutoPreviewChild } from './preview-protocol'
|
|
6
|
+
import type {
|
|
7
|
+
PlutoBlock,
|
|
8
|
+
PlutoBlockDefinition,
|
|
9
|
+
PlutoBlockDoc,
|
|
10
|
+
PlutoBlockSources,
|
|
11
|
+
PlutoComponentBlock,
|
|
12
|
+
} from './types'
|
|
13
|
+
import { blockMarkdown } from './operations'
|
|
14
|
+
import { BLOCK_KIND_LABELS } from './presets'
|
|
15
|
+
import { listPaletteBlocks, resolveBlockDefinition } from './registry'
|
|
16
|
+
import { renderBlock } from './serialize'
|
|
17
|
+
import { findBlock, flatChildren, pathOf } from './tree'
|
|
18
|
+
|
|
19
|
+
export interface PlutoBlockInfo {
|
|
20
|
+
/** A plain text or an i18n key. */
|
|
21
|
+
label: string
|
|
22
|
+
icon: string
|
|
23
|
+
/** A short plain text from the content of the block. */
|
|
24
|
+
excerpt: string
|
|
25
|
+
/** The name of the component, for a component block. */
|
|
26
|
+
name?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HEADING = /^#{1,6} +(\S.*)$/m
|
|
30
|
+
|
|
31
|
+
/** Removes the Markdown marks of a short text, so it reads as plain text in a list. */
|
|
32
|
+
export function plainExcerpt(markdown: string, max = 48): string {
|
|
33
|
+
const text = markdown
|
|
34
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
35
|
+
.replace(/[`*_~>#]/g, '')
|
|
36
|
+
.replace(/\{[^}]*\}/g, '')
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function textExcerpt(markdown: string): string {
|
|
43
|
+
const heading = HEADING.exec(markdown)
|
|
44
|
+
if (heading) {
|
|
45
|
+
return plainExcerpt(heading[1]!.replace(/ +#+$/, ''))
|
|
46
|
+
}
|
|
47
|
+
const first = markdown
|
|
48
|
+
.split('\n')
|
|
49
|
+
.find((line) => line.trim() !== '' && !/^\s*(?:---|:::?)/.test(line))
|
|
50
|
+
return plainExcerpt(first ?? '')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The first line of text in a component, for the excerpt of a block that has no title. */
|
|
54
|
+
function firstText(component: PlutoComponentBlock): string {
|
|
55
|
+
for (const child of flatChildren(component)) {
|
|
56
|
+
if (child.kind === 'text') {
|
|
57
|
+
return textExcerpt(child.markdown)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The label, the icon and the excerpt of a block in the outline. */
|
|
64
|
+
export function describeBlock(
|
|
65
|
+
block: PlutoBlock,
|
|
66
|
+
sources: PlutoBlockSources = {}
|
|
67
|
+
): PlutoBlockInfo {
|
|
68
|
+
switch (block.kind) {
|
|
69
|
+
case 'text': {
|
|
70
|
+
const heading = HEADING.test(block.markdown)
|
|
71
|
+
return {
|
|
72
|
+
label: BLOCK_KIND_LABELS.text,
|
|
73
|
+
icon: heading ? 'i-lucide-heading' : 'i-lucide-pilcrow',
|
|
74
|
+
excerpt: textExcerpt(block.markdown),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
case 'code':
|
|
78
|
+
return {
|
|
79
|
+
label: BLOCK_KIND_LABELS.code,
|
|
80
|
+
icon: 'i-lucide-code-xml',
|
|
81
|
+
excerpt: block.filename || block.lang || '',
|
|
82
|
+
}
|
|
83
|
+
case 'raw':
|
|
84
|
+
return {
|
|
85
|
+
label:
|
|
86
|
+
block.reason === 'table'
|
|
87
|
+
? 'docs.blocks.kinds.table'
|
|
88
|
+
: BLOCK_KIND_LABELS.raw,
|
|
89
|
+
icon:
|
|
90
|
+
block.reason === 'table' ? 'i-lucide-table' : 'i-lucide-file-text',
|
|
91
|
+
excerpt: block.reason === 'table' ? '' : textExcerpt(block.markdown),
|
|
92
|
+
}
|
|
93
|
+
case 'component': {
|
|
94
|
+
const definition = resolveBlockDefinition(block.name, { sources })
|
|
95
|
+
const title =
|
|
96
|
+
typeof block.props.title === 'string'
|
|
97
|
+
? block.props.title
|
|
98
|
+
: typeof block.props.label === 'string'
|
|
99
|
+
? block.props.label
|
|
100
|
+
: ''
|
|
101
|
+
return {
|
|
102
|
+
label: definition.label,
|
|
103
|
+
icon: definition.icon,
|
|
104
|
+
excerpt: plainExcerpt(title || firstText(block)),
|
|
105
|
+
name: block.name,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The Markdown of the children of one slot, without the white space of the parent. */
|
|
112
|
+
export function slotMarkdown(
|
|
113
|
+
component: PlutoComponentBlock,
|
|
114
|
+
slot: string
|
|
115
|
+
): string {
|
|
116
|
+
return (component.slots[slot] ?? [])
|
|
117
|
+
.map((child) => blockMarkdown(child))
|
|
118
|
+
.join('\n\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type PlutoSlotMode = 'rich' | 'inline' | 'blocks' | 'markdown'
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The editor for a slot. A `rich` or `inline` slot falls back when its content
|
|
125
|
+
* has a block or a syntax that the text editor would lose: it then shows the
|
|
126
|
+
* blocks, or a Markdown box.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveSlotMode(
|
|
129
|
+
component: PlutoComponentBlock,
|
|
130
|
+
slot: string,
|
|
131
|
+
wanted: 'rich' | 'inline' | 'blocks' | undefined
|
|
132
|
+
): PlutoSlotMode {
|
|
133
|
+
const children = component.slots[slot] ?? []
|
|
134
|
+
if (wanted === 'blocks') {
|
|
135
|
+
return 'blocks'
|
|
136
|
+
}
|
|
137
|
+
// A component or a code block inside the slot needs the list of blocks.
|
|
138
|
+
if (
|
|
139
|
+
!children.every((child) => child.kind === 'text' || child.kind === 'raw')
|
|
140
|
+
) {
|
|
141
|
+
return 'blocks'
|
|
142
|
+
}
|
|
143
|
+
// The parser turns prose that the text editor would lose into a raw block. Markdown keeps it as it is.
|
|
144
|
+
if (children.some((child) => child.kind === 'raw')) {
|
|
145
|
+
return 'markdown'
|
|
146
|
+
}
|
|
147
|
+
const markdown = slotMarkdown(component, slot)
|
|
148
|
+
if (wanted === 'inline') {
|
|
149
|
+
return markdown.includes('\n') ? 'markdown' : 'inline'
|
|
150
|
+
}
|
|
151
|
+
return 'rich'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The block that the preview shows as selected: the deepest component on the
|
|
156
|
+
* path of the selected block, else its top-level block. The preview marks only
|
|
157
|
+
* the top-level blocks and the inner components, so a text inside a card shows
|
|
158
|
+
* the card.
|
|
159
|
+
*/
|
|
160
|
+
export function previewSelectionId(
|
|
161
|
+
doc: PlutoBlockDoc,
|
|
162
|
+
id: string | null
|
|
163
|
+
): string | null {
|
|
164
|
+
if (!id) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const path = pathOf(doc, id)
|
|
168
|
+
for (let index = path.length - 1; index > 0; index--) {
|
|
169
|
+
if (findBlock(doc, path[index]!)?.block.kind === 'component') {
|
|
170
|
+
return path[index]!
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return path[0] ?? null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The top-level blocks as the preview frame wants them. */
|
|
177
|
+
export function toPreviewBlocks(
|
|
178
|
+
blocks: PlutoBlock[],
|
|
179
|
+
sources: PlutoBlockSources = {}
|
|
180
|
+
): PlutoPreviewBlock[] {
|
|
181
|
+
return blocks.map((block) => {
|
|
182
|
+
const markdown = renderBlock(block)
|
|
183
|
+
if (block.kind !== 'component') {
|
|
184
|
+
return { id: block.id, markdown }
|
|
185
|
+
}
|
|
186
|
+
const definition = resolveBlockDefinition(block.name, { sources })
|
|
187
|
+
const children = previewChildren(block, sources)
|
|
188
|
+
return {
|
|
189
|
+
id: block.id,
|
|
190
|
+
markdown,
|
|
191
|
+
label: definition.label,
|
|
192
|
+
...(definition.preview === false ? { preview: false } : {}),
|
|
193
|
+
...(children.length ? { children } : {}),
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The inner components of a component, in source order over all slots, for
|
|
200
|
+
* the marks of the preview (a click selects an inner block). Text, code and raw
|
|
201
|
+
* children are not here: a click on them selects the nearest component.
|
|
202
|
+
*/
|
|
203
|
+
export function previewChildren(
|
|
204
|
+
component: PlutoComponentBlock,
|
|
205
|
+
sources: PlutoBlockSources = {}
|
|
206
|
+
): PlutoPreviewChild[] {
|
|
207
|
+
return flatChildren(component)
|
|
208
|
+
.filter((child): child is PlutoComponentBlock => child.kind === 'component')
|
|
209
|
+
.map((child) => {
|
|
210
|
+
const children = previewChildren(child, sources)
|
|
211
|
+
return {
|
|
212
|
+
id: child.id,
|
|
213
|
+
name: child.name,
|
|
214
|
+
label: resolveBlockDefinition(child.name, { sources, block: child })
|
|
215
|
+
.label,
|
|
216
|
+
...(children.length ? { children } : {}),
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The component names that a slot accepts, or `undefined` for any. */
|
|
222
|
+
export function acceptedNames(
|
|
223
|
+
definition: PlutoBlockDefinition,
|
|
224
|
+
slot: string
|
|
225
|
+
): string[] | undefined {
|
|
226
|
+
return definition.slots?.[slot]?.accepts
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export type PlutoPaletteEntry =
|
|
230
|
+
| {
|
|
231
|
+
id: string
|
|
232
|
+
kind: 'text'
|
|
233
|
+
group: string
|
|
234
|
+
label: string
|
|
235
|
+
description: string
|
|
236
|
+
icon: string
|
|
237
|
+
markdown: string
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
id: string
|
|
241
|
+
kind: 'code'
|
|
242
|
+
group: string
|
|
243
|
+
label: string
|
|
244
|
+
description: string
|
|
245
|
+
icon: string
|
|
246
|
+
markdown: string
|
|
247
|
+
}
|
|
248
|
+
| {
|
|
249
|
+
id: string
|
|
250
|
+
kind: 'table'
|
|
251
|
+
group: string
|
|
252
|
+
label: string
|
|
253
|
+
description: string
|
|
254
|
+
icon: string
|
|
255
|
+
markdown: string
|
|
256
|
+
}
|
|
257
|
+
| {
|
|
258
|
+
id: string
|
|
259
|
+
kind: 'component'
|
|
260
|
+
group: string
|
|
261
|
+
label: string
|
|
262
|
+
description: string
|
|
263
|
+
icon: string
|
|
264
|
+
name: string
|
|
265
|
+
markdown: string
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface PlutoPaletteGroup {
|
|
269
|
+
group: string
|
|
270
|
+
entries: PlutoPaletteEntry[]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const TABLE_MARKDOWN =
|
|
274
|
+
'| Column | Column |\n| ------ | ------ |\n| Cell | Cell |'
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The palette: the built-in blocks (text, code, table) first, then the
|
|
278
|
+
* component groups. `accepts` limits the list to some component names, for a
|
|
279
|
+
* target inside a component that takes only some children.
|
|
280
|
+
*/
|
|
281
|
+
export function buildPalette(
|
|
282
|
+
sources: PlutoBlockSources = {},
|
|
283
|
+
accepts?: string[]
|
|
284
|
+
): PlutoPaletteGroup[] {
|
|
285
|
+
const groups: PlutoPaletteGroup[] = []
|
|
286
|
+
if (!accepts) {
|
|
287
|
+
groups.push({
|
|
288
|
+
group: 'text',
|
|
289
|
+
entries: [
|
|
290
|
+
{
|
|
291
|
+
id: 'builtin:text',
|
|
292
|
+
kind: 'text',
|
|
293
|
+
group: 'text',
|
|
294
|
+
label: BLOCK_KIND_LABELS.text,
|
|
295
|
+
description: 'docs.blocks.kinds.textDescription',
|
|
296
|
+
icon: 'i-lucide-pilcrow',
|
|
297
|
+
markdown: '## Title\n\nWrite your text here.',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'builtin:table',
|
|
301
|
+
kind: 'table',
|
|
302
|
+
group: 'text',
|
|
303
|
+
label: 'docs.blocks.kinds.table',
|
|
304
|
+
description: 'docs.blocks.kinds.tableDescription',
|
|
305
|
+
icon: 'i-lucide-table',
|
|
306
|
+
markdown: TABLE_MARKDOWN,
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
groups.push({
|
|
311
|
+
group: 'code',
|
|
312
|
+
entries: [
|
|
313
|
+
{
|
|
314
|
+
id: 'builtin:code',
|
|
315
|
+
kind: 'code',
|
|
316
|
+
group: 'code',
|
|
317
|
+
label: BLOCK_KIND_LABELS.code,
|
|
318
|
+
description: 'docs.blocks.kinds.codeDescription',
|
|
319
|
+
icon: 'i-lucide-code-xml',
|
|
320
|
+
markdown: "```ts\nconst hello = 'world'\n```",
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
for (const { group, items } of listPaletteBlocks(sources)) {
|
|
326
|
+
const entries = items
|
|
327
|
+
.filter((item) => !accepts || accepts.includes(item.name))
|
|
328
|
+
.map((item): PlutoPaletteEntry => ({
|
|
329
|
+
id: `component:${item.name}`,
|
|
330
|
+
kind: 'component',
|
|
331
|
+
group,
|
|
332
|
+
label: item.label,
|
|
333
|
+
description: item.description ?? '',
|
|
334
|
+
icon: item.icon,
|
|
335
|
+
name: item.name,
|
|
336
|
+
markdown: item.insert ?? `::${item.name}\n::`,
|
|
337
|
+
}))
|
|
338
|
+
if (entries.length === 0) {
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
const existing = groups.find((candidate) => candidate.group === group)
|
|
342
|
+
if (existing) {
|
|
343
|
+
existing.entries.push(...entries)
|
|
344
|
+
} else {
|
|
345
|
+
groups.push({ group, entries })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return groups
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** How many blocks a block holds, itself included. */
|
|
352
|
+
export function countBlocks(block: PlutoBlock): number {
|
|
353
|
+
return block.kind === 'component'
|
|
354
|
+
? 1 +
|
|
355
|
+
flatChildren(block).reduce((sum, child) => sum + countBlocks(child), 0)
|
|
356
|
+
: 1
|
|
357
|
+
}
|