@codegouvfr/react-dsfr
Version:
French State Design System React integration library
337 lines (273 loc) • 11.1 kB
text/typescript
import { getProjectRoot } from "./tools/getProjectRoot";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { assert } from "tsafe/assert";
import { exclude } from "tsafe/exclude";
import { writeFile, readFile, rm, access } from "fs/promises";
import { crawl } from "./tools/crawl";
import { basename as pathBasename, sep as pathSep, dirname as pathDirname } from "path";
import type { Equals } from "tsafe";
export const pathOfIconsJson = pathJoin("utility", "icons", "icons.json");
export const pathOfPatchedRawCssCodeForCompatWithRemixIconRelativeToDsfrDist = pathJoin(
"utility",
"icons",
"dsfr_remixicon.css"
);
export type Icon = Icon.Dsfr | Icon.Remixicon;
export namespace Icon {
export type Common = {
iconId: string;
};
export type Dsfr = Common & {
prefix: "fr-icon-";
svgRelativePath: string;
};
export type Remixicon = Common & {
prefix: "ri-";
rawSvgCode: string;
};
}
type IconLike = Icon.Dsfr | Omit<Icon.Remixicon, "rawSvgCode">;
export function generateIconsRawCssCode(params: {
usedIcons: IconLike[];
patchedRawCssCodeForCompatWithRemixIcon: string;
}): string {
const { usedIcons, patchedRawCssCodeForCompatWithRemixIcon } = params;
const buildRule = (icon: IconLike, isHighContrast: boolean) => {
const { iconId, prefix } = icon;
const className = `${prefix}${iconId}`;
const relativePath = (() => {
switch (icon.prefix) {
case "fr-icon-":
return icon.svgRelativePath;
case "ri-":
return `../../icons/remixicon/${iconId}.svg`;
}
})();
return [
`.${className}::before,`,
`.${className}::after {`,
...(isHighContrast
? [` background-image: url("${relativePath}");`]
: [
` -webkit-mask-image: url("${relativePath}");`,
` mask-image: url("${relativePath}");`
]),
`}`,
``
]
.map(!isHighContrast ? line => line : line => ` ${line}`)
.join("\n");
};
return [
...usedIcons.map(icon => buildRule(icon, false)),
...(usedIcons.length === 0
? []
: [
`@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {`,
...usedIcons.map(icon => buildRule(icon, true)),
`}`,
``
]),
...(usedIcons.find(({ prefix }) => prefix === "ri-") === undefined
? []
: [patchedRawCssCodeForCompatWithRemixIcon])
].join("\n");
}
async function main() {
const packageName = JSON.parse(
fs.readFileSync(pathJoin(getProjectRoot(), "package.json")).toString("utf8")
)["name"];
const cwd = process.cwd();
const dsfrDistDirPath =
getProjectRoot() === cwd
? pathJoin(cwd, "dist", "dsfr")
: await (async function callee(n: number): Promise<string> {
if (n >= cwd.split(pathSep).length) {
throw new Error("Need to install node modules?");
}
const dirPath = pathJoin(
...[
cwd,
...new Array(n).fill(".."),
"node_modules",
...packageName.split("/"),
"dsfr"
]
);
try {
await access(dirPath);
} catch {
return callee(n + 1);
}
return dirPath;
})(0);
const icons: Icon[] = JSON.parse(
(await readFile(pathJoin(dsfrDistDirPath, pathOfIconsJson))).toString("utf8")
);
const { usedIconClassNames } = await (async function getUsedIconClassNames() {
const candidateFilePaths = (
await crawl({
"dirPath": cwd,
"getDoCrawlInDir": async ({ relativeDirPath }) => {
if (relativeDirPath === "node_modules") {
return true;
}
if (
relativeDirPath.startsWith(`node_modules${pathSep}@`) &&
relativeDirPath.split(pathSep).length === 2
) {
return true;
}
if (
relativeDirPath.startsWith("node_modules") &&
(relativeDirPath.split(pathSep).length === 2 ||
(relativeDirPath.startsWith(`node_modules${pathSep}@`) &&
relativeDirPath.split(pathSep).length === 3))
) {
const parsedPackageJson = await readFile(
pathJoin(relativeDirPath, "package.json")
).then(
buff => JSON.parse(buff.toString("utf8")),
() => undefined
);
if (parsedPackageJson === undefined) {
return false;
}
if (
Object.keys({
...parsedPackageJson["dependencies"],
...parsedPackageJson["devDependencies"]
}).includes("@gouvfr/dsfr")
) {
return true;
}
return false;
}
if (relativeDirPath === `public${pathSep}dsfr`) {
return false;
}
if (pathBasename(relativeDirPath) === "generatedFromCss") {
return false;
}
if (
pathDirname(relativeDirPath).endsWith(pathJoin(...packageName.split("/")))
) {
return pathBasename(relativeDirPath) === "src";
}
if (pathBasename(relativeDirPath) === "node_modules") {
return false;
}
if (pathBasename(relativeDirPath).startsWith(".")) {
return false;
}
return true;
}
})
).filter(
filePath =>
["tsx", "jsx", "js", "ts", "html", "htm"].find(ext =>
filePath.endsWith(`.${ext}`)
) !== undefined
);
const prefixes = { "prefixDsfr": "fr-icon-", "prefixRemixIcon": "ri-" } as const;
assert<Equals<typeof prefixes[keyof typeof prefixes], Icon["prefix"]>>();
const { prefixDsfr, prefixRemixIcon, ...rest } = prefixes;
assert<Equals<keyof typeof rest, never>>();
const { availableDsfrIconClassNames, availableRemixiconIconClassNames } = (() => {
const allAvailableIconClassNames = icons.map(
({ prefix, iconId }) => `${prefix}${iconId}`
);
const availableDsfrIconClassNames: string[] = [];
const availableRemixiconIconClassNames: string[] = [];
allAvailableIconClassNames.forEach(className => {
if (className.startsWith(prefixDsfr)) {
availableDsfrIconClassNames.push(className);
return;
}
if (className.startsWith(prefixRemixIcon)) {
availableRemixiconIconClassNames.push(className);
return;
}
});
return { availableDsfrIconClassNames, availableRemixiconIconClassNames };
})();
const setUsedIconClassNames = new Set<string>();
await Promise.all(
candidateFilePaths.map(async candidateFilePath => {
const rawFileContent = (await readFile(candidateFilePath)).toString("utf8");
[
...(!rawFileContent.includes(prefixDsfr) ? [] : availableDsfrIconClassNames),
...(!rawFileContent.includes(prefixRemixIcon)
? []
: availableRemixiconIconClassNames)
].forEach(className => {
if (!rawFileContent.includes(className)) {
return;
}
setUsedIconClassNames.add(className);
});
})
);
return { "usedIconClassNames": Array.from(setUsedIconClassNames) };
})();
const usedIcons = usedIconClassNames.map(className => {
const icon = icons.find(({ prefix, iconId }) => `${prefix}${iconId}` === className);
assert(icon !== undefined);
return icon;
});
const rawIconCssCodeBuffer = Buffer.from(
generateIconsRawCssCode({
"patchedRawCssCodeForCompatWithRemixIcon": fs
.readFileSync(
pathJoin(
dsfrDistDirPath,
pathOfPatchedRawCssCodeForCompatWithRemixIconRelativeToDsfrDist
)
)
.toString("utf8"),
usedIcons
}),
"utf8"
);
const onConfirmedChange = async () => {
const nextCacheDir = pathJoin(cwd, ".next", "cache");
if (!fs.existsSync(nextCacheDir)) {
return;
}
rm(nextCacheDir, { "recursive": true, "force": true });
};
[dsfrDistDirPath, pathJoin(cwd, "public", "dsfr")].forEach(async dsfrDistDirPath => {
const cssFilePaths = ["icons.css", "icons.min.css"].map(cssFileBasename =>
pathJoin(dsfrDistDirPath, "utility", "icons", cssFileBasename)
);
if (cssFilePaths.some(cssFilePath => !fs.existsSync(cssFilePath))) {
return;
}
const remixiconDirPath = pathJoin(dsfrDistDirPath, "icons", "remixicon");
if (!fs.existsSync(remixiconDirPath)) {
fs.mkdirSync(remixiconDirPath);
}
usedIcons
.map(icon => (icon.prefix !== "ri-" ? undefined : icon))
.filter(exclude(undefined))
.map(({ iconId, rawSvgCode }) =>
writeFile(
pathJoin(remixiconDirPath, `${iconId}.svg`),
Buffer.from(rawSvgCode, "utf8")
)
);
cssFilePaths.forEach(async filePath => {
const currentCode = await readFile(filePath);
if (Buffer.compare(rawIconCssCodeBuffer, currentCode) === 0) {
return;
}
onConfirmedChange();
writeFile(filePath, rawIconCssCodeBuffer);
});
});
}
if (require.main === module) {
main();
}