@plutocms/docs 0.15.0 → 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/CHANGELOG.md +8 -0
- package/FEATURES.md +4 -0
- package/README.md +1 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +4 -2
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +4 -2
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +16 -15
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +1 -2
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +32 -9
- package/app/composables/pluto-block-editor.ts +23 -7
- package/modules/pluto-docs/blocks.ts +217 -0
- package/modules/pluto-docs/index.ts +108 -1
- package/modules/pluto-docs/options.ts +35 -1
- package/package.json +3 -2
- package/shared/types/pluto-blocks.d.ts +10 -0
- package/shared/utils/blocks/component-meta.ts +285 -0
- package/shared/utils/blocks/define.ts +15 -0
- package/shared/utils/blocks/editor.ts +11 -11
- package/shared/utils/blocks/presets.ts +6 -5
- package/shared/utils/blocks/registry.ts +175 -43
- package/shared/utils/blocks/types.ts +33 -3
|
@@ -1,12 +1,22 @@
|
|
|
1
|
+
import type { Nuxt } from '@nuxt/schema'
|
|
1
2
|
import type { PlutoDocsOptions } from './options'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
import {
|
|
5
|
+
addImports,
|
|
4
6
|
addServerHandler,
|
|
7
|
+
addVitePlugin,
|
|
5
8
|
createResolver,
|
|
6
9
|
defineNuxtModule,
|
|
7
10
|
useLogger,
|
|
8
11
|
} from '@nuxt/kit'
|
|
9
12
|
import { PREVIEW_PATH } from '../../shared/utils/blocks/preview-protocol'
|
|
13
|
+
import {
|
|
14
|
+
BLOCK_MACRO,
|
|
15
|
+
BlockCatalog,
|
|
16
|
+
blocksModuleSource,
|
|
17
|
+
CONTENT_COMPONENTS_DIR,
|
|
18
|
+
MACRO_KEY,
|
|
19
|
+
} from './blocks'
|
|
10
20
|
import { buildContentManifest, manifestModuleSource } from './manifest'
|
|
11
21
|
import { resolvePlutoDocsOptions } from './options'
|
|
12
22
|
|
|
@@ -33,7 +43,7 @@ export default defineNuxtModule<PlutoDocsOptions>({
|
|
|
33
43
|
nuxt.options as { plutoGithub?: { enabled?: boolean | 'auto' } }
|
|
34
44
|
).plutoGithub
|
|
35
45
|
|
|
36
|
-
const { enabled, render, runtimeMaxAge, types, errors } =
|
|
46
|
+
const { enabled, render, runtimeMaxAge, types, blocks, errors } =
|
|
37
47
|
resolvePlutoDocsOptions(options, {
|
|
38
48
|
cmsEnabled: cms?.enabled !== false,
|
|
39
49
|
})
|
|
@@ -41,6 +51,17 @@ export default defineNuxtModule<PlutoDocsOptions>({
|
|
|
41
51
|
for (const error of errors) {
|
|
42
52
|
logger.error(error)
|
|
43
53
|
}
|
|
54
|
+
// `definePlutoBlock(...)` in a component file. A typed no-op at run time: the build reads the call.
|
|
55
|
+
addImports({
|
|
56
|
+
name: BLOCK_MACRO,
|
|
57
|
+
from: createResolver(import.meta.url).resolve(
|
|
58
|
+
'../../shared/utils/blocks/define'
|
|
59
|
+
),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// The editor imports the virtual module even when the layer is off, so it always exists.
|
|
63
|
+
setupBlockDiscovery(nuxt, logger, enabled && blocks.discover)
|
|
64
|
+
|
|
44
65
|
if (!enabled) {
|
|
45
66
|
return
|
|
46
67
|
}
|
|
@@ -121,3 +142,89 @@ export default defineNuxtModule<PlutoDocsOptions>({
|
|
|
121
142
|
}
|
|
122
143
|
},
|
|
123
144
|
})
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The component layer of the block editor. The virtual module `#pluto-docs/blocks` holds the blocks
|
|
148
|
+
* of the `components/content/` folders. Only the two lazy editor roots import it, so it stays out of
|
|
149
|
+
* the public pages. The server build gets an empty object: the editor never renders on the server.
|
|
150
|
+
* The cost comes when the editor first loads, not at start-up. A change to a component file makes
|
|
151
|
+
* the module load again, and the catalog parses only the file that changed.
|
|
152
|
+
*/
|
|
153
|
+
function setupBlockDiscovery(
|
|
154
|
+
nuxt: Nuxt,
|
|
155
|
+
logger: ReturnType<typeof useLogger>,
|
|
156
|
+
discover: boolean
|
|
157
|
+
) {
|
|
158
|
+
const id = '#pluto-docs/blocks'
|
|
159
|
+
// The `\0` prefix marks a virtual id. No `#` in it: a browser URL ends at the `#`.
|
|
160
|
+
const resolved = '\0pluto-docs-blocks'
|
|
161
|
+
// The app first, then its layers (the order of priority).
|
|
162
|
+
const dirs = () =>
|
|
163
|
+
nuxt.options._layers.map((layer) =>
|
|
164
|
+
join(layer.config.srcDir, CONTENT_COMPONENTS_DIR)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
// The cache of the parser names a file from the text after the last slash, so give it slashes.
|
|
168
|
+
const slashes = (path: string) => path.replaceAll('\\', '/')
|
|
169
|
+
|
|
170
|
+
type Reader = typeof import('nuxt-component-meta/parser').getComponentMeta
|
|
171
|
+
let getComponentMeta: Reader | undefined
|
|
172
|
+
const catalog = new BlockCatalog(
|
|
173
|
+
dirs,
|
|
174
|
+
(file) =>
|
|
175
|
+
getComponentMeta!(slashes(file), {
|
|
176
|
+
rootDir: nuxt.options.rootDir,
|
|
177
|
+
cache: true,
|
|
178
|
+
cacheDir: join(nuxt.options.buildDir, 'cache/pluto-blocks'),
|
|
179
|
+
extendMetaFunctions: [
|
|
180
|
+
{ name: BLOCK_MACRO, transform: (arg) => ({ [MACRO_KEY]: arg }) },
|
|
181
|
+
],
|
|
182
|
+
}) as never,
|
|
183
|
+
logger
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const isBlockFile = (file: string) =>
|
|
187
|
+
file.endsWith('.vue') &&
|
|
188
|
+
dirs().some((dir) => slashes(file).startsWith(slashes(dir)))
|
|
189
|
+
|
|
190
|
+
addVitePlugin({
|
|
191
|
+
name: 'pluto-docs:blocks',
|
|
192
|
+
enforce: 'pre',
|
|
193
|
+
resolveId(source) {
|
|
194
|
+
return source === id ? resolved : undefined
|
|
195
|
+
},
|
|
196
|
+
async load(loaded, options) {
|
|
197
|
+
if (loaded !== resolved) {
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
if (options?.ssr || !discover) {
|
|
201
|
+
return blocksModuleSource({})
|
|
202
|
+
}
|
|
203
|
+
// The parser loads here, not at start-up, because it brings the TypeScript compiler.
|
|
204
|
+
getComponentMeta ||= (await import('nuxt-component-meta/parser'))
|
|
205
|
+
.getComponentMeta
|
|
206
|
+
return blocksModuleSource(catalog.collect())
|
|
207
|
+
},
|
|
208
|
+
configureServer(server) {
|
|
209
|
+
const invalidate = (file: string) => {
|
|
210
|
+
if (!isBlockFile(file)) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const module = server.moduleGraph.getModuleById(resolved)
|
|
214
|
+
if (module) {
|
|
215
|
+
server.moduleGraph.invalidateModule(module)
|
|
216
|
+
server.ws.send({ type: 'full-reload' })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
server.watcher.on('add', invalidate)
|
|
220
|
+
server.watcher.on('unlink', invalidate)
|
|
221
|
+
},
|
|
222
|
+
handleHotUpdate({ file, server }) {
|
|
223
|
+
if (!isBlockFile(file)) {
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
const module = server.moduleGraph.getModuleById(resolved)
|
|
227
|
+
return module ? [module] : undefined
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
}
|
|
@@ -41,6 +41,16 @@ export interface PlutoDocsOptions {
|
|
|
41
41
|
* It is short, so a save shows soon. `0` turns the cache header into `no-store`.
|
|
42
42
|
*/
|
|
43
43
|
runtimeMaxAge?: number
|
|
44
|
+
/** How the block editor finds the block components of the site. */
|
|
45
|
+
blocks?: PlutoDocsBlocksOptions
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PlutoDocsBlocksOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Read `components/content/` of the app and of its layers. A component that calls
|
|
51
|
+
* `definePlutoBlock(...)` becomes a block. Default `true`. `false` turns the search off.
|
|
52
|
+
*/
|
|
53
|
+
discover?: boolean
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
export interface PlutoDocsResolveContext {
|
|
@@ -52,6 +62,7 @@ export interface PlutoDocsResolveResult {
|
|
|
52
62
|
enabled: boolean
|
|
53
63
|
render: PlutoDocsRender
|
|
54
64
|
runtimeMaxAge: number
|
|
65
|
+
blocks: { discover: boolean }
|
|
55
66
|
types: PlutoDocsTypeOptions[]
|
|
56
67
|
errors: string[]
|
|
57
68
|
}
|
|
@@ -110,6 +121,7 @@ export function resolvePlutoDocsOptions(
|
|
|
110
121
|
enabled: false,
|
|
111
122
|
render: 'build',
|
|
112
123
|
runtimeMaxAge: DEFAULT_RUNTIME_MAX_AGE,
|
|
124
|
+
blocks: { discover: false },
|
|
113
125
|
types: [],
|
|
114
126
|
errors: [CMS_OFF_ERROR],
|
|
115
127
|
}
|
|
@@ -143,6 +155,21 @@ export function resolvePlutoDocsOptions(
|
|
|
143
155
|
}
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
let discover = true
|
|
159
|
+
if (options.blocks !== undefined) {
|
|
160
|
+
if (!isRecord(options.blocks)) {
|
|
161
|
+
errors.push('plutoDocs.blocks must be an object.')
|
|
162
|
+
} else if (options.blocks.discover !== undefined) {
|
|
163
|
+
if (typeof options.blocks.discover === 'boolean') {
|
|
164
|
+
discover = options.blocks.discover
|
|
165
|
+
} else {
|
|
166
|
+
errors.push(
|
|
167
|
+
'plutoDocs.blocks.discover must be true or false. Using true.'
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
const types: PlutoDocsTypeOptions[] = []
|
|
147
174
|
const names = new Set<string>()
|
|
148
175
|
const entries: unknown[] = Array.isArray(options.types) ? options.types : []
|
|
@@ -220,5 +247,12 @@ export function resolvePlutoDocsOptions(
|
|
|
220
247
|
})
|
|
221
248
|
}
|
|
222
249
|
|
|
223
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
enabled: true,
|
|
252
|
+
render,
|
|
253
|
+
runtimeMaxAge,
|
|
254
|
+
blocks: { discover },
|
|
255
|
+
types,
|
|
256
|
+
errors,
|
|
257
|
+
}
|
|
224
258
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plutocms/docs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.16.0",
|
|
5
5
|
"description": "Docs layer for Pluto: edit the Markdown pages of a Nuxt Content site in the Pluto admin",
|
|
6
6
|
"author": "Victor Ribeiro",
|
|
7
7
|
"license": "MIT",
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@nuxt/content": "^3.16.2",
|
|
27
27
|
"@nuxtjs/mdc": "^0.23.2",
|
|
28
|
-
"@plutocms/core": ">=0.
|
|
28
|
+
"@plutocms/core": ">=0.40.0 <1.0.0",
|
|
29
29
|
"@types/sortablejs": "^1.15.9",
|
|
30
|
+
"nuxt-component-meta": "0.18.0",
|
|
30
31
|
"reka-ui": "^2.11.0",
|
|
31
32
|
"remark-gfm": "4.0.1",
|
|
32
33
|
"remark-mdc": "3.11.1",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The component layer of the block editor: the blocks of the `components/content/` files that call
|
|
2
|
+
// `definePlutoBlock`. The module makes it as a Vite virtual module. See modules/pluto-docs/blocks.ts.
|
|
3
|
+
// This file has no top-level import, so the declaration is an ambient module.
|
|
4
|
+
declare module '#pluto-docs/blocks' {
|
|
5
|
+
const blocks: Record<
|
|
6
|
+
string,
|
|
7
|
+
import('../utils/blocks/types').PlutoBlockOverride
|
|
8
|
+
>
|
|
9
|
+
export default blocks
|
|
10
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
PlutoBlock,
|
|
8
8
|
PlutoBlockDefinition,
|
|
9
9
|
PlutoBlockDoc,
|
|
10
|
-
|
|
10
|
+
PlutoBlockSources,
|
|
11
11
|
PlutoComponentBlock,
|
|
12
12
|
} from './types'
|
|
13
13
|
import { blockMarkdown } from './operations'
|
|
@@ -63,7 +63,7 @@ function firstText(component: PlutoComponentBlock): string {
|
|
|
63
63
|
/** The label, the icon and the excerpt of a block in the outline. */
|
|
64
64
|
export function describeBlock(
|
|
65
65
|
block: PlutoBlock,
|
|
66
|
-
|
|
66
|
+
sources: PlutoBlockSources = {}
|
|
67
67
|
): PlutoBlockInfo {
|
|
68
68
|
switch (block.kind) {
|
|
69
69
|
case 'text': {
|
|
@@ -91,7 +91,7 @@ export function describeBlock(
|
|
|
91
91
|
excerpt: block.reason === 'table' ? '' : textExcerpt(block.markdown),
|
|
92
92
|
}
|
|
93
93
|
case 'component': {
|
|
94
|
-
const definition = resolveBlockDefinition(block.name, {
|
|
94
|
+
const definition = resolveBlockDefinition(block.name, { sources })
|
|
95
95
|
const title =
|
|
96
96
|
typeof block.props.title === 'string'
|
|
97
97
|
? block.props.title
|
|
@@ -176,15 +176,15 @@ export function previewSelectionId(
|
|
|
176
176
|
/** The top-level blocks as the preview frame wants them. */
|
|
177
177
|
export function toPreviewBlocks(
|
|
178
178
|
blocks: PlutoBlock[],
|
|
179
|
-
|
|
179
|
+
sources: PlutoBlockSources = {}
|
|
180
180
|
): PlutoPreviewBlock[] {
|
|
181
181
|
return blocks.map((block) => {
|
|
182
182
|
const markdown = renderBlock(block)
|
|
183
183
|
if (block.kind !== 'component') {
|
|
184
184
|
return { id: block.id, markdown }
|
|
185
185
|
}
|
|
186
|
-
const definition = resolveBlockDefinition(block.name, {
|
|
187
|
-
const children = previewChildren(block,
|
|
186
|
+
const definition = resolveBlockDefinition(block.name, { sources })
|
|
187
|
+
const children = previewChildren(block, sources)
|
|
188
188
|
return {
|
|
189
189
|
id: block.id,
|
|
190
190
|
markdown,
|
|
@@ -202,16 +202,16 @@ export function toPreviewBlocks(
|
|
|
202
202
|
*/
|
|
203
203
|
export function previewChildren(
|
|
204
204
|
component: PlutoComponentBlock,
|
|
205
|
-
|
|
205
|
+
sources: PlutoBlockSources = {}
|
|
206
206
|
): PlutoPreviewChild[] {
|
|
207
207
|
return flatChildren(component)
|
|
208
208
|
.filter((child): child is PlutoComponentBlock => child.kind === 'component')
|
|
209
209
|
.map((child) => {
|
|
210
|
-
const children = previewChildren(child,
|
|
210
|
+
const children = previewChildren(child, sources)
|
|
211
211
|
return {
|
|
212
212
|
id: child.id,
|
|
213
213
|
name: child.name,
|
|
214
|
-
label: resolveBlockDefinition(child.name, {
|
|
214
|
+
label: resolveBlockDefinition(child.name, { sources, block: child })
|
|
215
215
|
.label,
|
|
216
216
|
...(children.length ? { children } : {}),
|
|
217
217
|
}
|
|
@@ -279,7 +279,7 @@ const TABLE_MARKDOWN =
|
|
|
279
279
|
* target inside a component that takes only some children.
|
|
280
280
|
*/
|
|
281
281
|
export function buildPalette(
|
|
282
|
-
|
|
282
|
+
sources: PlutoBlockSources = {},
|
|
283
283
|
accepts?: string[]
|
|
284
284
|
): PlutoPaletteGroup[] {
|
|
285
285
|
const groups: PlutoPaletteGroup[] = []
|
|
@@ -322,7 +322,7 @@ export function buildPalette(
|
|
|
322
322
|
],
|
|
323
323
|
})
|
|
324
324
|
}
|
|
325
|
-
for (const { group, items } of listPaletteBlocks(
|
|
325
|
+
for (const { group, items } of listPaletteBlocks(sources)) {
|
|
326
326
|
const entries = items
|
|
327
327
|
.filter((item) => !accepts || accepts.includes(item.name))
|
|
328
328
|
.map((item): PlutoPaletteEntry => ({
|
|
@@ -37,7 +37,8 @@ export function groupLabelKey(group: string): string {
|
|
|
37
37
|
: group
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** The theme colors of Nuxt UI. */
|
|
41
|
+
export const BLOCK_COLORS = [
|
|
41
42
|
'primary',
|
|
42
43
|
'secondary',
|
|
43
44
|
'success',
|
|
@@ -125,7 +126,7 @@ const DEFINITIONS: PlutoBlockDefinition[] = [
|
|
|
125
126
|
block('callout', 'callouts', 'i-lucide-message-square-quote', {
|
|
126
127
|
props: {
|
|
127
128
|
icon: prop('icon', 'icon'),
|
|
128
|
-
color: prop('select', 'color', { options: options(
|
|
129
|
+
color: prop('select', 'color', { options: options(BLOCK_COLORS) }),
|
|
129
130
|
to: prop('link', 'to', { advanced: true }),
|
|
130
131
|
target: prop('select', 'target', {
|
|
131
132
|
options: options(TARGETS),
|
|
@@ -139,7 +140,7 @@ const DEFINITIONS: PlutoBlockDefinition[] = [
|
|
|
139
140
|
props: {
|
|
140
141
|
title: prop('string', 'title'),
|
|
141
142
|
icon: prop('icon', 'icon'),
|
|
142
|
-
color: prop('select', 'color', { options: options(
|
|
143
|
+
color: prop('select', 'color', { options: options(BLOCK_COLORS) }),
|
|
143
144
|
to: prop('link', 'to'),
|
|
144
145
|
target: prop('select', 'target', {
|
|
145
146
|
options: options(TARGETS),
|
|
@@ -234,7 +235,7 @@ const DEFINITIONS: PlutoBlockDefinition[] = [
|
|
|
234
235
|
hidden: true,
|
|
235
236
|
props: {
|
|
236
237
|
label: prop('string', 'label'),
|
|
237
|
-
color: prop('select', 'color', { options: options(
|
|
238
|
+
color: prop('select', 'color', { options: options(BLOCK_COLORS) }),
|
|
238
239
|
},
|
|
239
240
|
}),
|
|
240
241
|
block('kbd', 'text', 'i-lucide-keyboard', {
|
|
@@ -348,7 +349,7 @@ const DEFINITIONS: PlutoBlockDefinition[] = [
|
|
|
348
349
|
label: prop('string', 'label', { slot: 'default' }),
|
|
349
350
|
to: prop('link', 'to'),
|
|
350
351
|
icon: prop('icon', 'icon'),
|
|
351
|
-
color: prop('select', 'color', { options: options(
|
|
352
|
+
color: prop('select', 'color', { options: options(BLOCK_COLORS) }),
|
|
352
353
|
variant: prop('select', 'variant', {
|
|
353
354
|
options: options([
|
|
354
355
|
'solid',
|