diff --git a/nuxt.config.ts b/nuxt.config.ts index e85a0ef..994b78f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -6,23 +6,26 @@ import { createResolver } from '@nuxt/kit' const { resolve } = createResolver(import.meta.url) -// Every `i--` icon referenced from content/*.md needs its -// collection listed below, but Nuxt Icon's own static scanner only looks at -// .vue/.ts source, not markdown content, so it can't find these on its own. -// Scanning content here instead of hand-maintaining the list means a new -// icon collection used in an article gets bundled automatically on the next -// build; the only manual step left is `npm install @iconify-json/` -// for a genuinely new collection, and a missing one now fails the build -// loudly (unresolved import) instead of silently breaking at runtime behind -// the CSP (icons falling back to a live, blocked api.iconify.design call). -function scanContentIconCollections(): string[] { +// Every `i--` icon referenced from content/*.md or +// content/**/.navigation.yml needs both its collection (for serverBundle, +// used during SSR/prerender) and its exact name (for clientBundle, used +// client-side) listed below, but Nuxt Icon's own static scanner only looks +// at .vue/.ts source, not content, so it can't find any of these on its +// own. Scanning content here instead of hand-maintaining both lists means a +// newly used icon gets bundled automatically on the next build; the only +// manual step left is `npm install @iconify-json/` for a genuinely +// new collection, and a missing one now fails the build loudly (unresolved +// 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, // fluent-color), same as the separator before the icon name, so a plain // "first segment" split is ambiguous. Matching against the actual // installed @iconify-json package names, longest first, resolves it. const installed = readdirSync(resolve('./node_modules/@iconify-json')) .sort((a, b) => b.length - a.length) - const found = new Set() + const collections = new Set() + const icons = new Set() const pattern = /icon=["']i-([a-z0-9-]+)["']|icon:\s*["']?i-([a-z0-9-]+)/g function walk(dir: string) { for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -30,7 +33,12 @@ function scanContentIconCollections(): string[] { if (entry.isDirectory()) { 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') for (const match of text.matchAll(pattern)) { const iconRef = match[1] ?? match[2] @@ -39,17 +47,24 @@ function scanContentIconCollections(): string[] { // installed yet: still wrong, but it now surfaces as a clear // "cannot resolve @iconify-json/" build error to fix, // 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')) // 'brand' is the local customCollections prefix (app/assets/brand-icons), - // not an installable Iconify package. - found.delete('brand') - return [...found] + // not an installable Iconify package, and never needs live/API resolution. + collections.delete('brand') + 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 // 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)), }, 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: [ { prefix: 'brand', @@ -128,34 +154,41 @@ export default defineNuxtConfig({ // dynamically (not as a literal `i-xxx` string anywhere), and // 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 - // bundle. Without this, they fall back to a live api.iconify.design - // request at prerender time, which times out wherever outbound access - // is restricted. Bundling all three collections in full avoids that. - // Everything else used as a literal icon in content/*.md is added by - // scanContentIconCollections() above, so a new collection introduced in - // an article gets bundled automatically instead of needing a manual - // addition here. + // bundle. Without this, they'd need the live-API fallback above just + // disabled, which is now blocked instead of avoided. Bundling all + // three collections in full sidesteps that. + // Everything else used as a literal icon in content/*.md or + // .navigation.yml is added by scanContentIcons() above, so a new + // collection introduced in an article gets bundled automatically + // instead of needing a manual addition here. 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 - // content-hashed client bundle instead of Nuxt Icon's runtime - // /api/_nuxt_icon route: that route's URL doesn't change between - // builds, so a browser or CDN caching an old (or, before the - // serverBundle fix above, broken) response for it keeps serving that - // stale result until the cache expires or is purged, which is what - // "icon disappears until a hard refresh" actually was. vscode-icons' - // per-file-extension icons aren't listed here: there are too many to - // enumerate and new file types keep appearing in content, so they - // still rely on the runtime route plus cache purging on deploy. + // The codeIcon values above, forced into the content-hashed client + // bundle instead of relying on the (now-disabled) runtime route: that + // route's URL doesn't change between builds, so a browser or CDN + // caching an old (or, before the serverBundle fix above, broken) + // response for it kept serving that stale result until the cache + // expired or was purged, which is what "icon disappears until a hard + // refresh" actually was. `scan: true` catches any other icon used + // literally in .vue/.ts source; scanContentIcons() above covers content + // the same way it does for serverBundle. vscode-icons' per-file- + // 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: { - icons: [ + scan: true, + icons: [...new Set([ 'lucide:folder-tree', 'lucide:settings', 'simple-icons:apple', 'simple-icons:linux', 'simple-icons:windows', - ], + ...contentIcons.icons, + ])], }, }, content: {