From c279bd8c52e69650379198e1d5e233ea6fc0433e Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 13:08:22 +0200 Subject: [PATCH 1/5] Don't crash when public/dsfr exists without an index.html `assert(htmlFilePath !== undefined)` threw a message-less AssertionError as soon as public/dsfr existed while neither /index.html nor /index.html did. A monorepo invoked with --projectDir reproduces it, as does a project that lost its index.html. Nothing else in the script needs the html file, so make `spaParams.htmlFilePath` optional and skip only the cache busting query parameter when it is missing. Move that rewrite outside of the `hasChanged` early return while at it. It is idempotent, and inside the guard a stale or hand reverted hash could never be repaired as long as icons.min.css itself did not change. --- src/bin/only-include-used-icons.ts | 67 +++++++++++++++++------------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/bin/only-include-used-icons.ts b/src/bin/only-include-used-icons.ts index 700741364..ca2e2813f 100644 --- a/src/bin/only-include-used-icons.ts +++ b/src/bin/only-include-used-icons.ts @@ -116,7 +116,9 @@ type CommandContext = { spaParams: | { dsfrDirPath_static: string; - htmlFilePath: string; + // Undefined in Next.js: public/dsfr exists (copy-static-assets put it there) + // but there is no index.html to add a cache busting query parameter to. + htmlFilePath: string | undefined; } | undefined; isSilent: boolean; @@ -401,8 +403,6 @@ async function getCommandContext(args: string[]): Promise { return undefined; } - assert(htmlFilePath !== undefined); - return { dsfrDirPath_static, htmlFilePath @@ -529,6 +529,40 @@ export async function main(args: string[]) { }) ); + // NOTE: Deliberately outside of the `hasChanged` guard below. The rewrite is + // idempotent, and inside the guard a stale or hand reverted hash could never be + // repaired as long as icons.min.css itself did not change. + await (async function addHashQueryParameterInIndexHtml() { + const htmlFilePath = commandContext.spaParams?.htmlFilePath; + + if (htmlFilePath === undefined) { + return; + } + + const html = (await readFile(htmlFilePath)).toString("utf8"); + + const { modifiedHtml } = modifyHtmlHrefs({ + "html": html, + "getModifiedHref": href => { + if (!href.includes(iconsMinCssRelativePath.replace(/\\/g, "/"))) { + return href; + } + + const [urlWithoutQuery] = href.split("?"); + + return `${urlWithoutQuery}?hash=${fnv1aHashToHex( + rawIconCssCodeBuffer.toString("utf8") + )}`; + } + }); + + if (modifiedHtml === html) { + return; + } + + await writeFile(htmlFilePath, Buffer.from(modifiedHtml, "utf8")); + })(); + if (!hasChanged) { log?.("No change since last run"); return; @@ -584,33 +618,6 @@ export async function main(args: string[]) { .map(([srcFilePath, destFilePath]) => cp(srcFilePath, destFilePath)) ); })(), - (async function addHashQueryParameterInIndexHtml() { - if (commandContext.spaParams === undefined) { - return; - } - - const html = (await readFile(commandContext.spaParams.htmlFilePath)).toString("utf8"); - - const { modifiedHtml } = modifyHtmlHrefs({ - "html": html, - "getModifiedHref": href => { - if (!href.includes(iconsMinCssRelativePath.replace(/\\/g, "/"))) { - return href; - } - - const [urlWithoutQuery] = href.split("?"); - - return `${urlWithoutQuery}?hash=${fnv1aHashToHex( - rawIconCssCodeBuffer.toString("utf8") - )}`; - } - }); - - await writeFile( - commandContext.spaParams.htmlFilePath, - Buffer.from(modifiedHtml, "utf8") - ); - })(), (async function clearCache() { await Promise.all( [ From fa0dbe1f970d2e822005b7bfff458e09d602d821 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 14:18:12 +0200 Subject: [PATCH 2/5] Address review of #506 - Correct the `htmlFilePath` comment. Next.js has no `public/dsfr` in the documented setup, so it never reaches the assertion that used to fire here. The real trigger is any project with a `public/dsfr` and no `index.html`. - Move `generateUsedRemixiconFiles` and `copyUsedDsfrIconsToStatic` out of the `hasChanged` guard alongside the html rewrite. `copy-dsfr-to-public` builds its keep list from the `url()` of `dsfr.min.css`, so these icons are written by this script alone. A deleted svg was never repaired as long as `icons.min.css` itself did not change. - `fs.mkdirSync(remixiconDirPath, { recursive: true })`, so a `public/dsfr` without an `icons` directory no longer throws ENOENT. Only `clearCache` stays behind the guard, which is the one thing that really depends on the stylesheet having changed. --- src/bin/only-include-used-icons.ts | 120 +++++++++++++++-------------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/src/bin/only-include-used-icons.ts b/src/bin/only-include-used-icons.ts index ca2e2813f..ebcf5a18a 100644 --- a/src/bin/only-include-used-icons.ts +++ b/src/bin/only-include-used-icons.ts @@ -116,8 +116,10 @@ type CommandContext = { spaParams: | { dsfrDirPath_static: string; - // Undefined in Next.js: public/dsfr exists (copy-static-assets put it there) - // but there is no index.html to add a cache busting query parameter to. + // Undefined whenever public/dsfr exists but no index.html was found to add + // a cache busting query parameter to: a monorepo invoked with --projectDir, + // a project that used to be a Vite/CRA app, or a Next.js app that opted into + // copy-static-assets (the documented Next.js setup has no public/dsfr at all). htmlFilePath: string | undefined; } | undefined; @@ -529,46 +531,42 @@ export async function main(args: string[]) { }) ); - // NOTE: Deliberately outside of the `hasChanged` guard below. The rewrite is - // idempotent, and inside the guard a stale or hand reverted hash could never be - // repaired as long as icons.min.css itself did not change. - await (async function addHashQueryParameterInIndexHtml() { - const htmlFilePath = commandContext.spaParams?.htmlFilePath; - - if (htmlFilePath === undefined) { - return; - } - - const html = (await readFile(htmlFilePath)).toString("utf8"); + // NOTE: Deliberately outside of the `hasChanged` guard below. These three writes are + // idempotent, and inside the guard a stale or hand reverted output could never be + // repaired as long as icons.min.css itself did not change. Nothing else writes them: + // copy-dsfr-to-public builds its keep list from the url() of dsfr.min.css, so the + // icons and the hash query parameter are this script's responsibility alone. + await Promise.all([ + (async function addHashQueryParameterInIndexHtml() { + const htmlFilePath = commandContext.spaParams?.htmlFilePath; - const { modifiedHtml } = modifyHtmlHrefs({ - "html": html, - "getModifiedHref": href => { - if (!href.includes(iconsMinCssRelativePath.replace(/\\/g, "/"))) { - return href; - } + if (htmlFilePath === undefined) { + return; + } - const [urlWithoutQuery] = href.split("?"); + const html = (await readFile(htmlFilePath)).toString("utf8"); - return `${urlWithoutQuery}?hash=${fnv1aHashToHex( - rawIconCssCodeBuffer.toString("utf8") - )}`; - } - }); + const { modifiedHtml } = modifyHtmlHrefs({ + "html": html, + "getModifiedHref": href => { + if (!href.includes(iconsMinCssRelativePath.replace(/\\/g, "/"))) { + return href; + } - if (modifiedHtml === html) { - return; - } + const [urlWithoutQuery] = href.split("?"); - await writeFile(htmlFilePath, Buffer.from(modifiedHtml, "utf8")); - })(); + return `${urlWithoutQuery}?hash=${fnv1aHashToHex( + rawIconCssCodeBuffer.toString("utf8") + )}`; + } + }); - if (!hasChanged) { - log?.("No change since last run"); - return; - } + if (modifiedHtml === html) { + return; + } - await Promise.all([ + await writeFile(htmlFilePath, Buffer.from(modifiedHtml, "utf8")); + })(), (async function generateUsedRemixiconFiles() { await Promise.all( [commandContext.dsfrDirPath, commandContext.spaParams?.dsfrDirPath_static] @@ -576,9 +574,7 @@ export async function main(args: string[]) { .map(async dsfrDistDirPath => { const remixiconDirPath = pathJoin(dsfrDistDirPath, "icons", "remixicon"); - if (!fs.existsSync(remixiconDirPath)) { - fs.mkdirSync(remixiconDirPath); - } + fs.mkdirSync(remixiconDirPath, { "recursive": true }); await Promise.all( usedIcons @@ -617,29 +613,35 @@ export async function main(args: string[]) { ) .map(([srcFilePath, destFilePath]) => cp(srcFilePath, destFilePath)) ); - })(), - (async function clearCache() { - await Promise.all( - [ - pathJoin(".next", "cache"), - pathJoin(".vite"), - pathJoin(".cache", "storybook"), - pathJoin(".cache", "babel-loader"), - pathJoin(".cache", "default-development") - ] - .map(relativeDirPath => - pathJoin(commandContext.projectDirPath, "node_modules", relativeDirPath) - ) - .map(async dirPath => { - if (!(await existsAsync(dirPath))) { - return; - } - - await rm(dirPath, { "recursive": true, "force": true }); - }) - ); })() ]); + + if (!hasChanged) { + log?.("No change since last run"); + return; + } + + await (async function clearCache() { + await Promise.all( + [ + pathJoin(".next", "cache"), + pathJoin(".vite"), + pathJoin(".cache", "storybook"), + pathJoin(".cache", "babel-loader"), + pathJoin(".cache", "default-development") + ] + .map(relativeDirPath => + pathJoin(commandContext.projectDirPath, "node_modules", relativeDirPath) + ) + .map(async dirPath => { + if (!(await existsAsync(dirPath))) { + return; + } + + await rm(dirPath, { "recursive": true, "force": true }); + }) + ); + })(); } if (require.main === module) { From 7ea46f882eec0d0aa568a47f3e5660df05fb835d Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 15:19:30 +0200 Subject: [PATCH 3/5] Stop wiping the build cache on every only-include-used-icons run `setUsedIconClassNames` is filled from a `Promise.all` over the source files, so its insertion order follows I/O completion order and changes between runs. The rules of `icons.min.css` were emitted in that order, so the generated stylesheet differed from one run to the next for an unchanged set of icons. `hasChanged` compares that buffer against the file on disk, so it was true almost every time. For any project whose icons are spread across several source files, `icons.min.css` is rewritten, the `?hash=` query parameter in index.html is changed, and `node_modules/.next/cache`, `.vite`, the storybook and the babel-loader caches are wiped on every prebuild. "No change since last run" was reported only by luck. Measured on a 40 file fixture: 15 runs, 15 different outputs, and the message never fired once. That makes this a build time regression, not just a determinism cleanup. Sorting fixes it, and is safe: rule order carries no meaning here, every rule targets a distinct `.fr-icon-*::before` or `.ri-*::before` selector. Found while verifying the `hasChanged` guard for the review of #506, where the nondeterminism was masking the missing asset bug half the time. --- src/bin/only-include-used-icons.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bin/only-include-used-icons.ts b/src/bin/only-include-used-icons.ts index ebcf5a18a..45e4701b4 100644 --- a/src/bin/only-include-used-icons.ts +++ b/src/bin/only-include-used-icons.ts @@ -477,7 +477,12 @@ export async function main(args: string[]) { }) ); - return { "usedIconClassNames": Array.from(setUsedIconClassNames) }; + // NOTE: The set is filled from a Promise.all over the source files, so its + // insertion order follows I/O completion order and varies between runs. Sorting + // makes the generated stylesheet byte stable, which is what the `hasChanged` + // comparison below relies on. Rule order carries no meaning here: every rule + // targets a distinct `.fr-icon-*::before` / `.ri-*::before` selector. + return { "usedIconClassNames": Array.from(setUsedIconClassNames).sort() }; })(); if (usedIconClassNames.length > 300) { From aa428f323f4b2f7ba2c316d48c012187a5147d64 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 14:25:47 +0200 Subject: [PATCH 4/5] Drop copy-static-assets from the htmlFilePath comment It cannot produce this state: copy-dsfr-to-public.ts:60 asserts "Can't locate your index.html file." before the mkdirSync at :95, so a project with no index.html never gets a public/dsfr out of it, Next.js included. --- src/bin/only-include-used-icons.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bin/only-include-used-icons.ts b/src/bin/only-include-used-icons.ts index 45e4701b4..2547dd3f4 100644 --- a/src/bin/only-include-used-icons.ts +++ b/src/bin/only-include-used-icons.ts @@ -117,9 +117,11 @@ type CommandContext = { | { dsfrDirPath_static: string; // Undefined whenever public/dsfr exists but no index.html was found to add - // a cache busting query parameter to: a monorepo invoked with --projectDir, - // a project that used to be a Vite/CRA app, or a Next.js app that opted into - // copy-static-assets (the documented Next.js setup has no public/dsfr at all). + // a cache busting query parameter to: a monorepo invoked with --projectDir + // where the html is not where either branch looks, a project that used to be + // a Vite/CRA app and lost its index.html, or a public/dsfr that was committed + // or restored by other means. Not reachable through copy-static-assets: it + // asserts "Can't locate your index.html file." before creating anything. htmlFilePath: string | undefined; } | undefined; From 38dc8c44f9d29b75eba7fdaa68bc4ae4700d6a5e Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 15:20:09 +0200 Subject: [PATCH 5/5] Drop the monorepo case from the htmlFilePath comment It is not an independent trigger: both this script and copy-dsfr-to-public resolve index.html through the same two paths, /index.html and /index.html. If copy-static-assets was able to create public/dsfr, an index.html was found at one of them and this script finds it too. The case collapses into "the index.html was removed afterwards". --- src/bin/only-include-used-icons.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bin/only-include-used-icons.ts b/src/bin/only-include-used-icons.ts index 2547dd3f4..6e22defcf 100644 --- a/src/bin/only-include-used-icons.ts +++ b/src/bin/only-include-used-icons.ts @@ -117,11 +117,11 @@ type CommandContext = { | { dsfrDirPath_static: string; // Undefined whenever public/dsfr exists but no index.html was found to add - // a cache busting query parameter to: a monorepo invoked with --projectDir - // where the html is not where either branch looks, a project that used to be - // a Vite/CRA app and lost its index.html, or a public/dsfr that was committed - // or restored by other means. Not reachable through copy-static-assets: it - // asserts "Can't locate your index.html file." before creating anything. + // a cache busting query parameter to: a project that used to be a Vite/CRA + // app and lost its index.html, or a public/dsfr that was committed or + // restored by other means. Not reachable through copy-static-assets, which + // resolves index.html through the same two paths as this script and asserts + // "Can't locate your index.html file." before creating anything. htmlFilePath: string | undefined; } | undefined;