@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,295 @@
|
|
|
1
|
+
// Join the presets, the component layer, the site overrides (`plutoBlocks` in
|
|
2
|
+
// app.config.ts) and the props found in the document into one definition for
|
|
3
|
+
// each component. Pure functions: the caller reads the app config and passes
|
|
4
|
+
// the sources in.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
PlutoBlock,
|
|
8
|
+
PlutoBlockDefinition,
|
|
9
|
+
PlutoBlockOverride,
|
|
10
|
+
PlutoBlockPatch,
|
|
11
|
+
PlutoBlockPropDefinition,
|
|
12
|
+
PlutoBlocksConfig,
|
|
13
|
+
PlutoBlockSlotDefinition,
|
|
14
|
+
PlutoBlockSources,
|
|
15
|
+
PlutoComponentBlock,
|
|
16
|
+
} from './types'
|
|
17
|
+
import { BLOCK_GROUPS, BLOCK_PRESETS, HIDDEN_PROP_NAMES } from './presets'
|
|
18
|
+
|
|
19
|
+
// The typed identity helper is in its own file, so a component file that auto-imports it does not load the registry.
|
|
20
|
+
export { definePlutoBlock } from './define'
|
|
21
|
+
|
|
22
|
+
export interface ResolveOptions {
|
|
23
|
+
/** The layers: the component layer and the `plutoBlocks` value of the app config. */
|
|
24
|
+
sources?: PlutoBlockSources
|
|
25
|
+
/** A block of the doc. Its props and slots fill what the definition does not list. */
|
|
26
|
+
block?: PlutoComponentBlock
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A readable name from a component name: `pluto-hero` gives `Pluto hero`, `starCount` gives `Star count`. */
|
|
30
|
+
export function humanizeName(name: string): string {
|
|
31
|
+
const text = name
|
|
32
|
+
.replace(/^u-/, '')
|
|
33
|
+
.replace(
|
|
34
|
+
/([a-z0-9])([A-Z])/g,
|
|
35
|
+
(_, before: string, letter: string) => `${before} ${letter.toLowerCase()}`
|
|
36
|
+
)
|
|
37
|
+
.replace(/[-_]+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
return text ? text[0]!.toUpperCase() + text.slice(1) : name
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The key of a component in the sources: the kebab name that `pascalCase` turns back into the
|
|
44
|
+
* component name. `PlutoHero` gives `pluto-hero`, and `UPageCTA` gives `u-page-c-t-a`. A name that
|
|
45
|
+
* is already kebab case stays as it is.
|
|
46
|
+
*/
|
|
47
|
+
export function blockKey(name: string): string {
|
|
48
|
+
return name.replace(/(?<!^|-)[A-Z]/g, (letter) => `-${letter}`).toLowerCase()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `star-count` gives `starCount`. */
|
|
52
|
+
export function camelKey(name: string): string {
|
|
53
|
+
return name.replace(/-+([a-z0-9])/gi, (_, letter: string) =>
|
|
54
|
+
letter.toUpperCase()
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The key of `map` that means `key`: the same key, else the same name in kebab or camel case. */
|
|
59
|
+
function sameKey(map: object, key: string): string {
|
|
60
|
+
if (key in map) {
|
|
61
|
+
return key
|
|
62
|
+
}
|
|
63
|
+
const camel = camelKey(key)
|
|
64
|
+
return Object.keys(map).find((other) => camelKey(other) === camel) ?? key
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function automaticDefinition(name: string): PlutoBlockDefinition {
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
label: humanizeName(name),
|
|
71
|
+
icon: 'i-lucide-puzzle',
|
|
72
|
+
group: 'site',
|
|
73
|
+
slots: { default: { content: 'blocks' } },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function propFromValue(value: unknown): PlutoBlockPropDefinition {
|
|
78
|
+
if (typeof value === 'boolean') {
|
|
79
|
+
return { type: 'boolean', advanced: true }
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === 'number') {
|
|
82
|
+
return { type: 'number', advanced: true }
|
|
83
|
+
}
|
|
84
|
+
const text = typeof value === 'string' ? value : ''
|
|
85
|
+
return {
|
|
86
|
+
type: typeof value === 'string' && !text.includes('\n') ? 'string' : 'text',
|
|
87
|
+
advanced: true,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The layer of one block in a config. The key can be the kebab name or any form of it. */
|
|
92
|
+
function layerFor(
|
|
93
|
+
config: PlutoBlocksConfig | undefined,
|
|
94
|
+
key: string
|
|
95
|
+
): PlutoBlockOverride | undefined {
|
|
96
|
+
if (!config) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
if (key in config) {
|
|
100
|
+
return config[key]
|
|
101
|
+
}
|
|
102
|
+
const other = Object.keys(config).find((name) => blockKey(name) === key)
|
|
103
|
+
return other === undefined ? undefined : config[other]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Join the entries of one map (props or slots) one by one. An entry of the patch changes the
|
|
108
|
+
* entry of the base key by key, and `false` removes it. An entry with no base starts from `fill`.
|
|
109
|
+
*/
|
|
110
|
+
function mergeEntries<T extends object>(
|
|
111
|
+
base: Record<string, T> | undefined,
|
|
112
|
+
patch: Record<string, Partial<T> | false | undefined> | undefined,
|
|
113
|
+
fill: Partial<T>
|
|
114
|
+
): Record<string, T> {
|
|
115
|
+
const out = { ...base }
|
|
116
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
const target = sameKey(out, key)
|
|
121
|
+
if (value === false) {
|
|
122
|
+
delete out[target]
|
|
123
|
+
} else {
|
|
124
|
+
out[target] = { ...fill, ...out[target], ...value } as T
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The same join for two patches: `false` stays in the result, so a layer above can still remove. */
|
|
131
|
+
function mergePatchEntries<T extends object>(
|
|
132
|
+
base: Record<string, Partial<T> | false | undefined> | undefined,
|
|
133
|
+
patch: Record<string, Partial<T> | false | undefined> | undefined
|
|
134
|
+
): Record<string, Partial<T> | false | undefined> {
|
|
135
|
+
const out = { ...base }
|
|
136
|
+
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
137
|
+
if (value === undefined) {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const target = sameKey(out, key)
|
|
141
|
+
const current = out[target]
|
|
142
|
+
out[target] =
|
|
143
|
+
value === false || current === undefined || current === false
|
|
144
|
+
? value
|
|
145
|
+
: { ...current, ...value }
|
|
146
|
+
}
|
|
147
|
+
return out
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Put a layer over a definition. The fields of the layer replace the fields of the base. Props and
|
|
152
|
+
* slots join key by key (shallow). `false` removes a prop or a slot. `false` for the whole block
|
|
153
|
+
* hides it.
|
|
154
|
+
*/
|
|
155
|
+
function mergeDefinition(
|
|
156
|
+
base: PlutoBlockDefinition,
|
|
157
|
+
layer: PlutoBlockOverride | undefined
|
|
158
|
+
): PlutoBlockDefinition {
|
|
159
|
+
if (layer === false) {
|
|
160
|
+
return { ...base, hidden: true }
|
|
161
|
+
}
|
|
162
|
+
if (typeof layer !== 'object' || layer === null) {
|
|
163
|
+
return base
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
...layer,
|
|
168
|
+
name: base.name,
|
|
169
|
+
props: mergeEntries(base.props, layer.props, { type: 'string' }),
|
|
170
|
+
slots: mergeEntries(base.slots, layer.slots, {}),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Put one patch over another, for example the in-file macro over the block made from the
|
|
176
|
+
* component meta. The result is still a patch: `false` for a prop or a slot stays in it.
|
|
177
|
+
*/
|
|
178
|
+
export function mergeBlockPatch(
|
|
179
|
+
base: PlutoBlockPatch,
|
|
180
|
+
over: PlutoBlockPatch | false | undefined
|
|
181
|
+
): PlutoBlockPatch {
|
|
182
|
+
if (over === undefined) {
|
|
183
|
+
return base
|
|
184
|
+
}
|
|
185
|
+
if (over === false) {
|
|
186
|
+
return { ...base, hidden: true }
|
|
187
|
+
}
|
|
188
|
+
const props = mergePatchEntries(base.props, over.props)
|
|
189
|
+
const slots = mergePatchEntries(base.slots, over.slots)
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
...over,
|
|
193
|
+
...(Object.keys(props).length ? { props } : {}),
|
|
194
|
+
...(Object.keys(slots).length ? { slots } : {}),
|
|
195
|
+
} as PlutoBlockPatch
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The slot content kind for a slot that no definition lists. */
|
|
199
|
+
function slotFromChildren(children: PlutoBlock[]): PlutoBlockSlotDefinition {
|
|
200
|
+
const onlyText = children.every((child) => child.kind === 'text')
|
|
201
|
+
return { content: onlyText ? 'rich' : 'blocks' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The definition of a component: the preset (or an automatic one), the
|
|
206
|
+
* component layer over it, the site override over that, and the props and
|
|
207
|
+
* slots of `options.block` that none of them lists. Found props go to the
|
|
208
|
+
* Advanced section.
|
|
209
|
+
*
|
|
210
|
+
* The name can be kebab case (`pluto-hero`) or the Pascal form that a page
|
|
211
|
+
* can use (`PlutoHero`). The definition has the kebab name. A prop of the
|
|
212
|
+
* definition takes the spelling that the block uses: when the page has
|
|
213
|
+
* `star-count` and the meta has `starCount`, the key is `star-count`, so an
|
|
214
|
+
* edit changes the prop of the page and adds no second one.
|
|
215
|
+
*/
|
|
216
|
+
export function resolveBlockDefinition(
|
|
217
|
+
name: string,
|
|
218
|
+
options: ResolveOptions = {}
|
|
219
|
+
): PlutoBlockDefinition {
|
|
220
|
+
const key = blockKey(name)
|
|
221
|
+
const { components, overrides } = options.sources ?? {}
|
|
222
|
+
const merged = mergeDefinition(
|
|
223
|
+
mergeDefinition(
|
|
224
|
+
BLOCK_PRESETS[key] ?? automaticDefinition(key),
|
|
225
|
+
layerFor(components, key)
|
|
226
|
+
),
|
|
227
|
+
layerFor(overrides, key)
|
|
228
|
+
)
|
|
229
|
+
const found = options.block?.kind === 'component' ? options.block : undefined
|
|
230
|
+
if (!found) {
|
|
231
|
+
return merged
|
|
232
|
+
}
|
|
233
|
+
const props: Record<string, PlutoBlockPropDefinition> = {}
|
|
234
|
+
for (const [prop, definition] of Object.entries(merged.props ?? {})) {
|
|
235
|
+
props[sameKey(found.props, prop)] = definition
|
|
236
|
+
}
|
|
237
|
+
for (const [prop, value] of Object.entries(found.props)) {
|
|
238
|
+
if (
|
|
239
|
+
!(prop in props) &&
|
|
240
|
+
!(HIDDEN_PROP_NAMES as readonly string[]).includes(prop)
|
|
241
|
+
) {
|
|
242
|
+
props[prop] = propFromValue(value)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const slots = { ...merged.slots }
|
|
246
|
+
for (const [slot, children] of Object.entries(found.slots)) {
|
|
247
|
+
if (!(slot in slots)) {
|
|
248
|
+
slots[slot] = slotFromChildren(children)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { ...merged, props, slots }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The text a palette inserts for a component. */
|
|
255
|
+
export function insertTextFor(definition: PlutoBlockDefinition): string {
|
|
256
|
+
return definition.insert ?? `::${definition.name}\n::`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface PaletteGroup {
|
|
260
|
+
group: string
|
|
261
|
+
items: PlutoBlockDefinition[]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The blocks of the palette, in groups. The presets come first, then the
|
|
266
|
+
* components of the component layer (a component shows only when it is in
|
|
267
|
+
* that layer), then the components that only the site config declares. A
|
|
268
|
+
* hidden block, or a block set to `false`, is not in the list.
|
|
269
|
+
*/
|
|
270
|
+
export function listPaletteBlocks(
|
|
271
|
+
sources: PlutoBlockSources = {}
|
|
272
|
+
): PaletteGroup[] {
|
|
273
|
+
const names = new Set([
|
|
274
|
+
...Object.keys(BLOCK_PRESETS),
|
|
275
|
+
...Object.keys(sources.components ?? {}).map(blockKey),
|
|
276
|
+
...Object.keys(sources.overrides ?? {}).map(blockKey),
|
|
277
|
+
])
|
|
278
|
+
const groups = new Map<string, PlutoBlockDefinition[]>()
|
|
279
|
+
for (const name of names) {
|
|
280
|
+
const definition = resolveBlockDefinition(name, { sources })
|
|
281
|
+
if (definition.hidden) {
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
groups.set(definition.group, [
|
|
285
|
+
...(groups.get(definition.group) ?? []),
|
|
286
|
+
definition,
|
|
287
|
+
])
|
|
288
|
+
}
|
|
289
|
+
const known = BLOCK_GROUPS as readonly string[]
|
|
290
|
+
const order = [
|
|
291
|
+
...known.filter((group) => groups.has(group)),
|
|
292
|
+
...[...groups.keys()].filter((group) => !known.includes(group)),
|
|
293
|
+
]
|
|
294
|
+
return order.map((group) => ({ group, items: groups.get(group)! }))
|
|
295
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Write a block doc back to text. A block with `original` writes its source
|
|
2
|
+
// bytes. The gaps between blocks are always the source text. Only an edited
|
|
3
|
+
// block is built from the model, so one edit changes only the lines of that block.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PlutoBlock,
|
|
7
|
+
PlutoBlockDoc,
|
|
8
|
+
PlutoCodeBlock,
|
|
9
|
+
PlutoComponentBlock,
|
|
10
|
+
} from './types'
|
|
11
|
+
import {
|
|
12
|
+
completeOrder,
|
|
13
|
+
isSameProps,
|
|
14
|
+
renderInlineProps,
|
|
15
|
+
renderYamlProps,
|
|
16
|
+
} from './mdc-props'
|
|
17
|
+
import { flatChildren } from './tree'
|
|
18
|
+
|
|
19
|
+
/** Puts the indent before each non-blank line but the first. */
|
|
20
|
+
export function indentLines(text: string, indent: string): string {
|
|
21
|
+
if (!indent) {
|
|
22
|
+
return text
|
|
23
|
+
}
|
|
24
|
+
return text
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line, index) => (index > 0 && line !== '' ? indent + line : line))
|
|
27
|
+
.join('\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderCode(block: PlutoCodeBlock): string {
|
|
31
|
+
const char = block.fence[0] ?? '`'
|
|
32
|
+
let fence = block.fence.length >= 3 ? block.fence : char.repeat(3)
|
|
33
|
+
// A line of the code that looks like the fence needs a longer fence.
|
|
34
|
+
for (const line of block.code.split('\n')) {
|
|
35
|
+
const match = /^\s*([`~]{3,})\s*$/.exec(line)
|
|
36
|
+
if (match && match[1]![0] === char && match[1]!.length >= fence.length) {
|
|
37
|
+
fence = char.repeat(match[1]!.length + 1)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const info = [
|
|
41
|
+
block.lang,
|
|
42
|
+
block.filename === undefined ? '' : `[${block.filename}]`,
|
|
43
|
+
block.meta ?? '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(' ')
|
|
47
|
+
const body = block.code === '' ? '' : `\n${block.code}`
|
|
48
|
+
return indentLines(`${fence}${info}${body}\n${fence}`, block.indent ?? '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The opening line of a component, with its props. Unchanged props give the source text. */
|
|
52
|
+
export function renderOpener(block: PlutoComponentBlock): string {
|
|
53
|
+
if (isSameProps(block.props, block.propOrder, block.propSource)) {
|
|
54
|
+
return block.openerText
|
|
55
|
+
}
|
|
56
|
+
const order = completeOrder(block.props, block.propOrder)
|
|
57
|
+
const head = `${':'.repeat(block.colons)}${block.name}`
|
|
58
|
+
if (block.propsStyle === 'yaml' && block.yamlHead !== undefined) {
|
|
59
|
+
const entries = renderYamlProps(block.props, order, block.propSource)
|
|
60
|
+
if (entries.length === 0) {
|
|
61
|
+
return head
|
|
62
|
+
}
|
|
63
|
+
const fenceIndent = block.yamlHead.slice(
|
|
64
|
+
block.yamlHead.lastIndexOf('\n') + 1
|
|
65
|
+
)
|
|
66
|
+
const yamlIndent = block.yamlIndent ?? ''
|
|
67
|
+
const inner = entries
|
|
68
|
+
.map((entry) => yamlIndent + indentLines(entry, yamlIndent))
|
|
69
|
+
.join('\n')
|
|
70
|
+
return `${head}${block.yamlHead}---\n${inner}\n${fenceIndent}---`
|
|
71
|
+
}
|
|
72
|
+
const body = renderInlineProps(block.props, order, block.propSource)
|
|
73
|
+
return body ? `${head}{${body}}` : head
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderComponent(block: PlutoComponentBlock): string {
|
|
77
|
+
const children = flatChildren(block)
|
|
78
|
+
let out = renderOpener(block)
|
|
79
|
+
for (const [index, child] of children.entries()) {
|
|
80
|
+
out += (block.gaps[index] ?? '') + renderBlock(child)
|
|
81
|
+
}
|
|
82
|
+
return `${out}${block.gaps[children.length] ?? ''}${':'.repeat(block.colons)}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The text of one block, without the white space before its first line. */
|
|
86
|
+
export function renderBlock(block: PlutoBlock): string {
|
|
87
|
+
if (block.original) {
|
|
88
|
+
return block.original.text
|
|
89
|
+
}
|
|
90
|
+
switch (block.kind) {
|
|
91
|
+
case 'text':
|
|
92
|
+
case 'raw':
|
|
93
|
+
return indentLines(block.markdown, block.indent ?? '')
|
|
94
|
+
case 'code':
|
|
95
|
+
return renderCode(block)
|
|
96
|
+
case 'component':
|
|
97
|
+
return renderComponent(block)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function serializeBlocks(doc: PlutoBlockDoc): string {
|
|
102
|
+
let out = doc.gaps[0] ?? ''
|
|
103
|
+
for (const [index, block] of doc.blocks.entries()) {
|
|
104
|
+
out += renderBlock(block) + (doc.gaps[index + 1] ?? '')
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Helpers to walk the block tree. No Nuxt import.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PlutoBlock,
|
|
5
|
+
PlutoBlockDoc,
|
|
6
|
+
PlutoBlockLocation,
|
|
7
|
+
PlutoComponentBlock,
|
|
8
|
+
} from './types'
|
|
9
|
+
|
|
10
|
+
/** The children of a component in source order, over all slots. */
|
|
11
|
+
export function flatChildren(component: PlutoComponentBlock): PlutoBlock[] {
|
|
12
|
+
return Object.values(component.slots).flat()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Calls `visit` for each block, parents first. */
|
|
16
|
+
export function walkBlocks(
|
|
17
|
+
blocks: PlutoBlock[],
|
|
18
|
+
visit: (block: PlutoBlock, parent?: PlutoComponentBlock) => void,
|
|
19
|
+
parent?: PlutoComponentBlock
|
|
20
|
+
): void {
|
|
21
|
+
for (const block of blocks) {
|
|
22
|
+
visit(block, parent)
|
|
23
|
+
if (block.kind === 'component') {
|
|
24
|
+
walkBlocks(flatChildren(block), visit, block)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function findBlock(
|
|
30
|
+
doc: PlutoBlockDoc,
|
|
31
|
+
id: string
|
|
32
|
+
): PlutoBlockLocation | undefined {
|
|
33
|
+
const search = (
|
|
34
|
+
blocks: PlutoBlock[],
|
|
35
|
+
parent?: PlutoComponentBlock,
|
|
36
|
+
slot?: string
|
|
37
|
+
): PlutoBlockLocation | undefined => {
|
|
38
|
+
for (const [index, block] of blocks.entries()) {
|
|
39
|
+
if (block.id === id) {
|
|
40
|
+
return { block, parent, slot, index }
|
|
41
|
+
}
|
|
42
|
+
if (block.kind === 'component') {
|
|
43
|
+
for (const [name, children] of Object.entries(block.slots)) {
|
|
44
|
+
const found = search(children, block, name)
|
|
45
|
+
if (found) {
|
|
46
|
+
return found
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
return search(doc.blocks)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The ids from the top-level block down to the block. Empty when the id is unknown. */
|
|
57
|
+
export function pathOf(doc: PlutoBlockDoc, id: string): string[] {
|
|
58
|
+
const search = (
|
|
59
|
+
blocks: PlutoBlock[],
|
|
60
|
+
trail: string[]
|
|
61
|
+
): string[] | undefined => {
|
|
62
|
+
for (const block of blocks) {
|
|
63
|
+
const next = [...trail, block.id]
|
|
64
|
+
if (block.id === id) {
|
|
65
|
+
return next
|
|
66
|
+
}
|
|
67
|
+
if (block.kind === 'component') {
|
|
68
|
+
const found = search(flatChildren(block), next)
|
|
69
|
+
if (found) {
|
|
70
|
+
return found
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
return search(doc.blocks, []) ?? []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let counter = 0
|
|
80
|
+
|
|
81
|
+
/** A new id for a block that is not from a parse. */
|
|
82
|
+
export function newBlockId(): string {
|
|
83
|
+
counter++
|
|
84
|
+
return `n${counter}`
|
|
85
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Types of the block model. The model has no Nuxt import, so a package can
|
|
2
|
+
// own it later. The truth stays the Markdown string: a block with `original`
|
|
3
|
+
// is written back byte for byte.
|
|
4
|
+
|
|
5
|
+
/** A part of the source text that a block came from. */
|
|
6
|
+
export interface PlutoSlice {
|
|
7
|
+
start: number
|
|
8
|
+
end: number
|
|
9
|
+
text: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PlutoBlockBase {
|
|
13
|
+
id: string
|
|
14
|
+
/** The source text of the block. An edit removes it from the block and its ancestors. */
|
|
15
|
+
original?: PlutoSlice
|
|
16
|
+
/** The white space before the first line of the block, inside a component. */
|
|
17
|
+
indent?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PlutoTextBlock extends PlutoBlockBase {
|
|
21
|
+
kind: 'text'
|
|
22
|
+
/** Without the indent of the parent component. */
|
|
23
|
+
markdown: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlutoCodeBlock extends PlutoBlockBase {
|
|
27
|
+
kind: 'code'
|
|
28
|
+
lang: string
|
|
29
|
+
filename?: string
|
|
30
|
+
/** The rest of the info string after the language and the file name. */
|
|
31
|
+
meta?: string
|
|
32
|
+
/** The fence characters, for example three backticks. */
|
|
33
|
+
fence: string
|
|
34
|
+
/** Without the indent of the parent component. */
|
|
35
|
+
code: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PlutoPropValue = unknown
|
|
39
|
+
|
|
40
|
+
export type PlutoPropsStyle = 'inline' | 'yaml'
|
|
41
|
+
|
|
42
|
+
export interface PlutoComponentBlock extends PlutoBlockBase {
|
|
43
|
+
kind: 'component'
|
|
44
|
+
name: string
|
|
45
|
+
props: Record<string, PlutoPropValue>
|
|
46
|
+
/** Where the props are written: after the name `{a="b"}` or in a YAML block. */
|
|
47
|
+
propsStyle: PlutoPropsStyle
|
|
48
|
+
/** The order of the props in the source. New props go at the end. */
|
|
49
|
+
propOrder: string[]
|
|
50
|
+
/**
|
|
51
|
+
* The source of each prop, for a prop that has not changed. The key is a
|
|
52
|
+
* prop name. The value is the text of the token, or of the YAML entry.
|
|
53
|
+
*/
|
|
54
|
+
propSource: Record<string, { value: PlutoPropValue; text: string }>
|
|
55
|
+
/** The colons of the opening line. */
|
|
56
|
+
colons: number
|
|
57
|
+
/** The slots in the order of the source. `default` is the content before the first `#name`. */
|
|
58
|
+
slots: Record<string, PlutoBlock[]>
|
|
59
|
+
/**
|
|
60
|
+
* The text between the children, in source order, over all slots. It has the
|
|
61
|
+
* `#name` lines of the slots. Its length is the number of children plus one.
|
|
62
|
+
*/
|
|
63
|
+
gaps: string[]
|
|
64
|
+
/** For `yaml` style: the text between the opening line and the YAML block. */
|
|
65
|
+
yamlHead?: string
|
|
66
|
+
/** The opening line as it was in the source, for the check "did the props change". */
|
|
67
|
+
openerText: string
|
|
68
|
+
/** For `yaml` style: the indent of the YAML entries. */
|
|
69
|
+
yamlIndent?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type PlutoRawReason =
|
|
73
|
+
'table' | 'html' | 'inline-mdc' | 'footnote' | 'task-list' | 'unknown'
|
|
74
|
+
|
|
75
|
+
export interface PlutoRawBlock extends PlutoBlockBase {
|
|
76
|
+
kind: 'raw'
|
|
77
|
+
markdown: string
|
|
78
|
+
reason: PlutoRawReason
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PlutoBlock =
|
|
82
|
+
PlutoTextBlock | PlutoCodeBlock | PlutoComponentBlock | PlutoRawBlock
|
|
83
|
+
|
|
84
|
+
export interface PlutoBlockDoc {
|
|
85
|
+
blocks: PlutoBlock[]
|
|
86
|
+
/** The original text before, between and after the blocks. Its length is the number of blocks plus one. */
|
|
87
|
+
gaps: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where a block is in the tree. `parent` is undefined for a top-level block. */
|
|
91
|
+
export interface PlutoBlockLocation {
|
|
92
|
+
block: PlutoBlock
|
|
93
|
+
parent?: PlutoComponentBlock
|
|
94
|
+
slot?: string
|
|
95
|
+
/** The index in the list of the document, or in the slot. */
|
|
96
|
+
index: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Block definitions: what the editor shows for a component.
|
|
100
|
+
|
|
101
|
+
export type PlutoBlockPropType =
|
|
102
|
+
| 'string'
|
|
103
|
+
| 'text'
|
|
104
|
+
| 'boolean'
|
|
105
|
+
| 'number'
|
|
106
|
+
| 'select'
|
|
107
|
+
| 'icon'
|
|
108
|
+
| 'color'
|
|
109
|
+
| 'link'
|
|
110
|
+
|
|
111
|
+
export interface PlutoBlockPropOption {
|
|
112
|
+
value: string
|
|
113
|
+
/** A plain text or an i18n key. */
|
|
114
|
+
label?: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PlutoBlockPropDefinition {
|
|
118
|
+
type: PlutoBlockPropType
|
|
119
|
+
/** A plain text or an i18n key. */
|
|
120
|
+
label?: string
|
|
121
|
+
description?: string
|
|
122
|
+
options?: Array<string | PlutoBlockPropOption>
|
|
123
|
+
/** A prop in the Advanced section of the panel. */
|
|
124
|
+
advanced?: boolean
|
|
125
|
+
/**
|
|
126
|
+
* A slot with the same text, for example the `default` slot of a button and its `label`. The
|
|
127
|
+
* panel shows one field for both: it edits the slot when the block has text in it (the slot wins
|
|
128
|
+
* on the site), else the prop. The slot does not show in the Content section.
|
|
129
|
+
*/
|
|
130
|
+
slot?: string
|
|
131
|
+
/**
|
|
132
|
+
* The value the component uses when the prop is missing. The control shows it as the
|
|
133
|
+
* placeholder (or in the "Default" item of a choice). It never goes into the Markdown.
|
|
134
|
+
*/
|
|
135
|
+
default?: string | number | boolean
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The editor for the content of a slot. */
|
|
139
|
+
export type PlutoBlockSlotContent = 'rich' | 'inline' | 'blocks'
|
|
140
|
+
|
|
141
|
+
export interface PlutoBlockSlotDefinition {
|
|
142
|
+
label?: string
|
|
143
|
+
content?: PlutoBlockSlotContent
|
|
144
|
+
/** For `blocks`: the component names that the slot accepts. */
|
|
145
|
+
accepts?: string[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface PlutoBlockDefinition {
|
|
149
|
+
name: string
|
|
150
|
+
/** A plain text or an i18n key. */
|
|
151
|
+
label: string
|
|
152
|
+
/** A plain text or an i18n key. */
|
|
153
|
+
description?: string
|
|
154
|
+
icon: string
|
|
155
|
+
group: string
|
|
156
|
+
props?: Record<string, PlutoBlockPropDefinition>
|
|
157
|
+
slots?: Record<string, PlutoBlockSlotDefinition>
|
|
158
|
+
/** The Markdown that the palette inserts. */
|
|
159
|
+
insert?: string
|
|
160
|
+
/** `false` shows a placeholder card instead of the component in the preview. */
|
|
161
|
+
preview?: boolean
|
|
162
|
+
/** Not in the palette. The block still shows with its label. */
|
|
163
|
+
hidden?: boolean
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A change to one prop: a partial prop definition. A prop with no `type` is a `string`. */
|
|
167
|
+
export type PlutoBlockPropPatch = Partial<PlutoBlockPropDefinition>
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A partial block definition: one layer of the sources (the component layer, or the site
|
|
171
|
+
* override). `Props` is the prop interface of a component, so `definePlutoBlock<Props>()` can
|
|
172
|
+
* check the keys of `props`. A prop or a slot set to `false` is removed from the layers below.
|
|
173
|
+
*/
|
|
174
|
+
export interface PlutoBlockPatch<
|
|
175
|
+
Props extends object = Record<string, unknown>,
|
|
176
|
+
> extends Partial<Omit<PlutoBlockDefinition, 'name' | 'props' | 'slots'>> {
|
|
177
|
+
props?: { [Key in keyof Props & string]?: PlutoBlockPropPatch | false }
|
|
178
|
+
slots?: Record<string, PlutoBlockSlotDefinition | false>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One layer for one block: a partial definition, or `false` to hide the block from the palette. */
|
|
182
|
+
export type PlutoBlockOverride = PlutoBlockPatch | false
|
|
183
|
+
|
|
184
|
+
/** The blocks of one layer, by component name (kebab case). */
|
|
185
|
+
export type PlutoBlocksConfig = Record<string, PlutoBlockOverride>
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The layers that join the preset into a block definition. The component layer comes from the
|
|
189
|
+
* component files (it is empty until the build step fills it). The overrides are `plutoBlocks`
|
|
190
|
+
* in `app.config.ts`, and they win.
|
|
191
|
+
*/
|
|
192
|
+
export interface PlutoBlockSources {
|
|
193
|
+
components?: PlutoBlocksConfig
|
|
194
|
+
overrides?: PlutoBlocksConfig
|
|
195
|
+
}
|