@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.
Files changed (65) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +186 -0
  4. package/FEATURES.md +34 -0
  5. package/README.md +40 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +165 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +161 -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 +86 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +232 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +269 -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 +459 -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/blocks.ts +217 -0
  31. package/modules/pluto-docs/index.ts +230 -0
  32. package/modules/pluto-docs/manifest.ts +159 -0
  33. package/modules/pluto-docs/options.ts +258 -0
  34. package/nuxt.config.ts +36 -0
  35. package/package.json +56 -4
  36. package/server/handlers/navigation.get.ts +7 -0
  37. package/server/handlers/page.get.ts +7 -0
  38. package/server/handlers/surround.get.ts +7 -0
  39. package/server/plugins/pluto-docs.ts +25 -0
  40. package/server/utils/pluto-docs-runtime.ts +504 -0
  41. package/shared/types/docs.d.ts +40 -0
  42. package/shared/types/pluto-blocks.d.ts +10 -0
  43. package/shared/types/pluto-manifest.d.ts +6 -0
  44. package/shared/utils/blocks/component-meta.ts +285 -0
  45. package/shared/utils/blocks/define.ts +15 -0
  46. package/shared/utils/blocks/editor.ts +357 -0
  47. package/shared/utils/blocks/history.ts +94 -0
  48. package/shared/utils/blocks/mdc-props.ts +321 -0
  49. package/shared/utils/blocks/operations.ts +692 -0
  50. package/shared/utils/blocks/parse.ts +444 -0
  51. package/shared/utils/blocks/presets.ts +384 -0
  52. package/shared/utils/blocks/preview-marks.ts +98 -0
  53. package/shared/utils/blocks/preview-protocol.ts +195 -0
  54. package/shared/utils/blocks/registry.ts +295 -0
  55. package/shared/utils/blocks/serialize.ts +107 -0
  56. package/shared/utils/blocks/tree.ts +85 -0
  57. package/shared/utils/blocks/types.ts +195 -0
  58. package/shared/utils/content-route.ts +40 -0
  59. package/shared/utils/docs-type.ts +290 -0
  60. package/shared/utils/mdc-snippets.ts +114 -0
  61. package/shared/utils/navigation-config.ts +19 -0
  62. package/shared/utils/runtime-content.ts +403 -0
  63. package/tailwind.lint.css +10 -0
  64. package/tsconfig.json +18 -0
  65. package/vitest.config.ts +7 -0
@@ -0,0 +1,258 @@
1
+ /** One content type that the layer builds from a preset. */
2
+ export interface PlutoDocsTypeOptions {
3
+ /** The preset that makes the type. Only `docs` exists. */
4
+ preset: 'docs'
5
+ /** The content type name. It is a URL segment: `/admin/content/<name>`. */
6
+ name: string
7
+ /**
8
+ * The folder of the pages, under the CMS content dir. Use `''` for the content
9
+ * dir itself (the usual case for a Nuxt Content site).
10
+ */
11
+ source: string
12
+ label?: string
13
+ labelPlural?: string
14
+ /** The Iconify name of the type in the admin (sidebar, New menu, dashboard). Default `lucide:book-open`. */
15
+ icon?: string
16
+ /** Ids that the type must not list or edit. An id (`index`), or a folder with a trailing slash (`drafts/`). */
17
+ exclude?: string[]
18
+ /** The route prefix of the pages on the public site, for the View link of the form. Default: none. */
19
+ routePrefix?: string
20
+ /**
21
+ * The nav group of the type in the admin sidebar and the admin bar. Default `'pages'` (core's
22
+ * group). A string is a group id. `false` gives the type its own sidebar item.
23
+ */
24
+ navGroup?: string | false
25
+ }
26
+
27
+ /** How the public pages get their content. See the docs-setup skill. */
28
+ export type PlutoDocsRender = 'hybrid' | 'build' | 'runtime'
29
+
30
+ export interface PlutoDocsOptions {
31
+ /** The content types to register. Default: none. */
32
+ types?: PlutoDocsTypeOptions[]
33
+ /**
34
+ * `'hybrid'` (default): the pages are built with the site, and the server reads from the content
35
+ * repository the pages that changed after the build. `'build'`: Nuxt Content builds the pages
36
+ * and nothing else. `'runtime'`: the server reads every page from the repository at request time.
37
+ */
38
+ render?: PlutoDocsRender
39
+ /**
40
+ * Seconds that a CDN may cache the answers of the layer routes (`hybrid` and `runtime`). Default 10.
41
+ * It is short, so a save shows soon. `0` turns the cache header into `no-store`.
42
+ */
43
+ runtimeMaxAge?: number
44
+ /** How the block editor finds the block components of the site. */
45
+ blocks?: PlutoDocsBlocksOptions
46
+ }
47
+
48
+ export interface PlutoDocsBlocksOptions {
49
+ /**
50
+ * Read `components/content/` of the app and of its layers. A component that calls
51
+ * `definePlutoBlock(...)` becomes a block. Default `true`. `false` turns the search off.
52
+ */
53
+ discover?: boolean
54
+ }
55
+
56
+ export interface PlutoDocsResolveContext {
57
+ /** False when the app sets `plutoGithub.enabled` to false. */
58
+ cmsEnabled: boolean
59
+ }
60
+
61
+ export interface PlutoDocsResolveResult {
62
+ enabled: boolean
63
+ render: PlutoDocsRender
64
+ runtimeMaxAge: number
65
+ blocks: { discover: boolean }
66
+ types: PlutoDocsTypeOptions[]
67
+ errors: string[]
68
+ }
69
+
70
+ export const PLUTO_DOCS_PRESETS = ['docs'] as const
71
+ export const PLUTO_DOCS_RENDER_MODES = ['hybrid', 'build', 'runtime'] as const
72
+ export const DEFAULT_RUNTIME_MAX_AGE = 10
73
+
74
+ export const CMS_OFF_ERROR =
75
+ 'The docs layer needs the standalone CMS. Remove plutoGithub.enabled: false, or remove the layer.'
76
+
77
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
78
+ const SOURCE_SEGMENT = /^(?:\d+\.)?[a-z0-9]+(?:-[a-z0-9]+)*$/
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
82
+ }
83
+
84
+ /** Whether `source` is `''` or a safe nested path (the same segment rule as the CMS). */
85
+ export function isValidSource(source: unknown): source is string {
86
+ if (typeof source !== 'string') {
87
+ return false
88
+ }
89
+ if (source === '') {
90
+ return true
91
+ }
92
+ const segments = source.split('/')
93
+ return (
94
+ segments.length <= 6 &&
95
+ segments.every((segment) => SOURCE_SEGMENT.test(segment))
96
+ )
97
+ }
98
+
99
+ /** Normalizes a route prefix to `/a/b`, or `''` for none. */
100
+ export function normalizeRoutePrefix(prefix: unknown): string {
101
+ if (typeof prefix !== 'string') {
102
+ return ''
103
+ }
104
+ const segments = prefix.split('/').filter(Boolean)
105
+ return segments.length > 0 ? `/${segments.join('/')}` : ''
106
+ }
107
+
108
+ /**
109
+ * Checks the module options. A type with a problem is dropped and gives one
110
+ * error. When the CMS is off, the layer is off and gives one error. This file
111
+ * has no Nuxt import, so plain Vitest tests it.
112
+ */
113
+ export function resolvePlutoDocsOptions(
114
+ options: PlutoDocsOptions,
115
+ ctx: PlutoDocsResolveContext
116
+ ): PlutoDocsResolveResult {
117
+ const errors: string[] = []
118
+
119
+ if (!ctx.cmsEnabled) {
120
+ return {
121
+ enabled: false,
122
+ render: 'build',
123
+ runtimeMaxAge: DEFAULT_RUNTIME_MAX_AGE,
124
+ blocks: { discover: false },
125
+ types: [],
126
+ errors: [CMS_OFF_ERROR],
127
+ }
128
+ }
129
+
130
+ let render: PlutoDocsRender = 'hybrid'
131
+ if (options.render !== undefined) {
132
+ if (
133
+ (PLUTO_DOCS_RENDER_MODES as readonly unknown[]).includes(options.render)
134
+ ) {
135
+ render = options.render
136
+ } else {
137
+ errors.push(
138
+ `plutoDocs.render must be one of: ${PLUTO_DOCS_RENDER_MODES.join(', ')}. Using "hybrid".`
139
+ )
140
+ }
141
+ }
142
+
143
+ let runtimeMaxAge = DEFAULT_RUNTIME_MAX_AGE
144
+ if (options.runtimeMaxAge !== undefined) {
145
+ if (
146
+ typeof options.runtimeMaxAge === 'number' &&
147
+ Number.isInteger(options.runtimeMaxAge) &&
148
+ options.runtimeMaxAge >= 0
149
+ ) {
150
+ runtimeMaxAge = options.runtimeMaxAge
151
+ } else {
152
+ errors.push(
153
+ `plutoDocs.runtimeMaxAge must be a whole number of seconds, 0 or more. Using ${DEFAULT_RUNTIME_MAX_AGE}.`
154
+ )
155
+ }
156
+ }
157
+
158
+ let discover = true
159
+ if (options.blocks !== undefined) {
160
+ if (!isRecord(options.blocks)) {
161
+ errors.push('plutoDocs.blocks must be an object.')
162
+ } else if (options.blocks.discover !== undefined) {
163
+ if (typeof options.blocks.discover === 'boolean') {
164
+ discover = options.blocks.discover
165
+ } else {
166
+ errors.push(
167
+ 'plutoDocs.blocks.discover must be true or false. Using true.'
168
+ )
169
+ }
170
+ }
171
+ }
172
+
173
+ const types: PlutoDocsTypeOptions[] = []
174
+ const names = new Set<string>()
175
+ const entries: unknown[] = Array.isArray(options.types) ? options.types : []
176
+
177
+ for (const [index, entry] of entries.entries()) {
178
+ const where = `plutoDocs.types[${index}]`
179
+ if (!isRecord(entry)) {
180
+ errors.push(`${where} must be an object.`)
181
+ continue
182
+ }
183
+ if (!(PLUTO_DOCS_PRESETS as readonly unknown[]).includes(entry.preset)) {
184
+ errors.push(
185
+ `${where}.preset must be one of: ${PLUTO_DOCS_PRESETS.join(', ')}.`
186
+ )
187
+ continue
188
+ }
189
+ if (typeof entry.name !== 'string' || !NAME_PATTERN.test(entry.name)) {
190
+ errors.push(
191
+ `${where}.name must use lowercase letters, numbers and dashes, and start with a letter.`
192
+ )
193
+ continue
194
+ }
195
+ if (names.has(entry.name)) {
196
+ errors.push(`${where}.name "${entry.name}" is used twice.`)
197
+ continue
198
+ }
199
+ if (!isValidSource(entry.source)) {
200
+ errors.push(
201
+ `${where}.source must be '' or a folder path such as "docs" or "1.docs/api".`
202
+ )
203
+ continue
204
+ }
205
+ if (
206
+ entry.exclude !== undefined &&
207
+ !(
208
+ Array.isArray(entry.exclude) &&
209
+ entry.exclude.every((item) => typeof item === 'string')
210
+ )
211
+ ) {
212
+ errors.push(`${where}.exclude must be a list of strings.`)
213
+ continue
214
+ }
215
+ if (
216
+ entry.navGroup !== undefined &&
217
+ entry.navGroup !== false &&
218
+ !(typeof entry.navGroup === 'string' && entry.navGroup !== '')
219
+ ) {
220
+ errors.push(`${where}.navGroup must be a group id, or false.`)
221
+ continue
222
+ }
223
+ if (
224
+ entry.icon !== undefined &&
225
+ !(typeof entry.icon === 'string' && entry.icon !== '')
226
+ ) {
227
+ errors.push(`${where}.icon must be an icon name.`)
228
+ continue
229
+ }
230
+ names.add(entry.name)
231
+ types.push({
232
+ preset: 'docs',
233
+ name: entry.name,
234
+ source: entry.source,
235
+ ...(typeof entry.label === 'string' && { label: entry.label }),
236
+ ...(typeof entry.labelPlural === 'string' && {
237
+ labelPlural: entry.labelPlural,
238
+ }),
239
+ ...(typeof entry.icon === 'string' && { icon: entry.icon }),
240
+ ...(Array.isArray(entry.exclude) && {
241
+ exclude: entry.exclude as string[],
242
+ }),
243
+ routePrefix: normalizeRoutePrefix(entry.routePrefix),
244
+ ...((typeof entry.navGroup === 'string' || entry.navGroup === false) && {
245
+ navGroup: entry.navGroup,
246
+ }),
247
+ })
248
+ }
249
+
250
+ return {
251
+ enabled: true,
252
+ render,
253
+ runtimeMaxAge,
254
+ blocks: { discover },
255
+ types,
256
+ errors,
257
+ }
258
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Point PLUTO_CORE_PATH at a local checkout (e.g. `../pluto/packages/core`) to test
2
+ // unpublished changes; unset, it resolves to the published npm package.
3
+ const coreLayer = process.env.PLUTO_CORE_PATH || '@plutocms/core'
4
+
5
+ export default defineNuxtConfig({
6
+ extends: [coreLayer],
7
+
8
+ modules: ['@nuxt/content'],
9
+
10
+ $meta: {
11
+ name: 'docs',
12
+ },
13
+
14
+ // Nuxt Content stores its data in SQLite. The native connector needs Node 22.5
15
+ // or newer and no extra package. An app can set another connector.
16
+ content: {
17
+ experimental: {
18
+ sqliteConnector: 'native',
19
+ },
20
+ },
21
+
22
+ // No `css` entry: the app owns the one Tailwind build, and Nuxt UI scans
23
+ // this layer's app/ dir. The tokens of this layer are in
24
+ // app/assets/css/pluto-layer.css. See the single-tailwind-build skill in pluto.
25
+
26
+ // The pages are Markdown files, so the fixed blog post type is off. A docs
27
+ // edit must redeploy the site, so the commit message has no `[skip ci]`
28
+ // marker by default. An app can change both. The other `plutoGithub` options
29
+ // have defaults in core (see the github-cms skill in pluto).
30
+ plutoGithub: {
31
+ postType: false,
32
+ commit: {
33
+ skipDeploy: false,
34
+ },
35
+ },
36
+ })
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@plutocms/docs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "version": "0.16.0",
5
+ "description": "Docs layer for Pluto: edit the Markdown pages of a Nuxt Content site in the Pluto admin",
6
+ "author": "Victor Ribeiro",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/plutocms/docs#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/plutocms/docs.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/plutocms/docs/issues"
15
+ },
16
+ "main": "./nuxt.config.ts",
17
+ "scripts": {
18
+ "prepare": "nuxt prepare",
19
+ "prettier": "prettier --write .",
20
+ "lint": "eslint .",
21
+ "lint:fix": "eslint . --fix",
22
+ "typecheck": "nuxi typecheck",
23
+ "test": "vitest run"
24
+ },
25
+ "dependencies": {
26
+ "@nuxt/content": "^3.16.2",
27
+ "@nuxtjs/mdc": "^0.23.2",
28
+ "@plutocms/core": ">=0.40.0 <1.0.0",
29
+ "@types/sortablejs": "^1.15.9",
30
+ "nuxt-component-meta": "0.18.0",
31
+ "reka-ui": "^2.11.0",
32
+ "remark-gfm": "4.0.1",
33
+ "remark-mdc": "3.11.1",
34
+ "remark-parse": "11.0.0",
35
+ "sortablejs": "^1.15.7",
36
+ "unified": "11.0.5",
37
+ "yaml": "2.9.1"
38
+ },
39
+ "devDependencies": {
40
+ "@antfu/eslint-config": "^9.5.1",
41
+ "@nuxt/eslint-config": "^1.17.0",
42
+ "@nuxt/ui": "^4.12.0",
43
+ "@types/node": "^26.6.5",
44
+ "@vueuse/nuxt": "^15.0.0",
45
+ "eslint": "^10.12.0",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "eslint-plugin-better-tailwindcss": "^4.10.0",
48
+ "nuxt": "^4.6.1",
49
+ "prettier": "^3.9.10",
50
+ "vitest": "^5.0.5",
51
+ "vue": "3.5.44",
52
+ "vue-router": "5.4.0",
53
+ "vue-tsc": "^3.3.13"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }
@@ -0,0 +1,7 @@
1
+ import { defineEventHandler } from 'h3'
2
+
3
+ // Added by the module only when `plutoDocs.render` is `'hybrid'` or `'runtime'`. A route of the
4
+ // public API (`/api/pluto/v1/docs/:type/navigation`): the answer is `{ data }`, like core's routes.
5
+ export default defineEventHandler(async (event) => ({
6
+ data: await plutoRuntimeNavigation(event),
7
+ }))
@@ -0,0 +1,7 @@
1
+ import { defineEventHandler } from 'h3'
2
+
3
+ // Added by the module only when `plutoDocs.render` is `'hybrid'` or `'runtime'`. A route of the
4
+ // public API (`/api/pluto/v1/docs/:type/page`): the answer is `{ data }`, like core's routes.
5
+ export default defineEventHandler(async (event) => ({
6
+ data: await plutoRuntimePage(event),
7
+ }))
@@ -0,0 +1,7 @@
1
+ import { defineEventHandler } from 'h3'
2
+
3
+ // Added by the module only when `plutoDocs.render` is `'hybrid'` or `'runtime'`. A route of the
4
+ // public API (`/api/pluto/v1/docs/:type/surround`): the answer is `{ data }`, like core's routes.
5
+ export default defineEventHandler(async (event) => ({
6
+ data: await plutoRuntimeSurround(event),
7
+ }))
@@ -0,0 +1,25 @@
1
+ import type { PlutoDocsTypeOptions } from '../../modules/pluto-docs/options'
2
+
3
+ // Registers the content types of the layer with the core content registry. The
4
+ // CMS Nitro plugin registers the adapter. The types are looked up per request,
5
+ // so the order of the two plugins does not matter. When the layer is off (the
6
+ // CMS is off), the module sets no config and this plugin does nothing.
7
+ export default defineNitroPlugin(() => {
8
+ const config = useRuntimeConfig().public.plutoDocs as
9
+ | {
10
+ types: PlutoDocsTypeOptions[]
11
+ render?: string
12
+ }
13
+ | undefined
14
+ if (!config) {
15
+ return
16
+ }
17
+ for (const entry of config.types) {
18
+ registerContentType(
19
+ createDocsType({
20
+ ...entry,
21
+ liveOnSave: config.render === 'hybrid' || config.render === 'runtime',
22
+ })
23
+ )
24
+ }
25
+ })