Skip to content

feat: enhance TypeScript declaration generation #840

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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,

2 changes: 1 addition & 1 deletion src/core/context.ts
Original file line number Diff line number Diff line change
@@ -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 {
138 changes: 99 additions & 39 deletions src/core/declaration.ts
Original file line number Diff line number Diff line change
@@ -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,59 +39,104 @@ 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<string, string> {
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<string, Record<string, string>> {
const stringified = components.map(info => stringifyComponentInfo(dts, info, declarationType, importPathTransform)).filter(notNullish)

const filepathMap: Record<string, Record<string, string>> = {}

for (const info of stringified) {
const { name, entry, filepath } = info

if (!filepathMap[filepath])
filepathMap[filepath] = {}

filepathMap[filepath][name] = entry
}

return filepathMap
}

export interface DeclarationImports {
component: Record<string, string>
directive: Record<string, string>
}

export function getDeclarationImports(ctx: Context, filepath: string): DeclarationImports | undefined {
const component = stringifyComponentsInfo(filepath, [
export function getDeclarationImports(ctx: Context): Record<string, DeclarationImports> | 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<string, DeclarationImports> = {}

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<string, string>) {
@@ -104,11 +149,7 @@ export function stringifyDeclarationImports(imports: Record<string, string>) {
})
}

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<Record<string, string> | 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) {
20 changes: 12 additions & 8 deletions src/core/options.ts
Original file line number Diff line number Diff line change
@@ -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<Required<Options>, '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()
3 changes: 1 addition & 2 deletions src/core/unplugin.ts
Original file line number Diff line number Diff line change
@@ -66,8 +66,7 @@ export default createUnplugin<Options>((options = {}) => {

if (ctx.options.dts) {
ctx.searchGlob()
if (!existsSync(ctx.options.dts))
ctx.generateDeclaration()
ctx.generateDeclaration()
}

if (ctx.options.dumpComponentsInfo && ctx.dumpComponentsInfoPath) {
10 changes: 7 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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
}

164 changes: 156 additions & 8 deletions test/__snapshots__/dts.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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 <IMdi:diceD12> 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
149 changes: 138 additions & 11 deletions test/dts.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {