diff --git a/.npmrc b/.npmrc index bac7fb07..65d4f85d 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ shamefully-hoist=true ignore-workspace-root-check=true +shell-emulator=true diff --git a/README.md b/README.md index 1ab6d7d6..ad4bf379 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,77 @@ export const counter = ref(0) We recommend using [Volar](https://github.com/johnsoncodehk/volar) for type checking, which will help you to identify the misusage. +### Vue Directives Auto Import and TypeScript Declaration Generation + +In Vue's template, the usage of directives is in a different context than plain modules. Thus some custom transformations are required. To enable it, set `addons.vueDirectives` to `true`: + +```ts +Unimport.vite({ + addons: { + vueDirectives: true + } +}) +``` + +#### Library Authors + +When including directives in your presets, you should: +- provide the corresponding imports with `meta.vueDirective` set to `true`, otherwise, `unimport` will not be able to detect your directives. +- use named exports for your directives, or use default export and use `as` in the Import. +- set `dtsDisabled` to `true` if you provide a type declaration for your directives. + +```ts +import type { InlinePreset } from 'unimport' +import { defineUnimportPreset } from 'unimport' + +export const composables = defineUnimportPreset({ + from: 'my-unimport-library/composables', + /* imports and other options */ +}) + +export const directives = defineUnimportPreset({ + from: 'my-unimport-library/directives', + // disable dts generation globally + dtsEnabled: false, + // you can declare the vue directive globally + meta: { + vueDirective: true + }, + imports: [{ + name: 'ClickOutside', + // disable dts generation per import + dtsEnabled: false, + // you can declare the vue directive per import + meta: { + vueDirective: true + } + }, { + name: 'default', + // you should declare `as` for default exports + as: 'Focus' + }] +}) +``` + +#### Using Directory Scan and Local Directives + +If you add a directory scan for your local directives in the project, you need to: +- provide `isDirective` in the `vueDirectives`: `unimport` will use it to detect them (will never be called for imports with `meta.vueDirective` set to `true`). +- use always named exports for your directives. + +```ts +Unimport.vite({ + dirs: ['./directives/**'], + addons: { + vueDirectives: { + isDirective: (normalizedImportFrom, _importEntry) => { + return normalizedImportFrom.includes('/directives/') + } + } + } +}) +``` + ## 💻 Development - Clone this repository diff --git a/playground/composables-preset/dummy.ts b/playground/composables-preset/dummy.ts new file mode 100644 index 00000000..fd2cc991 --- /dev/null +++ b/playground/composables-preset/dummy.ts @@ -0,0 +1 @@ +export const dummy = 'from manual composable preset' diff --git a/playground/configure-directives.ts b/playground/configure-directives.ts new file mode 100644 index 00000000..a0cdb706 --- /dev/null +++ b/playground/configure-directives.ts @@ -0,0 +1,105 @@ +import type { InlinePreset } from '../src' +import type { UnimportPluginOptions } from '../src/unplugin' +import * as process from 'node:process' +import { resolve } from 'pathe' + +export function resolvePresets(presets: InlinePreset[]) { + return presets.map((preset) => { + preset.from = resolve(process.cwd(), preset.from) + return preset + }) +} + +const usePresets = process.env.USE_PRESETS === 'true' + +const unimportViteOptions: Partial = { + dts: true, + // eslint-disable-next-line no-console + debugLog: console.log, + presets: [ + 'vue', + { + from: resolve(process.cwd(), 'composables-preset/dummy.ts'), + imports: [{ + name: 'dummy', + }], + }, + ], + dirs: ['./composables/**'], + addons: { + vueTemplate: true, + vueDirectives: { + isDirective(normalizeImportFrom, _importEntry) { + return normalizeImportFrom.includes('/directives/') + }, + }, + }, +} + +if (!usePresets) { + unimportViteOptions.dirsScanOptions = { + cwd: process.cwd().replace(/\\/g, '/'), + } + unimportViteOptions.dirs!.push('./directives/**') +} +else { + unimportViteOptions.presets!.push(...resolvePresets([{ + from: 'directives/awesome-directive.ts', + imports: [{ + name: 'default', + as: 'AwesomeDirective', + meta: { + vueDirective: true, + }, + }], + }, { + from: 'directives/named-directive.ts', + imports: [{ + name: 'NamedDirective', + meta: { + vueDirective: true, + }, + }], + }, { + from: 'directives/mixed-directive.ts', + imports: [{ + name: 'NamedMixedDirective', + meta: { + vueDirective: true, + }, + }, { + name: 'default', + as: 'MixedDirective', + meta: { + vueDirective: true, + }, + }], + }, { + from: 'directives/custom-directive.ts', + imports: [{ + name: 'CustomDirective', + meta: { + vueDirective: true, + }, + }], + }, { + from: 'directives/ripple-directive.ts', + imports: [{ + name: 'vRippleDirective', + meta: { + vueDirective: true, + }, + }], + }, { + from: 'directives/v-focus-directive.ts', + imports: [{ + name: 'default', + as: 'FocusDirective', + meta: { + vueDirective: true, + }, + }], + }])) +} + +export { unimportViteOptions } diff --git a/playground/directives/awesome-directive.ts b/playground/directives/awesome-directive.ts new file mode 100644 index 00000000..058e2703 --- /dev/null +++ b/playground/directives/awesome-directive.ts @@ -0,0 +1,8 @@ +import type { DirectiveBinding } from 'vue' + +export function AwesomeDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('AwesomeDirective', el, binding) +} + +export default AwesomeDirective diff --git a/playground/directives/custom-directive.ts b/playground/directives/custom-directive.ts new file mode 100644 index 00000000..d6e4cd0f --- /dev/null +++ b/playground/directives/custom-directive.ts @@ -0,0 +1,24 @@ +import type { DirectiveBinding } from 'vue' + +function mounted(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('mounted', el, binding) +} + +function unmounted(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('unmounted', el, binding) +} + +function updated(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('updated', el, binding) +} + +export const CustomDirective = { + mounted, + unmounted, + updated, +} + +export default CustomDirective diff --git a/playground/directives/mixed-directive.ts b/playground/directives/mixed-directive.ts new file mode 100644 index 00000000..4605f1de --- /dev/null +++ b/playground/directives/mixed-directive.ts @@ -0,0 +1,11 @@ +import type { DirectiveBinding } from 'vue' + +export function NamedMixedDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('NamedMixedDirective', el, binding) +} + +export default function DefaultMixedDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('DefaultMixedDirective', el, binding) +} diff --git a/playground/directives/named-directive.ts b/playground/directives/named-directive.ts new file mode 100644 index 00000000..ff0924f7 --- /dev/null +++ b/playground/directives/named-directive.ts @@ -0,0 +1,6 @@ +import type { DirectiveBinding } from 'vue' + +export function NamedDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('NamedDirective', el, binding) +} diff --git a/playground/directives/ripple-directive.ts b/playground/directives/ripple-directive.ts new file mode 100644 index 00000000..26bcfe80 --- /dev/null +++ b/playground/directives/ripple-directive.ts @@ -0,0 +1,8 @@ +import type { DirectiveBinding } from 'vue' + +function vRippleDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('FocusDirective', el, binding) +} + +export { vRippleDirective } diff --git a/playground/directives/v-focus-directive.ts b/playground/directives/v-focus-directive.ts new file mode 100644 index 00000000..3d66ddcd --- /dev/null +++ b/playground/directives/v-focus-directive.ts @@ -0,0 +1,8 @@ +import type { DirectiveBinding } from 'vue' + +function VFocusDirective(el: HTMLElement, binding: DirectiveBinding) { + // eslint-disable-next-line no-console + console.log('FocusDirective', el, binding) +} + +export default VFocusDirective diff --git a/playground/multiple-directives/index.ts b/playground/multiple-directives/index.ts new file mode 100644 index 00000000..896858fa --- /dev/null +++ b/playground/multiple-directives/index.ts @@ -0,0 +1,2 @@ +export { AwesomeDirective } from '../directives/awesome-directive' +export { CustomDirective } from '../directives/custom-directive' diff --git a/playground/package.json b/playground/package.json index c2ebd044..7e0086af 100644 --- a/playground/package.json +++ b/playground/package.json @@ -4,7 +4,9 @@ "private": true, "scripts": { "dev": "vite", + "dev:presets": "USE_PRESETS=true vite", "build": "vite build", + "build:presets": "USE_PRESETS=true vite build", "typecheck": "vue-tsc --noEmit", "preview": "vite preview" }, diff --git a/playground/src/Options.vue b/playground/src/Options.vue index 176cdddf..6dee268d 100644 --- a/playground/src/Options.vue +++ b/playground/src/Options.vue @@ -22,16 +22,17 @@ export default defineComponent({ diff --git a/playground/src/Setup.vue b/playground/src/Setup.vue index 9f0432ac..ebcc8ca1 100644 --- a/playground/src/Setup.vue +++ b/playground/src/Setup.vue @@ -7,16 +7,17 @@ function inc() { diff --git a/playground/vite.config.ts b/playground/vite.config.ts index 5294656f..c1691b46 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -2,23 +2,13 @@ import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vite' import inspect from 'vite-plugin-inspect' import unimport from '../src/unplugin' +import { unimportViteOptions } from './configure-directives' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ vue(), - unimport.vite({ - dts: true, - presets: [ - 'vue', - ], - dirs: [ - './composables/**', - ], - addons: { - vueTemplate: true, - }, - }), + unimport.vite(unimportViteOptions), inspect(), ], }) diff --git a/src/addons.ts b/src/addons.ts index e59c9f5f..f37d7eb4 100644 --- a/src/addons.ts +++ b/src/addons.ts @@ -1 +1,2 @@ -export * from './addons/vue-template' +export { vueDirectivesAddon } from './addons/vue-directives' +export { vueTemplateAddon } from './addons/vue-template' diff --git a/src/addons/addons.ts b/src/addons/addons.ts new file mode 100644 index 00000000..5b67dca9 --- /dev/null +++ b/src/addons/addons.ts @@ -0,0 +1,40 @@ +import type { Addon, UnimportOptions } from '../types' +import { VUE_DIRECTIVES_NAME, vueDirectivesAddon } from './vue-directives' +import { VUE_TEMPLATE_NAME, vueTemplateAddon } from './vue-template' + +export function configureAddons(opts: Partial) { + const addons: Addon[] = [] + + if (Array.isArray(opts.addons)) { + addons.push(...opts.addons) + } + else { + const addonsMap = new Map() + if (opts.addons?.addons?.length) { + let i = 0 + for (const addon of opts.addons.addons) { + addonsMap.set(addon.name || `external:custom-${i++}`, addon) + } + } + + if (opts.addons?.vueTemplate) { + if (!addonsMap.has(VUE_TEMPLATE_NAME)) { + addonsMap.set(VUE_TEMPLATE_NAME, vueTemplateAddon()) + } + } + + if (opts.addons?.vueDirectives) { + if (!addonsMap.has(VUE_DIRECTIVES_NAME)) { + addonsMap.set(VUE_DIRECTIVES_NAME, vueDirectivesAddon( + typeof opts.addons.vueDirectives === 'object' + ? opts.addons.vueDirectives + : undefined, + )) + } + } + + addons.push(...addonsMap.values()) + } + + return addons +} diff --git a/src/addons/vue-directives.ts b/src/addons/vue-directives.ts new file mode 100644 index 00000000..95b64d1f --- /dev/null +++ b/src/addons/vue-directives.ts @@ -0,0 +1,190 @@ +import type { Addon, AddonVueDirectivesOptions, Import } from '../types' +import { basename } from 'node:path' +import process from 'node:process' +import { resolve } from 'pathe' +import { camelCase, kebabCase } from 'scule' +import { stringifyImports } from '../utils' + +const contextRE = /resolveDirective as _resolveDirective/ +const contextText = `${contextRE.source}, ` +const directiveRE = /(?:var|const) (\w+) = _resolveDirective\("([\w.-]+)"\);?\s*/g + +export const VUE_DIRECTIVES_NAME = 'unimport:vue-directives' + +export function vueDirectivesAddon( + options: AddonVueDirectivesOptions = {}, +): Addon { + function isDirective(importEntry: Import) { + return importEntry.meta?.vueDirective === true + || (options.isDirective?.(normalizePath(process.cwd(), importEntry.from), importEntry) ?? false) + } + + const self = { + name: VUE_DIRECTIVES_NAME, + async transform(s, id) { + if (!s.original.includes('_ctx.') || !s.original.match(contextRE)) + return s + + const matches = Array + .from(s.original.matchAll(directiveRE)) + .sort((a, b) => b.index - a.index) + + if (!matches.length) + return s + + let targets: Import[] = [] + for await ( + const [ + begin, + end, + importEntry, + ] of findDirectives( + isDirective, + matches, + this.getImports(), + ) + ) { + // remove the directive declaration + s.overwrite(begin, end, '') + targets.push(importEntry) + } + + if (!targets.length) + return s + + // remove resolveDirective import + s.replace(contextText, '') + + for (const addon of this.addons) { + if (addon === self) + continue + + targets = await addon.injectImportsResolved?.call(this, targets, s, id) ?? targets + } + + let injection = stringifyImports(targets) + for (const addon of this.addons) { + if (addon === self) + continue + + injection = await addon.injectImportsStringified?.call(this, injection, targets, s, id) ?? injection + } + + s.prepend(injection) + + return s + }, + async declaration(dts, options) { + const directivesMap = await this.getImports().then((imports) => { + return imports.filter(isDirective).reduce((acc, i) => { + if (i.type || i.dtsDisabled) + return acc + + let name: string + if (i.name === 'default' && (i.as === 'default' || !i.as)) { + const file = basename(i.from) + const idx = file.indexOf('.') + name = idx > -1 ? file.slice(0, idx) : file + } + else { + name = i.as ?? i.name + } + name = name[0] === 'v' ? camelCase(name) : camelCase(`v-${name}`) + if (!acc.has(name)) { + acc.set(name, i) + } + return acc + }, new Map()) + }) + + if (!directivesMap.size) + return dts + + const directives = Array + .from(directivesMap.entries()) + .map(([name, i]) => ` ${name}: typeof import('${options?.resolvePath?.(i) || i.from}')['${i.name}']`) + .sort() + .join('\n') + + return `${dts} +// for vue directives auto import +declare module 'vue' { + interface ComponentCustomProperties { +${directives} + } + interface GlobalDirectives { +${directives} + } +}` + }, + } satisfies Addon + + return self +} + +function resolvePath(cwd: string, path: string) { + return path[0] === '.' ? resolve(cwd, path) : path +} + +function normalizePath(cwd: string, path: string) { + return resolvePath(cwd, path).replace(/\\/g, '/') +} + +type DirectiveData = [begin: number, end: number, importName: string] +type DirectiveImport = [begin: number, end: number, import: Import] + +async function* findDirectives( + isDirective: (importEntry: Import) => boolean, + regexArray: RegExpExecArray[], + importsPromise: Promise, +): AsyncGenerator { + const imports = (await importsPromise).filter(isDirective) + if (!imports.length) + return + + const symbols = regexArray.reduce((acc, regex) => { + const [all, symbol, resolveDirectiveName] = regex + if (acc.has(symbol)) + return acc + + acc.set(symbol, [ + regex.index, + regex.index + all.length, + kebabCase(resolveDirectiveName), + ] as const) + return acc + }, new Map()) + + for (const [symbol, data] of symbols.entries()) { + yield * findDirective(imports, symbol, data) + } +} + +function* findDirective( + imports: Import[], + symbol: string, + [begin, end, importName]: DirectiveData, +): Generator { + let resolvedName: string + for (const i of imports) { + if (i.name === 'default' && (i.as === 'default' || !i.as)) { + const file = basename(i.from) + const idx = file.indexOf('.') + resolvedName = kebabCase(idx > -1 ? file.slice(0, idx) : file) + } + else { + resolvedName = kebabCase(i.as ?? i.name) + } + if (resolvedName[0] === 'v') { + resolvedName = resolvedName.slice(resolvedName[1] === '-' ? 2 : 1) + } + if (resolvedName === importName) { + yield [ + begin, + end, + { ...i, name: i.name, as: symbol }, + ] + return + } + } +} diff --git a/src/addons/vue-template.ts b/src/addons/vue-template.ts index 76e80c2c..f70242a3 100644 --- a/src/addons/vue-template.ts +++ b/src/addons/vue-template.ts @@ -4,8 +4,11 @@ import { stringifyImports } from '../utils' const contextRE = /\b_ctx\.([$\w]+)\b/g const UNREF_KEY = '__unimport_unref_' +export const VUE_TEMPLATE_NAME = 'unimport:vue-template' + export function vueTemplateAddon(): Addon { const self: Addon = { + name: VUE_TEMPLATE_NAME, async transform(s, id) { if (!s.original.includes('_ctx.') || s.original.includes(UNREF_KEY)) return s diff --git a/src/context.ts b/src/context.ts index 5b5d9a10..b3096240 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,8 +1,18 @@ import type MagicString from 'magic-string' -import type { Addon, Import, ImportInjectionResult, InjectImportsOptions, Thenable, TypeDeclarationOptions, Unimport, UnimportContext, UnimportMeta, UnimportOptions } from './types' +import type { + Import, + ImportInjectionResult, + InjectImportsOptions, + Thenable, + TypeDeclarationOptions, + Unimport, + UnimportContext, + UnimportMeta, + UnimportOptions, +} from './types' import { version } from '../package.json' -import { vueTemplateAddon } from './addons' +import { configureAddons } from './addons/addons' import { detectImports } from './detect' import { dedupeDtsExports, scanExports, scanFilesFromDir } from './node/scan-dirs' import { resolveBuiltinPresets } from './preset' @@ -101,12 +111,7 @@ function createInternalContext(opts: Partial) { let _combinedImports: Import[] | undefined const _map = new Map() - const addons: Addon[] = [] - - if (Array.isArray(opts.addons)) - addons.push(...opts.addons) - else if (opts.addons?.vueTemplate) - addons.push(vueTemplateAddon()) + const addons = configureAddons(opts) opts.addons = addons opts.commentsDisable = opts.commentsDisable ?? ['@unimport-disable', '@imports-disable'] diff --git a/src/types.ts b/src/types.ts index b9fa3bfb..e899793f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,12 +157,36 @@ export interface UnimportMeta { } export interface AddonsOptions { + addons?: Addon[] /** * Enable auto import inside for Vue's