From 7828c77f70867316103dfd4055cb1391d7b766c6 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Fri, 21 Aug 2026 16:41:30 +0200 Subject: [PATCH 1/2] feat(core): only-include-used-components: opt-in trimming of the spacing utility grid Closes #508 With component trimming in place (#505), the floor of the stylesheet is the core, of which ~78 kB raw (~11.5 kB gzip) is the exhaustive fr-m*/fr-p* spacing grid (1215 rules, 2457 classes in dsfr 1.14.2). These classes are never toggled by the DSFR runtime, so they can be trimmed per rule. Since this is the script's first rule level surgery, it ships behind a separate opt-in flag, --trim-spacing-utilities, and the risky part happens at react-dsfr build time: generateSpacingUtilitiesManifest cross-checks the string level extractor against a real CSS parser (a devDependency) on the exact files that get published, and fingerprints them in a generated dsfr/core/spacing-utilities.json. At run time, trimming only happens when the core file's hash matches the manifest; on any mismatch the stylesheet ships untrimmed, with a never-silenced warning (non zero exit in --strict). Used utilities are detected as literal class names in the already crawled sources, plus the build time derived list of utilities react-dsfr's own components render (the crawl excludes the package). Dynamically constructed class names are detected by their static prefix and every utility the prefix can produce is kept: `fr-m${x}` keeps the margins but trims the paddings, `fr-icon-${x}` triggers nothing. The escape hatch is "react-dsfr"."additionalSpacingUtilities" (["fr-mt-2w", "fr-mb-*"]), where a * suffix declares a prefix and acknowledges the matching dynamic construction under --strict. Measured on a Vite-shaped app using Button and two utilities: dsfr.min.css goes from 231.9 kB raw / 31.8 kB gzip to 154.4 kB / 20.4 kB. Without the flag the output is byte identical to the previous release. --- scripts/build/build.ts | 53 ++ .../build/generateSpacingUtilitiesManifest.ts | 211 ++++++++ src/bin/README.md | 50 ++ src/bin/only-include-used-components.ts | 510 +++++++++++++++--- src/bin/trimSpacingUtilities.ts | 335 ++++++++++++ .../detectDynamicSpacingClassPrefixes.test.ts | 118 ++++ .../detectUsedSpacingTokens.test.ts | 98 ++++ .../extractSpacingCssRules.test.ts | 124 +++++ .../generateDsfrCssCode.test.ts | 151 +++++- .../spacingUtilitiesManifest.test.ts | 112 ++++ .../trimSpacingUtilitiesFromCoreCss.test.ts | 190 +++++++ 11 files changed, 1881 insertions(+), 71 deletions(-) create mode 100644 scripts/build/generateSpacingUtilitiesManifest.ts create mode 100644 src/bin/trimSpacingUtilities.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/detectDynamicSpacingClassPrefixes.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/detectUsedSpacingTokens.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/extractSpacingCssRules.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/spacingUtilitiesManifest.test.ts create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/trimSpacingUtilitiesFromCoreCss.test.ts diff --git a/scripts/build/build.ts b/scripts/build/build.ts index 97c8291a3..993ce25bf 100644 --- a/scripts/build/build.ts +++ b/scripts/build/build.ts @@ -11,6 +11,8 @@ import { import * as child_process from "child_process"; import { patchCssForMui } from "./patchCssForMui"; import yargsParser from "yargs-parser"; +import { generateSpacingUtilitiesManifest } from "./generateSpacingUtilitiesManifest"; +import { PATH_OF_SPACING_UTILITIES_JSON } from "../../src/bin/trimSpacingUtilities"; function removeCharset(rawCssCode: string): string { return rawCssCode.replace(/@charset "UTF-8";\s*/g, ""); @@ -102,6 +104,57 @@ function removeCharset(rawCssCode: string): string { Buffer.from(JSON.stringify(icons, null, 2), "utf8") ); + { + const reactDsfrSrcFilesContents: string[] = []; + + (function walk(dirPath: string) { + for (const dirent of fs.readdirSync(dirPath, { "withFileTypes": true })) { + const path = pathJoin(dirPath, dirent.name); + + if (dirent.isDirectory()) { + if (dirent.name === "generatedFromCss" || dirent.name === "bin") { + // generatedFromCss lists every fr-* class, bin renders no markup + // and its doc comments cite spacing classes as examples. + continue; + } + walk(path); + continue; + } + + if (!/\.tsx?$/.test(dirent.name)) { + continue; + } + + reactDsfrSrcFilesContents.push(fs.readFileSync(path).toString("utf8")); + } + })(pathJoin(projectRootDirPath, "src")); + + fs.writeFileSync( + pathJoin(dsfrDirPath, PATH_OF_SPACING_UTILITIES_JSON), + Buffer.from( + JSON.stringify( + generateSpacingUtilitiesManifest({ + "readDsfrDistFile": fileRelativePath => + fs + .readFileSync(pathJoin(dsfrDirPath, ...fileRelativePath.split("/"))) + .toString("utf8"), + "dsfrVersion": JSON.parse( + fs + .readFileSync( + pathJoin(nodeModuleDirPath, "@gouvfr", "dsfr", "package.json") + ) + .toString("utf8") + )["version"], + reactDsfrSrcFilesContents + }), + null, + 2 + ), + "utf8" + ) + ); + } + const distDirPath = pathJoin(projectRootDirPath, "dist"); if (fs.existsSync(distDirPath)) { diff --git a/scripts/build/generateSpacingUtilitiesManifest.ts b/scripts/build/generateSpacingUtilitiesManifest.ts new file mode 100644 index 000000000..e74c958a8 --- /dev/null +++ b/scripts/build/generateSpacingUtilitiesManifest.ts @@ -0,0 +1,211 @@ +import { assert } from "tsafe/assert"; +import { parseCss } from "./parseCss"; +import { + SPACING_UTILITY_CLASS_REGEX, + extractSpacingCssRules, + detectUsedSpacingTokens, + type SpacingUtilitiesManifest +} from "../../src/bin/trimSpacingUtilities"; +import { fnv1aHashToHex } from "../../src/bin/tools/fnv1aHashToHex"; +import type { Rule as CssRule, Media as CssMedia } from "css"; + +/** + * The core stylesheet variants that generateDsfrCssCode may pick + * (the candidates of its readCssChunks, minified and not, "main" and legacy). + */ +export const SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS = [ + "core/core.main.min.css", + "core/core.min.css", + "core/core.main.css", + "core/core.css" +] as const; + +/** + * A wider net than SPACING_UTILITY_CLASS_REGEX: anything that even vaguely + * looks like a spacing utility selector. Every selector caught by this net + * must also match the strict regex, otherwise the DSFR introduced a spacing + * class shape the runtime grammar does not know, and trimming would wrongly + * keep it forever (or worse, half of a family). Build fails instead. + */ +const LOOKS_LIKE_SPACING_SELECTOR_REGEX = /^\.fr-[mp][a-z]?-/; + +/** + * Generates dsfr/core/spacing-utilities.json, and more importantly proves, + * with a real CSS parser (the css package, a devDependency unavailable at + * runtime), that the string level extractSpacingCssRules() is exact on the + * exact files that get published. Every assert here is a build failure: + * a @gouvfr/dsfr bump that breaks any assumption cannot ship silently. + */ +export function generateSpacingUtilitiesManifest(params: { + /** Returns the raw content of a file of the @gouvfr/dsfr dist copy (the dsfr/ directory) */ + readDsfrDistFile: (fileRelativePath: string) => string; + dsfrVersion: string; + /** + * Contents of react-dsfr's own runtime src/ files. The caller must exclude + * src/fr/generatedFromCss (it lists every fr-* class) and src/bin (the CLI + * scripts render no markup, and their doc comments cite spacing classes as + * examples, which detectUsedSpacingTokens would count as usages). + */ + reactDsfrSrcFilesContents: string[]; +}): SpacingUtilitiesManifest { + const { readDsfrDistFile, dsfrVersion, reactDsfrSrcFilesContents } = params; + + const coreFiles: SpacingUtilitiesManifest["coreFiles"] = {}; + + let referenceTokens: Set | undefined = undefined; + + for (const fileRelativePath of SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS) { + const rawCssCode = readDsfrDistFile(fileRelativePath); + + // Parser side: the ground truth. + const parserSelectorLists: string[] = []; + + { + const walkRules = (rules: (CssRule | CssMedia)[]) => { + for (const rule of rules) { + if (rule.type === "media") { + walkRules((rule as CssMedia).rules ?? []); + continue; + } + + if (rule.type !== "rule") { + continue; + } + + const selectors = (rule as CssRule).selectors ?? []; + + const matching = selectors.filter(selector => + SPACING_UTILITY_CLASS_REGEX.test(selector) + ); + + for (const selector of selectors) { + assert( + !LOOKS_LIKE_SPACING_SELECTOR_REGEX.test(selector) || + SPACING_UTILITY_CLASS_REGEX.test(selector), + [ + `${fileRelativePath}: selector "${selector}" looks like a spacing`, + `utility but is not matched by SPACING_UTILITY_CLASS_REGEX,`, + `the spacing grammar needs to be updated` + ].join(" ") + ); + } + + if (matching.length === 0) { + continue; + } + + assert( + matching.length === selectors.length, + [ + `${fileRelativePath}: rule "${selectors.join(",")}" mixes spacing`, + `utility selectors with other selectors, rule level trimming`, + `cannot be done safely` + ].join(" ") + ); + + parserSelectorLists.push([...selectors].sort().join(",")); + } + }; + + walkRules(parseCss(rawCssCode).stylesheet?.rules ?? []); + } + + // Extractor side: what the runtime will do on this very content. + const extractedRules = extractSpacingCssRules({ rawCssCode }); + + assert( + extractedRules.length === parserSelectorLists.length, + [ + `${fileRelativePath}: extractSpacingCssRules found ${extractedRules.length}`, + `spacing rules, the CSS parser found ${parserSelectorLists.length}` + ].join(" ") + ); + + { + const toCountByKey = (keys: string[]) => { + const countByKey = new Map(); + keys.forEach(key => countByKey.set(key, (countByKey.get(key) ?? 0) + 1)); + return countByKey; + }; + + const parserCounts = toCountByKey(parserSelectorLists); + const extractorCounts = toCountByKey( + extractedRules.map(rule => + rule.tokens + .map(token => `.${token}`) + .sort() + .join(",") + ) + ); + + for (const [key, count] of parserCounts) { + assert( + extractorCounts.get(key) === count, + `${fileRelativePath}: extractor and parser disagree on rule "${key}"` + ); + } + + assert( + parserCounts.size === extractorCounts.size, + `${fileRelativePath}: extractor found rules the parser did not` + ); + } + + for (const rule of extractedRules) { + assert( + rawCssCode.indexOf(rule.ruleText) === rawCssCode.lastIndexOf(rule.ruleText), + `${fileRelativePath}: spacing rule "${rule.ruleText}" is not unique` + ); + } + + assert( + extractedRules.length >= 1000, + [ + `${fileRelativePath}: only ${extractedRules.length} spacing rules found,`, + `the spacing grid is expected to be exhaustive (~1200 rules), something is off` + ].join(" ") + ); + + { + const tokens = new Set(extractedRules.flatMap(rule => rule.tokens)); + + if (referenceTokens === undefined) { + referenceTokens = tokens; + } else { + const nonUndefinedReferenceTokens = referenceTokens; + + assert( + tokens.size === nonUndefinedReferenceTokens.size && + Array.from(tokens).every(token => nonUndefinedReferenceTokens.has(token)), + [ + `${fileRelativePath}: its spacing token set differs from`, + `${SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS[0]}'s, the variants`, + `were assumed interchangeable` + ].join(" ") + ); + } + } + + coreFiles[fileRelativePath] = { + "contentHash": fnv1aHashToHex(rawCssCode), + "spacingRuleCount": extractedRules.length + }; + } + + assert(referenceTokens !== undefined); + + const alwaysKeepTokens = new Set(); + + for (const rawFileContent of reactDsfrSrcFilesContents) { + detectUsedSpacingTokens({ + rawFileContent, + "spacingTokens": referenceTokens + }).forEach(token => alwaysKeepTokens.add(token)); + } + + return { + dsfrVersion, + "alwaysKeepTokens": Array.from(alwaysKeepTokens).sort(), + coreFiles + }; +} diff --git a/src/bin/README.md b/src/bin/README.md index 0027ea7aa..7d0e68179 100644 --- a/src/bin/README.md +++ b/src/bin/README.md @@ -100,6 +100,56 @@ 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. +## `--trim-spacing-utilities`, going further + +With component trimming in place, the floor of the stylesheet is the core, and more than +40% of the core is the exhaustive spacing utility grid (`fr-m*-*` / `fr-p*-*`, ~2500 +classes, ~78 kB raw / ~11.5 kB gzip) of which most apps use a handful. Unlike component +CSS these classes are never toggled by the DSFR JavaScript, so they can be trimmed per +rule with reasonable guarantees. It is still a different risk profile than whole-file +concatenation, hence a separate opt-in: + +```bash +npx react-dsfr only-include-used-components --trim-spacing-utilities +``` + +How it stays safe: + +- The rule level surgery is validated at **react-dsfr build time** against a real CSS + parser, and the validated core stylesheets are fingerprinted in a generated manifest + (`dsfr/core/spacing-utilities.json`, which also carries the utilities react-dsfr's own + components render). At run time, trimming only happens if the core file's hash matches: + on any mismatch the stylesheet ships untrimmed, with a warning. +- Used utilities are detected as literal class names in the same crawled sources as the + component detection. A mention in a comment or an url counts as a usage (over-including + only costs bytes). +- **Dynamically constructed class names** (`` `fr-mt-${x}w` ``, `"fr-m" + side`) are + detected, and every utility their static prefix could produce is kept, with a warning. + `` `fr-icon-${name}` `` does not trigger anything (no spacing class starts with that), + `` `fr-m${x}` `` keeps all the margins but still trims the paddings, and a bare + `` `fr-${x}` `` keeps the whole grid. + +### `additionalSpacingUtilities`, the escape hatch + +For utilities only referenced from a stylesheet or built in ways the detection cannot see, +in your **`package.json`**: + +```jsonc +{ + "react-dsfr": { + "additionalSpacingUtilities": ["fr-mt-2w", "fr-mb-*"] + } +} +``` + +A `*` suffix declares a prefix: every utility starting with it is kept, and the dynamic +construction warning it covers is silenced (this is also how you make `--strict` pass when +the dynamic construction is intended). An unknown value is a hard warning and disables +spacing trimming for the run. + +Under `--strict`, a missing manifest, an unacknowledged dynamic construction or a core +file mismatch exits non zero instead of silently shipping the untrimmed grid. + ## Known limitations - Detection is textual: a dynamically composed import path or class name is not seen. diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index fc810c2e7..1911ac2f9 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -27,13 +27,24 @@ * } * (values are DSFR CSS component names or react-dsfr component names) * - * There are three optional arguments that you can use: + * There are four 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. + * - `--trim-spacing-utilities` to also remove, from the core stylesheet, the spacing + * utility classes (fr-m*-*, fr-p*-*) that your sources never use. This is rule level + * trimming (see src/bin/trimSpacingUtilities.ts for how it is kept safe), so it is a + * separate opt-in. Dynamically constructed class names (`fr-mt-${x}w`, "fr-m" + side) + * are detected and every utility their static prefix could produce is kept, with a + * warning. Utilities only referenced from a stylesheet or built in ways the detection + * cannot see can be forced in your package.json ("*" suffix declares a prefix, which + * also silences the dynamic construction warning it covers): + * "react-dsfr": { + * "additionalSpacingUtilities": ["fr-mt-2w", "fr-mb-*"] + * } */ import { getProjectRoot } from "./tools/getProjectRoot"; @@ -50,6 +61,14 @@ import { readPublicDirPath } from "./readPublicDirPath"; import { existsAsync } from "./tools/fs.existsAsync"; import { fnv1aHashToHex } from "./tools/fnv1aHashToHex"; import { modifyHtmlHrefs } from "./tools/modifyHtmlHrefs"; +import { + PATH_OF_SPACING_UTILITIES_JSON, + extractSpacingCssRules, + detectUsedSpacingTokens, + detectDynamicSpacingClassPrefixes, + trimSpacingUtilitiesFromCoreCss, + type SpacingUtilitiesManifest +} from "./trimSpacingUtilities"; /** * The DSFR CSS components (dsfr/component/ directories), listed in the @@ -482,13 +501,27 @@ export function patchCoreCssCodeForCompatWithMui(params: { rawCssCode: string }) ); } +export type SpacingTrimming = { + manifest: SpacingUtilitiesManifest; + usedTokens: Set; + keptPrefixes: string[]; + /** Never silenced by the caller: the stylesheet then ships untrimmed */ + onCannotTrim: (reason: string) => void; + onTrimmed?: (params: { + coreFileRelativePath: string; + removedRuleCount: number; + spacingRuleCount: number; + }) => void; +}; + 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; + spacingTrimming?: SpacingTrimming; }): string { - const { dsfrComponents, isMinified, readDsfrFile } = params; + const { dsfrComponents, isMinified, readDsfrFile, spacingTrimming } = params; const sortedDsfrComponents = [ ...DSFR_COMPONENTS_CASCADE_ORDER.filter(componentName => @@ -530,6 +563,7 @@ export function generateDsfrCssCode(params: { return { dirRelativePath, + fileRelativePath, rawCssCode }; } @@ -539,14 +573,55 @@ export function generateDsfrCssCode(params: { .filter(exclude(undefined)); }; + const mainCssChunks = readCssChunks({ + "getFileRelativePathCandidates": (dirRelativePath, basename) => + (isMinified + ? [`${basename}.main.min.css`, `${basename}.min.css`] + : [`${basename}.main.css`, `${basename}.css`] + ).map(fileBasename => `${dirRelativePath}/${fileBasename}`) + }); + + trim_spacing_utilities: { + if (spacingTrimming === undefined) { + break trim_spacing_utilities; + } + + // The spacing utility grid only exists in the non print core stylesheet. + const coreCssChunk = mainCssChunks.find( + ({ dirRelativePath }) => dirRelativePath === "core" + ); + + if (coreCssChunk === undefined) { + break trim_spacing_utilities; + } + + // Applied on the raw bytes, before the banner/url/MUI transformations below: + // the manifest hashes are the ones of the files as shipped on disk. + const { cssCode, wasTrimmed, removedRuleCount, spacingRuleCount } = + trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssChunk.rawCssCode, + "coreFileRelativePath": coreCssChunk.fileRelativePath, + "manifest": spacingTrimming.manifest, + "usedTokens": spacingTrimming.usedTokens, + "keptPrefixes": spacingTrimming.keptPrefixes, + "onCannotTrim": spacingTrimming.onCannotTrim + }); + + if (!wasTrimmed) { + break trim_spacing_utilities; + } + + coreCssChunk.rawCssCode = cssCode; + + spacingTrimming.onTrimmed?.({ + "coreFileRelativePath": coreCssChunk.fileRelativePath, + removedRuleCount, + spacingRuleCount + }); + } + const cssChunks = [ - ...readCssChunks({ - "getFileRelativePathCandidates": (dirRelativePath, basename) => - (isMinified - ? [`${basename}.main.min.css`, `${basename}.min.css`] - : [`${basename}.main.css`, `${basename}.css`] - ).map(fileBasename => `${dirRelativePath}/${fileBasename}`) - }), + ...mainCssChunks, ...readCssChunks({ "getFileRelativePathCandidates": (dirRelativePath, basename) => (isMinified ? [`${basename}.print.min.css`] : [`${basename}.print.css`]).map( @@ -602,6 +677,7 @@ type CommandContext = { | undefined; isSilent: boolean; isStrict: boolean; + doTrimSpacingUtilities: boolean; }; const CODEGOUV_REACT_DSFR: string = JSON.parse( @@ -707,6 +783,9 @@ async function getCommandContext(args: string[]): Promise { + const filePath = pathJoin(commandContext.dsfrDirPath, ...fileRelativePath.split("/")); + + if (!fs.existsSync(filePath)) { + return undefined; + } + + return fs.readFileSync(filePath).toString("utf8"); + }; + + let spacingTrimmingSetupFailure: string | undefined = undefined; + + // The whole spacing trimming state, undefined when --trim-spacing-utilities + // was not passed or when its prerequisites are missing (a warning is then issued). + const spacingState = ((): + | { + manifest: SpacingUtilitiesManifest; + spacingTokens: Set; + usedSpacingTokens: Set; + /** Dynamic construction prefix -> first file it was seen in */ + dynamicPrefixBySrcFilePath: Map; + declaredPrefixes: string[]; + /** Non empty disables trimming for the run (and fails --strict) */ + disabledReasons: string[]; + } + | undefined => { + if (!commandContext.doTrimSpacingUtilities) { + return undefined; + } + + const cannotEnable = (reason: string) => { + // NOTE: Deliberately not routed through log?.(), --silent must not hide + // the fact that the optimization has been disabled for this run. + console.warn( + `[react-dsfr] --trim-spacing-utilities is ignored for this run: ${reason}` + ); + spacingTrimmingSetupFailure = reason; + return undefined; + }; + + const manifestSourceCode = readDsfrFile( + PATH_OF_SPACING_UTILITIES_JSON.split(pathSep).join("/") + ); + + if (manifestSourceCode === undefined) { + return cannotEnable( + [ + `${PATH_OF_SPACING_UTILITIES_JSON} is missing from your installation of`, + `${CODEGOUV_REACT_DSFR}, is it up to date?` + ].join(" ") + ); + } + + const manifest: SpacingUtilitiesManifest = JSON.parse(manifestSourceCode); + + const coreCssCode = (() => { + for (const fileRelativePath of Object.keys(manifest.coreFiles)) { + const rawCssCode = readDsfrFile(fileRelativePath); + + if (rawCssCode !== undefined) { + return rawCssCode; + } + } + + return undefined; + })(); + + if (coreCssCode === undefined) { + return cannotEnable( + `none of the core stylesheets covered by the manifest exists on disk` + ); + } + + return { + manifest, + "spacingTokens": new Set( + extractSpacingCssRules({ "rawCssCode": coreCssCode }).flatMap( + ({ tokens }) => tokens + ) + ), + // The components of react-dsfr itself use a few spacing utilities, and the + // crawl deliberately excludes the package: the build time generated list + // is what prevents trimming them away. + "usedSpacingTokens": new Set(manifest.alwaysKeepTokens), + "dynamicPrefixBySrcFilePath": new Map(), + "declaredPrefixes": [], + "disabledReasons": [] + }; + })(); + const usedDsfrComponents = new Set(); let doIncludeAllComponents = false; @@ -941,87 +1111,238 @@ export async function main(args: string[]) { usedDsfrComponents.add(componentName); } + + if (spacingState !== undefined) { + const spacingTokens = detectUsedSpacingTokens({ + rawFileContent, + "spacingTokens": spacingState.spacingTokens + }); + + if (spacingTokens.length !== 0) { + log?.( + `Found usage of spacing utilities ${spacingTokens.join( + ", " + )} in ${pathRelative(process.cwd(), srcFilePath)}` + ); + + spacingTokens.forEach(token => spacingState.usedSpacingTokens.add(token)); + } + + for (const prefix of detectDynamicSpacingClassPrefixes({ + rawFileContent, + "spacingTokens": spacingState.spacingTokens + })) { + if (spacingState.dynamicPrefixBySrcFilePath.has(prefix)) { + continue; + } + + spacingState.dynamicPrefixBySrcFilePath.set(prefix, srcFilePath); + } + } }) ); - additional_components_from_package_json: { + additional_entries_from_package_json: { const packageJsonFilePath = pathJoin(commandContext.projectDirPath, "package.json"); if (!(await existsAsync(packageJsonFilePath))) { - break additional_components_from_package_json; + break additional_entries_from_package_json; } const reactDsfrConfig: unknown = JSON.parse( (await readFile(packageJsonFilePath)).toString("utf8") )["react-dsfr"]; - const additionalComponents: unknown = - reactDsfrConfig === null || typeof reactDsfrConfig !== "object" - ? undefined - : (reactDsfrConfig as Record)["additionalComponents"]; + if (reactDsfrConfig === null || typeof reactDsfrConfig !== "object") { + break additional_entries_from_package_json; + } + + const config = reactDsfrConfig as Record; - if (additionalComponents === undefined) { - if (reactDsfrConfig !== null && typeof reactDsfrConfig === "object") { - // A typo in the key would otherwise silently disable the escape hatch. + { + const unknownKeys = Object.keys(config).filter( + key => key !== "additionalComponents" && key !== "additionalSpacingUtilities" + ); + + if (unknownKeys.length !== 0) { + // A typo in a 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(", ")}` + `[react-dsfr] Unknown key(s) ${unknownKeys.join(", ")} in the`, + `"react-dsfr" entry of your package.json, is it a typo? Expected`, + `"additionalComponents" and/or "additionalSpacingUtilities".` ].join(" ") ); } + } + + additional_components: { + const additionalComponents = config["additionalComponents"]; + + if (additionalComponents === undefined) { + break additional_components; + } + + 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( + [ + `[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(" ") + ); + + doIncludeAllComponents = true; + + 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)`); - break additional_components_from_package_json; + dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + } } - 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' - ); + additional_spacing_utilities: { + const additionalSpacingUtilities = config["additionalSpacingUtilities"]; - for (const additionalComponent of additionalComponents) { - const dsfrComponents = - REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[additionalComponent] ?? - (availableDsfrComponents.includes(additionalComponent) - ? [additionalComponent] - : undefined); + if (additionalSpacingUtilities === undefined) { + break additional_spacing_utilities; + } - if (dsfrComponents === undefined) { - console.warn( - [ - `[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(" ") - ); + assert( + Array.isArray(additionalSpacingUtilities) && + additionalSpacingUtilities.every( + (value): value is string => typeof value === "string" + ), + 'Malformed "react-dsfr"."additionalSpacingUtilities" in package.json, expected an array of strings' + ); + + if (spacingState === undefined) { + // Inert without --trim-spacing-utilities (or when its setup failed, + // which has already been warned about). + break additional_spacing_utilities; + } - doIncludeAllComponents = true; + for (const entry of additionalSpacingUtilities) { + if (entry.endsWith("*")) { + const prefix = entry.slice(0, -1); + + const matchingTokenCount = Array.from(spacingState.spacingTokens).filter( + token => token.startsWith(prefix) + ).length; + + if (matchingTokenCount === 0) { + console.warn( + [ + `[react-dsfr] "${entry}" of`, + `"react-dsfr"."additionalSpacingUtilities" of your package.json`, + `matches no spacing utility class (typo?):`, + `spacing utilities are not trimmed for this run.` + ].join(" ") + ); - continue; + spacingState.disabledReasons.push( + `"${entry}" of additionalSpacingUtilities matches nothing` + ); + + continue; + } + + log?.( + `Keeping ${matchingTokenCount} spacing utilities matching "${entry}" (from package.json additionalSpacingUtilities)` + ); + + spacingState.declaredPrefixes.push(prefix); + + continue; + } + + if (!spacingState.spacingTokens.has(entry)) { + console.warn( + [ + `[react-dsfr] Unknown spacing utility "${entry}" in`, + `"react-dsfr"."additionalSpacingUtilities" of your package.json:`, + `spacing utilities are not trimmed for this run.` + ].join(" ") + ); + + spacingState.disabledReasons.push( + `"${entry}" of additionalSpacingUtilities is not a spacing utility class` + ); + + continue; + } + + log?.(`Keeping ${entry} (from package.json additionalSpacingUtilities)`); + + spacingState.usedSpacingTokens.add(entry); } + } + } - 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(" ") - ); + const uncoveredDynamicSpacingPrefixes: string[] = []; + if (spacingState !== undefined) { + for (const [prefix, srcFilePath] of spacingState.dynamicPrefixBySrcFilePath) { + if ( + spacingState.declaredPrefixes.some(declaredPrefix => + prefix.startsWith(declaredPrefix) + ) + ) { continue; } - log?.(`Including ${additionalComponent} (from package.json additionalComponents)`); + const keptTokenCount = Array.from(spacingState.spacingTokens).filter(token => + token.startsWith(prefix) + ).length; + + // NOTE: Deliberately not routed through log?.(), --silent must not hide + // that part of the spacing grid is retained because of a dynamic class name. + console.warn( + [ + `[react-dsfr] Dynamically constructed spacing class in`, + `${pathRelative(process.cwd(), srcFilePath)} ("${prefix}" + an expression):`, + `the ${keptTokenCount} spacing utilities this prefix can produce are kept`, + `(fail-safe). If this is intended, declare "${prefix}*" in`, + `"react-dsfr"."additionalSpacingUtilities" of your package.json to`, + `acknowledge it and silence this warning.` + ].join(" ") + ); - dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + uncoveredDynamicSpacingPrefixes.push(prefix); } } @@ -1039,6 +1360,26 @@ export async function main(args: string[]) { process.exit(1); } + if ( + commandContext.isStrict && + commandContext.doTrimSpacingUtilities && + (spacingTrimmingSetupFailure !== undefined || + (spacingState !== undefined && + (spacingState.disabledReasons.length !== 0 || + uncoveredDynamicSpacingPrefixes.length !== 0))) + ) { + console.error( + [ + `[react-dsfr] Aborting because of --strict:`, + `--trim-spacing-utilities could not do its job deterministically`, + `(see the warning(s) above). Fix the cause or declare the spacing`, + `utilities in "react-dsfr"."additionalSpacingUtilities" of your package.json.` + ].join(" ") + ); + + process.exit(1); + } + const dsfrComponents = doIncludeAllComponents ? availableDsfrComponents : availableDsfrComponents.filter(componentName => usedDsfrComponents.has(componentName)); @@ -1047,21 +1388,37 @@ export async function main(args: string[]) { `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 spacingCannotTrimReasons: string[] = []; + + const spacingTrimming: SpacingTrimming | undefined = + spacingState === undefined || spacingState.disabledReasons.length !== 0 + ? undefined + : { + "manifest": spacingState.manifest, + "usedTokens": spacingState.usedSpacingTokens, + "keptPrefixes": [ + ...spacingState.dynamicPrefixBySrcFilePath.keys(), + ...spacingState.declaredPrefixes + ], + "onCannotTrim": reason => { + // NOTE: Deliberately not routed through log?.(), --silent must not hide + // the fact that the optimization has been disabled for this run. + console.warn(`[react-dsfr] Spacing utilities are not trimmed: ${reason}`); + + spacingCannotTrimReasons.push(reason); + }, + "onTrimmed": ({ coreFileRelativePath, removedRuleCount, spacingRuleCount }) => + log?.( + `Trimmed ${removedRuleCount} of ${spacingRuleCount} spacing utility rules from ${coreFileRelativePath}` + ) + }; const rawDsfrCssCodeBuffer = Buffer.from( generateDsfrCssCode({ dsfrComponents, "isMinified": false, - readDsfrFile + readDsfrFile, + spacingTrimming }), "utf8" ); @@ -1070,11 +1427,24 @@ export async function main(args: string[]) { generateDsfrCssCode({ dsfrComponents, "isMinified": true, - readDsfrFile + readDsfrFile, + spacingTrimming }), "utf8" ); + if (spacingCannotTrimReasons.length !== 0 && commandContext.isStrict) { + console.error( + [ + `[react-dsfr] Aborting because of --strict:`, + `this run would have shipped the core stylesheet with untrimmed`, + `spacing utilities (see the warning(s) above).` + ].join(" ") + ); + + process.exit(1); + } + let hasChanged = false; await Promise.all( diff --git a/src/bin/trimSpacingUtilities.ts b/src/bin/trimSpacingUtilities.ts new file mode 100644 index 000000000..cd94b18e9 --- /dev/null +++ b/src/bin/trimSpacingUtilities.ts @@ -0,0 +1,335 @@ +import { join as pathJoin } from "path"; +import { fnv1aHashToHex } from "./tools/fnv1aHashToHex"; + +/** + * Everything needed by the opt-in `--trim-spacing-utilities` mode of + * only-include-used-components: the DSFR ships the exhaustive spacing utility + * grid (fr-m*-* / fr-p*-*, ~2500 classes) inside the core stylesheet, this + * module removes the rules of the utilities a project does not use. + * + * Unlike component trimming (whole pre-split files are included or excluded), + * this is rule level surgery on core.*.css. It is kept safe by doing the + * risky part at react-dsfr build time: scripts/build/generateSpacingUtilitiesManifest.ts + * cross-checks extractSpacingCssRules() against a real CSS parser on the exact + * files that get published, and records their content hash in + * dsfr/core/spacing-utilities.json. At runtime, in the consumer's node_modules, + * trimming only happens when the core file's hash matches the manifest: the + * extraction is then guaranteed to be the one that was validated at build time. + * On any mismatch the stylesheet is left untouched (fail-safe, when in doubt include). + */ + +export const PATH_OF_SPACING_UTILITIES_JSON = pathJoin("core", "spacing-utilities.json"); + +/** + * A DSFR spacing utility class selector, and nothing else. + * Grammar derived from @gouvfr/dsfr dist/core/core.main.css and asserted against + * it (via a real CSS parser) at build time by generateSpacingUtilitiesManifest: + * margin/padding, optional side (t r b l x y), optional breakpoint (first, md), + * value in v/w units (possibly negative "n", possibly half step "-5"), 0 or auto. + * e.g. .fr-mt-2w, .fr-p-1-5v, .fr-mx-md-n4w, .fr-m-auto, .fr-mb-0 + */ +export const SPACING_UTILITY_CLASS_REGEX = + /^\.fr-[mp][trblxy]?-(?:(?:first|md)-)?(?:n?\d+(?:-5)?[vw]|0|auto)$/; + +export type SpacingCssRule = { + /** Exact text of the rule, from the first character of the selector to the closing brace */ + ruleText: string; + /** Offsets of ruleText within the stylesheet the rule was extracted from */ + start: number; + end: number; + /** The selectors without the leading dot, e.g. ["fr-mt-1v", "fr-my-1v"] */ + tokens: string[]; +}; + +export type SpacingUtilitiesManifest = { + dsfrVersion: string; + /** + * Spacing utilities used by react-dsfr's own components (their JSX is not + * scanned at runtime, the whole package is excluded from the crawl). + * Derived at build time from src/, never written by hand. + */ + alwaysKeepTokens: string[]; + /** + * One entry per core stylesheet variant that generateDsfrCssCode may pick. + * contentHash (fnv1a) is the proof that the file is byte for byte the one + * the extraction was validated against at build time. + */ + coreFiles: Record; +}; + +/** + * Extracts the CSS rules whose selectors are ALL spacing utility classes. + * A rule with a selector that is anything else (another class, a descendant + * combinator, a comment in the selector list...) is never extracted: removing + * it could not be proven safe, so it stays (fail-safe). + * + * This is a string level scanner, not a CSS parser. It is only trusted on + * stylesheets whose content hash was recorded by the build time manifest, + * where it has been cross-checked against a real parser. + */ +export function extractSpacingCssRules(params: { rawCssCode: string }): SpacingCssRule[] { + const { rawCssCode } = params; + + const spacingCssRules: SpacingCssRule[] = []; + + /** Position right after the last rule/at-rule boundary: where the current selector text starts */ + let selectorStart = 0; + /** A comment in a selector list makes it unprovable, the rule is then kept */ + let selectorContainsComment = false; + let i = 0; + + const skipString = (openingQuoteIndex: number): number => { + const stringEnd = rawCssCode.indexOf(rawCssCode[openingQuoteIndex], openingQuoteIndex + 1); + return stringEnd === -1 ? rawCssCode.length : stringEnd + 1; + }; + + while (i < rawCssCode.length) { + const char = rawCssCode[i]; + + if (char === "/" && rawCssCode[i + 1] === "*") { + const commentEnd = rawCssCode.indexOf("*/", i + 2); + selectorContainsComment = true; + i = commentEnd === -1 ? rawCssCode.length : commentEnd + 2; + continue; + } + + if (char === '"' || char === "'") { + i = skipString(i); + continue; + } + + if (char === "}" || char === ";") { + selectorStart = i + 1; + selectorContainsComment = false; + i++; + continue; + } + + if (char !== "{") { + i++; + continue; + } + + const selectorText = rawCssCode.slice(selectorStart, i); + + if (selectorText.trimStart().startsWith("@")) { + // At-rule opening a block (@media, @supports, @font-face...): scan inside it. + selectorStart = i + 1; + selectorContainsComment = false; + i++; + continue; + } + + const bodyEnd = (() => { + let j = i + 1; + + while (j < rawCssCode.length) { + const bodyChar = rawCssCode[j]; + + if (bodyChar === "/" && rawCssCode[j + 1] === "*") { + const commentEnd = rawCssCode.indexOf("*/", j + 2); + j = commentEnd === -1 ? rawCssCode.length : commentEnd + 2; + continue; + } + + if (bodyChar === '"' || bodyChar === "'") { + j = skipString(j); + continue; + } + + if (bodyChar === "}") { + return j; + } + + j++; + } + + return undefined; + })(); + + if (bodyEnd === undefined) { + break; + } + + extract: { + if (selectorContainsComment) { + break extract; + } + + const selectors = selectorText.split(",").map(selector => selector.trim()); + + if (!selectors.every(selector => SPACING_UTILITY_CLASS_REGEX.test(selector))) { + break extract; + } + + const start = selectorStart + (selectorText.length - selectorText.trimStart().length); + const end = bodyEnd + 1; + + spacingCssRules.push({ + "ruleText": rawCssCode.slice(start, end), + start, + end, + "tokens": selectors.map(selector => selector.slice(1)) + }); + } + + i = bodyEnd + 1; + selectorStart = i; + selectorContainsComment = false; + } + + return spacingCssRules; +} + +/** + * The spacing utility classes appearing as literals in a source file. + * Matching is done on maximal fr-* character runs so that fr-mt-2vfoo or + * fr-mt-2v-legacy is not mistaken for fr-mt-2v. A mention in a comment or an + * url does count as a usage: over-including is the assumed fail-safe, the same + * trade-off as the component class name detection. + */ +export function detectUsedSpacingTokens(params: { + rawFileContent: string; + spacingTokens: Set; +}): string[] { + const { rawFileContent, spacingTokens } = params; + + if (!rawFileContent.includes("fr-")) { + return []; + } + + const usedTokens = new Set(); + + for (const [run] of rawFileContent.matchAll(/fr-[a-zA-Z0-9-]+/g)) { + if (spacingTokens.has(run)) { + usedTokens.add(run); + } + } + + return Array.from(usedTokens); +} + +/** + * Static prefixes of dynamically constructed class names that could produce a + * spacing utility: `fr-mt-${size}w`, "fr-m" + side... A prefix is only + * reported if at least one actual spacing token starts with it, so + * `fr-modal-title-${id}` or `fr-icon-${name}` never trigger anything. + * The caller keeps every token extending a reported prefix (fail-safe): + * `fr-m${x}` keeps all margins but still trims the paddings, and a bare + * `fr-${x}` degenerates into keeping the whole grid. + */ +export function detectDynamicSpacingClassPrefixes(params: { + rawFileContent: string; + spacingTokens: Set; +}): string[] { + const { rawFileContent, spacingTokens } = params; + + if (!rawFileContent.includes("fr-")) { + return []; + } + + const prefixes = new Set(); + + for (const regex of [ + // Template literal interpolation right after the prefix: `fr-mt-${size}w` + /(fr-[a-zA-Z0-9-]*)\$\{/g, + // String concatenation right after the prefix: "fr-mt-" + size + /(fr-[a-zA-Z0-9-]*)["']\s*\+/g + ]) { + for (const [, prefix] of rawFileContent.matchAll(regex)) { + prefixes.add(prefix); + } + } + + return Array.from(prefixes).filter(prefix => { + for (const token of spacingTokens) { + if (token.startsWith(prefix)) { + return true; + } + } + return false; + }); +} + +/** + * Removes from a core stylesheet the spacing utility rules of which every + * token is unused. Grouped selectors are kept whole if any of their tokens is + * used. Positions of the kept rules are preserved (removal by offset splicing), + * so the cascade is exactly the original one and the output is byte identical + * to the input when everything is used. + * + * If the stylesheet is not the one the manifest was generated against + * (hash or rule count mismatch: patched file, manifest drift, unknown variant), + * nothing is removed and onCannotTrim is called with the reason. + */ +export function trimSpacingUtilitiesFromCoreCss(params: { + rawCssCode: string; + coreFileRelativePath: string; + manifest: SpacingUtilitiesManifest; + usedTokens: Set; + keptPrefixes: string[]; + onCannotTrim: (reason: string) => void; +}): { cssCode: string; wasTrimmed: boolean; removedRuleCount: number; spacingRuleCount: number } { + const { rawCssCode, coreFileRelativePath, manifest, usedTokens, keptPrefixes, onCannotTrim } = + params; + + const cannotTrim = (reason: string) => { + onCannotTrim(reason); + return { + "cssCode": rawCssCode, + "wasTrimmed": false, + "removedRuleCount": 0, + "spacingRuleCount": 0 + }; + }; + + const manifestEntry = manifest.coreFiles[coreFileRelativePath]; + + if (manifestEntry === undefined) { + return cannotTrim( + `${coreFileRelativePath} is not covered by the spacing utilities manifest` + ); + } + + if (fnv1aHashToHex(rawCssCode) !== manifestEntry.contentHash) { + return cannotTrim( + [ + `${coreFileRelativePath} is not the file the spacing utilities manifest`, + `was generated against (patched or corrupted file, or manifest drift)` + ].join(" ") + ); + } + + const spacingCssRules = extractSpacingCssRules({ rawCssCode }); + + if (spacingCssRules.length !== manifestEntry.spacingRuleCount) { + return cannotTrim( + [ + `Expected ${manifestEntry.spacingRuleCount} spacing utility rules in`, + `${coreFileRelativePath}, found ${spacingCssRules.length}` + ].join(" ") + ); + } + + const isTokenKept = (token: string) => + usedTokens.has(token) || keptPrefixes.some(prefix => token.startsWith(prefix)); + + const removedRules = spacingCssRules.filter(rule => !rule.tokens.some(isTokenKept)); + + let cssCode = ""; + let cursor = 0; + + for (const rule of removedRules) { + cssCode += rawCssCode.slice(cursor, rule.start); + cursor = rule.end; + } + + cssCode += rawCssCode.slice(cursor); + + return { + cssCode, + "wasTrimmed": true, + "removedRuleCount": removedRules.length, + "spacingRuleCount": spacingCssRules.length + }; +} diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/detectDynamicSpacingClassPrefixes.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/detectDynamicSpacingClassPrefixes.test.ts new file mode 100644 index 000000000..bb4744c95 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/detectDynamicSpacingClassPrefixes.test.ts @@ -0,0 +1,118 @@ +import { it, expect, describe } from "vitest"; +import { detectDynamicSpacingClassPrefixes } from "../../../../src/bin/trimSpacingUtilities"; + +const spacingTokens = new Set([ + "fr-mt-2v", + "fr-mt-2w", + "fr-mb-4v", + "fr-m-auto", + "fr-p-1-5v", + "fr-pb-md-9w" +]); + +// The near misses come first: this detector decides whether part of the spacing +// grid is retained, a prefix that cannot produce a spacing token must never trigger. +describe("detectDynamicSpacingClassPrefixes, near misses", () => { + it("does not trigger on dynamic classes whose prefix cannot extend into a spacing token", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": [ + "const titleId = `fr-modal-title-${id}`;", + "const icon = `fr-icon-${name}`;", + 'const variant = "fr-btn--" + variantName;', + "const severityClass = `fr-message--${severity}`;" + ].join("\n"), + spacingTokens + }) + ).toStrictEqual([]); + }); + + it("does not trigger on interpolations or concatenations with no fr- prefix at all", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": [ + "const a = `${x}v`;", + 'const b = "margin-" + side;', + "const c = `fr-mt-2v`;" // literal, not dynamic + ].join("\n"), + spacingTokens + }) + ).toStrictEqual([]); + }); + + it("does not trigger on a mere fr- mention in an url without interpolation", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": `// https://example.com/fr-mt-2v.png`, + spacingTokens + }) + ).toStrictEqual([]); + }); +}); + +describe("detectDynamicSpacingClassPrefixes, dynamic constructions", () => { + it("captures the static prefix of a template literal interpolation", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": "const cls = `fr-mt-${size}w`;", + spacingTokens + }) + ).toStrictEqual(["fr-mt-"]); + }); + + it("captures a bare fr- prefix (degenerates into keeping the whole grid, by design)", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": "const cls = `fr-${anything}`;", + spacingTokens + }) + ).toStrictEqual(["fr-"]); + }); + + it("captures a family prefix (keeps the margins, the paddings stay trimmable)", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": "const cls = `fr-m${sideAndValue}`;", + spacingTokens + }) + ).toStrictEqual(["fr-m"]); + }); + + it("captures the static prefix of a string concatenation", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": 'const cls = "fr-mt-" + size + "w";', + spacingTokens + }) + ).toStrictEqual(["fr-mt-"]); + + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": "const cls = 'fr-pb-md-' + value;", + spacingTokens + }) + ).toStrictEqual(["fr-pb-md-"]); + }); + + it("captures a complete token being extended (the token itself is then kept)", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": 'const cls = "fr-mt-2v" + suffix;', + spacingTokens + }) + ).toStrictEqual(["fr-mt-2v"]); + }); + + it("deduplicates and reports each distinct prefix", () => { + expect( + detectDynamicSpacingClassPrefixes({ + "rawFileContent": [ + "const a = `fr-mt-${x}`;", + "const b = `fr-mt-${y}`;", + 'const c = "fr-mb-" + z;' + ].join("\n"), + spacingTokens + }).sort() + ).toStrictEqual(["fr-mb-", "fr-mt-"]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/detectUsedSpacingTokens.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/detectUsedSpacingTokens.test.ts new file mode 100644 index 000000000..73050ab44 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/detectUsedSpacingTokens.test.ts @@ -0,0 +1,98 @@ +import { it, expect, describe } from "vitest"; +import { detectUsedSpacingTokens } from "../../../../src/bin/trimSpacingUtilities"; + +const spacingTokens = new Set([ + "fr-mt-2v", + "fr-mt-2w", + "fr-mb-4v", + "fr-m-auto", + "fr-mx-md-n4w", + "fr-p-1-5v", + "fr-pb-md-9w" +]); + +// The false positive shaped inputs come first: a detector is only trustworthy +// once the inputs that should NOT count (or that count on purpose) are pinned down. +describe("detectUsedSpacingTokens, near misses and assumed false positives", () => { + it("does not count a longer class of which a token is a prefix", () => { + // Maximal run matching: the run is "fr-mt-2vfoo" / "fr-mt-2v-legacy", + // neither is a spacing token. + expect( + detectUsedSpacingTokens({ + "rawFileContent": `
`, + spacingTokens + }) + ).toStrictEqual([]); + }); + + it("does not count a bare family or side prefix", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": `const doc = "the fr-m and fr-mb and fr-modal classes";`, + spacingTokens + }) + ).toStrictEqual([]); + }); + + it("does count a mention in a comment or an url (assumed fail-safe: over-including only costs bytes)", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": [ + `// see https://www.systeme-de-design.gouv.fr/fr-mt-2v.png`, + `/* fr-mb-4v is documented here */` + ].join("\n"), + spacingTokens + }).sort() + ).toStrictEqual(["fr-mb-4v", "fr-mt-2v"]); + }); + + it("returns every token on an over-broad file that contains the whole grid", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": Array.from(spacingTokens).join(" "), + spacingTokens + }).sort() + ).toStrictEqual(Array.from(spacingTokens).sort()); + }); + + it("returns nothing on a file with no fr- occurrence at all", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": `export const margin = "mt-2v";`, + spacingTokens + }) + ).toStrictEqual([]); + }); +}); + +describe("detectUsedSpacingTokens, nominal cases", () => { + it("detects tokens used as literal class names", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": [ + `
`, + `

` + ].join("\n"), + spacingTokens + }).sort() + ).toStrictEqual(["fr-m-auto", "fr-mt-2w", "fr-p-1-5v", "fr-pb-md-9w"]); + }); + + it("detects breakpoint and negative variants", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": `classes.push("fr-mx-md-n4w");`, + spacingTokens + }) + ).toStrictEqual(["fr-mx-md-n4w"]); + }); + + it("deduplicates", () => { + expect( + detectUsedSpacingTokens({ + "rawFileContent": `"fr-mt-2v fr-mt-2v fr-mt-2v"`, + spacingTokens + }) + ).toStrictEqual(["fr-mt-2v"]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/extractSpacingCssRules.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/extractSpacingCssRules.test.ts new file mode 100644 index 000000000..75f2b1d1c --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/extractSpacingCssRules.test.ts @@ -0,0 +1,124 @@ +import { it, expect, describe } from "vitest"; +import { extractSpacingCssRules } from "../../../../src/bin/trimSpacingUtilities"; + +// The rules that must NOT be extracted come first: extracting a rule that is not +// purely made of spacing utility selectors would remove CSS that something else needs. +describe("extractSpacingCssRules, rules that must never be extracted", () => { + it("never extracts a rule mixing a spacing selector with another selector", () => { + expect( + extractSpacingCssRules({ + "rawCssCode": ".fr-mt-2v,.fr-header{margin-top:.5rem}" + }) + ).toStrictEqual([]); + }); + + it("never extracts a descendant or compound selector containing a spacing class", () => { + for (const rawCssCode of [ + ".foo .fr-mt-2v{margin-top:.5rem}", + ".fr-mt-2v .foo{margin-top:.5rem}", + ".fr-mt-2v.fr-header{margin-top:.5rem}", + ".fr-mt-2v>p{margin-top:.5rem}", + "[dir=rtl] .fr-mt-2v{margin-top:.5rem}" + ]) { + expect(extractSpacingCssRules({ rawCssCode })).toStrictEqual([]); + } + }); + + it("never extracts classes that merely look like spacing utilities", () => { + for (const rawCssCode of [ + ".fr-menu{top:0}", + ".fr-modal{top:0}", + ".fr-message{top:0}", + ".fr-mt-2vfoo{top:0}", + ".fr-mt-2v-legacy{top:0}", + ".fr-mt-lg-2v{top:0}", // unknown breakpoint, not part of the grammar + ".fr-mt-{top:0}" + ]) { + expect(extractSpacingCssRules({ rawCssCode })).toStrictEqual([]); + } + }); + + it("never extracts a rule whose selector list contains a comment (unprovable, kept)", () => { + expect( + extractSpacingCssRules({ + "rawCssCode": ".fr-mt-2v/* comment */,.fr-my-2v{margin-top:.5rem}" + }) + ).toStrictEqual([]); + }); + + it("is not confused by braces inside strings", () => { + const rawCssCode = '.foo{content:"}"}.bar .fr-mt-2v{margin-top:.5rem}'; + + expect(extractSpacingCssRules({ "rawCssCode": rawCssCode })).toStrictEqual([]); + }); +}); + +describe("extractSpacingCssRules, extraction", () => { + it("extracts a single selector rule with exact offsets", () => { + const rawCssCode = '@charset "UTF-8";.fr-mt-2v{margin-top:.5rem!important}'; + + const rules = extractSpacingCssRules({ rawCssCode }); + + expect(rules.length).toBe(1); + expect(rules[0].ruleText).toBe(".fr-mt-2v{margin-top:.5rem!important}"); + expect(rules[0].tokens).toStrictEqual(["fr-mt-2v"]); + expect(rawCssCode.slice(rules[0].start, rules[0].end)).toBe(rules[0].ruleText); + }); + + it("extracts grouped selectors as one rule carrying every token", () => { + const rules = extractSpacingCssRules({ + "rawCssCode": ".fr-ml-1v,.fr-mx-1v{margin-left:.25rem!important}" + }); + + expect(rules.length).toBe(1); + expect(rules[0].tokens).toStrictEqual(["fr-ml-1v", "fr-mx-1v"]); + }); + + it("extracts rules inside a media query, not the media block itself", () => { + const rawCssCode = + ".before{color:red}@media (min-width:48em){.fr-mt-md-2v{margin-top:.5rem}.other{color:blue}}.after{color:green}"; + + const rules = extractSpacingCssRules({ rawCssCode }); + + expect(rules.length).toBe(1); + expect(rules[0].ruleText).toBe(".fr-mt-md-2v{margin-top:.5rem}"); + expect(rawCssCode.slice(rules[0].start, rules[0].end)).toBe(rules[0].ruleText); + }); + + it("supports the whole token grammar: negatives, half steps, auto, 0, first and md breakpoints", () => { + const rawCssCode = [ + ".fr-m-n4w{margin:-2rem!important}", + ".fr-p-1-5v{padding:.375rem!important}", + ".fr-m-auto{margin:auto!important}", + ".fr-mb-0{margin-bottom:0!important}", + ".fr-m-first-n4w{margin:-2rem!important}", + ".fr-mx-md-n1-5v{margin-left:-.375rem!important}" + ].join(""); + + expect(extractSpacingCssRules({ rawCssCode }).map(({ tokens }) => tokens[0])).toStrictEqual( + ["fr-m-n4w", "fr-p-1-5v", "fr-m-auto", "fr-mb-0", "fr-m-first-n4w", "fr-mx-md-n1-5v"] + ); + }); + + it("handles whitespace and newlines in non minified selector lists", () => { + const rules = extractSpacingCssRules({ + "rawCssCode": ".fr-ml-1v,\n.fr-mx-1v {\n margin-left: 0.25rem !important;\n}" + }); + + expect(rules.length).toBe(1); + expect(rules[0].tokens).toStrictEqual(["fr-ml-1v", "fr-mx-1v"]); + // The extracted range starts at the selector, not at the preceding whitespace. + expect(rules[0].ruleText.startsWith(".fr-ml-1v")).toBe(true); + expect(rules[0].ruleText.endsWith("}")).toBe(true); + }); + + it("extracts consecutive rules with contiguous, non overlapping offsets", () => { + const rawCssCode = ".fr-mt-1v{margin-top:.25rem}.fr-mt-2v{margin-top:.5rem}"; + + const rules = extractSpacingCssRules({ rawCssCode }); + + expect(rules.length).toBe(2); + expect(rules[0].end).toBe(rules[1].start); + expect(rules.map(({ ruleText }) => ruleText).join("")).toBe(rawCssCode); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts index 6dd291cf2..1ff4c4cc2 100644 --- a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts +++ b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts @@ -3,8 +3,14 @@ import { generateDsfrCssCode, rewriteCssRelativeUrls, patchCoreCssCodeForCompatWithMui, - getReferencedAssetRelativePaths + getReferencedAssetRelativePaths, + type SpacingTrimming } from "../../../../src/bin/only-include-used-components"; +import { + extractSpacingCssRules, + type SpacingUtilitiesManifest +} from "../../../../src/bin/trimSpacingUtilities"; +import { fnv1aHashToHex } from "../../../../src/bin/tools/fnv1aHashToHex"; describe("rewriteCssRelativeUrls", () => { it("rewrites urls relative to the css file into urls relative to the dsfr directory", () => { @@ -132,6 +138,149 @@ describe("generateDsfrCssCode", () => { }); }); +describe("generateDsfrCssCode, spacing trimming", () => { + // A core that also exercises the MUI patch, to pin the transformation order: + // the trimming runs on the raw bytes (the ones the manifest hashed), the MUI + // patch and url rewriting run after, on the trimmed code. + const coreCssCode = [ + '@charset "UTF-8";', + ".core{--x:url('../fonts/f.woff2')}", + "button:not(:disabled):hover{background:red}", + ".fr-mt-2v{margin-top:.5rem!important}", + ".fr-pb-4v{padding-bottom:1rem!important}", + "@media (min-width:48em){.fr-mt-md-2v{margin-top:.5rem!important}}" + ].join(""); + + const corePrintCssCode = "@media print{.core-print{display:none}}"; + + const fakeDsfrFiles: Record = { + "core/core.main.min.css": coreCssCode, + "core/core.print.min.css": corePrintCssCode, + "scheme/scheme.min.css": ":root[data-fr-theme=dark]{--grey:#161616}", + "component/button/button.main.min.css": ".fr-btn{color:red}" + }; + + const readDsfrFile = (fileRelativePath: string) => fakeDsfrFiles[fileRelativePath]; + + const makeSpacingTrimming = (params: { + usedTokens: Set; + keptPrefixes?: string[]; + onCannotTrim?: (reason: string) => void; + }): SpacingTrimming => { + const manifest: SpacingUtilitiesManifest = { + "dsfrVersion": "0.0.0-test", + "alwaysKeepTokens": [], + "coreFiles": { + "core/core.main.min.css": { + "contentHash": fnv1aHashToHex(coreCssCode), + "spacingRuleCount": extractSpacingCssRules({ "rawCssCode": coreCssCode }).length + } + } + }; + + return { + manifest, + "usedTokens": params.usedTokens, + "keptPrefixes": params.keptPrefixes ?? [], + "onCannotTrim": + params.onCannotTrim ?? + (reason => { + throw new Error(`onCannotTrim was not expected to be called: ${reason}`); + }) + }; + }; + + it("without spacingTrimming the output is unchanged (opt-in, byte level non regression)", () => { + const withoutParam = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile + }); + + expect(withoutParam).toContain(".fr-mt-2v{"); + expect(withoutParam).toContain(".fr-pb-4v{"); + }); + + it("trims the unused spacing utilities from the core, keeps the used ones", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile, + "spacingTrimming": makeSpacingTrimming({ "usedTokens": new Set(["fr-mt-2v"]) }) + }); + + expect(generated).toContain(".fr-mt-2v{"); + expect(generated).not.toContain(".fr-pb-4v{"); + expect(generated).not.toContain(".fr-mt-md-2v{"); + // The rest of the core, and the other chunks, are untouched. + expect(generated).toContain(".core{"); + expect(generated).toContain(".core-print{"); + expect(generated).toContain(".fr-btn{"); + }); + + it("still applies the MUI patch and url rewriting, on the trimmed core", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile, + "spacingTrimming": makeSpacingTrimming({ "usedTokens": new Set() }) + }); + + expect(generated).toContain('button:not(:disabled):hover:not([class^="Mui"])'); + expect(generated).toContain("url('fonts/f.woff2')"); + expect(generated).not.toContain(".fr-mt-2v{"); + }); + + it("does not touch the print core (no spacing there, and it is not in the manifest)", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile, + "spacingTrimming": makeSpacingTrimming({ "usedTokens": new Set() }) + }); + + expect(generated).toContain(".core-print{"); + }); + + it("ships the untrimmed core and reports when the core does not match the manifest", () => { + const reasons: string[] = []; + + const spacingTrimming = makeSpacingTrimming({ + "usedTokens": new Set(), + "onCannotTrim": reason => reasons.push(reason) + }); + + spacingTrimming.manifest.coreFiles["core/core.main.min.css"].contentHash = "deadbeef"; + + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile, + spacingTrimming + }); + + expect(generated).toContain(".fr-mt-2v{"); + expect(generated).toContain(".fr-pb-4v{"); + expect(reasons.length).toBe(1); + }); + + it("retains by prefix", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile, + "spacingTrimming": makeSpacingTrimming({ + "usedTokens": new Set(), + "keptPrefixes": ["fr-mt-"] + }) + }); + + expect(generated).toContain(".fr-mt-2v{"); + expect(generated).toContain(".fr-mt-md-2v{"); + expect(generated).not.toContain(".fr-pb-4v{"); + }); +}); + describe("getReferencedAssetRelativePaths", () => { it("collects the local assets referenced by the generated stylesheet", () => { expect( diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/spacingUtilitiesManifest.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/spacingUtilitiesManifest.test.ts new file mode 100644 index 000000000..45e2f4fa0 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/spacingUtilitiesManifest.test.ts @@ -0,0 +1,112 @@ +import { it, expect, describe } from "vitest"; +import * as fs from "fs"; +import { join as pathJoin } from "path"; +import { + generateSpacingUtilitiesManifest, + SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS +} from "../../../../scripts/build/generateSpacingUtilitiesManifest"; +import { + SPACING_UTILITY_CLASS_REGEX, + extractSpacingCssRules +} from "../../../../src/bin/trimSpacingUtilities"; + +/** + * Runs the build time generator against the actually installed @gouvfr/dsfr: + * its internal asserts are the cross-check of the string level extraction + * against a real CSS parser (grammar exhaustiveness, no mixed rule, rule text + * uniqueness, identical token sets across variants). A @gouvfr/dsfr bump that + * breaks any of those assumptions fails here, it cannot drift silently. + * + * If @gouvfr/dsfr is not installed this test must fail, not skip: a skipped + * drift guard is a guard that no longer guards anything. + */ +describe("spacing utilities manifest", () => { + const dsfrDistDirPath = pathJoin(process.cwd(), "node_modules", "@gouvfr", "dsfr", "dist"); + + const readDsfrDistFile = (fileRelativePath: string) => + fs.readFileSync(pathJoin(dsfrDistDirPath, ...fileRelativePath.split("/"))).toString("utf8"); + + const getReactDsfrSrcFilesContents = () => { + const contents: string[] = []; + + (function walk(dirPath: string) { + for (const dirent of fs.readdirSync(dirPath, { "withFileTypes": true })) { + const path = pathJoin(dirPath, dirent.name); + + if (dirent.isDirectory()) { + if (dirent.name === "generatedFromCss" || dirent.name === "bin") { + continue; + } + walk(path); + continue; + } + + if (!/\.tsx?$/.test(dirent.name)) { + continue; + } + + contents.push(fs.readFileSync(path).toString("utf8")); + } + })(pathJoin(process.cwd(), "src")); + + return contents; + }; + + it("the installed @gouvfr/dsfr satisfies every assumption of the extraction (cross-check against a real CSS parser)", () => { + expect(fs.existsSync(dsfrDistDirPath)).toBe(true); + + const manifest = generateSpacingUtilitiesManifest({ + readDsfrDistFile, + "dsfrVersion": "0.0.0-irrelevant-here", + "reactDsfrSrcFilesContents": getReactDsfrSrcFilesContents() + }); + + expect(Object.keys(manifest.coreFiles)).toStrictEqual([ + ...SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS + ]); + }); + + it("finds the exhaustive spacing grid (re-derived value for dsfr 1.14.2, adjust on a dsfr bump)", () => { + const manifest = generateSpacingUtilitiesManifest({ + readDsfrDistFile, + "dsfrVersion": "0.0.0-irrelevant-here", + "reactDsfrSrcFilesContents": [] + }); + + for (const fileRelativePath of SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS) { + expect(manifest.coreFiles[fileRelativePath].spacingRuleCount).toBe(1215); + } + + const tokens = new Set( + extractSpacingCssRules({ + "rawCssCode": readDsfrDistFile(SPACING_MANIFEST_CORE_FILE_RELATIVE_PATHS[0]) + }).flatMap(({ tokens }) => tokens) + ); + + expect(tokens.size).toBe(2457); + + for (const token of tokens) { + expect(SPACING_UTILITY_CLASS_REGEX.test(`.${token}`)).toBe(true); + } + }); + + it("alwaysKeepTokens covers the spacing utilities that react-dsfr's own components render", () => { + const manifest = generateSpacingUtilitiesManifest({ + readDsfrDistFile, + "dsfrVersion": "0.0.0-irrelevant-here", + "reactDsfrSrcFilesContents": getReactDsfrSrcFilesContents() + }); + + // The tokens rendered by src/ components at the time of writing + // (AgentConnectButton, MegaMenu, consentManagement Placeholder). + // New usages in src/ extend the generated list on their own, this only + // pins that the mechanism keeps deriving them. + for (const token of ["fr-mt-1v", "fr-mb-2v", "fr-mb-4v", "fr-mb-6v"]) { + expect(manifest.alwaysKeepTokens).toContain(token); + } + + // Derived from sources, deterministic: sorted and duplicate free. + expect([...manifest.alwaysKeepTokens].sort()).toStrictEqual(manifest.alwaysKeepTokens); + expect(new Set(manifest.alwaysKeepTokens).size).toBe(manifest.alwaysKeepTokens.length); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/trimSpacingUtilitiesFromCoreCss.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/trimSpacingUtilitiesFromCoreCss.test.ts new file mode 100644 index 000000000..2eef96a04 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/trimSpacingUtilitiesFromCoreCss.test.ts @@ -0,0 +1,190 @@ +import { it, expect, describe } from "vitest"; +import { + trimSpacingUtilitiesFromCoreCss, + extractSpacingCssRules, + type SpacingUtilitiesManifest +} from "../../../../src/bin/trimSpacingUtilities"; +import { fnv1aHashToHex } from "../../../../src/bin/tools/fnv1aHashToHex"; + +const coreCssCode = [ + '@charset "UTF-8";', + ".core{--x:1}", + ".fr-mt-1v,.fr-my-1v{margin-top:.25rem!important}", + ".fr-mt-2v{margin-top:.5rem!important}", + ".fr-pb-4v{padding-bottom:1rem!important}", + "@media (min-width:48em){.fr-mt-md-2v{margin-top:.5rem!important}}", + ".after{color:red}" +].join(""); + +const makeManifest = (): SpacingUtilitiesManifest => ({ + "dsfrVersion": "0.0.0-test", + "alwaysKeepTokens": [], + "coreFiles": { + "core/core.main.min.css": { + "contentHash": fnv1aHashToHex(coreCssCode), + "spacingRuleCount": extractSpacingCssRules({ "rawCssCode": coreCssCode }).length + } + } +}); + +const failOnCannotTrim = (reason: string) => { + throw new Error(`onCannotTrim was not expected to be called: ${reason}`); +}; + +// The fail-safe paths come first: when anything is off, the stylesheet must +// ship byte identical to the input, and the caller must be told. +describe("trimSpacingUtilitiesFromCoreCss, fail-safe paths", () => { + it("does not trim and reports when the file is not covered by the manifest", () => { + const reasons: string[] = []; + + const { cssCode, wasTrimmed } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(), + "keptPrefixes": [], + "onCannotTrim": reason => reasons.push(reason) + }); + + expect(cssCode).toBe(coreCssCode); + expect(wasTrimmed).toBe(false); + expect(reasons.length).toBe(1); + }); + + it("does not trim and reports on a content hash mismatch (single corrupted byte)", () => { + const reasons: string[] = []; + + const corrupted = coreCssCode.replace(".core{--x:1}", ".core{--x:2}"); + + const { cssCode, wasTrimmed } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": corrupted, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(), + "keptPrefixes": [], + "onCannotTrim": reason => reasons.push(reason) + }); + + expect(cssCode).toBe(corrupted); + expect(wasTrimmed).toBe(false); + expect(reasons.length).toBe(1); + }); + + it("does not trim and reports on a spacing rule count mismatch", () => { + const reasons: string[] = []; + + const manifest = makeManifest(); + manifest.coreFiles["core/core.main.min.css"].spacingRuleCount += 1; + + const { cssCode, wasTrimmed } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": manifest, + "usedTokens": new Set(), + "keptPrefixes": [], + "onCannotTrim": reason => reasons.push(reason) + }); + + expect(cssCode).toBe(coreCssCode); + expect(wasTrimmed).toBe(false); + expect(reasons.length).toBe(1); + }); +}); + +describe("trimSpacingUtilitiesFromCoreCss, trimming", () => { + it("is a byte identical no-op when every token is used", () => { + const { cssCode, wasTrimmed, removedRuleCount } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(["fr-mt-1v", "fr-my-1v", "fr-mt-2v", "fr-pb-4v", "fr-mt-md-2v"]), + "keptPrefixes": [], + "onCannotTrim": failOnCannotTrim + }); + + expect(cssCode).toBe(coreCssCode); + expect(wasTrimmed).toBe(true); + expect(removedRuleCount).toBe(0); + }); + + it("partitions exactly: output plus removed rules reassemble the input, nothing else moves", () => { + const before = extractSpacingCssRules({ "rawCssCode": coreCssCode }); + + const { cssCode } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(["fr-mt-2v"]), + "keptPrefixes": [], + "onCannotTrim": failOnCannotTrim + }); + + const removedRules = before + .filter(({ tokens }) => !tokens.includes("fr-mt-2v")) + .sort((a, b) => a.start - b.start); + + // Reinsert the removed rules in ascending position order: everything before + // each insertion point is already restored, so the original offset is exact. + // The input must come back byte for byte. + let reassembled = cssCode; + for (const { start, ruleText } of removedRules) { + reassembled = reassembled.slice(0, start) + ruleText + reassembled.slice(start); + } + + expect(reassembled).toBe(coreCssCode); + }); + + it("keeps a grouped rule whole when a single one of its tokens is used", () => { + const { cssCode, removedRuleCount } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(["fr-my-1v"]), + "keptPrefixes": [], + "onCannotTrim": failOnCannotTrim + }); + + expect(cssCode).toContain(".fr-mt-1v,.fr-my-1v{"); + expect(cssCode).not.toContain(".fr-mt-2v{"); + expect(removedRuleCount).toBe(3); + }); + + it("keeps every token extending a kept prefix, trims the rest", () => { + const { cssCode } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(), + "keptPrefixes": ["fr-mt-"], + "onCannotTrim": failOnCannotTrim + }); + + // fr-mt- keeps fr-mt-1v (and its grouped sibling), fr-mt-2v, fr-mt-md-2v... + expect(cssCode).toContain(".fr-mt-1v,.fr-my-1v{"); + expect(cssCode).toContain(".fr-mt-2v{"); + expect(cssCode).toContain(".fr-mt-md-2v{"); + // ...but not the paddings. + expect(cssCode).not.toContain(".fr-pb-4v{"); + }); + + it("removes everything spacing when nothing is used, leaving the rest untouched", () => { + const { cssCode, removedRuleCount, spacingRuleCount } = trimSpacingUtilitiesFromCoreCss({ + "rawCssCode": coreCssCode, + "coreFileRelativePath": "core/core.main.min.css", + "manifest": makeManifest(), + "usedTokens": new Set(), + "keptPrefixes": [], + "onCannotTrim": failOnCannotTrim + }); + + expect(removedRuleCount).toBe(spacingRuleCount); + expect(cssCode).toBe( + [ + '@charset "UTF-8";', + ".core{--x:1}", + "@media (min-width:48em){}", + ".after{color:red}" + ].join("") + ); + }); +}); From 3ce8d3d30539ebf5b936d7e2606ced6791582ee7 Mon Sep 17 00:00:00 2001 From: kevbarns Date: Fri, 21 Aug 2026 16:51:15 +0200 Subject: [PATCH 2/2] fix(core): degrade gracefully on a malformed spacing utilities manifest A corrupted spacing-utilities.json (invalid JSON or unexpected shape) used to surface as a raw stack trace, unlike the corrupted-core case which warns and ships untrimmed. parseSpacingUtilitiesManifest now shape-checks the file and a malformed manifest takes the same path as a missing one: never-silenced warning, untrimmed stylesheet, non zero exit under --strict. Also log that "additionalSpacingUtilities" has no effect when the flag is not passed (a forgotten flag made the configuration a silent no-op), and document in the README exactly which two dynamic class construction forms are detected (template literal interpolation and + concatenation). --- src/bin/README.md | 7 +++- src/bin/only-include-used-components.ts | 26 ++++++++++-- src/bin/trimSpacingUtilities.ts | 30 ++++++++++++++ .../parseSpacingUtilitiesManifest.test.ts | 40 +++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 test/runtime/scripts/onlyIncludeUsedComponents/parseSpacingUtilitiesManifest.test.ts diff --git a/src/bin/README.md b/src/bin/README.md index 7d0e68179..08c927a15 100644 --- a/src/bin/README.md +++ b/src/bin/README.md @@ -123,8 +123,11 @@ How it stays safe: - Used utilities are detected as literal class names in the same crawled sources as the component detection. A mention in a comment or an url counts as a usage (over-including only costs bytes). -- **Dynamically constructed class names** (`` `fr-mt-${x}w` ``, `"fr-m" + side`) are - detected, and every utility their static prefix could produce is kept, with a warning. +- **Dynamically constructed class names** are detected, and every utility their static + prefix could produce is kept, with a warning. Exactly two forms are recognized: + template literal interpolation (`` `fr-mt-${x}w` ``) and string concatenation with `+` + (`"fr-m" + side`). Anything else — `.concat()`, an array `join`, a class built from its + suffix (`` `${side}-2v` ``) — is not seen and needs `additionalSpacingUtilities` below. `` `fr-icon-${name}` `` does not trigger anything (no spacing class starts with that), `` `fr-m${x}` `` keeps all the margins but still trims the paddings, and a bare `` `fr-${x}` `` keeps the whole grid. diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts index 1911ac2f9..902c3f906 100644 --- a/src/bin/only-include-used-components.ts +++ b/src/bin/only-include-used-components.ts @@ -67,6 +67,7 @@ import { detectUsedSpacingTokens, detectDynamicSpacingClassPrefixes, trimSpacingUtilitiesFromCoreCss, + parseSpacingUtilitiesManifest, type SpacingUtilitiesManifest } from "./trimSpacingUtilities"; @@ -1023,7 +1024,16 @@ export async function main(args: string[]) { ); } - const manifest: SpacingUtilitiesManifest = JSON.parse(manifestSourceCode); + const manifest = parseSpacingUtilitiesManifest({ manifestSourceCode }); + + if (manifest === undefined) { + return cannotEnable( + [ + `${PATH_OF_SPACING_UTILITIES_JSON} is malformed, is your installation of`, + `${CODEGOUV_REACT_DSFR} complete?` + ].join(" ") + ); + } const coreCssCode = (() => { for (const fileRelativePath of Object.keys(manifest.coreFiles)) { @@ -1251,8 +1261,18 @@ export async function main(args: string[]) { ); if (spacingState === undefined) { - // Inert without --trim-spacing-utilities (or when its setup failed, - // which has already been warned about). + if (!commandContext.doTrimSpacingUtilities) { + // A forgotten flag would otherwise make the whole configuration + // a silent no-op. + log?.( + [ + `"react-dsfr"."additionalSpacingUtilities" is configured but`, + `--trim-spacing-utilities was not passed, it has no effect.` + ].join(" ") + ); + } + + // Otherwise the setup failed, which has already been warned about. break additional_spacing_utilities; } diff --git a/src/bin/trimSpacingUtilities.ts b/src/bin/trimSpacingUtilities.ts index cd94b18e9..a89957036 100644 --- a/src/bin/trimSpacingUtilities.ts +++ b/src/bin/trimSpacingUtilities.ts @@ -57,6 +57,36 @@ export type SpacingUtilitiesManifest = { coreFiles: Record; }; +/** + * Parses and shape-checks a spacing-utilities.json source. Returns undefined on + * invalid JSON or an unexpected shape: a malformed manifest is the same failure + * as a missing one (broken or tampered installation), the caller warns and + * ships untrimmed instead of surfacing a raw stack trace. + */ +export function parseSpacingUtilitiesManifest(params: { + manifestSourceCode: string; +}): SpacingUtilitiesManifest | undefined { + const { manifestSourceCode } = params; + + try { + const parsed = JSON.parse(manifestSourceCode); + + if ( + parsed === null || + typeof parsed !== "object" || + parsed["coreFiles"] === null || + typeof parsed["coreFiles"] !== "object" || + !Array.isArray(parsed["alwaysKeepTokens"]) + ) { + return undefined; + } + + return parsed; + } catch { + return undefined; + } +} + /** * Extracts the CSS rules whose selectors are ALL spacing utility classes. * A rule with a selector that is anything else (another class, a descendant diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/parseSpacingUtilitiesManifest.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/parseSpacingUtilitiesManifest.test.ts new file mode 100644 index 000000000..21b1be916 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/parseSpacingUtilitiesManifest.test.ts @@ -0,0 +1,40 @@ +import { it, expect, describe } from "vitest"; +import { parseSpacingUtilitiesManifest } from "../../../../src/bin/trimSpacingUtilities"; + +// A malformed manifest must degrade like a missing one (undefined, the caller +// warns and ships untrimmed), never surface as a raw stack trace. +describe("parseSpacingUtilitiesManifest, malformed inputs", () => { + it("returns undefined on invalid JSON", () => { + for (const manifestSourceCode of ["{invalid", "", "null", '"a string"', "42"]) { + expect(parseSpacingUtilitiesManifest({ manifestSourceCode })).toBe(undefined); + } + }); + + it("returns undefined on an unexpected shape", () => { + for (const manifestSourceCode of [ + "{}", + '{"dsfrVersion":"x"}', + '{"coreFiles":null,"alwaysKeepTokens":[]}', + '{"coreFiles":"not an object","alwaysKeepTokens":[]}', + '{"coreFiles":{},"alwaysKeepTokens":"not an array"}' + ]) { + expect(parseSpacingUtilitiesManifest({ manifestSourceCode })).toBe(undefined); + } + }); +}); + +describe("parseSpacingUtilitiesManifest, nominal case", () => { + it("returns the parsed manifest", () => { + const manifest = { + "dsfrVersion": "1.14.2", + "alwaysKeepTokens": ["fr-mt-1v"], + "coreFiles": { + "core/core.main.min.css": { "contentHash": "d931172e", "spacingRuleCount": 1215 } + } + }; + + expect( + parseSpacingUtilitiesManifest({ "manifestSourceCode": JSON.stringify(manifest) }) + ).toStrictEqual(manifest); + }); +});