Disable Nuxt Icon's live API fallback and scan .navigation.yml files for icons too
Trigger container build / trigger (push) Successful in 2s

This commit is contained in:
Djeex
2026-09-07 17:01:56 +02:00
parent 14d910b267
commit 6ee8277205
+69 -36
View File
@@ -6,23 +6,26 @@ import { createResolver } from '@nuxt/kit'
const { resolve } = createResolver(import.meta.url) const { resolve } = createResolver(import.meta.url)
// Every `i-<collection>-<name>` icon referenced from content/*.md needs its // Every `i-<collection>-<name>` icon referenced from content/*.md or
// collection listed below, but Nuxt Icon's own static scanner only looks at // content/**/.navigation.yml needs both its collection (for serverBundle,
// .vue/.ts source, not markdown content, so it can't find these on its own. // used during SSR/prerender) and its exact name (for clientBundle, used
// Scanning content here instead of hand-maintaining the list means a new // client-side) listed below, but Nuxt Icon's own static scanner only looks
// icon collection used in an article gets bundled automatically on the next // at .vue/.ts source, not content, so it can't find any of these on its
// build; the only manual step left is `npm install @iconify-json/<name>` // own. Scanning content here instead of hand-maintaining both lists means a
// for a genuinely new collection, and a missing one now fails the build // newly used icon gets bundled automatically on the next build; the only
// loudly (unresolved import) instead of silently breaking at runtime behind // manual step left is `npm install @iconify-json/<name>` for a genuinely
// the CSP (icons falling back to a live, blocked api.iconify.design call). // new collection, and a missing one now fails the build loudly (unresolved
function scanContentIconCollections(): string[] { // import) instead of silently breaking at runtime behind the CSP (icons
// falling back to a live, blocked api.iconify.design call).
function scanContentIcons(): { collections: string[], icons: string[] } {
// Collection prefixes can contain hyphens themselves (simple-icons, // Collection prefixes can contain hyphens themselves (simple-icons,
// fluent-color), same as the separator before the icon name, so a plain // fluent-color), same as the separator before the icon name, so a plain
// "first segment" split is ambiguous. Matching against the actual // "first segment" split is ambiguous. Matching against the actual
// installed @iconify-json package names, longest first, resolves it. // installed @iconify-json package names, longest first, resolves it.
const installed = readdirSync(resolve('./node_modules/@iconify-json')) const installed = readdirSync(resolve('./node_modules/@iconify-json'))
.sort((a, b) => b.length - a.length) .sort((a, b) => b.length - a.length)
const found = new Set<string>() const collections = new Set<string>()
const icons = new Set<string>()
const pattern = /icon=["']i-([a-z0-9-]+)["']|icon:\s*["']?i-([a-z0-9-]+)/g const pattern = /icon=["']i-([a-z0-9-]+)["']|icon:\s*["']?i-([a-z0-9-]+)/g
function walk(dir: string) { function walk(dir: string) {
for (const entry of readdirSync(dir, { withFileTypes: true })) { for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -30,7 +33,12 @@ function scanContentIconCollections(): string[] {
if (entry.isDirectory()) { if (entry.isDirectory()) {
walk(full) walk(full)
} }
else if (entry.name.endsWith('.md')) { // .navigation.yml files set a section's nav icon (e.g. `icon:
// i-lucide-chart-no-axes-column`) and are just as invisible to this
// scan as markdown content is to Nuxt Icon's own .vue/.ts scanner if
// only .md files are walked here, which is exactly how the first
// version of this function missed them.
else if (entry.name.endsWith('.md') || entry.name.endsWith('.yml')) {
const text = readFileSync(full, 'utf8') const text = readFileSync(full, 'utf8')
for (const match of text.matchAll(pattern)) { for (const match of text.matchAll(pattern)) {
const iconRef = match[1] ?? match[2] const iconRef = match[1] ?? match[2]
@@ -39,17 +47,24 @@ function scanContentIconCollections(): string[] {
// installed yet: still wrong, but it now surfaces as a clear // installed yet: still wrong, but it now surfaces as a clear
// "cannot resolve @iconify-json/<name>" build error to fix, // "cannot resolve @iconify-json/<name>" build error to fix,
// rather than a silent runtime CSP block. // rather than a silent runtime CSP block.
found.add(collection ?? iconRef.split('-')[0]) const resolved = collection ?? iconRef.split('-')[0]
collections.add(resolved)
icons.add(`${resolved}:${iconRef.slice(resolved.length + 1)}`)
} }
} }
} }
} }
walk(resolve('./content')) walk(resolve('./content'))
// 'brand' is the local customCollections prefix (app/assets/brand-icons), // 'brand' is the local customCollections prefix (app/assets/brand-icons),
// not an installable Iconify package. // not an installable Iconify package, and never needs live/API resolution.
found.delete('brand') collections.delete('brand')
return [...found] const brandPrefix = /^brand:/
return {
collections: [...collections],
icons: [...icons].filter(icon => !brandPrefix.test(icon)),
} }
}
const contentIcons = scanContentIcons()
// Contributors per page: read straight from git history rather than an // Contributors per page: read straight from git history rather than an
// API, so it needs no token and no network call, but the CI checkout must // API, so it needs no token and no network call, but the CI checkout must
@@ -118,6 +133,17 @@ export default defineNuxtConfig({
dir: fileURLToPath(new URL('./public', import.meta.url)), dir: fileURLToPath(new URL('./public', import.meta.url)),
}, },
icon: { icon: {
// This site builds to a fully static export (nginx serving prerendered
// files, no Nitro server at runtime), so the `/api/_nuxt_icon` route
// Nuxt Icon's client falls back to for anything not in clientBundle
// doesn't exist in production: that fallback goes straight to the live
// Iconify API instead, which the CSP's connect-src blocks. `provider:
// 'none'` plus `fallbackToApi: false` removes that live-API fallback
// path entirely (both server- and client-side) rather than relying on
// clientBundle/serverBundle being perfectly exhaustive to avoid ever
// triggering it, per Nuxt Icon's own recommended static-site config.
provider: 'none',
fallbackToApi: false,
customCollections: [ customCollections: [
{ {
prefix: 'brand', prefix: 'brand',
@@ -128,34 +154,41 @@ export default defineNuxtConfig({
// dynamically (not as a literal `i-xxx` string anywhere), and // dynamically (not as a literal `i-xxx` string anywhere), and
// FileTreeNode.vue resolves vscode-icons file-type icons the same way, // FileTreeNode.vue resolves vscode-icons file-type icons the same way,
// so Nuxt Icon's static scanner can't pick any of them up for the local // so Nuxt Icon's static scanner can't pick any of them up for the local
// bundle. Without this, they fall back to a live api.iconify.design // bundle. Without this, they'd need the live-API fallback above just
// request at prerender time, which times out wherever outbound access // disabled, which is now blocked instead of avoided. Bundling all
// is restricted. Bundling all three collections in full avoids that. // three collections in full sidesteps that.
// Everything else used as a literal icon in content/*.md is added by // Everything else used as a literal icon in content/*.md or
// scanContentIconCollections() above, so a new collection introduced in // .navigation.yml is added by scanContentIcons() above, so a new
// an article gets bundled automatically instead of needing a manual // collection introduced in an article gets bundled automatically
// addition here. // instead of needing a manual addition here.
serverBundle: { serverBundle: {
collections: [...new Set(['simple-icons', 'lucide', 'vscode-icons', ...scanContentIconCollections()])], collections: [...new Set(['simple-icons', 'lucide', 'vscode-icons', ...contentIcons.collections])],
}, },
// The fixed, small set of codeIcon values above. Forces them into the // The codeIcon values above, forced into the content-hashed client
// content-hashed client bundle instead of Nuxt Icon's runtime // bundle instead of relying on the (now-disabled) runtime route: that
// /api/_nuxt_icon route: that route's URL doesn't change between // route's URL doesn't change between builds, so a browser or CDN
// builds, so a browser or CDN caching an old (or, before the // caching an old (or, before the serverBundle fix above, broken)
// serverBundle fix above, broken) response for it keeps serving that // response for it kept serving that stale result until the cache
// stale result until the cache expires or is purged, which is what // expired or was purged, which is what "icon disappears until a hard
// "icon disappears until a hard refresh" actually was. vscode-icons' // refresh" actually was. `scan: true` catches any other icon used
// per-file-extension icons aren't listed here: there are too many to // literally in .vue/.ts source; scanContentIcons() above covers content
// enumerate and new file types keep appearing in content, so they // the same way it does for serverBundle. vscode-icons' per-file-
// still rely on the runtime route plus cache purging on deploy. // extension icons aren't listed here: there are too many to enumerate
// and new file types keep appearing in content, so with the API
// fallback disabled, an unbundled one now renders as a missing icon
// instead of a broken network request; still preferable to a CSP
// violation, and serverBundle above still renders it correctly in the
// page's own initial prerendered HTML.
clientBundle: { clientBundle: {
icons: [ scan: true,
icons: [...new Set([
'lucide:folder-tree', 'lucide:folder-tree',
'lucide:settings', 'lucide:settings',
'simple-icons:apple', 'simple-icons:apple',
'simple-icons:linux', 'simple-icons:linux',
'simple-icons:windows', 'simple-icons:windows',
], ...contentIcons.icons,
])],
}, },
}, },
content: { content: {