Skip to content

Commit a1b8b71

Browse files
feat(component-meta): add component name and description fields (#5797)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Johnson Chu <[email protected]>
1 parent 8edc02f commit a1b8b71

File tree

8 files changed

+410
-42
lines changed

8 files changed

+410
-42
lines changed

packages/component-meta/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ const meta = checker.getComponentMeta(componentPath);
4242

4343
This meta contains really useful stuff like component props, slots, events and more. You can refer to its [type definition](https://github.com/vuejs/language-tools/blob/master/packages/component-meta/lib/types.ts) for more details.
4444

45+
### Extracting component name and description
46+
47+
The component meta also includes `name` and `description` fields at the root level:
48+
49+
- **`name`**: Extracted from the `name` property in the component options (for Options API components)
50+
- **`description`**: Extracted from JSDoc comments above the component export (for TypeScript/JavaScript files)
51+
52+
```ts
53+
/**
54+
* My awesome component description
55+
*/
56+
export default defineComponent({
57+
name: 'MyComponent',
58+
// ... component definition
59+
})
60+
```
61+
62+
When you extract the component meta, you'll get:
63+
```ts
64+
meta.name // 'MyComponent'
65+
meta.description // 'My awesome component description'
66+
```
67+
4568
### Extracting prop meta
4669

4770
`vue-component-meta` will automatically extract the prop details like its name, default value, is required or not, etc. Additionally, you can even write prop description in source code via [JSDoc](https://jsdoc.app/) comment for that prop.

packages/component-meta/lib/base.ts

Lines changed: 184 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,63 @@ export * from './types';
1818

1919
const windowsPathReg = /\\/g;
2020

21+
// Utility function to get the component node from an AST
22+
function getComponentNodeFromAst(
23+
ast: ts.SourceFile,
24+
exportName: string,
25+
ts: typeof import('typescript'),
26+
): ts.Node | undefined {
27+
let result: ts.Node | undefined;
28+
29+
if (exportName === 'default') {
30+
ast.forEachChild(child => {
31+
if (ts.isExportAssignment(child)) {
32+
result = child.expression;
33+
}
34+
});
35+
}
36+
else {
37+
ast.forEachChild(child => {
38+
if (
39+
ts.isVariableStatement(child)
40+
&& child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
41+
) {
42+
for (const dec of child.declarationList.declarations) {
43+
if (dec.name.getText(ast) === exportName) {
44+
result = dec.initializer;
45+
}
46+
}
47+
}
48+
});
49+
}
50+
51+
return result;
52+
}
53+
54+
// Utility function to get the component options node from a component node
55+
function getComponentOptionsNodeFromComponent(
56+
component: ts.Node | undefined,
57+
ts: typeof import('typescript'),
58+
): ts.ObjectLiteralExpression | undefined {
59+
if (component) {
60+
// export default { ... }
61+
if (ts.isObjectLiteralExpression(component)) {
62+
return component;
63+
}
64+
// export default defineComponent({ ... })
65+
else if (ts.isCallExpression(component)) {
66+
if (component.arguments.length) {
67+
const arg = component.arguments[0]!;
68+
if (ts.isObjectLiteralExpression(arg)) {
69+
return arg;
70+
}
71+
}
72+
}
73+
}
74+
75+
return undefined;
76+
}
77+
2178
export function createCheckerByJsonConfigBase(
2279
ts: typeof import('typescript'),
2380
rootDir: string,
@@ -282,8 +339,16 @@ interface ComponentMeta<T> {
282339
let _events: ReturnType<typeof getEvents> | undefined;
283340
let _slots: ReturnType<typeof getSlots> | undefined;
284341
let _exposed: ReturnType<typeof getExposed> | undefined;
342+
let _name: string | undefined;
343+
let _description: string | undefined;
285344

286345
const meta = {
346+
get name() {
347+
return _name ?? (_name = getName());
348+
},
349+
get description() {
350+
return _description ?? (_description = getDescription());
351+
},
287352
get type() {
288353
return _type ?? (_type = getType());
289354
},
@@ -450,6 +515,42 @@ interface ComponentMeta<T> {
450515

451516
return [];
452517
}
518+
519+
function getName() {
520+
// Try to get name from component options
521+
const sourceScript = language.scripts.get(componentPath)!;
522+
const { snapshot } = sourceScript;
523+
const vueFile = sourceScript.generated?.root;
524+
525+
if (vueFile && exportName === 'default' && vueFile instanceof core.VueVirtualCode) {
526+
// For Vue SFC, check the script section
527+
const { sfc } = vueFile;
528+
if (sfc.script) {
529+
const name = readComponentName(sfc.script.ast, exportName, ts);
530+
if (name) {
531+
return name;
532+
}
533+
}
534+
}
535+
else if (!vueFile) {
536+
// For TS/JS files
537+
const ast = ts.createSourceFile(
538+
'/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1),
539+
snapshot.getText(0, snapshot.getLength()),
540+
ts.ScriptTarget.Latest,
541+
);
542+
return readComponentName(ast, exportName, ts);
543+
}
544+
545+
return undefined;
546+
}
547+
548+
function getDescription() {
549+
const sourceFile = program.getSourceFile(componentPath);
550+
if (sourceFile) {
551+
return readComponentDescription(sourceFile, exportName, ts, typeChecker);
552+
}
553+
}
453554
}
454555

455556
function _getExports(
@@ -877,51 +978,12 @@ function readTsComponentDefaultProps(
877978
return {};
878979

879980
function getComponentNode() {
880-
let result: ts.Node | undefined;
881-
882-
if (exportName === 'default') {
883-
ast.forEachChild(child => {
884-
if (ts.isExportAssignment(child)) {
885-
result = child.expression;
886-
}
887-
});
888-
}
889-
else {
890-
ast.forEachChild(child => {
891-
if (
892-
ts.isVariableStatement(child)
893-
&& child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
894-
) {
895-
for (const dec of child.declarationList.declarations) {
896-
if (dec.name.getText(ast) === exportName) {
897-
result = dec.initializer;
898-
}
899-
}
900-
}
901-
});
902-
}
903-
904-
return result;
981+
return getComponentNodeFromAst(ast, exportName, ts);
905982
}
906983

907984
function getComponentOptionsNode() {
908985
const component = getComponentNode();
909-
910-
if (component) {
911-
// export default { ... }
912-
if (ts.isObjectLiteralExpression(component)) {
913-
return component;
914-
}
915-
// export default defineComponent({ ... })
916-
else if (ts.isCallExpression(component)) {
917-
if (component.arguments.length) {
918-
const arg = component.arguments[0]!;
919-
if (ts.isObjectLiteralExpression(arg)) {
920-
return arg;
921-
}
922-
}
923-
}
924-
}
986+
return getComponentOptionsNodeFromComponent(component, ts);
925987
}
926988

927989
function getPropsNode() {
@@ -1011,3 +1073,84 @@ function resolveDefaultOptionExpression(
10111073
}
10121074
return _default;
10131075
}
1076+
1077+
function readComponentName(
1078+
ast: ts.SourceFile,
1079+
exportName: string,
1080+
ts: typeof import('typescript'),
1081+
): string | undefined {
1082+
const componentNode = getComponentNodeFromAst(ast, exportName, ts);
1083+
const optionsNode = getComponentOptionsNodeFromComponent(componentNode, ts);
1084+
1085+
if (optionsNode) {
1086+
const nameProp = optionsNode.properties.find(
1087+
prop => ts.isPropertyAssignment(prop) && prop.name?.getText(ast) === 'name',
1088+
);
1089+
1090+
if (nameProp && ts.isPropertyAssignment(nameProp) && ts.isStringLiteral(nameProp.initializer)) {
1091+
return nameProp.initializer.text;
1092+
}
1093+
}
1094+
1095+
return undefined;
1096+
}
1097+
1098+
function readComponentDescription(
1099+
ast: ts.SourceFile,
1100+
exportName: string,
1101+
ts: typeof import('typescript'),
1102+
typeChecker: ts.TypeChecker,
1103+
): string | undefined {
1104+
const exportNode = getExportNode();
1105+
1106+
if (exportNode) {
1107+
// Try to get JSDoc comments from the node using TypeScript API
1108+
const jsDocComments = ts.getJSDocCommentsAndTags(exportNode);
1109+
for (const jsDoc of jsDocComments) {
1110+
if (ts.isJSDoc(jsDoc) && jsDoc.comment) {
1111+
// Handle both string and array of comment parts
1112+
if (typeof jsDoc.comment === 'string') {
1113+
return jsDoc.comment;
1114+
}
1115+
else if (Array.isArray(jsDoc.comment)) {
1116+
return jsDoc.comment.map(part => (part as any).text || '').join('');
1117+
}
1118+
}
1119+
}
1120+
1121+
// Fallback to symbol documentation
1122+
const symbol = typeChecker.getSymbolAtLocation(exportNode);
1123+
if (symbol) {
1124+
const description = ts.displayPartsToString(symbol.getDocumentationComment(typeChecker));
1125+
return description || undefined;
1126+
}
1127+
}
1128+
1129+
return undefined;
1130+
1131+
function getExportNode() {
1132+
let result: ts.Node | undefined;
1133+
1134+
if (exportName === 'default') {
1135+
ast.forEachChild(child => {
1136+
if (ts.isExportAssignment(child)) {
1137+
// Return the export assignment itself, not the expression
1138+
result = child;
1139+
}
1140+
});
1141+
}
1142+
else {
1143+
ast.forEachChild(child => {
1144+
if (
1145+
ts.isVariableStatement(child)
1146+
&& child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
1147+
) {
1148+
// Return the variable statement itself
1149+
result = child;
1150+
}
1151+
});
1152+
}
1153+
1154+
return result;
1155+
}
1156+
}

packages/component-meta/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface Declaration {
88
}
99

1010
export interface ComponentMeta {
11+
name?: string;
12+
description?: string;
1113
type: TypeMeta;
1214
props: PropertyMeta[];
1315
events: EventMeta[];

packages/component-meta/tests/index.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,42 @@ const worker = (checker: ComponentMetaChecker, withTsconfig: boolean) =>
13941394
}
13951395
`);
13961396
});
1397+
1398+
test('component-name-description (vue)', () => {
1399+
const componentPath = path.resolve(
1400+
__dirname,
1401+
'../../../test-workspace/component-meta/component-name-description/component.vue',
1402+
);
1403+
const meta = checker.getComponentMeta(componentPath);
1404+
1405+
expect(meta.name).toBe('MyComponent');
1406+
expect(meta.description).toBe('My awesome component description');
1407+
expect(meta.type).toEqual(TypeMeta.Class);
1408+
});
1409+
1410+
test('component-name-description (ts)', () => {
1411+
const componentPath = path.resolve(
1412+
__dirname,
1413+
'../../../test-workspace/component-meta/component-name-description/component-ts.ts',
1414+
);
1415+
const meta = checker.getComponentMeta(componentPath);
1416+
1417+
expect(meta.name).toBe('TsComponent');
1418+
expect(meta.description).toBe('TypeScript component with description');
1419+
expect(meta.type).toEqual(TypeMeta.Class);
1420+
});
1421+
1422+
test('component-no-name (vue)', () => {
1423+
const componentPath = path.resolve(
1424+
__dirname,
1425+
'../../../test-workspace/component-meta/component-name-description/component-no-name.vue',
1426+
);
1427+
const meta = checker.getComponentMeta(componentPath);
1428+
1429+
expect(meta.name).toBeUndefined();
1430+
expect(meta.description).toBeUndefined();
1431+
expect(meta.type).toEqual(TypeMeta.Class);
1432+
});
13971433
});
13981434

13991435
const checkerOptions: MetaCheckerOptions = {

0 commit comments

Comments
 (0)