Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/component-meta/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
225 changes: 184 additions & 41 deletions packages/component-meta/lib/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -282,8 +339,16 @@ interface ComponentMeta<T> {
let _events: ReturnType<typeof getEvents> | undefined;
let _slots: ReturnType<typeof getSlots> | undefined;
let _exposed: ReturnType<typeof getExposed> | 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());
},
Expand Down Expand Up @@ -450,6 +515,42 @@ interface ComponentMeta<T> {

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(
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/component-meta/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface Declaration {
}

export interface ComponentMeta {
name?: string;
description?: string;
type: TypeMeta;
props: PropertyMeta[];
events: EventMeta[];
Expand Down
36 changes: 36 additions & 0 deletions packages/component-meta/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading