Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
"@internationalized/date": "^3.12.2",
"@internationalized/number": "^3.6.7",
"@nuxt/fonts": "^0.14.0",
"@nuxt/icon": "^2.2.3",
"@nuxt/icon": "https://pkg.pr.new/@nuxt/icon@1de044f",
"@nuxt/kit": "^4.4.8",
"@nuxt/schema": "^4.4.8",
"@nuxtjs/color-mode": "^4.0.1",
Expand Down
21 changes: 10 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { HookResult, ModuleDependencies } from '@nuxt/schema'
import { addTemplates } from './templates'
import { publicComposables } from './imports'
import { defaultOptions, getDefaultConfig, resolveColors } from './utils/defaults'
import { getClientBundleIcons, hasIconCollection } from './utils/icons'
import { name, version } from '../package.json'

export type * from './runtime/types'
Expand Down Expand Up @@ -221,6 +222,25 @@ export default defineNuxtModule<ModuleOptions>({

nuxt.options.appConfig.ui = defu(nuxt.options.appConfig.ui || {}, getDefaultConfig(options.theme)) as typeof nuxt.options.appConfig.ui

// Pre-bundle the icons Nuxt UI uses into `@nuxt/icon`'s client bundle so they're
// embedded at build time instead of fetched at runtime. Its `clientBundle.scan`
// skips `node_modules`, so it can't discover the icons baked into our components.
//
// Only icons whose collection data is installed are added — `@nuxt/icon` reads that
// data from the filesystem at build time and fails the build otherwise, so missing
// collections gracefully fall back to runtime loading. It resolves that data from
// `[rootDir, workspaceDir]` (nuxt/icon#502), so we check the same paths from the same
// `nuxt` instance to stay aligned with what it can actually load in every build
// context (the module's own prepare fixture, the playground, the docs, …).
const iconResolvePaths = [nuxt.options.rootDir, nuxt.options.workspaceDir].filter(Boolean)
const isCollectionInstalled = (collection: string) => hasIconCollection(collection, iconResolvePaths)

nuxt.hook('icon:clientBundleIcons', (icons) => {
for (const name of getClientBundleIcons(nuxt.options.appConfig.ui?.icons, isCollectionInstalled)) {
icons.add(name)
}
})

nuxt.options.build.transpile.push('reka-ui')

// Isolate root node from portaled components
Expand Down
110 changes: 110 additions & 0 deletions src/utils/icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import defaultIcons from '../theme/icons'

const ICON_PREFIX = 'i-'

/**
* Whether `<dir>/node_modules/<relativePath>` exists, walking up the directory tree
* the way Node module resolution would.
*
* We check the filesystem directly rather than `require.resolve`/`import.meta.resolve`
* because, under the dev stub (jiti), the module loader patches CJS resolution and
* over-resolves across the whole workspace — reporting a collection as available where
* `@nuxt/icon` (native ESM resolution) can't actually load it, which fails the build.
*/
function existsInNodeModules(relativePath: string, dir: string): boolean {
let current = dir
while (true) {
if (existsSync(join(current, 'node_modules', relativePath))) {
return true
}
const parent = dirname(current)
if (parent === current) {
return false
}
current = parent
}
}

/**
* Whether the icon data for a collection (e.g. `lucide`) is installed under any of the
* given directories — either as a standalone `@iconify-json/<collection>` package or
* within the full `@iconify/json` collection — matching what `@nuxt/icon` can load from
* the filesystem at build time. Collections it can't find are left to runtime loading
* instead of being bundled (which would fail the build).
*/
export function hasIconCollection(collection: string, dirs: string[]): boolean {
return dirs.some(dir =>
existsInNodeModules(join('@iconify-json', collection, 'icons.json'), dir)
|| existsInNodeModules(join('@iconify', 'json', 'json', `${collection}.json`), dir)
)
}

/**
* Convert a Nuxt UI icon class (`i-{collection}-{name}`) into the `{collection}:{name}`
* form expected by `@nuxt/icon`'s `clientBundle.icons`, treating the first dash-delimited
* segment as the collection.
*
* This is only unambiguous for single-word collections (e.g. `lucide`); multi-word ones
* (e.g. `material-symbols`) can't be split without `@nuxt/icon`'s collection list, so the
* caller is responsible for restricting input to collections known to be single-word.
*/
function toBundleName(icon: unknown): string | undefined {
if (typeof icon !== 'string' || !icon.startsWith(ICON_PREFIX)) {
return
}

const id = icon.slice(ICON_PREFIX.length)
const dash = id.indexOf('-')
if (dash < 1 || dash === id.length - 1) {
return
}

return `${id.slice(0, dash)}:${id.slice(dash + 1)}`
}

// Collections Nuxt UI ships its default icons in (currently just `lucide`). We only
// auto-bundle icons from these collections because they convert unambiguously; icons
// from other collections may use multi-word collection names that can't be split
// reliably, so they're left to runtime loading rather than risk a wrong name breaking
// the client bundle. Derived from the defaults so it stays correct if they ever change.
const trustedCollections = new Set(
Object.values(defaultIcons)
.map(icon => toBundleName(icon)?.split(':')[0])
.filter(Boolean) as string[]
)

/**
* Resolve the icons Nuxt UI uses into `@nuxt/icon` client-bundle names so they're
* embedded at build time instead of fetched at runtime.
*
* `@nuxt/icon`'s `clientBundle.scan` skips `node_modules`, so the icons baked into
* Nuxt UI's components (chevrons, the loading spinner, close buttons, etc.) can't be
* discovered on their own and would otherwise be loaded on demand on every page.
*
* `@nuxt/icon` reads icon data from locally-installed collection packages at build
* time and throws if it can't find a bundled icon, so `isCollectionAvailable` lets
* the caller drop collections whose data isn't installed (e.g. `@iconify-json/lucide`)
* — those simply fall back to runtime loading instead of breaking the build.
*/
export function getClientBundleIcons(
icons: Record<string, string> = {},
isCollectionAvailable: (collection: string) => boolean = () => true
): string[] {
const names = new Set<string>()

for (const icon of Object.values(icons)) {
const name = toBundleName(icon)
if (!name) {
continue
}

const collection = name.slice(0, name.indexOf(':'))
if (trustedCollections.has(collection) && isCollectionAvailable(collection)) {
names.add(name)
}
}

return [...names]
}
53 changes: 53 additions & 0 deletions test/utils/icons.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { getClientBundleIcons } from '../../src/utils/icons'
import defaultIcons from '../../src/theme/icons'

describe('getClientBundleIcons', () => {
it('converts the default icons to `@nuxt/icon` bundle names', () => {
const names = getClientBundleIcons(defaultIcons)

expect(names).toContain('lucide:chevron-down')
expect(names).toContain('lucide:loader-circle')
expect(names).toContain('lucide:x')
// every default icon is a single-word `lucide` collection, so all are bundled
expect(names).toHaveLength(new Set(Object.values(defaultIcons)).size)
})

it('keeps overrides that stay in a shipped (trusted) collection', () => {
const names = getClientBundleIcons({ loading: 'i-lucide-loader' })

expect(names).toContain('lucide:loader')
})

it('skips overrides from collections Nuxt UI does not ship defaults in', () => {
// `heroicons` is single-word and would convert safely, but `svg-spinners` would
// mis-convert to `svg:...` and break the client bundle. Without `@nuxt/icon`'s
// collection list we can't tell them apart, so we only trust shipped collections
// (`lucide`) and leave everything else to runtime loading.
const names = getClientBundleIcons({
check: 'i-heroicons-check',
loading: 'i-svg-spinners-90-ring',
menu: 'i-material-symbols-menu'
})

expect(names).toEqual([])
})

it('ignores values that are not icon classes', () => {
const names = getClientBundleIcons({ a: '', b: 'lucide:x', c: 'i-', d: 'i-lucide-' })

expect(names).toEqual([])
})

it('returns an empty array when no icons are provided', () => {
expect(getClientBundleIcons()).toEqual([])
})

it('drops collections whose data is not installed', () => {
// `@nuxt/icon` reads icon data from disk at build time and fails the build on a
// missing collection, so icons are only bundled when their collection is available.
expect(getClientBundleIcons(defaultIcons, () => false)).toEqual([])
expect(getClientBundleIcons(defaultIcons, collection => collection === 'lucide').length)
.toBe(new Set(Object.values(defaultIcons)).size)
})
})
Loading