@abraca/mcp 1.9.1 → 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.
@@ -1,932 +1,13 @@
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
- const email = getStr(['email'])
117
- if (email) meta.email = email
118
- const phone = getStr(['phone'])
119
- if (phone) meta.phone = phone
120
-
121
- const ratingRaw = getStr(['rating'])
122
- if (ratingRaw !== undefined) {
123
- const n = Number(ratingRaw)
124
- if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
125
- }
126
-
127
- // Datetime fields
128
- const datetimeStart = getStr(['datetimeStart'])
129
- if (datetimeStart) meta.datetimeStart = datetimeStart
130
- const datetimeEnd = getStr(['datetimeEnd'])
131
- if (datetimeEnd) meta.datetimeEnd = datetimeEnd
132
- const allDayRaw = raw['allDay']
133
- if (allDayRaw !== undefined) meta.allDay = allDayRaw === 'true' || allDayRaw === true
134
-
135
- // Geo fields
136
- const geoLatRaw = getStr(['geoLat'])
137
- if (geoLatRaw !== undefined) { const n = Number(geoLatRaw); if (!Number.isNaN(n)) meta.geoLat = n }
138
- const geoLngRaw = getStr(['geoLng'])
139
- if (geoLngRaw !== undefined) { const n = Number(geoLngRaw); if (!Number.isNaN(n)) meta.geoLng = n }
140
- const geoType = getStr(['geoType'])
141
- if (geoType && (geoType === 'marker' || geoType === 'line' || geoType === 'measure')) {
142
- meta.geoType = geoType
143
- }
144
- const geoDescription = getStr(['geoDescription'])
145
- if (geoDescription) meta.geoDescription = geoDescription
146
-
147
- // Numeric fields
148
- const numberRaw = getStr(['number'])
149
- if (numberRaw !== undefined) { const n = Number(numberRaw); if (!Number.isNaN(n)) meta.number = n }
150
- const unit = getStr(['unit'])
151
- if (unit) meta.unit = unit
152
-
153
- // Note
154
- const note = getStr(['note'])
155
- if (note) meta.note = note
156
-
157
- const title = typeof raw['title'] === 'string' ? raw['title'] : undefined
158
-
159
- return { title, meta, body }
160
- }
161
-
162
- // ── Inline token parsing ─────────────────────────────────────────────────────
163
-
164
- interface InlineToken {
165
- text: string
166
- attrs?: Record<string, unknown>
167
- /** If set, emit an inline Y.XmlElement with this nodeName instead of text+marks. */
168
- node?: string
169
- /** Attributes for the inline node (when node is set). */
170
- nodeAttrs?: Record<string, string>
171
- }
172
-
173
- function parseInline(text: string): InlineToken[] {
174
- const stripped = text.replace(/\{lang="[^"]*"\}/g, '')
175
- .replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, '$2')
176
- .replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, '')
177
-
178
- const tokens: InlineToken[] = []
179
- const re = /:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|!?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g
180
- let lastIndex = 0
181
- let match: RegExpExecArray | null
182
-
183
- while ((match = re.exec(stripped)) !== null) {
184
- if (match.index > lastIndex) {
185
- tokens.push({ text: stripped.slice(lastIndex, match.index) })
186
- }
187
- if (match[1] !== undefined) {
188
- const badgeProps = parseMdcProps(match[2])
189
- tokens.push({ text: match[1] || 'Badge', attrs: { badge: { label: match[1] || 'Badge', color: badgeProps['color'] || 'neutral', variant: badgeProps['variant'] || 'subtle' } } })
190
- } else if (match[3] !== undefined) {
191
- const iconProps = parseMdcProps(`{${match[3]}}`)
192
- tokens.push({ text: '\u200B', attrs: { proseIcon: { name: iconProps['name'] || 'i-lucide-star' } } })
193
- } else if (match[4] !== undefined) {
194
- const kbdProps = parseMdcProps(`{${match[4]}}`)
195
- tokens.push({ text: kbdProps['value'] || '', attrs: { kbd: { value: kbdProps['value'] || '' } } })
196
- } else if (match[5] !== undefined) {
197
- // Inline wikilink [[docId]] or [[docId|label]] → inline docLink node
198
- // (the label is derived from the tree at render time; legacy label is dropped)
199
- tokens.push({ text: '', node: 'docLink', nodeAttrs: { docId: match[5]! } })
200
- } else if (match[7] !== undefined) {
201
- tokens.push({ text: match[7], attrs: { strike: true } })
202
- } else if (match[8] !== undefined) {
203
- tokens.push({ text: match[8], attrs: { bold: true } })
204
- } else if (match[9] !== undefined) {
205
- tokens.push({ text: match[9], attrs: { italic: true } })
206
- } else if (match[10] !== undefined) {
207
- tokens.push({ text: match[10], attrs: { italic: true } })
208
- } else if (match[11] !== undefined) {
209
- tokens.push({ text: match[11], attrs: { code: true } })
210
- } else if (match[12] !== undefined && match[13] !== undefined) {
211
- tokens.push({ text: match[12], attrs: { link: { href: match[13] } } })
212
- }
213
- lastIndex = match.index + match[0].length
214
- }
215
-
216
- if (lastIndex < stripped.length) {
217
- tokens.push({ text: stripped.slice(lastIndex) })
218
- }
219
- return tokens.filter(t => t.node || t.text.length > 0)
220
- }
221
-
222
- // ── Block-level parser ───────────────────────────────────────────────────────
223
-
224
- interface TaskItem {
225
- text: string
226
- checked: boolean
227
- }
228
-
229
- type Block =
230
- | { type: 'heading'; level: number; text: string }
231
- | { type: 'paragraph'; text: string }
232
- | { type: 'bulletList'; items: string[] }
233
- | { type: 'orderedList'; items: string[] }
234
- | { type: 'taskList'; items: TaskItem[] }
235
- | { type: 'codeBlock'; lang: string; code: string }
236
- | { type: 'blockquote'; lines: string[] }
237
- | { type: 'table'; headerRow: string[]; dataRows: string[][] }
238
- | { type: 'hr' }
239
- | { type: 'callout'; calloutType: string; innerBlocks: Block[] }
240
- | { type: 'collapsible'; label: string; open: boolean; innerBlocks: Block[] }
241
- | { type: 'steps'; innerBlocks: Block[] }
242
- | { type: 'card'; title: string; icon: string; to: string; innerBlocks: Block[] }
243
- | { type: 'cardGroup'; cards: Block[] }
244
- | { type: 'codeCollapse'; codeBlocks: Block[] }
245
- | { type: 'codeGroup'; codeBlocks: Block[] }
246
- | { type: 'codePreview'; innerBlocks: Block[]; codeBlocks: Block[] }
247
- | { type: 'codeTree'; files: string }
248
- | { type: 'accordion'; items: { label: string; icon: string; innerBlocks: Block[] }[] }
249
- | { type: 'tabs'; items: { label: string; icon: string; innerBlocks: Block[] }[] }
250
- | { type: 'field'; name: string; fieldType: string; required: boolean; innerBlocks: Block[] }
251
- | { type: 'fieldGroup'; fields: Block[] }
252
- | { type: 'image'; src: string; alt: string; width?: string; height?: string }
253
- | { type: 'docEmbed'; docId: string; seamless?: boolean }
254
- | { type: 'svgEmbed'; svg: string; title: string }
255
-
256
- function parseTableRow(line: string): string[] {
257
- const parts = line.split('|')
258
- return parts.slice(1, parts.length - 1).map(c => c.trim())
259
- }
260
-
261
- function isTableSeparator(line: string): boolean {
262
- return /^\|[\s|:-]+\|$/.test(line.trim())
263
- }
264
-
265
- function extractFencedCode(lines: string[]): Block[] {
266
- const result: Block[] = []
267
- let i = 0
268
- while (i < lines.length) {
269
- const line = lines[i]!
270
- const fenceMatch = line.match(/^(`{3,})(\w*)/)
271
- if (fenceMatch) {
272
- const fence = fenceMatch[1]!
273
- const lang = fenceMatch[2] ?? ''
274
- const codeLines: string[] = []
275
- i++
276
- while (i < lines.length && !lines[i]!.startsWith(fence)) {
277
- codeLines.push(lines[i]!)
278
- i++
279
- }
280
- i++
281
- result.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
282
- continue
283
- }
284
- i++
285
- }
286
- return result
287
- }
288
-
289
- function parseMdcProps(propsStr: string | undefined): Record<string, string> {
290
- if (!propsStr) return {}
291
- const result: Record<string, string> = {}
292
- const re = /(\w[\w-]*)="([^"]*)"/g
293
- let m: RegExpExecArray | null
294
- while ((m = re.exec(propsStr)) !== null) {
295
- result[m[1]!] = m[2]!
296
- }
297
- return result
298
- }
299
-
300
- function parseMdcChildren(innerLines: string[], slotPrefix: string): { label: string; icon: string; innerBlocks: Block[] }[] {
301
- const items: { label: string; icon: string; lines: string[] }[] = []
302
- let current: { label: string; icon: string; lines: string[] } | null = null
303
- const slotRe = new RegExp(`^#${slotPrefix}(\\{[^}]*\\})?\\s*$`)
304
-
305
- for (const line of innerLines) {
306
- const slotMatch = line.match(slotRe)
307
- if (slotMatch) {
308
- if (current) items.push(current)
309
- const props = parseMdcProps(slotMatch[1])
310
- current = { label: props['label'] || props['title'] || `Item ${items.length + 1}`, icon: props['icon'] || '', lines: [] }
311
- continue
312
- }
313
- if (current) {
314
- current.lines.push(line)
315
- } else {
316
- if (!items.length && !current) {
317
- current = { label: `Item 1`, icon: '', lines: [line] }
318
- }
319
- }
320
- }
321
- if (current) items.push(current)
322
-
323
- return items.map(item => ({
324
- label: item.label,
325
- icon: item.icon,
326
- innerBlocks: parseBlocks(item.lines.join('\n')),
327
- }))
328
- }
329
-
330
- const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/
331
-
332
- function parseBlocks(markdown: string): Block[] {
333
- const rawLines = markdown.split('\n')
334
- let firstContentLine = 0
335
- while (firstContentLine < rawLines.length) {
336
- const l = rawLines[firstContentLine]!
337
- if (l.trim() === '' || /^import\s/.test(l) || /^export\s/.test(l)) {
338
- firstContentLine++
339
- } else {
340
- break
341
- }
342
- }
343
- const stripped = rawLines.slice(firstContentLine).join('\n')
344
-
345
- const blocks: Block[] = []
346
- const lines = stripped.split('\n')
347
- let i = 0
348
-
349
- while (i < lines.length) {
350
- const line = lines[i]!
351
-
352
- const fenceBlockMatch = line.match(/^(`{3,})(.*)$/)
353
- if (fenceBlockMatch) {
354
- const fence = fenceBlockMatch[1]!
355
- const lang = fenceBlockMatch[2]!.trim()
356
- .replace(/\{[^}]*\}$/, '')
357
- .replace(/\s*\[.*\]$/, '')
358
- .trim()
359
- const codeLines: string[] = []
360
- i++
361
- while (i < lines.length && !lines[i]!.startsWith(fence)) {
362
- codeLines.push(lines[i]!)
363
- i++
364
- }
365
- i++
366
- if (lang === 'svg' || lang.startsWith('svg ')) {
367
- const svgTitle = lang === 'svg' ? '' : lang.slice(4).trim()
368
- blocks.push({ type: 'svgEmbed', svg: codeLines.join('\n'), title: svgTitle })
369
- } else {
370
- blocks.push({ type: 'codeBlock', lang, code: codeLines.join('\n') })
371
- }
372
- continue
373
- }
374
-
375
- const headingMatch = line.match(/^(#{1,6})\s+(.*)/)
376
- if (headingMatch) {
377
- blocks.push({ type: 'heading', level: headingMatch[1]!.length, text: headingMatch[2]!.trim() })
378
- i++
379
- continue
380
- }
381
-
382
- if (/^[-*_]{3,}\s*$/.test(line)) {
383
- blocks.push({ type: 'hr' })
384
- i++
385
- continue
386
- }
387
-
388
- const docEmbedMatch = line.match(/^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\](\{[^}]*\})?\s*$/)
389
- if (docEmbedMatch) {
390
- const props = parseMdcProps(docEmbedMatch[2])
391
- const seamless = 'seamless' in props || props['seamless'] === 'true' || /\{[^}]*\bseamless\b[^}]*\}/.test(docEmbedMatch[2] ?? '')
392
- blocks.push({ type: 'docEmbed', docId: docEmbedMatch[1]!, seamless: seamless || undefined })
393
- i++
394
- continue
395
- }
396
-
397
- const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)(\{[^}]*\})?\s*$/)
398
- if (imgMatch) {
399
- const alt = imgMatch[1] ?? ''
400
- const src = imgMatch[2] ?? ''
401
- const attrs = parseMdcProps(imgMatch[3])
402
- blocks.push({ type: 'image', src, alt, width: attrs['width'], height: attrs['height'] })
403
- i++
404
- continue
405
- }
406
-
407
- if (line.startsWith('> ') || line === '>') {
408
- const bqLines: string[] = []
409
- while (i < lines.length && (lines[i]!.startsWith('> ') || lines[i] === '>')) {
410
- bqLines.push(lines[i]!.replace(/^>\s?/, ''))
411
- i++
412
- }
413
- blocks.push({ type: 'blockquote', lines: bqLines })
414
- continue
415
- }
416
-
417
- if (/^\s*\|/.test(line)) {
418
- const tableLines: string[] = []
419
- while (i < lines.length && /^\s*\|/.test(lines[i]!)) {
420
- tableLines.push(lines[i]!)
421
- i++
422
- }
423
- if (tableLines.length >= 2 && isTableSeparator(tableLines[1]!)) {
424
- const headerRow = parseTableRow(tableLines[0]!)
425
- const dataRows = tableLines.slice(2)
426
- .filter(l => !isTableSeparator(l))
427
- .map(parseTableRow)
428
- blocks.push({ type: 'table', headerRow, dataRows })
429
- } else {
430
- for (const l of tableLines) blocks.push({ type: 'paragraph', text: l })
431
- }
432
- continue
433
- }
434
-
435
- const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/
436
- if (MDC_OPEN.test(line)) {
437
- const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2
438
- const componentName = line.match(/^\s*:{2,}(\w[\w-]*)/)?.[1] ?? ''
439
- const innerLines: string[] = []
440
- i++
441
- while (i < lines.length) {
442
- const l = lines[i]!
443
- if (new RegExp(`^\\s*:{${colons}}\\s*$`).test(l)) { i++; break }
444
- const innerFence = l.match(/^(\s*`{3,})/)
445
- if (innerFence) {
446
- const fenceStr = innerFence[1]!.trimStart()
447
- innerLines.push(l)
448
- i++
449
- while (i < lines.length && !lines[i]!.trimStart().startsWith(fenceStr)) {
450
- innerLines.push(lines[i]!)
451
- i++
452
- }
453
- if (i < lines.length) { innerLines.push(lines[i]!); i++ }
454
- continue
455
- }
456
- innerLines.push(l)
457
- i++
458
- }
459
-
460
- const nonBlank = innerLines.filter(l => l.trim().length > 0)
461
- if (nonBlank.length) {
462
- const minIndent = Math.min(...nonBlank.map(l => l.match(/^(\s*)/)?.[1]?.length ?? 0))
463
- if (minIndent > 0) {
464
- for (let j = 0; j < innerLines.length; j++) {
465
- innerLines[j] = innerLines[j]!.slice(Math.min(minIndent, innerLines[j]!.length))
466
- }
467
- }
468
- }
469
-
470
- let contentStart = 0
471
- if (innerLines[0]?.trim() === '---') {
472
- const fmEnd = innerLines.findIndex((l, idx) => idx > 0 && l.trim() === '---')
473
- if (fmEnd !== -1) contentStart = fmEnd + 1
474
- }
475
- const contentLines = innerLines.slice(contentStart)
476
-
477
- const defaultSlotLines: string[] = []
478
- const codeSlotLines: string[] = []
479
- let currentSlot: 'default' | 'code' | 'other' = 'default'
480
- for (const l of contentLines) {
481
- if (/^#code\s*$/.test(l)) { currentSlot = 'code'; continue }
482
- if (/^#\w+/.test(l) && !/^#{2,}\s/.test(l)) { currentSlot = 'other'; continue }
483
- if (currentSlot === 'default') defaultSlotLines.push(l)
484
- else if (currentSlot === 'code') codeSlotLines.push(l)
485
- }
486
- const innerBlocks = parseBlocks(defaultSlotLines.join('\n'))
487
-
488
- const codeBlocks = extractFencedCode(codeSlotLines)
489
-
490
- const CALLOUT_NAMES = new Set(['tip', 'note', 'info', 'warning', 'caution', 'danger', 'callout', 'alert'])
491
- if (CALLOUT_NAMES.has(componentName.toLowerCase())) {
492
- blocks.push({ type: 'callout', calloutType: componentName.toLowerCase(), innerBlocks })
493
- } else {
494
- const mdcProps = parseMdcProps(line.match(MDC_OPEN)?.[3])
495
- const lc = componentName.toLowerCase()
496
-
497
- if (lc === 'collapsible') {
498
- blocks.push({ type: 'collapsible', label: mdcProps['label'] || 'Details', open: mdcProps['open'] === 'true', innerBlocks })
499
- } else if (lc === 'steps') {
500
- blocks.push({ type: 'steps', innerBlocks })
501
- } else if (lc === 'card') {
502
- blocks.push({ type: 'card', title: mdcProps['title'] || '', icon: mdcProps['icon'] || '', to: mdcProps['to'] || '', innerBlocks })
503
- } else if (lc === 'card-group') {
504
- const cards = innerBlocks.filter(b => b.type === 'card')
505
- if (cards.length) {
506
- blocks.push({ type: 'cardGroup', cards })
507
- } else {
508
- blocks.push(...innerBlocks)
509
- }
510
- } else if (lc === 'code-collapse') {
511
- blocks.push({ type: 'codeCollapse', codeBlocks: codeBlocks.length ? codeBlocks : innerBlocks.filter(b => b.type === 'codeBlock') })
512
- } else if (lc === 'code-group') {
513
- const allCode = [...innerBlocks.filter(b => b.type === 'codeBlock'), ...codeBlocks]
514
- blocks.push({ type: 'codeGroup', codeBlocks: allCode })
515
- } else if (lc === 'code-preview') {
516
- blocks.push({ type: 'codePreview', innerBlocks, codeBlocks })
517
- } else if (lc === 'code-tree') {
518
- blocks.push({ type: 'codeTree', files: mdcProps['files'] || '[]' })
519
- } else if (lc === 'accordion') {
520
- const items = parseMdcChildren(contentLines, 'item')
521
- if (items.length) {
522
- blocks.push({ type: 'accordion', items })
523
- } else {
524
- blocks.push({ type: 'accordion', items: [{ label: 'Item 1', icon: '', innerBlocks }] })
525
- }
526
- } else if (lc === 'tabs') {
527
- const items = parseMdcChildren(contentLines, 'tab')
528
- if (items.length) {
529
- blocks.push({ type: 'tabs', items })
530
- } else {
531
- blocks.push({ type: 'tabs', items: [{ label: 'Tab 1', icon: '', innerBlocks }] })
532
- }
533
- } else if (lc === 'field') {
534
- blocks.push({ type: 'field', name: mdcProps['name'] || '', fieldType: mdcProps['type'] || 'string', required: mdcProps['required'] === 'true', innerBlocks })
535
- } else if (lc === 'field-group') {
536
- const fields = innerBlocks.filter(b => b.type === 'field')
537
- if (fields.length) {
538
- blocks.push({ type: 'fieldGroup', fields })
539
- } else {
540
- blocks.push(...innerBlocks)
541
- }
542
- } else {
543
- blocks.push(...innerBlocks)
544
- blocks.push(...codeBlocks)
545
- }
546
- }
547
- continue
548
- }
549
-
550
- if (TASK_RE.test(line)) {
551
- const items: TaskItem[] = []
552
- while (i < lines.length && TASK_RE.test(lines[i]!)) {
553
- const m = lines[i]!.match(TASK_RE)!
554
- items.push({ checked: m[1]!.toLowerCase() === 'x', text: m[2]! })
555
- i++
556
- }
557
- blocks.push({ type: 'taskList', items })
558
- continue
559
- }
560
-
561
- if (/^[-*+]\s+/.test(line)) {
562
- const items: string[] = []
563
- while (i < lines.length && /^[-*+]\s+/.test(lines[i]!) && !TASK_RE.test(lines[i]!)) {
564
- items.push(lines[i]!.replace(/^[-*+]\s+/, ''))
565
- i++
566
- }
567
- if (items.length) {
568
- blocks.push({ type: 'bulletList', items })
569
- continue
570
- }
571
- }
572
-
573
- if (/^\d+\.\s+/.test(line)) {
574
- const items: string[] = []
575
- while (i < lines.length && /^\d+\.\s+/.test(lines[i]!)) {
576
- items.push(lines[i]!.replace(/^\d+\.\s+/, ''))
577
- i++
578
- }
579
- blocks.push({ type: 'orderedList', items })
580
- continue
581
- }
582
-
583
- if (line.trim() === '') {
584
- i++
585
- continue
586
- }
587
-
588
- const paraLines: string[] = []
589
- while (
590
- i < lines.length
591
- && lines[i]!.trim() !== ''
592
- && !/^(#{1,6}\s|[-*+]\s|\d+\.\s|>|`{3,}|\s*\||[-*_]{3,}\s*$|\s*:{2,}\w)/.test(lines[i]!)
593
- ) {
594
- paraLines.push(lines[i]!)
595
- i++
596
- }
597
- if (paraLines.length) {
598
- blocks.push({ type: 'paragraph', text: paraLines.join(' ') })
599
- }
600
- }
601
-
602
- return blocks
603
- }
604
-
605
- // ── Y.js content population ──────────────────────────────────────────────────
606
-
607
- function fillTextInto(el: Y.XmlElement, tokens: InlineToken[]): void {
608
- const filtered = tokens.filter(t => t.node || t.text.length > 0)
609
- if (!filtered.length) return
610
-
611
- const children: (Y.XmlText | Y.XmlElement)[] = filtered.map((tok) => {
612
- if (tok.node) {
613
- const xe = new Y.XmlElement(tok.node)
614
- if (tok.nodeAttrs) {
615
- for (const [k, v] of Object.entries(tok.nodeAttrs)) xe.setAttribute(k, v)
616
- }
617
- return xe
618
- }
619
- return new Y.XmlText()
620
- })
621
- el.insert(0, children)
622
-
623
- filtered.forEach((tok, i) => {
624
- if (tok.node) return
625
- const xt = children[i] as Y.XmlText
626
- if (tok.attrs) {
627
- xt.insert(0, tok.text, tok.attrs as Record<string, boolean | object>)
628
- } else {
629
- xt.insert(0, tok.text)
630
- }
631
- })
632
- }
633
-
634
- function blockElName(b: Block): string {
635
- switch (b.type) {
636
- case 'heading': return 'heading'
637
- case 'paragraph': return 'paragraph'
638
- case 'bulletList': return 'bulletList'
639
- case 'orderedList': return 'orderedList'
640
- case 'taskList': return 'taskList'
641
- case 'codeBlock': return 'codeBlock'
642
- case 'blockquote': return 'blockquote'
643
- case 'table': return 'table'
644
- case 'hr': return 'horizontalRule'
645
- case 'callout': return 'callout'
646
- case 'collapsible': return 'collapsible'
647
- case 'steps': return 'steps'
648
- case 'card': return 'card'
649
- case 'cardGroup': return 'cardGroup'
650
- case 'codeCollapse': return 'codeCollapse'
651
- case 'codeGroup': return 'codeGroup'
652
- case 'codePreview': return 'codePreview'
653
- case 'codeTree': return 'codeTree'
654
- case 'accordion': return 'accordion'
655
- case 'tabs': return 'tabs'
656
- case 'field': return 'field'
657
- case 'fieldGroup': return 'fieldGroup'
658
- case 'image': return 'image'
659
- case 'docEmbed': return 'docEmbed'
660
- case 'svgEmbed': return 'svgEmbed'
661
- }
662
- }
663
-
664
- function fillBlock(el: Y.XmlElement, block: Block): void {
665
- switch (block.type) {
666
- case 'heading': {
667
- el.setAttribute('level', block.level as any)
668
- fillTextInto(el, parseInline(block.text))
669
- break
670
- }
671
- case 'paragraph': {
672
- fillTextInto(el, parseInline(block.text))
673
- break
674
- }
675
- case 'bulletList':
676
- case 'orderedList': {
677
- const listItemEls = block.items.map(() => new Y.XmlElement('listItem'))
678
- el.insert(0, listItemEls)
679
- block.items.forEach((text, i) => {
680
- const paraEl = new Y.XmlElement('paragraph')
681
- listItemEls[i]!.insert(0, [paraEl])
682
- fillTextInto(paraEl, parseInline(text))
683
- })
684
- break
685
- }
686
- case 'taskList': {
687
- const taskItemEls = block.items.map(() => new Y.XmlElement('taskItem'))
688
- el.insert(0, taskItemEls)
689
- block.items.forEach((item, i) => {
690
- taskItemEls[i]!.setAttribute('checked', item.checked as any)
691
- const paraEl = new Y.XmlElement('paragraph')
692
- taskItemEls[i]!.insert(0, [paraEl])
693
- fillTextInto(paraEl, parseInline(item.text))
694
- })
695
- break
696
- }
697
- case 'codeBlock': {
698
- if (block.lang) el.setAttribute('language', block.lang)
699
- const xt = new Y.XmlText()
700
- el.insert(0, [xt])
701
- xt.insert(0, block.code)
702
- break
703
- }
704
- case 'blockquote': {
705
- const paraEls = block.lines.map(() => new Y.XmlElement('paragraph'))
706
- el.insert(0, paraEls)
707
- block.lines.forEach((line, i) => fillTextInto(paraEls[i]!, parseInline(line)))
708
- break
709
- }
710
- case 'table': {
711
- const headerRowEl = new Y.XmlElement('tableRow')
712
- const dataRowEls = block.dataRows.map(() => new Y.XmlElement('tableRow'))
713
- el.insert(0, [headerRowEl, ...dataRowEls])
714
-
715
- const headerCellEls = block.headerRow.map(() => new Y.XmlElement('tableHeader'))
716
- headerRowEl.insert(0, headerCellEls)
717
- block.headerRow.forEach((cellText, i) => {
718
- const paraEl = new Y.XmlElement('paragraph')
719
- headerCellEls[i]!.insert(0, [paraEl])
720
- fillTextInto(paraEl, parseInline(cellText))
721
- })
722
-
723
- block.dataRows.forEach((row, ri) => {
724
- const cellEls = row.map(() => new Y.XmlElement('tableCell'))
725
- dataRowEls[ri]!.insert(0, cellEls)
726
- row.forEach((cellText, ci) => {
727
- const paraEl = new Y.XmlElement('paragraph')
728
- cellEls[ci]!.insert(0, [paraEl])
729
- fillTextInto(paraEl, parseInline(cellText))
730
- })
731
- })
732
- break
733
- }
734
- case 'hr': break
735
- case 'callout': {
736
- el.setAttribute('type', block.calloutType)
737
- if (!block.innerBlocks.length) {
738
- const paraEl = new Y.XmlElement('paragraph')
739
- el.insert(0, [paraEl])
740
- break
741
- }
742
- const innerEls = block.innerBlocks.map(b => new Y.XmlElement(blockElName(b)))
743
- el.insert(0, innerEls)
744
- block.innerBlocks.forEach((b, i) => fillBlock(innerEls[i]!, b))
745
- break
746
- }
747
- case 'collapsible': {
748
- el.setAttribute('label', block.label)
749
- el.setAttribute('open', block.open as any)
750
- const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
751
- const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
752
- el.insert(0, innerEls)
753
- inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
754
- break
755
- }
756
- case 'steps': {
757
- const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
758
- const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
759
- el.insert(0, innerEls)
760
- inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
761
- break
762
- }
763
- case 'card': {
764
- if (block.title) el.setAttribute('title', block.title)
765
- if (block.icon) el.setAttribute('icon', block.icon)
766
- if (block.to) el.setAttribute('to', block.to)
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 'cardGroup': {
774
- const cardEls = block.cards.map(b => new Y.XmlElement(blockElName(b)))
775
- el.insert(0, cardEls)
776
- block.cards.forEach((b, i) => fillBlock(cardEls[i]!, b))
777
- break
778
- }
779
- case 'codeCollapse': {
780
- const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
781
- const codeEl = new Y.XmlElement('codeBlock')
782
- el.insert(0, [codeEl])
783
- fillBlock(codeEl, codes[0]!)
784
- break
785
- }
786
- case 'codeGroup': {
787
- const codes = block.codeBlocks.length ? block.codeBlocks : [{ type: 'codeBlock' as const, lang: '', code: '' }]
788
- const codeEls = codes.map(() => new Y.XmlElement('codeBlock'))
789
- el.insert(0, codeEls)
790
- codes.forEach((b, i) => fillBlock(codeEls[i]!, b))
791
- break
792
- }
793
- case 'codePreview': {
794
- const all = [...block.innerBlocks, ...block.codeBlocks]
795
- const inner = all.length ? all : [{ type: 'paragraph' as const, text: '' }]
796
- const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
797
- el.insert(0, innerEls)
798
- inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
799
- break
800
- }
801
- case 'codeTree': {
802
- el.setAttribute('files', block.files)
803
- break
804
- }
805
- case 'accordion': {
806
- const itemEls = block.items.map(() => new Y.XmlElement('accordionItem'))
807
- el.insert(0, itemEls)
808
- block.items.forEach((item, i) => {
809
- itemEls[i]!.setAttribute('label', item.label)
810
- if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
811
- const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
812
- const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
813
- itemEls[i]!.insert(0, childEls)
814
- inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
815
- })
816
- break
817
- }
818
- case 'tabs': {
819
- const itemEls = block.items.map(() => new Y.XmlElement('tabsItem'))
820
- el.insert(0, itemEls)
821
- block.items.forEach((item, i) => {
822
- itemEls[i]!.setAttribute('label', item.label)
823
- if (item.icon) itemEls[i]!.setAttribute('icon', item.icon)
824
- const inner = item.innerBlocks.length ? item.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
825
- const childEls = inner.map(b => new Y.XmlElement(blockElName(b)))
826
- itemEls[i]!.insert(0, childEls)
827
- inner.forEach((b, ci) => fillBlock(childEls[ci]!, b))
828
- })
829
- break
830
- }
831
- case 'field': {
832
- if (block.name) el.setAttribute('name', block.name)
833
- el.setAttribute('type', block.fieldType)
834
- el.setAttribute('required', block.required as any)
835
- const inner = block.innerBlocks.length ? block.innerBlocks : [{ type: 'paragraph' as const, text: '' }]
836
- const innerEls = inner.map(b => new Y.XmlElement(blockElName(b)))
837
- el.insert(0, innerEls)
838
- inner.forEach((b, i) => fillBlock(innerEls[i]!, b))
839
- break
840
- }
841
- case 'fieldGroup': {
842
- const fieldEls = block.fields.map(b => new Y.XmlElement(blockElName(b)))
843
- el.insert(0, fieldEls)
844
- block.fields.forEach((b, i) => fillBlock(fieldEls[i]!, b))
845
- break
846
- }
847
- case 'image': {
848
- el.setAttribute('src', block.src)
849
- if (block.alt) el.setAttribute('alt', block.alt)
850
- if (block.width) el.setAttribute('width', block.width)
851
- if (block.height) el.setAttribute('height', block.height)
852
- break
853
- }
854
- case 'docEmbed': {
855
- el.setAttribute('docId', block.docId)
856
- if (block.seamless) el.setAttribute('seamless', 'true')
857
- break
858
- }
859
- case 'svgEmbed': {
860
- el.setAttribute('svg', block.svg)
861
- if (block.title) el.setAttribute('title', block.title)
862
- break
863
- }
864
- }
865
- }
866
-
867
- // ── Public API ───────────────────────────────────────────────────────────────
868
-
869
- export function populateYDocFromMarkdown(
870
- fragment: Y.XmlFragment,
871
- markdown: string,
872
- fallbackTitle = 'Untitled'
873
- ): void {
874
- const ydoc = fragment.doc
875
- if (!ydoc) {
876
- console.warn('[markdownToYjs] fragment has no doc — skipping population')
877
- return
878
- }
879
-
880
- const blocks = parseBlocks(markdown)
881
-
882
- let title = fallbackTitle
883
- let contentBlocks = blocks
884
- const h1 = blocks.findIndex(b => b.type === 'heading' && b.level === 1)
885
- if (h1 !== -1) {
886
- title = (blocks[h1] as { type: 'heading'; level: number; text: string }).text
887
- contentBlocks = blocks.filter((_, i) => i !== h1)
888
- }
889
- if (!contentBlocks.length) contentBlocks = [{ type: 'paragraph', text: '' }]
890
-
891
- ydoc.transact(() => {
892
- const headerEl = new Y.XmlElement('documentHeader')
893
- const metaEl = new Y.XmlElement('documentMeta')
894
- const bodyEls: Y.XmlElement[] = contentBlocks.map((b) => {
895
- switch (b.type) {
896
- case 'heading': return new Y.XmlElement('heading')
897
- case 'paragraph': return new Y.XmlElement('paragraph')
898
- case 'bulletList': return new Y.XmlElement('bulletList')
899
- case 'orderedList': return new Y.XmlElement('orderedList')
900
- case 'taskList': return new Y.XmlElement('taskList')
901
- case 'codeBlock': return new Y.XmlElement('codeBlock')
902
- case 'blockquote': return new Y.XmlElement('blockquote')
903
- case 'table': return new Y.XmlElement('table')
904
- case 'hr': return new Y.XmlElement('horizontalRule')
905
- case 'callout': return new Y.XmlElement('callout')
906
- case 'collapsible': return new Y.XmlElement('collapsible')
907
- case 'steps': return new Y.XmlElement('steps')
908
- case 'card': return new Y.XmlElement('card')
909
- case 'cardGroup': return new Y.XmlElement('cardGroup')
910
- case 'codeCollapse': return new Y.XmlElement('codeCollapse')
911
- case 'codeGroup': return new Y.XmlElement('codeGroup')
912
- case 'codePreview': return new Y.XmlElement('codePreview')
913
- case 'codeTree': return new Y.XmlElement('codeTree')
914
- case 'accordion': return new Y.XmlElement('accordion')
915
- case 'tabs': return new Y.XmlElement('tabs')
916
- case 'field': return new Y.XmlElement('field')
917
- case 'fieldGroup': return new Y.XmlElement('fieldGroup')
918
- case 'image': return new Y.XmlElement('image')
919
- case 'docEmbed': return new Y.XmlElement('docEmbed')
920
- case 'svgEmbed': return new Y.XmlElement('svgEmbed')
921
- }
922
- })
923
-
924
- fragment.insert(0, [headerEl, metaEl, ...bodyEls])
925
-
926
- const headerXt = new Y.XmlText()
927
- headerEl.insert(0, [headerXt])
928
- headerXt.insert(0, title)
929
-
930
- contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block))
931
- })
932
- }
1
+ // Thin re-export shim — markdown → Yjs conversion now lives in
2
+ // @abraca/convert (extracted from cou-sh + abracadabra-nuxt + this
3
+ // package). Existing imports inside the MCP server keep working
4
+ // through this re-export.
5
+ //
6
+ // New code should import from @abraca/convert directly.
7
+
8
+ export {
9
+ filenameToLabel,
10
+ parseFrontmatter,
11
+ populateYDocFromMarkdown,
12
+ type FrontmatterResult,
13
+ } from '@abraca/convert'