@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,357 @@
1
+ // Pure helpers for the block editor UI: the label, icon and excerpt of a block,
2
+ // the Markdown of a slot, the blocks sent to the preview, and the palette
3
+ // entries. No Nuxt import, so plain Vitest tests them.
4
+
5
+ import type { PlutoPreviewBlock, PlutoPreviewChild } from './preview-protocol'
6
+ import type {
7
+ PlutoBlock,
8
+ PlutoBlockDefinition,
9
+ PlutoBlockDoc,
10
+ PlutoBlocksConfig,
11
+ PlutoComponentBlock,
12
+ } from './types'
13
+ import { blockMarkdown } from './operations'
14
+ import { BLOCK_KIND_LABELS } from './presets'
15
+ import { listPaletteBlocks, resolveBlockDefinition } from './registry'
16
+ import { renderBlock } from './serialize'
17
+ import { findBlock, flatChildren, pathOf } from './tree'
18
+
19
+ export interface PlutoBlockInfo {
20
+ /** A plain text or an i18n key. */
21
+ label: string
22
+ icon: string
23
+ /** A short plain text from the content of the block. */
24
+ excerpt: string
25
+ /** The name of the component, for a component block. */
26
+ name?: string
27
+ }
28
+
29
+ const HEADING = /^#{1,6} +(\S.*)$/m
30
+
31
+ /** Removes the Markdown marks of a short text, so it reads as plain text in a list. */
32
+ export function plainExcerpt(markdown: string, max = 48): string {
33
+ const text = markdown
34
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
35
+ .replace(/[`*_~>#]/g, '')
36
+ .replace(/\{[^}]*\}/g, '')
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
40
+ }
41
+
42
+ function textExcerpt(markdown: string): string {
43
+ const heading = HEADING.exec(markdown)
44
+ if (heading) {
45
+ return plainExcerpt(heading[1]!.replace(/ +#+$/, ''))
46
+ }
47
+ const first = markdown
48
+ .split('\n')
49
+ .find((line) => line.trim() !== '' && !/^\s*(?:---|:::?)/.test(line))
50
+ return plainExcerpt(first ?? '')
51
+ }
52
+
53
+ /** The first line of text in a component, for the excerpt of a block that has no title. */
54
+ function firstText(component: PlutoComponentBlock): string {
55
+ for (const child of flatChildren(component)) {
56
+ if (child.kind === 'text') {
57
+ return textExcerpt(child.markdown)
58
+ }
59
+ }
60
+ return ''
61
+ }
62
+
63
+ /** The label, the icon and the excerpt of a block in the outline. */
64
+ export function describeBlock(
65
+ block: PlutoBlock,
66
+ overrides: PlutoBlocksConfig = {}
67
+ ): PlutoBlockInfo {
68
+ switch (block.kind) {
69
+ case 'text': {
70
+ const heading = HEADING.test(block.markdown)
71
+ return {
72
+ label: BLOCK_KIND_LABELS.text,
73
+ icon: heading ? 'i-lucide-heading' : 'i-lucide-pilcrow',
74
+ excerpt: textExcerpt(block.markdown),
75
+ }
76
+ }
77
+ case 'code':
78
+ return {
79
+ label: BLOCK_KIND_LABELS.code,
80
+ icon: 'i-lucide-code-xml',
81
+ excerpt: block.filename || block.lang || '',
82
+ }
83
+ case 'raw':
84
+ return {
85
+ label:
86
+ block.reason === 'table'
87
+ ? 'docs.blocks.kinds.table'
88
+ : BLOCK_KIND_LABELS.raw,
89
+ icon:
90
+ block.reason === 'table' ? 'i-lucide-table' : 'i-lucide-file-text',
91
+ excerpt: block.reason === 'table' ? '' : textExcerpt(block.markdown),
92
+ }
93
+ case 'component': {
94
+ const definition = resolveBlockDefinition(block.name, { overrides })
95
+ const title =
96
+ typeof block.props.title === 'string'
97
+ ? block.props.title
98
+ : typeof block.props.label === 'string'
99
+ ? block.props.label
100
+ : ''
101
+ return {
102
+ label: definition.label,
103
+ icon: definition.icon,
104
+ excerpt: plainExcerpt(title || firstText(block)),
105
+ name: block.name,
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ /** The Markdown of the children of one slot, without the white space of the parent. */
112
+ export function slotMarkdown(
113
+ component: PlutoComponentBlock,
114
+ slot: string
115
+ ): string {
116
+ return (component.slots[slot] ?? [])
117
+ .map((child) => blockMarkdown(child))
118
+ .join('\n\n')
119
+ }
120
+
121
+ export type PlutoSlotMode = 'rich' | 'inline' | 'blocks' | 'markdown'
122
+
123
+ /**
124
+ * The editor for a slot. A `rich` or `inline` slot falls back when its content
125
+ * has a block or a syntax that the text editor would lose: it then shows the
126
+ * blocks, or a Markdown box.
127
+ */
128
+ export function resolveSlotMode(
129
+ component: PlutoComponentBlock,
130
+ slot: string,
131
+ wanted: 'rich' | 'inline' | 'blocks' | undefined
132
+ ): PlutoSlotMode {
133
+ const children = component.slots[slot] ?? []
134
+ if (wanted === 'blocks') {
135
+ return 'blocks'
136
+ }
137
+ // A component or a code block inside the slot needs the list of blocks.
138
+ if (
139
+ !children.every((child) => child.kind === 'text' || child.kind === 'raw')
140
+ ) {
141
+ return 'blocks'
142
+ }
143
+ // The parser turns prose that the text editor would lose into a raw block. Markdown keeps it as it is.
144
+ if (children.some((child) => child.kind === 'raw')) {
145
+ return 'markdown'
146
+ }
147
+ const markdown = slotMarkdown(component, slot)
148
+ if (wanted === 'inline') {
149
+ return markdown.includes('\n') ? 'markdown' : 'inline'
150
+ }
151
+ return 'rich'
152
+ }
153
+
154
+ /**
155
+ * The block that the preview shows as selected: the deepest component on the
156
+ * path of the selected block, else its top-level block. The preview marks only
157
+ * the top-level blocks and the inner components, so a text inside a card shows
158
+ * the card.
159
+ */
160
+ export function previewSelectionId(
161
+ doc: PlutoBlockDoc,
162
+ id: string | null
163
+ ): string | null {
164
+ if (!id) {
165
+ return null
166
+ }
167
+ const path = pathOf(doc, id)
168
+ for (let index = path.length - 1; index > 0; index--) {
169
+ if (findBlock(doc, path[index]!)?.block.kind === 'component') {
170
+ return path[index]!
171
+ }
172
+ }
173
+ return path[0] ?? null
174
+ }
175
+
176
+ /** The top-level blocks as the preview frame wants them. */
177
+ export function toPreviewBlocks(
178
+ blocks: PlutoBlock[],
179
+ overrides: PlutoBlocksConfig = {}
180
+ ): PlutoPreviewBlock[] {
181
+ return blocks.map((block) => {
182
+ const markdown = renderBlock(block)
183
+ if (block.kind !== 'component') {
184
+ return { id: block.id, markdown }
185
+ }
186
+ const definition = resolveBlockDefinition(block.name, { overrides })
187
+ const children = previewChildren(block, overrides)
188
+ return {
189
+ id: block.id,
190
+ markdown,
191
+ label: definition.label,
192
+ ...(definition.preview === false ? { preview: false } : {}),
193
+ ...(children.length ? { children } : {}),
194
+ }
195
+ })
196
+ }
197
+
198
+ /**
199
+ * The inner components of a component, in source order over all slots, for
200
+ * the marks of the preview (a click selects an inner block). Text, code and raw
201
+ * children are not here: a click on them selects the nearest component.
202
+ */
203
+ export function previewChildren(
204
+ component: PlutoComponentBlock,
205
+ overrides: PlutoBlocksConfig = {}
206
+ ): PlutoPreviewChild[] {
207
+ return flatChildren(component)
208
+ .filter((child): child is PlutoComponentBlock => child.kind === 'component')
209
+ .map((child) => {
210
+ const children = previewChildren(child, overrides)
211
+ return {
212
+ id: child.id,
213
+ name: child.name,
214
+ label: resolveBlockDefinition(child.name, { overrides, block: child })
215
+ .label,
216
+ ...(children.length ? { children } : {}),
217
+ }
218
+ })
219
+ }
220
+
221
+ /** The component names that a slot accepts, or `undefined` for any. */
222
+ export function acceptedNames(
223
+ definition: PlutoBlockDefinition,
224
+ slot: string
225
+ ): string[] | undefined {
226
+ return definition.slots?.[slot]?.accepts
227
+ }
228
+
229
+ export type PlutoPaletteEntry =
230
+ | {
231
+ id: string
232
+ kind: 'text'
233
+ group: string
234
+ label: string
235
+ description: string
236
+ icon: string
237
+ markdown: string
238
+ }
239
+ | {
240
+ id: string
241
+ kind: 'code'
242
+ group: string
243
+ label: string
244
+ description: string
245
+ icon: string
246
+ markdown: string
247
+ }
248
+ | {
249
+ id: string
250
+ kind: 'table'
251
+ group: string
252
+ label: string
253
+ description: string
254
+ icon: string
255
+ markdown: string
256
+ }
257
+ | {
258
+ id: string
259
+ kind: 'component'
260
+ group: string
261
+ label: string
262
+ description: string
263
+ icon: string
264
+ name: string
265
+ markdown: string
266
+ }
267
+
268
+ export interface PlutoPaletteGroup {
269
+ group: string
270
+ entries: PlutoPaletteEntry[]
271
+ }
272
+
273
+ const TABLE_MARKDOWN =
274
+ '| Column | Column |\n| ------ | ------ |\n| Cell | Cell |'
275
+
276
+ /**
277
+ * The palette: the built-in blocks (text, code, table) first, then the
278
+ * component groups. `accepts` limits the list to some component names, for a
279
+ * target inside a component that takes only some children.
280
+ */
281
+ export function buildPalette(
282
+ overrides: PlutoBlocksConfig = {},
283
+ accepts?: string[]
284
+ ): PlutoPaletteGroup[] {
285
+ const groups: PlutoPaletteGroup[] = []
286
+ if (!accepts) {
287
+ groups.push({
288
+ group: 'text',
289
+ entries: [
290
+ {
291
+ id: 'builtin:text',
292
+ kind: 'text',
293
+ group: 'text',
294
+ label: BLOCK_KIND_LABELS.text,
295
+ description: 'docs.blocks.kinds.textDescription',
296
+ icon: 'i-lucide-pilcrow',
297
+ markdown: '## Title\n\nWrite your text here.',
298
+ },
299
+ {
300
+ id: 'builtin:table',
301
+ kind: 'table',
302
+ group: 'text',
303
+ label: 'docs.blocks.kinds.table',
304
+ description: 'docs.blocks.kinds.tableDescription',
305
+ icon: 'i-lucide-table',
306
+ markdown: TABLE_MARKDOWN,
307
+ },
308
+ ],
309
+ })
310
+ groups.push({
311
+ group: 'code',
312
+ entries: [
313
+ {
314
+ id: 'builtin:code',
315
+ kind: 'code',
316
+ group: 'code',
317
+ label: BLOCK_KIND_LABELS.code,
318
+ description: 'docs.blocks.kinds.codeDescription',
319
+ icon: 'i-lucide-code-xml',
320
+ markdown: "```ts\nconst hello = 'world'\n```",
321
+ },
322
+ ],
323
+ })
324
+ }
325
+ for (const { group, items } of listPaletteBlocks(overrides)) {
326
+ const entries = items
327
+ .filter((item) => !accepts || accepts.includes(item.name))
328
+ .map((item): PlutoPaletteEntry => ({
329
+ id: `component:${item.name}`,
330
+ kind: 'component',
331
+ group,
332
+ label: item.label,
333
+ description: item.description ?? '',
334
+ icon: item.icon,
335
+ name: item.name,
336
+ markdown: item.insert ?? `::${item.name}\n::`,
337
+ }))
338
+ if (entries.length === 0) {
339
+ continue
340
+ }
341
+ const existing = groups.find((candidate) => candidate.group === group)
342
+ if (existing) {
343
+ existing.entries.push(...entries)
344
+ } else {
345
+ groups.push({ group, entries })
346
+ }
347
+ }
348
+ return groups
349
+ }
350
+
351
+ /** How many blocks a block holds, itself included. */
352
+ export function countBlocks(block: PlutoBlock): number {
353
+ return block.kind === 'component'
354
+ ? 1 +
355
+ flatChildren(block).reduce((sum, child) => sum + countBlocks(child), 0)
356
+ : 1
357
+ }
@@ -0,0 +1,94 @@
1
+ // Undo and redo for the block doc. The history keeps snapshots of the doc.
2
+ // A snapshot is immutable, because every operation returns a new doc. The
3
+ // clock is a parameter, so a test needs no timers.
4
+
5
+ export const HISTORY_LIMIT = 100
6
+
7
+ /** Edits of the same key within this time (ms) join in one step. */
8
+ export const HISTORY_MERGE_MS = 600
9
+
10
+ export interface PlutoHistory<T> {
11
+ /** Oldest first. */
12
+ past: T[]
13
+ present: T
14
+ future: T[]
15
+ /** The key and the time of the last push, to join typing in one block. */
16
+ last?: { key: string; at: number }
17
+ }
18
+
19
+ export interface PlutoHistoryOptions {
20
+ limit?: number
21
+ mergeMs?: number
22
+ }
23
+
24
+ export function createHistory<T>(initial: T): PlutoHistory<T> {
25
+ return { past: [], present: initial, future: [] }
26
+ }
27
+
28
+ export function canUndo(history: PlutoHistory<unknown>): boolean {
29
+ return history.past.length > 0
30
+ }
31
+
32
+ export function canRedo(history: PlutoHistory<unknown>): boolean {
33
+ return history.future.length > 0
34
+ }
35
+
36
+ /**
37
+ * Adds a new state. A state equal (`===`) to the present is ignored. With a
38
+ * `key` (for example the id of a block), a push with the same key within
39
+ * `mergeMs` of the last push replaces the present, so it is one undo step.
40
+ */
41
+ export function pushHistory<T>(
42
+ history: PlutoHistory<T>,
43
+ state: T,
44
+ now: number,
45
+ key?: string,
46
+ options: PlutoHistoryOptions = {}
47
+ ): PlutoHistory<T> {
48
+ if (state === history.present) {
49
+ return history
50
+ }
51
+ const limit = options.limit ?? HISTORY_LIMIT
52
+ const mergeMs = options.mergeMs ?? HISTORY_MERGE_MS
53
+ const merge =
54
+ key !== undefined &&
55
+ history.last?.key === key &&
56
+ now - history.last.at <= mergeMs &&
57
+ history.past.length > 0 &&
58
+ history.future.length === 0
59
+ const last = key === undefined ? undefined : { key, at: now }
60
+ if (merge) {
61
+ return { ...history, present: state, last }
62
+ }
63
+ return {
64
+ past: [...history.past, history.present].slice(-limit),
65
+ present: state,
66
+ future: [],
67
+ last,
68
+ }
69
+ }
70
+
71
+ export function undo<T>(history: PlutoHistory<T>): PlutoHistory<T> {
72
+ if (!canUndo(history)) {
73
+ return history
74
+ }
75
+ const past = history.past.slice(0, -1)
76
+ return {
77
+ past,
78
+ present: history.past.at(-1)!,
79
+ future: [history.present, ...history.future],
80
+ }
81
+ }
82
+
83
+ export function redo<T>(history: PlutoHistory<T>): PlutoHistory<T> {
84
+ if (!canRedo(history)) {
85
+ return history
86
+ }
87
+ const [present, ...future] = history.future
88
+ return { past: [...history.past, history.present], present: present!, future }
89
+ }
90
+
91
+ /** Starts again from a state, for example after a switch to the Markdown mode. */
92
+ export function resetHistory<T>(state: T): PlutoHistory<T> {
93
+ return createHistory(state)
94
+ }