@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,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
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { Nuxt } from '@nuxt/schema'
|
|
2
|
+
import type { PlutoDocsOptions } from './options'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
addImports,
|
|
6
|
+
addServerHandler,
|
|
7
|
+
addVitePlugin,
|
|
8
|
+
createResolver,
|
|
9
|
+
defineNuxtModule,
|
|
10
|
+
useLogger,
|
|
11
|
+
} from '@nuxt/kit'
|
|
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'
|
|
20
|
+
import { buildContentManifest, manifestModuleSource } from './manifest'
|
|
21
|
+
import { resolvePlutoDocsOptions } from './options'
|
|
22
|
+
|
|
23
|
+
export type { PlutoDocsOptions, PlutoDocsTypeOptions } from './options'
|
|
24
|
+
|
|
25
|
+
/** The routes of the run-time render mode, on the public API of Pluto. The composables of the layer call them. */
|
|
26
|
+
export const RUNTIME_ROUTE_BASE = '/api/pluto/v1/docs'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Registers the content types of the docs layer. It is OFF when the
|
|
30
|
+
* app turns the standalone CMS off (`plutoGithub.enabled: false`). The default
|
|
31
|
+
* `'auto'` and `true` both count as on. The module only checks the options
|
|
32
|
+
* and copies them to the public runtime config. The client plugin
|
|
33
|
+
* (`app/plugins/pluto-docs.ts`) and the Nitro plugin
|
|
34
|
+
* (`server/plugins/pluto-docs.ts`) of the layer build the types from
|
|
35
|
+
* that config. See the docs-setup skill.
|
|
36
|
+
*/
|
|
37
|
+
export default defineNuxtModule<PlutoDocsOptions>({
|
|
38
|
+
meta: { name: 'pluto-docs', configKey: 'plutoDocs' },
|
|
39
|
+
defaults: { types: [] },
|
|
40
|
+
setup(options, nuxt) {
|
|
41
|
+
const logger = useLogger('pluto-docs')
|
|
42
|
+
const cms = (
|
|
43
|
+
nuxt.options as { plutoGithub?: { enabled?: boolean | 'auto' } }
|
|
44
|
+
).plutoGithub
|
|
45
|
+
|
|
46
|
+
const { enabled, render, runtimeMaxAge, types, blocks, errors } =
|
|
47
|
+
resolvePlutoDocsOptions(options, {
|
|
48
|
+
cmsEnabled: cms?.enabled !== false,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
for (const error of errors) {
|
|
52
|
+
logger.error(error)
|
|
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
|
+
|
|
65
|
+
if (!enabled) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The plugins read this key. It is absent when the layer is off.
|
|
70
|
+
nuxt.options.runtimeConfig.public.plutoDocs = {
|
|
71
|
+
types,
|
|
72
|
+
render,
|
|
73
|
+
runtimeMaxAge,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (render === 'hybrid' || render === 'runtime') {
|
|
77
|
+
const { resolve } = createResolver(import.meta.url)
|
|
78
|
+
// The toc rules of Nuxt Content, so the run-time parser makes the same table of contents.
|
|
79
|
+
const content = (
|
|
80
|
+
nuxt.options as {
|
|
81
|
+
content?: {
|
|
82
|
+
build?: {
|
|
83
|
+
markdown?: { toc?: { depth?: number; searchDepth?: number } }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
).content
|
|
88
|
+
nuxt.options.runtimeConfig.plutoDocs = {
|
|
89
|
+
toc: content?.build?.markdown?.toc,
|
|
90
|
+
} as never
|
|
91
|
+
for (const name of ['page', 'navigation', 'surround']) {
|
|
92
|
+
addServerHandler({
|
|
93
|
+
route: `${RUNTIME_ROUTE_BASE}/:type/${name}`,
|
|
94
|
+
method: 'get',
|
|
95
|
+
handler: resolve(`../../server/handlers/${name}.get`),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The build manifest: content id → version, for each type. The server compares it with the
|
|
101
|
+
// store (hybrid). It is a virtual module, so it is part of the server bundle and matches the
|
|
102
|
+
// pages that Nuxt Content builds in the same run. It is made once, when the bundler first asks.
|
|
103
|
+
const makeManifest = () => {
|
|
104
|
+
// The CMS module sets the storage and the content dir. They are known here, after all modules ran.
|
|
105
|
+
const cmsConfig = nuxt.options.runtimeConfig.public.plutoGithub as
|
|
106
|
+
{ storage?: string; dir?: string } | undefined
|
|
107
|
+
return buildContentManifest(types, {
|
|
108
|
+
contentDir: join(nuxt.options.rootDir, cmsConfig?.dir ?? 'content'),
|
|
109
|
+
storage: cmsConfig?.storage ?? 'github',
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const manifestId = '#pluto-docs/manifest'
|
|
114
|
+
let manifestSource: string | undefined
|
|
115
|
+
nuxt.hook('nitro:config', (nitroConfig) => {
|
|
116
|
+
nitroConfig.virtual ||= {}
|
|
117
|
+
nitroConfig.virtual[manifestId] = () => {
|
|
118
|
+
if (manifestSource === undefined) {
|
|
119
|
+
manifestSource = manifestModuleSource(
|
|
120
|
+
render === 'hybrid' ? makeManifest() : {}
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return manifestSource
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// The preview page of the visual editor is a client-only page that no search engine should list.
|
|
128
|
+
// It is not prerendered, and the response says noindex. An app rule for the same path wins.
|
|
129
|
+
nuxt.options.routeRules = {
|
|
130
|
+
[PREVIEW_PATH]: {
|
|
131
|
+
ssr: false,
|
|
132
|
+
prerender: false,
|
|
133
|
+
headers: { 'x-robots-tag': 'noindex, nofollow' },
|
|
134
|
+
},
|
|
135
|
+
...nuxt.options.routeRules,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!nuxt.options._prepare) {
|
|
139
|
+
logger.info(
|
|
140
|
+
`Docs layer on. Types: ${types.map((type) => type.name).join(', ') || 'none'}. Render: ${render}.`
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
},
|
|
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
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The build manifest of the hybrid mode. Node code that runs at build time. It lists the
|
|
2
|
+
// content files of each type with the version that the content store reports for the file
|
|
3
|
+
// (`_version`). At run time the server compares the store with this list to see which pages
|
|
4
|
+
// changed after the build. See the docs-setup skill.
|
|
5
|
+
|
|
6
|
+
import { Buffer } from 'node:buffer'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join, relative, sep } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Content id → version, for one type. */
|
|
12
|
+
export type TypeManifest = Record<string, string>
|
|
13
|
+
|
|
14
|
+
/** Type name → its manifest. */
|
|
15
|
+
export type ContentManifest = Record<string, TypeManifest>
|
|
16
|
+
|
|
17
|
+
/** The same dirs that the CMS fs store skips. */
|
|
18
|
+
const SKIPPED_DIRS = new Set([
|
|
19
|
+
'node_modules',
|
|
20
|
+
'.nuxt',
|
|
21
|
+
'.output',
|
|
22
|
+
'.data',
|
|
23
|
+
'.nitro',
|
|
24
|
+
'.cache',
|
|
25
|
+
'dist',
|
|
26
|
+
])
|
|
27
|
+
const SEGMENT_PATTERN = /^(?:\d+\.)?[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
28
|
+
const MAX_SEGMENTS = 6
|
|
29
|
+
const MAX_ID_LENGTH = 255
|
|
30
|
+
|
|
31
|
+
/** The Git blob id of a text: sha1 of `blob <byteLength>\0` and the UTF-8 bytes. Core does the same. */
|
|
32
|
+
export function gitBlobSha(text: string): string {
|
|
33
|
+
const bytes = Buffer.from(text, 'utf8')
|
|
34
|
+
return createHash('sha1')
|
|
35
|
+
.update(`blob ${bytes.length}\0`)
|
|
36
|
+
.update(bytes)
|
|
37
|
+
.digest('hex')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The version of a file text for a storage.
|
|
42
|
+
*
|
|
43
|
+
* - `github`: the store reports the Git blob id. Git keeps LF in the blob, so a CRLF working file
|
|
44
|
+
* (Windows with `core.autocrlf`) is hashed with LF. A file that really has CRLF in the repository
|
|
45
|
+
* looks changed at run time. That is safe: the page then comes from the store.
|
|
46
|
+
* - `fs` (and `memory`): the store hashes the file as it is on the disk, so the manifest does too.
|
|
47
|
+
*/
|
|
48
|
+
export function versionOf(text: string, storage: string): string {
|
|
49
|
+
return gitBlobSha(storage === 'github' ? text.replace(/\r\n/g, '\n') : text)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether a content id is one that the CMS lists: 1 to 6 safe segments, and not excluded. */
|
|
53
|
+
export function isListedId(
|
|
54
|
+
id: string,
|
|
55
|
+
exclude: readonly string[] = []
|
|
56
|
+
): boolean {
|
|
57
|
+
if (id.length === 0 || id.length > MAX_ID_LENGTH) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
const segments = id.split('/')
|
|
61
|
+
if (
|
|
62
|
+
segments.length > MAX_SEGMENTS ||
|
|
63
|
+
segments.some((segment) => !SEGMENT_PATTERN.test(segment))
|
|
64
|
+
) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return !exclude.some((entry) =>
|
|
68
|
+
entry.endsWith('/') ? id.startsWith(entry) : id === entry
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One file in memory: the path is relative to the type folder, with `/`. */
|
|
73
|
+
export interface ManifestFile {
|
|
74
|
+
path: string
|
|
75
|
+
text: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Builds the manifest of one type from its files. A file that is not a listed `.md` page is skipped. */
|
|
79
|
+
export function buildTypeManifest(
|
|
80
|
+
files: ManifestFile[],
|
|
81
|
+
options: { storage: string; exclude?: readonly string[] }
|
|
82
|
+
): TypeManifest {
|
|
83
|
+
const manifest: TypeManifest = {}
|
|
84
|
+
for (const file of [...files].sort((a, b) =>
|
|
85
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0
|
|
86
|
+
)) {
|
|
87
|
+
if (!file.path.endsWith('.md')) {
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const id = file.path.slice(0, -'.md'.length)
|
|
91
|
+
if (!isListedId(id, options.exclude)) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
manifest[id] = versionOf(file.text, options.storage)
|
|
95
|
+
}
|
|
96
|
+
return manifest
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function walkMarkdown(dir: string, out: string[]): void {
|
|
100
|
+
let entries
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
103
|
+
} catch {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (entry.name.startsWith('.')) {
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const full = join(dir, entry.name)
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
if (!SKIPPED_DIRS.has(entry.name)) {
|
|
113
|
+
walkMarkdown(full, out)
|
|
114
|
+
}
|
|
115
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
116
|
+
out.push(full)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Reads the `.md` files under a type folder on the disk. A missing folder gives `[]`. */
|
|
122
|
+
export function readTypeFiles(folder: string): ManifestFile[] {
|
|
123
|
+
const found: string[] = []
|
|
124
|
+
walkMarkdown(folder, found)
|
|
125
|
+
return found.map((full) => ({
|
|
126
|
+
path: relative(folder, full).split(sep).join('/'),
|
|
127
|
+
text: readFileSync(full, 'utf8'),
|
|
128
|
+
}))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ManifestTypeInput {
|
|
132
|
+
name: string
|
|
133
|
+
/** The folder under the CMS content dir. `''` is the content dir itself. */
|
|
134
|
+
source: string
|
|
135
|
+
exclude?: string[]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Builds the manifest of every type. `contentDir` is the absolute path of the CMS content dir. */
|
|
139
|
+
export function buildContentManifest(
|
|
140
|
+
types: ManifestTypeInput[],
|
|
141
|
+
options: { contentDir: string; storage: string }
|
|
142
|
+
): ContentManifest {
|
|
143
|
+
const manifest: ContentManifest = {}
|
|
144
|
+
for (const type of types) {
|
|
145
|
+
const folder = type.source
|
|
146
|
+
? join(options.contentDir, type.source)
|
|
147
|
+
: options.contentDir
|
|
148
|
+
manifest[type.name] = buildTypeManifest(readTypeFiles(folder), {
|
|
149
|
+
storage: options.storage,
|
|
150
|
+
exclude: type.exclude,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return manifest
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The source of the virtual module that the server imports. */
|
|
157
|
+
export function manifestModuleSource(manifest: ContentManifest): string {
|
|
158
|
+
return `export default ${JSON.stringify(manifest)}\n`
|
|
159
|
+
}
|