diff --git a/docs/content/docs/1.getting-started/6.integrations/6.ssr.md b/docs/content/docs/1.getting-started/6.integrations/6.ssr.md index 026bb1d4ef..e18169a055 100644 --- a/docs/content/docs/1.getting-started/6.integrations/6.ssr.md +++ b/docs/content/docs/1.getting-started/6.integrations/6.ssr.md @@ -179,4 +179,46 @@ if (theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-s ### Icons Display -Unfortunately displaying icons with the SSR server of the Vue version is currently not supported. The icons will only be displayed after the application is initialized at the user's end. +Nuxt UI bundles the icons it uses into your build, so they render straight away during SSR and work fully offline without any request to the Iconify API. This requires the icon collection data to be installed locally. + +Nuxt UI uses the `lucide` collection by default, so install it to embed its own icons: + +::code-group{sync="pm"} + +```bash [pnpm] +pnpm i @iconify-json/lucide +``` + +```bash [yarn] +yarn add @iconify-json/lucide +``` + +```bash [npm] +npm install @iconify-json/lucide +``` + +:: + +Once the collection is installed, Nuxt UI's own icons are embedded automatically. You can bundle your own icons on top of them through the `icon.clientBundle` option in your `vite.config.ts`: + +```ts [vite.config.ts] +import ui from '@nuxt/ui/vite' + +export default defineConfig({ + plugins: [ + ui({ + icon: { + clientBundle: { + icons: ['lucide:heart', 'simple-icons:github'] + } + } + }) + ] +}) +``` + +::note +Use the `{collection}:{name}` form for collections with a dash in their name, like `material-symbols:menu`, and install each collection you reference with `@iconify-json/{collection_name}`. +:: + +Icons whose collection is not installed fall back to loading from the Iconify API at runtime. To opt out of bundling entirely, set `icon.clientBundle` to `false`. diff --git a/package.json b/package.json index 5b4b172902..f89e8ef7f9 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ }, "dependencies": { "@floating-ui/dom": "^1.7.6", + "@iconify/utils": "^3.1.3", "@iconify/vue": "^5.0.1", "@internationalized/date": "^3.12.2", "@internationalized/number": "^3.6.7", @@ -194,6 +195,7 @@ "vue-component-type-helpers": "^3.3.5" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.114", "@nuxt/eslint-config": "^1.16.0", "@nuxt/module-builder": "^1.0.2", "@nuxt/test-utils": "^4.0.2", diff --git a/playgrounds/vue/package.json b/playgrounds/vue/package.json index bfe38f3da9..c204715790 100644 --- a/playgrounds/vue/package.json +++ b/playgrounds/vue/package.json @@ -19,6 +19,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.114", "@vitejs/plugin-vue": "^6.0.7", "typescript": "^6.0.3", "vite": "^7.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42bb5c1d06..21eefdd4c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@floating-ui/dom': specifier: ^1.7.6 version: 1.7.6 + '@iconify/utils': + specifier: ^3.1.3 + version: 3.1.3 '@iconify/vue': specifier: ^5.0.1 version: 5.0.1(vue@3.5.38(typescript@6.0.3)) @@ -240,6 +243,9 @@ importers: specifier: ^3.24.0 || ^4.0.0 version: 4.4.3 devDependencies: + '@iconify-json/lucide': + specifier: ^1.2.114 + version: 1.2.114 '@nuxt/eslint-config': specifier: ^1.16.0 version: 1.16.0(@typescript-eslint/utils@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.38)(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -377,10 +383,10 @@ importers: version: 3.26.1(@tiptap/core@3.26.1(@tiptap/pm@3.26.1)) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) + version: 2.0.1(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) '@vercel/speed-insights': specifier: ^2.0.0 - version: 2.0.0(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) + version: 2.0.0(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) '@vueuse/integrations': specifier: ^14.3.0 version: 14.3.0(change-case@5.4.4)(fuse.js@7.4.2)(sortablejs@1.15.7)(vue@3.5.38(typescript@6.0.3)) @@ -577,6 +583,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@iconify-json/lucide': + specifier: ^1.2.114 + version: 1.2.114 '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) @@ -12107,10 +12116,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@2.0.1(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))': + '@vercel/analytics@2.0.1(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))': optionalDependencies: nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0) vue: 3.5.38(typescript@6.0.3) + vue-router: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) '@vercel/nft@1.5.0(rollup@4.60.4)': dependencies: @@ -12133,10 +12143,11 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vercel/speed-insights@2.0.0(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))': + '@vercel/speed-insights@2.0.0(nuxt@4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0))(vue-router@5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))': optionalDependencies: nuxt: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.9.0)(@vue/compiler-sfc@3.5.38)(better-sqlite3@12.11.1)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.11.1))(eslint@10.5.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(meow@13.2.0)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.15)(terser@5.47.1)(typescript@6.0.3)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0) vue: 3.5.38(typescript@6.0.3) + vue-router: 5.1.0(@vue/compiler-sfc@3.5.38)(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.5(@types/node@25.9.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))': dependencies: diff --git a/src/plugins/icons.ts b/src/plugins/icons.ts new file mode 100644 index 0000000000..843e1e82d2 --- /dev/null +++ b/src/plugins/icons.ts @@ -0,0 +1,119 @@ +import type { UnpluginOptions } from 'unplugin' +import { getIconData } from '@iconify/utils' +import { loadCollectionFromFS } from '@iconify/utils/lib/loader/fs' +import { getClientBundleIcons, hasIconCollection, parseIconName } from '../utils/icons' +import type { NuxtUIOptions } from '../unplugin' + +const VIRTUAL_ID = 'virtual:nuxt-ui-icons' + +type IconData = NonNullable> + +/** + * Resolve the `{collection}:{name}` icons to embed: Nuxt UI's own defaults plus any the + * user lists in `icon.clientBundle.icons`, restricted to collections whose data is + * installed under `root` (others fall back to runtime loading instead of breaking). + */ +function resolveBundleNames(options: NuxtUIOptions, appConfig: Record, root: string): string[] { + const isAvailable = (collection: string) => hasIconCollection(collection, [root]) + + // Nuxt UI's defaults — already gated to trusted, single-word collections. + const names = new Set(getClientBundleIcons(appConfig.ui?.icons, isAvailable)) + + // User additions can be from any collection, so long as its data is installed. + const clientBundle = options.icon?.clientBundle + if (clientBundle && Array.isArray(clientBundle.icons)) { + for (const icon of clientBundle.icons) { + const name = parseIconName(icon) + if (name && isAvailable(name.slice(0, name.indexOf(':')))) { + names.add(name) + } + } + } + + return [...names] +} + +/** Load the SVG data for every requested icon from its locally-installed collection. */ +export async function loadIconsData(names: string[], cwd: string): Promise> { + const byCollection = new Map>() + for (const fullName of names) { + const colon = fullName.indexOf(':') + const collection = fullName.slice(0, colon) + + let iconNames = byCollection.get(collection) + if (!iconNames) { + byCollection.set(collection, iconNames = new Set()) + } + iconNames.add(fullName.slice(colon + 1)) + } + + const data: Record = {} + for (const [collection, iconNames] of byCollection) { + // `loadCollectionFromFS` resolves `@iconify-json/` from `cwd` and falls + // back to the full `@iconify/json` package, so it loads from wherever the collection + // is installed in the project (or workspace) — never `process.cwd()`. + const iconSet = await loadCollectionFromFS(collection, false, '@iconify-json', cwd) + if (!iconSet) { + continue + } + + for (const name of iconNames) { + const icon = getIconData(iconSet, name) + if (icon) { + data[`${collection}:${name}`] = icon + } + } + } + + return data +} + +/** + * Embed the icons Nuxt UI uses into the build so they render synchronously during SSR and + * fully offline, instead of being fetched from the Iconify API at runtime. + * + * `@iconify/vue` looks icons up from an in-memory store, so the generated + * `virtual:nuxt-ui-icons` module registers the bundled data via `addIcon` from a Vue + * plugin. The Vue plugin install runs on both server and client, which is what makes the + * icons available during SSR (see `PluginsPlugin`, which `app.use`s it). + */ +export default function IconsPlugin(options: NuxtUIOptions, appConfig: Record) { + // `config.root` is the Vite equivalent of Nuxt's `rootDir`; resolving from it (not + // `process.cwd()`) is what keeps workspace/monorepo builds working (nuxt/icon#502). + let root = options.root || process.cwd() + let source: Promise | undefined + + async function generate(): Promise { + if (options.icon?.clientBundle === false) { + return 'export default { install() {} }' + } + + const data = await loadIconsData(resolveBundleNames(options, appConfig, root), root) + + return [ + `import { addIcon } from '@iconify/vue'`, + `const icons = ${JSON.stringify(data)}`, + `export default { install() { for (const name in icons) addIcon(name, icons[name]) } }` + ].join('\n') + } + + return { + name: 'nuxt:ui:icons', + enforce: 'pre', + resolveId(id) { + if (id === VIRTUAL_ID) { + return VIRTUAL_ID + } + }, + loadInclude: id => id === VIRTUAL_ID, + load() { + source ||= generate() + return source + }, + vite: { + configResolved(config) { + root = options.root || config.root || root + } + } + } satisfies UnpluginOptions +} diff --git a/src/plugins/plugins.ts b/src/plugins/plugins.ts index 03d05ca454..522d19d167 100644 --- a/src/plugins/plugins.ts +++ b/src/plugins/plugins.ts @@ -54,11 +54,13 @@ export default function PluginsPlugin(options: NuxtUIOptions) { }) return ` + import __nuxt_ui_icons__ from "virtual:nuxt-ui-icons" ${plugins.map(p => `import ${genSafeVariableName(p)} from "${p}"`).join('\n')} ${proseImports.map(c => `import ${c.name} from "${c.path}"`).join('\n')} export default { install (app, pluginOptions = {}) { + app.use(__nuxt_ui_icons__, pluginOptions) ${plugins.map(p => ` app.use(${genSafeVariableName(p)}, pluginOptions)`).join('\n')} ${proseImports.map(c => ` app.component('${c.name}', ${c.name})`).join('\n')} } diff --git a/src/unplugin.ts b/src/unplugin.ts index 283ce56d0c..d3a688e0d9 100644 --- a/src/unplugin.ts +++ b/src/unplugin.ts @@ -22,6 +22,7 @@ import AppConfigPlugin from './plugins/app-config' import ComponentImportPlugin from './plugins/components' import NuxtEnvironmentPlugin from './plugins/nuxt-environment' import AutoImportPlugin from './plugins/auto-import' +import IconsPlugin from './plugins/icons' import type { TVConfig } from './runtime/types/tv' @@ -40,9 +41,25 @@ export interface NuxtUIOptions extends Omit + icon?: Pick & { + /** + * Embed the icons Nuxt UI uses into the build so they render during SSR and fully + * offline, instead of being fetched from the Iconify API at runtime. Enabled by + * default for Nuxt UI's own icons (when their collection is installed); set to + * `false` to opt out. + * @see https://ui.nuxt.com/docs/getting-started/integrations/ssr#icons-display + */ + clientBundle?: false | { + /** + * Extra icons to bundle, on top of Nuxt UI's defaults. Accepts `i-{collection}-{name}` + * or `{collection}:{name}` (use the colon form for multi-word collections, e.g. + * `material-symbols:menu`). Only bundled when the collection's data is installed. + */ + icons?: string[] + } + } /** * Enable or disable `@vueuse/core` color-mode integration * @defaultValue `true` @@ -96,13 +113,16 @@ export const NuxtUIPlugin = createUnplugin((_options options.theme = options.theme || {} options.theme.colors = resolveColors(options.theme.colors) - const appConfig = defu({ ui: options.ui, colorMode: options.colorMode, icon: options.icon }, { ui: getDefaultConfig(options.theme) }) + // `clientBundle` is a build-time concern, so keep it out of the runtime app config. + const { clientBundle, ...icon } = options.icon || {} + const appConfig = defu({ ui: options.ui, colorMode: options.colorMode, icon }, { ui: getDefaultConfig(options.theme) }) return [ NuxtEnvironmentPlugin(options), ComponentImportPlugin(options, meta), AutoImportPlugin(options, meta), tailwind(), + IconsPlugin(options, appConfig), PluginsPlugin(options), TemplatePlugin(options, appConfig), AppConfigPlugin(options, appConfig), diff --git a/src/utils/icons.ts b/src/utils/icons.ts index c3da32924a..bf6b23ea2c 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -75,6 +75,36 @@ const trustedCollections = new Set( .filter(Boolean) as string[] ) +/** + * Normalize a user-provided icon into a `{collection}:{name}` bundle name. + * + * Unlike {@link toBundleName} (which only handles Nuxt UI's own `i-{collection}-{name}` + * defaults), this accepts the forms a user might list in `icon.clientBundle.icons`: + * with or without the `i-` prefix, and either dash- or colon-separated. The colon form + * (`material-symbols:menu`) is the only way to bundle a multi-word collection, since a + * dashed name can't be split into collection and icon without `@nuxt/icon`'s collection + * list — matching how `@iconify/vue` expects multi-word collections to be written. + */ +export function parseIconName(icon: unknown): string | undefined { + if (typeof icon !== 'string') { + return + } + + const id = icon.startsWith(ICON_PREFIX) ? icon.slice(ICON_PREFIX.length) : icon + + const colon = id.indexOf(':') + if (colon > 0) { + return colon < id.length - 1 ? id : undefined + } + + const dash = id.indexOf('-') + if (dash < 1 || dash === id.length - 1) { + return + } + + return `${id.slice(0, dash)}:${id.slice(dash + 1)}` +} + /** * Resolve the icons Nuxt UI uses into `@nuxt/icon` client-bundle names so they're * embedded at build time instead of fetched at runtime. diff --git a/test/utils/icon-bundle.spec.ts b/test/utils/icon-bundle.spec.ts new file mode 100644 index 0000000000..b8b37169cc --- /dev/null +++ b/test/utils/icon-bundle.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { createSSRApp, h } from 'vue' +import { renderToString } from 'vue/server-renderer' +import { addIcon, Icon as IconifyIcon } from '@iconify/vue' +import { loadIconsData } from '../../src/plugins/icons' + +describe('loadIconsData', () => { + it('loads the SVG data for an installed collection', async () => { + const data = await loadIconsData(['lucide:check'], process.cwd()) + + expect(data['lucide:check']?.body).toBeTruthy() + }) + + it('skips collections whose data is not installed instead of throwing', async () => { + // The collection isn't installed, so it degrades to runtime loading (empty result) + // rather than failing the build. + await expect(loadIconsData(['not-a-real-collection:foo'], process.cwd())).resolves.toEqual({}) + }) +}) + +describe('icon bundling (SSR)', () => { + it('renders a bundled default icon to inline SVG during SSR with no network call', async () => { + // What the build does: load the data, then register it via `addIcon` (here using the + // same colon name the generated `virtual:nuxt-ui-icons` module uses). + const data = await loadIconsData(['lucide:check'], process.cwd()) + for (const name in data) { + addIcon(name, data[name]!) + } + + // `lucide-check` is exactly the string the `Icon` component looks up after stripping the + // `i-` prefix from `i-lucide-check`, so this asserts the registered name normalizes to the + // same Iconify icon the component resolves. On the server an unregistered icon renders no + // SVG, so inline `` here proves the icon was bundled rather than fetched at runtime. + const html = await renderToString(createSSRApp(() => h(IconifyIcon, { icon: 'lucide-check' }))) + + expect(html).toContain('') + }) +}) diff --git a/test/utils/icons.spec.ts b/test/utils/icons.spec.ts index 833eadafad..a218d232c6 100644 --- a/test/utils/icons.spec.ts +++ b/test/utils/icons.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { getClientBundleIcons } from '../../src/utils/icons' +import { getClientBundleIcons, parseIconName } from '../../src/utils/icons' import defaultIcons from '../../src/theme/icons' describe('getClientBundleIcons', () => { @@ -51,3 +51,24 @@ describe('getClientBundleIcons', () => { .toBe(new Set(Object.values(defaultIcons)).size) }) }) + +describe('parseIconName', () => { + it('parses the user-facing icon forms into `{collection}:{name}`', () => { + // with or without the `i-` prefix, dash or colon separated + expect(parseIconName('i-lucide-heart')).toBe('lucide:heart') + expect(parseIconName('lucide-heart')).toBe('lucide:heart') + expect(parseIconName('lucide:heart')).toBe('lucide:heart') + // the colon form is the only way to bundle a multi-word collection + expect(parseIconName('i-simple-icons:github')).toBe('simple-icons:github') + expect(parseIconName('material-symbols:menu')).toBe('material-symbols:menu') + }) + + it('returns undefined for values that are not parseable icon names', () => { + expect(parseIconName('')).toBeUndefined() + expect(parseIconName('i-')).toBeUndefined() + expect(parseIconName('i-lucide-')).toBeUndefined() + expect(parseIconName('lucide:')).toBeUndefined() + expect(parseIconName('lucide')).toBeUndefined() + expect(parseIconName(42)).toBeUndefined() + }) +})