@abraca/convert 2.3.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/dist/abracadabra-convert.cjs +3237 -0
- package/dist/abracadabra-convert.cjs.map +1 -0
- package/dist/abracadabra-convert.esm.js +3163 -0
- package/dist/abracadabra-convert.esm.js.map +1 -0
- package/dist/index.d.ts +356 -0
- package/package.json +41 -0
- package/src/diff.ts +302 -0
- package/src/file-blocks/manifest.ts +169 -0
- package/src/file-blocks/paths.ts +207 -0
- package/src/html-to-yjs.ts +322 -0
- package/src/index.ts +103 -0
- package/src/markdown-to-yjs.ts +1208 -0
- package/src/spec/index.ts +7 -0
- package/src/spec/marks.ts +92 -0
- package/src/spec/nodes.ts +333 -0
- package/src/spec/universal-meta.ts +147 -0
- package/src/types.ts +89 -0
- package/src/yjs-to-markdown.ts +820 -0
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
import * as Y from 'yjs'
|
|
2
|
+
import type { DocPageMeta } from './types.ts'
|
|
3
|
+
|
|
4
|
+
// ── Filename → readable label ────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Converts a filename (without extension) to a human-readable label.
|
|
8
|
+
*
|
|
9
|
+
* - `this-is-a-doc` → `"This is a doc"` (kebab/snake: sentence case)
|
|
10
|
+
* - `ThisIsADoc` → `"This Is A Doc"` (PascalCase: preserves word caps)
|
|
11
|
+
* - `thisIsADoc` → `"This Is A Doc"` (camelCase: preserves word caps)
|
|
12
|
+
*/
|
|
13
|
+
export function filenameToLabel(raw: string): string {
|
|
14
|
+
const base = raw.replace(/\.[^.]+$/, '') // strip extension if present
|
|
15
|
+
const spaced = base.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
16
|
+
const clean = spaced.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
17
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── YAML frontmatter parser ──────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FrontmatterResult {
|
|
23
|
+
title?: string
|
|
24
|
+
type?: string
|
|
25
|
+
meta: Partial<DocPageMeta>
|
|
26
|
+
body: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function coerceScalar(raw: string): string | number | boolean {
|
|
30
|
+
const trimmed = raw.trim()
|
|
31
|
+
if (trimmed === 'true') return true
|
|
32
|
+
if (trimmed === 'false') return false
|
|
33
|
+
const num = Number(trimmed)
|
|
34
|
+
if (!Number.isNaN(num) && trimmed !== '') return num
|
|
35
|
+
// Strip surrounding quotes
|
|
36
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
37
|
+
|| (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
|
|
38
|
+
return trimmed.slice(1, -1)
|
|
39
|
+
}
|
|
40
|
+
return trimmed
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseInlineArray(raw: string): string[] {
|
|
44
|
+
// e.g. "[a, b, c]"
|
|
45
|
+
return raw.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripQuotes(s: string): string {
|
|
49
|
+
if (s.length >= 2
|
|
50
|
+
&& ((s.startsWith('"') && s.endsWith('"'))
|
|
51
|
+
|| (s.startsWith('\'') && s.endsWith('\'')))) {
|
|
52
|
+
return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
53
|
+
}
|
|
54
|
+
return s
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseFrontmatter(markdown: string): FrontmatterResult {
|
|
58
|
+
const noResult: FrontmatterResult = { meta: {}, body: markdown }
|
|
59
|
+
|
|
60
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
61
|
+
if (!match) return noResult
|
|
62
|
+
|
|
63
|
+
const yamlBlock = match[1]!
|
|
64
|
+
const body = markdown.slice(match[0].length)
|
|
65
|
+
|
|
66
|
+
// Parse YAML into a raw map
|
|
67
|
+
const raw: Record<string, string | string[]> = {}
|
|
68
|
+
const lines = yamlBlock.split('\n')
|
|
69
|
+
let i = 0
|
|
70
|
+
while (i < lines.length) {
|
|
71
|
+
const line = lines[i]!
|
|
72
|
+
// Block sequence: "key:\n - a\n - b"
|
|
73
|
+
const blockSeqKey = line.match(/^(\w[\w-]*):\s*$/)
|
|
74
|
+
if (blockSeqKey && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1]!)) {
|
|
75
|
+
const key = blockSeqKey[1]!
|
|
76
|
+
const items: string[] = []
|
|
77
|
+
i++
|
|
78
|
+
while (i < lines.length && /^\s+-\s/.test(lines[i]!)) {
|
|
79
|
+
items.push(lines[i]!.replace(/^\s+-\s/, '').trim())
|
|
80
|
+
i++
|
|
81
|
+
}
|
|
82
|
+
raw[key] = items
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
// Scalar or inline array
|
|
86
|
+
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/)
|
|
87
|
+
if (kvMatch) {
|
|
88
|
+
const key = kvMatch[1]!
|
|
89
|
+
const val = kvMatch[2]!.trim()
|
|
90
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
91
|
+
raw[key] = parseInlineArray(val).map(stripQuotes)
|
|
92
|
+
} else {
|
|
93
|
+
// Strip surrounding quotes up-front so downstream readers
|
|
94
|
+
// don't have to. The original quote state is irrelevant — we
|
|
95
|
+
// re-emit with our canonical quoting rules.
|
|
96
|
+
raw[key] = stripQuotes(val)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
i++
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Map raw YAML keys → PageMeta
|
|
103
|
+
const meta: Partial<DocPageMeta> = {}
|
|
104
|
+
|
|
105
|
+
const getStr = (keys: string[]): string | undefined => {
|
|
106
|
+
for (const k of keys) {
|
|
107
|
+
const v = raw[k]
|
|
108
|
+
if (typeof v === 'string' && v) return v
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const getArr = (keys: string[]): string[] | undefined => {
|
|
112
|
+
for (const k of keys) {
|
|
113
|
+
const v = raw[k]
|
|
114
|
+
if (Array.isArray(v)) return v
|
|
115
|
+
if (typeof v === 'string' && v) return [v]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (raw['tags']) meta.tags = Array.isArray(raw['tags']) ? raw['tags'] : [raw['tags'] as string]
|
|
120
|
+
const color = getStr(['color'])
|
|
121
|
+
if (color) meta.color = color
|
|
122
|
+
const icon = getStr(['icon'])
|
|
123
|
+
if (icon) meta.icon = icon
|
|
124
|
+
const status = getStr(['status'])
|
|
125
|
+
if (status) meta.status = status
|
|
126
|
+
|
|
127
|
+
const priorityRaw = getStr(['priority'])
|
|
128
|
+
if (priorityRaw !== undefined) {
|
|
129
|
+
const map: Record<string, number> = { low: 1, medium: 2, high: 3, urgent: 4 }
|
|
130
|
+
meta.priority = map[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const checkedRaw: unknown = raw['checked'] ?? raw['done']
|
|
134
|
+
if (checkedRaw !== undefined) meta.checked = checkedRaw === 'true' || checkedRaw === true
|
|
135
|
+
|
|
136
|
+
// Canonical keys are checked first, aliases follow — so files that
|
|
137
|
+
// already use the canonical name (dateStart, dateEnd, subtitle) keep
|
|
138
|
+
// round-tripping byte-stably while legacy aliases still parse.
|
|
139
|
+
const dateStart = getStr(['dateStart', 'date', 'created'])
|
|
140
|
+
if (dateStart) meta.dateStart = dateStart
|
|
141
|
+
const dateEnd = getStr(['dateEnd', 'due'])
|
|
142
|
+
if (dateEnd) meta.dateEnd = dateEnd
|
|
143
|
+
|
|
144
|
+
const subtitle = getStr(['subtitle', 'description'])
|
|
145
|
+
if (subtitle) meta.subtitle = subtitle
|
|
146
|
+
const url = getStr(['url'])
|
|
147
|
+
if (url) meta.url = url
|
|
148
|
+
|
|
149
|
+
const ratingRaw = getStr(['rating'])
|
|
150
|
+
if (ratingRaw !== undefined) {
|
|
151
|
+
const n = Number(ratingRaw)
|
|
152
|
+
if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rawTitle = typeof raw['title'] === 'string' ? raw['title'] : undefined
|
|
156
|
+
const title = rawTitle !== undefined ? stripQuotes(rawTitle) : undefined
|
|
157
|
+
const type = getStr(['type'])
|
|
158
|
+
|
|
159
|
+
return { title, type, meta, body }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Inline token parsing ─────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
interface InlineToken {
|
|
165
|
+
text: string
|
|
166
|
+
attrs?: Record<string, unknown>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseInline(text: string): InlineToken[] {
|
|
170
|
+
// Strip MDC attribute annotations on inline code: `string`{lang="ts-type"}
|
|
171
|
+
const stripped = text.replace(/\{lang="[^"]*"\}/g, '')
|
|
172
|
+
// Strip remaining unknown inline MDC components (but NOT badge/icon/kbd — handled below)
|
|
173
|
+
.replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, '$2')
|
|
174
|
+
.replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, '')
|
|
175
|
+
|
|
176
|
+
const tokens: InlineToken[] = []
|
|
177
|
+
// Order matters: math (`$\u2026$`) before italic (so `$x$` doesn't match
|
|
178
|
+
// `$ \u2026 $` as italic-with-dollars); inline MDC first; mentions before
|
|
179
|
+
// plain `[\u2026]` links; wikilinks before plain brackets; strikethrough;
|
|
180
|
+
// bold before italic.
|
|
181
|
+
const re = /\$([^$\n]+?)\$|@\[([^\]]+?)\]\(user:([^)]+?)\)|:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g
|
|
182
|
+
let lastIndex = 0
|
|
183
|
+
let match: RegExpExecArray | null
|
|
184
|
+
|
|
185
|
+
while ((match = re.exec(stripped)) !== null) {
|
|
186
|
+
if (match.index > lastIndex) {
|
|
187
|
+
tokens.push({ text: stripped.slice(lastIndex, match.index) })
|
|
188
|
+
}
|
|
189
|
+
if (match[1] !== undefined) {
|
|
190
|
+
// $expression$ \u2014 inline math atom
|
|
191
|
+
tokens.push({ text: match[1], attrs: { mathInline: { expression: match[1] } } })
|
|
192
|
+
} else if (match[2] !== undefined && match[3] !== undefined) {
|
|
193
|
+
// @[label](user:uuid) \u2014 mention atom
|
|
194
|
+
tokens.push({ text: match[2], attrs: { mention: { userId: match[3], label: match[2] } } })
|
|
195
|
+
} else if (match[4] !== undefined) {
|
|
196
|
+
// :badge[text]{props}
|
|
197
|
+
const badgeProps = parseMdcProps(match[5])
|
|
198
|
+
tokens.push({ text: match[4] || 'Badge', attrs: { badge: { label: match[4] || 'Badge', color: badgeProps['color'] || 'neutral', variant: badgeProps['variant'] || 'subtle' } } })
|
|
199
|
+
} else if (match[6] !== undefined) {
|
|
200
|
+
// :icon{name="..."}
|
|
201
|
+
const iconProps = parseMdcProps(`{${match[6]}}`)
|
|
202
|
+
tokens.push({ text: '\u200B', attrs: { proseIcon: { name: iconProps['name'] || 'i-lucide-star' } } })
|
|
203
|
+
} else if (match[7] !== undefined) {
|
|
204
|
+
// :kbd{value="..."}
|
|
205
|
+
const kbdProps = parseMdcProps(`{${match[7]}}`)
|
|
206
|
+
tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
|
|
207
|
+
} else if (match[8] !== undefined) {
|
|
208
|
+
// [[uuid]] or [[uuid|label]] \u2014 inline doc link atom. The docId
|
|
209
|
+
// is the canonical anchor; the label is display-only. The
|
|
210
|
+
// serialiser may regenerate the label from a live doc registry
|
|
211
|
+
// (see SPEC.md \u00A76); when no resolver is supplied, the parser's
|
|
212
|
+
// stored display text is reused on emit.
|
|
213
|
+
const docId = match[8]
|
|
214
|
+
const label = match[9] ?? docId
|
|
215
|
+
tokens.push({ text: label, attrs: { docLink: { docId } } })
|
|
216
|
+
} else if (match[10] !== undefined) {
|
|
217
|
+
tokens.push({ text: match[10], attrs: { strike: true } })
|
|
218
|
+
} else if (match[11] !== undefined) {
|
|
219
|
+
tokens.push({ text: match[11], attrs: { bold: true } })
|
|
220
|
+
} else if (match[12] !== undefined) {
|
|
221
|
+
tokens.push({ text: match[12], attrs: { italic: true } })
|
|
222
|
+
} else if (match[13] !== undefined) {
|
|
223
|
+
tokens.push({ text: match[13], attrs: { italic: true } })
|
|
224
|
+
} else if (match[14] !== undefined) {
|
|
225
|
+
tokens.push({ text: match[14], attrs: { code: true } })
|
|
226
|
+
} else if (match[15] !== undefined && match[16] !== undefined) {
|
|
227
|
+
tokens.push({ text: match[15], attrs: { link: { href: match[16] } } })
|
|
228
|
+
}
|
|
229
|
+
lastIndex = match.index + match[0].length
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (lastIndex < stripped.length) {
|
|
233
|
+
tokens.push({ text: stripped.slice(lastIndex) })
|
|
234
|
+
}
|
|
235
|
+
return tokens.filter(t => t.text.length > 0)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Block-level parser ───────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
interface TaskItem {
|
|
241
|
+
text: string
|
|
242
|
+
checked: boolean
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface ListItemBlock {
|
|
246
|
+
text: string
|
|
247
|
+
innerBlocks?: Block[]
|
|
248
|
+
checked?: boolean
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type Block
|
|
252
|
+
= | { type: 'heading', level: number, text: string }
|
|
253
|
+
| { type: 'paragraph', text: string }
|
|
254
|
+
| { type: 'bulletList', items: ListItemBlock[] }
|
|
255
|
+
| { type: 'orderedList', items: ListItemBlock[] }
|
|
256
|
+
| { type: 'taskList', items: ListItemBlock[] }
|
|
257
|
+
| { type: 'codeBlock', lang: string, code: string }
|
|
258
|
+
| { type: 'blockquote', lines: string[] }
|
|
259
|
+
| { type: 'table', headerRow: string[], dataRows: string[][] }
|
|
260
|
+
| { type: 'hr' }
|
|
261
|
+
| { type: 'callout', calloutType: string, innerBlocks: Block[] }
|
|
262
|
+
| { type: 'collapsible', label: string, open: boolean, innerBlocks: Block[] }
|
|
263
|
+
| { type: 'steps', innerBlocks: Block[] }
|
|
264
|
+
| { type: 'card', title: string, icon: string, to: string, innerBlocks: Block[] }
|
|
265
|
+
| { type: 'cardGroup', cards: Block[] }
|
|
266
|
+
| { type: 'codeCollapse', codeBlocks: Block[] }
|
|
267
|
+
| { type: 'codeGroup', codeBlocks: Block[] }
|
|
268
|
+
| { type: 'codePreview', innerBlocks: Block[], codeBlocks: Block[] }
|
|
269
|
+
| { type: 'codeTree', files: string }
|
|
270
|
+
| { type: 'accordion', items: { label: string, icon: string, innerBlocks: Block[] }[] }
|
|
271
|
+
| { type: 'tabs', items: { label: string, icon: string, innerBlocks: Block[] }[] }
|
|
272
|
+
| { type: 'field', name: string, fieldType: string, required: boolean, innerBlocks: Block[] }
|
|
273
|
+
| { type: 'fieldGroup', fields: Block[] }
|
|
274
|
+
| { type: 'image', src: string, alt: string, width?: string, height?: string }
|
|
275
|
+
| { type: 'docEmbed', docId: string, label: string, props: Record<string, string> }
|
|
276
|
+
| { type: 'mathBlock', expression: string }
|
|
277
|
+
| { type: 'fileBlock', src: string, mime: string, uploadId: string, filename: string }
|
|
278
|
+
|
|
279
|
+
function parseTableRow(line: string): string[] {
|
|
280
|
+
const parts = line.split('|')
|
|
281
|
+
// Remove the first and last (empty from leading/trailing |) and trim each
|
|
282
|
+
return parts.slice(1, parts.length - 1).map(c => c.trim())
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isTableSeparator(line: string): boolean {
|
|
286
|
+
return /^\|[\s|:-]+\|$/.test(line.trim())
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Extract fenced code blocks from MDC #code slot lines. */
|
|
290
|
+
function extractFencedCode(lines: string[]): Block[] {
|
|
291
|
+
const result: Block[] = []
|
|
292
|
+
let i = 0
|
|
293
|
+
while (i < lines.length) {
|
|
294
|
+
const line = lines[i]!
|
|
295
|
+
// Match opening fence (```, ````, etc.)
|
|
296
|
+
const fenceMatch = line.match(/^(`{3,})(\w*)/)
|
|
297
|
+
if (fenceMatch) {
|
|
298
|
+
const fence = fenceMatch[1]!
|
|
299
|
+
const lang = fenceMatch[2] ?? ''
|
|
300
|
+
const codeLines: string[] = []
|
|
301
|
+
i++
|
|
302
|
+
while (i < lines.length && !lines[i]!.startsWith(fence)) {
|
|
303
|
+
codeLines.push(lines[i]!)
|
|
304
|
+
i++
|
|
305
|
+
}
|
|
306
|
+
i++ // skip closing fence
|
|
307
|
+
result.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
i++
|
|
311
|
+
}
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Extract key="value" pairs from MDC prop syntax `{key="value" other="x"}` */
|
|
316
|
+
function parseMdcProps(propsStr: string | undefined): Record<string, string> {
|
|
317
|
+
if (!propsStr) return {}
|
|
318
|
+
const result: Record<string, string> = {}
|
|
319
|
+
// Drop the wrapping `{ … }` if present.
|
|
320
|
+
let s = propsStr.trim()
|
|
321
|
+
if (s.startsWith('{') && s.endsWith('}')) s = s.slice(1, -1)
|
|
322
|
+
|
|
323
|
+
// Three accepted shapes per SPEC.md §5:
|
|
324
|
+
// key="value" (quoted)
|
|
325
|
+
// key=value (bare, no whitespace)
|
|
326
|
+
// key (boolean shorthand → "true")
|
|
327
|
+
const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|([^\s"}]+)))?/g
|
|
328
|
+
let m: RegExpExecArray | null
|
|
329
|
+
while ((m = re.exec(s)) !== null) {
|
|
330
|
+
const key = m[1]!
|
|
331
|
+
if (m[2] !== undefined) result[key] = m[2]
|
|
332
|
+
else if (m[3] !== undefined) result[key] = m[3]
|
|
333
|
+
else result[key] = 'true'
|
|
334
|
+
}
|
|
335
|
+
return result
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Parse named child MDC blocks from inner lines (e.g. #item for accordion, #tab for tabs) */
|
|
339
|
+
function parseMdcChildren(innerLines: string[], slotPrefix: string): { label: string, icon: string, innerBlocks: Block[] }[] {
|
|
340
|
+
const items: { label: string, icon: string, lines: string[] }[] = []
|
|
341
|
+
let current: { label: string, icon: string, lines: string[] } | null = null
|
|
342
|
+
const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`)
|
|
343
|
+
|
|
344
|
+
for (const line of innerLines) {
|
|
345
|
+
const slotMatch = line.match(slotRe)
|
|
346
|
+
if (slotMatch) {
|
|
347
|
+
if (current) items.push(current)
|
|
348
|
+
const props = parseMdcProps(slotMatch[1])
|
|
349
|
+
current = { label: props['label'] || props['title'] || `Item ${items.length + 1}`, icon: props['icon'] || '', lines: [] }
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (current) {
|
|
353
|
+
current.lines.push(line)
|
|
354
|
+
} else {
|
|
355
|
+
// Content before first slot — treat as first unnamed item
|
|
356
|
+
if (!items.length && !current) {
|
|
357
|
+
current = { label: `Item 1`, icon: '', lines: [line] }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (current) items.push(current)
|
|
362
|
+
|
|
363
|
+
return items.map(item => ({
|
|
364
|
+
label: item.label,
|
|
365
|
+
icon: item.icon,
|
|
366
|
+
innerBlocks: parseBlocks(item.lines.join('\n'))
|
|
367
|
+
}))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Consume a list (bullet / ordered / task) starting at `start`. Indented
|
|
374
|
+
* continuation lines and nested lists are captured into each item's
|
|
375
|
+
* `innerBlocks` so the parse → serialise → parse cycle preserves tree
|
|
376
|
+
* structure instead of flattening nested lists onto a single line.
|
|
377
|
+
*
|
|
378
|
+
* `indent` is the column of the item marker for the current list. A
|
|
379
|
+
* nested list starts ≥2 columns deeper. Lines with less indent than
|
|
380
|
+
* `indent` belong to the outer block and stop consumption.
|
|
381
|
+
*/
|
|
382
|
+
function consumeList(
|
|
383
|
+
lines: string[],
|
|
384
|
+
start: number,
|
|
385
|
+
indent: number,
|
|
386
|
+
kind: 'bullet' | 'ordered' | 'task',
|
|
387
|
+
): { items: ListItemBlock[], next: number } {
|
|
388
|
+
const items: ListItemBlock[] = []
|
|
389
|
+
let i = start
|
|
390
|
+
while (i < lines.length) {
|
|
391
|
+
const line = lines[i]!
|
|
392
|
+
if (line.trim() === '') {
|
|
393
|
+
// Blank line — peek ahead: if the next non-blank line still
|
|
394
|
+
// belongs to this list, treat the blank as an inter-item
|
|
395
|
+
// separator. Otherwise terminate.
|
|
396
|
+
let j = i + 1
|
|
397
|
+
while (j < lines.length && lines[j]!.trim() === '') j++
|
|
398
|
+
if (j >= lines.length) break
|
|
399
|
+
const lookahead = lines[j]!
|
|
400
|
+
if (leadingSpaces(lookahead) < indent) break
|
|
401
|
+
if (!matchMarker(lookahead.slice(indent), kind)) break
|
|
402
|
+
i = j
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
const leading = leadingSpaces(line)
|
|
406
|
+
if (leading < indent) break
|
|
407
|
+
if (leading > indent) break // belongs to a nested list opened by the previous item
|
|
408
|
+
const deindented = line.slice(indent)
|
|
409
|
+
const m = matchMarker(deindented, kind)
|
|
410
|
+
if (!m) break
|
|
411
|
+
|
|
412
|
+
const item: ListItemBlock = { text: m.text }
|
|
413
|
+
if (kind === 'task') item.checked = m.checked
|
|
414
|
+
i++
|
|
415
|
+
|
|
416
|
+
// Consume continuation: lines indented deeper than `indent`.
|
|
417
|
+
const contLines: string[] = []
|
|
418
|
+
while (i < lines.length) {
|
|
419
|
+
const next = lines[i]!
|
|
420
|
+
if (next.trim() === '') {
|
|
421
|
+
// Blank inside a continuation block — could be part of the
|
|
422
|
+
// continuation if the next non-blank is still indented. Peek.
|
|
423
|
+
let k = i + 1
|
|
424
|
+
while (k < lines.length && lines[k]!.trim() === '') k++
|
|
425
|
+
if (k >= lines.length) break
|
|
426
|
+
const peekIndent = leadingSpaces(lines[k]!)
|
|
427
|
+
if (peekIndent <= indent) break
|
|
428
|
+
// Carry forward the blank line as a paragraph separator.
|
|
429
|
+
contLines.push('')
|
|
430
|
+
i++
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
const nextIndent = leadingSpaces(next)
|
|
434
|
+
if (nextIndent <= indent) break
|
|
435
|
+
// De-indent by exactly 2 to match the canonical wire form.
|
|
436
|
+
const deindentBy = Math.min(nextIndent, indent + 2)
|
|
437
|
+
contLines.push(next.slice(deindentBy))
|
|
438
|
+
i++
|
|
439
|
+
}
|
|
440
|
+
if (contLines.length > 0) {
|
|
441
|
+
item.innerBlocks = parseBlocks(contLines.join('\n'))
|
|
442
|
+
}
|
|
443
|
+
items.push(item)
|
|
444
|
+
}
|
|
445
|
+
return { items, next: i }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function leadingSpaces(s: string): number {
|
|
449
|
+
let n = 0
|
|
450
|
+
while (n < s.length && s[n] === ' ') n++
|
|
451
|
+
return n
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function matchMarker(
|
|
455
|
+
s: string,
|
|
456
|
+
kind: 'bullet' | 'ordered' | 'task',
|
|
457
|
+
): { text: string, checked: boolean } | null {
|
|
458
|
+
if (kind === 'task') {
|
|
459
|
+
const m = s.match(TASK_RE)
|
|
460
|
+
if (!m) return null
|
|
461
|
+
return { text: m[2]!, checked: m[1]!.toLowerCase() === 'x' }
|
|
462
|
+
}
|
|
463
|
+
if (kind === 'bullet') {
|
|
464
|
+
if (TASK_RE.test(s)) return null
|
|
465
|
+
const m = s.match(/^[-*+]\s+(.*)$/)
|
|
466
|
+
if (!m) return null
|
|
467
|
+
return { text: m[1]!, checked: false }
|
|
468
|
+
}
|
|
469
|
+
// ordered
|
|
470
|
+
const m = s.match(/^\d+\.\s+(.*)$/)
|
|
471
|
+
if (!m) return null
|
|
472
|
+
return { text: m[1]!, checked: false }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parseBlocks(markdown: string): Block[] {
|
|
476
|
+
// Strip top-level MDX import/export lines (only before the first content block).
|
|
477
|
+
// We must NOT strip `export default ...` etc. inside fenced code blocks.
|
|
478
|
+
const rawLines = markdown.split('\n')
|
|
479
|
+
let firstContentLine = 0
|
|
480
|
+
while (firstContentLine < rawLines.length) {
|
|
481
|
+
const l = rawLines[firstContentLine]!
|
|
482
|
+
if (l.trim() === '' || /^import\s/.test(l) || /^export\s/.test(l)) {
|
|
483
|
+
firstContentLine++
|
|
484
|
+
} else {
|
|
485
|
+
break
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const stripped = rawLines.slice(firstContentLine).join('\n')
|
|
489
|
+
|
|
490
|
+
const blocks: Block[] = []
|
|
491
|
+
const lines = stripped.split('\n')
|
|
492
|
+
let i = 0
|
|
493
|
+
|
|
494
|
+
while (i < lines.length) {
|
|
495
|
+
const line = lines[i]!
|
|
496
|
+
|
|
497
|
+
// Fenced code block (supports variable-length fences: ```, ````, etc.)
|
|
498
|
+
const fenceBlockMatch = line.match(/^(`{3,})(.*)$/)
|
|
499
|
+
if (fenceBlockMatch) {
|
|
500
|
+
const fence = fenceBlockMatch[1]!
|
|
501
|
+
const lang = fenceBlockMatch[2]!.trim()
|
|
502
|
+
.replace(/\{[^}]*\}$/, '') // strip highlight annotations like {4-5}
|
|
503
|
+
.replace(/\s*\[.*\]$/, '') // strip filename hints like [nuxt.config.ts]
|
|
504
|
+
.trim()
|
|
505
|
+
const codeLines: string[] = []
|
|
506
|
+
i++
|
|
507
|
+
while (i < lines.length && !lines[i]!.startsWith(fence)) {
|
|
508
|
+
codeLines.push(lines[i]!)
|
|
509
|
+
i++
|
|
510
|
+
}
|
|
511
|
+
i++ // skip closing fence
|
|
512
|
+
const code = codeLines.join('\n')
|
|
513
|
+
if (lang === 'math') {
|
|
514
|
+
// ```math … ``` — block math atom (SPEC.md §6).
|
|
515
|
+
blocks.push({ type: 'mathBlock', expression: code })
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
blocks.push({ type: 'codeBlock', lang, code })
|
|
519
|
+
}
|
|
520
|
+
continue
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Heading
|
|
524
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*)/)
|
|
525
|
+
if (headingMatch) {
|
|
526
|
+
blocks.push({ type: 'heading', level: headingMatch[1]!.length, text: headingMatch[2]!.trim() })
|
|
527
|
+
i++
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Horizontal rule
|
|
532
|
+
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
533
|
+
blocks.push({ type: 'hr' })
|
|
534
|
+
i++
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Doc embed: ![[uuid|label]]{collapsed tall seamless} — block-level,
|
|
539
|
+
// must be on its own line. UUID is required (the bracket syntax
|
|
540
|
+
// without one is reserved for inline doc-link).
|
|
541
|
+
const embedMatch = line.match(/^!\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+))?\]\](\{[^}]*\})?\s*$/)
|
|
542
|
+
if (embedMatch) {
|
|
543
|
+
const docId = embedMatch[1]!
|
|
544
|
+
const label = embedMatch[2] ?? ''
|
|
545
|
+
const props = parseMdcProps(embedMatch[3])
|
|
546
|
+
blocks.push({ type: 'docEmbed', docId, label, props })
|
|
547
|
+
i++
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Image: {attrs} — must be on its own line
|
|
552
|
+
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/)
|
|
553
|
+
if (imgMatch) {
|
|
554
|
+
const alt = imgMatch[1] ?? ''
|
|
555
|
+
const src = imgMatch[2] ?? ''
|
|
556
|
+
const attrs = parseMdcProps(imgMatch[3])
|
|
557
|
+
blocks.push({ type: 'image', src, alt, width: attrs['width'], height: attrs['height'] })
|
|
558
|
+
i++
|
|
559
|
+
continue
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Blockquote
|
|
563
|
+
if (line.startsWith('> ') || line === '>') {
|
|
564
|
+
const bqLines: string[] = []
|
|
565
|
+
while (i < lines.length && (lines[i]!.startsWith('> ') || lines[i] === '>')) {
|
|
566
|
+
bqLines.push(lines[i]!.replace(/^>\s?/, ''))
|
|
567
|
+
i++
|
|
568
|
+
}
|
|
569
|
+
blocks.push({ type: 'blockquote', lines: bqLines })
|
|
570
|
+
continue
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Table — detect by leading pipe
|
|
574
|
+
if (/^\s*\|/.test(line)) {
|
|
575
|
+
const tableLines: string[] = []
|
|
576
|
+
while (i < lines.length && /^\s*\|/.test(lines[i]!)) {
|
|
577
|
+
tableLines.push(lines[i]!)
|
|
578
|
+
i++
|
|
579
|
+
}
|
|
580
|
+
// Need at least: header row, separator row
|
|
581
|
+
if (tableLines.length >= 2 && isTableSeparator(tableLines[1]!)) {
|
|
582
|
+
const headerRow = parseTableRow(tableLines[0]!)
|
|
583
|
+
const dataRows = tableLines.slice(2)
|
|
584
|
+
.filter(l => !isTableSeparator(l))
|
|
585
|
+
.map(parseTableRow)
|
|
586
|
+
blocks.push({ type: 'table', headerRow, dataRows })
|
|
587
|
+
} else {
|
|
588
|
+
// Couldn't parse as table — emit as paragraphs
|
|
589
|
+
for (const l of tableLines) blocks.push({ type: 'paragraph', text: l })
|
|
590
|
+
}
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// MDC atom block (single-colon, no body): `:name{props}` on its
|
|
595
|
+
// own line. Currently recognised: `:file{…}` (fileBlock per
|
|
596
|
+
// SPEC.md §7). Other atom blocks (`:video{…}`, `:embed{…}`, etc.)
|
|
597
|
+
// are reserved for later workstreams.
|
|
598
|
+
const atomMatch = line.match(/^:(\w[\w-]*)(\{[^}]*\})?\s*$/)
|
|
599
|
+
if (atomMatch && atomMatch[1] === 'file') {
|
|
600
|
+
const props = parseMdcProps(atomMatch[2])
|
|
601
|
+
const uploadId = props['upload-id'] ?? props['uploadId'] ?? ''
|
|
602
|
+
const filename = props['filename'] ?? ''
|
|
603
|
+
const mime = props['mime'] ?? ''
|
|
604
|
+
const src = props['src'] ?? (uploadId && filename
|
|
605
|
+
? `.abracadabra/files/${uploadId}-${filename}`
|
|
606
|
+
: '')
|
|
607
|
+
blocks.push({ type: 'fileBlock', src, mime, uploadId, filename })
|
|
608
|
+
i++
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// MDC component block (::component-name ... ::)
|
|
613
|
+
// Allow optional leading whitespace so indented nested blocks are recognised
|
|
614
|
+
const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/
|
|
615
|
+
if (MDC_OPEN.test(line)) {
|
|
616
|
+
const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2
|
|
617
|
+
const componentName = line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? ''
|
|
618
|
+
const innerLines: string[] = []
|
|
619
|
+
i++
|
|
620
|
+
// Collect inner lines, but skip over fenced code blocks so that
|
|
621
|
+
// `::` inside ```...``` or ````...```` doesn't close the MDC block
|
|
622
|
+
while (i < lines.length) {
|
|
623
|
+
const l = lines[i]!
|
|
624
|
+
// Closing tag: same colon count, possibly indented
|
|
625
|
+
if (new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)) { i++; break }
|
|
626
|
+
// If we hit a fenced code block, consume it entirely
|
|
627
|
+
const innerFence = l.match(/^(\s*`{3,})/)
|
|
628
|
+
if (innerFence) {
|
|
629
|
+
const fenceStr = innerFence[1]!.trimStart()
|
|
630
|
+
innerLines.push(l)
|
|
631
|
+
i++
|
|
632
|
+
while (i < lines.length && !lines[i]!.trimStart().startsWith(fenceStr)) {
|
|
633
|
+
innerLines.push(lines[i]!)
|
|
634
|
+
i++
|
|
635
|
+
}
|
|
636
|
+
if (i < lines.length) { innerLines.push(lines[i]!); i++ } // push closing fence
|
|
637
|
+
continue
|
|
638
|
+
}
|
|
639
|
+
innerLines.push(l)
|
|
640
|
+
i++
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Strip common leading indentation from inner lines
|
|
644
|
+
const nonBlank = innerLines.filter(l => l.trim().length > 0)
|
|
645
|
+
if (nonBlank.length) {
|
|
646
|
+
const minIndent = Math.min(...nonBlank.map(l => l.match(/^(\s*)/)?.[1]?.length ?? 0))
|
|
647
|
+
if (minIndent > 0) {
|
|
648
|
+
for (let j = 0; j < innerLines.length; j++) {
|
|
649
|
+
innerLines[j] = innerLines[j]!.slice(Math.min(minIndent, innerLines[j]!.length))
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Strip inline frontmatter props (---...---)
|
|
655
|
+
let contentStart = 0
|
|
656
|
+
if (innerLines[0]?.trim() === '---') {
|
|
657
|
+
const fmEnd = innerLines.findIndex((l, idx) => idx > 0 && l.trim() === '---')
|
|
658
|
+
if (fmEnd !== -1) contentStart = fmEnd + 1
|
|
659
|
+
}
|
|
660
|
+
const contentLines = innerLines.slice(contentStart)
|
|
661
|
+
|
|
662
|
+
// Split into named slots: collect default slot and #code slot separately
|
|
663
|
+
const defaultSlotLines: string[] = []
|
|
664
|
+
const codeSlotLines: string[] = []
|
|
665
|
+
let currentSlot: 'default' | 'code' | 'other' = 'default'
|
|
666
|
+
for (const l of contentLines) {
|
|
667
|
+
if (/^#code\s*$/.test(l)) { currentSlot = 'code'; continue }
|
|
668
|
+
if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) { currentSlot = 'other'; continue }
|
|
669
|
+
if (currentSlot === 'default') defaultSlotLines.push(l)
|
|
670
|
+
else if (currentSlot === 'code') codeSlotLines.push(l)
|
|
671
|
+
}
|
|
672
|
+
const innerBlocks = parseBlocks(defaultSlotLines.join('\n'))
|
|
673
|
+
|
|
674
|
+
// Extract fenced code from #code slot and add as code blocks
|
|
675
|
+
const codeBlocks = extractFencedCode(codeSlotLines)
|
|
676
|
+
|
|
677
|
+
const CALLOUT_NAMES = new Set(['tip', 'note', 'info', 'warning', 'caution', 'danger', 'callout', 'alert'])
|
|
678
|
+
if (CALLOUT_NAMES.has(componentName.toLowerCase())) {
|
|
679
|
+
blocks.push({ type: 'callout', calloutType: componentName.toLowerCase(), innerBlocks })
|
|
680
|
+
} else {
|
|
681
|
+
const mdcProps = parseMdcProps(line.match(MDC_OPEN)?.[3])
|
|
682
|
+
const lc = componentName.toLowerCase()
|
|
683
|
+
|
|
684
|
+
if (lc === 'collapsible') {
|
|
685
|
+
blocks.push({ type: 'collapsible', label: mdcProps['label'] || 'Details', open: mdcProps['open'] === 'true', innerBlocks })
|
|
686
|
+
} else if (lc === 'steps') {
|
|
687
|
+
blocks.push({ type: 'steps', innerBlocks })
|
|
688
|
+
} else if (lc === 'card') {
|
|
689
|
+
blocks.push({ type: 'card', title: mdcProps['title'] || '', icon: mdcProps['icon'] || '', to: mdcProps['to'] || '', innerBlocks })
|
|
690
|
+
} else if (lc === 'card-group') {
|
|
691
|
+
// Each nested ::card inside card-group is already parsed as inner blocks
|
|
692
|
+
const cards = innerBlocks.filter(b => b.type === 'card')
|
|
693
|
+
if (cards.length) {
|
|
694
|
+
blocks.push({ type: 'cardGroup', cards })
|
|
695
|
+
} else {
|
|
696
|
+
blocks.push(...innerBlocks)
|
|
697
|
+
}
|
|
698
|
+
} else if (lc === 'code-collapse') {
|
|
699
|
+
blocks.push({ type: 'codeCollapse', codeBlocks: codeBlocks.length ? codeBlocks : innerBlocks.filter(b => b.type === 'codeBlock') })
|
|
700
|
+
} else if (lc === 'code-group') {
|
|
701
|
+
const allCode = [...innerBlocks.filter(b => b.type === 'codeBlock'), ...codeBlocks]
|
|
702
|
+
blocks.push({ type: 'codeGroup', codeBlocks: allCode })
|
|
703
|
+
} else if (lc === 'code-preview') {
|
|
704
|
+
blocks.push({ type: 'codePreview', innerBlocks, codeBlocks })
|
|
705
|
+
} else if (lc === 'code-tree') {
|
|
706
|
+
blocks.push({ type: 'codeTree', files: mdcProps['files'] || '[]' })
|
|
707
|
+
} else if (lc === 'accordion') {
|
|
708
|
+
const items = parseMdcChildren(contentLines, 'item')
|
|
709
|
+
if (items.length) {
|
|
710
|
+
blocks.push({ type: 'accordion', items })
|
|
711
|
+
} else {
|
|
712
|
+
blocks.push({ type: 'accordion', items: [{ label: 'Item 1', icon: '', innerBlocks }] })
|
|
713
|
+
}
|
|
714
|
+
} else if (lc === 'tabs') {
|
|
715
|
+
const items = parseMdcChildren(contentLines, 'tab')
|
|
716
|
+
if (items.length) {
|
|
717
|
+
blocks.push({ type: 'tabs', items })
|
|
718
|
+
} else {
|
|
719
|
+
blocks.push({ type: 'tabs', items: [{ label: 'Tab 1', icon: '', innerBlocks }] })
|
|
720
|
+
}
|
|
721
|
+
} else if (lc === 'field') {
|
|
722
|
+
blocks.push({ type: 'field', name: mdcProps['name'] || '', fieldType: mdcProps['type'] || 'string', required: mdcProps['required'] === 'true', innerBlocks })
|
|
723
|
+
} else if (lc === 'field-group') {
|
|
724
|
+
const fields = innerBlocks.filter(b => b.type === 'field')
|
|
725
|
+
if (fields.length) {
|
|
726
|
+
blocks.push({ type: 'fieldGroup', fields })
|
|
727
|
+
} else {
|
|
728
|
+
blocks.push(...innerBlocks)
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
// Unknown component — inline the content
|
|
732
|
+
blocks.push(...innerBlocks)
|
|
733
|
+
blocks.push(...codeBlocks)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Task list (must check before bullet list — `- [x]` starts the same way)
|
|
740
|
+
if (TASK_RE.test(line)) {
|
|
741
|
+
const { items, next } = consumeList(lines, i, 0, 'task')
|
|
742
|
+
i = next
|
|
743
|
+
blocks.push({ type: 'taskList', items })
|
|
744
|
+
continue
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Bullet list
|
|
748
|
+
if (/^[-*+]\s+/.test(line)) {
|
|
749
|
+
const { items, next } = consumeList(lines, i, 0, 'bullet')
|
|
750
|
+
if (items.length > 0) {
|
|
751
|
+
i = next
|
|
752
|
+
blocks.push({ type: 'bulletList', items })
|
|
753
|
+
continue
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Ordered list
|
|
758
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
759
|
+
const { items, next } = consumeList(lines, i, 0, 'ordered')
|
|
760
|
+
if (items.length > 0) {
|
|
761
|
+
i = next
|
|
762
|
+
blocks.push({ type: 'orderedList', items })
|
|
763
|
+
continue
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Blank line
|
|
768
|
+
if (line.trim() === '') {
|
|
769
|
+
i++
|
|
770
|
+
continue
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Paragraph — consecutive non-special lines
|
|
774
|
+
const paraLines: string[] = []
|
|
775
|
+
while (
|
|
776
|
+
i < lines.length
|
|
777
|
+
&& lines[i]!.trim() !== ''
|
|
778
|
+
&& !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(lines[i]!)
|
|
779
|
+
) {
|
|
780
|
+
paraLines.push(lines[i]!)
|
|
781
|
+
i++
|
|
782
|
+
}
|
|
783
|
+
if (paraLines.length) {
|
|
784
|
+
blocks.push({ type: 'paragraph', text: paraLines.join(' ') })
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return blocks
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ── Y.js content population ──────────────────────────────────────────────────
|
|
792
|
+
//
|
|
793
|
+
// IMPORTANT — attach-before-fill ordering:
|
|
794
|
+
//
|
|
795
|
+
// Y.XmlText.insert() is only deterministically ordered when the text node is
|
|
796
|
+
// already attached to a Y.Doc (so it can use the doc's logical clock).
|
|
797
|
+
// Inserting text into a standalone Y.XmlText gives every operation clock-0,
|
|
798
|
+
// which causes the YATA algorithm to produce reversed or scrambled text.
|
|
799
|
+
//
|
|
800
|
+
// Rule: attach container to doc → attach Y.XmlText to container → insert text.
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Insert formatted inline tokens into an already-attached Y.XmlElement.
|
|
804
|
+
* Creates one Y.XmlText per token (attach first, fill second).
|
|
805
|
+
*/
|
|
806
|
+
function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
|
|
807
|
+
const filtered = tokens.filter(t => t.text.length > 0)
|
|
808
|
+
if (!filtered.length) return
|
|
809
|
+
|
|
810
|
+
// Create empty text nodes and attach them to el in one batch
|
|
811
|
+
const xtNodes = filtered.map(() => new Y.XmlText())
|
|
812
|
+
el.insert(0, xtNodes)
|
|
813
|
+
|
|
814
|
+
// Fill text only after attachment — el is already attached to the doc
|
|
815
|
+
filtered.forEach((tok, i) => {
|
|
816
|
+
if (tok.attrs) {
|
|
817
|
+
xtNodes[i]!.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
|
|
818
|
+
} else {
|
|
819
|
+
xtNodes[i]!.insert(0, tok.text)
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function blockElName(b: Block): string {
|
|
825
|
+
switch (b.type) {
|
|
826
|
+
case 'heading': return 'heading'
|
|
827
|
+
case 'paragraph': return 'paragraph'
|
|
828
|
+
case 'bulletList': return 'bulletList'
|
|
829
|
+
case 'orderedList': return 'orderedList'
|
|
830
|
+
case 'taskList': return 'taskList'
|
|
831
|
+
case 'codeBlock': return 'codeBlock'
|
|
832
|
+
case 'blockquote': return 'blockquote'
|
|
833
|
+
case 'table': return 'table'
|
|
834
|
+
case 'hr': return 'horizontalRule'
|
|
835
|
+
case 'callout': return 'callout'
|
|
836
|
+
case 'collapsible': return 'collapsible'
|
|
837
|
+
case 'steps': return 'steps'
|
|
838
|
+
case 'card': return 'card'
|
|
839
|
+
case 'cardGroup': return 'cardGroup'
|
|
840
|
+
case 'codeCollapse': return 'codeCollapse'
|
|
841
|
+
case 'codeGroup': return 'codeGroup'
|
|
842
|
+
case 'codePreview': return 'codePreview'
|
|
843
|
+
case 'codeTree': return 'codeTree'
|
|
844
|
+
case 'accordion': return 'accordion'
|
|
845
|
+
case 'tabs': return 'tabs'
|
|
846
|
+
case 'field': return 'field'
|
|
847
|
+
case 'fieldGroup': return 'fieldGroup'
|
|
848
|
+
case 'image': return 'image'
|
|
849
|
+
case 'docEmbed': return 'docEmbed'
|
|
850
|
+
case 'mathBlock': return 'mathBlock'
|
|
851
|
+
case 'fileBlock': return 'fileBlock'
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function populateListItemChildren(
|
|
856
|
+
itemEl: Y.XmlElement,
|
|
857
|
+
item: ListItemBlock,
|
|
858
|
+
_itemKind: 'listItem' | 'taskItem',
|
|
859
|
+
): void {
|
|
860
|
+
// Always start with a paragraph carrying the item's inline text.
|
|
861
|
+
const paraEl = new Y.XmlElement('paragraph')
|
|
862
|
+
itemEl.insert(itemEl.length, [paraEl])
|
|
863
|
+
fillTextInto(paraEl, parseInline(item.text))
|
|
864
|
+
|
|
865
|
+
// Nested blocks (sub-lists, paragraphs, code, etc.) follow.
|
|
866
|
+
if (!item.innerBlocks?.length) return
|
|
867
|
+
const innerEls = item.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
|
|
868
|
+
itemEl.insert(itemEl.length, innerEls)
|
|
869
|
+
item.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function fillBlock(el: Y.XmlElement, block: Block): void {
|
|
873
|
+
switch (block.type) {
|
|
874
|
+
case 'heading': {
|
|
875
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
876
|
+
el.setAttribute('level', block.level as any)
|
|
877
|
+
fillTextInto(el, parseInline(block.text))
|
|
878
|
+
break
|
|
879
|
+
}
|
|
880
|
+
case 'paragraph': {
|
|
881
|
+
fillTextInto(el, parseInline(block.text))
|
|
882
|
+
break
|
|
883
|
+
}
|
|
884
|
+
case 'bulletList':
|
|
885
|
+
case 'orderedList': {
|
|
886
|
+
const listItemEls = block.items.map(() => new Y.XmlElement('listItem'))
|
|
887
|
+
el.insert(0, listItemEls)
|
|
888
|
+
block.items.forEach((item, i) => {
|
|
889
|
+
populateListItemChildren(listItemEls[i]!, item, 'listItem')
|
|
890
|
+
})
|
|
891
|
+
break
|
|
892
|
+
}
|
|
893
|
+
case 'taskList': {
|
|
894
|
+
const taskItemEls = block.items.map(() => new Y.XmlElement('taskItem'))
|
|
895
|
+
el.insert(0, taskItemEls)
|
|
896
|
+
block.items.forEach((item, i) => {
|
|
897
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
898
|
+
taskItemEls[i]!.setAttribute('checked', !!item.checked as any)
|
|
899
|
+
populateListItemChildren(taskItemEls[i]!, item, 'taskItem')
|
|
900
|
+
})
|
|
901
|
+
break
|
|
902
|
+
}
|
|
903
|
+
case 'codeBlock': {
|
|
904
|
+
if (block.lang) el.setAttribute('language', block.lang)
|
|
905
|
+
const xt = new Y.XmlText()
|
|
906
|
+
el.insert(0, [xt]) // attach xt to el (already attached)
|
|
907
|
+
xt.insert(0, block.code)
|
|
908
|
+
break
|
|
909
|
+
}
|
|
910
|
+
case 'blockquote': {
|
|
911
|
+
const paraEls = block.lines.map(() => new Y.XmlElement('paragraph'))
|
|
912
|
+
el.insert(0, paraEls)
|
|
913
|
+
block.lines.forEach((line, i) => fillTextInto(paraEls[i]!, parseInline(line)))
|
|
914
|
+
break
|
|
915
|
+
}
|
|
916
|
+
case 'table': {
|
|
917
|
+
const headerRowEl = new Y.XmlElement('tableRow')
|
|
918
|
+
const dataRowEls = block.dataRows.map(() => new Y.XmlElement('tableRow'))
|
|
919
|
+
el.insert(0, [headerRowEl, ...dataRowEls])
|
|
920
|
+
|
|
921
|
+
// Header cells
|
|
922
|
+
const headerCellEls = block.headerRow.map(() => new Y.XmlElement('tableHeader'))
|
|
923
|
+
headerRowEl.insert(0, headerCellEls)
|
|
924
|
+
block.headerRow.forEach((cellText, i) => {
|
|
925
|
+
const paraEl = new Y.XmlElement('paragraph')
|
|
926
|
+
headerCellEls[i]!.insert(0, [paraEl])
|
|
927
|
+
fillTextInto(paraEl, parseInline(cellText))
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
// Data rows
|
|
931
|
+
block.dataRows.forEach((row, ri) => {
|
|
932
|
+
const cellEls = row.map(() => new Y.XmlElement('tableCell'))
|
|
933
|
+
dataRowEls[ri]!.insert(0, cellEls)
|
|
934
|
+
row.forEach((cellText, ci) => {
|
|
935
|
+
const paraEl = new Y.XmlElement('paragraph')
|
|
936
|
+
cellEls[ci]!.insert(0, [paraEl])
|
|
937
|
+
fillTextInto(paraEl, parseInline(cellText))
|
|
938
|
+
})
|
|
939
|
+
})
|
|
940
|
+
break
|
|
941
|
+
}
|
|
942
|
+
case 'hr': break // horizontalRule has no content
|
|
943
|
+
case 'callout': {
|
|
944
|
+
el.setAttribute('type', block.calloutType)
|
|
945
|
+
if (!block.innerBlocks.length) {
|
|
946
|
+
const paraEl = new Y.XmlElement('paragraph')
|
|
947
|
+
el.insert(0, [paraEl])
|
|
948
|
+
break
|
|
949
|
+
}
|
|
950
|
+
const innerEls = block.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
|
|
951
|
+
el.insert(0, innerEls)
|
|
952
|
+
block.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
953
|
+
break
|
|
954
|
+
}
|
|
955
|
+
case 'collapsible': {
|
|
956
|
+
el.setAttribute('label', block.label)
|
|
957
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
958
|
+
el.setAttribute('open', block.open as any)
|
|
959
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
960
|
+
const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
961
|
+
el.insert(0, innerEls)
|
|
962
|
+
inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
963
|
+
break
|
|
964
|
+
}
|
|
965
|
+
case 'steps': {
|
|
966
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
967
|
+
const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
968
|
+
el.insert(0, innerEls)
|
|
969
|
+
inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
970
|
+
break
|
|
971
|
+
}
|
|
972
|
+
case 'card': {
|
|
973
|
+
if (block.title) el.setAttribute('title', block.title)
|
|
974
|
+
if (block.icon) el.setAttribute('icon', block.icon)
|
|
975
|
+
if (block.to) el.setAttribute('to', block.to)
|
|
976
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
977
|
+
const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
978
|
+
el.insert(0, innerEls)
|
|
979
|
+
inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
980
|
+
break
|
|
981
|
+
}
|
|
982
|
+
case 'cardGroup': {
|
|
983
|
+
const cardEls = block.cards.map(b => new Y.XmlElement(blockElName(b)))
|
|
984
|
+
el.insert(0, cardEls)
|
|
985
|
+
block.cards.forEach((b, i) => fillBlock(cardEls[i]!, b))
|
|
986
|
+
break
|
|
987
|
+
}
|
|
988
|
+
case 'codeCollapse': {
|
|
989
|
+
const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
|
|
990
|
+
// Only insert the first code block (content model is singular codeBlock)
|
|
991
|
+
const codeEl = new Y.XmlElement('codeBlock')
|
|
992
|
+
el.insert(0, [codeEl])
|
|
993
|
+
fillBlock(codeEl, codes[0]!)
|
|
994
|
+
break
|
|
995
|
+
}
|
|
996
|
+
case 'codeGroup': {
|
|
997
|
+
const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
|
|
998
|
+
const codeEls = codes.map(() => new Y.XmlElement('codeBlock'))
|
|
999
|
+
el.insert(0, codeEls)
|
|
1000
|
+
codes.forEach((b, i) => fillBlock(codeEls[i]!, b))
|
|
1001
|
+
break
|
|
1002
|
+
}
|
|
1003
|
+
case 'codePreview': {
|
|
1004
|
+
const all = [...block.innerBlocks, ...block.codeBlocks]
|
|
1005
|
+
const inner = all.length ? all : [{ type: 'paragraph' as const, text: '' }]
|
|
1006
|
+
const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
1007
|
+
el.insert(0, innerEls)
|
|
1008
|
+
inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
1009
|
+
break
|
|
1010
|
+
}
|
|
1011
|
+
case 'codeTree': {
|
|
1012
|
+
el.setAttribute('files', block.files)
|
|
1013
|
+
break
|
|
1014
|
+
}
|
|
1015
|
+
case 'accordion': {
|
|
1016
|
+
const itemEls = block.items.map(() => new Y.XmlElement('accordionItem'))
|
|
1017
|
+
el.insert(0, itemEls)
|
|
1018
|
+
block.items.forEach((item, i) => {
|
|
1019
|
+
itemEls[i]!.setAttribute('label', item.label)
|
|
1020
|
+
if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
|
|
1021
|
+
const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
1022
|
+
const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
1023
|
+
itemEls[i]!.insert(0, childEls)
|
|
1024
|
+
inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
|
|
1025
|
+
})
|
|
1026
|
+
break
|
|
1027
|
+
}
|
|
1028
|
+
case 'tabs': {
|
|
1029
|
+
const itemEls = block.items.map(() => new Y.XmlElement('tabsItem'))
|
|
1030
|
+
el.insert(0, itemEls)
|
|
1031
|
+
block.items.forEach((item, i) => {
|
|
1032
|
+
itemEls[i]!.setAttribute('label', item.label)
|
|
1033
|
+
if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
|
|
1034
|
+
const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
1035
|
+
const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
1036
|
+
itemEls[i]!.insert(0, childEls)
|
|
1037
|
+
inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
|
|
1038
|
+
})
|
|
1039
|
+
break
|
|
1040
|
+
}
|
|
1041
|
+
case 'field': {
|
|
1042
|
+
if (block.name) el.setAttribute('name', block.name)
|
|
1043
|
+
el.setAttribute('type', block.fieldType)
|
|
1044
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1045
|
+
el.setAttribute('required', block.required as any)
|
|
1046
|
+
const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
|
|
1047
|
+
const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
|
|
1048
|
+
el.insert(0, innerEls)
|
|
1049
|
+
inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
|
|
1050
|
+
break
|
|
1051
|
+
}
|
|
1052
|
+
case 'fieldGroup': {
|
|
1053
|
+
const fieldEls = block.fields.map(b => new Y.XmlElement(blockElName(b)))
|
|
1054
|
+
el.insert(0, fieldEls)
|
|
1055
|
+
block.fields.forEach((b, i) => fillBlock(fieldEls[i]!, b))
|
|
1056
|
+
break
|
|
1057
|
+
}
|
|
1058
|
+
case 'image': {
|
|
1059
|
+
el.setAttribute('src', block.src)
|
|
1060
|
+
if (block.alt) el.setAttribute('alt', block.alt)
|
|
1061
|
+
if (block.width) el.setAttribute('width', block.width)
|
|
1062
|
+
if (block.height) el.setAttribute('height', block.height)
|
|
1063
|
+
break
|
|
1064
|
+
}
|
|
1065
|
+
case 'docEmbed': {
|
|
1066
|
+
el.setAttribute('docId', block.docId)
|
|
1067
|
+
for (const flag of ['collapsed', 'tall', 'seamless']) {
|
|
1068
|
+
if (block.props[flag] === 'true' || block.props[flag] === '1') {
|
|
1069
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1070
|
+
el.setAttribute(flag, true as any)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
break
|
|
1074
|
+
}
|
|
1075
|
+
case 'mathBlock': {
|
|
1076
|
+
el.setAttribute('expression', block.expression)
|
|
1077
|
+
break
|
|
1078
|
+
}
|
|
1079
|
+
case 'fileBlock': {
|
|
1080
|
+
if (block.src) el.setAttribute('src', block.src)
|
|
1081
|
+
if (block.mime) el.setAttribute('mime', block.mime)
|
|
1082
|
+
if (block.uploadId) el.setAttribute('uploadId', block.uploadId)
|
|
1083
|
+
if (block.filename) el.setAttribute('filename', block.filename)
|
|
1084
|
+
break
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Parses markdown text and writes the result into a Y.XmlFragment that
|
|
1093
|
+
* TipTap's Collaboration extension can read.
|
|
1094
|
+
*
|
|
1095
|
+
* Requires `fragment.doc` to be set (i.e. the fragment must already be
|
|
1096
|
+
* obtained from a live Y.Doc via `ydoc.getXmlFragment('default')`).
|
|
1097
|
+
*
|
|
1098
|
+
* @param fragment The target `Y.Doc.getXmlFragment('default')`
|
|
1099
|
+
* @param markdown Raw markdown string
|
|
1100
|
+
* @param fallbackTitle Used as the title when the markdown has no H1
|
|
1101
|
+
*/
|
|
1102
|
+
export function populateYDocFromMarkdown(
|
|
1103
|
+
fragment: Y.XmlFragment,
|
|
1104
|
+
markdown: string,
|
|
1105
|
+
fallbackTitle = 'Untitled'
|
|
1106
|
+
): void {
|
|
1107
|
+
const ydoc = fragment.doc
|
|
1108
|
+
if (!ydoc) {
|
|
1109
|
+
console.warn('[markdownToYjs] fragment has no doc — skipping population')
|
|
1110
|
+
return
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Strip YAML frontmatter from the head of the input so its `---`
|
|
1114
|
+
// fences don't get reinterpreted as horizontal rules.
|
|
1115
|
+
const fm = parseFrontmatter(markdown)
|
|
1116
|
+
const blocks = parseBlocks(fm.body)
|
|
1117
|
+
|
|
1118
|
+
let title = fallbackTitle
|
|
1119
|
+
// The title can live in three places: frontmatter, the first body H1,
|
|
1120
|
+
// or a filename-derived fallback. We track which so the serialiser
|
|
1121
|
+
// can round-trip into the same form. Body H1 wins over frontmatter
|
|
1122
|
+
// when both are present (frontmatter's title is typically a stale
|
|
1123
|
+
// copy of the body H1).
|
|
1124
|
+
let titleSource: 'h1' | 'frontmatter' | undefined
|
|
1125
|
+
if (fm.title !== undefined) {
|
|
1126
|
+
title = fm.title
|
|
1127
|
+
titleSource = 'frontmatter'
|
|
1128
|
+
}
|
|
1129
|
+
let contentBlocks = blocks
|
|
1130
|
+
const h1 = blocks.findIndex(b => b.type === 'heading' && b.level === 1)
|
|
1131
|
+
if (h1 !== -1) {
|
|
1132
|
+
title = (blocks[h1] as { type: 'heading', level: number, text: string }).text
|
|
1133
|
+
contentBlocks = blocks.filter((_, i) => i !== h1)
|
|
1134
|
+
titleSource = 'h1'
|
|
1135
|
+
}
|
|
1136
|
+
// For empty markdown we used to seed an empty paragraph so the
|
|
1137
|
+
// editor had something to focus. That bytes-pads the round-trip
|
|
1138
|
+
// back to "\n\n" — keep contentBlocks empty so the serialiser can
|
|
1139
|
+
// emit a clean empty file.
|
|
1140
|
+
|
|
1141
|
+
ydoc.transact(() => {
|
|
1142
|
+
// ── Step 1: create empty skeleton elements ────────────────────────────
|
|
1143
|
+
const headerEl = new Y.XmlElement('documentHeader')
|
|
1144
|
+
const metaEl = new Y.XmlElement('documentMeta')
|
|
1145
|
+
const bodyEls: Y.XmlElement[] = contentBlocks.map((b) => {
|
|
1146
|
+
switch (b.type) {
|
|
1147
|
+
case 'heading': return new Y.XmlElement('heading')
|
|
1148
|
+
case 'paragraph': return new Y.XmlElement('paragraph')
|
|
1149
|
+
case 'bulletList': return new Y.XmlElement('bulletList')
|
|
1150
|
+
case 'orderedList': return new Y.XmlElement('orderedList')
|
|
1151
|
+
case 'taskList': return new Y.XmlElement('taskList')
|
|
1152
|
+
case 'codeBlock': return new Y.XmlElement('codeBlock')
|
|
1153
|
+
case 'blockquote': return new Y.XmlElement('blockquote')
|
|
1154
|
+
case 'table': return new Y.XmlElement('table')
|
|
1155
|
+
case 'hr': return new Y.XmlElement('horizontalRule')
|
|
1156
|
+
case 'callout': return new Y.XmlElement('callout')
|
|
1157
|
+
case 'collapsible': return new Y.XmlElement('collapsible')
|
|
1158
|
+
case 'steps': return new Y.XmlElement('steps')
|
|
1159
|
+
case 'card': return new Y.XmlElement('card')
|
|
1160
|
+
case 'cardGroup': return new Y.XmlElement('cardGroup')
|
|
1161
|
+
case 'codeCollapse': return new Y.XmlElement('codeCollapse')
|
|
1162
|
+
case 'codeGroup': return new Y.XmlElement('codeGroup')
|
|
1163
|
+
case 'codePreview': return new Y.XmlElement('codePreview')
|
|
1164
|
+
case 'codeTree': return new Y.XmlElement('codeTree')
|
|
1165
|
+
case 'accordion': return new Y.XmlElement('accordion')
|
|
1166
|
+
case 'tabs': return new Y.XmlElement('tabs')
|
|
1167
|
+
case 'field': return new Y.XmlElement('field')
|
|
1168
|
+
case 'fieldGroup': return new Y.XmlElement('fieldGroup')
|
|
1169
|
+
case 'image': return new Y.XmlElement('image')
|
|
1170
|
+
case 'docEmbed': return new Y.XmlElement('docEmbed')
|
|
1171
|
+
case 'mathBlock': return new Y.XmlElement('mathBlock')
|
|
1172
|
+
case 'fileBlock': return new Y.XmlElement('fileBlock')
|
|
1173
|
+
}
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
// ── Step 2: attach ALL skeleton elements to fragment in one shot ──────
|
|
1177
|
+
// After this line everything is connected to ydoc and subsequent ops
|
|
1178
|
+
// use the real doc clock — no more clock-0 scrambling.
|
|
1179
|
+
fragment.insert(0, [headerEl, metaEl, ...bodyEls])
|
|
1180
|
+
|
|
1181
|
+
// ── Step 3: fill header title + record where it came from ─────────────
|
|
1182
|
+
if (titleSource) {
|
|
1183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1184
|
+
headerEl.setAttribute('titleSource', titleSource as any)
|
|
1185
|
+
}
|
|
1186
|
+
const headerXt = new Y.XmlText()
|
|
1187
|
+
headerEl.insert(0, [headerXt])
|
|
1188
|
+
headerXt.insert(0, title)
|
|
1189
|
+
|
|
1190
|
+
// ── Step 4: stash parsed frontmatter meta on documentMeta ─────────────
|
|
1191
|
+
// Each universal-meta key becomes an attribute on documentMeta so
|
|
1192
|
+
// the serialiser can faithfully emit it back to frontmatter. Type
|
|
1193
|
+
// (page-type) is also stashed here under the well-known "type" key.
|
|
1194
|
+
for (const k of Object.keys(fm.meta)) {
|
|
1195
|
+
const v = (fm.meta as Record<string, unknown>)[k]
|
|
1196
|
+
if (v === undefined || v === null) continue
|
|
1197
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1198
|
+
metaEl.setAttribute(k, v as any)
|
|
1199
|
+
}
|
|
1200
|
+
if (fm.type) {
|
|
1201
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1202
|
+
metaEl.setAttribute('type', fm.type as any)
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// ── Step 5: fill body blocks ──────────────────────────────────────────
|
|
1206
|
+
contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block))
|
|
1207
|
+
})
|
|
1208
|
+
}
|