diff --git a/src/core/context.ts b/src/core/context.ts index 3fdfafb7..37f205a1 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -8,7 +8,7 @@ import { DIRECTIVE_IMPORT_PREFIX } from './constants' import { getNameFromFilePath, matchGlobs, normalizeComponetInfo, parseId, pascalCase, resolveAlias } from './utils' import { resolveOptions } from './options' import { searchComponents } from './fs/glob' -import { generateDeclaration } from './declaration' +import { writeDeclaration } from './declaration' import transformer from './transformer' const debug = { @@ -27,6 +27,7 @@ export class Context { private _componentNameMap: Record = {} private _componentUsageMap: Record> = {} private _componentCustomMap: Record = {} + private _directiveCustomMap: Record = {} private _server: ViteDevServer | undefined root = process.cwd() @@ -37,7 +38,10 @@ export class Context { private rawOptions: Options, ) { this.options = resolveOptions(rawOptions, this.root) - this.generateDeclaration = throttle(500, false, this.generateDeclaration.bind(this)) + this.generateDeclaration + = throttle(500, false, this._generateDeclaration.bind(this)) as + // `throttle` will omit return value. + ((removeUnused?: boolean) => void) this.setTransformer(this.options.transformer) } @@ -147,6 +151,11 @@ export class Context { this._componentCustomMap[info.as] = info } + addCustomDirectives(info: ComponentInfo) { + if (info.as) + this._directiveCustomMap[info.as] = info + } + removeComponents(paths: string | string[]) { debug.components('remove', paths) @@ -220,24 +229,26 @@ export class Context { continue const result = await resolver.resolve(type === 'directive' ? name.slice(DIRECTIVE_IMPORT_PREFIX.length) : name) - if (result) { - if (typeof result === 'string') { - info = { - as: name, - from: result, - } - this.addCustomComponents(info) - return info + if (!result) + continue + + if (typeof result === 'string') { + info = { + as: name, + from: result, } - else { - info = { - as: name, - ...normalizeComponetInfo(result), - } - this.addCustomComponents(info) - return info + } + else { + info = { + as: name, + ...normalizeComponetInfo(result), } } + if (type === 'component') + this.addCustomComponents(info) + else if (type === 'directive') + this.addCustomDirectives(info) + return info } return undefined @@ -260,9 +271,6 @@ export class Context { * This search for components in with the given options. * Will be called multiple times to ensure file loaded, * should normally run only once. - * - * @param ctx - * @param force */ searchGlob() { if (this._searched) @@ -273,14 +281,16 @@ export class Context { this._searched = true } - generateDeclaration() { + _generateDeclaration(removeUnused = !this._server) { if (!this.options.dts) return debug.decleration('generating') - generateDeclaration(this, this.options.root, this.options.dts, !this._server) + return writeDeclaration(this, this.options.dts, removeUnused) } + generateDeclaration + get componentNameMap() { return this._componentNameMap } @@ -288,4 +298,8 @@ export class Context { get componentCustomMap() { return this._componentCustomMap } + + get directiveCustomMap() { + return this._directiveCustomMap + } } diff --git a/src/core/declaration.ts b/src/core/declaration.ts index 16899409..9922d6d4 100644 --- a/src/core/declaration.ts +++ b/src/core/declaration.ts @@ -1,76 +1,153 @@ import { dirname, isAbsolute, relative } from 'path' -import { existsSync, promises as fs } from 'fs' +import { existsSync } from 'fs' +import { readFile, writeFile } from 'fs/promises' import { notNullish, slash } from '@antfu/utils' +import type { ComponentInfo } from '../../dist' +import type { Options } from '../types' import type { Context } from './context' import { getTransformedPath } from './utils' import { resolveTypeImports } from './type-imports/detect' -export function parseDeclaration(code: string): Record { +const multilineCommentsRE = /\/\*.*?\*\//gms +const singlelineCommentsRE = /\/\/.*$/gm + +function extractImports(code: string) { + return Object.fromEntries(Array.from(code.matchAll(/['"]?([\S]+?)['"]?\s*:\s*(.+?)[,;\n]/g)).map(i => [i[1], i[2]])) +} + +export function parseDeclaration(code: string): DeclarationImports | undefined { if (!code) - return {} - return Object.fromEntries(Array.from(code.matchAll(/(? [i[1], i[2]])) + return + + code = code + .replace(multilineCommentsRE, '') + .replace(singlelineCommentsRE, '') + + const imports: DeclarationImports = { + component: {}, + directive: {}, + } + const componentDeclaration = /export\s+interface\s+GlobalComponents\s*{(.*?)}/s.exec(code)?.[0] + if (componentDeclaration) + imports.component = extractImports(componentDeclaration) + + const directiveDeclaration = /export\s+interface\s+ComponentCustomProperties\s*{(.*?)}/s.exec(code)?.[0] + if (directiveDeclaration) + imports.directive = extractImports(directiveDeclaration) + + return imports +} + +/** + * Converts `ComponentInfo` to an array + * + * `[name, "typeof import(path)[importName]"]` + */ +function stringifyComponentInfo(filepath: string, { from: path, as: name, name: importName }: ComponentInfo, importPathTransform?: Options['importPathTransform']): [string, string] | undefined { + if (!name) + return undefined + path = getTransformedPath(path, importPathTransform) + const related = isAbsolute(path) + ? `./${relative(dirname(filepath), path)}` + : path + const entry = `typeof import('${slash(related)}')['${importName || 'default'}']` + return [name, entry] +} + +/** + * Converts array of `ComponentInfo` to an import map + * + * `{ name: "typeof import(path)[importName]", ... }` + */ +export function stringifyComponentsInfo(filepath: string, components: ComponentInfo[], importPathTransform?: Options['importPathTransform']): Record { + return Object.fromEntries( + components.map(info => stringifyComponentInfo(filepath, info, importPathTransform)) + .filter(notNullish), + ) } -export async function generateDeclaration(ctx: Context, root: string, filepath: string, removeUnused = false): Promise { - const items = [ +export interface DeclarationImports { + component: Record + directive: Record +} + +export function getDeclarationImports(ctx: Context, filepath: string): DeclarationImports | undefined { + const component = stringifyComponentsInfo(filepath, [ ...Object.values({ ...ctx.componentNameMap, ...ctx.componentCustomMap, }), ...resolveTypeImports(ctx.options.types), - ] - const imports: Record = Object.fromEntries( - items.map(({ from: path, as: name, name: importName }) => { - if (!name) - return undefined - path = getTransformedPath(path, ctx) - const related = isAbsolute(path) - ? `./${relative(dirname(filepath), path)}` - : path - - let entry = `typeof import('${slash(related)}')` - if (importName) - entry += `['${importName}']` - else - entry += '[\'default\']' - return [name, entry] - }) - .filter(notNullish), + ], ctx.options.importPathTransform) + + const directive = stringifyComponentsInfo( + filepath, + Object.values(ctx.directiveCustomMap), + ctx.options.importPathTransform, ) - if (!Object.keys(imports).length) + if ( + (Object.keys(component).length + Object.keys(directive).length) === 0 + ) return - const originalContent = existsSync(filepath) ? await fs.readFile(filepath, 'utf-8') : '' - - const originalImports = parseDeclaration(originalContent) + return { component, directive } +} - const lines = Object.entries({ - ...originalImports, - ...imports, - }) - .sort((a, b) => a[0].localeCompare(b[0])) - .filter(([name]) => removeUnused ? items.find(i => i.as === name) : true) +export function stringifyDeclarationImports(imports: Record) { + return Object.entries(imports) + .sort(([a], [b]) => a.localeCompare(b)) .map(([name, v]) => { if (!/^\w+$/.test(name)) name = `'${name}'` return `${name}: ${v}` }) +} + +export function getDeclaration(ctx: Context, filepath: string, originalImports?: DeclarationImports) { + const imports = getDeclarationImports(ctx, filepath) + if (!imports) + return + + const declarations = { + component: stringifyDeclarationImports({ ...originalImports?.component, ...imports.component }), + directive: stringifyDeclarationImports({ ...originalImports?.directive, ...imports.directive }), + } - const code = `// generated by unplugin-vue-components + let code = `// generated by unplugin-vue-components // We suggest you to commit this file into source control // Read more: https://github.com/vuejs/core/pull/3399 import '@vue/runtime-core' -declare module '@vue/runtime-core' { +export {} + +declare module '@vue/runtime-core' {` + + if (Object.keys(declarations.component).length > 0) { + code += ` export interface GlobalComponents { - ${lines.join('\n ')} + ${declarations.component.join('\n ')} + } +` } + if (Object.keys(declarations.directive).length > 0) { + code += ` + export interface ComponentCustomProperties { + ${declarations.directive.join('\n ')} + }` + } + code += '\n}\n' + return code } -export {} -` +export async function writeDeclaration(ctx: Context, filepath: string, removeUnused = false) { + const originalContent = existsSync(filepath) ? await readFile(filepath, 'utf-8') : '' + const originalImports = removeUnused ? undefined : parseDeclaration(originalContent) + + const code = getDeclaration(ctx, filepath, originalImports) + if (!code) + return if (code !== originalContent) - await fs.writeFile(filepath, code, 'utf-8') + await writeFile(filepath, code, 'utf-8') } diff --git a/src/core/utils.ts b/src/core/utils.ts index 03bb947d..39cec494 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -6,7 +6,7 @@ import { getPackageInfo, isPackageExists, } from 'local-pkg' -import type { ComponentInfo, ImportInfo, ImportInfoLegacy, ResolvedOptions } from '../types' +import type { ComponentInfo, ImportInfo, ImportInfoLegacy, Options, ResolvedOptions } from '../types' import type { Context } from './context' import { DISABLE_COMMENT } from './constants' @@ -64,9 +64,9 @@ export function matchGlobs(filepath: string, globs: string[]) { return false } -export function getTransformedPath(path: string, ctx: Context): string { - if (ctx.options.importPathTransform) { - const result = ctx.options.importPathTransform(path) +export function getTransformedPath(path: string, importPathTransform?: Options['importPathTransform']): string { + if (importPathTransform) { + const result = importPathTransform(path) if (result != null) path = result } @@ -98,7 +98,7 @@ export function normalizeComponetInfo(info: ImportInfo | ImportInfoLegacy | Comp } export function stringifyComponentImport({ as: name, from: path, name: importName, sideEffects }: ComponentInfo, ctx: Context) { - path = getTransformedPath(path, ctx) + path = getTransformedPath(path, ctx.options.importPathTransform) const imports = [ stringifyImport({ as: name, from: path, name: importName }), diff --git a/test/__snapshots__/dts.test.ts.snap b/test/__snapshots__/dts.test.ts.snap new file mode 100644 index 00000000..f00f87fd --- /dev/null +++ b/test/__snapshots__/dts.test.ts.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1 + +exports[`dts > getDeclaration 1`] = ` +"// generated by unplugin-vue-components +// We suggest you to commit this file into source control +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +export {} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TestComp: typeof import('test/component/TestComp')['default'] + } + + export interface ComponentCustomProperties { + vLoading: typeof import('test/directive/Loading')['default'] + } +} +" +`; + +exports[`dts > writeDeclaration - keep unused 1`] = ` +"// generated by unplugin-vue-components +// We suggest you to commit this file into source control +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +export {} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + SomeComp: typeof import('test/component/SomeComp')['default'] + TestComp: typeof import('test/component/TestComp')['default'] + } + + export interface ComponentCustomProperties { + vDirective: typeof import('foo') + vLoading: typeof import('test/directive/Loading')['default'] + vSome: typeof import('test/directive/Some')['default'] + } +} +" +`; + +exports[`dts > writeDeclaration 1`] = ` +"// generated by unplugin-vue-components +// We suggest you to commit this file into source control +// Read more: https://github.com/vuejs/core/pull/3399 +import '@vue/runtime-core' + +export {} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TestComp: typeof import('test/component/TestComp')['default'] + } + + export interface ComponentCustomProperties { + vLoading: typeof import('test/directive/Loading')['default'] + } +} +" +`; diff --git a/test/dts.test.ts b/test/dts.test.ts new file mode 100644 index 00000000..ccad2ce3 --- /dev/null +++ b/test/dts.test.ts @@ -0,0 +1,85 @@ +import { readFile, writeFile } from 'fs/promises' +import path from 'path' +import { describe, expect, test } from 'vitest' +import type { ComponentResolver } from '../src' +import { Context } from '../src/core/context' +import { getDeclaration } from '../src/core/declaration' + +const resolver: ComponentResolver[] = [ + { + type: 'component', + resolve: name => ({ from: `test/component/${name}` }), + }, + { + type: 'directive', + resolve: name => ({ from: `test/directive/${name}` }), + }, +] + +describe('dts', () => { + test('getDeclaration', async () => { + const ctx = new Context({ + resolvers: resolver, + directives: true, + }) + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + + const declarations = getDeclaration(ctx, 'test.d.ts') + expect(declarations).toMatchSnapshot() + }) + + test('writeDeclaration', async () => { + const filepath = path.resolve(__dirname, 'tmp/dts-test.d.ts') + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: filepath, + }) + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + await ctx._generateDeclaration() + + expect(await readFile(filepath, 'utf-8')).matchSnapshot() + }) + + test('writeDeclaration - keep unused', async () => { + const filepath = path.resolve(__dirname, 'tmp/dts-keep-unused.d.ts') + await writeFile( + filepath, + ` +declare module '@vue/runtime-core' { + export interface GlobalComponents { + SomeComp: typeof import('test/component/SomeComp')['default'] + TestComp: typeof import('test/component/OldComp')['default'] + } + export interface ComponentCustomProperties{ + // with comment: b + // a: + vSome: typeof import('test/directive/Some')['default'];vDirective:typeof import('foo') + } +}`, + 'utf-8', + ) + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: filepath, + }) + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + await ctx._generateDeclaration(false) + + const contents = await readFile(filepath, 'utf-8') + expect(contents).matchSnapshot() + expect(contents).not.toContain('OldComp') + expect(contents).not.toContain('comment') + expect(contents).toContain('vSome') + }) +}) diff --git a/test/tmp/.gitignore b/test/tmp/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/test/tmp/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore