Skip to content
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
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 @@ -926,6 +926,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
9 changes: 8 additions & 1 deletion playgrounds/vue/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,

/* Aliases */
"paths": {
"#build/ui/*": [
"./node_modules/.nuxt-ui/ui/*"
]
}
},
"include": [
"src/**/*.ts",
Expand Down
33 changes: 30 additions & 3 deletions src/plugins/templates.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import fs from 'node:fs'
import path from 'node:path'
import { consola } from 'consola'
import type { UnpluginOptions } from 'unplugin'
import type { NuxtUIOptions } from '../unplugin'
import { getTemplates } from '../templates'
import { detectUsedComponents, 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) {
// `detectedComponents` is assigned in the `vite.config` hook (below), before
// any template's `getContents` runs β€” so `experimental.componentDetection`
// can blank the theme of unused components (see `getTemplates`).
const vue: { detectedComponents?: Set<string> } = {}
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 +67,28 @@ 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 || '.'))
const root = path.resolve(options.root || config.root || '.')

if (options.experimental?.componentDetection && componentDir) {
// `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.
const dirs = resolveExtraScanDirs(root, options.scanPackages, options.components ? options.components.dirs : undefined)
vue.detectedComponents = await detectUsedComponents(
[root, ...dirs],
options.prefix!,
componentDir,
Array.isArray(options.experimental.componentDetection) ? options.experimental.componentDetection : undefined
)

if (vue.detectedComponents?.size) {
consola.success(`Nuxt UI detected ${vue.detectedComponents.size} components in use (including dependencies)`)
} else {
consola.info('Nuxt UI detected no components in use, including all components')
}
}

const alias = await writeTemplates(root)

return {
resolve: {
Expand Down
65 changes: 41 additions & 24 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?: { detectedComponents?: Set<string> }) {
const templates: NuxtTemplate[] = []

let hasProse = false
Expand All @@ -30,10 +30,18 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
const template = (theme as any)[component]
let result = typeof template === 'function' ? template(options) : template

// With `experimental.componentDetection` (Vue integration), a component
// detection didn't find keeps its theme file β€” the `#build/ui` aliases
// and type imports rely on it existing β€” but with every class blanked
// (like `theme.unstyled`), so the `@source "./ui";` scan yields no CSS
// for it. Prose has no detection and stays styled.
const unused = path !== 'prose' && !!vue?.detectedComponents?.size
&& !Array.from(vue.detectedComponents).some(detected => camelCase(detected) === component)

// Override default variants from nuxt.config.ts
result = applyDefaultVariants(result, options.theme?.defaultVariants)
// Strip default theme classes if `unstyled` is enabled
result = applyUnstyled(result, options.theme?.unstyled)
result = applyUnstyled(result, options.theme?.unstyled || unused)
// Apply Tailwind prefix if configured
result = applyPrefixToObject(result, options.theme?.prefix)

Expand Down Expand Up @@ -67,7 +75,7 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
const themeUtilsPath = fileURLToPath(new URL('./utils/theme', import.meta.url))
const defaultVariantsJson = JSON.stringify(options.theme?.defaultVariants) ?? 'undefined'
const prefixJson = JSON.stringify(options.theme?.prefix) ?? 'undefined'
const unstyledJson = JSON.stringify(options.theme?.unstyled) ?? 'undefined'
const unstyledJson = JSON.stringify(options.theme?.unstyled || unused) ?? 'undefined'

return [
`import template from ${JSON.stringify(templatePath)}`,
Expand Down Expand Up @@ -116,37 +124,46 @@ 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}**/*";`)
}

// 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)});`)
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. With `experimental.componentDetection`, Nuxt narrows
// these to the detected components' files. The Vue plugin can't: its
// templates live inside `node_modules`, where Tailwind widens a file
// `@source` to a scan of its whole parent directory, so a narrowed list
// wouldn't narrow the CSS. It sources the whole directory instead and
// blanks the theme of unused components at write time (see
// `writeThemeTemplate`), which needs no extra directive.
const componentDir = resolve ? resolve('./runtime/components') : undefined

if (options.experimental?.componentDetection && nuxt && componentDir && layers.length) {
const detectedComponents = await detectUsedComponents(
layers,
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
Loading
Loading