@plutocms/docs 0.0.0-stage → 0.15.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 +178 -0
- package/FEATURES.md +30 -0
- package/README.md +39 -2
- package/app/assets/css/pluto-layer.css +40 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -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 +85 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
- package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -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 +443 -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/index.ts +123 -0
- package/modules/pluto-docs/manifest.ts +159 -0
- package/modules/pluto-docs/options.ts +224 -0
- package/nuxt.config.ts +36 -0
- package/package.json +55 -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-manifest.d.ts +6 -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 +383 -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 +163 -0
- package/shared/utils/blocks/serialize.ts +107 -0
- package/shared/utils/blocks/tree.ts +85 -0
- package/shared/utils/blocks/types.ts +165 -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,504 @@
|
|
|
1
|
+
import type { MDCParserResult } from '@nuxtjs/mdc'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
import type {
|
|
4
|
+
ManifestDiff,
|
|
5
|
+
RuntimeFolderConfig,
|
|
6
|
+
RuntimeNavItem,
|
|
7
|
+
RuntimeNavPage,
|
|
8
|
+
RuntimeSurroundItem,
|
|
9
|
+
} from '../../shared/utils/runtime-content'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import {
|
|
12
|
+
queryCollection,
|
|
13
|
+
queryCollectionItemSurroundings,
|
|
14
|
+
queryCollectionNavigation,
|
|
15
|
+
} from '@nuxt/content/server'
|
|
16
|
+
import { parseMarkdown } from '@nuxtjs/mdc/runtime'
|
|
17
|
+
import { getQuery, getRouterParam, setResponseHeader } from 'h3'
|
|
18
|
+
// The build manifest. The module makes this virtual module at build time. See modules/pluto-docs/manifest.ts.
|
|
19
|
+
import manifests from '#pluto-docs/manifest'
|
|
20
|
+
import { parseNavigationConfig } from '../../shared/utils/navigation-config'
|
|
21
|
+
import {
|
|
22
|
+
diffManifest,
|
|
23
|
+
frontmatterExtras,
|
|
24
|
+
} from '../../shared/utils/runtime-content'
|
|
25
|
+
|
|
26
|
+
// The server side of the `hybrid` and `runtime` render modes. The module adds the routes
|
|
27
|
+
// only in these modes. The logic is here, in plain functions, and the handlers in
|
|
28
|
+
// `server/handlers/` only call it. See the docs-setup skill.
|
|
29
|
+
//
|
|
30
|
+
// `runtime`: every answer comes from the content adapter that core registered (the GitHub
|
|
31
|
+
// CMS store with its cache).
|
|
32
|
+
// `hybrid`: the answer comes from the build data (Nuxt Content, `queryCollection` on the
|
|
33
|
+
// server) while the store has the same files as the build manifest. A changed or a new
|
|
34
|
+
// page comes from the store. When any file differs, the navigation and the surround come
|
|
35
|
+
// from the store. A removed page is a 404. A store error gives the build data.
|
|
36
|
+
|
|
37
|
+
const STALE_WHILE_REVALIDATE = 300
|
|
38
|
+
const PARSED_CACHE_SIZE = 200
|
|
39
|
+
const SNAPSHOT_CACHE_SIZE = 20
|
|
40
|
+
const COLLECTION_PATTERN = /^[A-Z][\w-]{0,63}$/i
|
|
41
|
+
const NAVIGATION_FILE = '.navigation.yml'
|
|
42
|
+
|
|
43
|
+
/** The page that the page route sends. The shape follows a Nuxt Content page. */
|
|
44
|
+
export interface PlutoRuntimePage {
|
|
45
|
+
id: string
|
|
46
|
+
stem: string
|
|
47
|
+
extension: 'md'
|
|
48
|
+
path: string
|
|
49
|
+
title: string
|
|
50
|
+
description: string
|
|
51
|
+
seo: { title?: string; description?: string; [key: string]: unknown }
|
|
52
|
+
navigation?: false | { title?: string; icon?: string; [key: string]: unknown }
|
|
53
|
+
meta: Record<string, unknown>
|
|
54
|
+
body: MDCParserResult['body'] & { toc?: MDCParserResult['toc'] }
|
|
55
|
+
/** The other frontmatter keys of the file, at the top level, like the schema keys of Nuxt Content. */
|
|
56
|
+
[key: string]: unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RuntimeContext {
|
|
60
|
+
type: PlutoContentType
|
|
61
|
+
prefix: string
|
|
62
|
+
mode: 'hybrid' | 'runtime'
|
|
63
|
+
/** The Nuxt Content collection for the build data (hybrid). */
|
|
64
|
+
collection: string | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The public config of the layer. The site may type its runtime config in its own way, so the code reads it through this type. */
|
|
68
|
+
interface PublicConfig {
|
|
69
|
+
render: string
|
|
70
|
+
runtimeMaxAge?: number
|
|
71
|
+
types: Array<{ name: string; routePrefix?: string }>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Manifests = Record<string, Record<string, string>> | undefined
|
|
75
|
+
|
|
76
|
+
function notFound(): Error {
|
|
77
|
+
return createPlutoError({
|
|
78
|
+
statusCode: 404,
|
|
79
|
+
message: 'Not found.',
|
|
80
|
+
key: 'pluto.errors.notFound',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function queryText(event: H3Event, key: string): string {
|
|
85
|
+
const value = getQuery(event)[key]
|
|
86
|
+
return typeof value === 'string' ? value : ''
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Checks the request and gives the type. A public read is always anonymous: the session
|
|
91
|
+
* cookie is never read, so every visitor gets the same answer and a CDN may cache it.
|
|
92
|
+
*/
|
|
93
|
+
function runtimeContext(event: H3Event): RuntimeContext {
|
|
94
|
+
markPublicRead(event)
|
|
95
|
+
const config = useRuntimeConfig(event).public.plutoDocs as unknown as
|
|
96
|
+
PublicConfig | undefined
|
|
97
|
+
const name = getRouterParam(event, 'type') ?? ''
|
|
98
|
+
const entry =
|
|
99
|
+
config?.render === 'hybrid' || config?.render === 'runtime'
|
|
100
|
+
? config.types.find((type) => type.name === name)
|
|
101
|
+
: undefined
|
|
102
|
+
const type = entry ? getContentType(name) : undefined
|
|
103
|
+
if (!config || !entry || !type) {
|
|
104
|
+
throw notFound()
|
|
105
|
+
}
|
|
106
|
+
const collection = queryText(event, 'collection')
|
|
107
|
+
return {
|
|
108
|
+
type,
|
|
109
|
+
prefix: entry.routePrefix ?? '',
|
|
110
|
+
mode: config.render as 'hybrid' | 'runtime',
|
|
111
|
+
collection: COLLECTION_PATTERN.test(collection) ? collection : undefined,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setCacheHeaders(event: H3Event): void {
|
|
116
|
+
const configured = Number(
|
|
117
|
+
(
|
|
118
|
+
useRuntimeConfig(event).public.plutoDocs as unknown as
|
|
119
|
+
PublicConfig | undefined
|
|
120
|
+
)?.runtimeMaxAge
|
|
121
|
+
)
|
|
122
|
+
const maxAge =
|
|
123
|
+
Number.isFinite(configured) && configured >= 0 ? configured : 10
|
|
124
|
+
const swr = Math.min(STALE_WHILE_REVALIDATE, Math.max(maxAge * 3, 1))
|
|
125
|
+
setResponseHeader(
|
|
126
|
+
event,
|
|
127
|
+
'Cache-Control',
|
|
128
|
+
maxAge === 0
|
|
129
|
+
? 'no-store'
|
|
130
|
+
: `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function text(value: unknown): string {
|
|
135
|
+
return typeof value === 'string' ? value : ''
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Every page of the type, in tree order, without the body. */
|
|
143
|
+
async function listPages(
|
|
144
|
+
event: H3Event,
|
|
145
|
+
type: PlutoContentType
|
|
146
|
+
): Promise<PlutoContentItem[]> {
|
|
147
|
+
const result = await requireContentAdapter().list(
|
|
148
|
+
{ event, type },
|
|
149
|
+
{ sort: type.defaultSort, includeUnpublished: false }
|
|
150
|
+
)
|
|
151
|
+
return result.data
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toNavPage(item: PlutoContentItem): RuntimeNavPage {
|
|
155
|
+
return {
|
|
156
|
+
id: String(item.id),
|
|
157
|
+
title: text(item.title),
|
|
158
|
+
description: text(item.description) || undefined,
|
|
159
|
+
navigationTitle: text(item.navigationTitle) || undefined,
|
|
160
|
+
navigationIcon: text(item.navigationIcon) || undefined,
|
|
161
|
+
navigationHidden: item.navigationHidden === true,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- The store state against the build manifest ------------------------------
|
|
166
|
+
|
|
167
|
+
interface Snapshot {
|
|
168
|
+
items: PlutoContentItem[]
|
|
169
|
+
ids: string[]
|
|
170
|
+
diff: ManifestDiff
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Lists the store and compares it with the manifest. */
|
|
174
|
+
async function takeSnapshot(
|
|
175
|
+
event: H3Event,
|
|
176
|
+
type: PlutoContentType
|
|
177
|
+
): Promise<Snapshot> {
|
|
178
|
+
const items = await listPages(event, type)
|
|
179
|
+
const manifest = (manifests as Manifests)?.[type.name]
|
|
180
|
+
const diff = diffManifest(
|
|
181
|
+
manifest,
|
|
182
|
+
items.map((item) => ({ id: String(item.id), version: item._version }))
|
|
183
|
+
)
|
|
184
|
+
return { items, ids: items.map((item) => String(item.id)), diff }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The snapshot, or `undefined` when the answer must come from the build data: the mode is
|
|
189
|
+
* hybrid and the store failed (no App, rate limit, outage). In `runtime` mode the error goes up.
|
|
190
|
+
*/
|
|
191
|
+
async function snapshotOrFallback(
|
|
192
|
+
event: H3Event,
|
|
193
|
+
context: RuntimeContext
|
|
194
|
+
): Promise<Snapshot | undefined> {
|
|
195
|
+
try {
|
|
196
|
+
return await takeSnapshot(event, context.type)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (context.mode === 'hybrid') {
|
|
199
|
+
console.warn(
|
|
200
|
+
'[pluto-docs] The store failed. The build data answers.',
|
|
201
|
+
(error as Error).message
|
|
202
|
+
)
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
throw error
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- The parser --------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
type Highlighter =
|
|
212
|
+
NonNullable<
|
|
213
|
+
NonNullable<Parameters<typeof parseMarkdown>[1]>['highlight']
|
|
214
|
+
> extends infer H
|
|
215
|
+
? H extends { highlighter?: infer F }
|
|
216
|
+
? F
|
|
217
|
+
: never
|
|
218
|
+
: never
|
|
219
|
+
|
|
220
|
+
let highlighterPromise: Promise<Highlighter> | undefined
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The code highlighter of `@nuxtjs/mdc`, made at build time with the themes and the
|
|
224
|
+
* languages of the site. The default highlighter of the parser asks the `/api/_mdc/highlight`
|
|
225
|
+
* route, which Nuxt Content turns off. So the parser gets this one.
|
|
226
|
+
*/
|
|
227
|
+
function getHighlighter(): Promise<Highlighter> {
|
|
228
|
+
// eslint-disable-next-line ts/ban-ts-comment
|
|
229
|
+
// @ts-ignore The module is a Nitro alias of @nuxtjs/mdc. It has no type file.
|
|
230
|
+
highlighterPromise ??= import('#mdc-highlighter').then(
|
|
231
|
+
(module) => module.default as Highlighter
|
|
232
|
+
)
|
|
233
|
+
return highlighterPromise
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parsing is the slow part. The cache is by blob id (`_version`), which never changes for
|
|
237
|
+
// one text, so an entry never goes stale. A new text has a new blob id.
|
|
238
|
+
const parsedCache = new Map<string, Promise<MDCParserResult>>()
|
|
239
|
+
|
|
240
|
+
function parseBody(
|
|
241
|
+
version: string | undefined,
|
|
242
|
+
body: string
|
|
243
|
+
): Promise<MDCParserResult> {
|
|
244
|
+
const toc = (
|
|
245
|
+
useRuntimeConfig().plutoDocs as
|
|
246
|
+
{ toc?: { depth?: number; searchDepth?: number } } | undefined
|
|
247
|
+
)?.toc
|
|
248
|
+
const parse = async () =>
|
|
249
|
+
parseMarkdown(body, {
|
|
250
|
+
...(toc && { toc }),
|
|
251
|
+
highlight: { highlighter: await getHighlighter() },
|
|
252
|
+
})
|
|
253
|
+
if (!version) {
|
|
254
|
+
return parse()
|
|
255
|
+
}
|
|
256
|
+
const hit = parsedCache.get(version)
|
|
257
|
+
if (hit) {
|
|
258
|
+
return hit
|
|
259
|
+
}
|
|
260
|
+
const promise = parse()
|
|
261
|
+
parsedCache.set(version, promise)
|
|
262
|
+
promise.catch(() => parsedCache.delete(version))
|
|
263
|
+
if (parsedCache.size > PARSED_CACHE_SIZE) {
|
|
264
|
+
parsedCache.delete(parsedCache.keys().next().value as string)
|
|
265
|
+
}
|
|
266
|
+
return promise
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Folder configs (.navigation.yml) ----------------------------------------
|
|
270
|
+
|
|
271
|
+
/** The folders of the ids: every parent path, once (`a/b/c` gives `a` and `a/b`). */
|
|
272
|
+
function folderPaths(ids: string[]): string[] {
|
|
273
|
+
const folders = new Set<string>()
|
|
274
|
+
for (const id of ids) {
|
|
275
|
+
const parts = id.split('/').slice(0, -1)
|
|
276
|
+
parts.forEach((_part, index) =>
|
|
277
|
+
folders.add(parts.slice(0, index + 1).join('/'))
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
return [...folders]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const foldersCache = new Map<string, Record<string, RuntimeFolderConfig>>()
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The `.navigation.yml` of each folder of the type, read through the adapter (cached by blob in
|
|
287
|
+
* core). The result is cached here by the listing (ids and versions), so a request with an
|
|
288
|
+
* unchanged listing reads no file. An adapter without `readFile` gives no configs.
|
|
289
|
+
*/
|
|
290
|
+
async function readFolderConfigs(
|
|
291
|
+
event: H3Event,
|
|
292
|
+
type: PlutoContentType,
|
|
293
|
+
items: PlutoContentItem[]
|
|
294
|
+
): Promise<Record<string, RuntimeFolderConfig>> {
|
|
295
|
+
const adapter = requireContentAdapter()
|
|
296
|
+
if (!adapter.readFile) {
|
|
297
|
+
return {}
|
|
298
|
+
}
|
|
299
|
+
const hash = createHash('sha1')
|
|
300
|
+
for (const item of items) {
|
|
301
|
+
hash.update(`${item.id}\0${item._version ?? ''}\n`)
|
|
302
|
+
}
|
|
303
|
+
const key = `${type.name}:${hash.digest('hex')}`
|
|
304
|
+
const hit = foldersCache.get(key)
|
|
305
|
+
if (hit) {
|
|
306
|
+
return hit
|
|
307
|
+
}
|
|
308
|
+
const configs: Record<string, RuntimeFolderConfig> = {}
|
|
309
|
+
await Promise.all(
|
|
310
|
+
folderPaths(items.map((item) => String(item.id))).map(async (folder) => {
|
|
311
|
+
const file = await adapter.readFile!(
|
|
312
|
+
{ event, type },
|
|
313
|
+
`${folder}/${NAVIGATION_FILE}`
|
|
314
|
+
)
|
|
315
|
+
const config = file ? parseNavigationConfig(file.text) : undefined
|
|
316
|
+
if (config) {
|
|
317
|
+
configs[folder] = config
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
)
|
|
321
|
+
foldersCache.set(key, configs)
|
|
322
|
+
if (foldersCache.size > SNAPSHOT_CACHE_SIZE) {
|
|
323
|
+
foldersCache.delete(foldersCache.keys().next().value as string)
|
|
324
|
+
}
|
|
325
|
+
return configs
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function storeTree(
|
|
329
|
+
event: H3Event,
|
|
330
|
+
context: RuntimeContext,
|
|
331
|
+
items: PlutoContentItem[],
|
|
332
|
+
withDescription: boolean
|
|
333
|
+
): Promise<RuntimeNavItem[]> {
|
|
334
|
+
const folders = await readFolderConfigs(event, context.type, items)
|
|
335
|
+
return buildNavigationTree(items.map(toNavPage), {
|
|
336
|
+
routePrefix: context.prefix,
|
|
337
|
+
includeRoot: queryText(event, 'includeRoot') === '1',
|
|
338
|
+
withDescription,
|
|
339
|
+
folders,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- The page ----------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
/** The page from the store: the text is parsed and the frontmatter passes whole. */
|
|
346
|
+
async function storePage(
|
|
347
|
+
event: H3Event,
|
|
348
|
+
context: RuntimeContext,
|
|
349
|
+
id: string
|
|
350
|
+
): Promise<PlutoRuntimePage | null> {
|
|
351
|
+
const item = await requireContentAdapter().get(
|
|
352
|
+
{ event, type: context.type },
|
|
353
|
+
id
|
|
354
|
+
)
|
|
355
|
+
if (!item) {
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
const parsed = await parseBody(item._version, text(item.body))
|
|
359
|
+
const frontmatter = isRecord(item._frontmatter) ? item._frontmatter : {}
|
|
360
|
+
const extras = frontmatterExtras(frontmatter)
|
|
361
|
+
|
|
362
|
+
const title = text(item.title) || text(parsed.data?.title)
|
|
363
|
+
const description = text(item.description) || text(parsed.data?.description)
|
|
364
|
+
// Like Nuxt Content: the seo title and description default to the page ones.
|
|
365
|
+
const seo: PlutoRuntimePage['seo'] = {
|
|
366
|
+
...(isRecord(frontmatter.seo) ? frontmatter.seo : {}),
|
|
367
|
+
title: text(item.seoTitle) || title,
|
|
368
|
+
description: text(item.seoDescription) || description,
|
|
369
|
+
}
|
|
370
|
+
const rawNavigation = frontmatter.navigation
|
|
371
|
+
const navigation: PlutoRuntimePage['navigation'] =
|
|
372
|
+
rawNavigation === false || item.navigationHidden === true
|
|
373
|
+
? false
|
|
374
|
+
: isRecord(rawNavigation)
|
|
375
|
+
? rawNavigation
|
|
376
|
+
: undefined
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
...extras.top,
|
|
380
|
+
id: `${context.type.name}:${id}.md`,
|
|
381
|
+
stem: id,
|
|
382
|
+
extension: 'md',
|
|
383
|
+
path: contentRoutePath(id, context.prefix),
|
|
384
|
+
title,
|
|
385
|
+
description,
|
|
386
|
+
seo,
|
|
387
|
+
...(navigation !== undefined && { navigation }),
|
|
388
|
+
meta: extras.meta,
|
|
389
|
+
body: { ...parsed.body, toc: parsed.toc },
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The page from the build data (Nuxt Content), or `null`. */
|
|
394
|
+
async function buildPage(
|
|
395
|
+
event: H3Event,
|
|
396
|
+
collection: string,
|
|
397
|
+
path: string
|
|
398
|
+
): Promise<unknown> {
|
|
399
|
+
try {
|
|
400
|
+
return await queryCollection(event, collection as never)
|
|
401
|
+
.path(path)
|
|
402
|
+
.first()
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.warn(
|
|
405
|
+
'[pluto-docs] The build data failed.',
|
|
406
|
+
(error as Error).message
|
|
407
|
+
)
|
|
408
|
+
return null
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** `GET /api/pluto/v1/docs/:type/page?path=/a/b&collection=docs` */
|
|
413
|
+
export async function plutoRuntimePage(event: H3Event): Promise<unknown> {
|
|
414
|
+
const context = runtimeContext(event)
|
|
415
|
+
const path = queryText(event, 'path')
|
|
416
|
+
setCacheHeaders(event)
|
|
417
|
+
|
|
418
|
+
const snapshot = await snapshotOrFallback(event, context)
|
|
419
|
+
if (!snapshot) {
|
|
420
|
+
// The store failed in hybrid mode.
|
|
421
|
+
const page = context.collection
|
|
422
|
+
? await buildPage(event, context.collection, path)
|
|
423
|
+
: null
|
|
424
|
+
if (!page) {
|
|
425
|
+
throw notFound()
|
|
426
|
+
}
|
|
427
|
+
return page
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const id = pageIdForRoute(snapshot.ids, path, context.prefix)
|
|
431
|
+
if (!id) {
|
|
432
|
+
// Not in the store. A page of the manifest was removed. Another page may be outside the type.
|
|
433
|
+
if (context.mode === 'hybrid' && context.collection) {
|
|
434
|
+
const built = Object.keys(
|
|
435
|
+
(manifests as Manifests)?.[context.type.name] ?? {}
|
|
436
|
+
)
|
|
437
|
+
if (!pageIdForRoute(built, path, context.prefix)) {
|
|
438
|
+
const page = await buildPage(event, context.collection, path)
|
|
439
|
+
if (page) {
|
|
440
|
+
return page
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
throw notFound()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const stale =
|
|
448
|
+
snapshot.diff.changed.includes(id) || snapshot.diff.added.includes(id)
|
|
449
|
+
if (context.mode === 'hybrid' && !stale && context.collection) {
|
|
450
|
+
const page = await buildPage(event, context.collection, path)
|
|
451
|
+
if (page) {
|
|
452
|
+
return page
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const page = await storePage(event, context, id)
|
|
456
|
+
if (!page) {
|
|
457
|
+
throw notFound()
|
|
458
|
+
}
|
|
459
|
+
return page
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** `GET /api/pluto/v1/docs/:type/navigation?collection=docs&includeRoot=1` */
|
|
463
|
+
export async function plutoRuntimeNavigation(event: H3Event): Promise<unknown> {
|
|
464
|
+
const context = runtimeContext(event)
|
|
465
|
+
setCacheHeaders(event)
|
|
466
|
+
const snapshot = await snapshotOrFallback(event, context)
|
|
467
|
+
if (
|
|
468
|
+
context.mode === 'hybrid' &&
|
|
469
|
+
context.collection &&
|
|
470
|
+
(!snapshot || snapshot.diff.same)
|
|
471
|
+
) {
|
|
472
|
+
return await queryCollectionNavigation(event, context.collection as never)
|
|
473
|
+
}
|
|
474
|
+
if (!snapshot) {
|
|
475
|
+
throw notFound()
|
|
476
|
+
}
|
|
477
|
+
return await storeTree(event, context, snapshot.items, false)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `GET /api/pluto/v1/docs/:type/surround?path=/a/b&collection=docs&includeRoot=1` */
|
|
481
|
+
export async function plutoRuntimeSurround(
|
|
482
|
+
event: H3Event
|
|
483
|
+
): Promise<Array<RuntimeSurroundItem | null> | unknown> {
|
|
484
|
+
const context = runtimeContext(event)
|
|
485
|
+
setCacheHeaders(event)
|
|
486
|
+
const path = queryText(event, 'path').replace(/(?<=.)\/+$/, '')
|
|
487
|
+
const snapshot = await snapshotOrFallback(event, context)
|
|
488
|
+
if (
|
|
489
|
+
context.mode === 'hybrid' &&
|
|
490
|
+
context.collection &&
|
|
491
|
+
(!snapshot || snapshot.diff.same)
|
|
492
|
+
) {
|
|
493
|
+
return await queryCollectionItemSurroundings(
|
|
494
|
+
event,
|
|
495
|
+
context.collection as never,
|
|
496
|
+
path,
|
|
497
|
+
{ fields: ['description'] as never }
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
if (!snapshot) {
|
|
501
|
+
throw notFound()
|
|
502
|
+
}
|
|
503
|
+
return surroundOf(await storeTree(event, context, snapshot.items, true), path)
|
|
504
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PlutoDocsOptions,
|
|
3
|
+
PlutoDocsRender,
|
|
4
|
+
PlutoDocsTypeOptions,
|
|
5
|
+
} from '../../modules/pluto-docs/options'
|
|
6
|
+
import type { PlutoBlocksConfig } from '../utils/blocks/types'
|
|
7
|
+
|
|
8
|
+
// The `pluto-docs` module fills the public key. See modules/pluto-docs/index.ts.
|
|
9
|
+
declare module '@nuxt/schema' {
|
|
10
|
+
interface NuxtConfig {
|
|
11
|
+
plutoDocs?: PlutoDocsOptions
|
|
12
|
+
}
|
|
13
|
+
interface NuxtOptions {
|
|
14
|
+
plutoDocs?: PlutoDocsOptions
|
|
15
|
+
}
|
|
16
|
+
interface PublicRuntimeConfig {
|
|
17
|
+
plutoDocs?: {
|
|
18
|
+
types: PlutoDocsTypeOptions[]
|
|
19
|
+
/** `'hybrid'`, `'build'` or `'runtime'`. The composables read it. */
|
|
20
|
+
render: PlutoDocsRender
|
|
21
|
+
/** Seconds of the public cache of the layer routes. */
|
|
22
|
+
runtimeMaxAge: number
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
interface RuntimeConfig {
|
|
26
|
+
plutoDocs?: {
|
|
27
|
+
/** The `toc` option of `content.build.markdown`, for the run-time parser. */
|
|
28
|
+
toc?: { depth?: number; searchDepth?: number }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// A site declares its blocks in app.config.ts. See shared/utils/blocks/registry.ts.
|
|
32
|
+
interface AppConfigInput {
|
|
33
|
+
plutoBlocks?: PlutoBlocksConfig
|
|
34
|
+
}
|
|
35
|
+
interface AppConfig {
|
|
36
|
+
plutoBlocks?: PlutoBlocksConfig
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export {}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The build manifest of the hybrid mode: type name → content id → version. The module makes it
|
|
2
|
+
// as a Nitro virtual module. See modules/pluto-docs/manifest.ts.
|
|
3
|
+
declare module '#pluto-docs/manifest' {
|
|
4
|
+
const manifest: Record<string, Record<string, string>>
|
|
5
|
+
export default manifest
|
|
6
|
+
}
|