@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.
Files changed (65) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +186 -0
  4. package/FEATURES.md +34 -0
  5. package/README.md +40 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +165 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +161 -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 +86 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +232 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +269 -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 +459 -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/blocks.ts +217 -0
  31. package/modules/pluto-docs/index.ts +230 -0
  32. package/modules/pluto-docs/manifest.ts +159 -0
  33. package/modules/pluto-docs/options.ts +258 -0
  34. package/nuxt.config.ts +36 -0
  35. package/package.json +56 -4
  36. package/server/handlers/navigation.get.ts +7 -0
  37. package/server/handlers/page.get.ts +7 -0
  38. package/server/handlers/surround.get.ts +7 -0
  39. package/server/plugins/pluto-docs.ts +25 -0
  40. package/server/utils/pluto-docs-runtime.ts +504 -0
  41. package/shared/types/docs.d.ts +40 -0
  42. package/shared/types/pluto-blocks.d.ts +10 -0
  43. package/shared/types/pluto-manifest.d.ts +6 -0
  44. package/shared/utils/blocks/component-meta.ts +285 -0
  45. package/shared/utils/blocks/define.ts +15 -0
  46. package/shared/utils/blocks/editor.ts +357 -0
  47. package/shared/utils/blocks/history.ts +94 -0
  48. package/shared/utils/blocks/mdc-props.ts +321 -0
  49. package/shared/utils/blocks/operations.ts +692 -0
  50. package/shared/utils/blocks/parse.ts +444 -0
  51. package/shared/utils/blocks/presets.ts +384 -0
  52. package/shared/utils/blocks/preview-marks.ts +98 -0
  53. package/shared/utils/blocks/preview-protocol.ts +195 -0
  54. package/shared/utils/blocks/registry.ts +295 -0
  55. package/shared/utils/blocks/serialize.ts +107 -0
  56. package/shared/utils/blocks/tree.ts +85 -0
  57. package/shared/utils/blocks/types.ts +195 -0
  58. package/shared/utils/content-route.ts +40 -0
  59. package/shared/utils/docs-type.ts +290 -0
  60. package/shared/utils/mdc-snippets.ts +114 -0
  61. package/shared/utils/navigation-config.ts +19 -0
  62. package/shared/utils/runtime-content.ts +403 -0
  63. package/tailwind.lint.css +10 -0
  64. package/tsconfig.json +18 -0
  65. 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
+ }