From 76a5865be69ff6f48bd6ed3c1c3f8281671c10fa Mon Sep 17 00:00:00 2001 From: kevbarns Date: Fri, 14 Aug 2026 14:59:13 +0200 Subject: [PATCH 1/7] Add only-include-used-components script to trim unused DSFR component CSS Opt-in script, modeled after only-include-used-icons, that rebuilds dsfr.css and dsfr.min.css in node_modules (and public/dsfr when applicable) with only the CSS of the DSFR components actually used by the project, plus the core and scheme which are always included. Usage is detected from @codegouvfr/react-dsfr/ imports and from raw fr-* class names found in the sources. Components can also be forced via "react-dsfr"."additionalComponents" in package.json. Any unknown component import falls back to including every component. The stylesheets are rebuilt from the granular files shipped in dsfr/ (core, scheme, component/*, print variants) preserving the upstream cascade order, rewriting relative asset urls and reapplying the Mui compat patch, so no individual CSS rule is ever dropped or rewritten. See #304 --- package.json | 3 +- src/bin/only-include-used-components.ts | 1048 +++++++++++++++++++++++ src/bin/react-dsfr.ts | 7 + 3 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 src/bin/only-include-used-components.ts diff --git a/package.json b/package.json index 408e22340..58ea8951d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "bin": { "react-dsfr": "dist/bin/react-dsfr.js", "copy-dsfr-to-public": "dist/bin/copy-dsfr-to-public.js", - "only-include-used-icons": "dist/bin/only-include-used-icons.js" + "only-include-used-icons": "dist/bin/only-include-used-icons.js", + "only-include-used-components": "dist/bin/only-include-used-components.js" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts new file mode 100644 index 000000000..9ba2dc80f --- /dev/null +++ b/src/bin/only-include-used-components.ts @@ -0,0 +1,1048 @@ +#!/usr/bin/env node + +/** + * This script is ran with `npx react-dsfr only-include-used-components` + * It scans your codebase to find which react-dsfr components are used and rebuilds + * the node_modules/@codegouvfr/react-dsfr/dsfr/dsfr.css and dsfr.min.css files + * with only the CSS of those components (plus the core, which is always included). + * The public/dsfr/dsfr.min.css file is patched as well if applicable (not in Next.js for example). + * + * The trimmed stylesheets are rebuilt from the granular CSS files shipped by the DSFR + * (dsfr/core/*, dsfr/component/*, dsfr/scheme/*) so no CSS rule is ever rewritten, + * only whole components are included or excluded. This makes the output robust to + * classes and attributes dynamically added by the DSFR JavaScript (data-fr-js-*). + * + * Usage of a component is detected by: + * - Imports of `@codegouvfr/react-dsfr/` in your sources. + * - Usage of the component's CSS class names (e.g. "fr-table") in your sources, + * for when you use raw DSFR classes without the React component. + * + * You can force the inclusion of components that the detection would miss by adding + * to your package.json: + * "react-dsfr": { + * "additionalComponents": ["table", "Range"] + * } + * (values are DSFR CSS component names or react-dsfr component names) + * + * There are two optional arguments that you can use: + * - `--projectDir ` to specify the project directory. Default to the current working directory. + * This can be used in monorepos to specify the react project directory. + * - `--silent` to disable console.log + */ + +import { getProjectRoot } from "./tools/getProjectRoot"; +import * as fs from "fs"; +import { join as pathJoin, relative as pathRelative } from "path"; +import { assert } from "tsafe/assert"; +import { exclude } from "tsafe/exclude"; +import { writeFile, readFile, rm } from "fs/promises"; +import { crawl } from "./tools/crawl"; +import { basename as pathBasename, sep as pathSep, dirname as pathDirname } from "path"; +import yargsParser from "yargs-parser"; +import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath"; +import { readPublicDirPath } from "./readPublicDirPath"; +import { existsAsync } from "./tools/fs.existsAsync"; +import { fnv1aHashToHex } from "./tools/fnv1aHashToHex"; +import { modifyHtmlHrefs } from "./tools/modifyHtmlHrefs"; + +/** + * The DSFR CSS components (dsfr/component/ directories), listed in the + * order in which they are concatenated in the upstream dsfr.css bundle. + * Preserving this order preserves the CSS cascade of the original stylesheet. + * (Order determined empirically by locating each component's section in dsfr.main.css) + */ +export const DSFR_COMPONENTS_CASCADE_ORDER = [ + "upload", + "range", + "accordion", + "badge", + "logo", + "button", + "connect", + "quote", + "breadcrumb", + "form", + "stepper", + "tooltip", + "link", + "sidemenu", + "callout", + "highlight", + "tab", + "pagination", + "summary", + "tag", + "download", + "alert", + "notice", + "radio", + "card", + "checkbox", + "input", + "content", + "transcription", + "segmented", + "toggle", + "skiplink", + "select", + "modal", + "navigation", + "share", + "footer", + "tile", + "search", + "consent", + "follow", + "password", + "translate", + "table", + "header" +] as const; + +export type DsfrComponentName = typeof DSFR_COMPONENTS_CASCADE_ORDER[number]; + +/** + * Map from react-dsfr module (the `@codegouvfr/react-dsfr/` import subpath) + * to the DSFR CSS components its markup depends on, transitive dependencies included + * (e.g. the Header renders a navigation, a search bar and a modal on mobile). + * When in doubt a dependency is included: too much CSS is only a size cost, + * not enough CSS is a rendering bug. + * Modules that do not appear here and are not known non-component modules trigger + * a fail-safe: every component is included. + */ +export const REACT_DSFR_MODULE_TO_DSFR_COMPONENTS: Record = { + "Accordion": ["accordion"], + "AgentConnectButton": ["connect", "button"], + "Alert": ["alert", "link", "button"], + "Badge": ["badge"], + "Breadcrumb": ["breadcrumb", "link"], + "Button": ["button", "link"], + "ButtonsGroup": ["button", "link"], + "CallOut": ["callout", "button", "link"], + "Card": ["card", "badge", "link"], + // The Chart components pull their CSS from the @gouvfr/dsfr-chart peer dependency. + "Chart": [], + "Checkbox": ["checkbox", "radio", "form"], + "Display": ["modal", "radio", "form", "button", "link"], + "Download": ["download", "link"], + "Follow": ["follow", "form", "input", "upload", "button", "link", "alert", "checkbox"], + "Footer": ["footer", "logo", "button", "link"], + "FranceConnectButton": ["connect", "button"], + "Header": [ + "header", + "navigation", + "modal", + "logo", + "button", + "link", + "search", + "input", + "form" + ], + "Highlight": ["highlight"], + "Input": ["input", "form", "upload"], + "LanguageSelect": ["translate", "navigation", "button", "link"], + "MainNavigation": ["navigation", "link"], + "Modal": ["modal", "button", "link"], + "MonCompteProButton": ["connect", "button"], + "Notice": ["notice", "button", "link"], + "Pagination": ["pagination", "link"], + "ProConnectButton": ["connect", "button"], + "Quote": ["quote"], + "RadioButtons": ["radio", "checkbox", "form"], + "Range": ["range", "form"], + "SearchBar": ["search", "input", "form", "button"], + "SegmentedControl": ["segmented", "form"], + "Select": ["select", "form"], + "SelectNext": ["select", "form"], + "SideMenu": ["sidemenu", "link"], + "SkipLinks": ["skiplink", "link"], + "Stepper": ["stepper"], + "Summary": ["summary", "link"], + "Table": ["table"], + "Tabs": ["tab"], + "Tag": ["tag"], + "TagsGroup": ["tag"], + "Tile": ["tile", "link"], + "ToggleSwitch": ["toggle", "form"], + "ToggleSwitchGroup": ["toggle", "form"], + "Tooltip": ["tooltip", "button"], + "Upload": ["upload", "input", "form"], + "blocks/PasswordInput": ["password", "input", "form", "link", "checkbox"], + "consentManagement": ["consent", "modal", "button", "link", "radio", "form"] +}; + +/** + * CSS class name prefixes that reveal a direct usage of a DSFR component in the + * sources (when raw fr-* classes are used without importing the React component). + * Substring matching is intentional and fail-safe: matching too much only means + * including a component's CSS that may not be needed. + */ +export const DSFR_COMPONENT_DETECTION_CLASS_PREFIXES: Record = { + "accordion": ["fr-accordion"], + "alert": ["fr-alert"], + "badge": ["fr-badge"], + "breadcrumb": ["fr-breadcrumb"], + "button": ["fr-btn"], + "callout": ["fr-callout"], + "card": ["fr-card"], + "checkbox": ["fr-checkbox"], + "connect": ["fr-connect"], + "consent": ["fr-consent"], + "content": ["fr-content-media", "fr-responsive-img", "fr-responsive-vid"], + "download": ["fr-download"], + "follow": ["fr-follow"], + "footer": ["fr-footer"], + "form": ["fr-fieldset", "fr-label", "fr-hint-text", "fr-message", "fr-input-group"], + "header": ["fr-header"], + "highlight": ["fr-highlight"], + "input": ["fr-input"], + "link": ["fr-link"], + "logo": ["fr-logo"], + "modal": ["fr-modal"], + "navigation": ["fr-nav", "fr-menu", "fr-mega-menu"], + "notice": ["fr-notice"], + "pagination": ["fr-pagination"], + "password": ["fr-password"], + "quote": ["fr-quote"], + "radio": ["fr-radio"], + "range": ["fr-range"], + "search": ["fr-search-bar"], + "segmented": ["fr-segmented"], + "select": ["fr-select"], + "share": ["fr-share"], + "sidemenu": ["fr-sidemenu"], + "skiplink": ["fr-skiplink"], + "stepper": ["fr-stepper"], + "summary": ["fr-summary"], + "tab": ["fr-tabs"], + "table": ["fr-table"], + "tag": ["fr-tag"], + "tile": ["fr-tile"], + "toggle": ["fr-toggle"], + "tooltip": ["fr-tooltip"], + "transcription": ["fr-transcription"], + "translate": ["fr-translate"], + "upload": ["fr-upload"] +}; + +/** + * react-dsfr modules that are known not to render any DSFR component markup + * (hooks, utilities, integration helpers, assets...). + */ +const NON_COMPONENT_MODULE_IDS = new Set([ + "fr", + "i18n", + "spa", + "start", + "tss", + "mui", + "link", + "picto", + "shared", + "tools", + "assets", + "favicon", + "main.css", + "dsfr.css", + "next-appdir", + "next-app-router", + "next-pagesdir", + "useBreakpointsValues", + "useBreakpointsValuesPx", + "useColors", + "useIsDark", + "eulerianAnalytics", + "getHtmlAttributes", + "zz_internal" +]); + +export function getReactDsfrImportedModuleIds(params: { rawFileContent: string }): string[] { + const { rawFileContent } = params; + + const moduleIds = new Set(); + + for (const [, subpath] of rawFileContent.matchAll( + /@codegouvfr\/react-dsfr\/([\w@.-]+(?:\/[\w@.-]+)*)/g + )) { + const segments = subpath + .replace(/\.(?:js|mjs|cjs|ts|tsx|jsx)$/, "") + .split("/") + .filter(segment => segment !== "index" && segment !== ""); + + if (segments.length === 0) { + continue; + } + + moduleIds.add( + (() => { + switch (segments[0]) { + case "blocks": + return segments.slice(0, 2).join("/"); + case "dsfr": + return segments.slice(0, 3).join("/"); + default: + return segments[0]; + } + })() + ); + } + + return Array.from(moduleIds); +} + +export function resolveModuleIdToDsfrComponents(params: { + moduleId: string; +}): DsfrComponentName[] | undefined { + const { moduleId } = params; + + direct_import_of_a_dsfr_component_stylesheet: { + const match = moduleId.match(/^dsfr\/component\/([^/]+)/); + + if (match === null) { + break direct_import_of_a_dsfr_component_stylesheet; + } + + const dsfrComponent = DSFR_COMPONENTS_CASCADE_ORDER.find( + componentName => componentName === match[1] + ); + + return dsfrComponent === undefined ? [] : [dsfrComponent]; + } + + if (moduleId.startsWith("dsfr/")) { + return []; + } + + { + const dsfrComponents = REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[moduleId.split("/")[0]]; + + if (dsfrComponents !== undefined) { + return dsfrComponents; + } + } + + { + const dsfrComponents = REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[moduleId]; + + if (dsfrComponents !== undefined) { + return dsfrComponents; + } + } + + const firstSegment = moduleId.split("/")[0]; + + if (NON_COMPONENT_MODULE_IDS.has(firstSegment)) { + return []; + } + + if (/^[a-z]/.test(firstSegment)) { + // Lowercase modules are utilities by convention in this repo. + return []; + } + + // A component this script does not know about (newer react-dsfr version?). + return undefined; +} + +export function detectDsfrComponentsFromClassNames(params: { + rawFileContent: string; +}): DsfrComponentName[] { + const { rawFileContent } = params; + + if (!rawFileContent.includes("fr-")) { + return []; + } + + return Object.entries(DSFR_COMPONENT_DETECTION_CLASS_PREFIXES) + .filter(([, classPrefixes]) => + classPrefixes.some(classPrefix => rawFileContent.includes(classPrefix)) + ) + .map(([componentName]) => componentName as DsfrComponentName); +} + +export function rewriteCssRelativeUrls(params: { + rawCssCode: string; + /** Posix style, relative to the dsfr directory, e.g. "component/header" */ + cssFileRelativeDirPath: string; +}): string { + const { rawCssCode, cssFileRelativeDirPath } = params; + + return rawCssCode.replace(/url\((["']?)([^)"']+)\1\)/g, (match, quote: string, url: string) => { + if (/^(?:data:|https?:|\/)/.test(url)) { + return match; + } + + const pathSegments: string[] = []; + + for (const segment of [...cssFileRelativeDirPath.split("/"), ...url.split("/")]) { + if (segment === "." || segment === "") { + continue; + } + + if (segment === ".." && pathSegments.length !== 0) { + pathSegments.pop(); + continue; + } + + pathSegments.push(segment); + } + + return `url(${quote}${pathSegments.join("/")}${quote})`; + }); +} + +/** + * String level equivalent of scripts/build/patchCssForMui.ts, the selectors + * `button:not(:disabled):hover` and `button:not(:disabled):active` only exist + * in the core stylesheet, within the `(hover: hover) and (pointer: fine)` media query. + */ +export function patchCoreCssCodeForCompatWithMui(params: { rawCssCode: string }): string { + const { rawCssCode } = params; + + return rawCssCode + .replace( + /button:not\(:disabled\):hover(?![\w:([-])/g, + 'button:not(:disabled):hover:not([class^="Mui"])' + ) + .replace( + /button:not\(:disabled\):active(?![\w:([-])/g, + 'button:not(:disabled):active:not([class^="Mui"])' + ); +} + +export function generateDsfrCssCode(params: { + dsfrComponents: string[]; + isMinified: boolean; + /** Returns the raw code of a file within the dsfr directory, undefined if it does not exist */ + readDsfrFile: (fileRelativePath: string) => string | undefined; +}): string { + const { dsfrComponents, isMinified, readDsfrFile } = params; + + const sortedDsfrComponents = [ + ...DSFR_COMPONENTS_CASCADE_ORDER.filter(componentName => + dsfrComponents.includes(componentName) + ), + // Fail-safe for component directories unknown to this script + // (newer DSFR version): appended in alphabetical order. + ...dsfrComponents + .filter( + componentName => + !DSFR_COMPONENTS_CASCADE_ORDER.includes(componentName as DsfrComponentName) + ) + .sort() + ]; + + const readCssChunks = (params: { + getFileRelativePathCandidates: (dirRelativePath: string, basename: string) => string[]; + }) => { + const { getFileRelativePathCandidates } = params; + + return [ + ["core", "core"], + ["scheme", "scheme"], + ...sortedDsfrComponents.map(componentName => [ + `component/${componentName}`, + componentName + ]) + ] + .map(([dirRelativePath, basename]) => { + for (const fileRelativePath of getFileRelativePathCandidates( + dirRelativePath, + basename + )) { + const rawCssCode = readDsfrFile(fileRelativePath); + + if (rawCssCode === undefined) { + continue; + } + + return { + dirRelativePath, + rawCssCode + }; + } + + return undefined; + }) + .filter(exclude(undefined)); + }; + + const cssChunks = [ + ...readCssChunks({ + "getFileRelativePathCandidates": (dirRelativePath, basename) => + (isMinified + ? [`${basename}.main.min.css`, `${basename}.min.css`] + : [`${basename}.main.css`, `${basename}.css`] + ).map(fileBasename => `${dirRelativePath}/${fileBasename}`) + }), + ...readCssChunks({ + "getFileRelativePathCandidates": (dirRelativePath, basename) => + (isMinified ? [`${basename}.print.min.css`] : [`${basename}.print.css`]).map( + fileBasename => `${dirRelativePath}/${fileBasename}` + ) + }) + ]; + + assert( + cssChunks.length !== 0, + "Can't find the granular DSFR stylesheets to rebuild dsfr.css from" + ); + + return [ + `/*! DSFR stylesheet rebuilt by react-dsfr only-include-used-components, components: ${sortedDsfrComponents.join( + ", " + )} */`, + ...cssChunks.map(({ dirRelativePath, rawCssCode }) => { + let cssCode = rawCssCode + .replace(/@charset "UTF-8";\s*/g, "") + .replace(/^\/\*![\s\S]*?\*\/\s*/, "") + .replace(/\/\*# sourceMappingURL=[^*]*\*\/\s*/g, ""); + + cssCode = rewriteCssRelativeUrls({ + "rawCssCode": cssCode, + "cssFileRelativeDirPath": dirRelativePath + }); + + if (dirRelativePath === "core") { + cssCode = patchCoreCssCodeForCompatWithMui({ "rawCssCode": cssCode }); + } + + return cssCode.trim(); + }) + ].join("\n"); +} + +type CommandContext = { + projectDirPath: string; + srcFilePaths: string[]; + dsfrDirPath: string; + spaParams: + | { + dsfrDirPath_static: string; + htmlFilePath: string; + } + | undefined; + isSilent: boolean; +}; + +const CODEGOUV_REACT_DSFR: string = JSON.parse( + fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8") +)["name"]; + +async function getCommandContext(args: string[]): Promise { + const argv = yargsParser(args); + + const projectDirPath: string = (() => { + read_from_argv: { + const arg = argv["projectDir"]; + + if (arg === undefined) { + break read_from_argv; + } + + return getAbsoluteAndInOsFormatPath({ "pathIsh": arg, "cwd": process.cwd() }); + } + + return process.cwd(); + })(); + + special_case_for_our_storybook: { + const packageJsonFilePath = pathJoin(process.cwd(), "package.json"); + + if (!(await existsAsync(packageJsonFilePath))) { + break special_case_for_our_storybook; + } + + const packageJson = JSON.parse((await readFile(packageJsonFilePath)).toString("utf8")); + + if (packageJson["name"] !== CODEGOUV_REACT_DSFR) { + break special_case_for_our_storybook; + } + + // The storybook documents every component, there is nothing to trim. + return undefined; + } + + const nodeModulesDirPath = await (async function callee(n: number): Promise { + if (n >= projectDirPath.split(pathSep).length) { + throw new Error("Need to install node modules?"); + } + + const nodeModulesDirPath = pathJoin( + ...[projectDirPath, ...new Array(n).fill(".."), "node_modules"] + ); + + const doesExist = await existsAsync( + pathJoin(...[nodeModulesDirPath, ...CODEGOUV_REACT_DSFR.split("/")]) + ); + + if (!doesExist) { + return callee(n + 1); + } + + return nodeModulesDirPath; + })(0); + + const dsfrDirPath = pathJoin( + ...[nodeModulesDirPath, ...CODEGOUV_REACT_DSFR.split("/"), "dsfr"] + ); + + const dsfrDirPath_static = await (async () => { + const dsfrDirPath_static = pathJoin(await readPublicDirPath({ projectDirPath }), "dsfr"); + + if (!(await existsAsync(dsfrDirPath_static))) { + return undefined; + } + + return dsfrDirPath_static; + })(); + + const htmlFilePath = await (async () => { + if (dsfrDirPath_static === undefined) { + return undefined; + } + + vite: { + const filePath = pathJoin(projectDirPath, "index.html"); + + if (!fs.existsSync(filePath)) { + break vite; + } + + return filePath; + } + + cra: { + const filePath = pathJoin(pathDirname(dsfrDirPath_static), "index.html"); + + if (!fs.existsSync(filePath)) { + break cra; + } + + return filePath; + } + + // Next.js + return undefined; + })(); + + const isSilent = argv["silent"] === true; + + const srcFilePaths = ( + await Promise.all([ + crawl({ + "dirPath": projectDirPath, + "returnedPathsType": "absolute", + "getDoCrawlInDir": async ({ relativeDirPath }) => { + if (relativeDirPath === "dist") { + return false; + } + + if (relativeDirPath === "build") { + return false; + } + + if (pathBasename(relativeDirPath) === "node_modules") { + return false; + } + + if ( + await existsAsync(pathJoin(projectDirPath, relativeDirPath, "dsfr.min.css")) + ) { + // We don't want to search in public/dsfr + return false; + } + + if (pathBasename(relativeDirPath).startsWith(".")) { + return false; + } + + return true; + } + }), + crawl({ + "dirPath": nodeModulesDirPath, + "returnedPathsType": "absolute", + "getDoCrawlInDir": async ({ relativeDirPath }) => { + if ( + relativeDirPath.startsWith("@") && + relativeDirPath.split(pathSep).length === 1 + ) { + return true; + } + + if ( + relativeDirPath.split(pathSep).length === 1 || + (relativeDirPath.startsWith("@") && + relativeDirPath.split(pathSep).length === 2) + ) { + const parsedPackageJson = await readFile( + pathJoin(nodeModulesDirPath, relativeDirPath, "package.json") + ).then( + buff => JSON.parse(buff.toString("utf8")), + () => undefined + ); + + if (parsedPackageJson === undefined) { + return false; + } + + if (parsedPackageJson["name"] === CODEGOUV_REACT_DSFR) { + // Scanning react-dsfr's own sources would mark every component as used. + return false; + } + + if (parsedPackageJson["name"] === "tss-react") { + return false; + } + + if (parsedPackageJson["name"] === "@gouvfr/dsfr-chart") { + return false; + } + + if (parsedPackageJson["name"] === "@gouvfr/dsfr") { + return false; + } + + for (const packageName of [ + CODEGOUV_REACT_DSFR, + "@gouvfr/dsfr", + "@dataesr/react-dsfr" + ]) { + if ( + Object.keys({ + ...parsedPackageJson["dependencies"], + ...parsedPackageJson["devDependencies"], + ...parsedPackageJson["peerDependencies"] + }).includes(packageName) + ) { + return true; + } + } + + return false; + } + + if (pathBasename(relativeDirPath) === "generatedFromCss") { + return false; + } + + if (pathBasename(relativeDirPath) === "node_modules") { + return false; + } + + if (pathBasename(relativeDirPath).startsWith(".")) { + return false; + } + + return true; + } + }) + ]) + ) + .flat() + .filter( + filePath => + [ + "tsx", + "jsx", + "js", + "ts", + "mdx", + "html", + "htm", + "svelte", + "vue", + "css", + "scss", + "sass", + "less" + ].find(ext => filePath.endsWith(`.${ext}`)) !== undefined + ); + + return { + projectDirPath, + srcFilePaths, + dsfrDirPath, + "spaParams": (() => { + if (dsfrDirPath_static === undefined) { + return undefined; + } + + assert(htmlFilePath !== undefined); + + return { + dsfrDirPath_static, + htmlFilePath + }; + })(), + isSilent + }; +} + +export async function main(args: string[]) { + const commandContext = await getCommandContext(args); + + if (commandContext === undefined) { + return; + } + + const log = commandContext.isSilent ? undefined : console.log; + + const availableDsfrComponents = fs + .readdirSync(pathJoin(commandContext.dsfrDirPath, "component"), { "withFileTypes": true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(componentName => + ["main.min.css", "min.css", "main.css", "css"].some(ext => + fs.existsSync( + pathJoin( + commandContext.dsfrDirPath, + "component", + componentName, + `${componentName}.${ext}` + ) + ) + ) + ); + + const usedDsfrComponents = new Set(); + + let doIncludeAllComponents = false; + + await Promise.all( + commandContext.srcFilePaths.map(async srcFilePath => { + const rawFileContent = (await readFile(srcFilePath)).toString("utf8"); + + for (const moduleId of getReactDsfrImportedModuleIds({ rawFileContent })) { + const dsfrComponents = resolveModuleIdToDsfrComponents({ moduleId }); + + if (dsfrComponents === undefined) { + console.warn( + [ + `Unknown react-dsfr module "${moduleId}" imported in`, + `${pathRelative(process.cwd(), srcFilePath)},`, + `including every component's CSS to be safe.`, + `Please report it: https://github.com/codegouvfr/react-dsfr/issues` + ].join(" ") + ); + + doIncludeAllComponents = true; + + continue; + } + + if (dsfrComponents.length === 0) { + continue; + } + + log?.(`Found import of ${moduleId} in ${pathRelative(process.cwd(), srcFilePath)}`); + + dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + } + + for (const componentName of detectDsfrComponentsFromClassNames({ rawFileContent })) { + if (usedDsfrComponents.has(componentName)) { + continue; + } + + log?.( + `Found usage of ${componentName} classes in ${pathRelative( + process.cwd(), + srcFilePath + )}` + ); + + usedDsfrComponents.add(componentName); + } + }) + ); + + additional_components_from_package_json: { + const packageJsonFilePath = pathJoin(commandContext.projectDirPath, "package.json"); + + if (!(await existsAsync(packageJsonFilePath))) { + break additional_components_from_package_json; + } + + const additionalComponents: unknown = JSON.parse( + (await readFile(packageJsonFilePath)).toString("utf8") + )["react-dsfr"]?.["additionalComponents"]; + + if (additionalComponents === undefined) { + break additional_components_from_package_json; + } + + assert( + Array.isArray(additionalComponents) && + additionalComponents.every((value): value is string => typeof value === "string"), + 'Malformed "react-dsfr"."additionalComponents" in package.json, expected an array of strings' + ); + + for (const additionalComponent of additionalComponents) { + const dsfrComponents = + REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[additionalComponent] ?? + (availableDsfrComponents.includes(additionalComponent) + ? [additionalComponent] + : undefined); + + if (dsfrComponents === undefined) { + console.warn( + [ + `Unknown component "${additionalComponent}" in`, + `"react-dsfr"."additionalComponents" of your package.json,`, + `including every component's CSS to be safe.` + ].join(" ") + ); + + doIncludeAllComponents = true; + + continue; + } + + log?.(`Including ${additionalComponent} (from package.json additionalComponents)`); + + dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + } + } + + const dsfrComponents = doIncludeAllComponents + ? availableDsfrComponents + : availableDsfrComponents.filter(componentName => usedDsfrComponents.has(componentName)); + + log?.( + `Including the CSS of ${dsfrComponents.length} DSFR components (out of ${availableDsfrComponents.length}).` + ); + + const readDsfrFile = (fileRelativePath: string) => { + const filePath = pathJoin(commandContext.dsfrDirPath, ...fileRelativePath.split("/")); + + if (!fs.existsSync(filePath)) { + return undefined; + } + + return fs.readFileSync(filePath).toString("utf8"); + }; + + const rawDsfrCssCodeBuffer = Buffer.from( + generateDsfrCssCode({ + dsfrComponents, + "isMinified": false, + readDsfrFile + }), + "utf8" + ); + + const rawDsfrMinCssCodeBuffer = Buffer.from( + generateDsfrCssCode({ + dsfrComponents, + "isMinified": true, + readDsfrFile + }), + "utf8" + ); + + let hasChanged = false; + + await Promise.all( + [ + { + "dsfrDirPath": commandContext.dsfrDirPath, + "cssFileBasenames": ["dsfr.css", "dsfr.min.css"] as const + }, + ...(commandContext.spaParams === undefined + ? [] + : [ + { + "dsfrDirPath": commandContext.spaParams.dsfrDirPath_static, + // copy-dsfr-to-public only keeps the minified variant. + "cssFileBasenames": ["dsfr.min.css"] as const + } + ]) + ].map(async ({ dsfrDirPath, cssFileBasenames }) => + Promise.all( + cssFileBasenames.map(async cssFileBasename => { + const cssFilePath = pathJoin(dsfrDirPath, cssFileBasename); + + if (!(await existsAsync(cssFilePath))) { + return; + } + + const buffer = + cssFileBasename === "dsfr.min.css" + ? rawDsfrMinCssCodeBuffer + : rawDsfrCssCodeBuffer; + + if (Buffer.compare(await readFile(cssFilePath), buffer) === 0) { + return; + } + + hasChanged = true; + + await writeFile(cssFilePath, buffer); + }) + ) + ) + ); + + if (!hasChanged) { + log?.("No change since last run"); + return; + } + + await Promise.all([ + (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("dsfr.min.css")) { + return href; + } + + const [urlWithoutQuery] = href.split("?"); + + return `${urlWithoutQuery}?hash=${fnv1aHashToHex( + rawDsfrMinCssCodeBuffer.toString("utf8") + )}`; + } + }); + + await writeFile( + commandContext.spaParams.htmlFilePath, + Buffer.from(modifiedHtml, "utf8") + ); + })(), + (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) { + main(process.argv.slice(2)); +} diff --git a/src/bin/react-dsfr.ts b/src/bin/react-dsfr.ts index daa459595..78348603c 100644 --- a/src/bin/react-dsfr.ts +++ b/src/bin/react-dsfr.ts @@ -11,6 +11,13 @@ const [, , commandName, ...args] = process.argv; await main(args); } break; + case "only-include-used-components": + { + const { main } = await import("./only-include-used-components"); + + await main(args); + } + break; case "copy-static-assets": { const { main } = await import("./copy-dsfr-to-public"); From 6825f7ff9f2e1b5395da9c58166d5d1e44d7bdce Mon Sep 17 00:00:00 2001 From: kevbarns Date: Fri, 14 Aug 2026 14:59:21 +0200 Subject: [PATCH 2/7] Add tests for only-include-used-components Covers import detection, module to DSFR components resolution, raw class name detection and stylesheet generation (cascade order, url rewriting, charset stripping, Mui compat patch, determinism). --- ...detectDsfrComponentsFromClassNames.test.ts | 36 +++++ .../generateDsfrCssCode.test.ts | 132 ++++++++++++++++++ .../getReactDsfrImportedModuleIds.test.ts | 55 ++++++++ .../resolveModuleIdToDsfrComponents.test.ts | 48 +++++++ 4 files changed, 271 insertions(+) create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts new file mode 100644 index 000000000..42c99c0fc --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts @@ -0,0 +1,36 @@ +import { it, expect, describe } from "vitest"; +import { detectDsfrComponentsFromClassNames } from "../../../../src/bin/only-include-used-components"; + +describe("detectDsfrComponentsFromClassNames", () => { + it("detects raw usage of DSFR component classes", () => { + const rawFileContent = ` + export function MyTable() { + return
+ +
; + } + `; + + const detected = detectDsfrComponentsFromClassNames({ rawFileContent }); + + expect(detected).toContain("table"); + expect(detected).toContain("badge"); + expect(detected).not.toContain("header"); + }); + + it("detects modifier classes thanks to prefix matching", () => { + const detected = detectDsfrComponentsFromClassNames({ + "rawFileContent": `` + }); + + expect(detected).toStrictEqual(["button"]); + }); + + it("detects nothing in files without fr- classes", () => { + expect( + detectDsfrComponentsFromClassNames({ + "rawFileContent": `const theme = { "color": "blue" };` + }) + ).toStrictEqual([]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts new file mode 100644 index 000000000..5de44f532 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts @@ -0,0 +1,132 @@ +import { it, expect, describe } from "vitest"; +import { + generateDsfrCssCode, + rewriteCssRelativeUrls, + patchCoreCssCodeForCompatWithMui +} from "../../../../src/bin/only-include-used-components"; + +describe("rewriteCssRelativeUrls", () => { + it("rewrites urls relative to the css file into urls relative to the dsfr directory", () => { + expect( + rewriteCssRelativeUrls({ + "rawCssCode": `.fr-accordion__btn::after{-webkit-mask-image:url("../../icons/arrows/arrow-down-s-line.svg")}`, + "cssFileRelativeDirPath": "component/accordion" + }) + ).toBe( + `.fr-accordion__btn::after{-webkit-mask-image:url("icons/arrows/arrow-down-s-line.svg")}` + ); + + expect( + rewriteCssRelativeUrls({ + "rawCssCode": `@font-face{src:url("../fonts/Marianne-Regular.woff2") format("woff2")}`, + "cssFileRelativeDirPath": "core" + }) + ).toBe(`@font-face{src:url("fonts/Marianne-Regular.woff2") format("woff2")}`); + }); + + it("leaves data, http and absolute urls untouched", () => { + for (const url of [ + "data:image/svg+xml;base64,abc", + "https://example.com/a.svg", + "/a.svg" + ]) { + const rawCssCode = `.foo{background-image:url("${url}")}`; + + expect( + rewriteCssRelativeUrls({ + rawCssCode, + "cssFileRelativeDirPath": "component/header" + }) + ).toBe(rawCssCode); + } + }); +}); + +describe("patchCoreCssCodeForCompatWithMui", () => { + it("excludes Mui buttons from the DSFR hover and active rules", () => { + expect( + patchCoreCssCodeForCompatWithMui({ + "rawCssCode": + "@media (hover:hover) and (pointer:fine){a[href]:hover,button:not(:disabled):hover,input[type=button]:not(:disabled):hover{background-color:var(--hover-tint)}a[href]:active,button:not(:disabled):active{background-color:var(--active-tint)}}" + }) + ).toBe( + '@media (hover:hover) and (pointer:fine){a[href]:hover,button:not(:disabled):hover:not([class^="Mui"]),input[type=button]:not(:disabled):hover{background-color:var(--hover-tint)}a[href]:active,button:not(:disabled):active:not([class^="Mui"]){background-color:var(--active-tint)}}' + ); + }); +}); + +describe("generateDsfrCssCode", () => { + const fakeDsfrFiles: Record = { + "core/core.main.min.css": '@charset "UTF-8";.core{--x:url("../fonts/f.woff2")}', + "core/core.print.min.css": "@media print{.core-print{display:none}}", + "scheme/scheme.min.css": ":root[data-fr-theme=dark]{--grey:#161616}", + "component/button/button.main.min.css": ".fr-btn{color:red}", + "component/button/button.print.min.css": "@media print{.fr-btn{color:black}}", + "component/header/header.main.min.css": '.fr-header{background:url("../../icons/a.svg")}', + "component/header/header.print.min.css": "@media print{.fr-header{display:none}}", + // The download component only ships a non "main" variant. + "component/download/download.min.css": ".fr-download{color:blue}" + }; + + const readDsfrFile = (fileRelativePath: string) => fakeDsfrFiles[fileRelativePath]; + + it("always includes core and scheme, includes only requested components, preserves the cascade order", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["header", "button", "download"], + "isMinified": true, + readDsfrFile + }); + + // Cascade order is button < download < header regardless of the requested order. + const indexes = [ + ".core{", + ":root[data-fr-theme=dark]", + ".fr-btn{", + ".fr-download{", + ".fr-header{" + ].map(marker => generated.indexOf(marker)); + + expect(indexes.every(index => index !== -1)).toBe(true); + expect([...indexes].sort((a, b) => a - b)).toStrictEqual(indexes); + + // Print styles come after every main style. + expect(generated.indexOf(".core-print")).toBeGreaterThan(generated.indexOf(".fr-header{")); + expect(generated.indexOf("@media print{.fr-btn")).toBeGreaterThan( + generated.indexOf(".core-print") + ); + }); + + it("excludes the components that are not requested", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile + }); + + expect(generated).toContain(".fr-btn{"); + expect(generated).not.toContain(".fr-header{"); + expect(generated).not.toContain(".fr-download{"); + }); + + it("rewrites asset urls and strips @charset", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["header"], + "isMinified": true, + readDsfrFile + }); + + expect(generated).toContain('url("fonts/f.woff2")'); + expect(generated).toContain('url("icons/a.svg")'); + expect(generated).not.toContain("@charset"); + }); + + it("is deterministic, running it twice yields the same output", () => { + const params = { + "dsfrComponents": ["button", "header"], + "isMinified": true, + readDsfrFile + }; + + expect(generateDsfrCssCode(params)).toBe(generateDsfrCssCode(params)); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts new file mode 100644 index 000000000..8e0972cc6 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts @@ -0,0 +1,55 @@ +import { it, expect, describe } from "vitest"; +import { getReactDsfrImportedModuleIds } from "../../../../src/bin/only-include-used-components"; + +describe("getReactDsfrImportedModuleIds", () => { + it("detects default and named imports from a component subpath", () => { + const rawFileContent = ` + import { Button } from "@codegouvfr/react-dsfr/Button"; + import Badge from "@codegouvfr/react-dsfr/Badge"; + import { fr } from "@codegouvfr/react-dsfr"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "Badge", + "Button" + ]); + }); + + it("detects deep imports and normalizes them to their module", () => { + const rawFileContent = ` + import { useIsModalOpen } from "@codegouvfr/react-dsfr/Modal/useIsModalOpen"; + import { createModal } from "@codegouvfr/react-dsfr/Modal"; + const { Header } = await import("@codegouvfr/react-dsfr/Header/index"); + const x = require("@codegouvfr/react-dsfr/Tabs.js"); + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "Header", + "Modal", + "Tabs" + ]); + }); + + it("keeps two segments for blocks and three for dsfr asset paths", () => { + const rawFileContent = ` + import { PasswordInput } from "@codegouvfr/react-dsfr/blocks/PasswordInput"; + import "@codegouvfr/react-dsfr/dsfr/component/table/table.min.css"; + import "@codegouvfr/react-dsfr/dsfr/utility/colors/colors.min.css"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "blocks/PasswordInput", + "dsfr/component/table", + "dsfr/utility/colors" + ]); + }); + + it("returns no module for files that do not use react-dsfr", () => { + const rawFileContent = ` + import { useState } from "react"; + import { z } from "zod"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent })).toStrictEqual([]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts new file mode 100644 index 000000000..baafc5739 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts @@ -0,0 +1,48 @@ +import { it, expect, describe } from "vitest"; +import { + resolveModuleIdToDsfrComponents, + REACT_DSFR_MODULE_TO_DSFR_COMPONENTS, + DSFR_COMPONENTS_CASCADE_ORDER +} from "../../../../src/bin/only-include-used-components"; + +describe("resolveModuleIdToDsfrComponents", () => { + it("resolves a component module with its transitive dependencies", () => { + const dsfrComponents = resolveModuleIdToDsfrComponents({ "moduleId": "Header" }); + + expect(dsfrComponents).not.toBe(undefined); + + for (const expected of ["header", "navigation", "modal", "logo", "search"]) { + expect(dsfrComponents).toContain(expected); + } + }); + + it("resolves known non component modules to no component", () => { + for (const moduleId of ["fr", "i18n", "spa", "mui", "next-appdir", "useIsDark"]) { + expect(resolveModuleIdToDsfrComponents({ moduleId })).toStrictEqual([]); + } + }); + + it("resolves direct dsfr component stylesheet imports", () => { + expect( + resolveModuleIdToDsfrComponents({ "moduleId": "dsfr/component/table" }) + ).toStrictEqual(["table"]); + + expect( + resolveModuleIdToDsfrComponents({ "moduleId": "dsfr/utility/colors" }) + ).toStrictEqual([]); + }); + + it("returns undefined for unknown component looking modules", () => { + expect(resolveModuleIdToDsfrComponents({ "moduleId": "BrandNewComponent" })).toBe( + undefined + ); + }); + + it("only maps to components that exist in the cascade order", () => { + for (const dsfrComponents of Object.values(REACT_DSFR_MODULE_TO_DSFR_COMPONENTS)) { + for (const componentName of dsfrComponents) { + expect(DSFR_COMPONENTS_CASCADE_ORDER).toContain(componentName); + } + } + }); +}); From a789e8610316860e3d23217e984e2f8c9f9901db Mon Sep 17 00:00:00 2001 From: kevbarns Date: Fri, 14 Aug 2026 17:22:03 +0200 Subject: [PATCH 3/7] Fix silent fail-safe bypass in resolveModuleIdToDsfrComponents A direct import of an unrecognized dsfr/component/ stylesheet, and any unknown lowercase-starting react-dsfr module, returned [] instead of undefined. This silently skipped the "include every component" fail-safe and its warning for modules this script does not know about, instead of only affecting genuinely non-component modules. --- src/bin/only-include-used-components.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index 9ba2dc80f..9f5bf5f8c 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -307,7 +307,7 @@ export function resolveModuleIdToDsfrComponents(params: { componentName => componentName === match[1] ); - return dsfrComponent === undefined ? [] : [dsfrComponent]; + return dsfrComponent === undefined ? undefined : [dsfrComponent]; } if (moduleId.startsWith("dsfr/")) { @@ -336,12 +336,8 @@ export function resolveModuleIdToDsfrComponents(params: { return []; } - if (/^[a-z]/.test(firstSegment)) { - // Lowercase modules are utilities by convention in this repo. - return []; - } - - // A component this script does not know about (newer react-dsfr version?). + // A component this script does not know about (newer react-dsfr version?), + // or a new non-component module missing from NON_COMPONENT_MODULE_IDS. return undefined; } From 6d268d07e19b3b03ce98f8a7d883abdabb92e636 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 12:56:42 +0200 Subject: [PATCH 4/7] Address review of only-include-used-components Copy the assets referenced by the regenerated public/dsfr/dsfr.min.css. copy-dsfr-to-public builds its keep list from the url() of the dsfr.min.css it finds in node_modules, then early returns as long as public/dsfr/version.txt matches the @gouvfr/dsfr version. Once it had run against an already trimmed stylesheet, public/dsfr was frozen on that asset subset and growing the component set later could never bring the missing files back (blank burger, close, search and alert icons in production). The copy runs outside the `hasChanged` guard so a purged public/dsfr is repairable even when the CSS itself did not change. Stop triggering the include-everything fail-safe on non imports. Stylesheets are no longer scanned (class name detection is substring based, so a single compiled bundle in out/ or in a dependency marked every component as used) and module ids are now read from actual import specifiers only, instead of any textual occurrence: a link to https://www.npmjs.com/package/@codegouvfr/react-dsfr/v/1.32.5 in an .mdx used to resolve to the module "v" and silently ship the full bundle. Add --strict to exit 1 instead of falling back, for CI where the warning goes unnoticed and the run still looks like a success. Move `transcription` between `table` and `header` in DSFR_COMPONENTS_CASCADE_ORDER, and assert the whole array against the section order extracted from the `sources` of the installed dsfr.main.css.map so it can't drift on a DSFR bump. Map `link` and `shared` to the components they render (fr-link for the Link fallback, fr-fieldset/fr-label/fr-hint-text/fr-radio-rich for Fieldset) instead of resolving them to no component at all, which suppressed the fail-safe. Report `additionalComponents` entries that map to no DSFR stylesheet for what they are instead of confirming an inclusion that does not happen, warn on a typo in the `additionalComponents` key, and give the missing dsfr/component directory a proper message instead of a raw ENOENT. Document the three bin scripts in src/bin/README.md, including the ordering constraint with copy-static-assets. --- src/bin/README.md | 107 ++++++++ src/bin/only-include-used-components.ts | 234 +++++++++++++++--- .../dsfrComponentsCascadeOrder.test.ts | 73 ++++++ .../generateDsfrCssCode.test.ts | 35 ++- .../getReactDsfrImportedModuleIds.test.ts | 29 +++ .../resolveModuleIdToDsfrComponents.test.ts | 26 ++ 6 files changed, 468 insertions(+), 36 deletions(-) create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts diff --git a/src/bin/README.md b/src/bin/README.md index 695837ce5..0027ea7aa 100644 --- a/src/bin/README.md +++ b/src/bin/README.md @@ -1 +1,108 @@ Here are the scripts exposed as utility to the user of `react-dsfr` + +| Command | Standalone bin | What it does | +| --------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------- | +| `npx react-dsfr copy-static-assets` | `copy-dsfr-to-public` | Copies the DSFR assets into `public/dsfr` (SPA setups: Vite, CRA). | +| `npx react-dsfr update-icons` | `only-include-used-icons` | Rebuilds `dsfr/utility/icons/icons.min.css` with only the icons you use. | +| `npx react-dsfr only-include-used-components` | `only-include-used-components` | Rebuilds `dsfr/dsfr.css` and `dsfr/dsfr.min.css` with only the components you use. | + +Every script accepts `--projectDir ` to point at the react project (monorepos), +defaulting to the current working directory. `update-icons` and +`only-include-used-components` also accept `--silent` to disable the `console.log` +(warnings are never silenced). + +# `only-include-used-components` + +Opt-in. `dsfr.min.css` weighs ~600 kB raw / ~76 kB gzip and is loaded render-blocking, +while most apps use a small subset of the DSFR components. + +This script rebuilds `dsfr.css` and `dsfr.min.css` in `node_modules` (and +`public/dsfr/dsfr.min.css` in SPA setups) by concatenating the granular stylesheets +already shipped in the package (`dsfr/core/*`, `dsfr/scheme/*`, `dsfr/component//*`). +**Whole components are included or excluded, never individual rules**, so everything the +DSFR JavaScript toggles at runtime (`data-fr-js-*`, `fr-collapse--expanded`, ...) keeps +working — unlike a PurgeCSS style pass. + +## Usage + +```bash +npx react-dsfr only-include-used-components +``` + +Typically as a `prebuild`/`predev` step: + +```jsonc +"scripts": { + "predev": "react-dsfr update-icons && react-dsfr only-include-used-components", + "prebuild": "react-dsfr update-icons && react-dsfr only-include-used-components" +} +``` + +## Ordering with `copy-static-assets` + +In SPA setups (Vite, CRA), run `copy-static-assets` **before** +`only-include-used-components`, not after: + +```jsonc +"prebuild": "react-dsfr copy-static-assets && react-dsfr update-icons && react-dsfr only-include-used-components" +``` + +`copy-static-assets` builds its keep list from the `url()` of the `dsfr.min.css` it finds +in `node_modules`, then early returns on every later run as long as +`public/dsfr/version.txt` matches the `@gouvfr/dsfr` version. Running it against an +already trimmed stylesheet freezes `public/dsfr` on that asset subset. +`only-include-used-components` copies the assets its own output references, so a component +added later still gets its icons — but keeping the order above avoids relying on it. + +## Detection of used components + +1. **Imports** of `@codegouvfr/react-dsfr/` in your sources + (`.ts`, `.tsx`, `.js`, `.jsx`, `.mdx`, `.html`, `.svelte`, `.vue`), resolved through a + static table that includes transitive dependencies (a `Header` renders a navigation, a + search bar and a modal). +2. **Raw class names**, e.g. `fr.cx("fr-table")` or a plain `class="fr-table"`, for when + you use DSFR classes without the React component. + +Stylesheets (`.css`, `.scss`, ...) are **not** scanned: class name detection is substring +based, so a single compiled bundle would mark every component as used. Use +`additionalComponents` below for the components you only reference from a stylesheet. + +## `additionalComponents`, the escape hatch + +For anything the detection cannot see (class names built dynamically, CMS content, +components only referenced from a `@import`ed stylesheet), in your **`package.json`**: + +```jsonc +{ + "react-dsfr": { + "additionalComponents": ["table", "Range"] + } +} +``` + +Values are DSFR CSS component names (the `dsfr/component/` directories) or +react-dsfr component names. An unknown value is a hard warning, not a silent no-op. + +## Fail-safe and `--strict` + +If anything can't be resolved — typically a react-dsfr module added in a newer release that +this script does not know about — the script **warns and includes every component**. The +output is then equivalent to the original bundle: never a broken page, but no trimming +either, and the run still exits `0`. + +Because nobody reads warnings in CI, add `--strict` there to turn that fallback into a +failure: + +```bash +npx react-dsfr only-include-used-components --strict +``` + +Please [report](https://github.com/codegouvfr/react-dsfr/issues) any module that triggers +the fail-safe, the static tables need to be updated. + +## Known limitations + +- Detection is textual: a dynamically composed import path or class name is not seen. + That is what `additionalComponents` is for. +- `utility/colors` and `utility/icons` are not part of `dsfr.css` upstream and are left + untouched (icons are handled by `update-icons`). diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index 9f5bf5f8c..68392bcae 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -17,6 +17,9 @@ * - Usage of the component's CSS class names (e.g. "fr-table") in your sources, * for when you use raw DSFR classes without the React component. * + * Stylesheets (.css, .scss...) are not scanned: class name detection is substring based, + * so a single compiled bundle would mark every component as used. + * * You can force the inclusion of components that the detection would miss by adding * to your package.json: * "react-dsfr": { @@ -24,10 +27,13 @@ * } * (values are DSFR CSS component names or react-dsfr component names) * - * There are two optional arguments that you can use: + * There are three optional arguments that you can use: * - `--projectDir ` to specify the project directory. Default to the current working directory. * This can be used in monorepos to specify the react project directory. * - `--silent` to disable console.log + * - `--strict` to exit with a non zero code instead of falling back to the untrimmed + * stylesheet when something can't be resolved. Recommended in CI, where the warning + * would otherwise go unnoticed and the build would silently ship the full bundle. */ import { getProjectRoot } from "./tools/getProjectRoot"; @@ -35,7 +41,7 @@ import * as fs from "fs"; import { join as pathJoin, relative as pathRelative } from "path"; import { assert } from "tsafe/assert"; import { exclude } from "tsafe/exclude"; -import { writeFile, readFile, rm } from "fs/promises"; +import { writeFile, readFile, rm, cp } from "fs/promises"; import { crawl } from "./tools/crawl"; import { basename as pathBasename, sep as pathSep, dirname as pathDirname } from "path"; import yargsParser from "yargs-parser"; @@ -49,7 +55,13 @@ import { modifyHtmlHrefs } from "./tools/modifyHtmlHrefs"; * The DSFR CSS components (dsfr/component/ directories), listed in the * order in which they are concatenated in the upstream dsfr.css bundle. * Preserving this order preserves the CSS cascade of the original stylesheet. - * (Order determined empirically by locating each component's section in dsfr.main.css) + * + * The order is the order of first occurrence of each `component//main.scss` + * in the `sources` of `@gouvfr/dsfr/dist/dsfr.main.css.map`. It is asserted against + * the installed DSFR in test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts + * so that it can't silently drift on a DSFR bump. + * `radio` is the only component that has no `main.scss` upstream, its position is + * taken from the first occurrence of any of its stylesheets (between notice and card). */ export const DSFR_COMPONENTS_CASCADE_ORDER = [ "upload", @@ -80,7 +92,6 @@ export const DSFR_COMPONENTS_CASCADE_ORDER = [ "checkbox", "input", "content", - "transcription", "segmented", "toggle", "skiplink", @@ -96,6 +107,7 @@ export const DSFR_COMPONENTS_CASCADE_ORDER = [ "password", "translate", "table", + "transcription", "header" ] as const; @@ -169,7 +181,12 @@ export const REACT_DSFR_MODULE_TO_DSFR_COMPONENTS: Record([ "start", "tss", "mui", - "link", "picto", - "shared", "tools", "assets", "favicon", @@ -257,14 +272,47 @@ const NON_COMPONENT_MODULE_IDS = new Set([ "zz_internal" ]); +/** + * Regexes matching the specifier of an actual import statement. + * Matching any textual occurrence of "@codegouvfr/react-dsfr/..." instead would + * pick up urls and comments (a link to https://www.npmjs.com/package/@codegouvfr/react-dsfr/v/1.32.5 + * would resolve to the unknown module "v" and trigger the include-everything fail-safe). + */ +const IMPORT_SPECIFIER_REGEXES = [ + // import X from "..." / export * from "..." + /\bfrom\s*["'`]([^"'`\n]+)["'`]/g, + // import "..." / import("...") + /\bimport\s*\(?\s*["'`]([^"'`\n]+)["'`]/g, + // require("...") + /\brequire\s*\(\s*["'`]([^"'`\n]+)["'`]/g, + // @import "..." / @import url("...") + /@import\s+(?:url\(\s*)?["'`]([^"'`\n]+)["'`]/g +]; + +const REACT_DSFR_PACKAGE_NAME = "@codegouvfr/react-dsfr"; + export function getReactDsfrImportedModuleIds(params: { rawFileContent: string }): string[] { const { rawFileContent } = params; const moduleIds = new Set(); - for (const [, subpath] of rawFileContent.matchAll( - /@codegouvfr\/react-dsfr\/([\w@.-]+(?:\/[\w@.-]+)*)/g - )) { + if (!rawFileContent.includes(REACT_DSFR_PACKAGE_NAME)) { + return []; + } + + const importSpecifiers = new Set( + IMPORT_SPECIFIER_REGEXES.map(regex => + Array.from(rawFileContent.matchAll(regex), ([, specifier]) => specifier) + ).flat() + ); + + for (const importSpecifier of importSpecifiers) { + if (!importSpecifier.startsWith(`${REACT_DSFR_PACKAGE_NAME}/`)) { + continue; + } + + const subpath = importSpecifier.slice(`${REACT_DSFR_PACKAGE_NAME}/`.length); + const segments = subpath .replace(/\.(?:js|mjs|cjs|ts|tsx|jsx)$/, "") .split("/") @@ -388,6 +436,33 @@ export function rewriteCssRelativeUrls(params: { }); } +/** + * The assets (fonts, icons, artwork...) referenced by a stylesheet whose url() + * have already been rewritten relative to the dsfr directory root by + * rewriteCssRelativeUrls(). Absolute and data: urls are skipped. + */ +export function getReferencedAssetRelativePaths(params: { rawCssCode: string }): string[] { + const { rawCssCode } = params; + + const assetRelativePaths = new Set(); + + for (const [, , url] of rawCssCode.matchAll(/url\((["']?)([^)"']+)\1\)/g)) { + if (/^(?:data:|https?:|\/)/.test(url)) { + continue; + } + + const [urlWithoutQuery] = url.split(/[?#]/); + + if (urlWithoutQuery === "") { + continue; + } + + assetRelativePaths.add(urlWithoutQuery); + } + + return Array.from(assetRelativePaths); +} + /** * String level equivalent of scripts/build/patchCssForMui.ts, the selectors * `button:not(:disabled):hover` and `button:not(:disabled):active` only exist @@ -520,6 +595,7 @@ type CommandContext = { } | undefined; isSilent: boolean; + isStrict: boolean; }; const CODEGOUV_REACT_DSFR: string = JSON.parse( @@ -624,6 +700,7 @@ async function getCommandContext(args: string[]): Promise - [ - "tsx", - "jsx", - "js", - "ts", - "mdx", - "html", - "htm", - "svelte", - "vue", - "css", - "scss", - "sass", - "less" - ].find(ext => filePath.endsWith(`.${ext}`)) !== undefined + // NOTE: Stylesheets are deliberately not scanned: detectDsfrComponentsFromClassNames() + // does substring matching, so a single compiled bundle (a leftover out/, a + // dependency shipping the DSFR) would mark every component as used. + // Use "react-dsfr"."additionalComponents" in your package.json for the + // components you only reference from a stylesheet. + ["tsx", "jsx", "js", "ts", "mdx", "html", "htm", "svelte", "vue"].find(ext => + filePath.endsWith(`.${ext}`) + ) !== undefined ); return { @@ -773,7 +843,8 @@ async function getCommandContext(args: string[]): Promise dirent.isDirectory()) .map(dirent => dirent.name) .filter(componentName => @@ -815,11 +898,13 @@ export async function main(args: string[]) { const dsfrComponents = resolveModuleIdToDsfrComponents({ moduleId }); if (dsfrComponents === undefined) { + // NOTE: Deliberately not routed through log?.(), --silent must not hide + // the fact that the optimization has been disabled for this run. console.warn( [ - `Unknown react-dsfr module "${moduleId}" imported in`, - `${pathRelative(process.cwd(), srcFilePath)},`, - `including every component's CSS to be safe.`, + `[react-dsfr] Unknown react-dsfr module "${moduleId}" imported in`, + `${pathRelative(process.cwd(), srcFilePath)}:`, + `no CSS is trimmed at all for this run, every component is included.`, `Please report it: https://github.com/codegouvfr/react-dsfr/issues` ].join(" ") ); @@ -862,11 +947,27 @@ export async function main(args: string[]) { break additional_components_from_package_json; } - const additionalComponents: unknown = JSON.parse( + const reactDsfrConfig: unknown = JSON.parse( (await readFile(packageJsonFilePath)).toString("utf8") - )["react-dsfr"]?.["additionalComponents"]; + )["react-dsfr"]; + + const additionalComponents: unknown = + reactDsfrConfig === null || typeof reactDsfrConfig !== "object" + ? undefined + : (reactDsfrConfig as Record)["additionalComponents"]; if (additionalComponents === undefined) { + if (reactDsfrConfig !== null && typeof reactDsfrConfig === "object") { + // A typo in the key would otherwise silently disable the escape hatch. + console.warn( + [ + `[react-dsfr] The "react-dsfr" entry of your package.json has no`, + `"additionalComponents" key, is it a typo? Found:`, + `${Object.keys(reactDsfrConfig as Record).join(", ")}` + ].join(" ") + ); + } + break additional_components_from_package_json; } @@ -886,9 +987,9 @@ export async function main(args: string[]) { if (dsfrComponents === undefined) { console.warn( [ - `Unknown component "${additionalComponent}" in`, - `"react-dsfr"."additionalComponents" of your package.json,`, - `including every component's CSS to be safe.` + `[react-dsfr] Unknown component "${additionalComponent}" in`, + `"react-dsfr"."additionalComponents" of your package.json:`, + `no CSS is trimmed at all for this run, every component is included.` ].join(" ") ); @@ -897,12 +998,43 @@ export async function main(args: string[]) { continue; } + if (dsfrComponents.length === 0) { + // Reporting "Including " here would be the exact opposite of what happens, + // and this escape hatch is precisely what one reaches for when something is unstyled. + console.warn( + [ + `[react-dsfr] "${additionalComponent}" of`, + `"react-dsfr"."additionalComponents" maps to no DSFR stylesheet,`, + `nothing was added.`, + ...(additionalComponent === "Chart" + ? [`The Chart CSS comes from the @gouvfr/dsfr-chart package.`] + : []) + ].join(" ") + ); + + continue; + } + log?.(`Including ${additionalComponent} (from package.json additionalComponents)`); dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); } } + if (doIncludeAllComponents && commandContext.isStrict) { + console.error( + [ + `[react-dsfr] Aborting because of --strict:`, + `something could not be resolved (see the warning(s) above),`, + `so this run would have shipped the untrimmed dsfr.min.css.`, + `Fix the cause or add the component to "react-dsfr"."additionalComponents"`, + `in your package.json.` + ].join(" ") + ); + + process.exit(1); + } + const dsfrComponents = doIncludeAllComponents ? availableDsfrComponents : availableDsfrComponents.filter(componentName => usedDsfrComponents.has(componentName)); @@ -982,6 +1114,38 @@ export async function main(args: string[]) { ) ); + // NOTE: Deliberately outside of the `hasChanged` guard below. + // copy-dsfr-to-public builds its keep list from the url() of the dsfr.min.css it + // finds in node_modules, then early returns on every later run as long as + // public/dsfr/version.txt matches the @gouvfr/dsfr version. So once it has run + // against an already trimmed stylesheet, public/dsfr is frozen on that asset subset + // and growing the component set later can never bring the missing files back. + // Copying them ourselves also makes a purged public/dsfr repairable when the CSS + // itself did not change. + await (async function copyUsedDsfrAssetsToStatic() { + if (commandContext.spaParams === undefined) { + return; + } + + const { dsfrDirPath_static } = commandContext.spaParams; + + await Promise.all( + getReferencedAssetRelativePaths({ + "rawCssCode": rawDsfrMinCssCodeBuffer.toString("utf8") + }).map(async assetRelativePath => { + const pathSegments = assetRelativePath.split("/"); + + const srcFilePath = pathJoin(commandContext.dsfrDirPath, ...pathSegments); + + if (!(await existsAsync(srcFilePath))) { + return; + } + + await cp(srcFilePath, pathJoin(dsfrDirPath_static, ...pathSegments)); + }) + ); + })(); + if (!hasChanged) { log?.("No change since last run"); return; diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts new file mode 100644 index 000000000..8d4232b42 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts @@ -0,0 +1,73 @@ +import { it, expect, describe } from "vitest"; +import * as fs from "fs"; +import { join as pathJoin } from "path"; +import { DSFR_COMPONENTS_CASCADE_ORDER } from "../../../../src/bin/only-include-used-components"; + +/** + * DSFR_COMPONENTS_CASCADE_ORDER only has a reason to exist if it matches the order in + * which the upstream bundle concatenates the components. There is no section banner in + * dsfr.main.css to read it from, but its source map lists the scss files in emission + * order, so the order can be extracted and asserted against the installed DSFR. + * This test is what makes a silent drift on a @gouvfr/dsfr bump impossible. + */ +describe("DSFR_COMPONENTS_CASCADE_ORDER", () => { + const sourceMapFilePath = pathJoin( + process.cwd(), + "node_modules", + "@gouvfr", + "dsfr", + "dist", + "dsfr.main.css.map" + ); + + const getUpstreamCascadeOrder = (): string[] => { + const { sources }: { sources: string[] } = JSON.parse( + fs.readFileSync(sourceMapFilePath).toString("utf8") + ); + + const sectionIndexByComponentName = new Map(); + + sources.forEach((source, index) => { + const match = source.match(/\/component\/([^/]+)\/(.*)$/); + + if (match === null) { + return; + } + + const [, componentName, sourceRelativePath] = match; + + // `main.scss` is the component's entry point, the position of its section. + // `radio` is the only component that has none upstream, fall back to the + // first occurrence of any of its stylesheets. + const isSectionEntryPoint = sourceRelativePath === "main.scss"; + + if (sectionIndexByComponentName.has(componentName) && !isSectionEntryPoint) { + return; + } + + if ( + isSectionEntryPoint && + sources[sectionIndexByComponentName.get(componentName) ?? index]?.endsWith( + "main.scss" + ) + ) { + return; + } + + sectionIndexByComponentName.set(componentName, index); + }); + + return Array.from(sectionIndexByComponentName.entries()) + .sort(([, indexA], [, indexB]) => indexA - indexB) + .map(([componentName]) => componentName); + }; + + it("matches the section order of the installed @gouvfr/dsfr", () => { + if (!fs.existsSync(sourceMapFilePath)) { + console.warn(`${sourceMapFilePath} not found, skipping the cascade order assertion.`); + return; + } + + expect([...DSFR_COMPONENTS_CASCADE_ORDER]).toStrictEqual(getUpstreamCascadeOrder()); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts index 5de44f532..6dd291cf2 100644 --- a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts +++ b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts @@ -2,7 +2,8 @@ import { it, expect, describe } from "vitest"; import { generateDsfrCssCode, rewriteCssRelativeUrls, - patchCoreCssCodeForCompatWithMui + patchCoreCssCodeForCompatWithMui, + getReferencedAssetRelativePaths } from "../../../../src/bin/only-include-used-components"; describe("rewriteCssRelativeUrls", () => { @@ -130,3 +131,35 @@ describe("generateDsfrCssCode", () => { expect(generateDsfrCssCode(params)).toBe(generateDsfrCssCode(params)); }); }); + +describe("getReferencedAssetRelativePaths", () => { + it("collects the local assets referenced by the generated stylesheet", () => { + expect( + getReferencedAssetRelativePaths({ + "rawCssCode": [ + `@font-face{src:url("fonts/Marianne-Regular.woff2") format("woff2")}`, + `.fr-header__menu{-webkit-mask-image:url(icons/system/menu-fill.svg)}`, + `.fr-btn--close{mask-image:url('icons/system/close-line.svg')}` + ].join("") + }).sort() + ).toStrictEqual([ + "fonts/Marianne-Regular.woff2", + "icons/system/close-line.svg", + "icons/system/menu-fill.svg" + ]); + }); + + it("deduplicates, strips query strings and skips data, http and absolute urls", () => { + expect( + getReferencedAssetRelativePaths({ + "rawCssCode": [ + `.a{background-image:url("icons/a.svg?v=1")}`, + `.b{background-image:url("icons/a.svg")}`, + `.c{background-image:url("data:image/svg+xml;base64,abc")}`, + `.d{background-image:url("https://example.com/b.svg")}`, + `.e{background-image:url("/c.svg")}` + ].join("") + }) + ).toStrictEqual(["icons/a.svg"]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts index 8e0972cc6..3f3cfa741 100644 --- a/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts +++ b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts @@ -53,3 +53,32 @@ describe("getReactDsfrImportedModuleIds", () => { expect(getReactDsfrImportedModuleIds({ rawFileContent })).toStrictEqual([]); }); }); + +describe("getReactDsfrImportedModuleIds, non import occurrences", () => { + it("ignores urls and comments that merely mention the package", () => { + const rawFileContent = ` + // see https://www.npmjs.com/package/@codegouvfr/react-dsfr/v/1.32.5 + + /* @codegouvfr/react-dsfr/Header is not imported here */ + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent })).toStrictEqual([]); + }); + + it("still detects the import when it sits next to a mention", () => { + const rawFileContent = ` + // https://www.npmjs.com/package/@codegouvfr/react-dsfr/v/1.32.5 + import { Button } from "@codegouvfr/react-dsfr/Button"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent })).toStrictEqual(["Button"]); + }); + + it("detects @import of a stylesheet", () => { + expect( + getReactDsfrImportedModuleIds({ + "rawFileContent": `@import "@codegouvfr/react-dsfr/dsfr/component/table/table.min.css";` + }) + ).toStrictEqual(["dsfr/component/table"]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts index baafc5739..e8cd01a40 100644 --- a/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts +++ b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts @@ -46,3 +46,29 @@ describe("resolveModuleIdToDsfrComponents", () => { } }); }); + +describe("resolveModuleIdToDsfrComponents, modules rendering DSFR markup", () => { + it("resolves the modules that render DSFR markup without being components", () => { + // src/link.tsx renders a `fr-link` button when the Link is a button. + expect(resolveModuleIdToDsfrComponents({ "moduleId": "link" })).toStrictEqual(["link"]); + + // src/shared/Fieldset.tsx renders fr-fieldset, fr-label, fr-hint-text and fr-radio-rich. + const dsfrComponents = resolveModuleIdToDsfrComponents({ "moduleId": "shared" }); + + for (const expected of ["form", "radio", "checkbox"]) { + expect(dsfrComponents).toContain(expected); + } + }); + + it("returns undefined for an unknown dsfr component stylesheet", () => { + expect(resolveModuleIdToDsfrComponents({ "moduleId": "dsfr/component/brandNew" })).toBe( + undefined + ); + }); + + it("returns undefined for an unknown lowercase module", () => { + for (const moduleId of ["v", "dist"]) { + expect(resolveModuleIdToDsfrComponents({ moduleId })).toBe(undefined); + } + }); +}); From 287c641687a87c2c725747784418eb9475a72307 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 13:23:30 +0200 Subject: [PATCH 5/7] Address the two follow-up notes on the cascade order test The section banners do exist in dsfr.main.css, my test docblock claimed otherwise. They are however a defective index, which is the real reason to prefer the source map: 44 banners for 45 components, `badge`, `consent`, `notice` and `radio` have none, and `notice` is labelled ALERT so the list contains `alert` twice. The map only misses `radio`. Docblock corrected to say that instead. Fail the cascade order test when dsfr.main.css.map is missing, instead of warning and reporting success. @gouvfr/dsfr is a direct dependency of this repo so there is no legitimate skip case, and a green skip would let the guard silently evaporate on a future DSFR that stops shipping source maps. Also apply here the fix sent for only-include-used-icons in #506, since this file does not exist on main yet: `spaParams.htmlFilePath` becomes optional so a public/dsfr without a findable index.html no longer throws a message-less AssertionError, and the cache busting rewrite moves out of the `hasChanged` early return so a stale or hand reverted hash is repairable. --- src/bin/only-include-used-components.ts | 67 ++++++++++--------- .../dsfrComponentsCascadeOrder.test.ts | 20 +++--- 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index 68392bcae..3f537b974 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -591,7 +591,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; @@ -836,8 +838,6 @@ async function getCommandContext(args: string[]): Promise { - if (!href.includes("dsfr.min.css")) { - return href; - } + const { modifiedHtml } = modifyHtmlHrefs({ + "html": html, + "getModifiedHref": href => { + if (!href.includes("dsfr.min.css")) { + return href; + } - const [urlWithoutQuery] = href.split("?"); + const [urlWithoutQuery] = href.split("?"); - return `${urlWithoutQuery}?hash=${fnv1aHashToHex( - rawDsfrMinCssCodeBuffer.toString("utf8") - )}`; - } - }); + return `${urlWithoutQuery}?hash=${fnv1aHashToHex( + rawDsfrMinCssCodeBuffer.toString("utf8") + )}`; + } + }); - await writeFile( - commandContext.spaParams.htmlFilePath, - Buffer.from(modifiedHtml, "utf8") - ); - })(), + if (modifiedHtml === html) { + return; + } + + await writeFile(htmlFilePath, Buffer.from(modifiedHtml, "utf8")); + })(); + + if (!hasChanged) { + log?.("No change since last run"); + return; + } + + await Promise.all([ (async function clearCache() { await Promise.all( [ diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts index 8d4232b42..aeae6a4e9 100644 --- a/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts +++ b/test/runtime/scripts/onlyIncludeUsedComponents/dsfrComponentsCascadeOrder.test.ts @@ -5,10 +5,14 @@ import { DSFR_COMPONENTS_CASCADE_ORDER } from "../../../../src/bin/only-include- /** * DSFR_COMPONENTS_CASCADE_ORDER only has a reason to exist if it matches the order in - * which the upstream bundle concatenates the components. There is no section banner in - * dsfr.main.css to read it from, but its source map lists the scss files in emission - * order, so the order can be extracted and asserted against the installed DSFR. - * This test is what makes a silent drift on a @gouvfr/dsfr bump impossible. + * which the upstream bundle concatenates the components. This test is what makes a + * silent drift on a @gouvfr/dsfr bump impossible. + * + * dsfr.main.css does carry section banners (`/* ¯¯¯ *\ NAME \* ˍˍˍ *\/`), but they are a + * defective index: 44 of them for 45 components, `badge`, `consent`, `notice` and `radio` + * have none, and `notice` is labelled ALERT so the list contains `alert` twice. + * The source map is read instead: its `sources` list the scss files in emission order, + * and only `radio` has no `main.scss` entry point to key on. */ describe("DSFR_COMPONENTS_CASCADE_ORDER", () => { const sourceMapFilePath = pathJoin( @@ -63,10 +67,10 @@ describe("DSFR_COMPONENTS_CASCADE_ORDER", () => { }; it("matches the section order of the installed @gouvfr/dsfr", () => { - if (!fs.existsSync(sourceMapFilePath)) { - console.warn(`${sourceMapFilePath} not found, skipping the cascade order assertion.`); - return; - } + // Deliberately an assertion and not a skip: @gouvfr/dsfr is a direct dependency of + // this repo, so there is no legitimate case where the map is absent. Skipping would + // let the guard silently evaporate on a future DSFR that stops shipping source maps. + expect(fs.existsSync(sourceMapFilePath), `${sourceMapFilePath} not found`).toBe(true); expect([...DSFR_COMPONENTS_CASCADE_ORDER]).toStrictEqual(getUpstreamCascadeOrder()); }); From 8ac9a5691a0e07f0ecc22d79d5aeb9d5fc77ccb8 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 14:19:57 +0200 Subject: [PATCH 6/7] Correct the htmlFilePath comment Next.js has no `public/dsfr` in the documented setup, `next-appdir` and `next-pagesdir` run the trimming scripts without `copy-dsfr-to-public`, so `spaParams` is undefined there and this field is never reached. The real trigger is any project with a `public/dsfr` and no `index.html`. Same correction as on fix/skip-index-html-when-absent, the comment was copied from there. --- src/bin/only-include-used-components.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index 3f537b974..d664b5d18 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -591,8 +591,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; From a84376f413855492b3ef312aa9a229b087114856 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Wed, 19 Aug 2026 14:24:57 +0200 Subject: [PATCH 7/7] 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-components.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index d664b5d18..fc810c2e7 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -592,9 +592,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;