Skip to content

Custom collection dir is not resolved across Nuxt Layers (server bundle path) #513

Description

@kjfranke

I was very glad to see that v2.3.1 include Nuxt layers in icon scanner context. Though it only seems to work in ClientBundle mode and no layer support for the serverBundle.

Environment

  • @nuxt/icon: 2.3.1
  • Nuxt: 4.5.0
  • Node: 24.x
  • Package manager: pnpm (monorepo using Nuxt Layers)
  • Provider: server (default), serverBundle.externalizeIconsJson: true

Reproduction

Base layer that ships icons, app that extends it:

packages/nuxt-base/            <-- a Nuxt Layer
  app/assets/icons/logo.svg
  nuxt.config.ts
examples/my-app/               <-- rootDir, extends nuxt-base

Base layer nuxt.config.ts:

export default defineNuxtConfig({
  icon: {
    customCollections: [
      { prefix: 'icon', dir: './app/assets/icons', recursive: true },
    ],
  },
})

Use it and run the app from examples/my-app:

<NuxtIcon name="icon:logo" />

Describe the bug

icon:logo lives in the base layer, but it is missing on the server (renders on the client only).

Two directory-resolution strategies exist and only one is layer-aware:

  • Client-bundle scanner is layer-awareloadClientBundleCollections() (dist/module.mjs) walks every layer:
    await this.scanner.scanFiles(this.nuxt.options._layers.map(l => l.cwd), this.scannedIcons)
  • Custom collection loader is notparseCustomCollection() (dist/shared/icon.*.mjs) resolves a single dir from rootDir and ignores nuxt.options._layers:
    const dir = isAbsolute(collection.dir) ? collection.dir : join(rootDir, collection.dir) // rootDir only
    This path feeds the server bundle (resolveServerBundle → loadCustomCollection → parseCustomCollection), so dir: './app/assets/icons' resolves to <rootDir>/app/assets/icons and the layer's SVGs are never found.

Additional context

Suggested fix: make parseCustomCollection iterate nuxt.options._layers and glob join(layer.cwd, collection.dir) per layer (app layers last so they can override), mirroring the scanner. Requires threading nuxt through resolveCollection/loadCustomCollection. We currently run this as a patch-package patch and it restores client/server parity. Happy to open a PR.

Local patch applied: (@nuxt+icon+2.3.1.patch)

diff --git a/node_modules/@nuxt/icon/dist/module.mjs b/node_modules/@nuxt/icon/dist/module.mjs
index b233609a..ae5dc5d0 100644
--- a/node_modules/@nuxt/icon/dist/module.mjs
+++ b/node_modules/@nuxt/icon/dist/module.mjs
@@ -394,7 +394,7 @@ class NuxtIconModuleContext {
     if (!resolved.collections)
       resolved.collections = resolved.remote ? collectionNames : await discoverInstalledCollections(getResolvePaths(this.nuxt));
     const collections = await Promise.all(
-      (resolved.collections || []).map((c) => resolveCollection(c, this.nuxt.options.rootDir))
+      (resolved.collections || []).map((c) => resolveCollection(c, this.nuxt.options.rootDir, this.nuxt))
     );
     return {
       disabled: false,
@@ -422,7 +422,7 @@ class NuxtIconModuleContext {
   }
   async _loadCustomCollection() {
     return Promise.all(
-      (this.options.customCollections || []).map((collection) => loadCustomCollection(collection, this.nuxt.options.rootDir))
+      (this.options.customCollections || []).map((collection) => loadCustomCollection(collection, this.nuxt.options.rootDir, this.nuxt))
     );
   }
   async loadClientBundleCollections() {
diff --git a/node_modules/@nuxt/icon/dist/shared/icon.i156g29l.mjs b/node_modules/@nuxt/icon/dist/shared/icon.i156g29l.mjs
index 8deea070..75fb930f 100644
--- a/node_modules/@nuxt/icon/dist/shared/icon.i156g29l.mjs
+++ b/node_modules/@nuxt/icon/dist/shared/icon.i156g29l.mjs
@@ -1,4 +1,4 @@
-import { join, isAbsolute, parse, normalize } from 'node:path';
+import { join, parse, normalize } from 'node:path';
 import fs from 'node:fs/promises';
 import { consola } from 'consola';
 import { glob } from 'tinyglobby';
@@ -233,11 +233,11 @@ const logger$1 = consola.withTag("nuxt:icon");
 function hasFullCollection(resolvePaths) {
   return isPackageExists("@iconify/json", { paths: resolvePaths });
 }
-async function resolveCollection(collection, rootDir) {
+async function resolveCollection(collection, rootDir, nuxt) {
   if (typeof collection === "string")
     return collection;
   if ("dir" in collection) {
-    return await loadCustomCollection(collection, rootDir);
+    return await loadCustomCollection(collection, rootDir, nuxt);
   }
   return collection;
 }
@@ -245,37 +245,47 @@ function getCollectionPath(collection, resolvePaths) {
   return hasFullCollection(resolvePaths) ? `@iconify/json/json/${collection}.json` : `@iconify-json/${collection}/icons.json`;
 }
 const validIconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
-async function loadCustomCollection(collection, rootDir) {
+async function loadCustomCollection(collection, rootDir, nuxt) {
   if ("dir" in collection) {
-    return parseCustomCollection(collection, rootDir);
+    return parseCustomCollection(collection, rootDir, nuxt);
   }
   logger$1.success(`Nuxt Icon loaded local collection \`${collection.prefix}\` with ${Object.keys(collection.icons).length} icons`);
   return collection;
 }
-async function parseCustomCollection(collection, rootDir) {
-  const dir = isAbsolute(collection.dir) ? collection.dir : join(rootDir, collection.dir);
+async function parseCustomCollection(collection, rootDir, nuxt) {
+  const layersCopy = [...nuxt.options._layers].toReversed();
   const {
     // TODO: next major flip this
     normalizeIconName = true,
     recursive = false
   } = collection;
   const pattern = recursive ? "**/*.svg" : "*.svg";
-  const files = (await glob([pattern], {
-    cwd: dir,
-    onlyFiles: true,
-    expandDirectories: recursive
-  })).sort();
-  const parsedIcons = await Promise.all(files.map(async (file) => {
-    const { dir: path, name: filename } = parse(file);
-    const pathNormalized = path ? normalize(path).replace(/[/\\]/g, "-") : "";
-    let name = pathNormalized ? `${pathNormalized}-${filename}` : filename;
+  const files = []
+  for (const layer of layersCopy) {
+    const cwd = join(layer.cwd, collection.dir)
+    files.push(...(await glob([pattern], {
+      cwd,
+      onlyFiles: true,
+      expandDirectories: recursive,
+    })).sort().map(result => {
+      return {
+        absolutePath: cwd,
+        result: result,
+      };
+    }))
+  }
+  const parsedIcons = await Promise.all(files.map(async (result) => {
+    const { absolutePath, result: file } = result;
+    const { dir: path, name: filename } = parse(file)
+    const pathNormalized = path ? normalize(path).replace(/[/\\]/g, '-') : ''
+    let name = pathNormalized ? `${pathNormalized}-${filename}` : filename
     if (normalizeIconName && !validIconNameRE.test(name)) {
       const normalized = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-");
       if (normalized !== name)
         logger$1.warn(`Custom icon \`${name}\` is normalized to \`${normalized}\`, we recommend to change the file name to match the icon name, or pass \`normalizeIconName: false\` to your custom collection options`);
       name = normalized;
     }
-    let svg = await fs.readFile(join(dir, file), "utf-8");
+    let svg = await fs.readFile(join(absolutePath, file), "utf-8");
     const cleanupIdx = svg.indexOf("<svg");
     if (cleanupIdx > 0)
       svg = svg.slice(cleanupIdx);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions