From 4e5601267b30cc55c9d0d4a4da0452c7b28cf9a0 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Mon, 13 Jul 2026 19:42:05 +0200 Subject: [PATCH 1/8] perf(vue): skip rewriting unchanged templates --- src/plugins/templates.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/plugins/templates.ts b/src/plugins/templates.ts index 9c0446f6c7..4a631e3081 100644 --- a/src/plugins/templates.ts +++ b/src/plugins/templates.ts @@ -15,15 +15,26 @@ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record async function writeTemplates(root: string) { const map: Record = {} const dir = path.join(root, 'node_modules', '.nuxt-ui') + const createdDirs = new Set() for (const template of templates) { if (!template.write || !template.filename) { continue } const filePath = path.join(dir, template.filename) - if (!fs.existsSync(path.dirname(filePath))) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const fileDir = path.dirname(filePath) + if (!createdDirs.has(fileDir)) { + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }) + } + createdDirs.add(fileDir) + } + + const contents = await template.getContents!({} as any) + // Skip rewriting identical files so we don't churn mtimes on every config + // resolve, which needlessly invalidates watchers and Tailwind's source scan. + if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== contents) { + fs.writeFileSync(filePath, contents) } - fs.writeFileSync(filePath, await template.getContents!({} as any)) map[`#build/${template.filename}`] = filePath } From 9337e190f4b8d9067c6e518ff41d98842f22e537 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Mon, 13 Jul 2026 20:00:41 +0200 Subject: [PATCH 2/8] feat(vue): support experimental.componentDetection --- src/plugins/templates.ts | 13 +++++-- src/templates.ts | 52 ++++++++++++++------------ src/unplugin.ts | 6 +-- test/utils/component-detection.spec.ts | 52 ++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 test/utils/component-detection.spec.ts diff --git a/src/plugins/templates.ts b/src/plugins/templates.ts index 4a631e3081..f234769c33 100644 --- a/src/plugins/templates.ts +++ b/src/plugins/templates.ts @@ -8,8 +8,14 @@ import { getTemplates } from '../templates' * This plugin is responsible for getting the generated virtual templates and * making them available to the Vue build. */ -export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record) { - const templates = getTemplates(options, appConfig.ui) +export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record, componentDir?: string) { + // Resolved lazily in `vite.config` (below), before the `ui.css` template's + // `getContents` runs — so `experimental.componentDetection` can scan the app. + let resolvedRoot: string | undefined + const templates = getTemplates(options, appConfig.ui, undefined, undefined, { + root: () => resolvedRoot, + componentDir + }) const templateKeys = new Set(templates.map(t => `#build/${t.filename}`)) async function writeTemplates(root: string) { @@ -53,7 +59,8 @@ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record // every theme class from the generated CSS. // `options.root` lets setups like `electron-vite` override the location // when `config.root` points to a sub-directory Tailwind doesn't scan. - const alias = await writeTemplates(path.resolve(options.root || config.root || '.')) + resolvedRoot = path.resolve(options.root || config.root || '.') + const alias = await writeTemplates(resolvedRoot) return { resolve: { diff --git a/src/templates.ts b/src/templates.ts index 7a7f372be6..e936cd5695 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -12,7 +12,7 @@ import * as theme from './theme' import * as themeProse from './theme/prose' import * as themeContent from './theme/content' -export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve']) { +export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: () => string | undefined, componentDir?: string }) { const templates: NuxtTemplate[] = [] let hasProse = false @@ -116,37 +116,43 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record layer.app) - // Add layer sources - for (const layer of layers) { - sources.push(`@source "${layer}**/*";`) - } + // Layer + inline sources are Nuxt-only; the Vue integration relies on the + // user's own Vite/Tailwind setup to scan their source. + const layers = nuxt ? getLayerDirectories(nuxt).map(layer => layer.app) : [] - // Add inline sources from Nuxt config (classes defined in config) - const inlineConfigs = [ - nuxt.options.app?.rootAttrs?.class, - nuxt.options.app?.head?.htmlAttrs?.class, - nuxt.options.app?.head?.bodyAttrs?.class - ] + if (nuxt) { + // Add layer sources + for (const layer of layers) { + sources.push(`@source "${layer}**/*";`) + } - for (const value of inlineConfigs) { - if (value && typeof value === 'string') { - sources.push(`@source inline(${JSON.stringify(value)});`) + // Add inline sources from Nuxt config (classes defined in config) + const inlineConfigs = [ + nuxt.options.app?.rootAttrs?.class, + nuxt.options.app?.head?.htmlAttrs?.class, + nuxt.options.app?.head?.bodyAttrs?.class + ] + + for (const value of inlineConfigs) { + if (value && typeof value === 'string') { + sources.push(`@source inline(${JSON.stringify(value)});`) + } } } - // Add theme sources (component detection or all) - if (resolve && options.experimental?.componentDetection) { + // Add theme sources (component detection or all). Detection works for both + // integrations: Nuxt scans its layers and resolves the component dir via + // `resolve`; the Vue plugin scans the Vite root with the component dir threaded in. + const componentDir = resolve ? resolve('./runtime/components') : vue?.componentDir + const scanDirs = nuxt ? layers : (vue?.root?.() ? [vue.root()!] : []) + + if (options.experimental?.componentDetection && componentDir && scanDirs.length) { const detectedComponents = await detectUsedComponents( - layers, + scanDirs, options.prefix!, - resolve('./runtime/components'), + componentDir, Array.isArray(options.experimental.componentDetection) ? options.experimental.componentDetection : undefined ) diff --git a/src/unplugin.ts b/src/unplugin.ts index dd3a36a112..8eee5b9524 100644 --- a/src/unplugin.ts +++ b/src/unplugin.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url' -import { normalize } from 'pathe' +import { join, normalize } from 'pathe' import type { UnpluginOptions } from 'unplugin' import { createUnplugin } from 'unplugin' import type { Options as AutoImportOptions } from 'unplugin-auto-import/types' @@ -36,7 +36,7 @@ type AppConfigUI = { prefix?: string } & TVConfig -export interface NuxtUIOptions extends Omit { +export interface NuxtUIOptions extends Omit { /** Whether to generate declaration files for auto-imported components. */ dts?: boolean ui?: AppConfigUI @@ -115,7 +115,7 @@ export const NuxtUIPlugin = createUnplugin((_options tailwind(), IconsPlugin(options, appConfig), PluginsPlugin(options), - TemplatePlugin(options, appConfig), + TemplatePlugin(options, appConfig, join(runtimeDir, 'components')), AppConfigPlugin(options, appConfig), { name: 'nuxt:ui:plugins-duplication-detection', diff --git a/test/utils/component-detection.spec.ts b/test/utils/component-detection.spec.ts new file mode 100644 index 0000000000..8f0996fbbc --- /dev/null +++ b/test/utils/component-detection.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { defu } from 'defu' +import { getTemplates } from '../../src/templates' +import { defaultOptions, getDefaultConfig, resolveColors } from '../../src/utils/defaults' + +const componentDir = join(process.cwd(), 'src/runtime/components') + +function buildOptions(componentDetection: boolean | string[]) { + const options: any = defu({ fonts: false, experimental: { componentDetection } }, defaultOptions) + options.theme = options.theme || {} + options.theme.colors = resolveColors(options.theme.colors) + return options +} + +// Drive the real `generateSources` through the Vue integration path (no `nuxt`, +// component dir + scan root threaded in) and return the emitted `@source` lines. +async function sources(componentDetection: boolean, root: string) { + const options = buildOptions(componentDetection) + const ui = getDefaultConfig(options.theme) + const templates = getTemplates(options, ui, undefined, undefined, { root: () => root, componentDir }) + const css = await templates.find(t => t.filename === 'ui.css')!.getContents!({} as any) + return css.split('\n').filter(line => line.trim().startsWith('@source')) +} + +function fixtureUsing(markup: string) { + const dir = mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-')) + writeFileSync(join(dir, 'App.vue'), `\n`) + return dir +} + +describe('vue componentDetection', () => { + it('scans every theme file when disabled (default)', async () => { + expect(await sources(false, fixtureUsing(''))).toEqual(['@source "./ui";']) + }) + + it('narrows @source to used components and their dependencies', async () => { + const emitted = await sources(true, fixtureUsing('')) + + // Button + its dependency closure, not the blanket scan of all ~163 themes. + expect(emitted).toContain('@source "./ui/button.ts";') + expect(emitted).toContain('@source "./ui/link.ts";') + expect(emitted).not.toContain('@source "./ui";') + expect(emitted.length).toBeLessThan(10) + }) + + it('falls back to scanning everything when no component is detected', async () => { + expect(await sources(true, fixtureUsing('
no nuxt ui here
'))).toContain('@source "./ui";') + }) +}) From d46f5f697fdbc809a5e6907302687aeb5af9544c Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Mon, 13 Jul 2026 20:02:02 +0200 Subject: [PATCH 3/8] docs(vue): document experimental.componentDetection --- .../1.getting-started/2.installation/2.vue.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/content/docs/1.getting-started/2.installation/2.vue.md b/docs/content/docs/1.getting-started/2.installation/2.vue.md index 3b725de070..3cd39fe447 100644 --- a/docs/content/docs/1.getting-started/2.installation/2.vue.md +++ b/docs/content/docs/1.getting-started/2.installation/2.vue.md @@ -913,6 +913,59 @@ export default defineConfig({ Point `root` at your project root so the generated `.nuxt-ui` directory ends up in a `node_modules` that Tailwind scans. :: +### `experimental.componentDetection` :badge{label="4.10+" class="align-text-top"} + +Use the `experimental.componentDetection` option to enable automatic component detection for tree-shaking. This feature scans your source code to detect which components are actually used and only generates the necessary CSS for those components (including their dependencies). Without it, the Vite plugin generates the theme CSS for every component. + +- Default: `false`{lang="ts-type"} +- Type: `boolean | string[]`{lang="ts-type"} + +**Enable automatic detection:** + +```ts [vite.config.ts] {8-10} +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import ui from '@nuxt/ui/vite' + +export default defineConfig({ + plugins: [ + vue(), + ui({ + experimental: { + componentDetection: true + } + }) + ] +}) +``` + +**Include additional components for dynamic usage:** + +```ts [vite.config.ts] {8-10} +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import ui from '@nuxt/ui/vite' + +export default defineConfig({ + plugins: [ + vue(), + ui({ + experimental: { + componentDetection: ['Modal', 'Dropdown', 'Popover'] + } + }) + ] +}) +``` + +::note +When providing an array of component names, automatic detection is enabled and these components (along with their dependencies) are guaranteed to be included. This is useful for dynamic components like `` that can't be statically analyzed. +:: + +::warning +Newly used components are picked up on the next dev-server start. If you add a component and its styles are missing, restart the dev server. +:: + ## Continuous releases Nuxt UI uses [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) for continuous preview releases, providing developers with instant access to the latest features and bug fixes without waiting for official releases. From 374f27419d4d63623fff7f1ae0ed2004e845fb36 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 10:51:31 +0200 Subject: [PATCH 4/8] docs(vue): update badge --- docs/content/docs/1.getting-started/2.installation/2.vue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/1.getting-started/2.installation/2.vue.md b/docs/content/docs/1.getting-started/2.installation/2.vue.md index 3cd39fe447..3ab2c67c37 100644 --- a/docs/content/docs/1.getting-started/2.installation/2.vue.md +++ b/docs/content/docs/1.getting-started/2.installation/2.vue.md @@ -913,7 +913,7 @@ export default defineConfig({ Point `root` at your project root so the generated `.nuxt-ui` directory ends up in a `node_modules` that Tailwind scans. :: -### `experimental.componentDetection` :badge{label="4.10+" class="align-text-top"} +### `experimental.componentDetection` :badge{label="Soon" class="align-text-top"} Use the `experimental.componentDetection` option to enable automatic component detection for tree-shaking. This feature scans your source code to detect which components are actually used and only generates the necessary CSS for those components (including their dependencies). Without it, the Vite plugin generates the theme CSS for every component. From 60f2f7cc6fd9b2a8b13bc5f454d829530a2c66f2 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 11:01:51 +0200 Subject: [PATCH 5/8] fix(vue): ignore nested node_modules/dist in component detection --- src/templates.ts | 3 +- src/utils/components.ts | 4 ++- test/utils/component-detection.spec.ts | 40 ++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/templates.ts b/src/templates.ts index e936cd5695..2e52e59eae 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -146,7 +146,8 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record root, componentDir }) @@ -25,12 +25,21 @@ async function sources(componentDetection: boolean, root: string) { return css.split('\n').filter(line => line.trim().startsWith('@source')) } +const fixtureDirs: string[] = [] + function fixtureUsing(markup: string) { const dir = mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-')) + fixtureDirs.push(dir) writeFileSync(join(dir, 'App.vue'), `\n`) return dir } +afterAll(() => { + for (const dir of fixtureDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + describe('vue componentDetection', () => { it('scans every theme file when disabled (default)', async () => { expect(await sources(false, fixtureUsing(''))).toEqual(['@source "./ui";']) @@ -46,6 +55,31 @@ describe('vue componentDetection', () => { expect(emitted.length).toBeLessThan(10) }) + it('always includes components listed in the array form', async () => { + // Nothing detected in the app, but `Modal` is guaranteed by the option. + const emitted = await sources(['Modal'], fixtureUsing('
no nuxt ui here
')) + + expect(emitted).toContain('@source "./ui/modal.ts";') + expect(emitted).toContain('@source "./ui/button.ts";') + expect(emitted).not.toContain('@source "./ui";') + }) + + it('skips files in nested node_modules and dist directories', async () => { + const dir = fixtureUsing('') + const nested = join(dir, 'packages', 'foo', 'node_modules', 'pkg') + const dist = join(dir, 'packages', 'foo', 'dist') + mkdirSync(nested, { recursive: true }) + mkdirSync(dist, { recursive: true }) + writeFileSync(join(nested, 'index.js'), 'export const UAlert = 1\n') + writeFileSync(join(dist, 'out.js'), 'export const UTable = 1\n') + + const emitted = await sources(true, dir) + + expect(emitted).toContain('@source "./ui/button.ts";') + expect(emitted).not.toContain('@source "./ui/alert.ts";') + expect(emitted).not.toContain('@source "./ui/table.ts";') + }) + it('falls back to scanning everything when no component is detected', async () => { expect(await sources(true, fixtureUsing('
no nuxt ui here
'))).toContain('@source "./ui";') }) From 80271468776b43bd5820eb107cd36b588bbc41c0 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 11:06:12 +0200 Subject: [PATCH 6/8] test: unit test detectUsedComponents directly --- test/utils/component-detection.spec.ts | 86 -------------------------- test/utils/components.spec.ts | 67 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 86 deletions(-) delete mode 100644 test/utils/component-detection.spec.ts create mode 100644 test/utils/components.spec.ts diff --git a/test/utils/component-detection.spec.ts b/test/utils/component-detection.spec.ts deleted file mode 100644 index 01f7dde3ce..0000000000 --- a/test/utils/component-detection.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, afterAll } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { defu } from 'defu' -import { getTemplates } from '../../src/templates' -import { defaultOptions, getDefaultConfig, resolveColors } from '../../src/utils/defaults' - -const componentDir = join(process.cwd(), 'src/runtime/components') - -function buildOptions(componentDetection: boolean | string[]) { - const options: any = defu({ fonts: false, experimental: { componentDetection } }, defaultOptions) - options.theme = options.theme || {} - options.theme.colors = resolveColors(options.theme.colors) - return options -} - -// Drive the real `generateSources` through the Vue integration path (no `nuxt`, -// component dir + scan root threaded in) and return the emitted `@source` lines. -async function sources(componentDetection: boolean | string[], root: string) { - const options = buildOptions(componentDetection) - const ui = getDefaultConfig(options.theme) - const templates = getTemplates(options, ui, undefined, undefined, { root: () => root, componentDir }) - const css = await templates.find(t => t.filename === 'ui.css')!.getContents!({} as any) - return css.split('\n').filter(line => line.trim().startsWith('@source')) -} - -const fixtureDirs: string[] = [] - -function fixtureUsing(markup: string) { - const dir = mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-')) - fixtureDirs.push(dir) - writeFileSync(join(dir, 'App.vue'), `\n`) - return dir -} - -afterAll(() => { - for (const dir of fixtureDirs) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -describe('vue componentDetection', () => { - it('scans every theme file when disabled (default)', async () => { - expect(await sources(false, fixtureUsing(''))).toEqual(['@source "./ui";']) - }) - - it('narrows @source to used components and their dependencies', async () => { - const emitted = await sources(true, fixtureUsing('')) - - // Button + its dependency closure, not the blanket scan of all ~163 themes. - expect(emitted).toContain('@source "./ui/button.ts";') - expect(emitted).toContain('@source "./ui/link.ts";') - expect(emitted).not.toContain('@source "./ui";') - expect(emitted.length).toBeLessThan(10) - }) - - it('always includes components listed in the array form', async () => { - // Nothing detected in the app, but `Modal` is guaranteed by the option. - const emitted = await sources(['Modal'], fixtureUsing('
no nuxt ui here
')) - - expect(emitted).toContain('@source "./ui/modal.ts";') - expect(emitted).toContain('@source "./ui/button.ts";') - expect(emitted).not.toContain('@source "./ui";') - }) - - it('skips files in nested node_modules and dist directories', async () => { - const dir = fixtureUsing('') - const nested = join(dir, 'packages', 'foo', 'node_modules', 'pkg') - const dist = join(dir, 'packages', 'foo', 'dist') - mkdirSync(nested, { recursive: true }) - mkdirSync(dist, { recursive: true }) - writeFileSync(join(nested, 'index.js'), 'export const UAlert = 1\n') - writeFileSync(join(dist, 'out.js'), 'export const UTable = 1\n') - - const emitted = await sources(true, dir) - - expect(emitted).toContain('@source "./ui/button.ts";') - expect(emitted).not.toContain('@source "./ui/alert.ts";') - expect(emitted).not.toContain('@source "./ui/table.ts";') - }) - - it('falls back to scanning everything when no component is detected', async () => { - expect(await sources(true, fixtureUsing('
no nuxt ui here
'))).toContain('@source "./ui";') - }) -}) diff --git a/test/utils/components.spec.ts b/test/utils/components.spec.ts new file mode 100644 index 0000000000..e85e06d1d9 --- /dev/null +++ b/test/utils/components.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { detectUsedComponents } from '../../src/utils/components' + +const componentDir = join(process.cwd(), 'src/runtime/components') + +const fixtureDirs: string[] = [] + +function fixtureUsing(markup: string) { + const dir = mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-')) + fixtureDirs.push(dir) + writeFileSync(join(dir, 'App.vue'), `\n`) + return dir +} + +afterAll(() => { + for (const dir of fixtureDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('detectUsedComponents', () => { + it('detects used components and resolves their dependencies', async () => { + const detected = await detectUsedComponents([fixtureUsing('')], 'U', componentDir) + + // Button + its dependency closure, nothing else. + expect(detected).toContain('Button') + expect(detected).toContain('Link') + expect(detected).not.toContain('Table') + expect(detected!.size).toBeLessThan(10) + }) + + it('detects lazy components', async () => { + const detected = await detectUsedComponents([fixtureUsing('')], 'U', componentDir) + + expect(detected).toContain('Tooltip') + }) + + it('always includes components from includeComponents', async () => { + const detected = await detectUsedComponents([fixtureUsing('
no nuxt ui here
')], 'U', componentDir, ['Modal']) + + expect(detected).toContain('Modal') + expect(detected).toContain('Button') + }) + + it('skips files in nested node_modules and dist directories', async () => { + const dir = fixtureUsing('') + const nested = join(dir, 'packages', 'foo', 'node_modules', 'pkg') + const dist = join(dir, 'packages', 'foo', 'dist') + mkdirSync(nested, { recursive: true }) + mkdirSync(dist, { recursive: true }) + writeFileSync(join(nested, 'index.js'), 'export const UAlert = 1\n') + writeFileSync(join(dist, 'out.js'), 'export const UTable = 1\n') + + const detected = await detectUsedComponents([dir], 'U', componentDir) + + expect(detected).toContain('Button') + expect(detected).not.toContain('Alert') + expect(detected).not.toContain('Table') + }) + + it('returns undefined when no component is detected', async () => { + expect(await detectUsedComponents([fixtureUsing('
no nuxt ui here
')], 'U', componentDir)).toBeUndefined() + }) +}) From 502806a9a3170edb7de72fed78b15e68b05923d8 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 15 Jul 2026 11:09:45 +0200 Subject: [PATCH 7/8] refactor(vue): pass resolved root as plain field --- src/plugins/templates.ts | 15 ++++++--------- src/templates.ts | 5 ++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/plugins/templates.ts b/src/plugins/templates.ts index fa817e62f9..2d0612c054 100644 --- a/src/plugins/templates.ts +++ b/src/plugins/templates.ts @@ -9,13 +9,10 @@ import { getTemplates } from '../templates' * making them available to the Vue build. */ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record, componentDir?: string) { - // Resolved lazily in `vite.config` (below), before the `ui.css` template's - // `getContents` runs — so `experimental.componentDetection` can scan the app. - let resolvedRoot: string | undefined - const templates = getTemplates(options, appConfig.ui, undefined, undefined, { - root: () => resolvedRoot, - componentDir - }) + // `root` is assigned in the `vite.config` hook (below), before the `ui.css` + // template's `getContents` runs — so `experimental.componentDetection` can scan the app. + const vue: { root?: string, componentDir?: string } = { componentDir } + const templates = getTemplates(options, appConfig.ui, undefined, undefined, vue) const templateKeys = new Set(templates.map(t => `#build/${t.filename}`)) async function writeTemplates(root: string) { @@ -67,8 +64,8 @@ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record // every theme class from the generated CSS. // `options.root` lets setups like `electron-vite` override the location // when `config.root` points to a sub-directory Tailwind doesn't scan. - resolvedRoot = path.resolve(options.root || config.root || '.') - const alias = await writeTemplates(resolvedRoot) + vue.root = path.resolve(options.root || config.root || '.') + const alias = await writeTemplates(vue.root) return { resolve: { diff --git a/src/templates.ts b/src/templates.ts index 2e52e59eae..654b52c5f5 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -12,7 +12,7 @@ import * as theme from './theme' import * as themeProse from './theme/prose' import * as themeContent from './theme/content' -export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: () => string | undefined, componentDir?: string }) { +export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: string, componentDir?: string }) { const templates: NuxtTemplate[] = [] let hasProse = false @@ -146,8 +146,7 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record Date: Wed, 15 Jul 2026 12:16:45 +0200 Subject: [PATCH 8/8] fix(vue): scan scanPackages and external component dirs for detection --- .../1.getting-started/2.installation/2.vue.md | 2 +- src/plugins/templates.ts | 9 ++- src/templates.ts | 7 +- src/utils/components.ts | 39 ++++++++++- test/utils/components.spec.ts | 64 ++++++++++++++++++- 5 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/1.getting-started/2.installation/2.vue.md b/docs/content/docs/1.getting-started/2.installation/2.vue.md index 3ab2c67c37..13eae920ac 100644 --- a/docs/content/docs/1.getting-started/2.installation/2.vue.md +++ b/docs/content/docs/1.getting-started/2.installation/2.vue.md @@ -915,7 +915,7 @@ Point `root` at your project root so the generated `.nuxt-ui` directory ends up ### `experimental.componentDetection` :badge{label="Soon" class="align-text-top"} -Use the `experimental.componentDetection` option to enable automatic component detection for tree-shaking. This feature scans your source code to detect which components are actually used and only generates the necessary CSS for those components (including their dependencies). Without it, the Vite plugin generates the theme CSS for every component. +Use the `experimental.componentDetection` option to enable automatic component detection for tree-shaking. This feature scans your source code to detect which components are actually used and only generates the necessary CSS for those components (including their dependencies). Without it, the Vite plugin generates the theme CSS for every component. Detection covers the Vite root, the packages listed in [`scanPackages`](#scanpackages) and any `dirs` from the [`components`](#components) option located outside the root. - Default: `false`{lang="ts-type"} - Type: `boolean | string[]`{lang="ts-type"} diff --git a/src/plugins/templates.ts b/src/plugins/templates.ts index 2d0612c054..51c8bebe52 100644 --- a/src/plugins/templates.ts +++ b/src/plugins/templates.ts @@ -3,6 +3,7 @@ import path from 'node:path' import type { UnpluginOptions } from 'unplugin' import type { NuxtUIOptions } from '../unplugin' import { getTemplates } from '../templates' +import { resolveExtraScanDirs } from '../utils/components' /** * This plugin is responsible for getting the generated virtual templates and @@ -11,7 +12,7 @@ import { getTemplates } from '../templates' export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record, componentDir?: string) { // `root` is assigned in the `vite.config` hook (below), before the `ui.css` // template's `getContents` runs — so `experimental.componentDetection` can scan the app. - const vue: { root?: string, componentDir?: string } = { componentDir } + const vue: { root?: string, dirs?: string[], componentDir?: string } = { componentDir } const templates = getTemplates(options, appConfig.ui, undefined, undefined, vue) const templateKeys = new Set(templates.map(t => `#build/${t.filename}`)) @@ -65,6 +66,12 @@ export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record // `options.root` lets setups like `electron-vite` override the location // when `config.root` points to a sub-directory Tailwind doesn't scan. vue.root = path.resolve(options.root || config.root || '.') + if (options.experimental?.componentDetection) { + // `scanPackages` packages resolve Nuxt UI components from `node_modules` + // and user component dirs can sit outside the root: detection has to + // scan both or their components lose their theme CSS. + vue.dirs = resolveExtraScanDirs(vue.root, options.scanPackages, options.components ? options.components.dirs : undefined) + } const alias = await writeTemplates(vue.root) return { diff --git a/src/templates.ts b/src/templates.ts index 654b52c5f5..a8334eaa84 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -12,7 +12,7 @@ import * as theme from './theme' import * as themeProse from './theme/prose' import * as themeContent from './theme/content' -export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: string, componentDir?: string }) { +export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: string, dirs?: string[], componentDir?: string }) { const templates: NuxtTemplate[] = [] let hasProse = false @@ -144,9 +144,10 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record() + + for (const pkg of scanPackages || []) { + try { + const entry = normalize(resolvePathSync(pkg, { url: join(root, '_index.mjs') })) + // Slice at the last `node_modules//` so pnpm's `.pnpm` layout resolves + // to the package directory, not its entry file. + const marker = `node_modules/${pkg}` + const index = entry.lastIndexOf(`${marker}/`) + dirs.add(index === -1 ? dirname(entry) : entry.slice(0, index + marker.length)) + } catch { + // Not resolvable from the root: nothing to scan. + } + } + + const rootDir = normalize(root) + const userDirs = Array.isArray(componentDirs) ? componentDirs : componentDirs ? [componentDirs] : [] + for (const dir of userDirs) { + // `dirs` entries can be globs: scan from the static prefix. + const resolved = resolve(root, dir.split(/[*{]/)[0]!) + // Dirs inside the root are already covered by the root scan. + if (resolved !== rootDir && !resolved.startsWith(`${rootDir}/`) && existsSync(resolved)) { + dirs.add(resolved) + } + } + + return [...dirs] +} + /** * Detect components used in the project by scanning source files */ diff --git a/test/utils/components.spec.ts b/test/utils/components.spec.ts index e85e06d1d9..bec85b9a56 100644 --- a/test/utils/components.spec.ts +++ b/test/utils/components.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterAll } from 'vitest' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { detectUsedComponents } from '../../src/utils/components' +import { detectUsedComponents, resolveExtraScanDirs } from '../../src/utils/components' const componentDir = join(process.cwd(), 'src/runtime/components') @@ -15,6 +15,22 @@ function fixtureUsing(markup: string) { return dir } +// `realpathSync` because module resolution returns real paths and macOS puts +// `tmpdir()` behind a `/private` symlink. +function fixtureRoot() { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'nuxt-ui-cd-'))) + fixtureDirs.push(dir) + return dir +} + +function fixturePackage(root: string, name: string, contents = 'module.exports = {}\n') { + const dir = join(root, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, main: 'index.js' })) + writeFileSync(join(dir, 'index.js'), contents) + return dir +} + afterAll(() => { for (const dir of fixtureDirs) { rmSync(dir, { recursive: true, force: true }) @@ -64,4 +80,48 @@ describe('detectUsedComponents', () => { it('returns undefined when no component is detected', async () => { expect(await detectUsedComponents([fixtureUsing('
no nuxt ui here
')], 'U', componentDir)).toBeUndefined() }) + + it('detects usage inside a scanned package directory', async () => { + // The dir itself sits in `node_modules`: the ignore patterns apply to paths + // relative to it, so its files are still scanned. + const pkgDir = fixturePackage(fixtureRoot(), 'my-lib', 'export { UAlert } from "#components"\n') + + expect(await detectUsedComponents([pkgDir], 'U', componentDir)).toContain('Alert') + }) +}) + +describe('resolveExtraScanDirs', () => { + it('resolves scanPackages to their package directory', () => { + const root = fixtureRoot() + const pkgDir = fixturePackage(root, 'my-lib') + + expect(resolveExtraScanDirs(root, ['my-lib'])).toEqual([pkgDir]) + }) + + it('resolves scoped packages', () => { + const root = fixtureRoot() + const pkgDir = fixturePackage(root, '@scope/my-lib') + + expect(resolveExtraScanDirs(root, ['@scope/my-lib'])).toEqual([pkgDir]) + }) + + it('skips packages that cannot be resolved', () => { + expect(resolveExtraScanDirs(fixtureRoot(), ['missing-lib'])).toEqual([]) + }) + + it('keeps component dirs outside the root and drops the ones inside', () => { + const root = fixtureRoot() + const outside = fixtureRoot() + mkdirSync(join(root, 'src', 'components'), { recursive: true }) + + expect(resolveExtraScanDirs(root, undefined, ['src/components', outside])).toEqual([outside]) + }) + + it('resolves glob dirs from their static prefix', () => { + const root = fixtureRoot() + const outside = fixtureRoot() + mkdirSync(join(outside, 'components'), { recursive: true }) + + expect(resolveExtraScanDirs(root, undefined, [`${outside}/components/**`])).toEqual([join(outside, 'components')]) + }) })