@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,40 @@
|
|
|
1
|
+
const NUMBER_PREFIX = /^\d+\./
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public route of a Nuxt Content page, from its content id. Nuxt Content
|
|
5
|
+
* drops the number prefixes of the folders and files, and a final `index`
|
|
6
|
+
* segment. For example `1.getting-started/2.installation` gives
|
|
7
|
+
* `/getting-started/installation`, and `index` gives `/`. `prefix` is added in
|
|
8
|
+
* front of the path (for example `/docs`).
|
|
9
|
+
*/
|
|
10
|
+
export function contentRoutePath(id: string, prefix = ''): string {
|
|
11
|
+
const segments = String(id)
|
|
12
|
+
.split('/')
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map((segment) => segment.replace(NUMBER_PREFIX, ''))
|
|
15
|
+
|
|
16
|
+
if (segments.at(-1) === 'index') {
|
|
17
|
+
segments.pop()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const base = prefix.split('/').filter(Boolean)
|
|
21
|
+
const path = [...base, ...segments].join('/')
|
|
22
|
+
return `/${path}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The admin form of a content item: `/admin/content/<type>/<id>`. Each segment
|
|
27
|
+
* of the id is URL-encoded, and the slashes stay (like `contentIdPath` in core).
|
|
28
|
+
*/
|
|
29
|
+
export function contentEditPath(type: string, id: string): string {
|
|
30
|
+
const path = String(id)
|
|
31
|
+
.split('/')
|
|
32
|
+
.map((segment) => encodeURIComponent(segment))
|
|
33
|
+
.join('/')
|
|
34
|
+
return `/admin/content/${encodeURIComponent(type)}/${path}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The admin form that creates an item of a type: `/admin/content/<type>/new`. */
|
|
38
|
+
export function contentNewPath(type: string): string {
|
|
39
|
+
return `/admin/content/${encodeURIComponent(type)}/new`
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|