@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,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
|
+
}
|