@troshab/slidev-theme-troshab 0.1.4 → 0.1.7

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,759 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-component-docs.mjs — Sync component API docs from `.vue` source
4
+ *
5
+ * Reads `components/*.vue` entry stubs, extracts:
6
+ * 1. JSDoc header comment before `defineProps` (component description + prop @params)
7
+ * 2. `defineProps<{...}>()` / `withDefaults(defineProps<{...}>(), {...})` — props
8
+ * 3. `<slot name="X" />` usages in template + `useSlots()` refs — slot list
9
+ *
10
+ * Updates the matching `.md` file in the visualise-content skill (by default,
11
+ * `~/.claude/plugins/marketplaces/troshab-own-claude-code/plugins/troshab-own/skills/
12
+ * slidev-visualise-content/components/{Name}.md`) between auto-markers:
13
+ *
14
+ * <!-- BEGIN AUTO PROPS --> …generated Props table… <!-- END AUTO PROPS -->
15
+ * <!-- BEGIN AUTO SLOTS --> …generated Slots table… <!-- END AUTO SLOTS -->
16
+ * <!-- SCHEMA …JSON… -->
17
+ *
18
+ * Human-authored sections (## Usage, ## Notes, ## Examples in theme) are never
19
+ * touched. Missing .md → skeleton is created. Orphan .md (no matching .vue) →
20
+ * warning printed.
21
+ *
22
+ * Flags:
23
+ * --theme-dir DIR (default ~/slidev-theme-troshab)
24
+ * --skill-dir DIR (default visualise-content components folder)
25
+ * --check-only Exit 1 if any .md would change; print diff summary.
26
+ * --only NAMES Comma-separated component names (e.g. Stepper,Callout).
27
+ * Handy when debugging a single component.
28
+ * --no-write Print intended changes to stdout; don't touch files.
29
+ *
30
+ * Exit codes:
31
+ * 0 — all docs in sync (or write succeeded)
32
+ * 1 — drift detected with --check-only, or orphan/missing component
33
+ * 2 — fatal parsing error
34
+ */
35
+
36
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'
37
+ import { join, resolve, dirname, basename } from 'node:path'
38
+ import { homedir } from 'node:os'
39
+ import { fileURLToPath } from 'node:url'
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url))
42
+
43
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
44
+
45
+ const argv = process.argv.slice(2)
46
+ function flag(name) { return argv.includes(name) }
47
+ function opt(name, fallback) {
48
+ const i = argv.indexOf(name)
49
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : fallback
50
+ }
51
+ function expandTilde(p) { return p.startsWith('~') ? join(homedir(), p.slice(1)) : p }
52
+
53
+ const THEME_DIR = resolve(expandTilde(opt('--theme-dir', join(__dirname, '..'))))
54
+ const DEFAULT_SKILL_DIR = join(
55
+ homedir(),
56
+ '.claude', 'plugins', 'marketplaces', 'troshab-own-claude-code',
57
+ 'plugins', 'troshab-own', 'skills', 'slidev-visualise-content', 'components',
58
+ )
59
+ const SKILL_DIR = resolve(expandTilde(opt('--skill-dir', DEFAULT_SKILL_DIR)))
60
+ const CHECK_ONLY = flag('--check-only')
61
+ const NO_WRITE = flag('--no-write')
62
+ const ONLY = opt('--only', null)
63
+ const ONLY_SET = ONLY ? new Set(ONLY.split(',').map(s => s.trim())) : null
64
+
65
+ // ─── .vue parser ──────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Extract the `<script setup lang="ts">` or plain `<script>` block, the template,
69
+ * and any top-of-file HTML comment (acts like JSDoc on entry-stub files).
70
+ * Returns { scriptSrc, templateSrc, headerComment } or null.
71
+ */
72
+ function splitSfc(src) {
73
+ const scriptSetupMatch = src.match(/<script\s+setup(?:\s+[^>]*)?>([\s\S]*?)<\/script>/)
74
+ const plainScriptMatch = src.match(/<script(?:\s+[^>]*)?>([\s\S]*?)<\/script>/)
75
+ const scriptSrc = scriptSetupMatch ? scriptSetupMatch[1] : (plainScriptMatch ? plainScriptMatch[1] : '')
76
+ // Greedy + last `</template>` — avoids truncation at inner `<template v-if>`.
77
+ const templateMatch = src.match(/<template(?:\s[^>]*)?>([\s\S]*)<\/template>/)
78
+ const headerMatch = src.match(/^\s*<!--([\s\S]*?)-->/)
79
+ if (!scriptSrc && !headerMatch) return null
80
+ return {
81
+ scriptSrc,
82
+ templateSrc: templateMatch ? templateMatch[1] : '',
83
+ headerComment: headerMatch ? headerMatch[1] : '',
84
+ isSetup: !!scriptSetupMatch,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * If the entry-stub does `import X from '../components_base/X.vue'; export default X`,
90
+ * return the resolved path to the real implementation .vue. Otherwise null.
91
+ */
92
+ function resolveReExport(scriptSrc, currentFile) {
93
+ const importMatch = scriptSrc.match(/import\s+\w+\s+from\s+['"]([^'"]+\.vue)['"]/)
94
+ if (!importMatch) return null
95
+ const exportMatch = scriptSrc.match(/export\s+default\s+\w+/)
96
+ if (!exportMatch) return null
97
+ const rel = importMatch[1]
98
+ return resolve(dirname(currentFile), rel)
99
+ }
100
+
101
+ /**
102
+ * Extract the JSDoc header block right before the first `defineProps` or
103
+ * `withDefaults(defineProps`. Returns { description, paramDocs } where paramDocs
104
+ * maps propName → description string.
105
+ *
106
+ * If `fallbackHtmlComment` is provided (from an entry-stub <!-- … -->), it's
107
+ * parsed with the same Props:/Slots:/Usage: rules.
108
+ */
109
+ function extractHeaderDoc(scriptSrc, fallbackHtmlComment) {
110
+ const defineIdx = scriptSrc.search(/(?:withDefaults\s*\(\s*)?defineProps\s*</)
111
+
112
+ let raw = ''
113
+ if (defineIdx >= 0) {
114
+ // Look backward for /** … */ ending just before defineProps.
115
+ const before = scriptSrc.slice(0, defineIdx)
116
+ const jsdocMatch = before.match(/\/\*\*[\s\S]*?\*\/(?=\s*(?:const\s+\w+\s*=\s*)?(?:withDefaults\s*\(\s*)?defineProps\s*<|\s*$)/)
117
+ const allMatches = [...before.matchAll(/\/\*\*[\s\S]*?\*\//g)]
118
+ raw = jsdocMatch ? jsdocMatch[0] : (allMatches.length ? allMatches[allMatches.length - 1][0] : '')
119
+ }
120
+ if (!raw && fallbackHtmlComment) {
121
+ // Treat the HTML comment as a JSDoc body (lines already look like " * foo"
122
+ // but may also just be plain lines — normalise accordingly).
123
+ raw = `/**\n${fallbackHtmlComment}\n*/`
124
+ }
125
+ if (!raw) return { description: '', paramDocs: {} }
126
+
127
+ // Clean leading " * " and trailing "*/".
128
+ const lines = raw
129
+ .replace(/^\s*\/\*\*\s*/, '')
130
+ .replace(/\*\/\s*$/, '')
131
+ .split('\n')
132
+ .map(l => l.replace(/^\s*\*\s?/, '').trimEnd())
133
+
134
+ const paramDocs = {}
135
+ const descLines = []
136
+ let inDescription = true
137
+
138
+ for (const line of lines) {
139
+ const paramMatch = line.match(/^@param\s+\{?[^}]*\}?\s*(\w+)\s*[-—]?\s*(.*)$/)
140
+ if (paramMatch) {
141
+ inDescription = false
142
+ paramDocs[paramMatch[1]] = paramMatch[2].trim()
143
+ continue
144
+ }
145
+ // Some JSDocs list props inline like "value: number — Target number".
146
+ const inlineParamMatch = line.match(/^\s*(\w+):\s*[^—\-]+[—\-]\s+(.+)$/)
147
+ if (inlineParamMatch && !inDescription) {
148
+ paramDocs[inlineParamMatch[1]] = inlineParamMatch[2].trim()
149
+ continue
150
+ }
151
+ // "Props:" section header switches into prop-list mode.
152
+ if (/^\s*Props:\s*$/i.test(line)) { inDescription = false; continue }
153
+ if (/^\s*Slots?:\s*$/i.test(line)) { inDescription = false; continue }
154
+ if (/^\s*Usage:?\s*$/i.test(line)) { inDescription = false; continue }
155
+ // Stop accumulating once we see a usage example, a code block, or a blank
156
+ // followed by an HTML-tag line — otherwise multi-line descriptions get
157
+ // concatenated with embedded `<Component …>` examples.
158
+ if (inDescription) {
159
+ const trimmed = line.trim()
160
+ if (!trimmed) { if (descLines.length) inDescription = false; continue }
161
+ if (/^</.test(trimmed)) { inDescription = false; continue }
162
+ descLines.push(trimmed)
163
+ }
164
+ }
165
+
166
+ // Pass 2 for "Props:" block style ("value: number — description").
167
+ const propsSectionMatch = raw.match(/Props:\s*\n([\s\S]*?)(?:\n\s*(?:Slots?|Usage|Notes?|Example)s?:|\*\/)/i)
168
+ if (propsSectionMatch) {
169
+ for (const line of propsSectionMatch[1].split('\n')) {
170
+ const m = line.replace(/^\s*\*\s?/, '').match(/^\s*(\w+)[?!:]?\s*(?:[:\-]\s*)?[^—\-]*[—\-]\s+(.+)$/)
171
+ if (m && !paramDocs[m[1]]) paramDocs[m[1]] = m[2].trim()
172
+ }
173
+ }
174
+
175
+ return {
176
+ description: descLines.join(' ').replace(/\s+/g, ' ').trim(),
177
+ paramDocs,
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Find defineProps<{...}>() or withDefaults(defineProps<{...}>(), {...}).
183
+ * Returns { propsBlock: string, defaultsBlock: string | null }.
184
+ */
185
+ function extractPropsBlocks(scriptSrc) {
186
+ // Locate `defineProps<`.
187
+ const propsStart = scriptSrc.indexOf('defineProps<')
188
+ if (propsStart < 0) return null
189
+
190
+ // Balanced `<...>` extraction starting at the `<` after defineProps.
191
+ const openAngle = scriptSrc.indexOf('<', propsStart)
192
+ if (openAngle < 0) return null
193
+ let depth = 0
194
+ let typeStart = openAngle + 1
195
+ let typeEnd = -1
196
+ for (let i = openAngle; i < scriptSrc.length; i++) {
197
+ const c = scriptSrc[i]
198
+ if (c === '<') depth++
199
+ else if (c === '>') {
200
+ depth--
201
+ if (depth === 0) { typeEnd = i; break }
202
+ }
203
+ }
204
+ if (typeEnd < 0) return null
205
+ const typeLiteral = scriptSrc.slice(typeStart, typeEnd).trim()
206
+
207
+ // Check if wrapped in withDefaults(..., {...}).
208
+ const withDefaultsIdx = scriptSrc.lastIndexOf('withDefaults', propsStart)
209
+ let defaultsBlock = null
210
+ if (withDefaultsIdx >= 0 && withDefaultsIdx < propsStart) {
211
+ // Locate the comma after defineProps<...>() and the { that starts defaults.
212
+ const afterClose = scriptSrc.indexOf(')', typeEnd)
213
+ if (afterClose > 0) {
214
+ const commaIdx = scriptSrc.indexOf(',', afterClose)
215
+ if (commaIdx > 0) {
216
+ const defBraceStart = scriptSrc.indexOf('{', commaIdx)
217
+ if (defBraceStart > 0) {
218
+ let bDepth = 0
219
+ let defEnd = -1
220
+ for (let i = defBraceStart; i < scriptSrc.length; i++) {
221
+ const c = scriptSrc[i]
222
+ if (c === '{') bDepth++
223
+ else if (c === '}') {
224
+ bDepth--
225
+ if (bDepth === 0) { defEnd = i; break }
226
+ }
227
+ }
228
+ if (defEnd > 0) defaultsBlock = scriptSrc.slice(defBraceStart + 1, defEnd).trim()
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ return { propsBlock: typeLiteral, defaultsBlock }
235
+ }
236
+
237
+ /**
238
+ * Parse `propName?: type` entries from the props-type literal. Handles union
239
+ * types (a|b|c), generics, nested arrays.
240
+ * Returns [{ name, type, required }].
241
+ */
242
+ function parsePropsBlock(block) {
243
+ // Strip single-line "// …" and multi-line "/* … */" comments.
244
+ let stripped = block
245
+ .replace(/\/\*[\s\S]*?\*\//g, '')
246
+ .replace(/(^|\s)\/\/[^\n]*/g, (_, p) => p)
247
+ .trim()
248
+
249
+ // Remove outer `{...}` if present — defineProps<{ ... }>() passes the literal
250
+ // as `{ type?: '...'; title?: string }`; we need to walk inside the object.
251
+ if (stripped.startsWith('{') && stripped.endsWith('}')) {
252
+ stripped = stripped.slice(1, -1)
253
+ }
254
+
255
+ const entries = []
256
+ let depth = 0
257
+ let current = ''
258
+ for (let i = 0; i < stripped.length; i++) {
259
+ const c = stripped[i]
260
+ if (c === '[' || c === '{' || c === '(') depth++
261
+ else if (c === ']' || c === '}' || c === ')') depth--
262
+ // `<` / `>` are tricky: `<T>` increases/decreases depth, but `|`/`>` in
263
+ // union/comparison contexts would too. For our case the prop-type literal
264
+ // usually stays balanced with the existing depth tracking.
265
+ if (c === '<' && /\w/.test(stripped[i - 1] || '')) depth++
266
+ else if (c === '>' && depth > 0 && stripped[i + 1] !== '=' && stripped[i - 1] !== '=') depth--
267
+ if ((c === ',' || c === ';' || c === '\n') && depth === 0) {
268
+ const piece = current.trim()
269
+ if (piece) entries.push(piece)
270
+ current = ''
271
+ continue
272
+ }
273
+ current += c
274
+ }
275
+ if (current.trim()) entries.push(current.trim())
276
+
277
+ const props = []
278
+ for (const entry of entries) {
279
+ // propName?: type
280
+ const m = entry.match(/^(\w+)(\??)\s*:\s*([\s\S]+)$/)
281
+ if (!m) continue
282
+ const [, name, optional, rawType] = m
283
+ props.push({ name, type: rawType.trim(), required: optional !== '?' })
284
+ }
285
+ return props
286
+ }
287
+
288
+ /**
289
+ * Parse defaults block. Returns propName → default-value (as raw string).
290
+ */
291
+ function parseDefaultsBlock(block) {
292
+ if (!block) return {}
293
+ const out = {}
294
+ let depth = 0
295
+ let current = ''
296
+ for (let i = 0; i < block.length; i++) {
297
+ const c = block[i]
298
+ if (c === '<' || c === '[' || c === '{' || c === '(') depth++
299
+ else if (c === '>' || c === ']' || c === '}' || c === ')') depth--
300
+ if ((c === ',' || c === '\n') && depth === 0) {
301
+ addDefault(out, current)
302
+ current = ''
303
+ continue
304
+ }
305
+ current += c
306
+ }
307
+ addDefault(out, current)
308
+ return out
309
+ }
310
+ function addDefault(out, piece) {
311
+ const trimmed = piece.trim().replace(/^\/\/.*$/gm, '').trim()
312
+ if (!trimmed) return
313
+ const m = trimmed.match(/^(\w+)\s*:\s*([\s\S]+?)$/)
314
+ if (!m) return
315
+ out[m[1]] = m[2].trim()
316
+ }
317
+
318
+ /**
319
+ * Slots: grep static `<slot name="X" />` + dynamic `<slot :name="`step${i}`" />` +
320
+ * `slots.XXX` / `slots[`step${i}`]` refs. Dynamic names are emitted in their
321
+ * raw template form (e.g. "step${i}") so collapseSlots() can render them.
322
+ * Returns sorted array of unique slot names.
323
+ */
324
+ function extractSlots(scriptSrc, templateSrc) {
325
+ const slots = new Set()
326
+ const staticSlotRe = /<slot(?:\s+name=["']([\w-]+)["'])?[\s\S]*?(?:\/>|>)/g
327
+ let m
328
+ while ((m = staticSlotRe.exec(templateSrc))) {
329
+ slots.add(m[1] || 'default')
330
+ }
331
+
332
+ // Dynamic: <slot :name="`step${...}`" /> or <slot :name="'step' + idx" />.
333
+ const dynSlotRe = /<slot\s+:name=["']\s*`?([^`"'$]+)(?:\$\{[^}]+\}|[+\s]+[\w.]+)/g
334
+ while ((m = dynSlotRe.exec(templateSrc))) {
335
+ slots.add(`${m[1].trim()}N`)
336
+ }
337
+
338
+ // slots[`stepN`] / slots.stepN / $slots.stepN access patterns in script.
339
+ const accessRe = /(?:\$?slots)[\s]*\[[\s]*`([^`${]+)(?:\$\{[^}]+\})?`[\s]*\]/g
340
+ while ((m = accessRe.exec(scriptSrc))) {
341
+ slots.add(`${m[1].trim()}N`)
342
+ }
343
+ const dotRe = /(?:\$?slots)\.(\w+)/g
344
+ while ((m = dotRe.exec(scriptSrc))) slots.add(m[1])
345
+
346
+ const noise = new Set(['name', 'value', 'length', 'forEach', 'map'])
347
+ return [...slots].filter(s => !noise.has(s)).sort()
348
+ }
349
+
350
+ // ─── Collapse long slot lists (e.g. step1..stepN) ────────────────────────────
351
+
352
+ function collapseSlots(slots) {
353
+ // Group patterns like step1, step2, step3 → "step1..step3".
354
+ const groups = new Map()
355
+ const singles = []
356
+ for (const s of slots) {
357
+ const m = s.match(/^([a-zA-Z]+)(\d+)$/)
358
+ if (!m) { singles.push(s); continue }
359
+ const [, prefix, num] = m
360
+ const arr = groups.get(prefix) || []
361
+ arr.push(parseInt(num, 10))
362
+ groups.set(prefix, arr)
363
+ }
364
+ const out = [...singles]
365
+ for (const [prefix, nums] of groups) {
366
+ nums.sort((a, b) => a - b)
367
+ if (nums.length === 1) out.push(`${prefix}${nums[0]}`)
368
+ else out.push(`${prefix}${nums[0]}..${prefix}${nums[nums.length - 1]}`)
369
+ }
370
+ return out.sort()
371
+ }
372
+
373
+ // ─── Markdown table renderers ────────────────────────────────────────────────
374
+
375
+ function renderPropsTable(props, paramDocs, defaults) {
376
+ if (!props.length) return '_No props._'
377
+ const rows = props.map(p => {
378
+ const def = defaults[p.name] ?? (p.required ? '—' : '—')
379
+ const desc = (paramDocs[p.name] || '').replace(/\|/g, '\\|')
380
+ return `| \`${p.name}\` | \`${p.type.replace(/\|/g, '\\|')}\` | \`${def.replace(/\|/g, '\\|')}\` | ${p.required ? 'yes' : 'no'} | ${desc} |`
381
+ })
382
+ return [
383
+ '| Prop | Type | Default | Required | Description |',
384
+ '|------|------|---------|----------|-------------|',
385
+ ...rows,
386
+ ].join('\n')
387
+ }
388
+
389
+ /**
390
+ * Scan example_slides/*.md for usages of `<ComponentName`. Returns sorted
391
+ * array of { file, heading } tuples. `file` is relative to example_slides/.
392
+ */
393
+ function findExamplesInTheme(themeDir, componentName) {
394
+ const dir = join(themeDir, 'example_slides')
395
+ if (!existsSync(dir)) return []
396
+ const out = []
397
+ for (const f of readdirSync(dir).sort()) {
398
+ if (!f.endsWith('.md')) continue
399
+ let src
400
+ try { src = readFileSync(join(dir, f), 'utf8') } catch { continue }
401
+ // Component tag: `<ComponentName` followed by whitespace, `/`, `>`, or end.
402
+ // Must not be inside a code fence — example_slides occasionally ship
403
+ // snippets showing wrong usage, but in practice the files are ~10-20 lines
404
+ // and any occurrence is a demo. So simple match is good enough.
405
+ const re = new RegExp('<' + componentName + '[\\s/>]', 'm')
406
+ if (!re.test(src)) continue
407
+ const headingMatch = src.match(/^#\s+(.+?)\s*$/m)
408
+ out.push({ file: f, heading: headingMatch ? headingMatch[1] : '(no heading)' })
409
+ }
410
+ return out
411
+ }
412
+
413
+ function renderExamplesTable(examples) {
414
+ if (!examples.length) return '_No theme example slides reference this component yet._'
415
+ const rows = examples.map(e => `| [\`${e.file}\`](@../../../../slidev-theme-troshab/example_slides/${e.file}) | ${e.heading.replace(/\|/g, '\\|')} |`)
416
+ return [
417
+ '| File | Heading |',
418
+ '|------|---------|',
419
+ ...rows,
420
+ ].join('\n')
421
+ }
422
+
423
+ function renderSlotsTable(slots, slotDescs = {}) {
424
+ if (!slots.length) return '_No slots._'
425
+ const rows = slots.map(s => {
426
+ const desc = (slotDescs[s] || '').replace(/\|/g, '\\|')
427
+ return `| \`${s}\` | ${desc} |`
428
+ })
429
+ return [
430
+ '| Slot | Description |',
431
+ '|------|-------------|',
432
+ ...rows,
433
+ ].join('\n')
434
+ }
435
+
436
+ function renderSchema(name, props, defaults, slots) {
437
+ const obj = {
438
+ name,
439
+ props: Object.fromEntries(props.map(p => {
440
+ const item = { type: p.type, required: p.required }
441
+ // Detect pure literal-union enum: `'a' | 'b' | 'c'` (no mixed `string` etc.).
442
+ const literalMatches = [...p.type.matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1])
443
+ const residue = p.type.replace(/['"][^'"]+['"]/g, '').replace(/\s*\|\s*/g, '').trim()
444
+ if (literalMatches.length > 0 && residue === '') {
445
+ item.enum = literalMatches
446
+ }
447
+ if (defaults[p.name] !== undefined) item.default = defaults[p.name]
448
+ return [p.name, item]
449
+ })),
450
+ slots,
451
+ }
452
+ return JSON.stringify(obj, null, 2)
453
+ }
454
+
455
+ // ─── .md updating ────────────────────────────────────────────────────────────
456
+
457
+ const MARKERS = {
458
+ propsBegin: '<!-- BEGIN AUTO PROPS -->',
459
+ propsEnd: '<!-- END AUTO PROPS -->',
460
+ slotsBegin: '<!-- BEGIN AUTO SLOTS -->',
461
+ slotsEnd: '<!-- END AUTO SLOTS -->',
462
+ examplesBegin: '<!-- BEGIN AUTO EXAMPLES -->',
463
+ examplesEnd: '<!-- END AUTO EXAMPLES -->',
464
+ schemaBegin: '<!-- SCHEMA',
465
+ schemaEnd: '-->',
466
+ }
467
+
468
+ function replaceBetween(src, begin, end, replacement) {
469
+ const s = src.indexOf(begin)
470
+ const e = src.indexOf(end, s + begin.length)
471
+ if (s < 0 || e < 0) return null
472
+ return src.slice(0, s + begin.length) + '\n' + replacement + '\n' + src.slice(e)
473
+ }
474
+
475
+ function upsertSchema(src, jsonBody) {
476
+ const block = `${MARKERS.schemaBegin}\n${jsonBody}\n${MARKERS.schemaEnd}`
477
+ const idx = src.indexOf(MARKERS.schemaBegin)
478
+ if (idx < 0) return src.trimEnd() + '\n\n' + block + '\n'
479
+ // Replace from schemaBegin up to the next `-->` line.
480
+ const afterBegin = src.indexOf('\n', idx)
481
+ const endIdx = src.indexOf(MARKERS.schemaEnd, afterBegin)
482
+ if (endIdx < 0) return src.trimEnd() + '\n\n' + block + '\n'
483
+ const trail = src.slice(endIdx + MARKERS.schemaEnd.length)
484
+ return src.slice(0, idx) + block + trail
485
+ }
486
+
487
+ function generateSkeleton(name, description, propsTable, slotsTable, examplesTable, schema) {
488
+ return `# ${name}
489
+ ${description ? `\n${description}\n` : ''}
490
+ ## Props
491
+
492
+ ${MARKERS.propsBegin}
493
+ ${propsTable}
494
+ ${MARKERS.propsEnd}
495
+
496
+ ## Slots
497
+
498
+ ${MARKERS.slotsBegin}
499
+ ${slotsTable}
500
+ ${MARKERS.slotsEnd}
501
+
502
+ ## Usage
503
+
504
+ _TODO: add representative usage example(s)._
505
+
506
+ ## Notes
507
+
508
+ _TODO: add constraints, common mistakes, layout tips._
509
+
510
+ ## Examples in theme
511
+
512
+ ${MARKERS.examplesBegin}
513
+ ${examplesTable}
514
+ ${MARKERS.examplesEnd}
515
+
516
+ ${MARKERS.schemaBegin}
517
+ ${schema}
518
+ ${MARKERS.schemaEnd}
519
+ `
520
+ }
521
+
522
+ /**
523
+ * Pull `| propName | type | default | ... | Description |` tables out of an
524
+ * existing `.md` so we can preserve the hand-written Description column even
525
+ * when the schema otherwise gets regenerated.
526
+ * Returns { propName → description }. Empty if no table found.
527
+ */
528
+ function readExistingPropDescriptions(md, heading) {
529
+ const headingIdx = md.search(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))
530
+ if (headingIdx < 0) return {}
531
+ const lines = md.slice(headingIdx).split('\n')
532
+ const out = {}
533
+ // Find the first header row: `| Prop | ... | Description |`
534
+ for (let i = 0; i < lines.length; i++) {
535
+ const row = lines[i]
536
+ if (!/^\|.*Description\s*\|?$/i.test(row) && !/^\|.*Description\s*\|/i.test(row)) continue
537
+ // Header row found at i; data rows start at i+2 (i+1 is `|---|---|` separator).
538
+ for (let j = i + 2; j < lines.length; j++) {
539
+ const r = lines[j].trim()
540
+ if (!r.startsWith('|')) break
541
+ const cells = r.split('|').map(c => c.trim()).filter(c => c.length > 0 || true)
542
+ // Leading and trailing | create empty cells; strip them.
543
+ const inner = r.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
544
+ if (inner.length < 2) continue
545
+ const propName = inner[0].replace(/^`|`$/g, '').trim()
546
+ const desc = inner[inner.length - 1]
547
+ if (propName && desc) out[propName] = desc
548
+ }
549
+ break
550
+ }
551
+ return out
552
+ }
553
+
554
+ function readExistingSlotDescriptions(md) {
555
+ const headingIdx = md.search(/^##\s+Slots\s*$/m)
556
+ if (headingIdx < 0) return {}
557
+ const lines = md.slice(headingIdx).split('\n')
558
+ const out = {}
559
+ for (let i = 0; i < lines.length; i++) {
560
+ if (!/^\|\s*Slot\s*\|/i.test(lines[i])) continue
561
+ for (let j = i + 2; j < lines.length; j++) {
562
+ const r = lines[j].trim()
563
+ if (!r.startsWith('|')) break
564
+ const inner = r.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
565
+ if (inner.length < 2) continue
566
+ const slotName = inner[0].replace(/^`|`$/g, '').trim()
567
+ const desc = inner[inner.length - 1]
568
+ if (slotName && desc) out[slotName] = desc
569
+ }
570
+ break
571
+ }
572
+ return out
573
+ }
574
+
575
+ function updateMd(currentMd, name, description, props, paramDocs, defaults, slots, slotDescs, examples, schema) {
576
+ const mergedParamDocs = { ...paramDocs }
577
+ const mergedSlotDescs = { ...slotDescs }
578
+ if (currentMd) {
579
+ const prev = readExistingPropDescriptions(currentMd, 'Props')
580
+ for (const [k, v] of Object.entries(prev)) {
581
+ if (!mergedParamDocs[k] || mergedParamDocs[k].length < v.length) mergedParamDocs[k] = v
582
+ }
583
+ const prevSlots = readExistingSlotDescriptions(currentMd)
584
+ for (const [k, v] of Object.entries(prevSlots)) {
585
+ if (!mergedSlotDescs[k] || mergedSlotDescs[k].length < v.length) mergedSlotDescs[k] = v
586
+ }
587
+ }
588
+
589
+ const propsTable = renderPropsTable(props, mergedParamDocs, defaults)
590
+ const slotsTable = renderSlotsTable(slots, mergedSlotDescs)
591
+ const examplesTable = renderExamplesTable(examples)
592
+
593
+ if (!currentMd) return generateSkeleton(name, description, propsTable, slotsTable, examplesTable, schema)
594
+
595
+ let out = currentMd
596
+ out = upsertTableBlock(out, 'Props', MARKERS.propsBegin, MARKERS.propsEnd, propsTable)
597
+ out = upsertTableBlock(out, 'Slots', MARKERS.slotsBegin, MARKERS.slotsEnd, slotsTable)
598
+ out = upsertTableBlock(out, 'Examples in theme', MARKERS.examplesBegin, MARKERS.examplesEnd, examplesTable)
599
+ out = upsertSchema(out, schema)
600
+ return out
601
+ }
602
+
603
+ /**
604
+ * Replace an existing `## Heading` table (|-rows) with a managed BEGIN/END block
605
+ * that contains the freshly-rendered table. Leaves non-table prose inside the
606
+ * section untouched (e.g. Stepper's `StepperItem: { … }` note lives on).
607
+ *
608
+ * If the markers already exist — simple replace-between.
609
+ * If the heading exists but no markers — find the first consecutive |-row run
610
+ * and wrap it with markers, replacing just those lines.
611
+ * If the heading is missing — append a new section after the previous managed
612
+ * block (or at EOF).
613
+ */
614
+ function upsertTableBlock(md, heading, beginMarker, endMarker, table) {
615
+ // Fast path: markers exist.
616
+ const replaced = replaceBetween(md, beginMarker, endMarker, table)
617
+ if (replaced !== null) return replaced
618
+
619
+ const headingRe = new RegExp(`^##\\s+${heading}\\s*$`, 'm')
620
+ const headingMatch = md.match(headingRe)
621
+ if (headingMatch) {
622
+ const headingIdx = md.indexOf(headingMatch[0])
623
+ const afterHeading = md.indexOf('\n', headingIdx) + 1
624
+ const rest = md.slice(afterHeading)
625
+ const lines = rest.split('\n')
626
+ // Find start of the table (first line starting with `|`).
627
+ let tableStart = -1
628
+ let tableEnd = -1
629
+ for (let i = 0; i < lines.length; i++) {
630
+ const trimmed = lines[i].trim()
631
+ if (trimmed.startsWith('##')) break
632
+ if (tableStart < 0 && trimmed.startsWith('|')) tableStart = i
633
+ if (tableStart >= 0 && !trimmed.startsWith('|') && tableEnd < 0) {
634
+ tableEnd = i
635
+ break
636
+ }
637
+ }
638
+ if (tableStart >= 0) {
639
+ if (tableEnd < 0) tableEnd = lines.length
640
+ const before = lines.slice(0, tableStart).join('\n')
641
+ const after = lines.slice(tableEnd).join('\n')
642
+ const block = `${beginMarker}\n${table}\n${endMarker}`
643
+ const rebuilt = [before.trimEnd(), block, after.trimStart()].filter(Boolean).join('\n\n')
644
+ return md.slice(0, afterHeading) + rebuilt
645
+ }
646
+ // Heading exists but no table — inject block right after heading line.
647
+ const block = `\n${beginMarker}\n${table}\n${endMarker}\n`
648
+ return md.slice(0, afterHeading) + block + rest
649
+ }
650
+
651
+ // No heading — append at EOF (before any schema comment) as a new section.
652
+ const schemaIdx = md.indexOf(MARKERS.schemaBegin)
653
+ const newSection = `\n## ${heading}\n\n${beginMarker}\n${table}\n${endMarker}\n`
654
+ if (schemaIdx < 0) return md.trimEnd() + '\n' + newSection
655
+ return md.slice(0, schemaIdx).trimEnd() + '\n' + newSection + '\n' + md.slice(schemaIdx)
656
+ }
657
+
658
+ // ─── Orchestrate ─────────────────────────────────────────────────────────────
659
+
660
+ function listVueComponents() {
661
+ const dir = join(THEME_DIR, 'components')
662
+ if (!existsSync(dir)) { console.error(`[gen-docs] missing ${dir}`); process.exit(2) }
663
+ return readdirSync(dir).filter(f => f.endsWith('.vue')).map(f => ({
664
+ name: basename(f, '.vue'),
665
+ path: join(dir, f),
666
+ }))
667
+ }
668
+
669
+ function listMdDocs() {
670
+ if (!existsSync(SKILL_DIR)) { mkdirSync(SKILL_DIR, { recursive: true }) }
671
+ return readdirSync(SKILL_DIR).filter(f => f.endsWith('.md'))
672
+ }
673
+
674
+ function diffSummary(oldSrc, newSrc) {
675
+ if (oldSrc === newSrc) return null
676
+ const oldLines = (oldSrc || '').split('\n').length
677
+ const newLines = newSrc.split('\n').length
678
+ return `${oldLines} → ${newLines} lines`
679
+ }
680
+
681
+ function main() {
682
+ const components = listVueComponents()
683
+ const existingMds = new Set(listMdDocs())
684
+ let changed = 0
685
+ let errors = 0
686
+ const orphans = []
687
+
688
+ for (const { name, path } of components) {
689
+ if (ONLY_SET && !ONLY_SET.has(name)) continue
690
+ const vueSrc = readFileSync(path, 'utf8')
691
+ const parts = splitSfc(vueSrc)
692
+ if (!parts) {
693
+ console.error(`[gen-docs] ${name}: could not parse SFC`)
694
+ errors++
695
+ continue
696
+ }
697
+
698
+ // Parse props from this file; if missing and it's a re-export, follow the
699
+ // import to the real implementation (components_base/X.vue).
700
+ let propsBlocks = extractPropsBlocks(parts.scriptSrc)
701
+ let slotsScriptSrc = parts.scriptSrc
702
+ let slotsTemplateSrc = parts.templateSrc
703
+ if (!propsBlocks) {
704
+ const reExport = resolveReExport(parts.scriptSrc, path)
705
+ if (reExport && existsSync(reExport)) {
706
+ const baseSrc = readFileSync(reExport, 'utf8')
707
+ const baseParts = splitSfc(baseSrc)
708
+ if (baseParts) {
709
+ propsBlocks = extractPropsBlocks(baseParts.scriptSrc)
710
+ slotsScriptSrc = baseParts.scriptSrc
711
+ slotsTemplateSrc = baseParts.templateSrc
712
+ }
713
+ }
714
+ }
715
+
716
+ const { description, paramDocs } = extractHeaderDoc(parts.scriptSrc, parts.headerComment)
717
+ const props = propsBlocks ? parsePropsBlock(propsBlocks.propsBlock) : []
718
+ const defaults = propsBlocks ? parseDefaultsBlock(propsBlocks.defaultsBlock) : {}
719
+ const slotsRaw = extractSlots(slotsScriptSrc, slotsTemplateSrc)
720
+ const slots = collapseSlots(slotsRaw)
721
+ const examples = findExamplesInTheme(THEME_DIR, name)
722
+
723
+ const schema = renderSchema(name, props, defaults, slots)
724
+
725
+ const mdPath = join(SKILL_DIR, `${name}.md`)
726
+ const existingMd = existsSync(mdPath) ? readFileSync(mdPath, 'utf8') : ''
727
+ existingMds.delete(`${name}.md`)
728
+
729
+ const newMd = updateMd(existingMd, name, description, props, paramDocs, defaults, slots, {}, examples, schema)
730
+ const diff = diffSummary(existingMd, newMd)
731
+ if (diff) {
732
+ changed++
733
+ if (CHECK_ONLY) console.log(`[drift] ${name}.md — ${diff}`)
734
+ else if (NO_WRITE) console.log(`[would-write] ${name}.md — ${diff}`)
735
+ else { writeFileSync(mdPath, newMd, 'utf8'); console.log(`[write] ${name}.md — ${diff}`) }
736
+ }
737
+ }
738
+
739
+ // Orphans — .md files in skill-dir with no matching .vue.
740
+ for (const md of existingMds) {
741
+ if (ONLY_SET) continue
742
+ orphans.push(md)
743
+ }
744
+ if (orphans.length) {
745
+ console.log(`[orphan] ${orphans.length} .md file(s) without matching .vue in theme:`)
746
+ for (const o of orphans) console.log(` ${o}`)
747
+ }
748
+
749
+ if (CHECK_ONLY) {
750
+ if (changed > 0 || errors > 0 || orphans.length > 0) process.exit(1)
751
+ console.log('[gen-docs] All docs in sync')
752
+ process.exit(0)
753
+ }
754
+
755
+ console.log(`[gen-docs] ${components.length} components, ${changed} updated, ${errors} error(s), ${orphans.length} orphan(s)`)
756
+ process.exit(errors > 0 ? 2 : 0)
757
+ }
758
+
759
+ main()