Skip to content
53 changes: 53 additions & 0 deletions docs/content/docs/1.getting-started/2.installation/2.vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="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. 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"}

**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 `<component :is="..." />` 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.
Expand Down
17 changes: 14 additions & 3 deletions src/plugins/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ 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
* making them available to the Vue build.
*/
export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record<string, any>) {
const templates = getTemplates(options, appConfig.ui)
export default function TemplatePlugin(options: NuxtUIOptions, appConfig: Record<string, any>, 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, dirs?: 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) {
Expand Down Expand Up @@ -61,7 +65,14 @@ 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 || '.'))
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 {
resolve: {
Expand Down
53 changes: 30 additions & 23 deletions src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>, nuxt?: Nuxt, resolve?: Resolver['resolve']) {
export function getTemplates(options: ModuleOptions, uiConfig: Record<string, any>, nuxt?: Nuxt, resolve?: Resolver['resolve'], vue?: { root?: string, dirs?: string[], componentDir?: string }) {
const templates: NuxtTemplate[] = []

let hasProse = false
Expand Down Expand Up @@ -116,37 +116,44 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
writeThemeTemplate(theme)

async function generateSources() {
if (!nuxt) {
return '@source "./ui";'
}

const sources: string[] = []
const layers = getLayerDirectories(nuxt).map(layer => 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 plus any extra dirs
// (`scanPackages` packages, component dirs outside the root).
const componentDir = resolve ? resolve('./runtime/components') : vue?.componentDir
const scanDirs = nuxt ? layers : [...(vue?.root ? [vue.root] : []), ...(vue?.dirs || [])]

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
)

Expand Down
6 changes: 3 additions & 3 deletions src/unplugin.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -36,7 +36,7 @@ type AppConfigUI = {
prefix?: string
} & TVConfig<typeof ui>

export interface NuxtUIOptions extends Omit<ModuleOptions, 'fonts' | 'colorMode' | 'content' | 'experimental'> {
export interface NuxtUIOptions extends Omit<ModuleOptions, 'fonts' | 'colorMode' | 'content'> {
/** Whether to generate declaration files for auto-imported components. */
dts?: boolean
ui?: AppConfigUI
Expand Down Expand Up @@ -115,7 +115,7 @@ export const NuxtUIPlugin = createUnplugin<NuxtUIOptions | undefined>((_options
tailwind(),
IconsPlugin(options, appConfig),
PluginsPlugin(options),
TemplatePlugin(options, appConfig),
TemplatePlugin(options, appConfig, join(runtimeDir, 'components')),
AppConfigPlugin(options, appConfig),
<UnpluginOptions>{
name: 'nuxt:ui:plugins-duplication-detection',
Expand Down
43 changes: 41 additions & 2 deletions src/utils/components.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'pathe'
import { dirname, join, normalize, resolve } from 'pathe'
import { globSync } from 'tinyglobby'
import { pascalCase } from 'scule'
import { resolvePathSync } from 'mlly'

/**
* Build a dependency graph of components by scanning their source files
Expand Down Expand Up @@ -61,6 +63,41 @@ function resolveComponentDependencies(
return resolved
}

/**
* Resolve additional directories component detection should scan besides the root:
* packages from `scanPackages` (they live in `node_modules`, which the root scan
* skips) and user component dirs pointing outside the root.
*/
export function resolveExtraScanDirs(root: string, scanPackages?: string[], componentDirs?: string | string[]): string[] {
const dirs = new Set<string>()

for (const pkg of scanPackages || []) {
try {
const entry = normalize(resolvePathSync(pkg, { url: join(root, '_index.mjs') }))
// Slice at the last `node_modules/<pkg>/` 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
*/
Expand Down Expand Up @@ -90,7 +127,9 @@ export async function detectUsedComponents(
for (const dir of dirs) {
const appFiles = globSync(['**/*.{vue,ts,js,tsx,jsx}'], {
cwd: dir,
ignore: ['node_modules/**', '.nuxt/**', 'dist/**']
// `**/` prefixes so nested dirs are skipped too: the Vue integration
// scans the whole Vite root, not just Nuxt layer `app/` directories.
ignore: ['**/node_modules/**', '**/.nuxt/**', '**/dist/**']
})

for (const file of appFiles) {
Expand Down
127 changes: 127 additions & 0 deletions test/utils/components.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, afterAll } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { detectUsedComponents, resolveExtraScanDirs } 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'), `<template>${markup}</template>\n`)
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 })
}
})

describe('detectUsedComponents', () => {
it('detects used components and resolves their dependencies', async () => {
const detected = await detectUsedComponents([fixtureUsing('<UButton label="x" />')], '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('<LazyUTooltip text="x" />')], 'U', componentDir)

expect(detected).toContain('Tooltip')
})

it('always includes components from includeComponents', async () => {
const detected = await detectUsedComponents([fixtureUsing('<div>no nuxt ui here</div>')], '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('<UButton label="x" />')
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('<div>no nuxt ui here</div>')], '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')])
})
})
Loading