@abraca/mcp 1.0.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.
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Markdown → Y.js converter.
3
+ * Ported from cou-sh/app/utils/markdownToYjs.ts with Vue dependency removed.
4
+ */
5
+ import * as Y from 'yjs'
6
+ import type { PageMeta } from './types.ts'
7
+
8
+ // ── Filename → readable label ────────────────────────────────────────────────
9
+
10
+ export function filenameToLabel(raw: string): string {
11
+ const base = raw.replace(/\.[^.]+$/, '')
12
+ const spaced = base.replace(/([a-z])([A-Z])/g, '$1 $2')
13
+ const clean = spaced.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim()
14
+ return clean.charAt(0).toUpperCase() + clean.slice(1)
15
+ }
16
+
17
+ // ── YAML frontmatter parser ──────────────────────────────────────────────────
18
+
19
+ export interface FrontmatterResult {
20
+ title?: string
21
+ meta: Partial<PageMeta>
22
+ body: string
23
+ }
24
+
25
+ function coerceScalar(raw: string): string | number | boolean {
26
+ const trimmed = raw.trim()
27
+ if (trimmed === 'true') return true
28
+ if (trimmed === 'false') return false
29
+ const num = Number(trimmed)
30
+ if (!Number.isNaN(num) && trimmed !== '') return num
31
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
32
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
33
+ return trimmed.slice(1, -1)
34
+ }
35
+ return trimmed
36
+ }
37
+
38
+ function parseInlineArray(raw: string): string[] {
39
+ return raw.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean)
40
+ }
41
+
42
+ export function parseFrontmatter(markdown: string): FrontmatterResult {
43
+ const noResult: FrontmatterResult = { meta: {}, body: markdown }
44
+
45
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
46
+ if (!match) return noResult
47
+
48
+ const yamlBlock = match[1]!
49
+ const body = markdown.slice(match[0].length)
50
+
51
+ const raw: Record<string, string | string[]> = {}
52
+ const lines = yamlBlock.split('\n')
53
+ let i = 0
54
+ while (i < lines.length) {
55
+ const line = lines[i]!
56
+ const blockSeqKey = line.match(/^(\w[\w-]*):\s*$/)
57
+ if (blockSeqKey && i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1]!)) {
58
+ const key = blockSeqKey[1]!
59
+ const items: string[] = []
60
+ i++
61
+ while (i < lines.length && /^\s+-\s/.test(lines[i]!)) {
62
+ items.push(lines[i]!.replace(/^\s+-\s/, '').trim())
63
+ i++
64
+ }
65
+ raw[key] = items
66
+ continue
67
+ }
68
+ const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)$/)
69
+ if (kvMatch) {
70
+ const key = kvMatch[1]!
71
+ const val = kvMatch[2]!.trim()
72
+ if (val.startsWith('[') && val.endsWith(']')) {
73
+ raw[key] = parseInlineArray(val)
74
+ } else {
75
+ raw[key] = val
76
+ }
77
+ }
78
+ i++
79
+ }
80
+
81
+ const meta: Partial<PageMeta> = {}
82
+
83
+ const getStr = (keys: string[]): string | undefined => {
84
+ for (const k of keys) {
85
+ const v = raw[k]
86
+ if (typeof v === 'string' && v) return v
87
+ }
88
+ }
89
+
90
+ if (raw['tags']) meta.tags = Array.isArray(raw['tags']) ? raw['tags'] : [raw['tags'] as string]
91
+ const color = getStr(['color'])
92
+ if (color) meta.color = color
93
+ const icon = getStr(['icon'])
94
+ if (icon) meta.icon = icon
95
+ const status = getStr(['status'])
96
+ if (status) meta.status = status
97
+
98
+ const priorityRaw = getStr(['priority'])
99
+ if (priorityRaw !== undefined) {
100
+ const map: Record<string, number> = { low: 1, medium: 2, high: 3, urgent: 4 }
101
+ meta.priority = map[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0)
102
+ }
103
+
104
+ const checkedRaw = raw['checked'] ?? raw['done']
105
+ if (checkedRaw !== undefined) meta.checked = checkedRaw === 'true' || checkedRaw === true
106
+
107
+ const dateStart = getStr(['date', 'created'])
108
+ if (dateStart) meta.dateStart = dateStart
109
+ const dateEnd = getStr(['due'])
110
+ if (dateEnd) meta.dateEnd = dateEnd
111
+
112
+ const subtitle = getStr(['description', 'subtitle'])
113
+ if (subtitle) meta.subtitle = subtitle
114
+ const url = getStr(['url'])
115
+ if (url) meta.url = url
116
+
117
+ const ratingRaw = getStr(['rating'])
118
+ if (ratingRaw !== undefined) {
119
+ const n = Number(ratingRaw)
120
+ if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
121
+ }
122
+
123
+ const title = typeof raw['title'] === 'string' ? raw['title'] : undefined
124
+
125
+ return { title, meta, body }
126
+ }
127
+
128
+ // ── Inline token parsing ─────────────────────────────────────────────────────
129
+
130
+ interface InlineToken {
131
+ text: string
132
+ attrs?: Record<string, unknown>
133
+ }
134
+
135
+ function parseInline(text: string): InlineToken[] {
136
+ const stripped = text.replace(/\{lang="[^"]*"\}/g, '')
137
+ .replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, '$2')
138
+ .replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, '')
139
+
140
+ const tokens: InlineToken[] = []
141
+ const re = /:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|!?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g
142
+ let lastIndex = 0
143
+ let match: RegExpExecArray | null
144
+
145
+ while ((match = re.exec(stripped)) !== null) {
146
+ if (match.index > lastIndex) {
147
+ tokens.push({ text: stripped.slice(lastIndex, match.index) })
148
+ }
149
+ if (match[1] !== undefined) {
150
+ const badgeProps = parseMdcProps(match[2])
151
+ tokens.push({ text: match[1] || 'Badge', attrs: { badge: { label: match[1] || 'Badge', color: badgeProps['color'] || 'neutral', variant: badgeProps['variant'] || 'subtle' } } })
152
+ } else if (match[3] !== undefined) {
153
+ const iconProps = parseMdcProps(`{${match[3]}}`)
154
+ tokens.push({ text: '\u200B', attrs: { proseIcon: { name: iconProps['name'] || 'i-lucide-star' } } })
155
+ } else if (match[4] !== undefined) {
156
+ const kbdProps = parseMdcProps(`{${match[4]}}`)
157
+ tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
158
+ } else if (match[5] !== undefined) {
159
+ const displayText = match[6] ?? match[5]
160
+ tokens.push({ text: displayText! })
161
+ } else if (match[7] !== undefined) {
162
+ tokens.push({ text: match[7], attrs: { strike: true } })
163
+ } else if (match[8] !== undefined) {
164
+ tokens.push({ text: match[8], attrs: { bold: true } })
165
+ } else if (match[9] !== undefined) {
166
+ tokens.push({ text: match[9], attrs: { italic: true } })
167
+ } else if (match[10] !== undefined) {
168
+ tokens.push({ text: match[10], attrs: { italic: true } })
169
+ } else if (match[11] !== undefined) {
170
+ tokens.push({ text: match[11], attrs: { code: true } })
171
+ } else if (match[12] !== undefined && match[13] !== undefined) {
172
+ tokens.push({ text: match[12], attrs: { link: { href: match[13] } } })
173
+ }
174
+ lastIndex = match.index + match[0].length
175
+ }
176
+
177
+ if (lastIndex < stripped.length) {
178
+ tokens.push({ text: stripped.slice(lastIndex) })
179
+ }
180
+ return tokens.filter(t => t.text.length > 0)
181
+ }
182
+
183
+ // ── Block-level parser ───────────────────────────────────────────────────────
184
+
185
+ interface TaskItem {
186
+ text: string
187
+ checked: boolean
188
+ }
189
+
190
+ type Block =
191
+ | { type: 'heading'; level: number; text: string }
192
+ | { type: 'paragraph'; text: string }
193
+ | { type: 'bulletList'; items: string[] }
194
+ | { type: 'orderedList'; items: string[] }
195
+ | { type: 'taskList'; items: TaskItem[] }
196
+ | { type: 'codeBlock'; lang: string; code: string }
197
+ | { type: 'blockquote'; lines: string[] }
198
+ | { type: 'table'; headerRow: string[]; dataRows: string[][] }
199
+ | { type: 'hr' }
200
+ | { type: 'callout'; calloutType: string; innerBlocks: Block[] }
201
+ | { type: 'collapsible'; label: string; open: boolean; innerBlocks: Block[] }
202
+ | { type: 'steps'; innerBlocks: Block[] }
203
+ | { type: 'card'; title: string; icon: string; to: string; innerBlocks: Block[] }
204
+ | { type: 'cardGroup'; cards: Block[] }
205
+ | { type: 'codeCollapse'; codeBlocks: Block[] }
206
+ | { type: 'codeGroup'; codeBlocks: Block[] }
207
+ | { type: 'codePreview'; innerBlocks: Block[]; codeBlocks: Block[] }
208
+ | { type: 'codeTree'; files: string }
209
+ | { type: 'accordion'; items: { label: string; icon: string; innerBlocks: Block[] }[] }
210
+ | { type: 'tabs'; items: { label: string; icon: string; innerBlocks: Block[] }[] }
211
+ | { type: 'field'; name: string; fieldType: string; required: boolean; innerBlocks: Block[] }
212
+ | { type: 'fieldGroup'; fields: Block[] }
213
+ | { type: 'image'; src: string; alt: string; width?: string; height?: string }
214
+
215
+ function parseTableRow(line: string): string[] {
216
+ const parts = line.split('|')
217
+ return parts.slice(1, parts.length - 1).map(c => c.trim())
218
+ }
219
+
220
+ function isTableSeparator(line: string): boolean {
221
+ return /^\|[\s|:-]+\|$/.test(line.trim())
222
+ }
223
+
224
+ function extractFencedCode(lines: string[]): Block[] {
225
+ const result: Block[] = []
226
+ let i = 0
227
+ while (i < lines.length) {
228
+ const line = lines[i]!
229
+ const fenceMatch = line.match(/^(`{3,})(\w*)/)
230
+ if (fenceMatch) {
231
+ const fence = fenceMatch[1]!
232
+ const lang = fenceMatch[2] ?? ''
233
+ const codeLines: string[] = []
234
+ i++
235
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
236
+ codeLines.push(lines[i]!)
237
+ i++
238
+ }
239
+ i++
240
+ result.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
241
+ continue
242
+ }
243
+ i++
244
+ }
245
+ return result
246
+ }
247
+
248
+ function parseMdcProps(propsStr: string | undefined): Record<string, string> {
249
+ if (!propsStr) return {}
250
+ const result: Record<string, string> = {}
251
+ const re = /(\w[\w-]*)="([^"]*)"/g
252
+ let m: RegExpExecArray | null
253
+ while ((m = re.exec(propsStr)) !== null) {
254
+ result[m[1]!] = m[2]!
255
+ }
256
+ return result
257
+ }
258
+
259
+ function parseMdcChildren(innerLines: string[], slotPrefix: string): { label: string; icon: string; innerBlocks: Block[] }[] {
260
+ const items: { label: string; icon: string; lines: string[] }[] = []
261
+ let current: { label: string; icon: string; lines: string[] } | null = null
262
+ const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`)
263
+
264
+ for (const line of innerLines) {
265
+ const slotMatch = line.match(slotRe)
266
+ if (slotMatch) {
267
+ if (current) items.push(current)
268
+ const props = parseMdcProps(slotMatch[1])
269
+ current = { label: props['label'] || props['title'] || `Item ${items.length + 1}`, icon: props['icon'] || '', lines: [] }
270
+ continue
271
+ }
272
+ if (current) {
273
+ current.lines.push(line)
274
+ } else {
275
+ if (!items.length && !current) {
276
+ current = { label: `Item 1`, icon: '', lines: [line] }
277
+ }
278
+ }
279
+ }
280
+ if (current) items.push(current)
281
+
282
+ return items.map(item => ({
283
+ label: item.label,
284
+ icon: item.icon,
285
+ innerBlocks: parseBlocks(item.lines.join('\n')),
286
+ }))
287
+ }
288
+
289
+ const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/
290
+
291
+ function parseBlocks(markdown: string): Block[] {
292
+ const rawLines = markdown.split('\n')
293
+ let firstContentLine = 0
294
+ while (firstContentLine < rawLines.length) {
295
+ const l = rawLines[firstContentLine]!
296
+ if (l.trim() === '' || /^import\s/.test(l) || /^export\s/.test(l)) {
297
+ firstContentLine++
298
+ } else {
299
+ break
300
+ }
301
+ }
302
+ const stripped = rawLines.slice(firstContentLine).join('\n')
303
+
304
+ const blocks: Block[] = []
305
+ const lines = stripped.split('\n')
306
+ let i = 0
307
+
308
+ while (i < lines.length) {
309
+ const line = lines[i]!
310
+
311
+ const fenceBlockMatch = line.match(/^(`{3,})(.*)$/)
312
+ if (fenceBlockMatch) {
313
+ const fence = fenceBlockMatch[1]!
314
+ const lang = fenceBlockMatch[2]!.trim()
315
+ .replace(/\{[^}]*\}$/, '')
316
+ .replace(/\s*\[.*\]$/, '')
317
+ .trim()
318
+ const codeLines: string[] = []
319
+ i++
320
+ while (i < lines.length && !lines[i]!.startsWith(fence)) {
321
+ codeLines.push(lines[i]!)
322
+ i++
323
+ }
324
+ i++
325
+ blocks.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
326
+ continue
327
+ }
328
+
329
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/)
330
+ if (headingMatch) {
331
+ blocks.push({ type: 'heading', level: headingMatch[1]!.length, text: headingMatch[2]!.trim() })
332
+ i++
333
+ continue
334
+ }
335
+
336
+ if (/^[-*_]{3,}\s*$/.test(line)) {
337
+ blocks.push({ type: 'hr' })
338
+ i++
339
+ continue
340
+ }
341
+
342
+ const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/)
343
+ if (imgMatch) {
344
+ const alt = imgMatch[1] ?? ''
345
+ const src = imgMatch[2] ?? ''
346
+ const attrs = parseMdcProps(imgMatch[3])
347
+ blocks.push({ type: 'image', src, alt, width: attrs['width'], height: attrs['height'] })
348
+ i++
349
+ continue
350
+ }
351
+
352
+ if (line.startsWith('> ') || line === '>') {
353
+ const bqLines: string[] = []
354
+ while (i < lines.length && (lines[i]!.startsWith('> ') || lines[i] === '>')) {
355
+ bqLines.push(lines[i]!.replace(/^>\s?/, ''))
356
+ i++
357
+ }
358
+ blocks.push({ type: 'blockquote', lines: bqLines })
359
+ continue
360
+ }
361
+
362
+ if (/^\s*\|/.test(line)) {
363
+ const tableLines: string[] = []
364
+ while (i < lines.length && /^\s*\|/.test(lines[i]!)) {
365
+ tableLines.push(lines[i]!)
366
+ i++
367
+ }
368
+ if (tableLines.length >= 2 && isTableSeparator(tableLines[1]!)) {
369
+ const headerRow = parseTableRow(tableLines[0]!)
370
+ const dataRows = tableLines.slice(2)
371
+ .filter(l => !isTableSeparator(l))
372
+ .map(parseTableRow)
373
+ blocks.push({ type: 'table', headerRow, dataRows })
374
+ } else {
375
+ for (const l of tableLines) blocks.push({ type: 'paragraph', text: l })
376
+ }
377
+ continue
378
+ }
379
+
380
+ const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/
381
+ if (MDC_OPEN.test(line)) {
382
+ const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2
383
+ const componentName = line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? ''
384
+ const innerLines: string[] = []
385
+ i++
386
+ while (i < lines.length) {
387
+ const l = lines[i]!
388
+ if (new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)) { i++; break }
389
+ const innerFence = l.match(/^(\s*`{3,})/)
390
+ if (innerFence) {
391
+ const fenceStr = innerFence[1]!.trimStart()
392
+ innerLines.push(l)
393
+ i++
394
+ while (i < lines.length && !lines[i]!.trimStart().startsWith(fenceStr)) {
395
+ innerLines.push(lines[i]!)
396
+ i++
397
+ }
398
+ if (i < lines.length) { innerLines.push(lines[i]!); i++ }
399
+ continue
400
+ }
401
+ innerLines.push(l)
402
+ i++
403
+ }
404
+
405
+ const nonBlank = innerLines.filter(l => l.trim().length > 0)
406
+ if (nonBlank.length) {
407
+ const minIndent = Math.min(...nonBlank.map(l => l.match(/^(\s*)/)?.[1]?.length ?? 0))
408
+ if (minIndent > 0) {
409
+ for (let j = 0; j < innerLines.length; j++) {
410
+ innerLines[j] = innerLines[j]!.slice(Math.min(minIndent, innerLines[j]!.length))
411
+ }
412
+ }
413
+ }
414
+
415
+ let contentStart = 0
416
+ if (innerLines[0]?.trim() === '---') {
417
+ const fmEnd = innerLines.findIndex((l, idx) => idx > 0 && l.trim() === '---')
418
+ if (fmEnd !== -1) contentStart = fmEnd + 1
419
+ }
420
+ const contentLines = innerLines.slice(contentStart)
421
+
422
+ const defaultSlotLines: string[] = []
423
+ const codeSlotLines: string[] = []
424
+ let currentSlot: 'default' | 'code' | 'other' = 'default'
425
+ for (const l of contentLines) {
426
+ if (/^#code\s*$/.test(l)) { currentSlot = 'code'; continue }
427
+ if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) { currentSlot = 'other'; continue }
428
+ if (currentSlot === 'default') defaultSlotLines.push(l)
429
+ else if (currentSlot === 'code') codeSlotLines.push(l)
430
+ }
431
+ const innerBlocks = parseBlocks(defaultSlotLines.join('\n'))
432
+
433
+ const codeBlocks = extractFencedCode(codeSlotLines)
434
+
435
+ const CALLOUT_NAMES = new Set(['tip', 'note', 'info', 'warning', 'caution', 'danger', 'callout', 'alert'])
436
+ if (CALLOUT_NAMES.has(componentName.toLowerCase())) {
437
+ blocks.push({ type: 'callout', calloutType: componentName.toLowerCase(), innerBlocks })
438
+ } else {
439
+ const mdcProps = parseMdcProps(line.match(MDC_OPEN)?.[3])
440
+ const lc = componentName.toLowerCase()
441
+
442
+ if (lc === 'collapsible') {
443
+ blocks.push({ type: 'collapsible', label: mdcProps['label'] || 'Details', open: mdcProps['open'] === 'true', innerBlocks })
444
+ } else if (lc === 'steps') {
445
+ blocks.push({ type: 'steps', innerBlocks })
446
+ } else if (lc === 'card') {
447
+ blocks.push({ type: 'card', title: mdcProps['title'] || '', icon: mdcProps['icon'] || '', to: mdcProps['to'] || '', innerBlocks })
448
+ } else if (lc === 'card-group') {
449
+ const cards = innerBlocks.filter(b => b.type === 'card')
450
+ if (cards.length) {
451
+ blocks.push({ type: 'cardGroup', cards })
452
+ } else {
453
+ blocks.push(...innerBlocks)
454
+ }
455
+ } else if (lc === 'code-collapse') {
456
+ blocks.push({ type: 'codeCollapse', codeBlocks: codeBlocks.length ? codeBlocks : innerBlocks.filter(b => b.type === 'codeBlock') })
457
+ } else if (lc === 'code-group') {
458
+ const allCode = [...innerBlocks.filter(b => b.type === 'codeBlock'), ...codeBlocks]
459
+ blocks.push({ type: 'codeGroup', codeBlocks: allCode })
460
+ } else if (lc === 'code-preview') {
461
+ blocks.push({ type: 'codePreview', innerBlocks, codeBlocks })
462
+ } else if (lc === 'code-tree') {
463
+ blocks.push({ type: 'codeTree', files: mdcProps['files'] || '[]' })
464
+ } else if (lc === 'accordion') {
465
+ const items = parseMdcChildren(contentLines, 'item')
466
+ if (items.length) {
467
+ blocks.push({ type: 'accordion', items })
468
+ } else {
469
+ blocks.push({ type: 'accordion', items: [{ label: 'Item 1', icon: '', innerBlocks }] })
470
+ }
471
+ } else if (lc === 'tabs') {
472
+ const items = parseMdcChildren(contentLines, 'tab')
473
+ if (items.length) {
474
+ blocks.push({ type: 'tabs', items })
475
+ } else {
476
+ blocks.push({ type: 'tabs', items: [{ label: 'Tab 1', icon: '', innerBlocks }] })
477
+ }
478
+ } else if (lc === 'field') {
479
+ blocks.push({ type: 'field', name: mdcProps['name'] || '', fieldType: mdcProps['type'] || 'string', required: mdcProps['required'] === 'true', innerBlocks })
480
+ } else if (lc === 'field-group') {
481
+ const fields = innerBlocks.filter(b => b.type === 'field')
482
+ if (fields.length) {
483
+ blocks.push({ type: 'fieldGroup', fields })
484
+ } else {
485
+ blocks.push(...innerBlocks)
486
+ }
487
+ } else {
488
+ blocks.push(...innerBlocks)
489
+ blocks.push(...codeBlocks)
490
+ }
491
+ }
492
+ continue
493
+ }
494
+
495
+ if (TASK_RE.test(line)) {
496
+ const items: TaskItem[] = []
497
+ while (i < lines.length && TASK_RE.test(lines[i]!)) {
498
+ const m = lines[i]!.match(TASK_RE)!
499
+ items.push({ checked: m[1]!.toLowerCase() === 'x', text: m[2]! })
500
+ i++
501
+ }
502
+ blocks.push({ type: 'taskList', items })
503
+ continue
504
+ }
505
+
506
+ if (/^[-*+]\s+/.test(line)) {
507
+ const items: string[] = []
508
+ while (i < lines.length && /^[-*+]\s+/.test(lines[i]!) && !TASK_RE.test(lines[i]!)) {
509
+ items.push(lines[i]!.replace(/^[-*+]\s+/, ''))
510
+ i++
511
+ }
512
+ if (items.length) {
513
+ blocks.push({ type: 'bulletList', items })
514
+ continue
515
+ }
516
+ }
517
+
518
+ if (/^\d+\.\s+/.test(line)) {
519
+ const items: string[] = []
520
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i]!)) {
521
+ items.push(lines[i]!.replace(/^\d+\.\s+/, ''))
522
+ i++
523
+ }
524
+ blocks.push({ type: 'orderedList', items })
525
+ continue
526
+ }
527
+
528
+ if (line.trim() === '') {
529
+ i++
530
+ continue
531
+ }
532
+
533
+ const paraLines: string[] = []
534
+ while (
535
+ i < lines.length
536
+ && lines[i]!.trim() !== ''
537
+ && !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(lines[i]!)
538
+ ) {
539
+ paraLines.push(lines[i]!)
540
+ i++
541
+ }
542
+ if (paraLines.length) {
543
+ blocks.push({ type: 'paragraph', text: paraLines.join(' ') })
544
+ }
545
+ }
546
+
547
+ return blocks
548
+ }
549
+
550
+ // ── Y.js content population ──────────────────────────────────────────────────
551
+
552
+ function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
553
+ const filtered = tokens.filter(t => t.text.length > 0)
554
+ if (!filtered.length) return
555
+
556
+ const xtNodes = filtered.map(() => new Y.XmlText())
557
+ el.insert(0, xtNodes)
558
+
559
+ filtered.forEach((tok, i) => {
560
+ if (tok.attrs) {
561
+ xtNodes[i]!.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
562
+ } else {
563
+ xtNodes[i]!.insert(0, tok.text)
564
+ }
565
+ })
566
+ }
567
+
568
+ function blockElName(b: Block): string {
569
+ switch (b.type) {
570
+ case 'heading': return 'heading'
571
+ case 'paragraph': return 'paragraph'
572
+ case 'bulletList': return 'bulletList'
573
+ case 'orderedList': return 'orderedList'
574
+ case 'taskList': return 'taskList'
575
+ case 'codeBlock': return 'codeBlock'
576
+ case 'blockquote': return 'blockquote'
577
+ case 'table': return 'table'
578
+ case 'hr': return 'horizontalRule'
579
+ case 'callout': return 'callout'
580
+ case 'collapsible': return 'collapsible'
581
+ case 'steps': return 'steps'
582
+ case 'card': return 'card'
583
+ case 'cardGroup': return 'cardGroup'
584
+ case 'codeCollapse': return 'codeCollapse'
585
+ case 'codeGroup': return 'codeGroup'
586
+ case 'codePreview': return 'codePreview'
587
+ case 'codeTree': return 'codeTree'
588
+ case 'accordion': return 'accordion'
589
+ case 'tabs': return 'tabs'
590
+ case 'field': return 'field'
591
+ case 'fieldGroup': return 'fieldGroup'
592
+ case 'image': return 'image'
593
+ }
594
+ }
595
+
596
+ function fillBlock(el: Y.XmlElement, block: Block): void {
597
+ switch (block.type) {
598
+ case 'heading': {
599
+ el.setAttribute('level', block.level as any)
600
+ fillTextInto(el, parseInline(block.text))
601
+ break
602
+ }
603
+ case 'paragraph': {
604
+ fillTextInto(el, parseInline(block.text))
605
+ break
606
+ }
607
+ case 'bulletList':
608
+ case 'orderedList': {
609
+ const listItemEls = block.items.map(() => new Y.XmlElement('listItem'))
610
+ el.insert(0, listItemEls)
611
+ block.items.forEach((text, i) => {
612
+ const paraEl = new Y.XmlElement('paragraph')
613
+ listItemEls[i]!.insert(0, [paraEl])
614
+ fillTextInto(paraEl, parseInline(text))
615
+ })
616
+ break
617
+ }
618
+ case 'taskList': {
619
+ const taskItemEls = block.items.map(() => new Y.XmlElement('taskItem'))
620
+ el.insert(0, taskItemEls)
621
+ block.items.forEach((item, i) => {
622
+ taskItemEls[i]!.setAttribute('checked', item.checked as any)
623
+ const paraEl = new Y.XmlElement('paragraph')
624
+ taskItemEls[i]!.insert(0, [paraEl])
625
+ fillTextInto(paraEl, parseInline(item.text))
626
+ })
627
+ break
628
+ }
629
+ case 'codeBlock': {
630
+ if (block.lang) el.setAttribute('language', block.lang)
631
+ const xt = new Y.XmlText()
632
+ el.insert(0, [xt])
633
+ xt.insert(0, block.code)
634
+ break
635
+ }
636
+ case 'blockquote': {
637
+ const paraEls = block.lines.map(() => new Y.XmlElement('paragraph'))
638
+ el.insert(0, paraEls)
639
+ block.lines.forEach((line, i) => fillTextInto(paraEls[i]!, parseInline(line)))
640
+ break
641
+ }
642
+ case 'table': {
643
+ const headerRowEl = new Y.XmlElement('tableRow')
644
+ const dataRowEls = block.dataRows.map(() => new Y.XmlElement('tableRow'))
645
+ el.insert(0, [headerRowEl, ...dataRowEls])
646
+
647
+ const headerCellEls = block.headerRow.map(() => new Y.XmlElement('tableHeader'))
648
+ headerRowEl.insert(0, headerCellEls)
649
+ block.headerRow.forEach((cellText, i) => {
650
+ const paraEl = new Y.XmlElement('paragraph')
651
+ headerCellEls[i]!.insert(0, [paraEl])
652
+ fillTextInto(paraEl, parseInline(cellText))
653
+ })
654
+
655
+ block.dataRows.forEach((row, ri) => {
656
+ const cellEls = row.map(() => new Y.XmlElement('tableCell'))
657
+ dataRowEls[ri]!.insert(0, cellEls)
658
+ row.forEach((cellText, ci) => {
659
+ const paraEl = new Y.XmlElement('paragraph')
660
+ cellEls[ci]!.insert(0, [paraEl])
661
+ fillTextInto(paraEl, parseInline(cellText))
662
+ })
663
+ })
664
+ break
665
+ }
666
+ case 'hr': break
667
+ case 'callout': {
668
+ el.setAttribute('type', block.calloutType)
669
+ if (!block.innerBlocks.length) {
670
+ const paraEl = new Y.XmlElement('paragraph')
671
+ el.insert(0, [paraEl])
672
+ break
673
+ }
674
+ const innerEls = block.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
675
+ el.insert(0, innerEls)
676
+ block.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
677
+ break
678
+ }
679
+ case 'collapsible': {
680
+ el.setAttribute('label', block.label)
681
+ el.setAttribute('open', block.open as any)
682
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
683
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
684
+ el.insert(0, innerEls)
685
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
686
+ break
687
+ }
688
+ case 'steps': {
689
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
690
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
691
+ el.insert(0, innerEls)
692
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
693
+ break
694
+ }
695
+ case 'card': {
696
+ if (block.title) el.setAttribute('title', block.title)
697
+ if (block.icon) el.setAttribute('icon', block.icon)
698
+ if (block.to) el.setAttribute('to', block.to)
699
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
700
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
701
+ el.insert(0, innerEls)
702
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
703
+ break
704
+ }
705
+ case 'cardGroup': {
706
+ const cardEls = block.cards.map(b => new Y.XmlElement(blockElName(b)))
707
+ el.insert(0, cardEls)
708
+ block.cards.forEach((b, i) => fillBlock(cardEls[i]!, b))
709
+ break
710
+ }
711
+ case 'codeCollapse': {
712
+ const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
713
+ const codeEl = new Y.XmlElement('codeBlock')
714
+ el.insert(0, [codeEl])
715
+ fillBlock(codeEl, codes[0]!)
716
+ break
717
+ }
718
+ case 'codeGroup': {
719
+ const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
720
+ const codeEls = codes.map(() => new Y.XmlElement('codeBlock'))
721
+ el.insert(0, codeEls)
722
+ codes.forEach((b, i) => fillBlock(codeEls[i]!, b))
723
+ break
724
+ }
725
+ case 'codePreview': {
726
+ const all = [...block.innerBlocks, ...block.codeBlocks]
727
+ const inner = all.length ? all : [{ type: 'paragraph' as const, text: '' }]
728
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
729
+ el.insert(0, innerEls)
730
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
731
+ break
732
+ }
733
+ case 'codeTree': {
734
+ el.setAttribute('files', block.files)
735
+ break
736
+ }
737
+ case 'accordion': {
738
+ const itemEls = block.items.map(() => new Y.XmlElement('accordionItem'))
739
+ el.insert(0, itemEls)
740
+ block.items.forEach((item, i) => {
741
+ itemEls[i]!.setAttribute('label', item.label)
742
+ if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
743
+ const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
744
+ const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
745
+ itemEls[i]!.insert(0, childEls)
746
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
747
+ })
748
+ break
749
+ }
750
+ case 'tabs': {
751
+ const itemEls = block.items.map(() => new Y.XmlElement('tabsItem'))
752
+ el.insert(0, itemEls)
753
+ block.items.forEach((item, i) => {
754
+ itemEls[i]!.setAttribute('label', item.label)
755
+ if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
756
+ const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
757
+ const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
758
+ itemEls[i]!.insert(0, childEls)
759
+ inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
760
+ })
761
+ break
762
+ }
763
+ case 'field': {
764
+ if (block.name) el.setAttribute('name', block.name)
765
+ el.setAttribute('type', block.fieldType)
766
+ el.setAttribute('required', block.required as any)
767
+ const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
768
+ const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
769
+ el.insert(0, innerEls)
770
+ inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
771
+ break
772
+ }
773
+ case 'fieldGroup': {
774
+ const fieldEls = block.fields.map(b => new Y.XmlElement(blockElName(b)))
775
+ el.insert(0, fieldEls)
776
+ block.fields.forEach((b, i) => fillBlock(fieldEls[i]!, b))
777
+ break
778
+ }
779
+ case 'image': {
780
+ el.setAttribute('src', block.src)
781
+ if (block.alt) el.setAttribute('alt', block.alt)
782
+ if (block.width) el.setAttribute('width', block.width)
783
+ if (block.height) el.setAttribute('height', block.height)
784
+ break
785
+ }
786
+ }
787
+ }
788
+
789
+ // ── Public API ───────────────────────────────────────────────────────────────
790
+
791
+ export function populateYDocFromMarkdown(
792
+ fragment: Y.XmlFragment,
793
+ markdown: string,
794
+ fallbackTitle = 'Untitled'
795
+ ): void {
796
+ const ydoc = fragment.doc
797
+ if (!ydoc) {
798
+ console.warn('[markdownToYjs] fragment has no doc — skipping population')
799
+ return
800
+ }
801
+
802
+ const blocks = parseBlocks(markdown)
803
+
804
+ let title = fallbackTitle
805
+ let contentBlocks = blocks
806
+ const h1 = blocks.findIndex(b => b.type === 'heading' && b.level === 1)
807
+ if (h1 !== -1) {
808
+ title = (blocks[h1] as { type: 'heading'; level: number; text: string }).text
809
+ contentBlocks = blocks.filter((_, i) => i !== h1)
810
+ }
811
+ if (!contentBlocks.length) contentBlocks = [{ type: 'paragraph', text: '' }]
812
+
813
+ ydoc.transact(() => {
814
+ const headerEl = new Y.XmlElement('documentHeader')
815
+ const metaEl = new Y.XmlElement('documentMeta')
816
+ const bodyEls: Y.XmlElement[] = contentBlocks.map((b) => {
817
+ switch (b.type) {
818
+ case 'heading': return new Y.XmlElement('heading')
819
+ case 'paragraph': return new Y.XmlElement('paragraph')
820
+ case 'bulletList': return new Y.XmlElement('bulletList')
821
+ case 'orderedList': return new Y.XmlElement('orderedList')
822
+ case 'taskList': return new Y.XmlElement('taskList')
823
+ case 'codeBlock': return new Y.XmlElement('codeBlock')
824
+ case 'blockquote': return new Y.XmlElement('blockquote')
825
+ case 'table': return new Y.XmlElement('table')
826
+ case 'hr': return new Y.XmlElement('horizontalRule')
827
+ case 'callout': return new Y.XmlElement('callout')
828
+ case 'collapsible': return new Y.XmlElement('collapsible')
829
+ case 'steps': return new Y.XmlElement('steps')
830
+ case 'card': return new Y.XmlElement('card')
831
+ case 'cardGroup': return new Y.XmlElement('cardGroup')
832
+ case 'codeCollapse': return new Y.XmlElement('codeCollapse')
833
+ case 'codeGroup': return new Y.XmlElement('codeGroup')
834
+ case 'codePreview': return new Y.XmlElement('codePreview')
835
+ case 'codeTree': return new Y.XmlElement('codeTree')
836
+ case 'accordion': return new Y.XmlElement('accordion')
837
+ case 'tabs': return new Y.XmlElement('tabs')
838
+ case 'field': return new Y.XmlElement('field')
839
+ case 'fieldGroup': return new Y.XmlElement('fieldGroup')
840
+ case 'image': return new Y.XmlElement('image')
841
+ }
842
+ })
843
+
844
+ fragment.insert(0, [headerEl, metaEl, ...bodyEls])
845
+
846
+ const headerXt = new Y.XmlText()
847
+ headerEl.insert(0, [headerXt])
848
+ headerXt.insert(0, title)
849
+
850
+ contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block))
851
+ })
852
+ }