diff --git a/docs/content/docs/1.getting-started/2.installation/1.nuxt.md b/docs/content/docs/1.getting-started/2.installation/1.nuxt.md index 015cfaeb51..4b7d99cc08 100644 --- a/docs/content/docs/1.getting-started/2.installation/1.nuxt.md +++ b/docs/content/docs/1.getting-started/2.installation/1.nuxt.md @@ -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 `` 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. diff --git a/src/module.ts b/src/module.ts index f90557c984..b2f09604ca 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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' { diff --git a/src/templates.ts b/src/templates.ts index 31898d56a7..cedb0905bd 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,8 +1,11 @@ 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' @@ -10,18 +13,137 @@ 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) : component - return acc - }, {} as Record) +/** + * Build a dependency graph of components by scanning their source files + */ +async function buildComponentDependencyGraph(componentDir: string, prefix: string): Promise>> { + const dependencyGraph = new Map>() + + 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() + + 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>, + resolved: Set = new Set() +): Set { + 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 | undefined> { + const detectedComponents = new Set() + + // 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: + // - () + 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, nuxt?: Nuxt) { +export function getTemplates(options: ModuleOptions, uiConfig: Record, nuxt?: Nuxt, resolve?: Resolver['resolve']) { const templates: NuxtTemplate[] = [] let hasProse = false let hasContent = false + let previousDetectedComponents: Set | undefined const isDev = process.argv.includes('--uiDev') @@ -91,6 +213,60 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record 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 @@ -116,7 +292,10 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record `@source "./ui"; + getContents: async () => { + const sources = await getSources() + + return `${sources} @theme static { --color-old-neutral-50: ${colors.neutral[50]}; @@ -182,6 +361,7 @@ export function getTemplates(options: ModuleOptions, uiConfig: Record { 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' }) + } + }) + } }