Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions scripts/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down Expand Up @@ -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)) {
Expand Down
211 changes: 211 additions & 0 deletions scripts/build/generateSpacingUtilitiesManifest.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string, number>();
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<string>();

for (const rawFileContent of reactDsfrSrcFilesContents) {
detectUsedSpacingTokens({
rawFileContent,
"spacingTokens": referenceTokens
}).forEach(token => alwaysKeepTokens.add(token));
}

return {
dsfrVersion,
"alwaysKeepTokens": Array.from(alwaysKeepTokens).sort(),
coreFiles
};
}
53 changes: 53 additions & 0 deletions src/bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading