@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,290 @@
1
+ // The docs preset. Pure code with no Nuxt import, so plain Vitest tests it.
2
+ // It builds the content type of a Nuxt Content docs page. The frontmatter
3
+ // hooks map `navigation` and `seo` to flat keys, so each of them is a normal
4
+ // field (a virtual field is not read back). See the docs-preset skill.
5
+
6
+ import { contentRoutePath } from './content-route'
7
+
8
+ type Frontmatter = Record<string, unknown>
9
+
10
+ function isRecord(value: unknown): value is Frontmatter {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
12
+ }
13
+
14
+ function hasText(value: unknown): value is string {
15
+ return typeof value === 'string' && value !== ''
16
+ }
17
+
18
+ /**
19
+ * Sets a key of a nested object from a flat value. An empty flat value removes
20
+ * the key, but only when the key holds text or nothing: a value of another
21
+ * type (a number, a list) stays as the author wrote it.
22
+ */
23
+ function applyText(target: Frontmatter, key: string, flat: unknown): void {
24
+ if (hasText(flat)) {
25
+ target[key] = flat
26
+ } else if (target[key] === undefined || typeof target[key] === 'string') {
27
+ delete target[key]
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Runs after a file is parsed. Adds the flat keys that the form fields read:
33
+ * `navigationTitle`, `navigationIcon`, `navigationHidden` (from `navigation`)
34
+ * and `seoTitle`, `seoDescription` (from `seo`). It keeps every other key.
35
+ */
36
+ export function readDocsFrontmatter(frontmatter: Frontmatter): Frontmatter {
37
+ const out: Frontmatter = { ...frontmatter }
38
+ const navigation = frontmatter.navigation
39
+ if (navigation === false) {
40
+ out.navigationHidden = true
41
+ } else if (isRecord(navigation)) {
42
+ if (typeof navigation.title === 'string') {
43
+ out.navigationTitle = navigation.title
44
+ }
45
+ if (typeof navigation.icon === 'string') {
46
+ out.navigationIcon = navigation.icon
47
+ }
48
+ }
49
+ const seo = frontmatter.seo
50
+ if (isRecord(seo)) {
51
+ if (typeof seo.title === 'string') {
52
+ out.seoTitle = seo.title
53
+ }
54
+ if (typeof seo.description === 'string') {
55
+ out.seoDescription = seo.description
56
+ }
57
+ }
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * Runs before a file is written. Folds the flat keys back into `navigation`
63
+ * and `seo`, and drops them. The flat keys win: a missing flat key means the
64
+ * author cleared the field. Keys of `navigation` and `seo` that the preset does
65
+ * not know stay. An empty object is dropped. A hidden page gets
66
+ * `navigation: false`, which replaces the title and the icon.
67
+ */
68
+ export function writeDocsFrontmatter(frontmatter: Frontmatter): Frontmatter {
69
+ const out: Frontmatter = { ...frontmatter }
70
+ const {
71
+ navigationTitle,
72
+ navigationIcon,
73
+ navigationHidden,
74
+ seoTitle,
75
+ seoDescription,
76
+ } = frontmatter
77
+ delete out.navigationTitle
78
+ delete out.navigationIcon
79
+ delete out.navigationHidden
80
+ delete out.seoTitle
81
+ delete out.seoDescription
82
+
83
+ // navigation
84
+ const navigation = frontmatter.navigation
85
+ if (navigationHidden === true) {
86
+ out.navigation = false
87
+ } else {
88
+ const object: Frontmatter = isRecord(navigation) ? { ...navigation } : {}
89
+ applyText(object, 'title', navigationTitle)
90
+ applyText(object, 'icon', navigationIcon)
91
+ if (Object.keys(object).length > 0) {
92
+ out.navigation = object
93
+ } else if (isRecord(navigation) || navigation === false) {
94
+ delete out.navigation
95
+ }
96
+ }
97
+
98
+ // seo
99
+ const seo = frontmatter.seo
100
+ const seoObject: Frontmatter = isRecord(seo) ? { ...seo } : {}
101
+ applyText(seoObject, 'title', seoTitle)
102
+ applyText(seoObject, 'description', seoDescription)
103
+ if (Object.keys(seoObject).length > 0) {
104
+ out.seo = seoObject
105
+ } else if (isRecord(seo)) {
106
+ delete out.seo
107
+ }
108
+
109
+ return out
110
+ }
111
+
112
+ export interface DocsTypeOptions {
113
+ /** The content type name, for example `doc`. */
114
+ name: string
115
+ /** The folder under the CMS content dir. `''` is the content dir itself. */
116
+ source: string
117
+ label?: string
118
+ labelPlural?: string
119
+ /** The Iconify name of the type in the admin. Default `lucide:book-open`. */
120
+ icon?: string
121
+ /** Ids to hide from the list: an id (`index`), or a folder with a trailing slash. */
122
+ exclude?: string[]
123
+ /** The public path in front of the page paths, for example `/docs`. It builds the View link. */
124
+ routePrefix?: string
125
+ /**
126
+ * The public site shows a save at once, because it reads the type at run time (the render mode
127
+ * is `hybrid` or `runtime`). The build indicator of the edit form then shows "Live" after a save.
128
+ */
129
+ liveOnSave?: boolean
130
+ /**
131
+ * The nav group of the type in the admin sidebar and the admin bar. Default `'pages'`, the group
132
+ * of core: the docs types show as the children of one "Pages" item, and in one "New" menu. A group
133
+ * id puts the type in another group. `false` gives the type its own item.
134
+ */
135
+ navGroup?: string | false
136
+ }
137
+
138
+ /** The nav group of a docs type when the options set none. Core declares it. */
139
+ export const DEFAULT_DOCS_NAV_GROUP = 'pages'
140
+
141
+ /** The capabilities of the docs preset. The client plugin registers them. */
142
+ export const DOCS_CAPABILITIES = {
143
+ write: 'docs:write',
144
+ delete: 'docs:delete',
145
+ } as const
146
+
147
+ /**
148
+ * The docs page type. The id of an item is the path of the file under `source`
149
+ * without `.md` (`2.core/03.content-model`). The slug field holds that whole
150
+ * path, and a new path moves the file. The path is never a frontmatter key,
151
+ * because Nuxt Content reserves `path`.
152
+ */
153
+ export function createDocsType(options: DocsTypeOptions): PlutoContentType {
154
+ const navGroup =
155
+ options.navGroup === false
156
+ ? undefined
157
+ : (options.navGroup ?? DEFAULT_DOCS_NAV_GROUP)
158
+ return {
159
+ name: options.name,
160
+ label: options.label ?? 'Page',
161
+ labelPlural: options.labelPlural ?? 'Pages',
162
+ icon: options.icon ?? 'lucide:book-open',
163
+ ...(navGroup && { navGroup }),
164
+ source: options.source,
165
+ titleField: 'title',
166
+ slug: { field: 'slug' },
167
+ fields: [
168
+ {
169
+ name: 'title',
170
+ type: 'text',
171
+ label: 'Title',
172
+ required: true,
173
+ inList: true,
174
+ order: 10,
175
+ region: 'side',
176
+ },
177
+ {
178
+ name: 'slug',
179
+ type: 'slug',
180
+ label: 'Path',
181
+ description:
182
+ 'The file path without .md, for example 2.core/03.content-model. A new path moves the file.',
183
+ required: true,
184
+ inList: true,
185
+ order: 20,
186
+ region: 'side',
187
+ },
188
+ {
189
+ name: 'description',
190
+ type: 'textarea',
191
+ label: 'Description',
192
+ rows: 2,
193
+ order: 30,
194
+ region: 'side',
195
+ },
196
+ {
197
+ name: 'body',
198
+ type: 'richtext',
199
+ label: 'Content',
200
+ widget: 'mdc',
201
+ editor: 'visual',
202
+ inList: false,
203
+ order: 40,
204
+ region: 'main',
205
+ },
206
+ {
207
+ name: 'navigationTitle',
208
+ type: 'text',
209
+ label: 'Navigation title',
210
+ order: 110,
211
+ inList: false,
212
+ region: 'side',
213
+ },
214
+ {
215
+ name: 'navigationIcon',
216
+ type: 'text',
217
+ label: 'Navigation icon',
218
+ placeholder: 'i-lucide-rocket',
219
+ order: 120,
220
+ inList: false,
221
+ region: 'side',
222
+ },
223
+ {
224
+ name: 'navigationHidden',
225
+ type: 'boolean',
226
+ label: 'Hide from the navigation',
227
+ order: 130,
228
+ inList: false,
229
+ region: 'side',
230
+ },
231
+ {
232
+ name: 'seoTitle',
233
+ type: 'text',
234
+ label: 'SEO title',
235
+ order: 140,
236
+ inList: false,
237
+ region: 'side',
238
+ },
239
+ {
240
+ name: 'seoDescription',
241
+ type: 'textarea',
242
+ label: 'SEO description',
243
+ rows: 3,
244
+ order: 150,
245
+ inList: false,
246
+ region: 'side',
247
+ },
248
+ ],
249
+ // "New page" and "Edit page" in the admin bar (core). See the page-links skill.
250
+ adminBar: true,
251
+ // The pages are public on core's public API: /api/pluto/v1/content/<name>[/<id>] (the item has
252
+ // `html`, rendered by the CMS adapter).
253
+ api: { public: true },
254
+ ...(options.liveOnSave && { liveOnSave: true }),
255
+ status: false,
256
+ timestamps: false,
257
+ // Tree order: the numeric prefixes decide, not the title.
258
+ defaultSort: { field: 'id', direction: 'asc' },
259
+ capabilities: {
260
+ write: DOCS_CAPABILITIES.write,
261
+ delete: DOCS_CAPABILITIES.delete,
262
+ },
263
+ // The canvas layout: the body fills the page (the block editor), and the other fields are in the Page tab.
264
+ // `leftPanel`: the block list. Core shows its skeleton while the page or the editor code loads.
265
+ form: {
266
+ layout: 'canvas',
267
+ canvasField: 'body',
268
+ leftPanel: {
269
+ label: 'docs.editor.blocks',
270
+ class: 'bg-white dark:bg-[#0b0b0b]',
271
+ },
272
+ },
273
+ previewUrl: (item) =>
274
+ item.id
275
+ ? contentRoutePath(String(item.id), options.routePrefix ?? '')
276
+ : undefined,
277
+ files: {
278
+ nested: true,
279
+ exclude: options.exclude ?? [],
280
+ frontmatter: { read: readDocsFrontmatter, write: writeDocsFrontmatter },
281
+ },
282
+ }
283
+ }
284
+
285
+ /** Builds a content type from a preset entry of the module options. */
286
+ export function createPresetType(
287
+ entry: DocsTypeOptions & { preset: 'docs' }
288
+ ): PlutoContentType {
289
+ return createDocsType(entry)
290
+ }
@@ -0,0 +1,114 @@
1
+ // The MDC snippets of the Insert menu. Pure data, so plain Vitest tests it.
2
+ // Each `label` is a full i18n key (a literal, so the locale test finds it).
3
+
4
+ export interface MdcSnippet {
5
+ id: string
6
+ /** An i18n key of the layer. */
7
+ label: string
8
+ icon: string
9
+ /** The Markdown text to insert. */
10
+ text: string
11
+ /** Put a blank line before and after the text. Inline snippets set `false`. */
12
+ block: boolean
13
+ }
14
+
15
+ export const MDC_SNIPPETS: MdcSnippet[] = [
16
+ {
17
+ id: 'note',
18
+ label: 'docs.snippets.note',
19
+ icon: 'i-lucide-info',
20
+ block: true,
21
+ text: '::note\nText of the note.\n::',
22
+ },
23
+ {
24
+ id: 'tip',
25
+ label: 'docs.snippets.tip',
26
+ icon: 'i-lucide-lightbulb',
27
+ block: true,
28
+ text: '::tip\nText of the tip.\n::',
29
+ },
30
+ {
31
+ id: 'warning',
32
+ label: 'docs.snippets.warning',
33
+ icon: 'i-lucide-triangle-alert',
34
+ block: true,
35
+ text: '::warning\nText of the warning.\n::',
36
+ },
37
+ {
38
+ id: 'caution',
39
+ label: 'docs.snippets.caution',
40
+ icon: 'i-lucide-octagon-alert',
41
+ block: true,
42
+ text: '::caution\nText of the caution.\n::',
43
+ },
44
+ {
45
+ id: 'callout',
46
+ label: 'docs.snippets.callout',
47
+ icon: 'i-lucide-message-square-quote',
48
+ block: true,
49
+ text: '::callout{icon="i-lucide-info" color="primary"}\nText of the callout.\n::',
50
+ },
51
+ {
52
+ id: 'code-group',
53
+ label: 'docs.snippets.codeGroup',
54
+ icon: 'i-lucide-square-code',
55
+ block: true,
56
+ text: '::code-group\n```bash [bun]\nbun add package\n```\n\n```bash [pnpm]\npnpm add package\n```\n::',
57
+ },
58
+ {
59
+ id: 'tabs',
60
+ label: 'docs.snippets.tabs',
61
+ icon: 'i-lucide-panel-top',
62
+ block: true,
63
+ text: '::tabs\n :::tabs-item{label="First" icon="i-lucide-code"}\n Text of the first tab.\n :::\n\n :::tabs-item{label="Second" icon="i-lucide-file"}\n Text of the second tab.\n :::\n::',
64
+ },
65
+ {
66
+ id: 'steps',
67
+ label: 'docs.snippets.steps',
68
+ icon: 'i-lucide-list-ordered',
69
+ block: true,
70
+ text: '::steps{level="3"}\n### First step\n\nText of the first step.\n\n### Second step\n\nText of the second step.\n::',
71
+ },
72
+ {
73
+ id: 'card-group',
74
+ label: 'docs.snippets.cardGroup',
75
+ icon: 'i-lucide-layout-grid',
76
+ block: true,
77
+ text: '::card-group\n :::card\n ---\n title: Title\n icon: i-lucide-box\n to: /path\n ---\n Text of the card.\n :::\n::',
78
+ },
79
+ {
80
+ id: 'accordion',
81
+ label: 'docs.snippets.accordion',
82
+ icon: 'i-lucide-chevrons-up-down',
83
+ block: true,
84
+ text: '::accordion\n :::accordion-item{label="Question" icon="i-lucide-circle-help"}\n Answer.\n :::\n::',
85
+ },
86
+ {
87
+ id: 'field-group',
88
+ label: 'docs.snippets.fieldGroup',
89
+ icon: 'i-lucide-list-tree',
90
+ block: true,
91
+ text: '::field-group\n :::field{name="name" type="string" required}\n Text of the field.\n :::\n::',
92
+ },
93
+ {
94
+ id: 'kbd',
95
+ label: 'docs.snippets.kbd',
96
+ icon: 'i-lucide-keyboard',
97
+ block: false,
98
+ text: ':kbd{value="Ctrl"}',
99
+ },
100
+ {
101
+ id: 'icon',
102
+ label: 'docs.snippets.icon',
103
+ icon: 'i-lucide-smile',
104
+ block: false,
105
+ text: ':icon{name="i-lucide-rocket"}',
106
+ },
107
+ {
108
+ id: 'code-file',
109
+ label: 'docs.snippets.codeFile',
110
+ icon: 'i-lucide-file-code',
111
+ block: true,
112
+ text: '```ts [file.ts]\nconst value = 1\n```',
113
+ },
114
+ ]
@@ -0,0 +1,19 @@
1
+ // Reads a `.navigation.yml` file of a folder. No Nuxt import, so plain Vitest tests it.
2
+ // Nuxt Content applies these files to the navigation tree. See `folderItemFields` in
3
+ // `runtime-content.ts` for the keys that count.
4
+
5
+ import { parse } from 'yaml'
6
+
7
+ /** The parsed file, or `undefined` when the text is empty, is not valid YAML, or is not a map. */
8
+ export function parseNavigationConfig(
9
+ source: string
10
+ ): Record<string, unknown> | undefined {
11
+ try {
12
+ const data: unknown = parse(source)
13
+ return typeof data === 'object' && data !== null && !Array.isArray(data)
14
+ ? (data as Record<string, unknown>)
15
+ : undefined
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }