diff --git a/package.json b/package.json index 408e22340..58ea8951d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "bin": { "react-dsfr": "dist/bin/react-dsfr.js", "copy-dsfr-to-public": "dist/bin/copy-dsfr-to-public.js", - "only-include-used-icons": "dist/bin/only-include-used-icons.js" + "only-include-used-icons": "dist/bin/only-include-used-icons.js", + "only-include-used-components": "dist/bin/only-include-used-components.js" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/src/bin/only-include-used-components.ts b/src/bin/only-include-used-components.ts new file mode 100644 index 000000000..9f5bf5f8c --- /dev/null +++ b/src/bin/only-include-used-components.ts @@ -0,0 +1,1044 @@ +#!/usr/bin/env node + +/** + * This script is ran with `npx react-dsfr only-include-used-components` + * It scans your codebase to find which react-dsfr components are used and rebuilds + * the node_modules/@codegouvfr/react-dsfr/dsfr/dsfr.css and dsfr.min.css files + * with only the CSS of those components (plus the core, which is always included). + * The public/dsfr/dsfr.min.css file is patched as well if applicable (not in Next.js for example). + * + * The trimmed stylesheets are rebuilt from the granular CSS files shipped by the DSFR + * (dsfr/core/*, dsfr/component/*, dsfr/scheme/*) so no CSS rule is ever rewritten, + * only whole components are included or excluded. This makes the output robust to + * classes and attributes dynamically added by the DSFR JavaScript (data-fr-js-*). + * + * Usage of a component is detected by: + * - Imports of `@codegouvfr/react-dsfr/` in your sources. + * - Usage of the component's CSS class names (e.g. "fr-table") in your sources, + * for when you use raw DSFR classes without the React component. + * + * You can force the inclusion of components that the detection would miss by adding + * to your package.json: + * "react-dsfr": { + * "additionalComponents": ["table", "Range"] + * } + * (values are DSFR CSS component names or react-dsfr component names) + * + * There are two optional arguments that you can use: + * - `--projectDir ` to specify the project directory. Default to the current working directory. + * This can be used in monorepos to specify the react project directory. + * - `--silent` to disable console.log + */ + +import { getProjectRoot } from "./tools/getProjectRoot"; +import * as fs from "fs"; +import { join as pathJoin, relative as pathRelative } from "path"; +import { assert } from "tsafe/assert"; +import { exclude } from "tsafe/exclude"; +import { writeFile, readFile, rm } from "fs/promises"; +import { crawl } from "./tools/crawl"; +import { basename as pathBasename, sep as pathSep, dirname as pathDirname } from "path"; +import yargsParser from "yargs-parser"; +import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath"; +import { readPublicDirPath } from "./readPublicDirPath"; +import { existsAsync } from "./tools/fs.existsAsync"; +import { fnv1aHashToHex } from "./tools/fnv1aHashToHex"; +import { modifyHtmlHrefs } from "./tools/modifyHtmlHrefs"; + +/** + * The DSFR CSS components (dsfr/component/ directories), listed in the + * order in which they are concatenated in the upstream dsfr.css bundle. + * Preserving this order preserves the CSS cascade of the original stylesheet. + * (Order determined empirically by locating each component's section in dsfr.main.css) + */ +export const DSFR_COMPONENTS_CASCADE_ORDER = [ + "upload", + "range", + "accordion", + "badge", + "logo", + "button", + "connect", + "quote", + "breadcrumb", + "form", + "stepper", + "tooltip", + "link", + "sidemenu", + "callout", + "highlight", + "tab", + "pagination", + "summary", + "tag", + "download", + "alert", + "notice", + "radio", + "card", + "checkbox", + "input", + "content", + "transcription", + "segmented", + "toggle", + "skiplink", + "select", + "modal", + "navigation", + "share", + "footer", + "tile", + "search", + "consent", + "follow", + "password", + "translate", + "table", + "header" +] as const; + +export type DsfrComponentName = typeof DSFR_COMPONENTS_CASCADE_ORDER[number]; + +/** + * Map from react-dsfr module (the `@codegouvfr/react-dsfr/` import subpath) + * to the DSFR CSS components its markup depends on, transitive dependencies included + * (e.g. the Header renders a navigation, a search bar and a modal on mobile). + * When in doubt a dependency is included: too much CSS is only a size cost, + * not enough CSS is a rendering bug. + * Modules that do not appear here and are not known non-component modules trigger + * a fail-safe: every component is included. + */ +export const REACT_DSFR_MODULE_TO_DSFR_COMPONENTS: Record = { + "Accordion": ["accordion"], + "AgentConnectButton": ["connect", "button"], + "Alert": ["alert", "link", "button"], + "Badge": ["badge"], + "Breadcrumb": ["breadcrumb", "link"], + "Button": ["button", "link"], + "ButtonsGroup": ["button", "link"], + "CallOut": ["callout", "button", "link"], + "Card": ["card", "badge", "link"], + // The Chart components pull their CSS from the @gouvfr/dsfr-chart peer dependency. + "Chart": [], + "Checkbox": ["checkbox", "radio", "form"], + "Display": ["modal", "radio", "form", "button", "link"], + "Download": ["download", "link"], + "Follow": ["follow", "form", "input", "upload", "button", "link", "alert", "checkbox"], + "Footer": ["footer", "logo", "button", "link"], + "FranceConnectButton": ["connect", "button"], + "Header": [ + "header", + "navigation", + "modal", + "logo", + "button", + "link", + "search", + "input", + "form" + ], + "Highlight": ["highlight"], + "Input": ["input", "form", "upload"], + "LanguageSelect": ["translate", "navigation", "button", "link"], + "MainNavigation": ["navigation", "link"], + "Modal": ["modal", "button", "link"], + "MonCompteProButton": ["connect", "button"], + "Notice": ["notice", "button", "link"], + "Pagination": ["pagination", "link"], + "ProConnectButton": ["connect", "button"], + "Quote": ["quote"], + "RadioButtons": ["radio", "checkbox", "form"], + "Range": ["range", "form"], + "SearchBar": ["search", "input", "form", "button"], + "SegmentedControl": ["segmented", "form"], + "Select": ["select", "form"], + "SelectNext": ["select", "form"], + "SideMenu": ["sidemenu", "link"], + "SkipLinks": ["skiplink", "link"], + "Stepper": ["stepper"], + "Summary": ["summary", "link"], + "Table": ["table"], + "Tabs": ["tab"], + "Tag": ["tag"], + "TagsGroup": ["tag"], + "Tile": ["tile", "link"], + "ToggleSwitch": ["toggle", "form"], + "ToggleSwitchGroup": ["toggle", "form"], + "Tooltip": ["tooltip", "button"], + "Upload": ["upload", "input", "form"], + "blocks/PasswordInput": ["password", "input", "form", "link", "checkbox"], + "consentManagement": ["consent", "modal", "button", "link", "radio", "form"] +}; + +/** + * CSS class name prefixes that reveal a direct usage of a DSFR component in the + * sources (when raw fr-* classes are used without importing the React component). + * Substring matching is intentional and fail-safe: matching too much only means + * including a component's CSS that may not be needed. + */ +export const DSFR_COMPONENT_DETECTION_CLASS_PREFIXES: Record = { + "accordion": ["fr-accordion"], + "alert": ["fr-alert"], + "badge": ["fr-badge"], + "breadcrumb": ["fr-breadcrumb"], + "button": ["fr-btn"], + "callout": ["fr-callout"], + "card": ["fr-card"], + "checkbox": ["fr-checkbox"], + "connect": ["fr-connect"], + "consent": ["fr-consent"], + "content": ["fr-content-media", "fr-responsive-img", "fr-responsive-vid"], + "download": ["fr-download"], + "follow": ["fr-follow"], + "footer": ["fr-footer"], + "form": ["fr-fieldset", "fr-label", "fr-hint-text", "fr-message", "fr-input-group"], + "header": ["fr-header"], + "highlight": ["fr-highlight"], + "input": ["fr-input"], + "link": ["fr-link"], + "logo": ["fr-logo"], + "modal": ["fr-modal"], + "navigation": ["fr-nav", "fr-menu", "fr-mega-menu"], + "notice": ["fr-notice"], + "pagination": ["fr-pagination"], + "password": ["fr-password"], + "quote": ["fr-quote"], + "radio": ["fr-radio"], + "range": ["fr-range"], + "search": ["fr-search-bar"], + "segmented": ["fr-segmented"], + "select": ["fr-select"], + "share": ["fr-share"], + "sidemenu": ["fr-sidemenu"], + "skiplink": ["fr-skiplink"], + "stepper": ["fr-stepper"], + "summary": ["fr-summary"], + "tab": ["fr-tabs"], + "table": ["fr-table"], + "tag": ["fr-tag"], + "tile": ["fr-tile"], + "toggle": ["fr-toggle"], + "tooltip": ["fr-tooltip"], + "transcription": ["fr-transcription"], + "translate": ["fr-translate"], + "upload": ["fr-upload"] +}; + +/** + * react-dsfr modules that are known not to render any DSFR component markup + * (hooks, utilities, integration helpers, assets...). + */ +const NON_COMPONENT_MODULE_IDS = new Set([ + "fr", + "i18n", + "spa", + "start", + "tss", + "mui", + "link", + "picto", + "shared", + "tools", + "assets", + "favicon", + "main.css", + "dsfr.css", + "next-appdir", + "next-app-router", + "next-pagesdir", + "useBreakpointsValues", + "useBreakpointsValuesPx", + "useColors", + "useIsDark", + "eulerianAnalytics", + "getHtmlAttributes", + "zz_internal" +]); + +export function getReactDsfrImportedModuleIds(params: { rawFileContent: string }): string[] { + const { rawFileContent } = params; + + const moduleIds = new Set(); + + for (const [, subpath] of rawFileContent.matchAll( + /@codegouvfr\/react-dsfr\/([\w@.-]+(?:\/[\w@.-]+)*)/g + )) { + const segments = subpath + .replace(/\.(?:js|mjs|cjs|ts|tsx|jsx)$/, "") + .split("/") + .filter(segment => segment !== "index" && segment !== ""); + + if (segments.length === 0) { + continue; + } + + moduleIds.add( + (() => { + switch (segments[0]) { + case "blocks": + return segments.slice(0, 2).join("/"); + case "dsfr": + return segments.slice(0, 3).join("/"); + default: + return segments[0]; + } + })() + ); + } + + return Array.from(moduleIds); +} + +export function resolveModuleIdToDsfrComponents(params: { + moduleId: string; +}): DsfrComponentName[] | undefined { + const { moduleId } = params; + + direct_import_of_a_dsfr_component_stylesheet: { + const match = moduleId.match(/^dsfr\/component\/([^/]+)/); + + if (match === null) { + break direct_import_of_a_dsfr_component_stylesheet; + } + + const dsfrComponent = DSFR_COMPONENTS_CASCADE_ORDER.find( + componentName => componentName === match[1] + ); + + return dsfrComponent === undefined ? undefined : [dsfrComponent]; + } + + if (moduleId.startsWith("dsfr/")) { + return []; + } + + { + const dsfrComponents = REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[moduleId.split("/")[0]]; + + if (dsfrComponents !== undefined) { + return dsfrComponents; + } + } + + { + const dsfrComponents = REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[moduleId]; + + if (dsfrComponents !== undefined) { + return dsfrComponents; + } + } + + const firstSegment = moduleId.split("/")[0]; + + if (NON_COMPONENT_MODULE_IDS.has(firstSegment)) { + return []; + } + + // A component this script does not know about (newer react-dsfr version?), + // or a new non-component module missing from NON_COMPONENT_MODULE_IDS. + return undefined; +} + +export function detectDsfrComponentsFromClassNames(params: { + rawFileContent: string; +}): DsfrComponentName[] { + const { rawFileContent } = params; + + if (!rawFileContent.includes("fr-")) { + return []; + } + + return Object.entries(DSFR_COMPONENT_DETECTION_CLASS_PREFIXES) + .filter(([, classPrefixes]) => + classPrefixes.some(classPrefix => rawFileContent.includes(classPrefix)) + ) + .map(([componentName]) => componentName as DsfrComponentName); +} + +export function rewriteCssRelativeUrls(params: { + rawCssCode: string; + /** Posix style, relative to the dsfr directory, e.g. "component/header" */ + cssFileRelativeDirPath: string; +}): string { + const { rawCssCode, cssFileRelativeDirPath } = params; + + return rawCssCode.replace(/url\((["']?)([^)"']+)\1\)/g, (match, quote: string, url: string) => { + if (/^(?:data:|https?:|\/)/.test(url)) { + return match; + } + + const pathSegments: string[] = []; + + for (const segment of [...cssFileRelativeDirPath.split("/"), ...url.split("/")]) { + if (segment === "." || segment === "") { + continue; + } + + if (segment === ".." && pathSegments.length !== 0) { + pathSegments.pop(); + continue; + } + + pathSegments.push(segment); + } + + return `url(${quote}${pathSegments.join("/")}${quote})`; + }); +} + +/** + * String level equivalent of scripts/build/patchCssForMui.ts, the selectors + * `button:not(:disabled):hover` and `button:not(:disabled):active` only exist + * in the core stylesheet, within the `(hover: hover) and (pointer: fine)` media query. + */ +export function patchCoreCssCodeForCompatWithMui(params: { rawCssCode: string }): string { + const { rawCssCode } = params; + + return rawCssCode + .replace( + /button:not\(:disabled\):hover(?![\w:([-])/g, + 'button:not(:disabled):hover:not([class^="Mui"])' + ) + .replace( + /button:not\(:disabled\):active(?![\w:([-])/g, + 'button:not(:disabled):active:not([class^="Mui"])' + ); +} + +export function generateDsfrCssCode(params: { + dsfrComponents: string[]; + isMinified: boolean; + /** Returns the raw code of a file within the dsfr directory, undefined if it does not exist */ + readDsfrFile: (fileRelativePath: string) => string | undefined; +}): string { + const { dsfrComponents, isMinified, readDsfrFile } = params; + + const sortedDsfrComponents = [ + ...DSFR_COMPONENTS_CASCADE_ORDER.filter(componentName => + dsfrComponents.includes(componentName) + ), + // Fail-safe for component directories unknown to this script + // (newer DSFR version): appended in alphabetical order. + ...dsfrComponents + .filter( + componentName => + !DSFR_COMPONENTS_CASCADE_ORDER.includes(componentName as DsfrComponentName) + ) + .sort() + ]; + + const readCssChunks = (params: { + getFileRelativePathCandidates: (dirRelativePath: string, basename: string) => string[]; + }) => { + const { getFileRelativePathCandidates } = params; + + return [ + ["core", "core"], + ["scheme", "scheme"], + ...sortedDsfrComponents.map(componentName => [ + `component/${componentName}`, + componentName + ]) + ] + .map(([dirRelativePath, basename]) => { + for (const fileRelativePath of getFileRelativePathCandidates( + dirRelativePath, + basename + )) { + const rawCssCode = readDsfrFile(fileRelativePath); + + if (rawCssCode === undefined) { + continue; + } + + return { + dirRelativePath, + rawCssCode + }; + } + + return undefined; + }) + .filter(exclude(undefined)); + }; + + const cssChunks = [ + ...readCssChunks({ + "getFileRelativePathCandidates": (dirRelativePath, basename) => + (isMinified + ? [`${basename}.main.min.css`, `${basename}.min.css`] + : [`${basename}.main.css`, `${basename}.css`] + ).map(fileBasename => `${dirRelativePath}/${fileBasename}`) + }), + ...readCssChunks({ + "getFileRelativePathCandidates": (dirRelativePath, basename) => + (isMinified ? [`${basename}.print.min.css`] : [`${basename}.print.css`]).map( + fileBasename => `${dirRelativePath}/${fileBasename}` + ) + }) + ]; + + assert( + cssChunks.length !== 0, + "Can't find the granular DSFR stylesheets to rebuild dsfr.css from" + ); + + return [ + `/*! DSFR stylesheet rebuilt by react-dsfr only-include-used-components, components: ${sortedDsfrComponents.join( + ", " + )} */`, + ...cssChunks.map(({ dirRelativePath, rawCssCode }) => { + let cssCode = rawCssCode + .replace(/@charset "UTF-8";\s*/g, "") + .replace(/^\/\*![\s\S]*?\*\/\s*/, "") + .replace(/\/\*# sourceMappingURL=[^*]*\*\/\s*/g, ""); + + cssCode = rewriteCssRelativeUrls({ + "rawCssCode": cssCode, + "cssFileRelativeDirPath": dirRelativePath + }); + + if (dirRelativePath === "core") { + cssCode = patchCoreCssCodeForCompatWithMui({ "rawCssCode": cssCode }); + } + + return cssCode.trim(); + }) + ].join("\n"); +} + +type CommandContext = { + projectDirPath: string; + srcFilePaths: string[]; + dsfrDirPath: string; + spaParams: + | { + dsfrDirPath_static: string; + htmlFilePath: string; + } + | undefined; + isSilent: boolean; +}; + +const CODEGOUV_REACT_DSFR: string = JSON.parse( + fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8") +)["name"]; + +async function getCommandContext(args: string[]): Promise { + const argv = yargsParser(args); + + const projectDirPath: string = (() => { + read_from_argv: { + const arg = argv["projectDir"]; + + if (arg === undefined) { + break read_from_argv; + } + + return getAbsoluteAndInOsFormatPath({ "pathIsh": arg, "cwd": process.cwd() }); + } + + return process.cwd(); + })(); + + special_case_for_our_storybook: { + const packageJsonFilePath = pathJoin(process.cwd(), "package.json"); + + if (!(await existsAsync(packageJsonFilePath))) { + break special_case_for_our_storybook; + } + + const packageJson = JSON.parse((await readFile(packageJsonFilePath)).toString("utf8")); + + if (packageJson["name"] !== CODEGOUV_REACT_DSFR) { + break special_case_for_our_storybook; + } + + // The storybook documents every component, there is nothing to trim. + return undefined; + } + + const nodeModulesDirPath = await (async function callee(n: number): Promise { + if (n >= projectDirPath.split(pathSep).length) { + throw new Error("Need to install node modules?"); + } + + const nodeModulesDirPath = pathJoin( + ...[projectDirPath, ...new Array(n).fill(".."), "node_modules"] + ); + + const doesExist = await existsAsync( + pathJoin(...[nodeModulesDirPath, ...CODEGOUV_REACT_DSFR.split("/")]) + ); + + if (!doesExist) { + return callee(n + 1); + } + + return nodeModulesDirPath; + })(0); + + const dsfrDirPath = pathJoin( + ...[nodeModulesDirPath, ...CODEGOUV_REACT_DSFR.split("/"), "dsfr"] + ); + + const dsfrDirPath_static = await (async () => { + const dsfrDirPath_static = pathJoin(await readPublicDirPath({ projectDirPath }), "dsfr"); + + if (!(await existsAsync(dsfrDirPath_static))) { + return undefined; + } + + return dsfrDirPath_static; + })(); + + const htmlFilePath = await (async () => { + if (dsfrDirPath_static === undefined) { + return undefined; + } + + vite: { + const filePath = pathJoin(projectDirPath, "index.html"); + + if (!fs.existsSync(filePath)) { + break vite; + } + + return filePath; + } + + cra: { + const filePath = pathJoin(pathDirname(dsfrDirPath_static), "index.html"); + + if (!fs.existsSync(filePath)) { + break cra; + } + + return filePath; + } + + // Next.js + return undefined; + })(); + + const isSilent = argv["silent"] === true; + + const srcFilePaths = ( + await Promise.all([ + crawl({ + "dirPath": projectDirPath, + "returnedPathsType": "absolute", + "getDoCrawlInDir": async ({ relativeDirPath }) => { + if (relativeDirPath === "dist") { + return false; + } + + if (relativeDirPath === "build") { + return false; + } + + if (pathBasename(relativeDirPath) === "node_modules") { + return false; + } + + if ( + await existsAsync(pathJoin(projectDirPath, relativeDirPath, "dsfr.min.css")) + ) { + // We don't want to search in public/dsfr + return false; + } + + if (pathBasename(relativeDirPath).startsWith(".")) { + return false; + } + + return true; + } + }), + crawl({ + "dirPath": nodeModulesDirPath, + "returnedPathsType": "absolute", + "getDoCrawlInDir": async ({ relativeDirPath }) => { + if ( + relativeDirPath.startsWith("@") && + relativeDirPath.split(pathSep).length === 1 + ) { + return true; + } + + if ( + relativeDirPath.split(pathSep).length === 1 || + (relativeDirPath.startsWith("@") && + relativeDirPath.split(pathSep).length === 2) + ) { + const parsedPackageJson = await readFile( + pathJoin(nodeModulesDirPath, relativeDirPath, "package.json") + ).then( + buff => JSON.parse(buff.toString("utf8")), + () => undefined + ); + + if (parsedPackageJson === undefined) { + return false; + } + + if (parsedPackageJson["name"] === CODEGOUV_REACT_DSFR) { + // Scanning react-dsfr's own sources would mark every component as used. + return false; + } + + if (parsedPackageJson["name"] === "tss-react") { + return false; + } + + if (parsedPackageJson["name"] === "@gouvfr/dsfr-chart") { + return false; + } + + if (parsedPackageJson["name"] === "@gouvfr/dsfr") { + return false; + } + + for (const packageName of [ + CODEGOUV_REACT_DSFR, + "@gouvfr/dsfr", + "@dataesr/react-dsfr" + ]) { + if ( + Object.keys({ + ...parsedPackageJson["dependencies"], + ...parsedPackageJson["devDependencies"], + ...parsedPackageJson["peerDependencies"] + }).includes(packageName) + ) { + return true; + } + } + + return false; + } + + if (pathBasename(relativeDirPath) === "generatedFromCss") { + return false; + } + + if (pathBasename(relativeDirPath) === "node_modules") { + return false; + } + + if (pathBasename(relativeDirPath).startsWith(".")) { + return false; + } + + return true; + } + }) + ]) + ) + .flat() + .filter( + filePath => + [ + "tsx", + "jsx", + "js", + "ts", + "mdx", + "html", + "htm", + "svelte", + "vue", + "css", + "scss", + "sass", + "less" + ].find(ext => filePath.endsWith(`.${ext}`)) !== undefined + ); + + return { + projectDirPath, + srcFilePaths, + dsfrDirPath, + "spaParams": (() => { + if (dsfrDirPath_static === undefined) { + return undefined; + } + + assert(htmlFilePath !== undefined); + + return { + dsfrDirPath_static, + htmlFilePath + }; + })(), + isSilent + }; +} + +export async function main(args: string[]) { + const commandContext = await getCommandContext(args); + + if (commandContext === undefined) { + return; + } + + const log = commandContext.isSilent ? undefined : console.log; + + const availableDsfrComponents = fs + .readdirSync(pathJoin(commandContext.dsfrDirPath, "component"), { "withFileTypes": true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(componentName => + ["main.min.css", "min.css", "main.css", "css"].some(ext => + fs.existsSync( + pathJoin( + commandContext.dsfrDirPath, + "component", + componentName, + `${componentName}.${ext}` + ) + ) + ) + ); + + const usedDsfrComponents = new Set(); + + let doIncludeAllComponents = false; + + await Promise.all( + commandContext.srcFilePaths.map(async srcFilePath => { + const rawFileContent = (await readFile(srcFilePath)).toString("utf8"); + + for (const moduleId of getReactDsfrImportedModuleIds({ rawFileContent })) { + const dsfrComponents = resolveModuleIdToDsfrComponents({ moduleId }); + + if (dsfrComponents === undefined) { + console.warn( + [ + `Unknown react-dsfr module "${moduleId}" imported in`, + `${pathRelative(process.cwd(), srcFilePath)},`, + `including every component's CSS to be safe.`, + `Please report it: https://github.com/codegouvfr/react-dsfr/issues` + ].join(" ") + ); + + doIncludeAllComponents = true; + + continue; + } + + if (dsfrComponents.length === 0) { + continue; + } + + log?.(`Found import of ${moduleId} in ${pathRelative(process.cwd(), srcFilePath)}`); + + dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + } + + for (const componentName of detectDsfrComponentsFromClassNames({ rawFileContent })) { + if (usedDsfrComponents.has(componentName)) { + continue; + } + + log?.( + `Found usage of ${componentName} classes in ${pathRelative( + process.cwd(), + srcFilePath + )}` + ); + + usedDsfrComponents.add(componentName); + } + }) + ); + + additional_components_from_package_json: { + const packageJsonFilePath = pathJoin(commandContext.projectDirPath, "package.json"); + + if (!(await existsAsync(packageJsonFilePath))) { + break additional_components_from_package_json; + } + + const additionalComponents: unknown = JSON.parse( + (await readFile(packageJsonFilePath)).toString("utf8") + )["react-dsfr"]?.["additionalComponents"]; + + if (additionalComponents === undefined) { + break additional_components_from_package_json; + } + + assert( + Array.isArray(additionalComponents) && + additionalComponents.every((value): value is string => typeof value === "string"), + 'Malformed "react-dsfr"."additionalComponents" in package.json, expected an array of strings' + ); + + for (const additionalComponent of additionalComponents) { + const dsfrComponents = + REACT_DSFR_MODULE_TO_DSFR_COMPONENTS[additionalComponent] ?? + (availableDsfrComponents.includes(additionalComponent) + ? [additionalComponent] + : undefined); + + if (dsfrComponents === undefined) { + console.warn( + [ + `Unknown component "${additionalComponent}" in`, + `"react-dsfr"."additionalComponents" of your package.json,`, + `including every component's CSS to be safe.` + ].join(" ") + ); + + doIncludeAllComponents = true; + + continue; + } + + log?.(`Including ${additionalComponent} (from package.json additionalComponents)`); + + dsfrComponents.forEach(componentName => usedDsfrComponents.add(componentName)); + } + } + + const dsfrComponents = doIncludeAllComponents + ? availableDsfrComponents + : availableDsfrComponents.filter(componentName => usedDsfrComponents.has(componentName)); + + log?.( + `Including the CSS of ${dsfrComponents.length} DSFR components (out of ${availableDsfrComponents.length}).` + ); + + const readDsfrFile = (fileRelativePath: string) => { + const filePath = pathJoin(commandContext.dsfrDirPath, ...fileRelativePath.split("/")); + + if (!fs.existsSync(filePath)) { + return undefined; + } + + return fs.readFileSync(filePath).toString("utf8"); + }; + + const rawDsfrCssCodeBuffer = Buffer.from( + generateDsfrCssCode({ + dsfrComponents, + "isMinified": false, + readDsfrFile + }), + "utf8" + ); + + const rawDsfrMinCssCodeBuffer = Buffer.from( + generateDsfrCssCode({ + dsfrComponents, + "isMinified": true, + readDsfrFile + }), + "utf8" + ); + + let hasChanged = false; + + await Promise.all( + [ + { + "dsfrDirPath": commandContext.dsfrDirPath, + "cssFileBasenames": ["dsfr.css", "dsfr.min.css"] as const + }, + ...(commandContext.spaParams === undefined + ? [] + : [ + { + "dsfrDirPath": commandContext.spaParams.dsfrDirPath_static, + // copy-dsfr-to-public only keeps the minified variant. + "cssFileBasenames": ["dsfr.min.css"] as const + } + ]) + ].map(async ({ dsfrDirPath, cssFileBasenames }) => + Promise.all( + cssFileBasenames.map(async cssFileBasename => { + const cssFilePath = pathJoin(dsfrDirPath, cssFileBasename); + + if (!(await existsAsync(cssFilePath))) { + return; + } + + const buffer = + cssFileBasename === "dsfr.min.css" + ? rawDsfrMinCssCodeBuffer + : rawDsfrCssCodeBuffer; + + if (Buffer.compare(await readFile(cssFilePath), buffer) === 0) { + return; + } + + hasChanged = true; + + await writeFile(cssFilePath, buffer); + }) + ) + ) + ); + + if (!hasChanged) { + log?.("No change since last run"); + return; + } + + await Promise.all([ + (async function addHashQueryParameterInIndexHtml() { + if (commandContext.spaParams === undefined) { + return; + } + + const html = (await readFile(commandContext.spaParams.htmlFilePath)).toString("utf8"); + + const { modifiedHtml } = modifyHtmlHrefs({ + "html": html, + "getModifiedHref": href => { + if (!href.includes("dsfr.min.css")) { + return href; + } + + const [urlWithoutQuery] = href.split("?"); + + return `${urlWithoutQuery}?hash=${fnv1aHashToHex( + rawDsfrMinCssCodeBuffer.toString("utf8") + )}`; + } + }); + + await writeFile( + commandContext.spaParams.htmlFilePath, + Buffer.from(modifiedHtml, "utf8") + ); + })(), + (async function clearCache() { + await Promise.all( + [ + pathJoin(".next", "cache"), + pathJoin(".vite"), + pathJoin(".cache", "storybook"), + pathJoin(".cache", "babel-loader"), + pathJoin(".cache", "default-development") + ] + .map(relativeDirPath => + pathJoin(commandContext.projectDirPath, "node_modules", relativeDirPath) + ) + .map(async dirPath => { + if (!(await existsAsync(dirPath))) { + return; + } + + await rm(dirPath, { "recursive": true, "force": true }); + }) + ); + })() + ]); +} + +if (require.main === module) { + main(process.argv.slice(2)); +} diff --git a/src/bin/react-dsfr.ts b/src/bin/react-dsfr.ts index daa459595..78348603c 100644 --- a/src/bin/react-dsfr.ts +++ b/src/bin/react-dsfr.ts @@ -11,6 +11,13 @@ const [, , commandName, ...args] = process.argv; await main(args); } break; + case "only-include-used-components": + { + const { main } = await import("./only-include-used-components"); + + await main(args); + } + break; case "copy-static-assets": { const { main } = await import("./copy-dsfr-to-public"); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts new file mode 100644 index 000000000..42c99c0fc --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/detectDsfrComponentsFromClassNames.test.ts @@ -0,0 +1,36 @@ +import { it, expect, describe } from "vitest"; +import { detectDsfrComponentsFromClassNames } from "../../../../src/bin/only-include-used-components"; + +describe("detectDsfrComponentsFromClassNames", () => { + it("detects raw usage of DSFR component classes", () => { + const rawFileContent = ` + export function MyTable() { + return
+ +
; + } + `; + + const detected = detectDsfrComponentsFromClassNames({ rawFileContent }); + + expect(detected).toContain("table"); + expect(detected).toContain("badge"); + expect(detected).not.toContain("header"); + }); + + it("detects modifier classes thanks to prefix matching", () => { + const detected = detectDsfrComponentsFromClassNames({ + "rawFileContent": `` + }); + + expect(detected).toStrictEqual(["button"]); + }); + + it("detects nothing in files without fr- classes", () => { + expect( + detectDsfrComponentsFromClassNames({ + "rawFileContent": `const theme = { "color": "blue" };` + }) + ).toStrictEqual([]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts new file mode 100644 index 000000000..5de44f532 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/generateDsfrCssCode.test.ts @@ -0,0 +1,132 @@ +import { it, expect, describe } from "vitest"; +import { + generateDsfrCssCode, + rewriteCssRelativeUrls, + patchCoreCssCodeForCompatWithMui +} from "../../../../src/bin/only-include-used-components"; + +describe("rewriteCssRelativeUrls", () => { + it("rewrites urls relative to the css file into urls relative to the dsfr directory", () => { + expect( + rewriteCssRelativeUrls({ + "rawCssCode": `.fr-accordion__btn::after{-webkit-mask-image:url("../../icons/arrows/arrow-down-s-line.svg")}`, + "cssFileRelativeDirPath": "component/accordion" + }) + ).toBe( + `.fr-accordion__btn::after{-webkit-mask-image:url("icons/arrows/arrow-down-s-line.svg")}` + ); + + expect( + rewriteCssRelativeUrls({ + "rawCssCode": `@font-face{src:url("../fonts/Marianne-Regular.woff2") format("woff2")}`, + "cssFileRelativeDirPath": "core" + }) + ).toBe(`@font-face{src:url("fonts/Marianne-Regular.woff2") format("woff2")}`); + }); + + it("leaves data, http and absolute urls untouched", () => { + for (const url of [ + "data:image/svg+xml;base64,abc", + "https://example.com/a.svg", + "/a.svg" + ]) { + const rawCssCode = `.foo{background-image:url("${url}")}`; + + expect( + rewriteCssRelativeUrls({ + rawCssCode, + "cssFileRelativeDirPath": "component/header" + }) + ).toBe(rawCssCode); + } + }); +}); + +describe("patchCoreCssCodeForCompatWithMui", () => { + it("excludes Mui buttons from the DSFR hover and active rules", () => { + expect( + patchCoreCssCodeForCompatWithMui({ + "rawCssCode": + "@media (hover:hover) and (pointer:fine){a[href]:hover,button:not(:disabled):hover,input[type=button]:not(:disabled):hover{background-color:var(--hover-tint)}a[href]:active,button:not(:disabled):active{background-color:var(--active-tint)}}" + }) + ).toBe( + '@media (hover:hover) and (pointer:fine){a[href]:hover,button:not(:disabled):hover:not([class^="Mui"]),input[type=button]:not(:disabled):hover{background-color:var(--hover-tint)}a[href]:active,button:not(:disabled):active:not([class^="Mui"]){background-color:var(--active-tint)}}' + ); + }); +}); + +describe("generateDsfrCssCode", () => { + const fakeDsfrFiles: Record = { + "core/core.main.min.css": '@charset "UTF-8";.core{--x:url("../fonts/f.woff2")}', + "core/core.print.min.css": "@media print{.core-print{display:none}}", + "scheme/scheme.min.css": ":root[data-fr-theme=dark]{--grey:#161616}", + "component/button/button.main.min.css": ".fr-btn{color:red}", + "component/button/button.print.min.css": "@media print{.fr-btn{color:black}}", + "component/header/header.main.min.css": '.fr-header{background:url("../../icons/a.svg")}', + "component/header/header.print.min.css": "@media print{.fr-header{display:none}}", + // The download component only ships a non "main" variant. + "component/download/download.min.css": ".fr-download{color:blue}" + }; + + const readDsfrFile = (fileRelativePath: string) => fakeDsfrFiles[fileRelativePath]; + + it("always includes core and scheme, includes only requested components, preserves the cascade order", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["header", "button", "download"], + "isMinified": true, + readDsfrFile + }); + + // Cascade order is button < download < header regardless of the requested order. + const indexes = [ + ".core{", + ":root[data-fr-theme=dark]", + ".fr-btn{", + ".fr-download{", + ".fr-header{" + ].map(marker => generated.indexOf(marker)); + + expect(indexes.every(index => index !== -1)).toBe(true); + expect([...indexes].sort((a, b) => a - b)).toStrictEqual(indexes); + + // Print styles come after every main style. + expect(generated.indexOf(".core-print")).toBeGreaterThan(generated.indexOf(".fr-header{")); + expect(generated.indexOf("@media print{.fr-btn")).toBeGreaterThan( + generated.indexOf(".core-print") + ); + }); + + it("excludes the components that are not requested", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["button"], + "isMinified": true, + readDsfrFile + }); + + expect(generated).toContain(".fr-btn{"); + expect(generated).not.toContain(".fr-header{"); + expect(generated).not.toContain(".fr-download{"); + }); + + it("rewrites asset urls and strips @charset", () => { + const generated = generateDsfrCssCode({ + "dsfrComponents": ["header"], + "isMinified": true, + readDsfrFile + }); + + expect(generated).toContain('url("fonts/f.woff2")'); + expect(generated).toContain('url("icons/a.svg")'); + expect(generated).not.toContain("@charset"); + }); + + it("is deterministic, running it twice yields the same output", () => { + const params = { + "dsfrComponents": ["button", "header"], + "isMinified": true, + readDsfrFile + }; + + expect(generateDsfrCssCode(params)).toBe(generateDsfrCssCode(params)); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts new file mode 100644 index 000000000..8e0972cc6 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/getReactDsfrImportedModuleIds.test.ts @@ -0,0 +1,55 @@ +import { it, expect, describe } from "vitest"; +import { getReactDsfrImportedModuleIds } from "../../../../src/bin/only-include-used-components"; + +describe("getReactDsfrImportedModuleIds", () => { + it("detects default and named imports from a component subpath", () => { + const rawFileContent = ` + import { Button } from "@codegouvfr/react-dsfr/Button"; + import Badge from "@codegouvfr/react-dsfr/Badge"; + import { fr } from "@codegouvfr/react-dsfr"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "Badge", + "Button" + ]); + }); + + it("detects deep imports and normalizes them to their module", () => { + const rawFileContent = ` + import { useIsModalOpen } from "@codegouvfr/react-dsfr/Modal/useIsModalOpen"; + import { createModal } from "@codegouvfr/react-dsfr/Modal"; + const { Header } = await import("@codegouvfr/react-dsfr/Header/index"); + const x = require("@codegouvfr/react-dsfr/Tabs.js"); + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "Header", + "Modal", + "Tabs" + ]); + }); + + it("keeps two segments for blocks and three for dsfr asset paths", () => { + const rawFileContent = ` + import { PasswordInput } from "@codegouvfr/react-dsfr/blocks/PasswordInput"; + import "@codegouvfr/react-dsfr/dsfr/component/table/table.min.css"; + import "@codegouvfr/react-dsfr/dsfr/utility/colors/colors.min.css"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent }).sort()).toStrictEqual([ + "blocks/PasswordInput", + "dsfr/component/table", + "dsfr/utility/colors" + ]); + }); + + it("returns no module for files that do not use react-dsfr", () => { + const rawFileContent = ` + import { useState } from "react"; + import { z } from "zod"; + `; + + expect(getReactDsfrImportedModuleIds({ rawFileContent })).toStrictEqual([]); + }); +}); diff --git a/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts new file mode 100644 index 000000000..baafc5739 --- /dev/null +++ b/test/runtime/scripts/onlyIncludeUsedComponents/resolveModuleIdToDsfrComponents.test.ts @@ -0,0 +1,48 @@ +import { it, expect, describe } from "vitest"; +import { + resolveModuleIdToDsfrComponents, + REACT_DSFR_MODULE_TO_DSFR_COMPONENTS, + DSFR_COMPONENTS_CASCADE_ORDER +} from "../../../../src/bin/only-include-used-components"; + +describe("resolveModuleIdToDsfrComponents", () => { + it("resolves a component module with its transitive dependencies", () => { + const dsfrComponents = resolveModuleIdToDsfrComponents({ "moduleId": "Header" }); + + expect(dsfrComponents).not.toBe(undefined); + + for (const expected of ["header", "navigation", "modal", "logo", "search"]) { + expect(dsfrComponents).toContain(expected); + } + }); + + it("resolves known non component modules to no component", () => { + for (const moduleId of ["fr", "i18n", "spa", "mui", "next-appdir", "useIsDark"]) { + expect(resolveModuleIdToDsfrComponents({ moduleId })).toStrictEqual([]); + } + }); + + it("resolves direct dsfr component stylesheet imports", () => { + expect( + resolveModuleIdToDsfrComponents({ "moduleId": "dsfr/component/table" }) + ).toStrictEqual(["table"]); + + expect( + resolveModuleIdToDsfrComponents({ "moduleId": "dsfr/utility/colors" }) + ).toStrictEqual([]); + }); + + it("returns undefined for unknown component looking modules", () => { + expect(resolveModuleIdToDsfrComponents({ "moduleId": "BrandNewComponent" })).toBe( + undefined + ); + }); + + it("only maps to components that exist in the cascade order", () => { + for (const dsfrComponents of Object.values(REACT_DSFR_MODULE_TO_DSFR_COMPONENTS)) { + for (const componentName of dsfrComponents) { + expect(DSFR_COMPONENTS_CASCADE_ORDER).toContain(componentName); + } + } + }); +});