diff --git a/docs/content/3.docs/2.directory-structure/5.composables.md b/docs/content/3.docs/2.directory-structure/5.composables.md index f4f33bd7e3..cdd33d068c 100644 --- a/docs/content/3.docs/2.directory-structure/5.composables.md +++ b/docs/content/3.docs/2.directory-structure/5.composables.md @@ -6,4 +6,41 @@ head.title: Composables directory # Composables directory -Nuxt will soon support a `composables/` directory to auto import your Vue composables into your application when used, learn more on the [open issue](https://github.com/nuxt/framework/issues/639). +Nuxt 3 supports `composables/` directory to auto import your Vue composables into your application and use using auto imports! + + +Example: (using named exports) + +```js [composables/useFoo.ts] +import { useState } from '#app' + +export const useFoo () { + return useState('foo', () => 'bar') +} +``` + +Example: (using default export) + +```js [composables/use-foo.ts or composables/useFoo.ts] +import { useState } from '#app' + +// It will be available as useFoo() +export default function () { + return 'foo' +} +``` + +You can now auto import it: + +```vue [app.vue] + + + +``` + diff --git a/examples/with-composables/app.vue b/examples/with-composables/app.vue new file mode 100644 index 0000000000..8c8715d8d6 --- /dev/null +++ b/examples/with-composables/app.vue @@ -0,0 +1,17 @@ + + + diff --git a/examples/with-composables/composables/use-foo.ts b/examples/with-composables/composables/use-foo.ts new file mode 100644 index 0000000000..3550421ffe --- /dev/null +++ b/examples/with-composables/composables/use-foo.ts @@ -0,0 +1,23 @@ +import { useState } from '#app' + +export function useA () { + return 'a' +} + +function useB () { + return 'b' +} + +function _useC () { + return 'c' +} + +export const useD = () => { + return 'd' +} + +export { useB, _useC as useC } + +export default function () { + return useState('foo', () => 'bar') +} diff --git a/examples/with-composables/nuxt.config.ts b/examples/with-composables/nuxt.config.ts new file mode 100644 index 0000000000..a3e4d68096 --- /dev/null +++ b/examples/with-composables/nuxt.config.ts @@ -0,0 +1,4 @@ +import { defineNuxtConfig } from 'nuxt3' + +export default defineNuxtConfig({ +}) diff --git a/examples/with-composables/package.json b/examples/with-composables/package.json new file mode 100644 index 0000000000..1fb77a9a04 --- /dev/null +++ b/examples/with-composables/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-with-composables", + "private": true, + "devDependencies": { + "nuxt3": "latest" + }, + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "start": "node .output/server/index.mjs" + } +} diff --git a/packages/kit/src/types/hooks.ts b/packages/kit/src/types/hooks.ts index e27b71f537..5de25042ca 100644 --- a/packages/kit/src/types/hooks.ts +++ b/packages/kit/src/types/hooks.ts @@ -41,6 +41,7 @@ export interface NuxtHooks { // Auto imports 'autoImports:sources': (autoImportSources: AutoImportSource[]) => HookResult 'autoImports:extend': (autoImports: AutoImport[]) => HookResult + 'autoImports:dirs': (dirs: string[]) => HookResult // Components 'components:dirs': (dirs: ComponentsOptions['dirs']) => HookResult diff --git a/packages/kit/src/types/imports.ts b/packages/kit/src/types/imports.ts index c46655dff4..990645d0e7 100644 --- a/packages/kit/src/types/imports.ts +++ b/packages/kit/src/types/imports.ts @@ -46,4 +46,10 @@ export interface AutoImportsOptions { * [experimental] Use globalThis injection instead of transform for development */ global?: boolean + /** + * Additional directories to scan composables from + * + * By default /composables is added + */ + dirs?: [] } diff --git a/packages/nuxi/src/commands/dev.ts b/packages/nuxi/src/commands/dev.ts index d86957e9f7..cd6629efb7 100644 --- a/packages/nuxi/src/commands/dev.ts +++ b/packages/nuxi/src/commands/dev.ts @@ -74,20 +74,26 @@ export default defineNuxtCommand({ // TODO: Watcher service, modules, and requireTree const dLoad = debounce(load, 250) const watcher = chokidar.watch([rootDir], { ignoreInitial: true, depth: 1 }) - watcher.on('all', (_event, file) => { + watcher.on('all', (event, file) => { if (!currentNuxt) { return } if (file.startsWith(currentNuxt.options.buildDir)) { return } if (file.match(/nuxt\.config\.(js|ts|mjs|cjs)$/)) { dLoad(true, `${relative(rootDir, file)} updated`) } - if (['addDir', 'unlinkDir'].includes(_event) && file.match(/pages$/)) { - dLoad(true, `Directory \`pages/\` ${_event === 'addDir' ? 'created' : 'removed'}`) - } - if (['addDir', 'unlinkDir'].includes(_event) && file.match(/components$/)) { - dLoad(true, `Directory \`components/\` ${_event === 'addDir' ? 'created' : 'removed'}`) - } - if (['add', 'unlink'].includes(_event) && file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) { - dLoad(true, `\`${relative(rootDir, file)}\` ${_event === 'add' ? 'created' : 'removed'}`) + + const isDirChange = ['addDir', 'unlinkDir'].includes(event) + const isFileChange = ['add', 'unlink'].includes(event) + const reloadDirs = ['pages', 'components', 'composables'] + + if (isDirChange) { + const dir = reloadDirs.find(dir => file.endsWith(dir)) + if (dir) { + dLoad(true, `Directory \`${dir}/\` ${event === 'addDir' ? 'created' : 'removed'}`) + } + } else if (isFileChange) { + if (file.match(/app\.(js|ts|mjs|jsx|tsx|vue)$/)) { + dLoad(true, `\`${relative(rootDir, file)}\` ${event === 'add' ? 'created' : 'removed'}`) + } } }) diff --git a/packages/nuxt3/src/auto-imports/composables.ts b/packages/nuxt3/src/auto-imports/composables.ts new file mode 100644 index 0000000000..1000ee1283 --- /dev/null +++ b/packages/nuxt3/src/auto-imports/composables.ts @@ -0,0 +1,39 @@ +import { promises as fsp, existsSync } from 'fs' +import { parse as parsePath, join } from 'pathe' +import globby from 'globby' +import { findExports } from 'mlly' +import { camelCase } from 'scule' +import { AutoImport } from '@nuxt/kit' +import { filterInPlace } from './utils' + +export async function scanForComposables (dir: string, autoImports: AutoImport[]) { + if (!existsSync(dir)) { return } + + const files = await globby(['*.{ts,js,tsx,jsx,mjs,cjs,mts,cts}'], { cwd: dir }) + + await Promise.all( + files.map(async (file) => { + const importPath = join(dir, file) + + // Remove original entries from the same import (for build watcher) + filterInPlace(autoImports, i => i.from !== importPath) + + const code = await fsp.readFile(join(dir, file), 'utf-8') + const exports = findExports(code) + const defaultExport = exports.find(i => i.type === 'default') + + if (defaultExport) { + autoImports.push({ name: 'default', as: camelCase(parsePath(file).name), from: importPath }) + } + for (const exp of exports) { + if (exp.type === 'named') { + for (const name of exp.names) { + autoImports.push({ name, as: name, from: importPath }) + } + } else if (exp.type === 'declaration') { + autoImports.push({ name: exp.name, as: exp.name, from: importPath }) + } + } + }) + ) +} diff --git a/packages/nuxt3/src/auto-imports/context.ts b/packages/nuxt3/src/auto-imports/context.ts new file mode 100644 index 0000000000..78bf9f9598 --- /dev/null +++ b/packages/nuxt3/src/auto-imports/context.ts @@ -0,0 +1,42 @@ +import type { AutoImport } from '@nuxt/kit' + +export interface AutoImportContext { + autoImports: AutoImport[] + matchRE: RegExp + map: Map +} + +export function createAutoImportContext (): AutoImportContext { + return { + autoImports: [], + map: new Map(), + matchRE: /__never__/ + } +} + +export function updateAutoImportContext (ctx: AutoImportContext) { + // Detect duplicates + const usedNames = new Set() + for (const autoImport of ctx.autoImports) { + if (usedNames.has(autoImport.as)) { + autoImport.disabled = true + console.warn(`Disabling duplicate auto import '${autoImport.as}' (imported from '${autoImport.from}')`) + } else { + usedNames.add(autoImport.as) + } + } + + // Filter out disabled auto imports + ctx.autoImports = ctx.autoImports.filter(i => i.disabled !== true) + + // Create regex + ctx.matchRE = new RegExp(`\\b(${ctx.autoImports.map(i => i.as).join('|')})\\b`, 'g') + + // Create map + ctx.map.clear() + for (const autoImport of ctx.autoImports) { + ctx.map.set(autoImport.as, autoImport) + } + + return ctx +} diff --git a/packages/nuxt3/src/auto-imports/module.ts b/packages/nuxt3/src/auto-imports/module.ts index 1141711123..0a6cf1bb5b 100644 --- a/packages/nuxt3/src/auto-imports/module.ts +++ b/packages/nuxt3/src/auto-imports/module.ts @@ -1,15 +1,19 @@ -import { addVitePlugin, addWebpackPlugin, defineNuxtModule, addTemplate, resolveAlias, addPluginTemplate, AutoImport } from '@nuxt/kit' +import { addVitePlugin, addWebpackPlugin, defineNuxtModule, addTemplate, resolveAlias, addPluginTemplate, useNuxt } from '@nuxt/kit' import type { AutoImportsOptions } from '@nuxt/kit' -import { isAbsolute, relative, resolve } from 'pathe' +import { isAbsolute, join, relative, resolve, normalize } from 'pathe' import { TransformPlugin } from './transform' import { Nuxt3AutoImports } from './imports' +import { scanForComposables } from './composables' +import { toImports } from './utils' +import { AutoImportContext, createAutoImportContext, updateAutoImportContext } from './context' export default defineNuxtModule({ name: 'auto-imports', configKey: 'autoImports', defaults: { sources: Nuxt3AutoImports, - global: false + global: false, + dirs: [] }, async setup (options, nuxt) { // Allow modules extending sources @@ -18,91 +22,96 @@ export default defineNuxtModule({ // Filter disabled sources options.sources = options.sources.filter(source => source.disabled !== true) + // Create a context to share state between module internals + const ctx = createAutoImportContext() + // Resolve autoimports from sources - let autoImports: AutoImport[] = [] for (const source of options.sources) { for (const importName of source.names) { if (typeof importName === 'string') { - autoImports.push({ name: importName, as: importName, from: source.from }) + ctx.autoImports.push({ name: importName, as: importName, from: source.from }) } else { - autoImports.push({ name: importName.name, as: importName.as || importName.name, from: source.from }) + ctx.autoImports.push({ name: importName.name, as: importName.as || importName.name, from: source.from }) } } } - // Allow modules extending resolved imports - await nuxt.callHook('autoImports:extend', autoImports) + // composables/ dirs + let composablesDirs = [ + join(nuxt.options.srcDir, 'composables'), + ...options.dirs + ] + await nuxt.callHook('autoImports:dirs', composablesDirs) + composablesDirs = composablesDirs.map(dir => normalize(dir)) - // Disable duplicate auto imports - const usedNames = new Set() - for (const autoImport of autoImports) { - if (usedNames.has(autoImport.as)) { - autoImport.disabled = true - console.warn(`Disabling duplicate auto import '${autoImport.as}' (imported from '${autoImport.from}')`) - } else { - usedNames.add(autoImport.as) - } - } - - // Filter disabled imports - autoImports = autoImports.filter(i => i.disabled !== true) - - // temporary disable #746 - // @ts-ignore + // Transpile and injection + // @ts-ignore temporary disabled due to #746 if (nuxt.options.dev && options.global) { // Add all imports to globalThis in development mode addPluginTemplate({ filename: 'auto-imports.mjs', src: '', getContents: () => { - const imports = toImports(autoImports) - const globalThisSet = autoImports.map(i => `globalThis.${i.as} = ${i.as};`).join('\n') + const imports = toImports(ctx.autoImports) + const globalThisSet = ctx.autoImports.map(i => `globalThis.${i.as} = ${i.as};`).join('\n') return `${imports}\n\n${globalThisSet}\n\nexport default () => {};` } }) } else { // Transform to inject imports in production mode - addVitePlugin(TransformPlugin.vite(autoImports)) - addWebpackPlugin(TransformPlugin.webpack(autoImports)) + addVitePlugin(TransformPlugin.vite(ctx)) + addWebpackPlugin(TransformPlugin.webpack(ctx)) } - // Add types - const resolved = {} - const r = (id: string) => { - if (resolved[id]) { return resolved[id] } - let path = resolveAlias(id, nuxt.options.alias) - if (isAbsolute(path)) { - path = relative(nuxt.options.buildDir, path) + const updateAutoImports = async () => { + // Scan composables/ + for (const composablesDir of composablesDirs) { + await scanForComposables(composablesDir, ctx.autoImports) } - // Remove file extension for benefit of TypeScript - path = path.replace(/\.[a-z]+$/, '') - resolved[id] = path - return path + // Allow modules extending + await nuxt.callHook('autoImports:extend', ctx.autoImports) + // Update context + updateAutoImportContext(ctx) + // Generate types + generateDts(ctx) } + await updateAutoImports() - addTemplate({ - filename: 'auto-imports.d.ts', - write: true, - getContents: () => `// Generated by auto imports -declare global { -${autoImports.map(i => ` const ${i.as}: typeof import('${r(i.from)}')['${i.name}']`).join('\n')} -}\nexport {}` - }) - nuxt.hook('prepare:types', ({ references }) => { - references.push({ path: resolve(nuxt.options.buildDir, 'auto-imports.d.ts') }) + // Watch composables/ directory + nuxt.hook('builder:watch', async (_, path) => { + const _resolved = resolve(nuxt.options.srcDir, path) + if (composablesDirs.find(dir => _resolved.startsWith(dir))) { + await updateAutoImports() + } }) } }) -function toImports (autoImports: AutoImport[]) { - const map: Record> = {} - for (const autoImport of autoImports) { - if (!map[autoImport.from]) { - map[autoImport.from] = new Set() +function generateDts (ctx: AutoImportContext) { + const nuxt = useNuxt() + + const resolved = {} + const r = (id: string) => { + if (resolved[id]) { return resolved[id] } + let path = resolveAlias(id, nuxt.options.alias) + if (isAbsolute(path)) { + path = relative(nuxt.options.buildDir, path) } - map[autoImport.from].add(autoImport.as) + // Remove file extension for benefit of TypeScript + path = path.replace(/\.[a-z]+$/, '') + resolved[id] = path + return path } - return Object.entries(map) - .map(([name, imports]) => `import { ${Array.from(imports).join(', ')} } from '${name}';`) - .join('\n') + + addTemplate({ + filename: 'auto-imports.d.ts', + write: true, + getContents: () => `// Generated by auto imports +declare global { +${ctx.autoImports.map(i => ` const ${i.as}: typeof import('${r(i.from)}')['${i.name}']`).join('\n')} +} + +export {} +` + }) } diff --git a/packages/nuxt3/src/auto-imports/transform.ts b/packages/nuxt3/src/auto-imports/transform.ts index 233bcee231..cc2c7a3d54 100644 --- a/packages/nuxt3/src/auto-imports/transform.ts +++ b/packages/nuxt3/src/auto-imports/transform.ts @@ -1,6 +1,7 @@ import { createUnplugin } from 'unplugin' import { parseQuery, parseURL } from 'ufo' -import type { AutoImport } from '@nuxt/kit' +import { toImports } from './utils' +import { AutoImportContext } from './context' const excludeRE = [ // imported from other module @@ -22,15 +23,7 @@ function stripeComments (code: string) { .replace(singlelineCommentsRE, '') } -export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => { - const matchRE = new RegExp(`\\b(${autoImports.map(i => i.as).join('|')})\\b`, 'g') - - // Create an internal map for faster lookup - const autoImportMap = new Map() - for (const autoImport of autoImports) { - autoImportMap.set(autoImport.as, autoImport) - } - +export const TransformPlugin = createUnplugin((ctx: AutoImportContext) => { return { name: 'nuxt-auto-imports-transform', enforce: 'post', @@ -60,7 +53,7 @@ export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => { const withoutComment = stripeComments(code) // find all possible injection - const matched = new Set(Array.from(withoutComment.matchAll(matchRE)).map(i => i[1])) + const matched = new Set(Array.from(withoutComment.matchAll(ctx.matchRE)).map(i => i[1])) // remove those already defined for (const regex of excludeRE) { @@ -78,28 +71,11 @@ export const TransformPlugin = createUnplugin((autoImports: AutoImport[]) => { return null } - const modules: Record = {} - - // group by module name - Array.from(matched).forEach((name) => { - const moduleName = autoImportMap.get(name).from - if (!modules[moduleName]) { - modules[moduleName] = [] - } - modules[moduleName].push(name) - }) - - // Needed for webpack4/bridge support + // For webpack4/bridge support const isCJSContext = code.includes('require(') - // stringify import - const imports = !isCJSContext - ? Object.entries(modules) - .map(([moduleName, names]) => `import { ${names.join(',')} } from '${moduleName}';`) - .join('') - : Object.entries(modules) - .map(([moduleName, names]) => `const { ${names.join(',')} } = require('${moduleName}');`) - .join('') + const matchedImports = Array.from(matched).map(name => ctx.map.get(name)).filter(Boolean) + const imports = toImports(matchedImports, isCJSContext) return imports + code } diff --git a/packages/nuxt3/src/auto-imports/utils.ts b/packages/nuxt3/src/auto-imports/utils.ts new file mode 100644 index 0000000000..2d7d8789d5 --- /dev/null +++ b/packages/nuxt3/src/auto-imports/utils.ts @@ -0,0 +1,36 @@ +import type { AutoImport } from '@nuxt/kit' + +export function toImports (autoImports: AutoImport[], isCJS = false) { + const aliasKeyword = isCJS ? ' : ' : ' as ' + + const map: Record> = {} + for (const autoImport of autoImports) { + if (!map[autoImport.from]) { + map[autoImport.from] = new Set() + } + map[autoImport.from].add( + autoImport.name === autoImport.as + ? autoImport.name + : autoImport.name + aliasKeyword + autoImport.as + ) + } + + if (isCJS) { + return Object.entries(map) + .map(([name, imports]) => `const { ${Array.from(imports).join(', ')} } = require('${name}');`) + .join('\n') + } else { + return Object.entries(map) + .map(([name, imports]) => `import { ${Array.from(imports).join(', ')} } from '${name}';`) + .join('\n') + } +} + +export function filterInPlace (arr: T[], predicate: (v: T) => any) { + let i = arr.length + while (i--) { + if (!predicate(arr[i])) { + arr.splice(i, 1) + } + } +} diff --git a/packages/nuxt3/test/auto-imports.test.ts b/packages/nuxt3/test/auto-imports.test.ts index 8bb19b29ca..892c77f6f7 100644 --- a/packages/nuxt3/test/auto-imports.test.ts +++ b/packages/nuxt3/test/auto-imports.test.ts @@ -1,5 +1,6 @@ import type { AutoImport } from '@nuxt/kit' import { expect } from 'chai' +import { AutoImportContext, updateAutoImportContext } from '../src/auto-imports/context' import { TransformPlugin } from '../src/auto-imports/transform' describe('auto-imports:transform', () => { @@ -8,7 +9,10 @@ describe('auto-imports:transform', () => { { name: 'computed', as: 'computed', from: 'bar' } ] - const transformPlugin = TransformPlugin.raw(autoImports, { framework: 'rollup' }) + const ctx = { autoImports, map: new Map() } as AutoImportContext + updateAutoImportContext(ctx) + + const transformPlugin = TransformPlugin.raw(ctx, { framework: 'rollup' }) const transform = (code: string) => transformPlugin.transform.call({ error: null, warn: null }, code, '') it('should correct inject', async () => { diff --git a/yarn.lock b/yarn.lock index 46aad3f41e..4070c1764c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9278,6 +9278,14 @@ __metadata: languageName: unknown linkType: soft +"example-with-composables@workspace:examples/with-composables": + version: 0.0.0-use.local + resolution: "example-with-composables@workspace:examples/with-composables" + dependencies: + nuxt3: latest + languageName: unknown + linkType: soft + "example-with-layouts@workspace:examples/with-layouts": version: 0.0.0-use.local resolution: "example-with-layouts@workspace:examples/with-layouts"