@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,692 @@
1
+ // Edit operations on a block doc. Each one is pure: it returns a new doc and
2
+ // never changes its input. An edit removes `original` from the edited block and
3
+ // from its ancestors, so only those blocks are built again on save. The gaps
4
+ // stay where they are, so the layout of the other blocks does not change.
5
+
6
+ import type {
7
+ PlutoBlock,
8
+ PlutoBlockDoc,
9
+ PlutoCodeBlock,
10
+ PlutoComponentBlock,
11
+ PlutoPropValue,
12
+ } from './types'
13
+ import { completeOrder } from './mdc-props'
14
+ import { dedent, parseBlocks } from './parse'
15
+ import { renderBlock } from './serialize'
16
+ import { findBlock, flatChildren, newBlockId, pathOf } from './tree'
17
+
18
+ /** Where a block goes: a list of the doc, or a slot of a component. `index` is the position in that list. */
19
+ export interface PlutoBlockTarget {
20
+ parentId?: string
21
+ slot?: string
22
+ index: number
23
+ }
24
+
25
+ export interface PlutoInsertResult {
26
+ doc: PlutoBlockDoc
27
+ /** The ids of the new blocks. */
28
+ ids: string[]
29
+ }
30
+
31
+ // Clone and dirty marks
32
+
33
+ function cloneDoc(doc: PlutoBlockDoc): PlutoBlockDoc {
34
+ // A JSON copy also works for a reactive proxy.
35
+ return JSON.parse(JSON.stringify(doc)) as PlutoBlockDoc
36
+ }
37
+
38
+ function clone<T>(value: T): T {
39
+ return JSON.parse(JSON.stringify(value)) as T
40
+ }
41
+
42
+ /** Removes `original` from the block and from its ancestors. */
43
+ function touch(doc: PlutoBlockDoc, id: string): void {
44
+ for (const pathId of pathOf(doc, id)) {
45
+ const found = findBlock(doc, pathId)
46
+ if (found) {
47
+ delete found.block.original
48
+ }
49
+ }
50
+ }
51
+
52
+ function renewIds(block: PlutoBlock): PlutoBlock {
53
+ block.id = newBlockId()
54
+ if (block.kind === 'component') {
55
+ for (const child of flatChildren(block)) {
56
+ renewIds(child)
57
+ }
58
+ }
59
+ return block
60
+ }
61
+
62
+ // Indent
63
+
64
+ /**
65
+ * Changes the indent of the lines after the first one. A gap ends before a
66
+ * block, so a line end at the end of the text also gets the indent.
67
+ */
68
+ function shiftText(text: string, from: string, to: string): string {
69
+ return text.replace(
70
+ /\n([ \t]*)(?=[^\n]|$)/g,
71
+ (_match, white: string) =>
72
+ `\n${to}${white.startsWith(from) ? white.slice(from.length) : white}`
73
+ )
74
+ }
75
+
76
+ /** A block with a new indent. A block that moves to another level is built again on save. */
77
+ function withIndent(block: PlutoBlock, indent: string): PlutoBlock {
78
+ const from = block.indent ?? ''
79
+ if (from === indent) {
80
+ return block
81
+ }
82
+ delete block.original
83
+ if (indent) {
84
+ block.indent = indent
85
+ } else {
86
+ delete block.indent
87
+ }
88
+ if (block.kind === 'component') {
89
+ block.gaps = block.gaps.map((gap) => shiftText(gap, from, indent))
90
+ block.openerText = shiftText(block.openerText, from, indent)
91
+ if (block.yamlHead !== undefined) {
92
+ block.yamlHead = shiftText(block.yamlHead, from, indent)
93
+ }
94
+ if (block.yamlIndent !== undefined) {
95
+ block.yamlIndent = block.yamlIndent.startsWith(from)
96
+ ? indent + block.yamlIndent.slice(from.length)
97
+ : block.yamlIndent
98
+ }
99
+ for (const child of flatChildren(block)) {
100
+ const childIndent = child.indent ?? ''
101
+ withIndent(
102
+ child,
103
+ childIndent.startsWith(from)
104
+ ? indent + childIndent.slice(from.length)
105
+ : indent
106
+ )
107
+ }
108
+ }
109
+ return block
110
+ }
111
+
112
+ // Lists
113
+
114
+ interface ListView {
115
+ blocks: PlutoBlock[]
116
+ /** The gaps that belong to the list: `gaps[start]` is the gap before the first block. */
117
+ gaps: string[]
118
+ start: number
119
+ parent?: PlutoComponentBlock
120
+ slot?: string
121
+ /** The slot has no entry yet. */
122
+ missing: boolean
123
+ }
124
+
125
+ function viewOf(
126
+ doc: PlutoBlockDoc,
127
+ parentId?: string,
128
+ slot = 'default'
129
+ ): ListView | undefined {
130
+ if (parentId === undefined) {
131
+ return { blocks: doc.blocks, gaps: doc.gaps, start: 0, missing: false }
132
+ }
133
+ const parent = findBlock(doc, parentId)?.block
134
+ if (parent?.kind !== 'component') {
135
+ return undefined
136
+ }
137
+ let start = 0
138
+ for (const [name, children] of Object.entries(parent.slots)) {
139
+ if (name === slot) {
140
+ return {
141
+ blocks: children,
142
+ gaps: parent.gaps,
143
+ start,
144
+ parent,
145
+ slot,
146
+ missing: false,
147
+ }
148
+ }
149
+ start += children.length
150
+ }
151
+ // A default slot with no entry goes first. Another missing slot goes last.
152
+ return {
153
+ blocks: [],
154
+ gaps: parent.gaps,
155
+ start: slot === 'default' ? 0 : start,
156
+ parent,
157
+ slot,
158
+ missing: true,
159
+ }
160
+ }
161
+
162
+ const MARKER = /^[ \t]*#[a-z][\w-]*[ \t]*$/gim
163
+
164
+ /** The text of a gap up to the end of its last `#name` line, or an empty text. */
165
+ function markerPrefix(gap: string): string {
166
+ let end = -1
167
+ for (const match of gap.matchAll(MARKER)) {
168
+ end = match.index + match[0].length
169
+ }
170
+ return end === -1 ? '' : gap.slice(0, end)
171
+ }
172
+
173
+ function markerLine(gap: string, slot: string): { end: number } | undefined {
174
+ for (const match of gap.matchAll(MARKER)) {
175
+ if (match[0].trim() === `#${slot}`) {
176
+ return { end: match.index + match[0].length }
177
+ }
178
+ }
179
+ return undefined
180
+ }
181
+
182
+ /** The indent for a new child: the one of the sibling next to it, or a default. */
183
+ function childIndentFor(
184
+ view: ListView,
185
+ index: number,
186
+ block: PlutoBlock
187
+ ): string {
188
+ if (!view.parent) {
189
+ return ''
190
+ }
191
+ const sibling =
192
+ view.blocks[Math.min(index, view.blocks.length - 1)] ??
193
+ flatChildren(view.parent)[0]
194
+ if (sibling) {
195
+ return sibling.indent ?? ''
196
+ }
197
+ const base = view.parent.indent ?? ''
198
+ return block.kind === 'component' ? `${base} ` : base
199
+ }
200
+
201
+ /** The gap between two blocks of a list. It is a blank line, plus the indent in a component. */
202
+ function separatorFor(view: ListView, indent: string): string {
203
+ return view.parent ? `\n\n${indent}` : '\n\n'
204
+ }
205
+
206
+ /** The gaps between the new blocks, indented for a component. */
207
+ function innerGapsFor(gaps: string[], indent: string): string[] {
208
+ return gaps.map((gap) =>
209
+ indent && gap.includes('\n')
210
+ ? `${gap.replace(/[ \t]*$/, '')}${indent}`
211
+ : gap
212
+ )
213
+ }
214
+
215
+ /** Puts blocks into an empty list: the doc, or a slot with no child. */
216
+ function fillEmpty(
217
+ view: ListView,
218
+ blocks: PlutoBlock[],
219
+ inner: string[]
220
+ ): void {
221
+ const { parent } = view
222
+ if (!parent) {
223
+ view.gaps.splice(0, view.gaps.length, '', ...inner, '\n')
224
+ view.blocks.push(...blocks)
225
+ return
226
+ }
227
+ const indent = blocks[0]!.indent ?? ''
228
+ const slot = view.slot!
229
+ // Where the slot starts: after its `#name` line, or at the start of the content.
230
+ let at = view.start
231
+ let cut: number | undefined
232
+ const marker = markerLine(parent.gaps[at] ?? '', slot)
233
+ if (marker) {
234
+ cut = marker.end
235
+ } else if (slot === 'default') {
236
+ cut = 0
237
+ }
238
+ if (cut !== undefined) {
239
+ const gap = parent.gaps[at]!
240
+ parent.gaps.splice(
241
+ at,
242
+ 1,
243
+ `${gap.slice(0, cut)}\n${indent}`,
244
+ ...inner,
245
+ gap.slice(cut) || '\n'
246
+ )
247
+ parent.slots[slot] = view.blocks
248
+ view.blocks.push(...blocks)
249
+ if (slot === 'default' && view.missing) {
250
+ // Content before the first `#name` goes first in the record.
251
+ const { default: own, ...rest } = parent.slots
252
+ parent.slots = { default: own!, ...rest }
253
+ }
254
+ return
255
+ }
256
+ // A named slot with no entry: add a `#name` section at the end.
257
+ at = parent.gaps.length - 1
258
+ const last = parent.gaps[at]!
259
+ const closing = /\n[ \t]*$/.exec(last)
260
+ const base = closing ? last.slice(0, closing.index) : last
261
+ const closeGap = closing ? closing[0] : '\n'
262
+ parent.gaps.splice(
263
+ at,
264
+ 1,
265
+ `${base}\n${parent.indent ?? ''}#${slot}\n${indent}`,
266
+ ...inner,
267
+ closeGap
268
+ )
269
+ parent.slots[slot] = view.blocks
270
+ view.blocks.push(...blocks)
271
+ }
272
+
273
+ /**
274
+ * Replaces `count` blocks at `index` with new blocks. `inner` has the gaps
275
+ * between the new blocks. Insert is `count` 0, remove is no new block.
276
+ */
277
+ function spliceList(
278
+ view: ListView,
279
+ index: number,
280
+ count: number,
281
+ blocks: PlutoBlock[],
282
+ inner: string[]
283
+ ): void {
284
+ const { gaps } = view
285
+ const f = view.start + index
286
+ if (count === 0 && view.blocks.length === 0) {
287
+ if (blocks.length > 0) {
288
+ fillEmpty(view, blocks, inner)
289
+ }
290
+ return
291
+ }
292
+ if (count === 0) {
293
+ const sep = separatorFor(view, blocks[0]!.indent ?? '')
294
+ if (index === 0) {
295
+ gaps.splice(f + 1, 0, ...inner, sep)
296
+ } else {
297
+ gaps.splice(f, 0, sep, ...inner)
298
+ }
299
+ view.blocks.splice(index, 0, ...blocks)
300
+ return
301
+ }
302
+ if (blocks.length === 0) {
303
+ // Remove: join the gap before and the gap after.
304
+ const before = gaps[f] ?? ''
305
+ const after = gaps[f + count] ?? ''
306
+ const rest = view.blocks.length - count
307
+ let merged: string
308
+ if (!view.parent && f === 0) {
309
+ merged = rest > 0 ? before : before + after
310
+ } else {
311
+ merged = markerPrefix(before) + after
312
+ }
313
+ gaps.splice(f, count + 1, merged)
314
+ view.blocks.splice(index, count)
315
+ return
316
+ }
317
+ // Replace: keep the outer gaps, change the inner ones.
318
+ gaps.splice(f + 1, count - 1, ...inner)
319
+ view.blocks.splice(index, count, ...blocks)
320
+ }
321
+
322
+ /** A nested component needs more colons than its parent. */
323
+ function ensureColons(block: PlutoBlock, min: number): void {
324
+ if (block.kind !== 'component') {
325
+ return
326
+ }
327
+ if (block.colons < min) {
328
+ block.colons = min
329
+ block.openerText = block.openerText.replace(/^:+/, ':'.repeat(min))
330
+ delete block.original
331
+ }
332
+ for (const child of flatChildren(block)) {
333
+ ensureColons(child, block.colons + 1)
334
+ }
335
+ }
336
+
337
+ function prepare(view: ListView, index: number, blocks: PlutoBlock[]): void {
338
+ const indent = childIndentFor(view, index, blocks[0]!)
339
+ for (const block of blocks) {
340
+ withIndent(block, view.parent ? indent : '')
341
+ if (view.parent) {
342
+ ensureColons(block, view.parent.colons + 1)
343
+ }
344
+ }
345
+ }
346
+
347
+ // Make blocks
348
+
349
+ /** Parses Markdown into new blocks, with fresh ids. The text between them is in `gaps`. */
350
+ export function blocksFromMarkdown(markdown: string): {
351
+ blocks: PlutoBlock[]
352
+ gaps: string[]
353
+ } {
354
+ const doc = parseBlocks(markdown.replace(/^(?:[ \t]*\n)+/, '').trimEnd())
355
+ return {
356
+ blocks: doc.blocks.map((block) => renewIds(block)),
357
+ gaps: doc.gaps.slice(1, -1),
358
+ }
359
+ }
360
+
361
+ export function createTextBlock(markdown: string): PlutoBlock {
362
+ return { id: newBlockId(), kind: 'text', markdown }
363
+ }
364
+
365
+ export function createCodeBlock(code = '', lang = ''): PlutoBlock {
366
+ return { id: newBlockId(), kind: 'code', lang, fence: '```', code }
367
+ }
368
+
369
+ export function createComponentBlock(
370
+ name: string,
371
+ props: Record<string, PlutoPropValue> = {},
372
+ content?: string
373
+ ): PlutoBlock {
374
+ const text = content ? `\n${content}\n` : '\n'
375
+ const block = parseBlocks(`::${name}${text}::`).blocks[0]
376
+ if (block?.kind !== 'component') {
377
+ throw new Error(`Cannot make a component from the name "${name}".`)
378
+ }
379
+ renewIds(block)
380
+ for (const [key, value] of Object.entries(props)) {
381
+ block.props[key] = value
382
+ block.propOrder.push(key)
383
+ }
384
+ delete block.original
385
+ return block
386
+ }
387
+
388
+ // Operations
389
+
390
+ /** Puts blocks at a target. It returns `undefined` when the target does not exist. */
391
+ export function insertBlocks(
392
+ doc: PlutoBlockDoc,
393
+ blocks: PlutoBlock[],
394
+ target: PlutoBlockTarget,
395
+ gaps: string[] = []
396
+ ): PlutoInsertResult | undefined {
397
+ if (blocks.length === 0) {
398
+ return { doc, ids: [] }
399
+ }
400
+ const next = cloneDoc(doc)
401
+ const view = viewOf(next, target.parentId, target.slot)
402
+ if (!view) {
403
+ return undefined
404
+ }
405
+ const fresh = blocks.map((block) => clone(block))
406
+ const index = Math.max(0, Math.min(target.index, view.blocks.length))
407
+ prepare(view, index, fresh)
408
+ const indent = view.parent ? (fresh[0]!.indent ?? '') : ''
409
+ spliceList(
410
+ view,
411
+ index,
412
+ 0,
413
+ fresh,
414
+ innerGapsFor(
415
+ gaps.length === fresh.length - 1
416
+ ? gaps
417
+ : fresh.slice(1).map(() => '\n\n'),
418
+ indent
419
+ )
420
+ )
421
+ if (target.parentId !== undefined) {
422
+ touch(next, target.parentId)
423
+ }
424
+ return { doc: next, ids: fresh.map((block) => block.id) }
425
+ }
426
+
427
+ export function insertBlock(
428
+ doc: PlutoBlockDoc,
429
+ block: PlutoBlock,
430
+ target: PlutoBlockTarget
431
+ ): PlutoInsertResult | undefined {
432
+ return insertBlocks(doc, [block], target)
433
+ }
434
+
435
+ export function removeBlock(doc: PlutoBlockDoc, id: string): PlutoBlockDoc {
436
+ const next = cloneDoc(doc)
437
+ const found = findBlock(next, id)
438
+ if (!found) {
439
+ return doc
440
+ }
441
+ const view = viewOf(next, found.parent?.id, found.slot)!
442
+ if (found.parent) {
443
+ touch(next, found.parent.id)
444
+ }
445
+ spliceList(view, found.index, 1, [], [])
446
+ return next
447
+ }
448
+
449
+ /** Moves a block. `target.index` is the position in the target list after the move. */
450
+ export function moveBlock(
451
+ doc: PlutoBlockDoc,
452
+ id: string,
453
+ target: PlutoBlockTarget
454
+ ): PlutoBlockDoc {
455
+ const found = findBlock(doc, id)
456
+ if (!found) {
457
+ return doc
458
+ }
459
+ // A block cannot go into itself.
460
+ if (
461
+ target.parentId !== undefined &&
462
+ pathOf(doc, target.parentId).includes(id)
463
+ ) {
464
+ return doc
465
+ }
466
+ const sameList =
467
+ found.parent?.id === target.parentId &&
468
+ (found.slot ?? 'default') === (target.slot ?? 'default')
469
+ if (sameList) {
470
+ const next = cloneDoc(doc)
471
+ const located = findBlock(next, id)!
472
+ const view = viewOf(next, target.parentId, target.slot)!
473
+ const index = Math.max(0, Math.min(target.index, view.blocks.length - 1))
474
+ if (index === located.index) {
475
+ return doc
476
+ }
477
+ // Only the order of the blocks changes. The gaps stay in place.
478
+ const [block] = view.blocks.splice(located.index, 1)
479
+ view.blocks.splice(index, 0, block!)
480
+ if (target.parentId !== undefined) {
481
+ touch(next, target.parentId)
482
+ }
483
+ return next
484
+ }
485
+ const moving = clone(found.block)
486
+ const removed = removeBlock(doc, id)
487
+ // The moved block keeps its id.
488
+ const inserted = insertBlocks(removed, [moving], target)
489
+ if (!inserted) {
490
+ return doc
491
+ }
492
+ return inserted.doc
493
+ }
494
+
495
+ /** Copies a block and puts the copy after it. */
496
+ export function duplicateBlock(
497
+ doc: PlutoBlockDoc,
498
+ id: string
499
+ ): PlutoInsertResult | undefined {
500
+ const found = findBlock(doc, id)
501
+ if (!found) {
502
+ return undefined
503
+ }
504
+ const copy = renewIds(clone(found.block))
505
+ return insertBlocks(doc, [copy], {
506
+ parentId: found.parent?.id,
507
+ slot: found.slot,
508
+ index: found.index + 1,
509
+ })
510
+ }
511
+
512
+ /** Edits a block with a function on a copy. A no-op edit returns the same doc. */
513
+ function editBlock(
514
+ doc: PlutoBlockDoc,
515
+ id: string,
516
+ edit: (block: PlutoBlock) => boolean
517
+ ): PlutoBlockDoc {
518
+ const next = cloneDoc(doc)
519
+ const found = findBlock(next, id)
520
+ if (!found || !edit(found.block)) {
521
+ return doc
522
+ }
523
+ touch(next, id)
524
+ return next
525
+ }
526
+
527
+ export function updateText(
528
+ doc: PlutoBlockDoc,
529
+ id: string,
530
+ markdown: string
531
+ ): PlutoBlockDoc {
532
+ return editBlock(doc, id, (block) => {
533
+ if (
534
+ (block.kind !== 'text' && block.kind !== 'raw') ||
535
+ block.markdown === markdown
536
+ ) {
537
+ return false
538
+ }
539
+ block.markdown = markdown
540
+ return true
541
+ })
542
+ }
543
+
544
+ export function updateCode(
545
+ doc: PlutoBlockDoc,
546
+ id: string,
547
+ patch: Partial<Pick<PlutoCodeBlock, 'lang' | 'filename' | 'meta' | 'code'>>
548
+ ): PlutoBlockDoc {
549
+ return editBlock(doc, id, (block) => {
550
+ if (block.kind !== 'code') {
551
+ return false
552
+ }
553
+ let changed = false
554
+ for (const [key, value] of Object.entries(patch) as Array<
555
+ [keyof typeof patch, string | undefined]
556
+ >) {
557
+ if (block[key] !== value) {
558
+ changed = true
559
+ if (
560
+ value === undefined ||
561
+ (value === '' && key !== 'code' && key !== 'lang')
562
+ ) {
563
+ delete block[key]
564
+ } else {
565
+ block[key] = value
566
+ }
567
+ }
568
+ }
569
+ return changed
570
+ })
571
+ }
572
+
573
+ /** Sets a prop of a component. A value of `undefined` removes the prop. */
574
+ export function setProp(
575
+ doc: PlutoBlockDoc,
576
+ id: string,
577
+ key: string,
578
+ value: PlutoPropValue
579
+ ): PlutoBlockDoc {
580
+ return editBlock(doc, id, (block) => {
581
+ if (block.kind !== 'component') {
582
+ return false
583
+ }
584
+ if (value === undefined) {
585
+ if (!(key in block.props)) {
586
+ return false
587
+ }
588
+ delete block.props[key]
589
+ return true
590
+ }
591
+ if (
592
+ JSON.stringify(block.props[key]) === JSON.stringify(value) &&
593
+ key in block.props
594
+ ) {
595
+ return false
596
+ }
597
+ block.props[key] = value
598
+ block.propOrder = completeOrder(block.props, block.propOrder)
599
+ return true
600
+ })
601
+ }
602
+
603
+ /** Replaces all children of a slot with the blocks parsed from Markdown. */
604
+ export function setSlot(
605
+ doc: PlutoBlockDoc,
606
+ id: string,
607
+ slot: string,
608
+ markdown: string
609
+ ): PlutoBlockDoc {
610
+ const found = findBlock(doc, id)
611
+ if (found?.block.kind !== 'component') {
612
+ return doc
613
+ }
614
+ const current = found.block.slots[slot] ?? []
615
+ const parsed = blocksFromMarkdown(markdown)
616
+ // The same text gives the same doc.
617
+ if (
618
+ current.length > 0 &&
619
+ parsed.blocks.length > 0 &&
620
+ current.length === parsed.blocks.length
621
+ ) {
622
+ const same = current.every(
623
+ (child, index) =>
624
+ (child.kind === 'text' || child.kind === 'raw') &&
625
+ parsed.blocks[index]!.kind === child.kind &&
626
+ (parsed.blocks[index] as typeof child).markdown === child.markdown
627
+ )
628
+ if (same) {
629
+ return doc
630
+ }
631
+ }
632
+ const next = cloneDoc(doc)
633
+ const view = viewOf(next, id, slot)!
634
+ const parent = view.parent!
635
+ touch(next, id)
636
+ const blocks = parsed.blocks
637
+ const indent = blocks.length > 0 ? childIndentFor(view, 0, blocks[0]!) : ''
638
+ for (const block of blocks) {
639
+ withIndent(block, indent)
640
+ ensureColons(block, parent.colons + 1)
641
+ }
642
+ const inner = innerGapsFor(parsed.gaps, indent)
643
+ if (view.blocks.length > 0) {
644
+ spliceList(view, 0, view.blocks.length, blocks, inner)
645
+ } else if (blocks.length > 0) {
646
+ spliceList(view, 0, 0, blocks, inner)
647
+ }
648
+ parent.slots = { ...parent.slots }
649
+ return next
650
+ }
651
+
652
+ /** Replaces a block with the blocks parsed from Markdown. */
653
+ export function replaceWithMarkdown(
654
+ doc: PlutoBlockDoc,
655
+ id: string,
656
+ markdown: string
657
+ ): PlutoInsertResult | undefined {
658
+ const found = findBlock(doc, id)
659
+ if (!found) {
660
+ return undefined
661
+ }
662
+ const parsed = blocksFromMarkdown(markdown)
663
+ const next = cloneDoc(doc)
664
+ const at = findBlock(next, id)!
665
+ const view = viewOf(next, at.parent?.id, at.slot)!
666
+ if (at.parent) {
667
+ touch(next, at.parent.id)
668
+ }
669
+ const indent = at.block.indent ?? ''
670
+ for (const block of parsed.blocks) {
671
+ withIndent(block, indent)
672
+ if (view.parent) {
673
+ ensureColons(block, view.parent.colons + 1)
674
+ }
675
+ }
676
+ spliceList(
677
+ view,
678
+ at.index,
679
+ 1,
680
+ parsed.blocks,
681
+ innerGapsFor(parsed.gaps, indent)
682
+ )
683
+ return { doc: next, ids: parsed.blocks.map((block) => block.id) }
684
+ }
685
+
686
+ /** The block as plain Markdown text, for "Edit as Markdown". */
687
+ export function blockMarkdown(block: PlutoBlock): string {
688
+ if (block.kind === 'text' || block.kind === 'raw') {
689
+ return block.markdown
690
+ }
691
+ return dedent(renderBlock(block), block.indent ?? '')
692
+ }