nuxt / nuxt/icon

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

Open
#513 1 comment 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
1.2k
Forks
96
Avg merge
8h 12m
Merged PRs (30d)
2

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at resolveServerBundle → loadCustomCollection → parseCustomCollection in dist/shared/icon.*.mjs, and compare it with loadClientBundleCollections() in dist/module.mjs, which already walks Nuxt layers. Use the provided layer reproduction with serverBundle.externalizeIconsJson enabled; done means the layer's icon renders on the server as it does on the client, including the documented layer override order.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, build-system
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.