diff --git a/README.md b/README.md index cdf4dfe4..ec1c3ac3 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,22 @@ Once the setup is done, a `components.d.ts` will be generated and updates automa > **Make sure you also add `components.d.ts` to your `tsconfig.json` under `include`.** +We also provide a way to generate multiple `d.ts` files for components or directives. You can pass a function to `dts` option, which will be called with the component info and type. You can return a string or a boolean to indicate whether to generate it to a file or not. + +```ts +Components({ + dts: (componentInfo, type) => { + if (type === 'component') { + return 'components.d.ts' + } + else if (type === 'directive') { + return 'directives.d.ts' + } + return false + }, +}) +``` + ## Importing from UI Libraries We have several built-in resolvers for popular UI libraries like **Vuetify**, **Ant Design Vue**, and **Element Plus**, where you can enable them by: @@ -371,7 +387,7 @@ Components({ resolvers: [], // generate `components.d.ts` global declarations, - // also accepts a path for custom filename + // also accepts a path, a custom filename or a function that returns a path or a boolean // default: `true` if package typescript is installed dts: false, diff --git a/src/core/context.ts b/src/core/context.ts index 499a9a3b..b654acfb 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -301,7 +301,7 @@ export class Context { return debug.declaration('generating dts') - return writeDeclaration(this, this.options.dts, removeUnused) + return writeDeclaration(this, removeUnused) } generateDeclaration(removeUnused = !this._server): void { diff --git a/src/core/declaration.ts b/src/core/declaration.ts index 4fd2c168..608ebdd4 100644 --- a/src/core/declaration.ts +++ b/src/core/declaration.ts @@ -1,4 +1,4 @@ -import type { ComponentInfo, Options } from '../types' +import type { ComponentInfo, DtsConfigure, DtsDeclarationType, Options } from '../types' import type { Context } from './context' import { existsSync } from 'node:fs' import { mkdir, readFile, writeFile as writeFile_ } from 'node:fs/promises' @@ -39,31 +39,48 @@ export function parseDeclaration(code: string): DeclarationImports | undefined { } /** - * Converts `ComponentInfo` to an array + * Converts `ComponentInfo` to an import info. * - * `[name, "typeof import(path)[importName]"]` + * `{name, entry: "typeof import(path)[importName]", filepath}` */ -function stringifyComponentInfo(filepath: string, { from: path, as: name, name: importName }: ComponentInfo, importPathTransform?: Options['importPathTransform']): [string, string] | undefined { +function stringifyComponentInfo(dts: DtsConfigure, info: ComponentInfo, declarationType: DtsDeclarationType, importPathTransform?: Options['importPathTransform']): Record<'name' | 'entry' | 'filepath', string> | undefined { + const { from: path, as: name, name: importName } = info + if (!name) return undefined - path = getTransformedPath(path, importPathTransform) - const related = isAbsolute(path) - ? `./${relative(dirname(filepath), path)}` - : path + + const filepath = dts(info, declarationType) + if (!filepath) + return undefined + + const transformedPath = getTransformedPath(path, importPathTransform) + const related = isAbsolute(transformedPath) + ? `./${relative(dirname(filepath), transformedPath)}` + : transformedPath const entry = `typeof import('${slash(related)}')['${importName || 'default'}']` - return [name, entry] + return { name, entry, filepath } } /** - * Converts array of `ComponentInfo` to an import map + * Converts array of `ComponentInfo` to a filepath grouped import map. * - * `{ name: "typeof import(path)[importName]", ... }` + * `{ filepath: { 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 function stringifyComponentsInfo(dts: DtsConfigure, components: ComponentInfo[], declarationType: DtsDeclarationType, importPathTransform?: Options['importPathTransform']): Record> { + const stringified = components.map(info => stringifyComponentInfo(dts, info, declarationType, importPathTransform)).filter(notNullish) + + const filepathMap: Record> = {} + + for (const info of stringified) { + const { name, entry, filepath } = info + + if (!filepathMap[filepath]) + filepathMap[filepath] = {} + + filepathMap[filepath][name] = entry + } + + return filepathMap } export interface DeclarationImports { @@ -71,27 +88,55 @@ export interface DeclarationImports { directive: Record } -export function getDeclarationImports(ctx: Context, filepath: string): DeclarationImports | undefined { - const component = stringifyComponentsInfo(filepath, [ +export function getDeclarationImports(ctx: Context): Record | undefined { + if (!ctx.options.dts) + return undefined + + const componentMap = stringifyComponentsInfo(ctx.options.dts, [ ...Object.values({ ...ctx.componentNameMap, ...ctx.componentCustomMap, }), ...resolveTypeImports(ctx.options.types), - ], ctx.options.importPathTransform) + ], 'component', ctx.options.importPathTransform) - const directive = stringifyComponentsInfo( - filepath, + const directiveMap = stringifyComponentsInfo( + ctx.options.dts, Object.values(ctx.directiveCustomMap), + 'directive', ctx.options.importPathTransform, ) - if ( - (Object.keys(component).length + Object.keys(directive).length) === 0 - ) - return + const declarationMap: Record = {} + + for (const [filepath, component] of Object.entries(componentMap)) { + if (!declarationMap[filepath]) + declarationMap[filepath] = { component: {}, directive: {} } + + declarationMap[filepath].component = { + ...declarationMap[filepath].component, + ...component, + } + } - return { component, directive } + for (const [filepath, directive] of Object.entries(directiveMap)) { + if (!declarationMap[filepath]) + declarationMap[filepath] = { component: {}, directive: {} } + + declarationMap[filepath].directive = { + ...declarationMap[filepath].directive, + ...directive, + } + } + + for (const [filepath, { component, directive }] of Object.entries(declarationMap)) { + if ( + (Object.keys(component).length + Object.keys(directive).length) === 0 + ) + delete declarationMap[filepath] + } + + return declarationMap } export function stringifyDeclarationImports(imports: Record) { @@ -104,11 +149,7 @@ export function stringifyDeclarationImports(imports: Record) { }) } -export function getDeclaration(ctx: Context, filepath: string, originalImports?: DeclarationImports) { - const imports = getDeclarationImports(ctx, filepath) - if (!imports) - return - +function getDeclaration(imports: DeclarationImports, originalImports?: DeclarationImports): string { const declarations = { component: stringifyDeclarationImports({ ...originalImports?.component, ...imports.component }), directive: stringifyDeclarationImports({ ...originalImports?.directive, ...imports.directive }), @@ -140,21 +181,40 @@ declare module 'vue' {` return code } +export async function getDeclarations(ctx: Context, removeUnused = false): Promise | undefined> { + const importsMap = getDeclarationImports(ctx) + if (!importsMap || !Object.keys(importsMap).length) + return undefined + + const results = await Promise.all(Object.entries(importsMap).map(async ([filepath, imports]) => { + const originalContent = existsSync(filepath) ? await readFile(filepath, 'utf-8') : '' + const originalImports = removeUnused ? undefined : parseDeclaration(originalContent) + + const code = getDeclaration(imports, originalImports) + + if (code !== originalContent) { + return [filepath, code] + } + })) + + return Object.fromEntries(results.filter(notNullish)) +} + async function writeFile(filePath: string, content: string) { await mkdir(dirname(filePath), { recursive: true }) return await writeFile_(filePath, content, 'utf-8') } -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) +export async function writeDeclaration(ctx: Context, removeUnused = false) { + const declarations = await getDeclarations(ctx, removeUnused) + if (!declarations || !Object.keys(declarations).length) return - if (code !== originalContent) - await writeFile(filepath, code) + await Promise.all( + Object.entries(declarations).map(async ([filepath, code]) => { + return writeFile(filepath, code) + }), + ) } export async function writeComponentsJson(ctx: Context, _removeUnused = false) { diff --git a/src/core/options.ts b/src/core/options.ts index 58ba6239..ee0d2cee 100644 --- a/src/core/options.ts +++ b/src/core/options.ts @@ -1,4 +1,4 @@ -import type { ComponentResolver, ComponentResolverObject, Options, ResolvedOptions } from '../types' +import type { ComponentResolver, ComponentResolverObject, DtsConfigure, Options, ResolvedOptions } from '../types' import { join, resolve } from 'node:path' import { slash, toArray } from '@antfu/utils' import { getPackageInfoSync, isPackageExists } from 'local-pkg' @@ -21,6 +21,8 @@ export const defaultOptions: Omit, 'include' | 'exclude' | 'ex importPathTransform: v => v, allowOverrides: false, + sourcemap: true, + dumpComponentsInfo: false, } function normalizeResolvers(resolvers: (ComponentResolver | ComponentResolver[])[]): ComponentResolverObject[] { @@ -78,14 +80,16 @@ export function resolveOptions(options: Options, root: string): ResolvedOptions return false }) - resolved.dts = !resolved.dts + const originalDts = resolved.dts + + resolved.dts = !originalDts ? false - : resolve( - root, - typeof resolved.dts === 'string' - ? resolved.dts - : 'components.d.ts', - ) + : ((...args) => { + const res = typeof originalDts === 'function' ? originalDts(...args) : originalDts + if (!res) + return false + return resolve(root, typeof res === 'string' ? res : 'components.d.ts') + }) as DtsConfigure if (!resolved.types && resolved.dts) resolved.types = detectTypeImports() diff --git a/src/core/unplugin.ts b/src/core/unplugin.ts index 39616787..084d5256 100644 --- a/src/core/unplugin.ts +++ b/src/core/unplugin.ts @@ -66,8 +66,7 @@ export default createUnplugin((options = {}) => { if (ctx.options.dts) { ctx.searchGlob() - if (!existsSync(ctx.options.dts)) - ctx.generateDeclaration() + ctx.generateDeclaration() } if (ctx.options.dumpComponentsInfo && ctx.dumpComponentsInfoPath) { diff --git a/src/types.ts b/src/types.ts index ac17d1e3..8ba20dc9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,10 @@ export type Transformer = (code: string, id: string, path: string, query: Record export type SupportedTransformer = 'vue3' | 'vue2' +export type DtsDeclarationType = 'component' | 'directive' + +export type DtsConfigure = (info: ComponentInfo, declarationType: DtsDeclarationType) => string | false + export interface PublicPluginAPI { /** * Resolves a component using the configured resolvers. @@ -163,13 +167,13 @@ export interface Options { /** * Generate TypeScript declaration for global components * - * Accept boolean or a path related to project root + * Accept boolean, a path related to project root or a function that returns boolean or a path. * * @see https://github.com/vuejs/core/pull/3399 * @see https://github.com/johnsoncodehk/volar#using * @default true */ - dts?: boolean | string + dts?: boolean | string | DtsConfigure /** * Do not emit warning on component overriding @@ -227,7 +231,7 @@ export type ResolvedOptions = Omit< resolvedDirs: string[] globs: string[] globsExclude: string[] - dts: string | false + dts: false | DtsConfigure root: string } diff --git a/test/__snapshots__/dts.test.ts.snap b/test/__snapshots__/dts.test.ts.snap index 75752379..60de08e1 100644 --- a/test/__snapshots__/dts.test.ts.snap +++ b/test/__snapshots__/dts.test.ts.snap @@ -1,7 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`dts > components only 1`] = ` -"/* eslint-disable */ +[ + "/* eslint-disable */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 @@ -16,11 +17,13 @@ declare module 'vue' { TestComp: typeof import('test/component/TestComp')['default'] } } -" +", +] `; exports[`dts > directive only 1`] = ` -"/* eslint-disable */ +[ + "/* eslint-disable */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 @@ -33,11 +36,117 @@ declare module 'vue' { vLoading: typeof import('test/directive/Loading')['default'] } } -" +", +] +`; + +exports[`dts > getDeclaration - filter 1`] = ` +[ + "/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TestComp: typeof import('test/component/TestComp')['default'] + } +} +", +] +`; + +exports[`dts > getDeclaration - function expression 1`] = ` +[ + "/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + 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 > getDeclaration - multiple files 1`] = ` +[ + "/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TestComp: typeof import('test/component/TestComp')['default'] + } +} +", + "/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface ComponentCustomProperties { + vLoading: typeof import('test/directive/Loading')['default'] + } +} +", +] +`; + +exports[`dts > getDeclaration - return absolute path 1`] = ` +[ + "/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + 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 > getDeclaration 1`] = ` -"/* eslint-disable */ +[ + "/* eslint-disable */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 @@ -55,7 +164,8 @@ declare module 'vue' { vLoading: typeof import('test/directive/Loading')['default'] } } -" +", +] `; exports[`dts > parseDeclaration - has icon component like 1`] = ` @@ -96,7 +206,8 @@ exports[`dts > parseDeclaration 1`] = ` `; exports[`dts > vue 2.7 components only 1`] = ` -"/* eslint-disable */ +[ + "/* eslint-disable */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 @@ -111,7 +222,8 @@ declare module 'vue' { TestComp: typeof import('test/component/TestComp')['default'] } } -" +", +] `; exports[`dts > writeDeclaration - keep unused 1`] = ` @@ -139,6 +251,42 @@ declare module 'vue' { " `; +exports[`dts > writeDeclaration - multiple files 1`] = ` +"/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TestComp: typeof import('test/component/TestComp')['default'] + } +} +" +`; + +exports[`dts > writeDeclaration - multiple files 2`] = ` +"/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface ComponentCustomProperties { + vLoading: typeof import('test/directive/Loading')['default'] + } +} +" +`; + exports[`dts > writeDeclaration 1`] = ` "/* eslint-disable */ // @ts-nocheck diff --git a/test/dts.test.ts b/test/dts.test.ts index b26ccba4..f8b78e36 100644 --- a/test/dts.test.ts +++ b/test/dts.test.ts @@ -1,9 +1,9 @@ -import type { ComponentResolver } from '../src' +import type { ComponentInfo, ComponentResolver, DtsDeclarationType } from '../src' import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '../src/core/context' -import { getDeclaration, parseDeclaration } from '../src/core/declaration' +import { getDeclarations, parseDeclaration } from '../src/core/declaration' const resolver: ComponentResolver[] = [ { @@ -27,8 +27,112 @@ 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() + const declarations = await getDeclarations(ctx) + expect(Object.values(declarations ?? {})).toMatchSnapshot() + }) + + it('getDeclaration - function expression', async () => { + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: () => 'test.d.ts', + }) + + const filepath = path.resolve(__dirname, '../test.d.ts') + + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + + const declarations = await getDeclarations(ctx) + + expect(Object.keys(declarations ?? {})).toEqual([filepath]) + expect(Object.values(declarations ?? {})).toMatchSnapshot() + }) + + it('getDeclaration - return absolute path', async () => { + const filepath = path.resolve(__dirname, '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, '') + + const declarations = await getDeclarations(ctx) + + expect(Object.keys(declarations ?? {})).toEqual([filepath]) + expect(Object.values(declarations ?? {})).toMatchSnapshot() + }) + + it('getDeclaration - return false', async () => { + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: () => false, + }) + + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + const declarations = await getDeclarations(ctx) + expect(declarations).toBeUndefined() + }) + + it('getDeclaration - multiple files', async () => { + const fn = vi.fn().mockImplementation((_info: ComponentInfo, type: DtsDeclarationType) => { + return type === 'component' ? 'test.d.ts' : 'test2.d.ts' + }) + + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: fn, + }) + + const filepath = path.resolve(__dirname, '../test.d.ts') + const filepath2 = path.resolve(__dirname, '../test2.d.ts') + + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + + await ctx.transform(code, '') + + const declarations = await getDeclarations(ctx) + + expect(fn).toBeCalledTimes(4) + expect(fn).toBeCalledWith({ as: 'TestComp', from: 'test/component/TestComp' } satisfies ComponentInfo, 'component') + expect(fn).toBeCalledWith({ as: 'vLoading', from: 'test/directive/Loading' } satisfies ComponentInfo, 'directive') + expect(fn).toBeCalledWith({ from: 'vue-router', name: 'RouterView', as: 'RouterView' } satisfies ComponentInfo, 'component') + expect(fn).toBeCalledWith({ from: 'vue-router', name: 'RouterLink', as: 'RouterLink' } satisfies ComponentInfo, 'component') + + expect(Object.keys(declarations ?? {})).toEqual([filepath, filepath2]) + expect(Object.values(declarations ?? {})).toMatchSnapshot() + }) + + it('getDeclaration - filter', async () => { + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: (_, type) => type === 'component' ? 'test.d.ts' : false, + }) + + const code = ` +const _component_test_comp = _resolveComponent("test-comp") +const _directive_loading = _resolveDirective("loading")` + await ctx.transform(code, '') + + const declarations = await getDeclarations(ctx) + + expect(Object.values(declarations ?? {})).toMatchSnapshot() }) it('writeDeclaration', async () => { @@ -83,42 +187,65 @@ const _directive_loading = _resolveDirective("loading")` expect(contents).toContain('vSome') }) + it('writeDeclaration - multiple files', async () => { + const filepath = path.resolve(__dirname, 'tmp/dts-test.d.ts') + const filepath2 = path.resolve(__dirname, 'tmp/dts-test2.d.ts') + + const ctx = new Context({ + resolvers: resolver, + directives: true, + dts: (_, type) => (type === 'component' ? filepath : filepath2), + }) + + 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() + expect(await readFile(filepath2, 'utf-8')).matchSnapshot() + }) + it('components only', async () => { const ctx = new Context({ resolvers: resolver, directives: true, + dts: 'test.d.ts', }) const code = 'const _component_test_comp = _resolveComponent("test-comp")' await ctx.transform(code, '') - const declarations = getDeclaration(ctx, 'test.d.ts') - expect(declarations).toMatchSnapshot() + const declarations = await getDeclarations(ctx) + expect(Object.values(declarations ?? {})).toMatchSnapshot() }) it('vue 2.7 components only', async () => { const ctx = new Context({ resolvers: resolver, directives: true, + dts: 'test.d.ts', version: 2.7, }) const code = 'const _component_test_comp = _c("test-comp")' await ctx.transform(code, '') - const declarations = getDeclaration(ctx, 'test.d.ts') - expect(declarations).toMatchSnapshot() + const declarations = await getDeclarations(ctx) + expect(Object.values(declarations ?? {})).toMatchSnapshot() }) it('directive only', async () => { const ctx = new Context({ resolvers: resolver, directives: true, + dts: 'test.d.ts', types: [], }) const code = 'const _directive_loading = _resolveDirective("loading")' await ctx.transform(code, '') - const declarations = getDeclaration(ctx, 'test.d.ts') - expect(declarations).toMatchSnapshot() + const declarations = await getDeclarations(ctx) + expect(Object.values(declarations ?? {})).toMatchSnapshot() }) it('parseDeclaration', async () => { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy