@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,444 @@
1
+ // Cut a Markdown (MDC) text into blocks. Each block keeps the exact text it
2
+ // came from, and the doc keeps the text between the blocks, so a save with no
3
+ // edit writes the same bytes. The parser is `remark-mdc` on `remark-parse`:
4
+ // pin the versions to the ones of `@nuxtjs/mdc`.
5
+
6
+ import type {
7
+ PlutoBlock,
8
+ PlutoBlockDoc,
9
+ PlutoCodeBlock,
10
+ PlutoComponentBlock,
11
+ PlutoRawBlock,
12
+ PlutoRawReason,
13
+ PlutoSlice,
14
+ PlutoTextBlock,
15
+ } from './types'
16
+ import remarkGfm from 'remark-gfm'
17
+ import remarkMdc from 'remark-mdc'
18
+ import remarkParse from 'remark-parse'
19
+ import { unified } from 'unified'
20
+ import { parseInlineProps, parseOpener, parseYamlProps } from './mdc-props'
21
+ import { flatChildren } from './tree'
22
+
23
+ interface MdNode {
24
+ type: string
25
+ name?: string
26
+ depth?: number
27
+ lang?: string | null
28
+ meta?: string | null
29
+ checked?: boolean | null
30
+ children?: MdNode[]
31
+ position?: { start: { offset?: number }; end: { offset?: number } }
32
+ }
33
+
34
+ interface Context {
35
+ source: string
36
+ nextId: () => string
37
+ }
38
+
39
+ /** A block with the range of the source that it covers. */
40
+ interface Entry {
41
+ block: PlutoBlock
42
+ start: number
43
+ end: number
44
+ }
45
+
46
+ const SAFE_TYPES = new Set([
47
+ 'paragraph',
48
+ 'heading',
49
+ 'list',
50
+ 'listItem',
51
+ 'blockquote',
52
+ 'thematicBreak',
53
+ 'definition',
54
+ 'text',
55
+ 'emphasis',
56
+ 'strong',
57
+ 'delete',
58
+ 'inlineCode',
59
+ 'link',
60
+ 'linkReference',
61
+ 'image',
62
+ 'imageReference',
63
+ 'break',
64
+ ])
65
+
66
+ const PROSE_TYPES = new Set([
67
+ 'paragraph',
68
+ 'heading',
69
+ 'list',
70
+ 'blockquote',
71
+ 'thematicBreak',
72
+ 'definition',
73
+ ])
74
+
75
+ /** The reason why the visual editor cannot keep a piece of prose, or `undefined` when it can. */
76
+ function unsafeReason(
77
+ node: MdNode,
78
+ source: string
79
+ ): PlutoRawReason | undefined {
80
+ switch (node.type) {
81
+ case 'html':
82
+ return 'html'
83
+ case 'table':
84
+ return 'table'
85
+ case 'footnoteReference':
86
+ case 'footnoteDefinition':
87
+ return 'footnote'
88
+ case 'textComponent':
89
+ // The parser reads a footnote reference as a text span.
90
+ return source.startsWith('[^', node.position!.start.offset!)
91
+ ? 'footnote'
92
+ : 'inline-mdc'
93
+ case 'containerComponent':
94
+ case 'leafComponent':
95
+ return 'inline-mdc'
96
+ case 'code':
97
+ // A fenced code inside a list or a quote: only a plain language is safe.
98
+ return node.meta ? 'unknown' : undefined
99
+ }
100
+ if (!SAFE_TYPES.has(node.type)) {
101
+ return 'unknown'
102
+ }
103
+ if (
104
+ node.type === 'listItem' &&
105
+ node.checked !== null &&
106
+ node.checked !== undefined
107
+ ) {
108
+ return 'task-list'
109
+ }
110
+ for (const child of node.children ?? []) {
111
+ const reason = unsafeReason(child, source)
112
+ if (reason) {
113
+ return reason
114
+ }
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ function range(node: MdNode): [number, number] {
120
+ return [node.position!.start.offset!, node.position!.end.offset!]
121
+ }
122
+
123
+ function lineStartOf(source: string, offset: number): number {
124
+ return source.lastIndexOf('\n', offset - 1) + 1
125
+ }
126
+
127
+ /** The white space before a node, on its first line. */
128
+ function indentOf(source: string, start: number): string {
129
+ const before = source.slice(lineStartOf(source, start), start)
130
+ return /^[ \t]*$/.test(before) ? before : ''
131
+ }
132
+
133
+ /** Removes the indent from each line but the first. */
134
+ export function dedent(text: string, indent: string): string {
135
+ if (!indent) {
136
+ return text
137
+ }
138
+ return text
139
+ .split('\n')
140
+ .map((line, index) =>
141
+ index > 0 && line.startsWith(indent) ? line.slice(indent.length) : line
142
+ )
143
+ .join('\n')
144
+ }
145
+
146
+ /** Removes the indent from every line. */
147
+ function dedentAll(text: string, indent: string): string {
148
+ return text
149
+ .split('\n')
150
+ .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
151
+ .join('\n')
152
+ }
153
+
154
+ function sliceOf(source: string, start: number, end: number): PlutoSlice {
155
+ return { start, end, text: source.slice(start, end) }
156
+ }
157
+
158
+ function parseCode(ctx: Context, node: MdNode): Entry {
159
+ const [start, end] = range(node)
160
+ const indent = indentOf(ctx.source, start)
161
+ const text = ctx.source.slice(start, end)
162
+ const lines = dedent(text, indent).split('\n')
163
+ const first = lines[0]!.replace(/\r$/, '')
164
+ let fenceLength = 0
165
+ while (first[fenceLength] === first[0]) {
166
+ fenceLength++
167
+ }
168
+ const fence = fenceLength >= 3 ? first.slice(0, fenceLength) : '```'
169
+ const info = first.slice(fenceLength).trim()
170
+ const closing =
171
+ lines.length > 1 ? /^[ \t]*([`~]{3,})[ \t]*\r?$/.exec(lines.at(-1)!) : null
172
+ const closed =
173
+ closing !== null &&
174
+ closing[1]![0] === fence[0] &&
175
+ closing[1]!.length >= fence.length
176
+ const code = lines.slice(1, closed ? -1 : undefined).join('\n')
177
+
178
+ // The language is the first word. A file name or a flag can follow.
179
+ const langEnd = info.search(/[\s[{]/)
180
+ const lang = langEnd === -1 ? info : info.slice(0, langEnd)
181
+ let rest = info.slice(lang.length).trim()
182
+ let filename: string | undefined
183
+ const nameMatch = /\[([^\]]*)\]/.exec(rest)
184
+ if (nameMatch) {
185
+ filename = nameMatch[1]
186
+ rest =
187
+ `${rest.slice(0, nameMatch.index)}${rest.slice(nameMatch.index + nameMatch[0].length)}`
188
+ .replace(/\s+/g, ' ')
189
+ .trim()
190
+ }
191
+ const block: PlutoCodeBlock = {
192
+ id: ctx.nextId(),
193
+ kind: 'code',
194
+ lang,
195
+ fence,
196
+ code,
197
+ original: sliceOf(ctx.source, start, end),
198
+ ...(indent ? { indent } : {}),
199
+ ...(filename === undefined ? {} : { filename }),
200
+ ...(rest ? { meta: rest } : {}),
201
+ }
202
+ return { block, start, end }
203
+ }
204
+
205
+ function parseRaw(
206
+ ctx: Context,
207
+ start: number,
208
+ end: number,
209
+ reason: PlutoRawReason
210
+ ): Entry {
211
+ const indent = indentOf(ctx.source, start)
212
+ const original = sliceOf(ctx.source, start, end)
213
+ const block: PlutoRawBlock = {
214
+ id: ctx.nextId(),
215
+ kind: 'raw',
216
+ reason,
217
+ markdown: dedent(original.text, indent),
218
+ original,
219
+ ...(indent ? { indent } : {}),
220
+ }
221
+ return { block, start, end }
222
+ }
223
+
224
+ function parseText(ctx: Context, start: number, end: number): Entry {
225
+ const indent = indentOf(ctx.source, start)
226
+ const original = sliceOf(ctx.source, start, end)
227
+ const block: PlutoTextBlock = {
228
+ id: ctx.nextId(),
229
+ kind: 'text',
230
+ markdown: dedent(original.text, indent),
231
+ original,
232
+ ...(indent ? { indent } : {}),
233
+ }
234
+ return { block, start, end }
235
+ }
236
+
237
+ const YAML_BLOCK =
238
+ /^(\r?\n[ \t]*)---[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*---[ \t]*(?=\r?\n|$)/
239
+
240
+ /** The common white space at the start of the non-blank lines. */
241
+ function commonIndent(text: string): string {
242
+ let common: string | undefined
243
+ for (const line of text.split('\n')) {
244
+ if (line.trim() === '') {
245
+ continue
246
+ }
247
+ const lead = /^[ \t]*/.exec(line)![0]
248
+ if (common === undefined) {
249
+ common = lead
250
+ continue
251
+ }
252
+ let index = 0
253
+ while (index < common.length && common[index] === lead[index]) {
254
+ index++
255
+ }
256
+ common = common.slice(0, index)
257
+ }
258
+ return common ?? ''
259
+ }
260
+
261
+ function parseComponent(ctx: Context, node: MdNode): Entry {
262
+ const { source } = ctx
263
+ const [start, end] = range(node)
264
+ const rawFallback = () => parseRaw(ctx, start, end, 'unknown')
265
+
266
+ const lineEnd = source.indexOf('\n', start)
267
+ const line = source
268
+ .slice(start, lineEnd === -1 ? source.length : lineEnd)
269
+ .replace(/\r$/, '')
270
+ const opener = parseOpener(line)
271
+ if (!opener) {
272
+ return rawFallback()
273
+ }
274
+ const closer = ':'.repeat(opener.colons)
275
+ const openerEnd = start + line.length
276
+ const closerStart = end - opener.colons
277
+ if (source.slice(closerStart, end) !== closer || closerStart < openerEnd) {
278
+ return rawFallback()
279
+ }
280
+
281
+ // The children by slot. `default` is the content before the first `#name`.
282
+ const slots: Record<string, PlutoBlock[]> = {}
283
+ const entries: Entry[] = []
284
+ const take = (name: string, nodes: MdNode[]) => {
285
+ const parsed = parseNodes(ctx, nodes, false)
286
+ entries.push(...parsed)
287
+ slots[name] = [
288
+ ...(slots[name] ?? []),
289
+ ...parsed.map((entry) => entry.block),
290
+ ]
291
+ }
292
+ // Content before the first `#name` is `default`. After a section, loose content
293
+ // is a `#default` section: the parser keeps no node for that marker.
294
+ let loose: MdNode[] = []
295
+ const flushLoose = () => {
296
+ if (loose.length > 0) {
297
+ take('default', loose)
298
+ loose = []
299
+ }
300
+ }
301
+ for (const child of node.children ?? []) {
302
+ if (child.type === 'componentContainerSection') {
303
+ flushLoose()
304
+ take(child.name ?? 'default', child.children ?? [])
305
+ slots[child.name ?? 'default'] ??= []
306
+ } else {
307
+ loose.push(child)
308
+ }
309
+ }
310
+ flushLoose()
311
+
312
+ // The children must be in source order, inside the component.
313
+ const ordered = flatChildren({ slots } as PlutoComponentBlock)
314
+ const sorted = ordered.map((block) =>
315
+ entries.find((entry) => entry.block === block)!
316
+ )
317
+ let cursor = openerEnd
318
+ for (const entry of sorted) {
319
+ if (entry.start < cursor || entry.end > closerStart) {
320
+ return rawFallback()
321
+ }
322
+ cursor = entry.end
323
+ }
324
+ const gaps: string[] = []
325
+ cursor = openerEnd
326
+ for (const entry of sorted) {
327
+ gaps.push(source.slice(cursor, entry.start))
328
+ cursor = entry.end
329
+ }
330
+ gaps.push(source.slice(cursor, closerStart))
331
+
332
+ let props: ReturnType<typeof parseInlineProps> = {
333
+ props: {},
334
+ order: [],
335
+ source: {},
336
+ }
337
+ let propsStyle: PlutoComponentBlock['propsStyle'] = 'inline'
338
+ let openerText = source.slice(start, openerEnd)
339
+ let yamlHead: string | undefined
340
+ let yamlIndent: string | undefined
341
+ if (opener.braces === undefined) {
342
+ const match = YAML_BLOCK.exec(gaps[0]!)
343
+ if (match) {
344
+ const inner = match[2]!
345
+ const lead = commonIndent(inner)
346
+ const parsed = parseYamlProps(dedentAll(inner, lead))
347
+ if (parsed) {
348
+ props = parsed
349
+ propsStyle = 'yaml'
350
+ yamlHead = match[1]!
351
+ yamlIndent = lead
352
+ openerText += match[0]
353
+ gaps[0] = gaps[0]!.slice(match[0].length)
354
+ }
355
+ }
356
+ } else {
357
+ props = parseInlineProps(opener.braces)
358
+ }
359
+
360
+ const indent = indentOf(source, start)
361
+ const block: PlutoComponentBlock = {
362
+ id: ctx.nextId(),
363
+ kind: 'component',
364
+ name: opener.name,
365
+ props: props.props,
366
+ propsStyle,
367
+ propOrder: props.order,
368
+ propSource: props.source,
369
+ colons: opener.colons,
370
+ slots,
371
+ gaps,
372
+ openerText,
373
+ original: sliceOf(source, start, end),
374
+ ...(indent ? { indent } : {}),
375
+ ...(yamlHead === undefined ? {} : { yamlHead, yamlIndent }),
376
+ }
377
+ return { block, start, end }
378
+ }
379
+
380
+ /** Turns sibling nodes into blocks. `top` cuts a Text block at each H1 and H2. */
381
+ function parseNodes(ctx: Context, nodes: MdNode[], top: boolean): Entry[] {
382
+ const entries: Entry[] = []
383
+ let group: { start: number; end: number } | undefined
384
+
385
+ const flush = () => {
386
+ if (group) {
387
+ entries.push(parseText(ctx, group.start, group.end))
388
+ group = undefined
389
+ }
390
+ }
391
+
392
+ for (const node of nodes) {
393
+ if (!node.position) {
394
+ continue
395
+ }
396
+ const [start, end] = range(node)
397
+ if (PROSE_TYPES.has(node.type)) {
398
+ const reason = unsafeReason(node, ctx.source)
399
+ if (reason) {
400
+ flush()
401
+ entries.push(parseRaw(ctx, start, end, reason))
402
+ continue
403
+ }
404
+ if (top && node.type === 'heading' && (node.depth ?? 6) <= 2) {
405
+ flush()
406
+ }
407
+ group = group ? { start: group.start, end } : { start, end }
408
+ continue
409
+ }
410
+ flush()
411
+ if (node.type === 'code') {
412
+ entries.push(parseCode(ctx, node))
413
+ } else if (node.type === 'containerComponent') {
414
+ entries.push(parseComponent(ctx, node))
415
+ } else {
416
+ entries.push(
417
+ parseRaw(ctx, start, end, unsafeReason(node, ctx.source) ?? 'unknown')
418
+ )
419
+ }
420
+ }
421
+ flush()
422
+ return entries
423
+ }
424
+
425
+ /** Cuts a Markdown (MDC) text into a block doc. `serializeBlocks` of the result gives the same text. */
426
+ export function parseBlocks(source: string): PlutoBlockDoc {
427
+ const tree = unified()
428
+ .use(remarkParse)
429
+ .use(remarkGfm)
430
+ .use(remarkMdc)
431
+ .parse(source) as unknown as MdNode
432
+ let counter = 0
433
+ const ctx: Context = { source, nextId: () => `b${counter++}` }
434
+ const entries = parseNodes(ctx, tree.children ?? [], true)
435
+
436
+ const gaps: string[] = []
437
+ let cursor = 0
438
+ for (const entry of entries) {
439
+ gaps.push(source.slice(cursor, entry.start))
440
+ cursor = entry.end
441
+ }
442
+ gaps.push(source.slice(cursor))
443
+ return { blocks: entries.map((entry) => entry.block), gaps }
444
+ }