Custom collection `dir` is not resolved across Nuxt Layers (server bundle path)
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-aware —
loadClientBundleCollections()(dist/module.mjs) walks every layer:await this.scanner.scanFiles(this.nuxt.options._layers.map(l => l.cwd), this.scannedIcons) - Custom collection loader is not —
parseCustomCollection()(dist/shared/icon.*.mjs) resolves a single dir fromrootDirand ignoresnuxt.options._layers:
This path feeds the server bundle (const dir = isAbsolute(collection.dir) ? collection.dir : join(rootDir, collection.dir) // rootDir onlyresolveServerBundle → loadCustomCollection → parseCustomCollection), sodir: './app/assets/icons'resolves to<rootDir>/app/assets/iconsand 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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