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..08c927a15 100644 --- a/src/bin/README.md +++ b/src/bin/README.md @@ -100,6 +100,59 @@ 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** 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. + +### `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..902c3f906 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,15 @@ 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, + parseSpacingUtilitiesManifest, + type SpacingUtilitiesManifest +} from "./trimSpacingUtilities"; /** * The DSFR CSS components (dsfr/component/ directories), listed in the @@ -482,13 +502,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 +564,7 @@ export function generateDsfrCssCode(params: { return { dirRelativePath, + fileRelativePath, rawCssCode }; } @@ -539,14 +574,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 +678,7 @@ type CommandContext = { | undefined; isSilent: boolean; isStrict: boolean; + doTrimSpacingUtilities: boolean; }; const CODEGOUV_REACT_DSFR: string = JSON.parse( @@ -707,6 +784,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 = 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)) { + 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 +1121,248 @@ 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; - break additional_components_from_package_json; + 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)); + } } - 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' + ); - doIncludeAllComponents = true; + if (spacingState === undefined) { + 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(" ") + ); + } - continue; + // Otherwise the setup failed, which has already been warned about. + break additional_spacing_utilities; } - 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(" ") - ); + 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(" ") + ); + + 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); + } + } + } + + 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 +1380,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 +1408,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 +1447,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..a89957036 --- /dev/null +++ b/src/bin/trimSpacingUtilities.ts @@ -0,0 +1,365 @@ +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; +}; + +/** + * 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 + * 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/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); + }); +}); 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("") + ); + }); +});