@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.
Files changed (61) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +178 -0
  4. package/FEATURES.md +30 -0
  5. package/README.md +39 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -0
  10. package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
  11. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
  12. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
  13. package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +85 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -0
  16. package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
  17. package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
  18. package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
  19. package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
  20. package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
  21. package/app/components/PlutoContentPreview.vue +35 -0
  22. package/app/composables/pluto-block-editor.ts +443 -0
  23. package/app/composables/pluto-content-edit-link.ts +19 -0
  24. package/app/composables/pluto-content-preview.ts +29 -0
  25. package/app/composables/pluto-content-runtime.ts +237 -0
  26. package/app/pages/_pluto/preview.vue +555 -0
  27. package/app/plugins/pluto-docs.ts +50 -0
  28. package/i18n/locales/en.json +253 -0
  29. package/i18n/locales/pt-BR.json +289 -0
  30. package/modules/pluto-docs/index.ts +123 -0
  31. package/modules/pluto-docs/manifest.ts +159 -0
  32. package/modules/pluto-docs/options.ts +224 -0
  33. package/nuxt.config.ts +36 -0
  34. package/package.json +55 -4
  35. package/server/handlers/navigation.get.ts +7 -0
  36. package/server/handlers/page.get.ts +7 -0
  37. package/server/handlers/surround.get.ts +7 -0
  38. package/server/plugins/pluto-docs.ts +25 -0
  39. package/server/utils/pluto-docs-runtime.ts +504 -0
  40. package/shared/types/docs.d.ts +40 -0
  41. package/shared/types/pluto-manifest.d.ts +6 -0
  42. package/shared/utils/blocks/editor.ts +357 -0
  43. package/shared/utils/blocks/history.ts +94 -0
  44. package/shared/utils/blocks/mdc-props.ts +321 -0
  45. package/shared/utils/blocks/operations.ts +692 -0
  46. package/shared/utils/blocks/parse.ts +444 -0
  47. package/shared/utils/blocks/presets.ts +383 -0
  48. package/shared/utils/blocks/preview-marks.ts +98 -0
  49. package/shared/utils/blocks/preview-protocol.ts +195 -0
  50. package/shared/utils/blocks/registry.ts +163 -0
  51. package/shared/utils/blocks/serialize.ts +107 -0
  52. package/shared/utils/blocks/tree.ts +85 -0
  53. package/shared/utils/blocks/types.ts +165 -0
  54. package/shared/utils/content-route.ts +40 -0
  55. package/shared/utils/docs-type.ts +290 -0
  56. package/shared/utils/mdc-snippets.ts +114 -0
  57. package/shared/utils/navigation-config.ts +19 -0
  58. package/shared/utils/runtime-content.ts +403 -0
  59. package/tailwind.lint.css +10 -0
  60. package/tsconfig.json +18 -0
  61. package/vitest.config.ts +7 -0
@@ -0,0 +1,403 @@
1
+ // The pure part of the run-time render mode. No Nuxt import, so plain Vitest tests it.
2
+ // It turns the flat list of pages of a type into what Nuxt Content gives at build time:
3
+ // the navigation tree and the previous and next page. The tree follows the algorithm of
4
+ // `generateNavigationTree` in Nuxt Content, so a site can use the same components.
5
+ // See the docs-setup skill.
6
+
7
+ import { contentRoutePath } from './content-route'
8
+
9
+ /** The fields of a page that the navigation needs. They come from the docs type fields. */
10
+ export interface RuntimeNavPage {
11
+ /** The content id, for example `2.core/03.content-model`. */
12
+ id: string
13
+ title: string
14
+ description?: string
15
+ navigationTitle?: string
16
+ navigationIcon?: string
17
+ navigationHidden?: boolean
18
+ }
19
+
20
+ /** A node of the navigation tree. The shape of `ContentNavigationItem` of Nuxt Content. */
21
+ export interface RuntimeNavItem {
22
+ title: string
23
+ path: string
24
+ stem: string
25
+ children?: RuntimeNavItem[]
26
+ /** `false` for a folder that has no index page. */
27
+ page?: false
28
+ icon?: string
29
+ description?: string
30
+ /** Other keys of a `.navigation.yml` file, as Nuxt Content passes them. */
31
+ [key: string]: unknown
32
+ }
33
+
34
+ /** A neighbor page for the surround. */
35
+ export interface RuntimeSurroundItem {
36
+ title: string
37
+ path: string
38
+ stem: string
39
+ description?: string
40
+ }
41
+
42
+ const INDEX_STEM = /(?:[1-9]\d*\.)?index/
43
+
44
+ /** `getting-started` gives `Getting Started`. Nuxt Content names a folder without an index page this way. */
45
+ export function titleFromSegment(segment: string): string {
46
+ return segment
47
+ .split(/[\s-]/g)
48
+ .filter(Boolean)
49
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
50
+ .join(' ')
51
+ }
52
+
53
+ /** Whether the id is the root page of the source (`index`, or `1.index`). */
54
+ export function isRootIndexId(id: string): boolean {
55
+ return !id.includes('/') && INDEX_STEM.test(id)
56
+ }
57
+
58
+ /**
59
+ * The parsed `.navigation.yml` of a folder. Nuxt Content reads `title`, `navigation` (`false` hides
60
+ * the folder, an object overrides the keys) and passes every other key (`icon`, ...) to the folder item.
61
+ */
62
+ export type RuntimeFolderConfig = Record<string, unknown>
63
+
64
+ /** The keys that Nuxt Content keeps as page fields. They do not go to the folder item as they are. */
65
+ const FOLDER_PAGE_KEYS = new Set([
66
+ 'title',
67
+ 'description',
68
+ 'seo',
69
+ 'navigation',
70
+ 'path',
71
+ 'id',
72
+ 'stem',
73
+ 'extension',
74
+ 'body',
75
+ ])
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
79
+ }
80
+
81
+ /**
82
+ * The keys that a folder config puts on its navigation item, like `pickConfigNavigationFields`
83
+ * of Nuxt Content: the title, then the other keys, then the `navigation` object.
84
+ */
85
+ export function folderItemFields(
86
+ config: RuntimeFolderConfig | undefined
87
+ ): Record<string, unknown> {
88
+ if (!config) {
89
+ return {}
90
+ }
91
+ const out: Record<string, unknown> = {}
92
+ if (
93
+ typeof config.title === 'string' &&
94
+ config.title !== '' &&
95
+ config.title.toLowerCase() !== 'navigation'
96
+ ) {
97
+ out.title = config.title
98
+ }
99
+ for (const [key, value] of Object.entries(config)) {
100
+ if (!FOLDER_PAGE_KEYS.has(key)) {
101
+ out[key] = value
102
+ }
103
+ }
104
+ if (isRecord(config.navigation)) {
105
+ Object.assign(out, config.navigation)
106
+ }
107
+ return out
108
+ }
109
+
110
+ export interface RuntimeNavOptions {
111
+ /** The `.navigation.yml` configs, by folder id path (`2.core`, `2.core/1.sub`). */
112
+ folders?: Record<string, RuntimeFolderConfig>
113
+ /** The route prefix of the type, for example `/docs`. */
114
+ routePrefix?: string
115
+ /** Keep the root `index` page in the tree. Default false: it is the landing page. */
116
+ includeRoot?: boolean
117
+ /** Copy the page description to each item. The surround needs it. Default false. */
118
+ withDescription?: boolean
119
+ }
120
+
121
+ /** Whether the folder config of this folder, or of a parent folder, hides it. */
122
+ function isInHiddenFolder(
123
+ id: string,
124
+ folders: Record<string, RuntimeFolderConfig> | undefined
125
+ ): boolean {
126
+ if (!folders) {
127
+ return false
128
+ }
129
+ const parts = id.split('/').slice(0, -1)
130
+ return parts.some(
131
+ (_part, index) =>
132
+ folders[parts.slice(0, index + 1).join('/')]?.navigation === false
133
+ )
134
+ }
135
+
136
+ function visiblePages(
137
+ pages: RuntimeNavPage[],
138
+ options: RuntimeNavOptions
139
+ ): RuntimeNavPage[] {
140
+ return pages.filter(
141
+ (page) =>
142
+ page.navigationHidden !== true &&
143
+ (options.includeRoot === true || !isRootIndexId(page.id)) &&
144
+ !isInHiddenFolder(page.id, options.folders)
145
+ )
146
+ }
147
+
148
+ /**
149
+ * Builds the navigation tree from the pages, in the order given (the tree order of ids).
150
+ * A page with `navigationHidden` is left out. The root `index` page is left out unless
151
+ * `includeRoot` is set. A folder takes the title of its index page. A folder with no
152
+ * index page gets a title from its name and `page: false`.
153
+ */
154
+ export function buildNavigationTree(
155
+ pages: RuntimeNavPage[],
156
+ options: RuntimeNavOptions = {}
157
+ ): RuntimeNavItem[] {
158
+ const prefix = options.routePrefix ?? ''
159
+ const nav: RuntimeNavItem[] = []
160
+
161
+ for (const page of visiblePages(pages, options)) {
162
+ const path = contentRoutePath(page.id, prefix)
163
+ const prefixParts = prefix.split('/').filter(Boolean)
164
+ const parts = path.substring(1).split('/').slice(prefixParts.length)
165
+ const idParts = page.id.split('/')
166
+ const isIndex = INDEX_STEM.test(idParts.at(-1) ?? '')
167
+
168
+ const makeItem = (): RuntimeNavItem => ({
169
+ title: page.navigationTitle || page.title,
170
+ path,
171
+ stem: page.id,
172
+ children: [],
173
+ ...(page.navigationIcon ? { icon: page.navigationIcon } : {}),
174
+ ...(options.withDescription && page.description
175
+ ? { description: page.description }
176
+ : {}),
177
+ })
178
+ const item = makeItem()
179
+ if (isIndex && path !== (prefix || '/')) {
180
+ item.children!.push(makeItem())
181
+ // The config of the folder wins over the title and the icon of its index page.
182
+ Object.assign(
183
+ item,
184
+ folderItemFields(options.folders?.[idParts.slice(0, -1).join('/')])
185
+ )
186
+ }
187
+
188
+ // A page at the top level of the source.
189
+ if (parts.length <= 1) {
190
+ const placeholder = isIndex
191
+ ? nav.find((node) => node.path === item.path && node.page === false)
192
+ : undefined
193
+ if (placeholder) {
194
+ Object.assign(placeholder, {
195
+ page: undefined,
196
+ children: [...(item.children ?? []), ...(placeholder.children ?? [])],
197
+ })
198
+ } else {
199
+ nav.push(item)
200
+ }
201
+ continue
202
+ }
203
+
204
+ // Walk down the folders. A missing folder becomes a placeholder.
205
+ let siblings = nav
206
+ for (const [index, part] of parts.slice(0, -1).entries()) {
207
+ const folderPath = contentRoutePath(
208
+ idParts.slice(0, index + 1).join('/'),
209
+ prefix
210
+ )
211
+ let parent = siblings.find((node) => node.path === folderPath)
212
+ if (!parent) {
213
+ const fields = folderItemFields(
214
+ options.folders?.[idParts.slice(0, index + 1).join('/')]
215
+ )
216
+ parent = {
217
+ ...fields,
218
+ title:
219
+ typeof fields.title === 'string' && fields.title
220
+ ? fields.title
221
+ : titleFromSegment(part),
222
+ path: folderPath,
223
+ stem: idParts.slice(0, index + 1).join('/'),
224
+ children: [],
225
+ page: false,
226
+ }
227
+ siblings.push(parent)
228
+ }
229
+ siblings = parent.children!
230
+ }
231
+
232
+ const existing = siblings.find(
233
+ (node) => node.path === item.path && node.page === false
234
+ )
235
+ if (existing) {
236
+ Object.assign(existing, {
237
+ ...item,
238
+ page: undefined,
239
+ children: [...(item.children ?? []), ...(existing.children ?? [])],
240
+ })
241
+ } else {
242
+ siblings.push(item)
243
+ }
244
+ }
245
+
246
+ return clearTree(nav)
247
+ }
248
+
249
+ /** Removes empty `children` and the `page: undefined` keys, like Nuxt Content does. */
250
+ function clearTree(nav: RuntimeNavItem[]): RuntimeNavItem[] {
251
+ for (const item of nav) {
252
+ if (item.page === undefined) {
253
+ delete item.page
254
+ }
255
+ if (item.children?.length) {
256
+ clearTree(item.children)
257
+ } else {
258
+ delete item.children
259
+ }
260
+ }
261
+ return nav
262
+ }
263
+
264
+ /** The pages of a tree in reading order. A folder with no page is skipped. */
265
+ export function flattenNavigation(nav: RuntimeNavItem[]): RuntimeNavItem[] {
266
+ return nav.flatMap((item) => {
267
+ const children = item.children ? flattenNavigation(item.children) : []
268
+ if (
269
+ item.page === false ||
270
+ children.some((child) => child.path === item.path)
271
+ ) {
272
+ return children
273
+ }
274
+ return [{ ...item, children: undefined }, ...children]
275
+ })
276
+ }
277
+
278
+ /** The previous and the next page of `path`. A missing neighbor is `null`. An unknown path gives two `null`. */
279
+ export function surroundOf(
280
+ nav: RuntimeNavItem[],
281
+ path: string
282
+ ): [RuntimeSurroundItem | null, RuntimeSurroundItem | null] {
283
+ const flat = flattenNavigation(nav)
284
+ const index = flat.findIndex((item) => item.path === path)
285
+ const pick = (
286
+ item: RuntimeNavItem | undefined
287
+ ): RuntimeSurroundItem | null =>
288
+ item
289
+ ? {
290
+ title: item.title,
291
+ path: item.path,
292
+ stem: item.stem,
293
+ ...(item.description ? { description: item.description } : {}),
294
+ }
295
+ : null
296
+ if (index === -1) {
297
+ return [null, null]
298
+ }
299
+ return [pick(flat[index - 1]), pick(flat[index + 1])]
300
+ }
301
+
302
+ /** The content id of the page for a route path, or `undefined`. Ignores a trailing slash. */
303
+ export function pageIdForRoute(
304
+ ids: string[],
305
+ path: string,
306
+ routePrefix = ''
307
+ ): string | undefined {
308
+ const wanted = path.length > 1 ? path.replace(/\/+$/, '') : path
309
+ return ids.find((id) => contentRoutePath(id, routePrefix) === wanted)
310
+ }
311
+
312
+ /** One entry of the store listing: the id and the version (`_version`) of the file. */
313
+ export interface ListedVersion {
314
+ id: string
315
+ version?: string
316
+ }
317
+
318
+ /** The difference between the build manifest of a type and the store listing. */
319
+ export interface ManifestDiff {
320
+ /** True when nothing differs: the build data is current. */
321
+ same: boolean
322
+ /** Ids in both, with another version. */
323
+ changed: string[]
324
+ /** Ids only in the store. */
325
+ added: string[]
326
+ /** Ids only in the manifest. */
327
+ removed: string[]
328
+ }
329
+
330
+ /**
331
+ * Compares the build manifest (id → version) with the store listing. A store entry without a
332
+ * version counts as changed, because the build data cannot be proven current.
333
+ */
334
+ export function diffManifest(
335
+ manifest: Record<string, string> | undefined,
336
+ current: ListedVersion[]
337
+ ): ManifestDiff {
338
+ const built = manifest ?? {}
339
+ const seen = new Set<string>()
340
+ const changed: string[] = []
341
+ const added: string[] = []
342
+ for (const entry of current) {
343
+ seen.add(entry.id)
344
+ if (!Object.hasOwn(built, entry.id)) {
345
+ added.push(entry.id)
346
+ } else if (!entry.version || built[entry.id] !== entry.version) {
347
+ changed.push(entry.id)
348
+ }
349
+ }
350
+ const removed = Object.keys(built).filter((id) => !seen.has(id))
351
+ return {
352
+ same: changed.length === 0 && added.length === 0 && removed.length === 0,
353
+ changed,
354
+ added,
355
+ removed,
356
+ }
357
+ }
358
+
359
+ /** The page fields that Nuxt Content builds itself. Frontmatter with these keys does not go to `meta`. */
360
+ const PAGE_FIELD_KEYS = new Set([
361
+ 'id',
362
+ 'stem',
363
+ 'extension',
364
+ 'path',
365
+ 'title',
366
+ 'description',
367
+ 'seo',
368
+ 'navigation',
369
+ 'body',
370
+ 'rawbody',
371
+ 'meta',
372
+ ])
373
+
374
+ /** Flat keys that the docs type adds to the frontmatter for its form fields. They are not file keys. */
375
+ const FORM_ONLY_KEYS = new Set([
376
+ 'navigationTitle',
377
+ 'navigationIcon',
378
+ 'navigationHidden',
379
+ 'seoTitle',
380
+ 'seoDescription',
381
+ ])
382
+
383
+ /**
384
+ * The extra keys of a page from its whole frontmatter. Nuxt Content keeps the keys that the
385
+ * collection schema declares at the top level of the page and puts the other keys in `meta`. The
386
+ * server does not know the schema, so a key goes in both places: the page reads it the same way in
387
+ * both modes. A key that is a page field (`title`, `seo`, ...) is left out: the page builds it.
388
+ */
389
+ export function frontmatterExtras(
390
+ frontmatter: Record<string, unknown> | undefined
391
+ ): { top: Record<string, unknown>; meta: Record<string, unknown> } {
392
+ const top: Record<string, unknown> = {}
393
+ for (const [key, value] of Object.entries(frontmatter ?? {})) {
394
+ if (
395
+ !PAGE_FIELD_KEYS.has(key) &&
396
+ !FORM_ONLY_KEYS.has(key) &&
397
+ key !== '__proto__'
398
+ ) {
399
+ top[key] = value
400
+ }
401
+ }
402
+ return { top, meta: { ...top } }
403
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ * The Tailwind design system for ESLint (better-tailwindcss) and the editor
3
+ * (Tailwind IntelliSense). It is not part of the build: a Pluto layer has no
4
+ * Tailwind entry, the app owns it (see the single-tailwind-build skill in pluto). This file gives the
5
+ * lint the theme of an app: Tailwind, Nuxt UI and the Pluto layer CSS.
6
+ */
7
+ @import 'tailwindcss';
8
+ @import '@nuxt/ui';
9
+ @import '@plutocms/core/app/assets/css/pluto-layer.css';
10
+ @import './app/assets/css/pluto-layer.css';
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "references": [
3
+ {
4
+ "path": "./.nuxt/tsconfig.app.json"
5
+ },
6
+ {
7
+ "path": "./.nuxt/tsconfig.server.json"
8
+ },
9
+ {
10
+ "path": "./.nuxt/tsconfig.shared.json"
11
+ },
12
+ {
13
+ "path": "./.nuxt/tsconfig.node.json"
14
+ }
15
+ ],
16
+ // https://nuxt.com/docs/guide/concepts/typescript
17
+ "files": []
18
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ define: {
5
+ 'import.meta.dev': 'true',
6
+ },
7
+ })