diff --git a/packages/component-meta/README.md b/packages/component-meta/README.md index 69ea054024..f12d871ed8 100644 --- a/packages/component-meta/README.md +++ b/packages/component-meta/README.md @@ -42,6 +42,29 @@ const meta = checker.getComponentMeta(componentPath); 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. +### Extracting component name and description + +The component meta also includes `name` and `description` fields at the root level: + +- **`name`**: Extracted from the `name` property in the component options (for Options API components) +- **`description`**: Extracted from JSDoc comments above the component export (for TypeScript/JavaScript files) + +```ts +/** + * My awesome component description + */ +export default defineComponent({ + name: 'MyComponent', + // ... component definition +}) +``` + +When you extract the component meta, you'll get: +```ts +meta.name // 'MyComponent' +meta.description // 'My awesome component description' +``` + ### Extracting prop meta `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. diff --git a/packages/component-meta/lib/base.ts b/packages/component-meta/lib/base.ts index 418ce8f2e8..bf7daec080 100644 --- a/packages/component-meta/lib/base.ts +++ b/packages/component-meta/lib/base.ts @@ -18,6 +18,63 @@ export * from './types'; const windowsPathReg = /\\/g; +// Utility function to get the component node from an AST +function getComponentNodeFromAst( + ast: ts.SourceFile, + exportName: string, + ts: typeof import('typescript'), +): ts.Node | undefined { + let result: ts.Node | undefined; + + if (exportName === 'default') { + ast.forEachChild(child => { + if (ts.isExportAssignment(child)) { + result = child.expression; + } + }); + } + else { + ast.forEachChild(child => { + if ( + ts.isVariableStatement(child) + && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) + ) { + for (const dec of child.declarationList.declarations) { + if (dec.name.getText(ast) === exportName) { + result = dec.initializer; + } + } + } + }); + } + + return result; +} + +// Utility function to get the component options node from a component node +function getComponentOptionsNodeFromComponent( + component: ts.Node | undefined, + ts: typeof import('typescript'), +): ts.ObjectLiteralExpression | undefined { + if (component) { + // export default { ... } + if (ts.isObjectLiteralExpression(component)) { + return component; + } + // export default defineComponent({ ... }) + else if (ts.isCallExpression(component)) { + if (component.arguments.length) { + const arg = component.arguments[0]!; + if (ts.isObjectLiteralExpression(arg)) { + return arg; + } + } + } + } + + return undefined; +} + export function createCheckerByJsonConfigBase( ts: typeof import('typescript'), rootDir: string, @@ -282,8 +339,16 @@ interface ComponentMeta { let _events: ReturnType | undefined; let _slots: ReturnType | undefined; let _exposed: ReturnType | undefined; + let _name: string | undefined; + let _description: string | undefined; const meta = { + get name() { + return _name ?? (_name = getName()); + }, + get description() { + return _description ?? (_description = getDescription()); + }, get type() { return _type ?? (_type = getType()); }, @@ -450,6 +515,42 @@ interface ComponentMeta { return []; } + + function getName() { + // Try to get name from component options + const sourceScript = language.scripts.get(componentPath)!; + const { snapshot } = sourceScript; + const vueFile = sourceScript.generated?.root; + + if (vueFile && exportName === 'default' && vueFile instanceof core.VueVirtualCode) { + // For Vue SFC, check the script section + const { sfc } = vueFile; + if (sfc.script) { + const name = readComponentName(sfc.script.ast, exportName, ts); + if (name) { + return name; + } + } + } + else if (!vueFile) { + // For TS/JS files + const ast = ts.createSourceFile( + '/tmp.' + componentPath.slice(componentPath.lastIndexOf('.') + 1), + snapshot.getText(0, snapshot.getLength()), + ts.ScriptTarget.Latest, + ); + return readComponentName(ast, exportName, ts); + } + + return undefined; + } + + function getDescription() { + const sourceFile = program.getSourceFile(componentPath); + if (sourceFile) { + return readComponentDescription(sourceFile, exportName, ts, typeChecker); + } + } } function _getExports( @@ -877,51 +978,12 @@ function readTsComponentDefaultProps( return {}; function getComponentNode() { - let result: ts.Node | undefined; - - if (exportName === 'default') { - ast.forEachChild(child => { - if (ts.isExportAssignment(child)) { - result = child.expression; - } - }); - } - else { - ast.forEachChild(child => { - if ( - ts.isVariableStatement(child) - && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) - ) { - for (const dec of child.declarationList.declarations) { - if (dec.name.getText(ast) === exportName) { - result = dec.initializer; - } - } - } - }); - } - - return result; + return getComponentNodeFromAst(ast, exportName, ts); } function getComponentOptionsNode() { const component = getComponentNode(); - - if (component) { - // export default { ... } - if (ts.isObjectLiteralExpression(component)) { - return component; - } - // export default defineComponent({ ... }) - else if (ts.isCallExpression(component)) { - if (component.arguments.length) { - const arg = component.arguments[0]!; - if (ts.isObjectLiteralExpression(arg)) { - return arg; - } - } - } - } + return getComponentOptionsNodeFromComponent(component, ts); } function getPropsNode() { @@ -1011,3 +1073,84 @@ function resolveDefaultOptionExpression( } return _default; } + +function readComponentName( + ast: ts.SourceFile, + exportName: string, + ts: typeof import('typescript'), +): string | undefined { + const componentNode = getComponentNodeFromAst(ast, exportName, ts); + const optionsNode = getComponentOptionsNodeFromComponent(componentNode, ts); + + if (optionsNode) { + const nameProp = optionsNode.properties.find( + prop => ts.isPropertyAssignment(prop) && prop.name?.getText(ast) === 'name', + ); + + if (nameProp && ts.isPropertyAssignment(nameProp) && ts.isStringLiteral(nameProp.initializer)) { + return nameProp.initializer.text; + } + } + + return undefined; +} + +function readComponentDescription( + ast: ts.SourceFile, + exportName: string, + ts: typeof import('typescript'), + typeChecker: ts.TypeChecker, +): string | undefined { + const exportNode = getExportNode(); + + if (exportNode) { + // Try to get JSDoc comments from the node using TypeScript API + const jsDocComments = ts.getJSDocCommentsAndTags(exportNode); + for (const jsDoc of jsDocComments) { + if (ts.isJSDoc(jsDoc) && jsDoc.comment) { + // Handle both string and array of comment parts + if (typeof jsDoc.comment === 'string') { + return jsDoc.comment; + } + else if (Array.isArray(jsDoc.comment)) { + return jsDoc.comment.map(part => (part as any).text || '').join(''); + } + } + } + + // Fallback to symbol documentation + const symbol = typeChecker.getSymbolAtLocation(exportNode); + if (symbol) { + const description = ts.displayPartsToString(symbol.getDocumentationComment(typeChecker)); + return description || undefined; + } + } + + return undefined; + + function getExportNode() { + let result: ts.Node | undefined; + + if (exportName === 'default') { + ast.forEachChild(child => { + if (ts.isExportAssignment(child)) { + // Return the export assignment itself, not the expression + result = child; + } + }); + } + else { + ast.forEachChild(child => { + if ( + ts.isVariableStatement(child) + && child.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword) + ) { + // Return the variable statement itself + result = child; + } + }); + } + + return result; + } +} diff --git a/packages/component-meta/lib/types.ts b/packages/component-meta/lib/types.ts index d22b656f39..608d185fd8 100644 --- a/packages/component-meta/lib/types.ts +++ b/packages/component-meta/lib/types.ts @@ -8,6 +8,8 @@ export interface Declaration { } export interface ComponentMeta { + name?: string; + description?: string; type: TypeMeta; props: PropertyMeta[]; events: EventMeta[]; diff --git a/packages/component-meta/tests/index.spec.ts b/packages/component-meta/tests/index.spec.ts index 2d422d1615..d60eefd118 100644 --- a/packages/component-meta/tests/index.spec.ts +++ b/packages/component-meta/tests/index.spec.ts @@ -1394,6 +1394,42 @@ const worker = (checker: ComponentMetaChecker, withTsconfig: boolean) => } `); }); + + test('component-name-description (vue)', () => { + const componentPath = path.resolve( + __dirname, + '../../../test-workspace/component-meta/component-name-description/component.vue', + ); + const meta = checker.getComponentMeta(componentPath); + + expect(meta.name).toBe('MyComponent'); + expect(meta.description).toBe('My awesome component description'); + expect(meta.type).toEqual(TypeMeta.Class); + }); + + test('component-name-description (ts)', () => { + const componentPath = path.resolve( + __dirname, + '../../../test-workspace/component-meta/component-name-description/component-ts.ts', + ); + const meta = checker.getComponentMeta(componentPath); + + expect(meta.name).toBe('TsComponent'); + expect(meta.description).toBe('TypeScript component with description'); + expect(meta.type).toEqual(TypeMeta.Class); + }); + + test('component-no-name (vue)', () => { + const componentPath = path.resolve( + __dirname, + '../../../test-workspace/component-meta/component-name-description/component-no-name.vue', + ); + const meta = checker.getComponentMeta(componentPath); + + expect(meta.name).toBeUndefined(); + expect(meta.description).toBeUndefined(); + expect(meta.type).toEqual(TypeMeta.Class); + }); }); const checkerOptions: MetaCheckerOptions = { diff --git a/packages/tsc/tests/__snapshots__/dts.spec.ts.snap b/packages/tsc/tests/__snapshots__/dts.spec.ts.snap index 1f40f7c028..07f753d44a 100644 --- a/packages/tsc/tests/__snapshots__/dts.spec.ts.snap +++ b/packages/tsc/tests/__snapshots__/dts.spec.ts.snap @@ -1,5 +1,116 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`vue-tsc-dts > Input: component-name-description/component.vue, Output: component-name-description/component.vue.d.ts 1`] = ` +"declare const __VLS_export: import("vue").DefineComponent2<{ + setup(): {}; + data(): {}; + props: { + /** + * The title prop + */ + title: { + type: StringConstructor; + required: true; + }; + }; + computed: {}; + methods: {}; + mixins: {}[]; + extends: {}; + emits: string[]; + slots: {}; + inject: {}; + components: {}; + directives: {}; + provide: {}; + expose: string; + __typeProps: unknown; + __typeEmits: unknown; + __typeRefs: {}; + __typeEl: any; + __defaults: unknown; +}>; +/** + * My awesome component description + */ +declare const _default: typeof __VLS_export; +export default _default; +" +`; + +exports[`vue-tsc-dts > Input: component-name-description/component-no-name.vue, Output: component-name-description/component-no-name.vue.d.ts 1`] = ` +"/** + * Component without explicit name + */ +type __VLS_Props = { + /** + * The value prop + */ + value: string; +}; +declare const __VLS_export: import("vue").DefineComponent2<{ + setup(): {}; + data(): {}; + props: {}; + computed: {}; + methods: {}; + mixins: {}[]; + extends: {}; + emits: string[]; + slots: {}; + inject: {}; + components: {}; + directives: {}; + provide: {}; + expose: string; + __typeProps: __VLS_Props; + __typeEmits: unknown; + __typeRefs: {}; + __typeEl: any; + __defaults: unknown; +}>; +declare const _default: typeof __VLS_export; +export default _default; +" +`; + +exports[`vue-tsc-dts > Input: component-name-description/component-ts.ts, Output: component-name-description/component-ts.d.ts 1`] = ` +"/** + * TypeScript component with description + */ +declare const _default: import("vue").DefineComponent2<{ + setup(): {}; + data(): {}; + props: { + /** + * The message prop + */ + message: { + type: StringConstructor; + required: true; + }; + }; + computed: {}; + methods: {}; + mixins: {}[]; + extends: {}; + emits: string[]; + slots: {}; + inject: {}; + components: {}; + directives: {}; + provide: {}; + expose: string; + __typeProps: unknown; + __typeEmits: unknown; + __typeRefs: {}; + __typeEl: any; + __defaults: unknown; +}>; +export default _default; +" +`; + exports[`vue-tsc-dts > Input: empty-component/component.vue, Output: empty-component/component.vue.d.ts 1`] = ` "declare const __VLS_export: import("vue").DefineComponent2<{ setup(): {}; @@ -500,7 +611,7 @@ exports[`vue-tsc-dts > Input: reference-type-props/component-js-setup.vue, Outpu default: string; }; numberOrStringProp: { - type: (NumberConstructor | StringConstructor)[]; + type: (StringConstructor | NumberConstructor)[]; default: number; }; arrayProps: { diff --git a/test-workspace/component-meta/component-name-description/component-no-name.vue b/test-workspace/component-meta/component-name-description/component-no-name.vue new file mode 100644 index 0000000000..b7065bbc72 --- /dev/null +++ b/test-workspace/component-meta/component-name-description/component-no-name.vue @@ -0,0 +1,15 @@ + + + diff --git a/test-workspace/component-meta/component-name-description/component-ts.ts b/test-workspace/component-meta/component-name-description/component-ts.ts new file mode 100644 index 0000000000..27ea480b72 --- /dev/null +++ b/test-workspace/component-meta/component-name-description/component-ts.ts @@ -0,0 +1,17 @@ +import { defineComponent } from 'vue'; + +/** + * TypeScript component with description + */ +export default defineComponent({ + name: 'TsComponent', + props: { + /** + * The message prop + */ + message: { + type: String, + required: true, + }, + }, +}); diff --git a/test-workspace/component-meta/component-name-description/component.vue b/test-workspace/component-meta/component-name-description/component.vue new file mode 100644 index 0000000000..3d80323521 --- /dev/null +++ b/test-workspace/component-meta/component-name-description/component.vue @@ -0,0 +1,21 @@ + + +