@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.0](https://github.com/plutocms/docs/compare/v0.15.0...v0.16.0) (2026-10-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* block sources and component meta mapping for the editor ([#75](https://github.com/plutocms/docs/issues/75)) ([cc04cf0](https://github.com/plutocms/docs/commit/cc04cf044112b6d4432817711c1e8ed34cce1b7e))
|
|
9
|
+
* discover editor blocks from definePlutoBlock in components/content ([#78](https://github.com/plutocms/docs/issues/78)) ([0ce82b9](https://github.com/plutocms/docs/commit/0ce82b9672b276c6b50edf6f647fd42490595c84))
|
|
10
|
+
|
|
3
11
|
## [0.15.0](https://github.com/plutocms/nuxt-content/compare/v0.14.0...v0.15.0) (2026-10-31)
|
|
4
12
|
|
|
5
13
|
|
package/FEATURES.md
CHANGED
|
@@ -26,5 +26,9 @@ policies shared by every project here.
|
|
|
26
26
|
- Block model: the pure model of the visual block editor (parse, serialize, edit, history,
|
|
27
27
|
presets, registry, preview protocol). The text of an unedited block stays byte for byte. See
|
|
28
28
|
@.claude/skills/block-model/SKILL.md.
|
|
29
|
+
- Block discovery: a component in `components/content/` (the app or a layer) that calls
|
|
30
|
+
`definePlutoBlock({...})` becomes a block in the palette, with its props and slots read from its
|
|
31
|
+
TypeScript types. Opt-in. The option `plutoDocs.blocks.discover` turns it off. See
|
|
32
|
+
@.claude/skills/block-model/SKILL.md.
|
|
29
33
|
- Block editor: the visual editor for the page body. A live preview, the block list, the palette, and
|
|
30
34
|
the controls of the selected block, with no raw MDC. See @.claude/skills/block-editor/SKILL.md.
|
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ setup: the collections, the CSS entry, `PlutoRoot`, and the GitHub App.
|
|
|
31
31
|
|
|
32
32
|
- A `docs` page type for nested, numbered Markdown pages.
|
|
33
33
|
- A visual block editor with a live preview, and a Markdown mode.
|
|
34
|
+
- Your own components in the palette: call `definePlutoBlock({ label, icon })` in a component of `components/content/`. The props and slots come from its TypeScript types.
|
|
34
35
|
- Hybrid render mode (default): a save shows on the site at once, with no wait for a build. Build and runtime modes are options.
|
|
35
36
|
- "New Page" and "Edit Page" in the admin bar, and an "Edit in admin" link for the public page.
|
|
36
37
|
- The pages on the public API of Pluto (`/api/pluto/v1/content/<type>`), for a site in any stack.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
// The component layer. Only the two editor roots import it, so it stays out of the public pages.
|
|
3
|
+
import componentBlocks from '#pluto-docs/blocks'
|
|
2
4
|
// The visual block editor: the canvas of the 'canvas' form layout. The canvas
|
|
3
5
|
// shows the live preview of the page. The block list goes in the left panel,
|
|
4
6
|
// and the controls of the selected block go in the right panel (a tab of the
|
|
@@ -24,12 +26,12 @@ const emit = defineEmits<{
|
|
|
24
26
|
|
|
25
27
|
const { t } = usePlutoI18n()
|
|
26
28
|
const form = usePlutoContentForm()
|
|
27
|
-
const
|
|
29
|
+
const blockSources = usePlutoBlockSources(componentBlocks)
|
|
28
30
|
|
|
29
31
|
const editor = createPlutoBlockEditor({
|
|
30
32
|
source: () => props.modelValue,
|
|
31
33
|
emit: (value) => emit('update:modelValue', value),
|
|
32
|
-
|
|
34
|
+
sources: blockSources,
|
|
33
35
|
})
|
|
34
36
|
provide(plutoBlockEditorKey, editor)
|
|
35
37
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// here: the block model only feeds the preview and never writes.
|
|
10
10
|
import type { SplitterItem } from '@nuxt/ui'
|
|
11
11
|
import type { PlutoMarkdownCodeEditor as CodeEditor } from '#components'
|
|
12
|
+
// The component layer. Only the two editor roots import it, so it stays out of the public pages.
|
|
13
|
+
import componentBlocks from '#pluto-docs/blocks'
|
|
12
14
|
import { findBlock } from '../../../../../shared/utils/blocks/tree'
|
|
13
15
|
import PlutoMdcInsertMenu from '../PlutoMdcInsertMenu.vue'
|
|
14
16
|
import PlutoBlockModeSwitch from './PlutoBlockModeSwitch.vue'
|
|
@@ -28,12 +30,12 @@ const emit = defineEmits<{
|
|
|
28
30
|
}>()
|
|
29
31
|
|
|
30
32
|
const form = usePlutoContentForm()
|
|
31
|
-
const
|
|
33
|
+
const blockSources = usePlutoBlockSources(componentBlocks)
|
|
32
34
|
|
|
33
35
|
const editor = createPlutoBlockEditor({
|
|
34
36
|
source: () => props.modelValue,
|
|
35
37
|
emit: () => {},
|
|
36
|
-
|
|
38
|
+
sources: blockSources,
|
|
37
39
|
})
|
|
38
40
|
provide(plutoBlockEditorKey, editor)
|
|
39
41
|
|
|
@@ -13,7 +13,6 @@ import { findBlock } from '../../../../../shared/utils/blocks/tree'
|
|
|
13
13
|
|
|
14
14
|
const editor = usePlutoBlockEditor()
|
|
15
15
|
const { t, tx } = usePlutoI18n()
|
|
16
|
-
const blocksConfig = usePlutoBlocksConfig()
|
|
17
16
|
|
|
18
17
|
const open = computed({
|
|
19
18
|
get: () => editor.palette.value.open,
|
|
@@ -43,19 +42,21 @@ function findBlockById(id: string) {
|
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
const groups = computed<CommandPaletteGroup[]>(() =>
|
|
46
|
-
buildPalette(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
buildPalette(editor.sources.value, accepts.value).map(
|
|
46
|
+
({ group, entries }) => ({
|
|
47
|
+
id: group,
|
|
48
|
+
label: tx(groupLabelKey(group)),
|
|
49
|
+
items: entries.map((entry): CommandPaletteItem => ({
|
|
50
|
+
label: tx(entry.label),
|
|
51
|
+
suffix: entry.description ? tx(entry.description) : undefined,
|
|
52
|
+
icon: entry.icon,
|
|
53
|
+
onSelect: () => {
|
|
54
|
+
editor.insert(entry, editor.palette.value.target)
|
|
55
|
+
open.value = false
|
|
56
|
+
},
|
|
57
|
+
})),
|
|
58
|
+
})
|
|
59
|
+
)
|
|
59
60
|
)
|
|
60
61
|
</script>
|
|
61
62
|
|
|
@@ -64,7 +65,7 @@ const groups = computed<CommandPaletteGroup[]>(() =>
|
|
|
64
65
|
v-model:open="open"
|
|
65
66
|
:title="t('docs.editor.addBlock')"
|
|
66
67
|
:description="t('docs.editor.addBlockDescription')"
|
|
67
|
-
:ui="{ content: 'max-w-xl', header: 'sr-only' }"
|
|
68
|
+
:ui="{ content: 'pluto-palette max-w-xl', header: 'sr-only' }"
|
|
68
69
|
>
|
|
69
70
|
<template #content>
|
|
70
71
|
<UCommandPalette
|
|
@@ -22,7 +22,6 @@ const props = defineProps<{
|
|
|
22
22
|
const editor = usePlutoBlockEditor()
|
|
23
23
|
const { t, tx } = usePlutoI18n()
|
|
24
24
|
const router = useRouter()
|
|
25
|
-
const blocksConfig = usePlutoBlocksConfig()
|
|
26
25
|
|
|
27
26
|
const DEBOUNCE_MS = 150
|
|
28
27
|
const LOAD_TIMEOUT_MS = 12000
|
|
@@ -40,7 +39,7 @@ const shownId = computed(() =>
|
|
|
40
39
|
previewSelectionId(editor.doc.value, editor.selectedId.value)
|
|
41
40
|
)
|
|
42
41
|
const blocks = computed(() =>
|
|
43
|
-
toPreviewBlocks(editor.doc.value.blocks,
|
|
42
|
+
toPreviewBlocks(editor.doc.value.blocks, editor.sources.value)
|
|
44
43
|
)
|
|
45
44
|
|
|
46
45
|
function send(message: ReturnType<typeof previewMessage>) {
|
|
@@ -62,12 +62,27 @@ function setText(value: string | undefined) {
|
|
|
62
62
|
)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// The default of the component, as a text for a placeholder.
|
|
66
|
+
const defaultText = computed(() =>
|
|
67
|
+
props.definition.default === undefined
|
|
68
|
+
? undefined
|
|
69
|
+
: String(props.definition.default)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const switchOn = computed(() =>
|
|
73
|
+
props.modelValue === undefined
|
|
74
|
+
? props.definition.default === true
|
|
75
|
+
: props.modelValue === true || props.modelValue === 'true'
|
|
76
|
+
)
|
|
77
|
+
|
|
65
78
|
function setBoolean(value: boolean) {
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
// The state of the default removes the prop. An explicit value in the source stays explicit.
|
|
80
|
+
const fallback = props.definition.default === true
|
|
81
|
+
if (value === fallback) {
|
|
82
|
+
emit('update:modelValue', props.modelValue === value ? value : undefined)
|
|
83
|
+
} else {
|
|
84
|
+
emit('update:modelValue', value)
|
|
85
|
+
}
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
const numberValue = computed(() => {
|
|
@@ -90,7 +105,12 @@ const selectItems = computed(() => {
|
|
|
90
105
|
if (current && !items.some((item) => item.value === current)) {
|
|
91
106
|
items.push({ value: current, label: current })
|
|
92
107
|
}
|
|
93
|
-
|
|
108
|
+
// "Default (slow)": the item shows the value that the component uses.
|
|
109
|
+
const fallback = items.find((item) => item.value === defaultText.value)
|
|
110
|
+
const defaultLabel = defaultText.value
|
|
111
|
+
? `${t('docs.editor.default')} (${fallback?.label ?? defaultText.value})`
|
|
112
|
+
: t('docs.editor.default')
|
|
113
|
+
return [{ value: DEFAULT, label: defaultLabel }, ...items]
|
|
94
114
|
})
|
|
95
115
|
|
|
96
116
|
const colorItems = computed(() =>
|
|
@@ -156,7 +176,7 @@ const iconPreview = computed(() =>
|
|
|
156
176
|
>
|
|
157
177
|
<USwitch
|
|
158
178
|
v-if="type === 'boolean'"
|
|
159
|
-
:model-value="
|
|
179
|
+
:model-value="switchOn"
|
|
160
180
|
:disabled="disabled"
|
|
161
181
|
:aria-label="label"
|
|
162
182
|
@update:model-value="setBoolean($event)"
|
|
@@ -165,6 +185,7 @@ const iconPreview = computed(() =>
|
|
|
165
185
|
<UInputNumber
|
|
166
186
|
v-else-if="type === 'number'"
|
|
167
187
|
:model-value="numberValue"
|
|
188
|
+
:placeholder="defaultText"
|
|
168
189
|
:disabled="disabled"
|
|
169
190
|
class="w-full"
|
|
170
191
|
@update:model-value="emit('update:modelValue', $event ?? undefined)"
|
|
@@ -193,6 +214,7 @@ const iconPreview = computed(() =>
|
|
|
193
214
|
<UTextarea
|
|
194
215
|
v-else-if="type === 'text'"
|
|
195
216
|
:model-value="text"
|
|
217
|
+
:placeholder="defaultText"
|
|
196
218
|
:disabled="disabled"
|
|
197
219
|
:rows="2"
|
|
198
220
|
class="w-full"
|
|
@@ -217,7 +239,7 @@ const iconPreview = computed(() =>
|
|
|
217
239
|
:model-value="text"
|
|
218
240
|
:disabled="disabled"
|
|
219
241
|
:icon="iconPreview"
|
|
220
|
-
placeholder="i-lucide-rocket"
|
|
242
|
+
:placeholder="defaultText ?? 'i-lucide-rocket'"
|
|
221
243
|
spellcheck="false"
|
|
222
244
|
autocomplete="off"
|
|
223
245
|
class="w-full"
|
|
@@ -228,7 +250,7 @@ const iconPreview = computed(() =>
|
|
|
228
250
|
v-else-if="type === 'link'"
|
|
229
251
|
:model-value="text"
|
|
230
252
|
:disabled="disabled"
|
|
231
|
-
:placeholder="t('docs.editor.linkPlaceholder')"
|
|
253
|
+
:placeholder="defaultText ?? t('docs.editor.linkPlaceholder')"
|
|
232
254
|
icon="i-lucide-link"
|
|
233
255
|
autocomplete="off"
|
|
234
256
|
class="w-full"
|
|
@@ -238,6 +260,7 @@ const iconPreview = computed(() =>
|
|
|
238
260
|
<UInput
|
|
239
261
|
v-else
|
|
240
262
|
:model-value="text"
|
|
263
|
+
:placeholder="defaultText"
|
|
241
264
|
:disabled="disabled"
|
|
242
265
|
class="w-full"
|
|
243
266
|
@update:model-value="setText($event as string)"
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
PlutoBlockDoc,
|
|
9
9
|
PlutoBlockLocation,
|
|
10
10
|
PlutoBlocksConfig,
|
|
11
|
+
PlutoBlockSources,
|
|
11
12
|
PlutoCodeBlock,
|
|
12
13
|
PlutoPropValue,
|
|
13
14
|
} from '../../shared/utils/blocks/types'
|
|
@@ -53,12 +54,14 @@ export interface PlutoBlockEditorOptions {
|
|
|
53
54
|
source: () => string
|
|
54
55
|
/** Called with the new text after each edit, undo and redo. Never called at the start. */
|
|
55
56
|
emit: (value: string) => void
|
|
56
|
-
/** The `plutoBlocks`
|
|
57
|
-
|
|
57
|
+
/** The layers of the block definitions: the component layer and the `plutoBlocks` of the app config. */
|
|
58
|
+
sources?: () => PlutoBlockSources | undefined
|
|
58
59
|
now?: () => number
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export interface PlutoBlockEditor {
|
|
63
|
+
/** The layers of the block definitions. The palette, the preview and the labels read them here. */
|
|
64
|
+
sources: ComputedRef<PlutoBlockSources>
|
|
62
65
|
doc: ComputedRef<PlutoBlockDoc>
|
|
63
66
|
/** The message of a parse error. The editor then offers the Markdown mode only. */
|
|
64
67
|
error: Ref<string | null>
|
|
@@ -156,7 +159,7 @@ export function createPlutoBlockEditor(
|
|
|
156
159
|
options: PlutoBlockEditorOptions
|
|
157
160
|
): PlutoBlockEditor {
|
|
158
161
|
const now = options.now ?? Date.now
|
|
159
|
-
const
|
|
162
|
+
const sources = computed(() => options.sources?.() ?? {})
|
|
160
163
|
|
|
161
164
|
const initial = tryParse(options.source())
|
|
162
165
|
const error = ref<string | null>(initial.error)
|
|
@@ -244,7 +247,7 @@ export function createPlutoBlockEditor(
|
|
|
244
247
|
|
|
245
248
|
function definitionOf(block: PlutoBlock): PlutoBlockDefinition | undefined {
|
|
246
249
|
return block.kind === 'component'
|
|
247
|
-
? resolveBlockDefinition(block.name, {
|
|
250
|
+
? resolveBlockDefinition(block.name, { sources: sources.value, block })
|
|
248
251
|
: undefined
|
|
249
252
|
}
|
|
250
253
|
|
|
@@ -332,6 +335,7 @@ export function createPlutoBlockEditor(
|
|
|
332
335
|
}
|
|
333
336
|
|
|
334
337
|
return {
|
|
338
|
+
sources,
|
|
335
339
|
doc,
|
|
336
340
|
error,
|
|
337
341
|
selection,
|
|
@@ -378,7 +382,7 @@ export function createPlutoBlockEditor(
|
|
|
378
382
|
}
|
|
379
383
|
},
|
|
380
384
|
definitionOf,
|
|
381
|
-
describe: (block) => describeBlock(block,
|
|
385
|
+
describe: (block) => describeBlock(block, sources.value),
|
|
382
386
|
siblings,
|
|
383
387
|
}
|
|
384
388
|
}
|
|
@@ -401,10 +405,22 @@ export function usePlutoBlocksConfig(): () => PlutoBlocksConfig | undefined {
|
|
|
401
405
|
return () => appConfig.plutoBlocks as unknown as PlutoBlocksConfig | undefined
|
|
402
406
|
}
|
|
403
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The layers of the block definitions for a block editor: the component layer and the
|
|
410
|
+
* `plutoBlocks` value of the app config. The caller passes the component layer in: the two editor
|
|
411
|
+
* roots import it from `#pluto-docs/blocks`, so the data stays in the lazy editor chunk.
|
|
412
|
+
*/
|
|
413
|
+
export function usePlutoBlockSources(
|
|
414
|
+
components: PlutoBlocksConfig = {}
|
|
415
|
+
): () => PlutoBlockSources {
|
|
416
|
+
const blocksConfig = usePlutoBlocksConfig()
|
|
417
|
+
return () => ({ components, overrides: blocksConfig() })
|
|
418
|
+
}
|
|
419
|
+
|
|
404
420
|
/** The texts of the slots of a component: the title of a slot, and the label of its "Add" button. */
|
|
405
421
|
export function usePlutoBlockLabels() {
|
|
406
422
|
const { t, tx } = usePlutoI18n()
|
|
407
|
-
const
|
|
423
|
+
const editor = usePlutoBlockEditor()
|
|
408
424
|
|
|
409
425
|
function slotLabel(
|
|
410
426
|
definition: PlutoBlockDefinition | undefined,
|
|
@@ -428,7 +444,7 @@ export function usePlutoBlockLabels() {
|
|
|
428
444
|
if (accepts?.length === 1) {
|
|
429
445
|
return t('docs.editor.addItem', {
|
|
430
446
|
name: tx(
|
|
431
|
-
resolveBlockDefinition(accepts[0]!, {
|
|
447
|
+
resolveBlockDefinition(accepts[0]!, { sources: editor.sources.value })
|
|
432
448
|
.label
|
|
433
449
|
),
|
|
434
450
|
})
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Find the block components of a site and read their definitions at build time.
|
|
2
|
+
// A component is a block only when its file calls `definePlutoBlock(...)`. The props and slots come
|
|
3
|
+
// from the TypeScript meta (`nuxt-component-meta`), the label, icon and more from the call.
|
|
4
|
+
// This file has no Nuxt import, so plain Vitest tests it.
|
|
5
|
+
|
|
6
|
+
import type { PlutoComponentMeta } from '../../shared/utils/blocks/component-meta'
|
|
7
|
+
import type {
|
|
8
|
+
PlutoBlockPatch,
|
|
9
|
+
PlutoBlocksConfig,
|
|
10
|
+
} from '../../shared/utils/blocks/types'
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { basename, join } from 'node:path'
|
|
14
|
+
import { blockFromComponentMeta } from '../../shared/utils/blocks/component-meta'
|
|
15
|
+
|
|
16
|
+
/** The name of the macro. A component file calls it in `<script setup>`. */
|
|
17
|
+
export const BLOCK_MACRO = 'definePlutoBlock'
|
|
18
|
+
|
|
19
|
+
/** The key of the macro argument in the output of the parser. */
|
|
20
|
+
export const MACRO_KEY = 'pluto'
|
|
21
|
+
|
|
22
|
+
/** The folder of the components that Nuxt Content renders, in the app and in each layer. */
|
|
23
|
+
export const CONTENT_COMPONENTS_DIR = 'components/content'
|
|
24
|
+
|
|
25
|
+
export interface BlockFile {
|
|
26
|
+
/** The component name in Pascal case: `PlutoHero`. */
|
|
27
|
+
name: string
|
|
28
|
+
file: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The output of the parser, with the macro argument under `pluto`. */
|
|
32
|
+
export type ParsedComponent = PlutoComponentMeta & {
|
|
33
|
+
[MACRO_KEY]?: PlutoBlockPatch
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type MetaReader = (file: string) => ParsedComponent
|
|
37
|
+
|
|
38
|
+
export interface BlockEntry {
|
|
39
|
+
/** The hash of the file text. The entry is valid while the hash is the same. */
|
|
40
|
+
hash: string
|
|
41
|
+
block?: { name: string; definition: PlutoBlockPatch }
|
|
42
|
+
warnings: string[]
|
|
43
|
+
/** The time of the parse, in milliseconds. 0 when the entry came from the cache. */
|
|
44
|
+
ms: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BlockLog {
|
|
48
|
+
warn: (message: string) => void
|
|
49
|
+
debug: (message: string) => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether the file text calls the macro: `definePlutoBlock(...)` or `definePlutoBlock<Props>(...)`. */
|
|
53
|
+
export function hasBlockMacro(text: string): boolean {
|
|
54
|
+
return /\bdefinePlutoBlock\s*[(<]/.test(text)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The component files of the `components/content` folders. The folders come in order of priority
|
|
59
|
+
* (the app first, then its layers). When two files have the same name, the first one wins. A
|
|
60
|
+
* `Prose*` file is a prose override, an `.client` or `.server` file is a variant: none is a block.
|
|
61
|
+
*/
|
|
62
|
+
export function findContentComponents(dirs: string[]): BlockFile[] {
|
|
63
|
+
const found = new Map<string, BlockFile>()
|
|
64
|
+
const visit = (dir: string) => {
|
|
65
|
+
let entries
|
|
66
|
+
try {
|
|
67
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
68
|
+
} catch {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
72
|
+
const path = join(dir, entry.name)
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
visit(path)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (!entry.name.endsWith('.vue')) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const name = basename(entry.name, '.vue')
|
|
81
|
+
if (
|
|
82
|
+
name.startsWith('Prose') ||
|
|
83
|
+
name.endsWith('.client') ||
|
|
84
|
+
name.endsWith('.server') ||
|
|
85
|
+
found.has(name)
|
|
86
|
+
) {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
found.set(name, { name, file: path })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const dir of dirs) {
|
|
93
|
+
visit(dir)
|
|
94
|
+
}
|
|
95
|
+
return [...found.values()]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hashOf(text: string): string {
|
|
99
|
+
return createHash('sha1').update(text).digest('hex')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the block of one component file. A file without the macro gives an entry with no block, and
|
|
104
|
+
* the parser does not run. A parse error gives a warning and no block. A macro that the parser
|
|
105
|
+
* cannot evaluate (for example, an argument that uses a local variable) gives a warning and no
|
|
106
|
+
* block: a half-read definition would hide the mistake.
|
|
107
|
+
*/
|
|
108
|
+
export function readBlockEntry(
|
|
109
|
+
{ name, file }: BlockFile,
|
|
110
|
+
text: string,
|
|
111
|
+
read: MetaReader,
|
|
112
|
+
now: () => number = Date.now
|
|
113
|
+
): BlockEntry {
|
|
114
|
+
const hash = hashOf(text)
|
|
115
|
+
if (!hasBlockMacro(text)) {
|
|
116
|
+
return { hash, warnings: [], ms: 0 }
|
|
117
|
+
}
|
|
118
|
+
const start = now()
|
|
119
|
+
let parsed: ParsedComponent
|
|
120
|
+
try {
|
|
121
|
+
parsed = read(file)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
hash,
|
|
125
|
+
warnings: [
|
|
126
|
+
`${BLOCK_MACRO} in ${file} was skipped. The component did not parse: ${error instanceof Error ? error.message : String(error)}`,
|
|
127
|
+
],
|
|
128
|
+
ms: now() - start,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const ms = now() - start
|
|
132
|
+
const macro = parsed[MACRO_KEY]
|
|
133
|
+
if (!macro) {
|
|
134
|
+
return {
|
|
135
|
+
hash,
|
|
136
|
+
warnings: [
|
|
137
|
+
`${BLOCK_MACRO} in ${file} was skipped. Its argument must be an object literal that does not use a local variable. It can use a constant imported from a file.`,
|
|
138
|
+
],
|
|
139
|
+
ms,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// The key is not a block field. The meta is only props and slots.
|
|
143
|
+
const { [MACRO_KEY]: _macro, ...meta } = parsed
|
|
144
|
+
return {
|
|
145
|
+
hash,
|
|
146
|
+
block: blockFromComponentMeta(name, pickMeta(meta), macro),
|
|
147
|
+
warnings: [],
|
|
148
|
+
ms,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pickMeta(meta: ParsedComponent): PlutoComponentMeta {
|
|
153
|
+
return { props: meta.props, slots: meta.slots }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The component layer of a site: the blocks of the component files, with a cache by file. A file
|
|
158
|
+
* is parsed again only when its text changed. Call `collect` for each build and each hot update.
|
|
159
|
+
*/
|
|
160
|
+
export class BlockCatalog {
|
|
161
|
+
private cache = new Map<string, BlockEntry>()
|
|
162
|
+
|
|
163
|
+
constructor(
|
|
164
|
+
private dirs: () => string[],
|
|
165
|
+
private read: MetaReader,
|
|
166
|
+
private log: BlockLog,
|
|
167
|
+
private now: () => number = Date.now
|
|
168
|
+
) {}
|
|
169
|
+
|
|
170
|
+
collect(): PlutoBlocksConfig {
|
|
171
|
+
const start = this.now()
|
|
172
|
+
const files = findContentComponents(this.dirs())
|
|
173
|
+
const result: PlutoBlocksConfig = {}
|
|
174
|
+
const seen = new Set<string>()
|
|
175
|
+
let parsed = 0
|
|
176
|
+
for (const item of files) {
|
|
177
|
+
seen.add(item.file)
|
|
178
|
+
let text: string
|
|
179
|
+
try {
|
|
180
|
+
text = readFileSync(item.file, 'utf8')
|
|
181
|
+
} catch {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
let entry = this.cache.get(item.file)
|
|
185
|
+
if (!entry || entry.hash !== hashOf(text)) {
|
|
186
|
+
entry = readBlockEntry(item, text, this.read, this.now)
|
|
187
|
+
this.cache.set(item.file, entry)
|
|
188
|
+
for (const warning of entry.warnings) {
|
|
189
|
+
this.log.warn(warning)
|
|
190
|
+
}
|
|
191
|
+
if (entry.block) {
|
|
192
|
+
parsed++
|
|
193
|
+
this.log.debug(`${item.name}: ${entry.ms} ms`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (entry.block) {
|
|
197
|
+
result[entry.block.name] = entry.block.definition
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const file of this.cache.keys()) {
|
|
201
|
+
if (!seen.has(file)) {
|
|
202
|
+
this.cache.delete(file)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (parsed > 0) {
|
|
206
|
+
this.log.debug(
|
|
207
|
+
`Read ${parsed} block component(s) in ${this.now() - start} ms.`
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return result
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The source of the Vite virtual module for a component layer. */
|
|
215
|
+
export function blocksModuleSource(blocks: PlutoBlocksConfig): string {
|
|
216
|
+
return `export default ${JSON.stringify(blocks)}\n`
|
|
217
|
+
}
|