Skip to content

feat: enhance TypeScript declaration generation #840

New issue

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

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

Already on GitHub? # to your account

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

Expand Down
2 changes: 1 addition & 1 deletion src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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'
Expand Down Expand Up @@ -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>) {
Expand All @@ -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 }),
Expand Down Expand Up @@ -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) {
Expand Down
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'
Expand All @@ -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[] {
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions src/core/unplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
10 changes: 7 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -227,7 +231,7 @@ export type ResolvedOptions = Omit<
resolvedDirs: string[]
globs: string[]
globsExclude: string[]
dts: string | false
dts: false | DtsConfigure
root: string
}

Expand Down
Loading