Skip to content
Merged
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
39 changes: 39 additions & 0 deletions docs/content/docs/1.getting-started/2.installation/1.nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,45 @@ export default defineNuxtConfig({
})
```

### `experimental.componentDetection` :badge{label="Soon"}

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).

- Default: `false`{lang="ts-type"}
- Type: `boolean | string[]`{lang="ts-type"}

**Enable automatic detection:**

```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
ui: {
experimental: {
componentDetection: true
}
}
})
```

**Include additional components for dynamic usage:**

```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
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.
::

## 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
16 changes: 16 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ export interface ModuleOptions {
* @defaultValue false
*/
content?: boolean

/**
* Experimental features
*/
experimental?: {
/**
* Enable automatic component detection for tree-shaking
* Only generates theme files for components actually used in your app
* - `true`: Enable automatic detection
* - `string[]`: Enable detection and include additional components (useful for dynamic components)
* @defaultValue false
* @example true
* @example ['Modal', 'Dropdown']
*/
componentDetection?: boolean | string[]
}
}

declare module '#app' {
Expand Down
208 changes: 198 additions & 10 deletions src/templates.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,149 @@
import { fileURLToPath } from 'node:url'
import { kebabCase } from 'scule'
import { readFile } from 'node:fs/promises'
import { join } from 'pathe'
import { globSync } from 'tinyglobby'
import { camelCase, kebabCase, pascalCase } from 'scule'
import { genExport } from 'knitwork'
import colors from 'tailwindcss/colors'
import { addTemplate, addTypeTemplate, hasNuxtModule } from '@nuxt/kit'
import { addTemplate, addTypeTemplate, hasNuxtModule, logger, updateTemplates } from '@nuxt/kit'
import type { Nuxt, NuxtTemplate, NuxtTypeTemplate } from '@nuxt/schema'
import type { Resolver } from '@nuxt/kit'
import type { ModuleOptions } from './module'
import * as theme from './theme'
import * as themeProse from './theme/prose'
import * as themeContent from './theme/content'

export function buildTemplates(options: ModuleOptions) {
return Object.entries(theme).reduce((acc, [key, component]) => {
acc[key] = typeof component === 'function' ? component(options as Required<ModuleOptions>) : component
return acc
}, {} as Record<string, any>)
/**
* Build a dependency graph of components by scanning their source files
*/
async function buildComponentDependencyGraph(componentDir: string, prefix: string): Promise<Map<string, Set<string>>> {
const dependencyGraph = new Map<string, Set<string>>()

const componentFiles = globSync(['**/*.vue'], {
cwd: componentDir,
absolute: true
})

const componentPattern = new RegExp(`<${prefix}([A-Z][a-zA-Z]+)|\\b${prefix}([A-Z][a-zA-Z]+)\\b`, 'g')

for (const componentFile of componentFiles) {
try {
const content = await readFile(componentFile, 'utf-8')
const componentName = pascalCase(componentFile.split('/').pop()!.replace('.vue', ''))
const dependencies = new Set<string>()

const matches = content.matchAll(componentPattern)
for (const match of matches) {
const depName = match[1] || match[2]
if (depName && depName !== componentName) {
dependencies.add(depName)
}
}

dependencyGraph.set(componentName, dependencies)
} catch {
// Ignore files that can't be read
}
}

return dependencyGraph
}

/**
* Recursively resolve all dependencies for a component
*/
function resolveComponentDependencies(
component: string,
dependencyGraph: Map<string, Set<string>>,
resolved: Set<string> = new Set()
): Set<string> {
if (resolved.has(component)) {
return resolved
}

resolved.add(component)
const dependencies = dependencyGraph.get(component)

if (dependencies) {
for (const dep of dependencies) {
resolveComponentDependencies(dep, dependencyGraph, resolved)
}
}

return resolved
}

/**
* Detect components used in the project by scanning source files
*/
async function detectUsedComponents(
rootDir: string,
prefix: string,
componentDir: string,
includeComponents?: string[]
): Promise<Set<string> | undefined> {
const detectedComponents = new Set<string>()

// Add manually specified components
if (includeComponents && includeComponents.length > 0) {
for (const component of includeComponents) {
detectedComponents.add(component)
}
}

// Scan all source files for component usage
const appFiles = globSync(['**/*.{vue,ts,js,tsx,jsx}'], {
cwd: rootDir,
ignore: ['node_modules/**', '.nuxt/**', 'dist/**']
})

// Pattern to match:
// - <UButton in templates
// - UButton in script (imports, usage)
const componentPattern = new RegExp(`<${prefix}([A-Z][a-zA-Z]+)|\\b${prefix}([A-Z][a-zA-Z]+)\\b`, 'g')

for (const file of appFiles) {
try {
const filePath = join(rootDir, file)
const content = await readFile(filePath, 'utf-8')
const matches = content.matchAll(componentPattern)

for (const match of matches) {
const componentName = match[1] || match[2]
if (componentName) {
detectedComponents.add(componentName)
}
}
} catch {
// Ignore files that can't be read
}
}

if (detectedComponents.size === 0) {
return undefined
}

// Build dependency graph of components
const dependencyGraph = await buildComponentDependencyGraph(componentDir, prefix)

// Resolve all dependencies for detected components
const allComponents = new Set<string>()
for (const component of detectedComponents) {
const resolved = resolveComponentDependencies(component, dependencyGraph)
for (const resolvedComponent of resolved) {
allComponents.add(resolvedComponent)
}
}

return allComponents
}

export function getTemplates(options: ModuleOptions, uiConfig: Record<string, any>, nuxt?: Nuxt) {
export function getTemplates(options: ModuleOptions, uiConfig: Record<string, any>, nuxt?: Nuxt, resolve?: Resolver['resolve']) {
const templates: NuxtTemplate[] = []

let hasProse = false
let hasContent = false
let previousDetectedComponents: Set<string> | undefined

const isDev = process.argv.includes('--uiDev')

Expand Down Expand Up @@ -91,6 +213,60 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
}
}

async function getSources() {
let sources = ''

if (!!nuxt && !!resolve && options.experimental?.componentDetection) {
const detectedComponents = await detectUsedComponents(
nuxt.options.rootDir,
options.prefix!,
resolve!('./runtime/components'),
Array.isArray(options.experimental.componentDetection) ? options.experimental.componentDetection : undefined
)

if (detectedComponents && detectedComponents.size > 0) {
if (previousDetectedComponents) {
const newComponents = Array.from(detectedComponents).filter(
component => !previousDetectedComponents!.has(component)
)
if (newComponents.length > 0) {
logger.success(`Nuxt UI detected new components: ${newComponents.join(', ')}`)
}
} else {
logger.success(`Nuxt UI detected ${detectedComponents.size} components in use (including dependencies)`)
}

previousDetectedComponents = detectedComponents

const sourcesList: string[] = []

if (hasProse) {
sourcesList.push('@source "./ui/prose";')
}

for (const component of detectedComponents) {
const kebabComponent = kebabCase(component)
const camelComponent = camelCase(component)

if (hasContent && (themeContent as any)[camelComponent]) {
sourcesList.push(`@source "./ui/content/${kebabComponent}.ts";`)
} else if ((theme as any)[camelComponent]) {
sourcesList.push(`@source "./ui/${kebabComponent}.ts";`)
}
}

sources = sourcesList.join('\n')
} else {
if (!previousDetectedComponents || previousDetectedComponents.size > 0) {
logger.info('Nuxt UI detected no components in use, including all components')
}
previousDetectedComponents = new Set()
}
}

return sources || '@source "./ui";'
}

if (!!nuxt && ((hasNuxtModule('@nuxtjs/mdc') || options.mdc) || (hasNuxtModule('@nuxt/content') || options.content))) {
hasProse = true

Expand All @@ -116,7 +292,10 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
templates.push({
filename: 'ui.css',
write: true,
getContents: () => `@source "./ui";
getContents: async () => {
const sources = await getSources()

return `${sources}

@theme static {
--color-old-neutral-50: ${colors.neutral[50]};
Expand Down Expand Up @@ -182,6 +361,7 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record<string, an
--fill-inverted: var(--ui-border-inverted);
}
`
}
})

templates.push({
Expand Down Expand Up @@ -253,7 +433,7 @@ export {}
}

export function addTemplates(options: ModuleOptions, nuxt: Nuxt, resolve: Resolver['resolve']) {
const templates = getTemplates(options, nuxt.options.appConfig.ui, nuxt)
const templates = getTemplates(options, nuxt.options.appConfig.ui, nuxt, resolve)
for (const template of templates) {
if (template.filename!.endsWith('.d.ts')) {
addTypeTemplate(template as NuxtTypeTemplate)
Expand All @@ -265,4 +445,12 @@ export function addTemplates(options: ModuleOptions, nuxt: Nuxt, resolve: Resolv
nuxt.hook('prepare:types', ({ references }) => {
references.push({ path: resolve('./runtime/types/app.config.d.ts') })
})

if (options.experimental?.componentDetection && nuxt.options.dev) {
nuxt.hook('builder:watch', async (_, path) => {
if (/\.(?:vue|ts|js|tsx|jsx)$/.test(path)) {
await updateTemplates({ filter: template => template.filename === 'ui.css' })
}
})
}
}
Loading