component-auto-docs 0.1.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,1643 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import ts from 'typescript';
5
+
6
+ const rootDir = process.cwd();
7
+ const argv = process.argv.slice(2);
8
+ const args = new Set(argv);
9
+ const isCheckMode = args.has('--check');
10
+ const isInitMode = args.has('--init');
11
+ const generatedBy = 'component-auto-docs';
12
+
13
+ const defaultDocsConfig = {
14
+ projectName: 'uni-vue3-ts-vite',
15
+ componentRoot: 'src/components',
16
+ metaFileName: 'docs.meta.json',
17
+ output: {
18
+ aiDir: 'docs/ai',
19
+ markdownDir: 'docs/components',
20
+ pageDataRoot: 'src/docs/components',
21
+ indexDataFile: 'src/docs/data.json',
22
+ catalogFile: 'components.catalog.json',
23
+ aiIndexFile: 'components.index.md',
24
+ llmsFile: 'llms.txt',
25
+ qualityFile: 'components.quality.json',
26
+ },
27
+ routes: {
28
+ enabled: true,
29
+ pagesJson: 'src/pages.json',
30
+ indexPath: 'docs/index',
31
+ indexTitle: '组件文档',
32
+ basePath: 'docs/components',
33
+ insertBeforeMarker: ' // -- 示例 demo --',
34
+ titleSuffix: ' 文档',
35
+ },
36
+ runtime: {
37
+ componentDocPageImport: '@/docs/runtime/component-doc-page.vue',
38
+ autoDocHookImport: '@/docs/runtime/use-auto-component-doc',
39
+ componentImportBase: '@/components',
40
+ componentDocPageName: 'ComponentDocPage',
41
+ autoDocHookName: 'useAutoComponentDoc',
42
+ },
43
+ quality: {
44
+ requireDocPage: true,
45
+ shellHints: ['<ComponentDocPage', '<app-page', '<AppPage'],
46
+ },
47
+ };
48
+
49
+ function getArgValue(name) {
50
+ const index = argv.indexOf(name);
51
+ if (index >= 0) return argv[index + 1];
52
+
53
+ const inline = argv.find((item) => item.startsWith(`${name}=`));
54
+ return inline ? inline.slice(name.length + 1) : '';
55
+ }
56
+
57
+ function mergeConfig(base, override = {}) {
58
+ const result = { ...base };
59
+
60
+ for (const [key, value] of Object.entries(override || {})) {
61
+ if (
62
+ value &&
63
+ typeof value === 'object' &&
64
+ !Array.isArray(value) &&
65
+ base[key] &&
66
+ typeof base[key] === 'object' &&
67
+ !Array.isArray(base[key])
68
+ ) {
69
+ result[key] = mergeConfig(base[key], value);
70
+ } else if (value !== undefined) {
71
+ result[key] = value;
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ async function loadDocsConfig() {
79
+ const configArg = getArgValue('--config') || process.env.DOCS_CONFIG || 'docs.config.mjs';
80
+ const configPath = path.resolve(rootDir, configArg);
81
+
82
+ if (!fs.existsSync(configPath)) return defaultDocsConfig;
83
+
84
+ const module = await import(pathToFileURL(configPath).href);
85
+ return mergeConfig(defaultDocsConfig, module.default || module.docsConfig || module);
86
+ }
87
+
88
+ const docsConfig = await loadDocsConfig();
89
+ const componentRoot = path.resolve(rootDir, docsConfig.componentRoot);
90
+ const aiDocsDir = path.resolve(rootDir, docsConfig.output.aiDir);
91
+ const markdownDocsDir = path.resolve(rootDir, docsConfig.output.markdownDir);
92
+ const pageDataRoot = path.resolve(rootDir, docsConfig.output.pageDataRoot);
93
+ const indexDataFile = path.resolve(rootDir, docsConfig.output.indexDataFile);
94
+ const pagesJsonFile = path.resolve(rootDir, docsConfig.routes.pagesJson);
95
+
96
+ function getMetaFile(componentDir) {
97
+ return path.join(componentDir, docsConfig.metaFileName);
98
+ }
99
+
100
+ function getDocsRoute(componentName) {
101
+ return `${docsConfig.routes.basePath}/${componentName}/index`;
102
+ }
103
+
104
+ function createPageRouteEntry(routePath, title) {
105
+ return ` {
106
+ "path": "${routePath}",
107
+ "style": {
108
+ "navigationBarTitleText": "${title}"
109
+ }
110
+ },
111
+ `;
112
+ }
113
+
114
+ function insertPageRoute(source, entry) {
115
+ const marker = docsConfig.routes.insertBeforeMarker;
116
+ const markerIndex = source.indexOf(marker);
117
+
118
+ if (markerIndex >= 0) {
119
+ return `${source.slice(0, markerIndex)}${entry}${source.slice(markerIndex)}`;
120
+ }
121
+
122
+ const pagesEndIndex = source.indexOf('\n ],');
123
+ if (pagesEndIndex < 0) return source;
124
+
125
+ return `${source.slice(0, pagesEndIndex)}${entry}${source.slice(pagesEndIndex)}`;
126
+ }
127
+
128
+ const constructorTypeMap = {
129
+ String: 'string',
130
+ Number: 'number',
131
+ Boolean: 'boolean',
132
+ Array: 'array',
133
+ Object: 'object',
134
+ Function: 'function',
135
+ Date: 'Date',
136
+ };
137
+
138
+ function toPosix(filePath) {
139
+ return filePath.split(path.sep).join('/');
140
+ }
141
+
142
+ function ensureDir(dir) {
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ }
145
+
146
+ function readJson(filePath) {
147
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
148
+ }
149
+
150
+ function writeJson(filePath, data) {
151
+ ensureDir(path.dirname(filePath));
152
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
153
+ }
154
+
155
+ function writeText(filePath, content) {
156
+ ensureDir(path.dirname(filePath));
157
+ fs.writeFileSync(filePath, `${content.trimEnd()}\n`);
158
+ }
159
+
160
+ function normalizeTypeText(typeText) {
161
+ return typeText
162
+ .replace(/\s+/g, ' ')
163
+ .replace(/\s*\|\s*/g, ' | ')
164
+ .replace(/\s*,\s*/g, ' | ')
165
+ .trim();
166
+ }
167
+
168
+ function normalizeJsdocType(typeText) {
169
+ return normalizeTypeText(typeText)
170
+ .split(' | ')
171
+ .map((part) => constructorTypeMap[part] || part.toLowerCase?.() || part)
172
+ .join(' | ');
173
+ }
174
+
175
+ function toTitleCase(componentName) {
176
+ return componentName
177
+ .replace(/^app-/, '')
178
+ .split('-')
179
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
180
+ .join(' ');
181
+ }
182
+
183
+ function inferPropDescription(name, type = '', values = []) {
184
+ const lowerName = name.toLowerCase();
185
+ const known = {
186
+ modelValue: '组件绑定值,通常通过 v-model 双向更新。',
187
+ value: '组件当前值。',
188
+ checked: '是否处于选中状态。',
189
+ disabled: '是否禁用组件交互。',
190
+ readonly: '是否只读。',
191
+ placeholder: '输入或选择为空时展示的占位提示。',
192
+ title: '组件标题文本。',
193
+ text: '组件展示文本。',
194
+ label: '组件标签文本或数据项标签字段名。',
195
+ content: '组件主体内容。',
196
+ type: '组件视觉类型或展示模式。',
197
+ size: '组件尺寸规格。',
198
+ w: '组件宽度,支持数字或带单位字符串。',
199
+ h: '组件高度,支持数字或带单位字符串。',
200
+ width: '组件宽度。',
201
+ height: '组件高度。',
202
+ radius: '组件圆角大小。',
203
+ margin: '组件外边距。',
204
+ padding: '组件内边距。',
205
+ bg: '组件背景色。',
206
+ color: '组件颜色。',
207
+ src: '资源地址。',
208
+ icon: '图标资源地址。',
209
+ items: '选项数据列表。',
210
+ list: '展示数据列表。',
211
+ range: '选择器数据源。',
212
+ tabs: '标签页数据列表。',
213
+ max: '最大数量或最大值。',
214
+ min: '最小数量或最小值。',
215
+ maxlength: '允许输入的最大字符数。',
216
+ maxLength: '允许输入的最大字符数。',
217
+ focus: '是否自动聚焦。',
218
+ autoplay: '是否自动播放。',
219
+ openType: '小程序开放能力类型。',
220
+ };
221
+
222
+ if (known[name]) return known[name];
223
+ if (values.length) return `可选值为 ${values.join('、')}。`;
224
+ if (lowerName.startsWith('show')) return `是否展示${name.replace(/^show/i, '') || '对应元素'}。`;
225
+ if (lowerName.startsWith('is')) return `是否启用${name.replace(/^is/i, '') || '对应状态'}。`;
226
+ if (type.includes('boolean')) return '布尔开关配置。';
227
+ if (type.includes('number')) return '数值配置项。';
228
+ if (type.includes('array') || type.includes('Array')) return '列表数据配置。';
229
+ if (type.includes('object') || type.includes('Object')) return '对象配置项。';
230
+
231
+ return `${name} 配置项。`;
232
+ }
233
+
234
+ function inferEventDescription(name) {
235
+ const known = {
236
+ click: '点击组件时触发。',
237
+ change: '组件值或状态变化时触发。',
238
+ input: '输入内容变化时触发。',
239
+ blur: '组件失焦时触发。',
240
+ focus: '组件聚焦时触发。',
241
+ confirm: '确认操作时触发。',
242
+ cancel: '取消操作时触发。',
243
+ close: '关闭组件时触发。',
244
+ clear: '清空内容时触发。',
245
+ search: '触发搜索动作时触发。',
246
+ load: '资源加载成功时触发。',
247
+ error: '资源加载失败时触发。',
248
+ result: '异步处理完成并返回结果时触发。',
249
+ };
250
+
251
+ if (known[name]) return known[name];
252
+ if (name.startsWith('update:')) return `更新 ${name.slice('update:'.length)} 绑定值时触发。`;
253
+
254
+ return `${name} 事件触发时回调。`;
255
+ }
256
+
257
+ function getNodeText(node, sourceFile) {
258
+ return normalizeTypeText(node.getText(sourceFile));
259
+ }
260
+
261
+ function readLiteralValue(node, sourceFile) {
262
+ if (!node) return null;
263
+ if (ts.isStringLiteralLike(node)) return node.text;
264
+ if (ts.isNumericLiteral(node)) return Number(node.text);
265
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
266
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
267
+ if (node.kind === ts.SyntaxKind.NullKeyword) return null;
268
+ if (ts.isPropertyAccessExpression(node)) {
269
+ const text = node.getText(sourceFile);
270
+ if (text === 'Number.MAX_SAFE_INTEGER') return Number.MAX_SAFE_INTEGER;
271
+ if (text === 'Number.MIN_SAFE_INTEGER') return Number.MIN_SAFE_INTEGER;
272
+ if (text === 'Number.POSITIVE_INFINITY') return 'Infinity';
273
+ if (text === 'Number.NEGATIVE_INFINITY') return '-Infinity';
274
+ }
275
+ if (ts.isPrefixUnaryExpression(node) && ts.isNumericLiteral(node.operand)) {
276
+ const value = Number(node.operand.text);
277
+ return node.operator === ts.SyntaxKind.MinusToken ? -value : value;
278
+ }
279
+
280
+ return node.getText(sourceFile);
281
+ }
282
+
283
+ function extractSfcBlock(source, tagName, matcher = () => true) {
284
+ const blockPattern = new RegExp(`<${tagName}\\b([^>]*)>([\\s\\S]*?)<\\/${tagName}>`, 'gi');
285
+ const blocks = [...source.matchAll(blockPattern)];
286
+ const block = blocks.find((item) => matcher(item[1]));
287
+
288
+ return block?.[2] || '';
289
+ }
290
+
291
+ function parseJsdoc(script) {
292
+ const blocks = [...script.matchAll(/\/\*\*([\s\S]*?)\*\//g)].map((match) => match[1]);
293
+ const result = {
294
+ description: '',
295
+ props: new Map(),
296
+ events: new Map(),
297
+ examples: [],
298
+ };
299
+
300
+ for (const block of blocks) {
301
+ const lines = block
302
+ .split('\n')
303
+ .map((line) => line.replace(/^\s*\*\s?/, '').trimEnd())
304
+ .filter(Boolean);
305
+ const descriptionLines = [];
306
+ const tags = [];
307
+ let currentTag = null;
308
+
309
+ for (const line of lines) {
310
+ if (line.startsWith('@')) {
311
+ if (currentTag) tags.push(currentTag);
312
+ const [, tag = '', body = ''] = line.match(/^@(\S+)\s*(.*)$/) || [];
313
+ currentTag = { tag, body };
314
+ } else if (currentTag) {
315
+ currentTag.body = `${currentTag.body}\n${line}`;
316
+ } else {
317
+ descriptionLines.push(line);
318
+ }
319
+ }
320
+
321
+ if (currentTag) tags.push(currentTag);
322
+ if (!result.description && descriptionLines.length) {
323
+ result.description = descriptionLines.join('\n').trim();
324
+ }
325
+
326
+ for (const item of tags) {
327
+ if (item.tag === 'property') {
328
+ const match = item.body.match(/^\{([^}]+)\}\s+([\w:$-]+)\s*(.*)$/s);
329
+ if (!match) continue;
330
+ const [, type, name, description] = match;
331
+ result.props.set(name, {
332
+ name,
333
+ jsdocType: normalizeJsdocType(type),
334
+ description: description.trim(),
335
+ });
336
+ }
337
+
338
+ if (item.tag === 'event') {
339
+ const match = item.body.match(/^\{([^}]+)\}\s+([\w:$-]+)\s*(.*)$/s);
340
+ if (!match) continue;
341
+ const [, type, name, description] = match;
342
+ result.events.set(name, {
343
+ name,
344
+ type: normalizeJsdocType(type),
345
+ description: description.trim(),
346
+ });
347
+ }
348
+
349
+ if (item.tag === 'example') {
350
+ const code = item.body.trim();
351
+ if (code) {
352
+ result.examples.push({
353
+ title: '源码示例',
354
+ description: '来自组件源码 JSDoc。',
355
+ code,
356
+ });
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ return result;
363
+ }
364
+
365
+ function extractStringUnionValues(typeNode) {
366
+ if (!typeNode) return [];
367
+
368
+ if (ts.isParenthesizedTypeNode(typeNode)) {
369
+ return extractStringUnionValues(typeNode.type);
370
+ }
371
+
372
+ const nodes = ts.isUnionTypeNode(typeNode) ? typeNode.types : [typeNode];
373
+ const values = [];
374
+
375
+ for (const node of nodes) {
376
+ if (ts.isLiteralTypeNode(node)) {
377
+ const literal = node.literal;
378
+ if (ts.isStringLiteralLike(literal) || ts.isNumericLiteral(literal)) {
379
+ values.push(literal.text);
380
+ }
381
+ }
382
+ }
383
+
384
+ return values;
385
+ }
386
+
387
+ function collectTypeAliases(componentDir) {
388
+ const aliases = new Map();
389
+ const typingFiles = fs
390
+ .readdirSync(componentDir)
391
+ .filter((fileName) => fileName.endsWith('.ts'))
392
+ .map((fileName) => path.join(componentDir, fileName));
393
+
394
+ for (const filePath of typingFiles) {
395
+ const source = fs.readFileSync(filePath, 'utf8');
396
+ const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
397
+
398
+ const visit = (node) => {
399
+ if (ts.isTypeAliasDeclaration(node)) {
400
+ aliases.set(node.name.text, {
401
+ name: node.name.text,
402
+ type: getNodeText(node.type, sourceFile),
403
+ values: extractStringUnionValues(node.type),
404
+ source: toPosix(path.relative(rootDir, filePath)),
405
+ });
406
+ }
407
+
408
+ ts.forEachChild(node, visit);
409
+ };
410
+
411
+ visit(sourceFile);
412
+ }
413
+
414
+ return aliases;
415
+ }
416
+
417
+ function parseTypeNode(typeNode, sourceFile, aliases) {
418
+ if (!typeNode) return { type: 'unknown', values: [] };
419
+
420
+ if (
421
+ ts.isTypeReferenceNode(typeNode) &&
422
+ ts.isIdentifier(typeNode.typeName) &&
423
+ typeNode.typeName.text === 'PropType' &&
424
+ typeNode.typeArguments?.length
425
+ ) {
426
+ return parseTypeNode(typeNode.typeArguments[0], sourceFile, aliases);
427
+ }
428
+
429
+ const type = getNodeText(typeNode, sourceFile);
430
+ const alias = aliases.get(type);
431
+
432
+ return {
433
+ type,
434
+ values: alias?.values?.length ? alias.values : extractStringUnionValues(typeNode),
435
+ typeSource: alias?.source,
436
+ };
437
+ }
438
+
439
+ function parsePropTypeExpression(expression, sourceFile, aliases) {
440
+ if (!expression) return { type: 'unknown', values: [] };
441
+
442
+ if (ts.isAsExpression(expression)) {
443
+ const fromTypeNode = parseTypeNode(expression.type, sourceFile, aliases);
444
+ if (fromTypeNode.type !== 'unknown') return fromTypeNode;
445
+
446
+ return parsePropTypeExpression(expression.expression, sourceFile, aliases);
447
+ }
448
+
449
+ if (ts.isArrayLiteralExpression(expression)) {
450
+ const parts = expression.elements.map((item) => parsePropTypeExpression(item, sourceFile, aliases).type);
451
+ return {
452
+ type: [...new Set(parts)].join(' | '),
453
+ values: [],
454
+ };
455
+ }
456
+
457
+ if (ts.isIdentifier(expression)) {
458
+ return {
459
+ type: constructorTypeMap[expression.text] || expression.text,
460
+ values: [],
461
+ };
462
+ }
463
+
464
+ return {
465
+ type: getNodeText(expression, sourceFile),
466
+ values: [],
467
+ };
468
+ }
469
+
470
+ function getPropertyName(nameNode) {
471
+ if (!nameNode) return '';
472
+ if (ts.isIdentifier(nameNode) || ts.isStringLiteralLike(nameNode) || ts.isNumericLiteral(nameNode)) {
473
+ return nameNode.text;
474
+ }
475
+
476
+ return '';
477
+ }
478
+
479
+ function getObjectProperty(objectNode, propertyName) {
480
+ if (!ts.isObjectLiteralExpression(objectNode)) return null;
481
+
482
+ return objectNode.properties.find((property) => {
483
+ if (!ts.isPropertyAssignment(property)) return false;
484
+ return getPropertyName(property.name) === propertyName;
485
+ });
486
+ }
487
+
488
+ function extractProps(script, componentFile, aliases, jsdoc) {
489
+ const sourceFile = ts.createSourceFile(componentFile, script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
490
+ let definePropsCall = null;
491
+ let withDefaultsCall = null;
492
+
493
+ const visit = (node) => {
494
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
495
+ if (node.expression.text === 'defineProps') {
496
+ definePropsCall = node;
497
+ }
498
+
499
+ if (
500
+ node.expression.text === 'withDefaults' &&
501
+ node.arguments.length &&
502
+ ts.isCallExpression(node.arguments[0]) &&
503
+ ts.isIdentifier(node.arguments[0].expression) &&
504
+ node.arguments[0].expression.text === 'defineProps'
505
+ ) {
506
+ definePropsCall = node.arguments[0];
507
+ withDefaultsCall = node;
508
+ }
509
+ }
510
+
511
+ ts.forEachChild(node, visit);
512
+ };
513
+
514
+ visit(sourceFile);
515
+
516
+ if (!definePropsCall) return [];
517
+
518
+ const props = [];
519
+ const propsArg = definePropsCall.arguments[0];
520
+ const defaultsArg = withDefaultsCall?.arguments?.[1];
521
+
522
+ if (propsArg && ts.isObjectLiteralExpression(propsArg)) {
523
+ for (const property of propsArg.properties) {
524
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) continue;
525
+
526
+ const name = getPropertyName(property.name);
527
+ if (!name) continue;
528
+
529
+ const initializer = ts.isPropertyAssignment(property) ? property.initializer : null;
530
+ const doc = jsdoc.props.get(name);
531
+ let parsedType = { type: doc?.jsdocType || 'unknown', values: [] };
532
+ let defaultValue = null;
533
+ let required = false;
534
+
535
+ if (initializer && ts.isObjectLiteralExpression(initializer)) {
536
+ const typeProperty = getObjectProperty(initializer, 'type');
537
+ const defaultProperty = getObjectProperty(initializer, 'default');
538
+ const requiredProperty = getObjectProperty(initializer, 'required');
539
+
540
+ if (typeProperty) {
541
+ parsedType = parsePropTypeExpression(typeProperty.initializer, sourceFile, aliases);
542
+ }
543
+
544
+ if (defaultProperty) {
545
+ defaultValue = readLiteralValue(defaultProperty.initializer, sourceFile);
546
+ }
547
+
548
+ if (requiredProperty) {
549
+ required = readLiteralValue(requiredProperty.initializer, sourceFile) === true;
550
+ }
551
+ } else if (initializer) {
552
+ parsedType = parsePropTypeExpression(initializer, sourceFile, aliases);
553
+ }
554
+
555
+ if (defaultValue === null && parsedType.type === 'boolean') {
556
+ defaultValue = false;
557
+ }
558
+
559
+ props.push({
560
+ name,
561
+ type: parsedType.type === 'unknown' && doc?.jsdocType ? doc.jsdocType : parsedType.type,
562
+ default: defaultValue,
563
+ required,
564
+ values: parsedType.values || [],
565
+ description: doc?.description || inferPropDescription(name, parsedType.type, parsedType.values || []),
566
+ typeSource: parsedType.typeSource,
567
+ });
568
+ }
569
+ } else if (definePropsCall.typeArguments?.length) {
570
+ const typeArg = definePropsCall.typeArguments[0];
571
+ if (ts.isTypeLiteralNode(typeArg)) {
572
+ for (const member of typeArg.members) {
573
+ if (!ts.isPropertySignature(member)) continue;
574
+ const name = getPropertyName(member.name);
575
+ const doc = jsdoc.props.get(name);
576
+ const parsedType = parseTypeNode(member.type, sourceFile, aliases);
577
+ const defaultValue =
578
+ defaultsArg && ts.isObjectLiteralExpression(defaultsArg)
579
+ ? readLiteralValue(getObjectProperty(defaultsArg, name)?.initializer, sourceFile)
580
+ : parsedType.type === 'boolean'
581
+ ? false
582
+ : null;
583
+
584
+ props.push({
585
+ name,
586
+ type: parsedType.type,
587
+ default: defaultValue,
588
+ required: !member.questionToken,
589
+ values: parsedType.values || [],
590
+ description: doc?.description || inferPropDescription(name, parsedType.type, parsedType.values || []),
591
+ typeSource: parsedType.typeSource,
592
+ });
593
+ }
594
+ }
595
+ }
596
+
597
+ return props;
598
+ }
599
+
600
+ function extractEvents(script, componentFile, jsdoc) {
601
+ const sourceFile = ts.createSourceFile(componentFile, script, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
602
+ const eventNames = [];
603
+
604
+ const visit = (node) => {
605
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'defineEmits') {
606
+ const firstArg = node.arguments[0];
607
+
608
+ if (firstArg && ts.isArrayLiteralExpression(firstArg)) {
609
+ for (const item of firstArg.elements) {
610
+ if (ts.isStringLiteralLike(item)) eventNames.push(item.text);
611
+ }
612
+ }
613
+
614
+ if (node.typeArguments?.length) {
615
+ const typeArg = node.typeArguments[0];
616
+ if (ts.isTypeLiteralNode(typeArg)) {
617
+ for (const member of typeArg.members) {
618
+ if (!ts.isCallSignatureDeclaration(member) || !member.parameters.length) continue;
619
+ const firstParam = member.parameters[0];
620
+ if (!firstParam.type) continue;
621
+ for (const value of extractStringUnionValues(firstParam.type)) {
622
+ eventNames.push(value);
623
+ }
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ ts.forEachChild(node, visit);
630
+ };
631
+
632
+ visit(sourceFile);
633
+
634
+ for (const eventName of jsdoc.events.keys()) {
635
+ eventNames.push(eventName);
636
+ }
637
+
638
+ return [...new Set(eventNames)].map((name) => {
639
+ const doc = jsdoc.events.get(name);
640
+
641
+ return {
642
+ name,
643
+ type: doc?.type || 'function',
644
+ description: doc?.description || inferEventDescription(name),
645
+ };
646
+ });
647
+ }
648
+
649
+ function extractSlots(template) {
650
+ const matches = [...template.matchAll(/<slot\b([^>]*)>/gi)];
651
+ const slots = [];
652
+
653
+ for (const match of matches) {
654
+ const attrs = match[1] || '';
655
+ const nameMatch = attrs.match(/(?:^|\s):?name\s*=\s*["']([^"']+)["']/);
656
+ const name = nameMatch?.[1] || 'default';
657
+
658
+ slots.push({
659
+ name,
660
+ description: name === 'default' ? '默认插槽,用于渲染按钮内容。' : '',
661
+ props: [],
662
+ });
663
+ }
664
+
665
+ if (!slots.length) return [];
666
+
667
+ return [...new Map(slots.map((slot) => [slot.name, slot])).values()];
668
+ }
669
+
670
+ function normalizeExamples(metaExamples = [], jsdocExamples = []) {
671
+ const examples = [];
672
+ const seen = new Set();
673
+
674
+ for (const example of [...metaExamples, ...jsdocExamples]) {
675
+ const normalized = typeof example === 'string' ? { title: '示例', description: '', code: example } : example;
676
+ const code = normalized.code?.trim();
677
+ if (!code || seen.has(code)) continue;
678
+
679
+ seen.add(code);
680
+ examples.push({
681
+ title: normalized.title || '示例',
682
+ description: normalized.description || '',
683
+ code,
684
+ });
685
+ }
686
+
687
+ return examples;
688
+ }
689
+
690
+ function findAllComponentDirs() {
691
+ if (!fs.existsSync(componentRoot)) return [];
692
+
693
+ return fs
694
+ .readdirSync(componentRoot, { withFileTypes: true })
695
+ .filter((entry) => entry.isDirectory())
696
+ .map((entry) => path.join(componentRoot, entry.name))
697
+ .filter((componentDir) => Boolean(findComponentFile(componentDir)));
698
+ }
699
+
700
+ function findComponentDirs() {
701
+ if (!fs.existsSync(componentRoot)) return [];
702
+
703
+ return fs
704
+ .readdirSync(componentRoot, { withFileTypes: true })
705
+ .filter((entry) => entry.isDirectory())
706
+ .map((entry) => path.join(componentRoot, entry.name))
707
+ .filter((componentDir) => fs.existsSync(getMetaFile(componentDir)));
708
+ }
709
+
710
+ function findComponentFile(componentDir) {
711
+ const componentName = path.basename(componentDir);
712
+ const preferredFile = path.join(componentDir, `${componentName}.vue`);
713
+
714
+ if (fs.existsSync(preferredFile)) return preferredFile;
715
+
716
+ const vueFile = fs.readdirSync(componentDir).find((fileName) => fileName.endsWith('.vue'));
717
+ return vueFile ? path.join(componentDir, vueFile) : null;
718
+ }
719
+
720
+ function inferCategory(componentName) {
721
+ if (/page|header|footer|safearea|status-bar/.test(componentName)) return '布局组件';
722
+ if (/input|textarea|picker|radio|checkbox|switch|upload|search/.test(componentName)) return '表单组件';
723
+ if (/dialog|pop|toast|modal|popup/.test(componentName)) return '反馈组件';
724
+ if (/tabs|tab|back|totop/.test(componentName)) return '导航组件';
725
+ if (/item|list|img|tag|dot|title|highlight|marquee|qrcode|radar|progress|steps|timeline|timer|video|swiper|star|tree/.test(componentName)) {
726
+ return '展示组件';
727
+ }
728
+
729
+ return '基础组件';
730
+ }
731
+
732
+ function buildUsageHints(componentName, title, props, events, slots) {
733
+ const hasModel = props.some((prop) => prop.name === 'modelValue');
734
+ const hasList = props.some((prop) => ['items', 'list', 'range', 'tabs', 'arr'].includes(prop.name));
735
+ const hasDisabled = props.some((prop) => prop.name === 'disabled');
736
+ const hasOpen = props.some((prop) => prop.name === 'modelValue' && prop.type.includes('boolean'));
737
+ const hasClick = events.some((event) => event.name === 'click');
738
+ const hasSlot = slots.length > 0;
739
+
740
+ const useWhen = [
741
+ `需要在页面中统一使用${title}相关能力时。`,
742
+ hasModel ? `需要通过 v-model 管理${title}的当前值或显示状态时。` : `需要复用项目内一致的${title}视觉和交互规范时。`,
743
+ hasList ? `需要基于业务数据列表渲染${title}内容时。` : hasClick ? `需要响应用户点击并触发业务动作时。` : `需要减少页面内重复实现时。`,
744
+ ];
745
+
746
+ const avoidWhen = [
747
+ `只是一次性、强业务定制且无法复用的页面片段时。`,
748
+ hasOpen ? `只需要静态展示内容、不需要打开或关闭状态管理时。` : `组件默认结构无法表达当前业务语义时,应优先扩展组件或新增更合适的组件。`,
749
+ hasDisabled ? `需要禁用状态以外的复杂权限控制时,应在业务层先完成权限判断。` : `需要承载复杂业务流程时,不建议把流程逻辑直接塞进${componentName}。`,
750
+ ];
751
+
752
+ const notes = [
753
+ `API 契约优先从 ${componentName}.vue 的 defineProps、defineEmits 和 JSDoc 自动生成。`,
754
+ hasSlot ? `该组件包含插槽,使用时注意插槽内容的尺寸、换行和点击区域。` : `该组件当前未暴露显式插槽,内容主要通过 props 配置。`,
755
+ hasModel ? `使用 v-model 时,应以 update:modelValue 作为状态同步来源。` : `如需新增对外配置,请同步维护 props 类型和 JSDoc。`,
756
+ ];
757
+
758
+ return { useWhen, avoidWhen, notes };
759
+ }
760
+
761
+ function buildSamplePropValue(prop) {
762
+ if (prop.default !== null && prop.default !== undefined && prop.default !== '') return prop.default;
763
+ if (prop.values?.length) return prop.values[0];
764
+
765
+ const type = prop.type || '';
766
+ const name = prop.name.toLowerCase();
767
+
768
+ if (type.includes('boolean')) return false;
769
+ if (type.includes('number') || type.includes('Number')) {
770
+ if (name.includes('percent')) return 60;
771
+ if (name.includes('max')) return 10;
772
+ if (name.includes('min')) return 0;
773
+ if (name.includes('size')) return 32;
774
+ if (name === 'w' || name.includes('width')) return 240;
775
+ if (name === 'h' || name.includes('height')) return 80;
776
+ return 1;
777
+ }
778
+ if (type.includes('Array') || type.includes('array') || ['items', 'list', 'range', 'tabs', 'arr'].includes(prop.name)) {
779
+ return [
780
+ { label: '选项一', value: 'one' },
781
+ { label: '选项二', value: 'two' },
782
+ ];
783
+ }
784
+ if (type.includes('Object') || type.includes('object')) return {};
785
+ if (name.includes('placeholder')) return '请输入';
786
+ if (name.includes('title')) return '标题';
787
+ if (name.includes('label')) return '标签';
788
+ if (name.includes('text') || name.includes('content')) return '示例内容';
789
+ if (name.includes('phone')) return '13800000000';
790
+ if (name === 'icon') return '/static/icon/avatar.svg';
791
+ if (name === 'src' || name.includes('poster')) return '';
792
+
793
+ return '';
794
+ }
795
+
796
+ function renderExampleFromProps(componentName, props) {
797
+ const importantProps = props
798
+ .filter((prop) => prop.required || ['modelValue', 'title', 'text', 'label', 'items', 'range', 'tabs', 'src', 'icon', 'type', 'size'].includes(prop.name))
799
+ .slice(0, 5);
800
+ const attrs = importantProps
801
+ .map((prop) => {
802
+ if (prop.name === 'modelValue') return 'v-model="value"';
803
+ const value = buildSamplePropValue(prop);
804
+ if (typeof value === 'boolean') return value ? prop.name : `:${prop.name}="false"`;
805
+ if (typeof value === 'number') return `:${prop.name}="${value}"`;
806
+ if (Array.isArray(value) || (value && typeof value === 'object')) return `:${prop.name}="${prop.name}"`;
807
+ if (!value) return '';
808
+
809
+ return `${prop.name}="${value}"`;
810
+ })
811
+ .filter(Boolean)
812
+ .join(' ');
813
+ const attrText = attrs ? ` ${attrs}` : '';
814
+
815
+ return `<${componentName}${attrText}>示例内容</${componentName}>`;
816
+ }
817
+
818
+ function inferComponentInfo(componentDir) {
819
+ const componentName = path.basename(componentDir);
820
+ const componentFile = findComponentFile(componentDir);
821
+ const sfcSource = fs.readFileSync(componentFile, 'utf8');
822
+ const script = extractSfcBlock(sfcSource, 'script', (attrs) => attrs.includes('setup'));
823
+ const template = extractSfcBlock(sfcSource, 'template');
824
+ const jsdoc = parseJsdoc(script);
825
+ const aliases = collectTypeAliases(componentDir);
826
+ const props = extractProps(script, componentFile, aliases, jsdoc);
827
+ const events = extractEvents(script, componentFile, jsdoc);
828
+ const slots = extractSlots(template);
829
+
830
+ return {
831
+ componentName,
832
+ componentFile,
833
+ jsdoc,
834
+ props,
835
+ events,
836
+ slots,
837
+ };
838
+ }
839
+
840
+ function inferInitialMeta(componentDir) {
841
+ const { componentName, jsdoc, props, events, slots } = inferComponentInfo(componentDir);
842
+ const examples = normalizeExamples([], jsdoc.examples);
843
+ const title = jsdoc.description?.split('\n')[0]?.trim() || toTitleCase(componentName);
844
+ const usageHints = buildUsageHints(componentName, title, props, events, slots);
845
+ const exampleCode = renderExampleFromProps(componentName, props);
846
+
847
+ return {
848
+ title,
849
+ category: inferCategory(componentName),
850
+ description: jsdoc.description || `${title},用于在项目中复用 ${componentName} 的标准视觉、状态和交互能力。`,
851
+ useWhen: usageHints.useWhen,
852
+ avoidWhen: usageHints.avoidWhen,
853
+ related: [],
854
+ notes: usageHints.notes,
855
+ examples: examples.length
856
+ ? examples
857
+ : [
858
+ {
859
+ title: '基础用法',
860
+ description: `自动根据 ${componentName} 的 props 生成,可按业务语义继续调整。`,
861
+ code: exampleCode,
862
+ },
863
+ ],
864
+ };
865
+ }
866
+
867
+ function mergeMeta(existingMeta, inferredMeta) {
868
+ const merged = { ...existingMeta };
869
+ const scalarKeys = ['title', 'category', 'description'];
870
+ const arrayKeys = ['useWhen', 'avoidWhen', 'related', 'notes', 'examples'];
871
+
872
+ for (const key of scalarKeys) {
873
+ if (!merged[key] || merged[key] === `${merged.title} 组件。`) {
874
+ merged[key] = inferredMeta[key];
875
+ }
876
+ }
877
+
878
+ for (const key of arrayKeys) {
879
+ if (!Array.isArray(merged[key]) || merged[key].length === 0) {
880
+ merged[key] = inferredMeta[key];
881
+ }
882
+ }
883
+
884
+ return merged;
885
+ }
886
+
887
+ function isAutoGeneratedDocPage(filePath) {
888
+ if (!fs.existsSync(filePath)) return true;
889
+
890
+ const source = fs.readFileSync(filePath, 'utf8');
891
+ return source.includes('@generated by docs:init') || source.includes('交互预览待补充');
892
+ }
893
+
894
+ function renderGeneratedControlStyles() {
895
+ return `.control-panel,
896
+ .control-list {
897
+ box-sizing: border-box;
898
+ display: flex;
899
+ flex-direction: column;
900
+ gap: 14rpx;
901
+ width: 100%;
902
+ padding: 18rpx;
903
+ background: #f8fafc;
904
+ border: 1rpx solid #e2e8f0;
905
+ border-radius: 10rpx;
906
+ }
907
+
908
+ .control-item {
909
+ display: flex;
910
+ flex-direction: column;
911
+ gap: 8rpx;
912
+ width: 100%;
913
+ padding-bottom: 4rpx;
914
+ }
915
+
916
+ .control-item.is-inline,
917
+ .control-row {
918
+ display: flex;
919
+ flex-direction: row;
920
+ gap: 10rpx;
921
+ align-items: center;
922
+ justify-content: space-between;
923
+ width: 100%;
924
+ }
925
+
926
+ .control-label {
927
+ flex-shrink: 0;
928
+ font-size: 22rpx;
929
+ font-weight: 600;
930
+ line-height: 1.4;
931
+ color: #475569;
932
+ }
933
+
934
+ .control-meta {
935
+ display: flex;
936
+ flex: 1 1 320rpx;
937
+ flex-wrap: wrap;
938
+ gap: 8rpx;
939
+ justify-content: flex-end;
940
+ min-width: 0;
941
+ }
942
+
943
+ .control-type {
944
+ max-width: 100%;
945
+ padding: 6rpx 10rpx;
946
+ font-family: Menlo, Monaco, Consolas, monospace;
947
+ font-size: 21rpx;
948
+ line-height: 1.35;
949
+ color: #475569;
950
+ overflow-wrap: anywhere;
951
+ background: #fff;
952
+ border: 1rpx solid #e2e8f0;
953
+ border-radius: 8rpx;
954
+ }
955
+
956
+ .control-input {
957
+ box-sizing: border-box;
958
+ display: block;
959
+ width: 100%;
960
+ min-height: 64rpx;
961
+ padding: 0 16rpx;
962
+ font-size: 26rpx;
963
+ line-height: 64rpx;
964
+ color: #111827;
965
+ background: #fff;
966
+ border: 1rpx solid #d7e0ee;
967
+ border-radius: 8rpx;
968
+ }
969
+
970
+ .control-textarea {
971
+ box-sizing: border-box;
972
+ display: block;
973
+ width: 100%;
974
+ height: 150rpx;
975
+ padding: 14rpx;
976
+ font-size: 23rpx;
977
+ line-height: 1.45;
978
+ color: #111827;
979
+ background: #fff;
980
+ border: 1rpx solid #d7e0ee;
981
+ border-radius: 8rpx;
982
+ }
983
+
984
+ .tag-list {
985
+ display: flex;
986
+ flex-wrap: wrap;
987
+ gap: 8rpx;
988
+ width: 100%;
989
+ }
990
+
991
+ .option-tag {
992
+ box-sizing: border-box;
993
+ display: inline-flex;
994
+ align-items: center;
995
+ max-width: 100%;
996
+ min-height: 48rpx;
997
+ padding: 8rpx 12rpx;
998
+ font-size: 22rpx;
999
+ line-height: 1.35;
1000
+ color: #374151;
1001
+ overflow-wrap: anywhere;
1002
+ background: #fff;
1003
+ border: 1rpx solid #e2e8f0;
1004
+ border-radius: 8rpx;
1005
+ }
1006
+
1007
+ .option-tag.active {
1008
+ color: #1d4ed8;
1009
+ background: #eff6ff;
1010
+ border-color: #93c5fd;
1011
+ }
1012
+
1013
+ .code-card-mini {
1014
+ box-sizing: border-box;
1015
+ width: 100%;
1016
+ padding: 16rpx;
1017
+ background: #111827;
1018
+ border-radius: 10rpx;
1019
+ }
1020
+
1021
+ .code-text-mini {
1022
+ display: block;
1023
+ width: 100%;
1024
+ font-family: Menlo, Monaco, Consolas, monospace;
1025
+ font-size: 22rpx;
1026
+ line-height: 1.5;
1027
+ color: #e5e7eb;
1028
+ overflow-wrap: anywhere;
1029
+ white-space: pre-wrap;
1030
+ }`;
1031
+ }
1032
+
1033
+ function renderAutoDocPage(componentName, componentFile) {
1034
+ const fileName = path.basename(componentFile);
1035
+ const importName = componentName
1036
+ .replace(/(^|-)(\w)/g, (_, __, char) => char.toUpperCase())
1037
+ .replace(/[^\w]/g, '');
1038
+ const {
1039
+ componentDocPageImport,
1040
+ autoDocHookImport,
1041
+ componentImportBase,
1042
+ componentDocPageName,
1043
+ autoDocHookName,
1044
+ } = docsConfig.runtime;
1045
+
1046
+ return `<!-- @generated by docs:init. You can edit this file to customize the interactive docs page. -->
1047
+ <script setup lang="ts">
1048
+ import ${componentDocPageName} from '${componentDocPageImport}';
1049
+ import { ${autoDocHookName} } from '${autoDocHookImport}';
1050
+ import ${importName} from '${componentImportBase}/${componentName}/${fileName}';
1051
+ import doc from './data.json';
1052
+
1053
+ const {
1054
+ propControls,
1055
+ previewProps,
1056
+ slotText,
1057
+ codeSnippet,
1058
+ isBooleanProp,
1059
+ isNumberProp,
1060
+ isArrayProp,
1061
+ isObjectProp,
1062
+ formatControlDefault,
1063
+ setBooleanProp,
1064
+ setControlValue,
1065
+ setOptionProp,
1066
+ } = ${autoDocHookName}(doc);
1067
+ </script>
1068
+
1069
+ <template>
1070
+ <${componentDocPageName} :doc="doc" :show-controls="doc.props.length > 0">
1071
+ <template #preview>
1072
+ <view class="preview-frame">
1073
+ <${importName} v-bind="previewProps">
1074
+ {{ slotText }}
1075
+ </${importName}>
1076
+ </view>
1077
+ </template>
1078
+
1079
+ <template #controls>
1080
+ <view v-if="doc.props.length" class="control-panel">
1081
+ <view v-for="prop in doc.props" :key="prop.name" class="control-item">
1082
+ <view class="control-row">
1083
+ <text class="control-label">{{ prop.name }}</text>
1084
+ <view class="control-meta">
1085
+ <text class="control-type">{{ prop.type }}</text>
1086
+ <text class="control-type">default: {{ formatControlDefault(prop.default) }}</text>
1087
+ </view>
1088
+ </view>
1089
+
1090
+ <view v-if="isBooleanProp(prop)" class="tag-list">
1091
+ <text class="option-tag" :class="{ active: propControls[prop.name] === true }" @click="setBooleanProp(prop.name, true)">
1092
+ true
1093
+ </text>
1094
+ <text class="option-tag" :class="{ active: propControls[prop.name] === false }" @click="setBooleanProp(prop.name, false)">
1095
+ false
1096
+ </text>
1097
+ </view>
1098
+
1099
+ <view v-else-if="prop.values?.length" class="tag-list">
1100
+ <text
1101
+ v-for="value in prop.values"
1102
+ :key="value"
1103
+ class="option-tag"
1104
+ :class="{ active: propControls[prop.name] === value }"
1105
+ @click="setOptionProp(prop.name, value)"
1106
+ >
1107
+ {{ value }}
1108
+ </text>
1109
+ </view>
1110
+
1111
+ <textarea
1112
+ v-else-if="isArrayProp(prop) || isObjectProp(prop)"
1113
+ :value="propControls[prop.name]"
1114
+ class="control-textarea"
1115
+ :placeholder="prop.description || prop.name"
1116
+ @input="setControlValue(prop.name, $event.detail.value)"
1117
+ />
1118
+
1119
+ <input
1120
+ v-else
1121
+ :value="propControls[prop.name]"
1122
+ class="control-input"
1123
+ :type="isNumberProp(prop) ? 'number' : 'text'"
1124
+ :placeholder="prop.description || prop.name"
1125
+ @input="setControlValue(prop.name, $event.detail.value)"
1126
+ />
1127
+ </view>
1128
+ </view>
1129
+ </template>
1130
+
1131
+ <template #code>
1132
+ <view class="code-card-mini">
1133
+ <text class="code-text-mini">{{ codeSnippet }}</text>
1134
+ </view>
1135
+ </template>
1136
+ </${componentDocPageName}>
1137
+ </template>
1138
+
1139
+ <style lang="scss">
1140
+ ${renderGeneratedControlStyles()}
1141
+ </style>
1142
+ `;
1143
+ }
1144
+
1145
+ function renderGenericDocPage() {
1146
+ const { componentDocPageImport, componentDocPageName } = docsConfig.runtime;
1147
+
1148
+ return `<script setup lang="ts">
1149
+ import ${componentDocPageName} from '${componentDocPageImport}';
1150
+ import doc from './data.json';
1151
+ </script>
1152
+
1153
+ <template>
1154
+ <${componentDocPageName} :doc="doc">
1155
+ <template #preview>
1156
+ <view class="preview-frame">
1157
+ <text class="empty-preview">该组件已生成基础文档页,交互预览待补充。</text>
1158
+ </view>
1159
+ </template>
1160
+ </${componentDocPageName}>
1161
+ </template>
1162
+
1163
+ <style lang="scss">
1164
+ .empty-preview {
1165
+ display: block;
1166
+ font-size: 24rpx;
1167
+ line-height: 1.6;
1168
+ color: #64748b;
1169
+ overflow-wrap: anywhere;
1170
+ }
1171
+ </style>
1172
+ `;
1173
+ }
1174
+
1175
+ function ensureAutoDocPage(componentName, componentFile) {
1176
+ const pageFile = path.join(pageDataRoot, componentName, 'index.vue');
1177
+ if (!isAutoGeneratedDocPage(pageFile)) return false;
1178
+
1179
+ writeText(pageFile, renderAutoDocPage(componentName, componentFile));
1180
+ return true;
1181
+ }
1182
+
1183
+ function ensureDocsRoutes(componentNames) {
1184
+ if (!docsConfig.routes.enabled) return [];
1185
+ const pagesFile = pagesJsonFile;
1186
+ if (!fs.existsSync(pagesFile)) return [];
1187
+
1188
+ let source = fs.readFileSync(pagesFile, 'utf8');
1189
+ const added = [];
1190
+ const indexPath = docsConfig.routes.indexPath;
1191
+
1192
+ if (indexPath && !source.includes(`"path": "${indexPath}"`)) {
1193
+ source = insertPageRoute(source, createPageRouteEntry(indexPath, docsConfig.routes.indexTitle));
1194
+ added.push(indexPath);
1195
+ }
1196
+
1197
+ for (const componentName of componentNames) {
1198
+ const routePath = getDocsRoute(componentName);
1199
+ if (source.includes(`"path": "${routePath}"`)) continue;
1200
+
1201
+ source = insertPageRoute(source, createPageRouteEntry(routePath, `${componentName}${docsConfig.routes.titleSuffix}`));
1202
+
1203
+ added.push(componentName);
1204
+ }
1205
+
1206
+ if (added.length) {
1207
+ fs.writeFileSync(pagesFile, source);
1208
+ }
1209
+
1210
+ return added;
1211
+ }
1212
+
1213
+ function hasDocPageShell(pageSource) {
1214
+ return docsConfig.quality.shellHints.some((hint) => pageSource.includes(hint));
1215
+ }
1216
+
1217
+ function initComponentMetas() {
1218
+ const componentDirs = findAllComponentDirs();
1219
+ const created = [];
1220
+ const enriched = [];
1221
+ const untouched = [];
1222
+ const createdPages = [];
1223
+ const preservedPages = [];
1224
+
1225
+ for (const componentDir of componentDirs) {
1226
+ const metaFile = getMetaFile(componentDir);
1227
+ const componentName = path.basename(componentDir);
1228
+ const inferredMeta = inferInitialMeta(componentDir);
1229
+
1230
+ if (fs.existsSync(metaFile)) {
1231
+ const existingMeta = readJson(metaFile);
1232
+ const mergedMeta = mergeMeta(existingMeta, inferredMeta);
1233
+
1234
+ if (JSON.stringify(existingMeta) !== JSON.stringify(mergedMeta)) {
1235
+ writeJson(metaFile, mergedMeta);
1236
+ enriched.push(componentName);
1237
+ } else {
1238
+ untouched.push(componentName);
1239
+ }
1240
+
1241
+ continue;
1242
+ }
1243
+
1244
+ writeJson(metaFile, inferredMeta);
1245
+ created.push(componentName);
1246
+ }
1247
+
1248
+ for (const componentDir of componentDirs) {
1249
+ const componentName = path.basename(componentDir);
1250
+ const componentFile = findComponentFile(componentDir);
1251
+
1252
+ if (ensureAutoDocPage(componentName, componentFile)) {
1253
+ createdPages.push(componentName);
1254
+ } else {
1255
+ preservedPages.push(componentName);
1256
+ }
1257
+ }
1258
+
1259
+ const addedRoutes = ensureDocsRoutes(componentDirs.map((componentDir) => path.basename(componentDir)));
1260
+
1261
+ console.log(`Initialized ${docsConfig.metaFileName} for ${created.length} component(s): ${created.join(', ') || 'none'}`);
1262
+ console.log(`Enriched ${docsConfig.metaFileName} for ${enriched.length} component(s): ${enriched.join(', ') || 'none'}`);
1263
+ console.log(`Preserved ${docsConfig.metaFileName} for ${untouched.length} component(s): ${untouched.join(', ') || 'none'}`);
1264
+ console.log(`Created or refreshed auto docs page for ${createdPages.length} component(s): ${createdPages.join(', ') || 'none'}`);
1265
+ console.log(`Preserved custom docs page for ${preservedPages.length} component(s): ${preservedPages.join(', ') || 'none'}`);
1266
+ console.log(`Registered docs route for ${addedRoutes.length} component(s): ${addedRoutes.join(', ') || 'none'}`);
1267
+ }
1268
+
1269
+ function collectComponentDoc(componentDir) {
1270
+ const componentFile = findComponentFile(componentDir);
1271
+ if (!componentFile) return null;
1272
+
1273
+ const componentName = path.basename(componentDir);
1274
+ const sfcSource = fs.readFileSync(componentFile, 'utf8');
1275
+ const script = extractSfcBlock(sfcSource, 'script', (attrs) => attrs.includes('setup'));
1276
+ const template = extractSfcBlock(sfcSource, 'template');
1277
+ const meta = readJson(getMetaFile(componentDir));
1278
+ const jsdoc = parseJsdoc(script);
1279
+ const aliases = collectTypeAliases(componentDir);
1280
+ const props = extractProps(script, componentFile, aliases, jsdoc);
1281
+ const events = extractEvents(script, componentFile, jsdoc);
1282
+ const slots = extractSlots(template);
1283
+
1284
+ const component = {
1285
+ name: componentName,
1286
+ title: meta.title || jsdoc.description || componentName,
1287
+ category: meta.category || '未分类',
1288
+ description: meta.description || jsdoc.description || '',
1289
+ source: toPosix(path.relative(rootDir, componentFile)),
1290
+ meta: toPosix(path.relative(rootDir, getMetaFile(componentDir))),
1291
+ props,
1292
+ events,
1293
+ slots,
1294
+ examples: normalizeExamples(meta.examples, jsdoc.examples),
1295
+ useWhen: meta.useWhen || [],
1296
+ avoidWhen: meta.avoidWhen || [],
1297
+ notes: meta.notes || [],
1298
+ related: meta.related || [],
1299
+ };
1300
+
1301
+ Object.defineProperties(component, {
1302
+ __jsdocProps: { value: [...jsdoc.props.keys()] },
1303
+ __jsdocEvents: { value: [...jsdoc.events.keys()] },
1304
+ });
1305
+
1306
+ return component;
1307
+ }
1308
+
1309
+ function renderList(items, emptyText = '暂无') {
1310
+ if (!items?.length) return `- ${emptyText}`;
1311
+
1312
+ return items.map((item) => `- ${item}`).join('\n');
1313
+ }
1314
+
1315
+ function renderDefault(value) {
1316
+ if (value === null || value === undefined) return '未设置';
1317
+ if (typeof value === 'string') return value ? `"${value}"` : '空字符串';
1318
+
1319
+ return String(value);
1320
+ }
1321
+
1322
+ function renderProps(props) {
1323
+ if (!props.length) return '暂无 Props。';
1324
+
1325
+ return props
1326
+ .map((prop) => {
1327
+ const lines = [
1328
+ `### ${prop.name}`,
1329
+ `- 类型:\`${prop.type}\``,
1330
+ `- 默认值:\`${renderDefault(prop.default)}\``,
1331
+ `- 必填:${prop.required ? '是' : '否'}`,
1332
+ ];
1333
+
1334
+ if (prop.values?.length) lines.push(`- 可选值:${prop.values.map((value) => `\`${value}\``).join('、')}`);
1335
+ if (prop.description) lines.push(`- 说明:${prop.description}`);
1336
+ if (prop.typeSource) lines.push(`- 类型来源:\`${prop.typeSource}\``);
1337
+
1338
+ return lines.join('\n');
1339
+ })
1340
+ .join('\n\n');
1341
+ }
1342
+
1343
+ function renderEvents(events) {
1344
+ if (!events.length) return '暂无 Events。';
1345
+
1346
+ return events
1347
+ .map((event) => {
1348
+ const lines = [`### ${event.name}`, `- 类型:\`${event.type}\``];
1349
+ if (event.description) lines.push(`- 说明:${event.description}`);
1350
+
1351
+ return lines.join('\n');
1352
+ })
1353
+ .join('\n\n');
1354
+ }
1355
+
1356
+ function renderSlots(slots) {
1357
+ if (!slots.length) return '暂无 Slots。';
1358
+
1359
+ return slots
1360
+ .map((slot) => {
1361
+ const lines = [`### ${slot.name}`];
1362
+ if (slot.description) lines.push(`- 说明:${slot.description}`);
1363
+ if (slot.props?.length) lines.push(`- Slot Props:${slot.props.map((item) => `\`${item}\``).join('、')}`);
1364
+
1365
+ return lines.join('\n');
1366
+ })
1367
+ .join('\n\n');
1368
+ }
1369
+
1370
+ function renderExamples(examples) {
1371
+ if (!examples.length) return '暂无示例。';
1372
+
1373
+ return examples
1374
+ .map((example) => {
1375
+ const description = example.description ? `\n${example.description}\n` : '';
1376
+
1377
+ return `### ${example.title}${description}\n\`\`\`vue\n${example.code}\n\`\`\``;
1378
+ })
1379
+ .join('\n\n');
1380
+ }
1381
+
1382
+ function renderComponentMarkdown(component) {
1383
+ return `# ${component.name} - ${component.title}
1384
+
1385
+ ${component.description}
1386
+
1387
+ - 分类:${component.category}
1388
+ - 源码:\`${component.source}\`
1389
+ - 元数据:\`${component.meta}\`
1390
+
1391
+ ## 何时使用
1392
+
1393
+ ${renderList(component.useWhen)}
1394
+
1395
+ ## 不建议使用
1396
+
1397
+ ${renderList(component.avoidWhen)}
1398
+
1399
+ ## Props
1400
+
1401
+ ${renderProps(component.props)}
1402
+
1403
+ ## Events
1404
+
1405
+ ${renderEvents(component.events)}
1406
+
1407
+ ## Slots
1408
+
1409
+ ${renderSlots(component.slots)}
1410
+
1411
+ ## 注意事项
1412
+
1413
+ ${renderList(component.notes)}
1414
+
1415
+ ## 示例
1416
+
1417
+ ${renderExamples(component.examples)}
1418
+
1419
+ ## 相关组件
1420
+
1421
+ ${renderList(component.related)}
1422
+ `;
1423
+ }
1424
+
1425
+ function renderAiIndex(catalog) {
1426
+ const componentSections = catalog.components
1427
+ .map((component) => {
1428
+ return `## ${component.name}
1429
+
1430
+ - 标题:${component.title}
1431
+ - 分类:${component.category}
1432
+ - 描述:${component.description}
1433
+ - 源码:\`${component.source}\`
1434
+
1435
+ ### Props
1436
+
1437
+ ${renderProps(component.props)}
1438
+
1439
+ ### Events
1440
+
1441
+ ${renderEvents(component.events)}
1442
+
1443
+ ### Slots
1444
+
1445
+ ${renderSlots(component.slots)}
1446
+
1447
+ ### 何时使用
1448
+
1449
+ ${renderList(component.useWhen)}
1450
+
1451
+ ### 不建议使用
1452
+
1453
+ ${renderList(component.avoidWhen)}
1454
+
1455
+ ### 注意事项
1456
+
1457
+ ${renderList(component.notes)}
1458
+
1459
+ ### 示例
1460
+
1461
+ ${renderExamples(component.examples)}
1462
+ `;
1463
+ })
1464
+ .join('\n');
1465
+
1466
+ return `# Components AI Index
1467
+
1468
+ 本文件由 \`${generatedBy}\` 生成,供 AI 和检索系统读取。结构化 JSON 以 \`${toPosix(path.relative(rootDir, path.join(aiDocsDir, docsConfig.output.catalogFile)))}\` 为准。
1469
+
1470
+ ${componentSections}
1471
+ `;
1472
+ }
1473
+
1474
+ function renderLlmsTxt(catalog) {
1475
+ const catalogPath = toPosix(path.relative(rootDir, path.join(aiDocsDir, docsConfig.output.catalogFile)));
1476
+ const aiIndexPath = toPosix(path.relative(rootDir, path.join(aiDocsDir, docsConfig.output.aiIndexFile)));
1477
+ const markdownPath = `${toPosix(path.relative(rootDir, markdownDocsDir))}/*.md`;
1478
+ const pagePath = `${toPosix(path.relative(rootDir, pageDataRoot))}/*/index.vue`;
1479
+ const lines = [
1480
+ `# ${docsConfig.projectName} component docs`,
1481
+ '',
1482
+ `Use ${catalogPath} as the canonical structured component API catalog.`,
1483
+ `Use ${aiIndexPath} for a readable Markdown summary of the same catalog.`,
1484
+ `Human-facing component pages live in ${markdownPath} and ${pagePath}.`,
1485
+ '',
1486
+ ];
1487
+
1488
+ for (const component of catalog.components) {
1489
+ lines.push(`## ${component.name}`);
1490
+ lines.push(`Title: ${component.title}`);
1491
+ lines.push(`Category: ${component.category}`);
1492
+ lines.push(`Description: ${component.description}`);
1493
+ lines.push(`Source: ${component.source}`);
1494
+ lines.push(`Props: ${component.props.map((prop) => `${prop.name}:${prop.type}`).join(', ') || 'none'}`);
1495
+ lines.push(`Events: ${component.events.map((event) => event.name).join(', ') || 'none'}`);
1496
+ lines.push(`Slots: ${component.slots.map((slot) => slot.name).join(', ') || 'none'}`);
1497
+ lines.push(`UseWhen: ${component.useWhen.join(' | ') || 'none'}`);
1498
+ lines.push(`AvoidWhen: ${component.avoidWhen.join(' | ') || 'none'}`);
1499
+ lines.push('');
1500
+ }
1501
+
1502
+ return lines.join('\n');
1503
+ }
1504
+
1505
+ function buildPageIndex(components) {
1506
+ return {
1507
+ generatedBy,
1508
+ components: components.map((component) => ({
1509
+ name: component.name,
1510
+ title: component.title,
1511
+ category: component.category,
1512
+ description: component.description,
1513
+ route: `/${getDocsRoute(component.name)}`,
1514
+ source: component.source,
1515
+ propsCount: component.props.length,
1516
+ eventsCount: component.events.length,
1517
+ slotsCount: component.slots.length,
1518
+ })),
1519
+ };
1520
+ }
1521
+
1522
+ function collectQualityReport(components) {
1523
+ const allComponentNames = new Set(
1524
+ fs
1525
+ .readdirSync(componentRoot, { withFileTypes: true })
1526
+ .filter((entry) => entry.isDirectory())
1527
+ .map((entry) => entry.name),
1528
+ );
1529
+ const documentedNames = new Set(components.map((component) => component.name));
1530
+ const warnings = [];
1531
+ const errors = [];
1532
+
1533
+ const addWarning = (component, message) => warnings.push({ component, message });
1534
+ const addError = (component, message) => errors.push({ component, message });
1535
+
1536
+ for (const component of components) {
1537
+ if (!component.title) addError(component.name, 'missing title');
1538
+ if (!component.category) addError(component.name, 'missing category');
1539
+ if (!component.description) addError(component.name, 'missing description');
1540
+ if (!component.examples.length) addWarning(component.name, 'missing examples');
1541
+ if (!fs.existsSync(path.join(rootDir, component.source))) {
1542
+ addError(component.name, `source not found: ${component.source}`);
1543
+ }
1544
+
1545
+ const pageFile = path.join(pageDataRoot, component.name, 'index.vue');
1546
+ if (docsConfig.quality.requireDocPage && !fs.existsSync(pageFile)) {
1547
+ addWarning(component.name, `docs page not found: ${toPosix(path.relative(rootDir, pageFile))}`);
1548
+ } else if (fs.existsSync(pageFile)) {
1549
+ const pageSource = fs.readFileSync(pageFile, 'utf8');
1550
+ if (!hasDocPageShell(pageSource)) {
1551
+ addWarning(
1552
+ component.name,
1553
+ `docs page should include one of configured shell hints: ${docsConfig.quality.shellHints.join(', ')}`,
1554
+ );
1555
+ }
1556
+ }
1557
+
1558
+ const propNames = new Set(component.props.map((prop) => prop.name));
1559
+ const eventNames = new Set(component.events.map((event) => event.name));
1560
+
1561
+ for (const prop of component.props) {
1562
+ if (!prop.type || prop.type === 'unknown') addWarning(component.name, `prop "${prop.name}" has unknown type`);
1563
+ if (!prop.description) addWarning(component.name, `prop "${prop.name}" missing description`);
1564
+ }
1565
+
1566
+ for (const event of component.events) {
1567
+ if (!event.description) addWarning(component.name, `event "${event.name}" missing description`);
1568
+ }
1569
+
1570
+ for (const jsdocProp of component.__jsdocProps || []) {
1571
+ if (!propNames.has(jsdocProp)) {
1572
+ addWarning(component.name, `JSDoc @property "${jsdocProp}" does not match defineProps`);
1573
+ }
1574
+ }
1575
+
1576
+ for (const jsdocEvent of component.__jsdocEvents || []) {
1577
+ if (!eventNames.has(jsdocEvent)) {
1578
+ addWarning(component.name, `JSDoc @event "${jsdocEvent}" does not match defineEmits`);
1579
+ }
1580
+ }
1581
+
1582
+ for (const related of component.related) {
1583
+ if (!allComponentNames.has(related)) {
1584
+ addWarning(component.name, `related component "${related}" does not exist in ${docsConfig.componentRoot}`);
1585
+ } else if (!documentedNames.has(related)) {
1586
+ addWarning(component.name, `related component "${related}" is not documented yet`);
1587
+ }
1588
+ }
1589
+ }
1590
+
1591
+ return {
1592
+ generatedBy,
1593
+ componentsCount: components.length,
1594
+ errors,
1595
+ warnings,
1596
+ ok: errors.length === 0,
1597
+ };
1598
+ }
1599
+
1600
+ function main() {
1601
+ if (isInitMode) {
1602
+ initComponentMetas();
1603
+ return;
1604
+ }
1605
+
1606
+ const components = findComponentDirs().map(collectComponentDoc).filter(Boolean);
1607
+
1608
+ if (!components.length) {
1609
+ console.warn(
1610
+ `No component docs found. Add ${docsConfig.metaFileName} under ${docsConfig.componentRoot}/<component-name>.`,
1611
+ );
1612
+ return;
1613
+ }
1614
+
1615
+ const catalog = {
1616
+ schemaVersion: '1.0.0',
1617
+ generatedBy,
1618
+ components,
1619
+ };
1620
+
1621
+ writeJson(path.join(aiDocsDir, docsConfig.output.catalogFile), catalog);
1622
+ writeText(path.join(aiDocsDir, docsConfig.output.aiIndexFile), renderAiIndex(catalog));
1623
+ writeText(path.join(aiDocsDir, docsConfig.output.llmsFile), renderLlmsTxt(catalog));
1624
+ writeJson(indexDataFile, buildPageIndex(components));
1625
+ const qualityReport = collectQualityReport(components);
1626
+ writeJson(path.join(aiDocsDir, docsConfig.output.qualityFile), qualityReport);
1627
+
1628
+ for (const component of components) {
1629
+ writeText(path.join(markdownDocsDir, `${component.name}.md`), renderComponentMarkdown(component));
1630
+ writeJson(path.join(pageDataRoot, component.name, 'data.json'), component);
1631
+ }
1632
+
1633
+ console.log(`Generated component docs for ${components.length} component(s): ${components.map((item) => item.name).join(', ')}`);
1634
+ console.log(
1635
+ `Docs quality: ${qualityReport.errors.length} error(s), ${qualityReport.warnings.length} warning(s). See ${toPosix(path.relative(rootDir, path.join(aiDocsDir, docsConfig.output.qualityFile)))}`,
1636
+ );
1637
+
1638
+ if (isCheckMode && qualityReport.errors.length) {
1639
+ process.exitCode = 1;
1640
+ }
1641
+ }
1642
+
1643
+ main();