From 38dd1812295fcd6a4af56204904f504746e68dce Mon Sep 17 00:00:00 2001 From: lonewolfyx Date: Fri, 28 Aug 2026 14:37:32 +0800 Subject: [PATCH] feat(usage): scan antdv-next component usage --- package.json | 1 + pnpm-lock.yaml | 215 ++++++++++++++++++++++++++ src/commands/usage.ts | 340 +++++++++++++++++++++++++++++++++++++++++- src/types/usage.d.ts | 26 ++++ test/usage.test.ts | 81 ++++++++++ 5 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 src/types/usage.d.ts create mode 100644 test/usage.test.ts diff --git a/package.json b/package.json index a33034b..a260345 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "fast-levenshtein": "^3.0.0", "glob": "^13.0.6", "local-pkg": "^1.2.1", + "oxc-parser": "^0.147.0", "pkg-types": "^2.3.1", "semver": "^7.8.5", "tinyexec": "^1.3.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf78b25..dd73099 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: local-pkg: specifier: ^1.2.1 version: 1.2.1 + oxc-parser: + specifier: ^0.147.0 + version: 0.147.0 pkg-types: specifier: ^2.3.1 version: 2.3.1 @@ -493,9 +496,134 @@ packages: resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2114,6 +2242,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + p-event@6.0.1: resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} engines: {node: '>=16.17'} @@ -3045,8 +3177,67 @@ snapshots: '@ota-meshi/ast-token-store@0.3.0': {} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/types@0.144.0': {} + '@oxc-project/types@0.147.0': {} + '@pkgr/core@0.3.6': {} '@quansync/fs@1.0.0': @@ -4772,6 +4963,30 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + p-event@6.0.1: dependencies: p-timeout: 6.1.4 diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 1961558..0d6a479 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -1,13 +1,343 @@ +import type { ComponentRecord } from '#/components.ts' +import type { AstNode, ComponentUsage, ParsedSourceUsage } from '@/types/usage' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' import { defineCommand } from 'citty' +import { Table } from 'console-table-printer' +import { glob } from 'glob' +import { parseSync } from 'oxc-parser' +import { defaultArgs } from '@/args/default.ts' +import { resolveConfig } from '@/config.ts' +import { tableBorderStyle } from '@/constants/table.ts' +import { loadVersionMetaData } from '@/utils/loader.ts' +import { output } from '@/utils/output.ts' +import { resolveVersion } from '@/utils/version.ts' + +function getJsxName(node: AstNode): string | undefined { + if (node.type === 'JSXIdentifier' && typeof node.name === 'string') + return node.name + + if (node.type === 'JSXMemberExpression') { + const object = getJsxName(node.object!) + const property = getJsxName(node.property!) + return object && property ? `${object}.${property}` : undefined + } + + return undefined +} + +function collectJsxTagNames(program: unknown): string[] { + const tags: { name: string, start: number }[] = [] + const nodes: unknown[] = [program] + const visited = new WeakSet() + + while (nodes.length) { + const value = nodes.pop() as AstNode + if (!value || typeof value !== 'object' || visited.has(value)) + continue + + visited.add(value) + + if (Array.isArray(value)) { + nodes.push(...value) + continue + } + + const node = value as AstNode + if (node.type === 'JSXOpeningElement') { + const name = getJsxName(node.name as AstNode) + if (name) + tags.push({ name, start: node.start ?? 0 }) + } + + for (const child of Object.values(node)) + nodes.push(child) + } + + return tags.sort((a, b) => a.start - b.start).map(tag => tag.name) +} + +function extractVueTemplate(source: string): string { + const openingTag = /]*)?>/i.exec(source) + if (!openingTag) + return '' + + const start = openingTag.index + openingTag[0].length + const end = source.lastIndexOf('') + return end >= start ? source.slice(start, end) : '' +} + +function extractVueOpeningTagNames(template: string): string[] { + const tags: string[] = [] + let index = 0 + + while (index < template.length) { + if (template.startsWith('', index + 4) + index = commentEnd === -1 ? template.length : commentEnd + 3 + continue + } + + if (template.startsWith('{{', index)) { + const interpolationEnd = template.indexOf('}}', index + 2) + index = interpolationEnd === -1 ? template.length : interpolationEnd + 2 + continue + } + + if (template[index] !== '<') { + index += 1 + continue + } + + let cursor = index + 1 + while (/\s/.test(template[cursor] ?? '')) + cursor += 1 + + const isClosingOrSpecial = ['/', '!', '?'].includes(template[cursor] ?? '') + if (isClosingOrSpecial) + cursor += 1 + + const nameStart = cursor + while (/[\w.$-]/.test(template[cursor] ?? '')) + cursor += 1 + + const name = template.slice(nameStart, cursor) + if (!isClosingOrSpecial && /^[A-Z][\w$]*(?:\.[\w$]+)*$/.test(name)) + tags.push(name) + + let quote = '' + while (cursor < template.length) { + const character = template[cursor]! + if (quote) { + if (character === quote) + quote = '' + } + else if (character === '"' || character === '\'') { + quote = character + } + else if (character === '>') { + cursor += 1 + break + } + cursor += 1 + } + index = cursor + } + + return tags +} + +function parseVueTagNames(filePath: string, source: string): string[] { + const template = extractVueTemplate(source) + const jsx = extractVueOpeningTagNames(template) + .map(tag => `<${tag} />`) + .join('') + + if (!jsx) + return [] + + const result = parseSync(`${filePath}.tsx`, `<>${jsx}`, { lang: 'tsx' }) + return collectJsxTagNames(result.program) +} + +function collectAntdvImports(program: unknown): Pick { + const namedImports = new Map() + const namespaceImports = new Set() + const body = (program as { body?: AstNode[] } | undefined)?.body ?? [] + + for (const declaration of body) { + if (declaration.type !== 'ImportDeclaration' + || declaration.importKind === 'type' + || declaration.source?.value !== 'antdv-next') { + continue + } + + for (const specifier of declaration.specifiers ?? []) { + const local = specifier.local!.name as string + if (!local || specifier.importKind === 'type') + continue + + if (specifier.type === 'ImportSpecifier') { + const imported = specifier.imported!.name as string + if (imported) + namedImports.set(local!, imported) + } + else if (specifier.type === 'ImportNamespaceSpecifier') { + namespaceImports.add(local) + } + } + } + + return { namedImports, namespaceImports } +} + +function mergeAntdvImports(target: Pick, program: unknown): void { + const imports = collectAntdvImports(program) + imports.namedImports.forEach((imported, local) => target.namedImports.set(local, imported)) + imports.namespaceImports.forEach(local => target.namespaceImports.add(local)) +} + +function parseVueScripts(filePath: string, source: string): Pick { + const imports = { + namedImports: new Map(), + namespaceImports: new Set(), + } + const scriptBlockRe = /]*)>([\s\S]*?)<\/script>/gi + + for (const match of source.matchAll(scriptBlockRe)) { + const attributes = match[1] ?? '' + const script = match[2] ?? '' + const lang = /\blang=["'](tsx?|jsx?)["']/i.exec(attributes)?.[1]?.toLowerCase() + const parserLang = lang === 'tsx' || lang === 'jsx' ? lang : lang === 'js' ? 'js' : 'ts' + const program = parseSync(`${filePath}.${parserLang}`, script, { lang: parserLang }).program + mergeAntdvImports(imports, program) + } + + return imports +} + +function parseSourceUsage(filePath: string, source: string): ParsedSourceUsage { + if (filePath.endsWith('.vue')) { + return { + tags: parseVueTagNames(filePath, source), + ...parseVueScripts(filePath, source), + } + } + + const result = parseSync(filePath, source, { lang: 'tsx' }) + return { + tags: collectJsxTagNames(result.program), + ...collectAntdvImports(result.program), + } +} + +export function parseComponentTags(filePath: string, source: string): string[] { + return parseSourceUsage(filePath, source).tags.map(tag => tag.split('.')[0]!) +} + +function createComponentLookup(components: ComponentRecord[]): Map { + const componentLookup = new Map() + + for (const component of components) { + componentLookup.set(component.name, component.name) + component.subComponents?.forEach((subComponent) => { + if (/^[A-Z][\w$]*$/.test(subComponent) && !componentLookup.has(subComponent)) + componentLookup.set(subComponent, component.name) + }) + } + + return componentLookup +} + +function resolveMetadataComponent(exported: string, components: ComponentRecord[], lookup: Map): string | undefined { + const exact = lookup.get(exported) + if (exact) + return exact + + return components + .map(component => component.name) + .sort((a, b) => b.length - a.length) + .find(component => exported.startsWith(component)) +} + +function resolveUsedComponent(tag: string, parsed: ParsedSourceUsage, components: ComponentRecord[], lookup: Map): string | undefined { + const [root, member] = tag.split('.') + if (!root) + return undefined + + const imported = parsed.namedImports.get(root) + if (imported) + return resolveMetadataComponent(imported, components, lookup) + + if (parsed.namespaceImports.has(root) && member) + return resolveMetadataComponent(member, components, lookup) + + if (root.startsWith('A')) + return resolveMetadataComponent(root.slice(1), components, lookup) + + return undefined +} + +export async function scanProjectUsage(projectRoot: string, components: ComponentRecord[]): Promise { + const files = await glob('./**/*.{vue,tsx}', { + cwd: projectRoot, + ignore: ['**/node_modules/**'], + nodir: true, + }) + const componentLookup = createComponentLookup(components) + const usage = new Map }>() + + for (const file of files.sort()) { + const source = await readFile(resolve(projectRoot, file), 'utf8') + const parsed = parseSourceUsage(file, source) + + for (const tag of parsed.tags) { + const component = resolveUsedComponent(tag, parsed, components, componentLookup) + if (!component) + continue + + const entry = usage.get(component) ?? { count: 0, files: new Set() } + entry.count += 1 + entry.files.add(file) + usage.set(component, entry) + } + } + + return [...usage] + .map(([component, item]) => ({ + component, + count: item.count, + files: [...item.files], + })) + .sort((a, b) => a.component.localeCompare(b.component)) +} + +function outputTable(usages: ComponentUsage[]): string { + const table = new Table({ + style: tableBorderStyle, + columns: [ + { name: 'Component', alignment: 'left' }, + { name: 'Usage', alignment: 'right' }, + { name: 'Files', alignment: 'right' }, + ], + }) + + usages.forEach((usage) => { + table.addRow({ + Component: usage.component, + Usage: usage.count, + Files: usage.files.length, + }) + }) + + return table.render() +} + +function outputMarkdown(usages: ComponentUsage[]): string { + const rows = usages.map(usage => `| ${usage.component} | ${usage.count} | ${usage.files.length} |`) + return [ + '| Component | Usage | Files |', + '| --- | ---: | ---: |', + ...rows, + ].join('\n') +} export default defineCommand({ meta: { name: 'usage', - description: 'Scan project for antd component/API usage statistics', + description: 'Scan project for antdv-next component usage statistics', }, - run({ args }) { - // antd usage [dir] - // 导入统计、子组件分布(Form.Item)、非组件导出 - console.log('Parsed args:', args) + args: defaultArgs, + async run({ args }) { + const config = resolveConfig(args) + const version = await resolveVersion(config) + const metaData = await loadVersionMetaData(version) + const usages = await scanProjectUsage(config.cwd, metaData.components) + + output({ + text: outputTable(usages), + markdown: outputMarkdown(usages), + json: usages, + }, args.format) }, }) diff --git a/src/types/usage.d.ts b/src/types/usage.d.ts new file mode 100644 index 0000000..598e7fe --- /dev/null +++ b/src/types/usage.d.ts @@ -0,0 +1,26 @@ +export interface AstNode { + type?: string + name?: string | AstNode + object?: AstNode + property?: AstNode + imported?: AstNode + local?: AstNode + source?: AstNode + specifiers?: AstNode[] + value?: unknown + importKind?: string + start?: number + [key: string]: unknown +} + +export interface ParsedSourceUsage { + tags: string[] + namedImports: Map + namespaceImports: Set +} + +export interface ComponentUsage { + component: string + count: number + files: string[] +} diff --git a/test/usage.test.ts b/test/usage.test.ts new file mode 100644 index 0000000..1d56306 --- /dev/null +++ b/test/usage.test.ts @@ -0,0 +1,81 @@ +import type { ComponentRecord } from '../src/types/components.ts' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseComponentTags, scanProjectUsage } from '../src/commands/usage.ts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +function component(name: string, subComponents: string[] = []): ComponentRecord { + return { name, subComponents } as ComponentRecord +} + +describe('usage command', () => { + it('extracts component tags from Vue templates and TSX syntax with oxc-parser', () => { + const vue = ` + + + ` + const tsx = 'export const View = () => <>' + + expect(parseComponentTags('Example.vue', vue)).toEqual(['AButton', 'AForm', 'AInput', 'NotAntdv']) + expect(parseComponentTags('Example.tsx', tsx)).toEqual(['AButton', 'ATable', 'Other']) + }) + + it('supports A-prefixed tags, named imports, aliases, subcomponents, and namespace imports', async () => { + const root = await mkdtemp(join(tmpdir(), 'antdv-usage-')) + temporaryDirectories.push(root) + + await Promise.all([ + mkdir(join(root, 'src', 'pages'), { recursive: true }), + mkdir(join(root, 'src', 'node_modules', 'ignored'), { recursive: true }), + mkdir(join(root, 'packages', 'feature', 'src'), { recursive: true }), + ]) + await Promise.all([ + writeFile(join(root, 'src', 'App.vue'), ` + + + `), + writeFile(join(root, 'src', 'pages', 'Table.tsx'), ` + import type { ButtonProps } from 'antdv-next' + import { Alert, ConfigProvider as AntdConfigProvider, FormItem, LayoutContent } from 'antdv-next' + import * as Antd from 'antdv-next' + export const View = () => <> + `), + writeFile(join(root, 'packages', 'feature', 'src', 'Input.vue'), ''), + writeFile(join(root, 'src', 'node_modules', 'ignored', 'Index.vue'), ''), + writeFile(join(root, 'src', 'ignored.jsx'), 'export const Input = () => '), + ]) + + await expect(scanProjectUsage(root, [ + component('Alert'), + component('Button'), + component('ConfigProvider'), + component('Form', ['FormItem']), + component('Input'), + component('Layout'), + component('Table'), + ])).resolves.toEqual([ + { component: 'Alert', count: 1, files: ['src/pages/Table.tsx'] }, + { component: 'Button', count: 2, files: ['src/App.vue'] }, + { component: 'ConfigProvider', count: 1, files: ['src/pages/Table.tsx'] }, + { component: 'Form', count: 1, files: ['src/pages/Table.tsx'] }, + { component: 'Input', count: 1, files: ['packages/feature/src/Input.vue'] }, + { component: 'Layout', count: 1, files: ['src/pages/Table.tsx'] }, + { component: 'Table', count: 1, files: ['src/pages/Table.tsx'] }, + ]) + }) +})