From 560bd93813c29c357e2c9b96b12a14ca1f67f1b8 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Tue, 18 Aug 2026 17:57:14 +0200 Subject: [PATCH] refactor(react-icons): generate font-family definitions from one manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSS font-family, the font binary names, the `data-fui-icon-font` value and the `FontFile` enum value were duplicated across six hand-maintained places, which is how the Light family ended up declared on the headless surface but emitted as `font-family: undefined` on the Griffel one. `scripts/font-families.js` is now the single source of truth, and `scripts/generate-font-styles.js` (wired into `build:fonts-and-svg`) rewrites the derived `#region @generated *` blocks in place — the surrounding code stays hand-written, type-checked and committed, and `--check` fails on a stale checkout. `copy-base-fonts.js` and `convert-font*.js` consume the same manifest instead of their own family lists and hardcoded `0/1/2/3` style codes. Adds a build-verify assertion that the shipped Griffel and headless artifacts declare exactly the same families, plus unit tests pinning the enum values that are baked into the generated atoms. --- packages/react-icons/build-verify.test.js | 127 +++++---- packages/react-icons/package.json | 3 +- packages/react-icons/scripts/convert-font.js | 11 +- .../react-icons/scripts/convert-font.utils.js | 9 +- .../react-icons/scripts/copy-base-fonts.js | 10 +- packages/react-icons/scripts/font-families.js | 85 ++++++ .../scripts/generate-font-styles.js | 248 ++++++++++++++++++ .../scripts/generate-font-styles.test.js | 72 +++++ .../headless/fonts/createFluentFontIcon.tsx | 2 + .../react-icons/src/headless/fonts/styles.css | 18 +- packages/react-icons/src/headless/styles.css | 2 + .../fonts/createFluentFontIcon.shared.ts | 2 + .../fonts/createFluentFontIcon.styles.ts | 64 ++--- 13 files changed, 546 insertions(+), 107 deletions(-) create mode 100644 packages/react-icons/scripts/font-families.js create mode 100644 packages/react-icons/scripts/generate-font-styles.js create mode 100644 packages/react-icons/scripts/generate-font-styles.test.js diff --git a/packages/react-icons/build-verify.test.js b/packages/react-icons/build-verify.test.js index ec5392f58b8..7bdfaba001a 100644 --- a/packages/react-icons/build-verify.test.js +++ b/packages/react-icons/build-verify.test.js @@ -6,6 +6,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; +import { FONT_FAMILIES } from './scripts/font-families'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** @@ -289,12 +291,12 @@ describe('Build Verification', () => { const processed = 'utils/fonts/createFluentFontIcon.styles.js'; const unprocessed = 'utils/fonts/createFluentFontIcon.styles.raw.js'; expect(fs.readFileSync(path.join(root, processed), 'utf8')).toMatchInlineSnapshot(` - "import _asset10 from "./FluentSystemIcons-Resizable.ttf"; - import _asset1 from "./FluentSystemIcons-Resizable.woff"; - import _asset0 from "./FluentSystemIcons-Resizable.woff2"; - import _asset9 from "./FluentSystemIcons-Light.ttf"; - import _asset8 from "./FluentSystemIcons-Light.woff"; - import _asset7 from "./FluentSystemIcons-Light.woff2"; + "import _asset10 from "./FluentSystemIcons-Light.ttf"; + import _asset1 from "./FluentSystemIcons-Light.woff"; + import _asset0 from "./FluentSystemIcons-Light.woff2"; + import _asset9 from "./FluentSystemIcons-Resizable.ttf"; + import _asset8 from "./FluentSystemIcons-Resizable.woff"; + import _asset7 from "./FluentSystemIcons-Resizable.woff2"; import _asset6 from "./FluentSystemIcons-Regular.ttf"; import _asset5 from "./FluentSystemIcons-Regular.woff"; import _asset4 from "./FluentSystemIcons-Regular.woff2"; @@ -302,27 +304,23 @@ describe('Build Verification', () => { import _asset2 from "./FluentSystemIcons-Filled.woff"; import _asset from "./FluentSystemIcons-Filled.woff2"; import { __styles, __staticStyles } from '@griffel/react'; - import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; - import fontFilledWoff from './FluentSystemIcons-Filled.woff'; + // #region @generated font-faces import fontFilledWoff2 from './FluentSystemIcons-Filled.woff2'; - import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; - import fontRegularWoff from './FluentSystemIcons-Regular.woff'; + import fontFilledWoff from './FluentSystemIcons-Filled.woff'; + import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; import fontRegularWoff2 from './FluentSystemIcons-Regular.woff2'; - import fontLightTtf from './FluentSystemIcons-Light.ttf'; - import fontLightWoff from './FluentSystemIcons-Light.woff'; + import fontRegularWoff from './FluentSystemIcons-Regular.woff'; + import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; + import fontResizableWoff2 from './FluentSystemIcons-Resizable.woff2'; + import fontResizableWoff from './FluentSystemIcons-Resizable.woff'; + import fontResizableTtf from './FluentSystemIcons-Resizable.ttf'; import fontLightWoff2 from './FluentSystemIcons-Light.woff2'; - import fontOneSizeTtf from './FluentSystemIcons-Resizable.ttf'; - import fontOneSizeWoff from './FluentSystemIcons-Resizable.woff'; - import fontOneSizeWoff2 from './FluentSystemIcons-Resizable.woff2'; - const FONT_FAMILY_MAP = { - [0 /* Filled */]: 'FluentSystemIconsFilled', - [1 /* Regular */]: 'FluentSystemIconsRegular', - [2 /* Resizable */]: 'FluentSystemIcons', - [3 /* Light */]: 'FluentSystemIconsLight' - }; + import fontLightWoff from './FluentSystemIcons-Light.woff'; + import fontLightTtf from './FluentSystemIcons-Light.ttf'; export const useStaticStyles = __staticStyles({ - d: [\`@font-face{font-family:FluentSystemIconsFilled;font-display:"block";src:url(\${_asset}) format("woff2"),url(\${_asset2}) format("woff"),url(\${_asset3}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIconsRegular;font-display:"block";src:url(\${_asset4}) format("woff2"),url(\${_asset5}) format("woff"),url(\${_asset6}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIconsLight;font-display:"block";src:url(\${_asset7}) format("woff2"),url(\${_asset8}) format("woff"),url(\${_asset9}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIcons;font-display:"block";src:url(\${_asset0}) format("woff2"),url(\${_asset1}) format("woff"),url(\${_asset10}) format("truetype");}\`] + d: [\`@font-face{font-family:FluentSystemIconsFilled;font-display:"block";src:url(\${_asset}) format("woff2"),url(\${_asset2}) format("woff"),url(\${_asset3}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIconsRegular;font-display:"block";src:url(\${_asset4}) format("woff2"),url(\${_asset5}) format("woff"),url(\${_asset6}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIcons;font-display:"block";src:url(\${_asset7}) format("woff2"),url(\${_asset8}) format("woff"),url(\${_asset9}) format("truetype");}\`, \`@font-face{font-family:FluentSystemIconsLight;font-display:"block";src:url(\${_asset0}) format("woff2"),url(\${_asset1}) format("woff"),url(\${_asset10}) format("truetype");}\`] }); + // #endregion @generated font-faces export const useRootStyles = __styles({ "0": { Bahqtrf: "f9dzkbp" @@ -355,56 +353,54 @@ describe('Build Verification', () => { `); expect(fs.readFileSync(path.join(root, unprocessed), 'utf8')).toMatchInlineSnapshot(` "import { makeStyles, makeStaticStyles } from '@griffel/react'; - import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; - import fontFilledWoff from './FluentSystemIcons-Filled.woff'; + // #region @generated font-faces import fontFilledWoff2 from './FluentSystemIcons-Filled.woff2'; - import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; - import fontRegularWoff from './FluentSystemIcons-Regular.woff'; + import fontFilledWoff from './FluentSystemIcons-Filled.woff'; + import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; import fontRegularWoff2 from './FluentSystemIcons-Regular.woff2'; - import fontLightTtf from './FluentSystemIcons-Light.ttf'; - import fontLightWoff from './FluentSystemIcons-Light.woff'; + import fontRegularWoff from './FluentSystemIcons-Regular.woff'; + import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; + import fontResizableWoff2 from './FluentSystemIcons-Resizable.woff2'; + import fontResizableWoff from './FluentSystemIcons-Resizable.woff'; + import fontResizableTtf from './FluentSystemIcons-Resizable.ttf'; import fontLightWoff2 from './FluentSystemIcons-Light.woff2'; - import fontOneSizeTtf from './FluentSystemIcons-Resizable.ttf'; - import fontOneSizeWoff from './FluentSystemIcons-Resizable.woff'; - import fontOneSizeWoff2 from './FluentSystemIcons-Resizable.woff2'; - const FONT_FAMILY_MAP = { - [0 /* Filled */]: 'FluentSystemIconsFilled', - [1 /* Regular */]: 'FluentSystemIconsRegular', - [2 /* Resizable */]: 'FluentSystemIcons', - [3 /* Light */]: 'FluentSystemIconsLight', - }; + import fontLightWoff from './FluentSystemIcons-Light.woff'; + import fontLightTtf from './FluentSystemIcons-Light.ttf'; export const useStaticStyles = makeStaticStyles(\` @font-face { - font-family: \${FONT_FAMILY_MAP[0 /* Filled */]}; + font-family: FluentSystemIconsFilled; font-display: "block"; - src: url(\${JSON.stringify(fontFilledWoff2)}) format("woff2"), + src: + url(\${JSON.stringify(fontFilledWoff2)}) format("woff2"), url(\${JSON.stringify(fontFilledWoff)}) format("woff"), url(\${JSON.stringify(fontFilledTtf)}) format("truetype"); } @font-face { - font-family: \${FONT_FAMILY_MAP[1 /* Regular */]}; + font-family: FluentSystemIconsRegular; font-display: "block"; - src: url(\${JSON.stringify(fontRegularWoff2)}) format("woff2"), + src: + url(\${JSON.stringify(fontRegularWoff2)}) format("woff2"), url(\${JSON.stringify(fontRegularWoff)}) format("woff"), url(\${JSON.stringify(fontRegularTtf)}) format("truetype"); } - @font-face { - font-family: \${FONT_FAMILY_MAP[3 /* Light */]}; + font-family: FluentSystemIcons; font-display: "block"; - src: url(\${JSON.stringify(fontLightWoff2)}) format("woff2"), - url(\${JSON.stringify(fontLightWoff)}) format("woff"), - url(\${JSON.stringify(fontLightTtf)}) format("truetype"); + src: + url(\${JSON.stringify(fontResizableWoff2)}) format("woff2"), + url(\${JSON.stringify(fontResizableWoff)}) format("woff"), + url(\${JSON.stringify(fontResizableTtf)}) format("truetype"); } - @font-face { - font-family: \${FONT_FAMILY_MAP[2 /* Resizable */]}; + font-family: FluentSystemIconsLight; font-display: "block"; - src: url(\${JSON.stringify(fontOneSizeWoff2)}) format("woff2"), - url(\${JSON.stringify(fontOneSizeWoff)}) format("woff"), - url(\${JSON.stringify(fontOneSizeTtf)}) format("truetype"); + src: + url(\${JSON.stringify(fontLightWoff2)}) format("woff2"), + url(\${JSON.stringify(fontLightWoff)}) format("woff"), + url(\${JSON.stringify(fontLightTtf)}) format("truetype"); } \`); + // #endregion @generated font-faces export const useRootStyles = makeStyles({ root: { display: 'inline-block', @@ -425,6 +421,7 @@ describe('Build Verification', () => { lineHeight: '1em', color: 'currentColor', }, + // #region @generated font-families [0 /* Filled */]: { fontFamily: 'FluentSystemIconsFilled', }, @@ -2024,7 +2021,7 @@ describe('Build Verification', () => { const fontsPath = path.join(__dirname, 'lib', 'utils', 'fonts'); // Check for different font types - const fontTypes = ['Regular', 'Filled', 'Light', 'Resizable']; + const fontTypes = FONT_FAMILIES.map(({ key }) => key); const fontExtensions = ['json', 'ttf', 'woff', 'woff2']; for (const fontType of fontTypes) { @@ -2056,6 +2053,32 @@ describe('Build Verification', () => { expect(fs.existsSync(filePath)).toBe(true); } }); + + // A family declared on only one of the two surfaces renders as no font at all. This + // asserts the shipped artifacts agree with each other and with scripts/font-families.js. + it('should declare the same font families in the Griffel and headless outputs', () => { + const expected = FONT_FAMILIES.map(({ family }) => family).sort(); + + const griffel = fs.readFileSync(path.join(__dirname, 'lib/utils/fonts/createFluentFontIcon.styles.js'), 'utf8'); + const headless = fs.readFileSync(path.join(__dirname, 'lib/headless/fonts/styles.css'), 'utf8'); + + /** @param {string} source */ + const fontFaceFamilies = (source) => + [...source.matchAll(/@font-face\s*\{[^}]*?font-family:\s*'?([\w-]+)'?\s*;/g)].map((match) => match[1]).sort(); + + expect(fontFaceFamilies(griffel)).toEqual(expected); + expect(fontFaceFamilies(headless)).toEqual(expected); + + // …and the same families are selectable from both APIs. + const variants = fs.readFileSync(path.join(__dirname, 'lib/headless/styles.css'), 'utf8'); + const griffelRules = [...griffel.matchAll(/\.[\w-]+\{font-family:([\w-]+);\}/g)].map((match) => match[1]).sort(); + const headlessRules = [...variants.matchAll(/\[data-fui-icon-font=[^\]]+\]\s*\{\s*font-family:\s*'([\w-]+)'/g)] + .map((match) => match[1]) + .sort(); + + expect(griffelRules).toEqual(expected); + expect(headlessRules).toEqual(expected); + }); }); /** diff --git a/packages/react-icons/package.json b/packages/react-icons/package.json index ea823151f97..443c538e0e3 100644 --- a/packages/react-icons/package.json +++ b/packages/react-icons/package.json @@ -22,8 +22,9 @@ "convert:fonts": "node scripts/convert-font.js --source=./src/utils/fonts --dest=./src/fonts --perIconDest=./src/atoms/fonts --headless --headlessPerIconDest=./src/atoms/headless-fonts --codepointDest=./src/utils/fonts --rtl=./intermediate/rtl.json --metadata=./tmp/metadata-font.json", "convert:merge-metadata": "node scripts/merge-metadata.js --svgMetadata=./tmp/metadata-svg.json --fontMetadata=./tmp/metadata-font.json --output=./metadata.json", "generate:font": "node scripts/copy-base-fonts.js --source=../../fonts --dest=src/utils/fonts", + "generate:font-styles": "node scripts/generate-font-styles.js --dest=./src", "generate:rtl": "node ../../importer/rtlMetadata.js --source=../../assets --dest=./intermediate/rtl.json", - "build:fonts-and-svg": "yarn generate:base-svg && yarn generate:font && yarn generate:rtl", + "build:fonts-and-svg": "yarn generate:base-svg && yarn generate:font && yarn generate:font-styles && yarn generate:rtl", "build:generate-chunks-and-atoms": "yarn convert:svg && yarn convert:fonts && yarn convert:merge-metadata", "build:js": "node scripts/build.js", "build": "yarn clean && yarn build:fonts-and-svg && yarn build:generate-chunks-and-atoms && yarn build:js", diff --git a/packages/react-icons/scripts/convert-font.js b/packages/react-icons/scripts/convert-font.js index 909bf097315..dbf20dacec7 100644 --- a/packages/react-icons/scripts/convert-font.js +++ b/packages/react-icons/scripts/convert-font.js @@ -23,6 +23,7 @@ const { getCreateFluentIconHeader, generatePerIconFiles, } = require('./convert-font.utils'); +const { FONT_FAMILIES, getFontFamilyByKey } = require('./font-families'); if (require.main === module) { main().catch((err) => { @@ -157,12 +158,10 @@ async function processPerChunk(dest, iconEntries, rtlMetadata) { * @returns {{ resizable: IconEntry[]; sized: IconEntry[]; }} */ function prepareProcessedCodepointMap(srcPath, destFolder) { - const fileNamesResizable = ['FluentSystemIcons-Resizable.json']; - const fileNamesSized = [ - 'FluentSystemIcons-Light.json', - 'FluentSystemIcons-Filled.json', - 'FluentSystemIcons-Regular.json', - ]; + // Order is load-bearing: it decides chunk assignment for the generated sized icons. + const SIZED_ORDER = ['Light', 'Filled', 'Regular']; + const fileNamesResizable = FONT_FAMILIES.filter((f) => f.resizable).map((f) => `${f.file}.json`); + const fileNamesSized = SIZED_ORDER.map((key) => `${getFontFamilyByKey(key).file}.json`); const resolveExistingFiles = (/** @type {string[]} */ names) => names.map((name) => path.resolve(srcPath, name)).filter((f) => fsS.existsSync(f)); diff --git a/packages/react-icons/scripts/convert-font.utils.js b/packages/react-icons/scripts/convert-font.utils.js index 90d845c85e9..f89b07c1fdf 100644 --- a/packages/react-icons/scripts/convert-font.utils.js +++ b/packages/react-icons/scripts/convert-font.utils.js @@ -7,6 +7,7 @@ const path = require('path'); const _ = require('lodash'); const { writePerIconFiles } = require('./per-icon.writer'); +const { getFontFamilyByKey } = require('./font-families'); /** @typedef {{ [key: string]: 'mirror' | 'unique' }} RtlMetadata */ @@ -39,10 +40,14 @@ function loadRtlMetadata(rtlFilePath) { * @param {string=} rawGlyphName Used to derive size for sized variants */ function buildFontIconExport(exportName, codepoint, resizable, flipInRtl, rawGlyphName) { - const style = /filled$/i.test(rawGlyphName || '') ? 0 : /regular$/i.test(rawGlyphName || '') ? 1 : 3; // Light = 3 + const style = /filled$/i.test(rawGlyphName || '') + ? getFontFamilyByKey('Filled').value + : /regular$/i.test(rawGlyphName || '') + ? getFontFamilyByKey('Regular').value + : getFontFamilyByKey('Light').value; const sizeMatch = rawGlyphName && /(?<=_)\d+(?=_filled|_regular|_light)/.exec(rawGlyphName); const size = resizable ? undefined : sizeMatch?.[0]; - return `export const ${exportName}: FluentFontIcon = (/*#__PURE__*/createFluentFontIcon(${JSON.stringify(exportName)}, ${JSON.stringify(String.fromCodePoint(codepoint))}, ${resizable ? 2 : style}, ${resizable ? undefined : size}${flipInRtl ? ', { flipInRtl: true }' : ''}));`; + return `export const ${exportName}: FluentFontIcon = (/*#__PURE__*/createFluentFontIcon(${JSON.stringify(exportName)}, ${JSON.stringify(String.fromCodePoint(codepoint))}, ${resizable ? getFontFamilyByKey('Resizable').value : style}, ${resizable ? undefined : size}${flipInRtl ? ', { flipInRtl: true }' : ''}));`; } /** diff --git a/packages/react-icons/scripts/copy-base-fonts.js b/packages/react-icons/scripts/copy-base-fonts.js index ad9afad9c7a..62f37b78cc7 100644 --- a/packages/react-icons/scripts/copy-base-fonts.js +++ b/packages/react-icons/scripts/copy-base-fonts.js @@ -20,8 +20,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { parseArgs } = require('node:util'); -const FONT_TYPES = ['Regular', 'Filled', 'Light', 'Resizable']; -const BINARY_EXTENSIONS = ['ttf', 'woff', 'woff2']; +const { FONT_FAMILIES, FONT_BINARY_EXTENSIONS } = require('./font-families'); if (require.main === module) { try { @@ -61,10 +60,9 @@ function main() { fs.mkdirSync(destDir, { recursive: true }); let copied = 0; - for (const type of FONT_TYPES) { - const baseName = `FluentSystemIcons-${type}`; - for (const ext of [...BINARY_EXTENSIONS, 'json']) { - const fileName = `${baseName}.${ext}`; + for (const { file } of FONT_FAMILIES) { + for (const ext of [...FONT_BINARY_EXTENSIONS, 'json']) { + const fileName = `${file}.${ext}`; const srcPath = path.join(sourceDir, fileName); if (!fs.existsSync(srcPath)) { throw new Error(`Expected font file missing in source: ${srcPath}`); diff --git a/packages/react-icons/scripts/font-families.js b/packages/react-icons/scripts/font-families.js new file mode 100644 index 00000000000..17a8f8cd73a --- /dev/null +++ b/packages/react-icons/scripts/font-families.js @@ -0,0 +1,85 @@ +// @ts-check +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Single source of truth for the icon font families. + * + * Everything that names a font family, a font file, a `data-fui-icon-font` value or a + * `FontFile` enum member is derived from this list — the Griffel `@font-face` block, the + * headless CSS, the generated per-icon atoms and the font copy step. Keeping them in one + * place is what makes the headless and non-headless surfaces provably 1:1. + * + * @typedef {object} FontFamilyDefinition + * @property {string} key `FontFile` enum member name. + * @property {number} value `FontFile` enum value. BAKED INTO GENERATED ATOMS — never renumber. + * @property {string} family CSS `font-family` name. + * @property {string} variant `data-fui-icon-font` attribute value (headless). + * @property {string} file Font binary base name, without extension. + * @property {boolean} resizable Whether this family holds the size-agnostic glyphs. + */ + +/** @type {ReadonlyArray} */ +const FONT_FAMILIES = [ + { + key: 'Filled', + value: 0, + family: 'FluentSystemIconsFilled', + variant: 'filled', + file: 'FluentSystemIcons-Filled', + resizable: false, + }, + { + key: 'Regular', + value: 1, + family: 'FluentSystemIconsRegular', + variant: 'regular', + file: 'FluentSystemIcons-Regular', + resizable: false, + }, + { + key: 'Resizable', + value: 2, + family: 'FluentSystemIcons', + variant: 'resizable', + file: 'FluentSystemIcons-Resizable', + resizable: true, + }, + { + key: 'Light', + value: 3, + family: 'FluentSystemIconsLight', + variant: 'light', + file: 'FluentSystemIcons-Light', + resizable: false, + }, +]; + +/** Extensions of the font binaries shipped for every family, in `@font-face` `src` order. */ +const FONT_BINARY_EXTENSIONS = /** @type {const} */ (['woff2', 'woff', 'ttf']); + +/** CSS `format()` keyword per binary extension. */ +const FONT_FORMAT_BY_EXTENSION = /** @type {const} */ ({ + woff2: 'woff2', + woff: 'woff', + ttf: 'truetype', +}); + +/** + * @param {string} key + * @returns {FontFamilyDefinition} + */ +function getFontFamilyByKey(key) { + const found = FONT_FAMILIES.find((definition) => definition.key === key); + if (!found) { + throw new Error(`[font-families] unknown font family key: ${key}`); + } + return found; +} + +module.exports = { + FONT_FAMILIES, + FONT_BINARY_EXTENSIONS, + FONT_FORMAT_BY_EXTENSION, + getFontFamilyByKey, +}; diff --git a/packages/react-icons/scripts/generate-font-styles.js b/packages/react-icons/scripts/generate-font-styles.js new file mode 100644 index 00000000000..1308cb4a9bc --- /dev/null +++ b/packages/react-icons/scripts/generate-font-styles.js @@ -0,0 +1,248 @@ +// @ts-check +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Keeps every font-family declaration in the package derived from a single manifest +// (./font-families.js) so the Griffel (non-headless) and headless surfaces stay 1:1. +// +// Rather than owning whole files, this script rewrites delimited `#region @generated ` +// blocks in place. The surrounding code stays hand-written, type-checked and committed; +// only the derived data is generated. +// +// Run with `--check` to fail instead of writing — used by CI to catch a stale checkout. + +const fs = require('node:fs'); +const path = require('node:path'); +const { parseArgs } = require('node:util'); + +const { FONT_FAMILIES, FONT_BINARY_EXTENSIONS, FONT_FORMAT_BY_EXTENSION } = require('./font-families'); + +if (require.main === module) { + try { + main(); + } catch (err) { + console.error('[generate-font-styles] failed:', /** @type {Error} */ (err).message); + process.exit(1); + } +} + +function main() { + const { + values: { dest, check, help }, + } = parseArgs({ + options: { + dest: { type: 'string' }, + check: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h' }, + }, + }); + + if (help) { + printUsage(); + return; + } + + if (!dest) throw new Error('Source folder not specified by --dest'); + + const srcDir = path.resolve(dest); + const targets = buildTargets(); + + /** @type {string[]} */ + const stale = []; + + for (const [relativePath, regions] of Object.entries(targets)) { + const filePath = path.join(srcDir, relativePath); + if (!fs.existsSync(filePath)) { + throw new Error(`Target file not found: ${filePath}`); + } + + const current = fs.readFileSync(filePath, 'utf8'); + const next = Object.entries(regions).reduce( + (content, [name, lines]) => replaceRegion(content, name, lines, relativePath), + current, + ); + + if (current === next) continue; + + if (check) { + stale.push(relativePath); + continue; + } + + fs.writeFileSync(filePath, next); + console.log(` ✓ [font-families] ${relativePath}`); + } + + if (stale.length > 0) { + throw new Error( + `Generated font-family regions are out of date in:\n - ${stale.join('\n - ')}\n` + + 'Run `yarn generate:font-styles` and commit the result.', + ); + } +} + +/** + * @returns {Record>} file -> region name -> replacement lines + */ +function buildTargets() { + return { + 'utils/fonts/createFluentFontIcon.shared.ts': { + 'font-file-enum': fontFileEnum(), + }, + 'utils/fonts/createFluentFontIcon.styles.ts': { + 'font-faces': griffelFontFaces(), + 'font-families': griffelFontFamilies(), + }, + 'headless/fonts/styles.css': { + 'font-faces': cssFontFaces('../../utils/fonts/'), + }, + 'headless/styles.css': { + 'font-families': cssFontFamilies(), + }, + 'headless/fonts/createFluentFontIcon.tsx': { + 'font-variants': headlessFontVariants(), + }, + }; +} + +// ================================================================================================= +// region contents +// ================================================================================================= + +/** `FontFile` enum — values are baked into the generated atoms, so the manifest pins them. */ +function fontFileEnum() { + return ['export const enum FontFile {', ...FONT_FAMILIES.map(({ key, value }) => ` ${key} = ${value},`), '}']; +} + +/** + * @param {string} key + * @param {'woff2' | 'woff' | 'ttf'} ext + */ +function importName(key, ext) { + const suffix = { woff2: 'Woff2', woff: 'Woff', ttf: 'Ttf' }[ext]; + return `font${key}${suffix}`; +} + +/** + * Font binary imports plus the `@font-face` CSS handed to `makeStaticStyles`. + * + * The template literal is kept inline in the call: Griffel's babel preset only drops the now + * dead font imports when it can replace the whole call expression. + * + * Family names are emitted as literals on purpose — Griffel evaluates this file in a VM, and an + * unresolvable lookup silently compiles to `font-family: undefined` rather than failing the build. + */ +function griffelFontFaces() { + /** @type {string[]} */ + const imports = []; + /** @type {string[]} */ + const fontFaces = []; + + for (const { key, family, file } of FONT_FAMILIES) { + for (const ext of FONT_BINARY_EXTENSIONS) { + imports.push(`import ${importName(key, ext)} from './${file}.${ext}';`); + } + imports.push(''); + + const src = FONT_BINARY_EXTENSIONS.map( + (ext) => ` url(\${JSON.stringify(${importName(key, ext)})}) format("${FONT_FORMAT_BY_EXTENSION[ext]}")`, + ).join(',\n'); + fontFaces.push( + '@font-face {', + ` font-family: ${family};`, + ' font-display: "block";', + ' src:', + `${src};`, + '}', + ); + } + + return [...imports, 'export const useStaticStyles = makeStaticStyles(`', ...fontFaces, '`);']; +} + +/** Per-variant `makeStyles` entries — inlined into the `useRootStyles` object literal. */ +function griffelFontFamilies() { + return FONT_FAMILIES.flatMap(({ key, family }) => [` [FontFile.${key}]: {`, ` fontFamily: '${family}',`, ' },']); +} + +/** + * Plain-CSS `@font-face` block, equivalent to the Griffel one. + * @param {string} urlPrefix Path from the stylesheet to the font binaries. + */ +function cssFontFaces(urlPrefix) { + /** @type {string[]} */ + const lines = []; + + for (const [index, { family, file }] of FONT_FAMILIES.entries()) { + if (index > 0) lines.push(''); + const src = FONT_BINARY_EXTENSIONS.map( + (ext) => ` url('${urlPrefix}${file}.${ext}') format('${FONT_FORMAT_BY_EXTENSION[ext]}')`, + ).join(',\n'); + lines.push('@font-face {', ` font-family: '${family}';`, ' font-display: block;', ' src:', `${src};`, '}'); + } + + return lines; +} + +/** Headless `data-fui-icon-font` → font-family rules. */ +function cssFontFamilies() { + return FONT_FAMILIES.flatMap(({ variant, family }) => [ + `[data-fui-icon-font='${variant}'] {`, + ` font-family: '${family}';`, + '}', + ]); +} + +/** Headless `FontFile` → `data-fui-icon-font` value map. */ +function headlessFontVariants() { + return [ + 'const FONT_VARIANT_MAP: Record = {', + ...FONT_FAMILIES.map(({ key, variant }) => ` [FontFile.${key}]: '${variant}',`), + '};', + ]; +} + +// ================================================================================================= + +/** + * Replaces the content between the `#region ` / `#endregion ` markers, keeping the markers. + * + * @param {string} content + * @param {string} name + * @param {string[]} lines + * @param {string} filePath Used for error messages only. + */ +function replaceRegion(content, name, lines, filePath) { + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const all = content.split(/\r?\n/); + const start = all.findIndex((line) => line.includes(`#region @generated ${name}`)); + const end = all.findIndex((line, index) => index > start && line.includes(`#endregion @generated ${name}`)); + + if (start === -1 || end === -1) { + throw new Error(`Missing or malformed \`#region @generated ${name}\` markers in ${filePath}`); + } + + return [...all.slice(0, start + 1), ...lines, ...all.slice(end)].join(eol); +} + +function printUsage() { + console.log( + `Usage: node generate-font-styles.js --dest [--check]\n\n` + + `Regenerates the \`#region @generated *\` blocks from scripts/font-families.js.\n\n` + + `Options:\n` + + ` --dest Package source folder containing utils/ and headless/ (required)\n` + + ` --check Fail if any region is out of date instead of writing\n` + + ` -h, --help Show this help message`, + ); +} + +module.exports = { + buildTargets, + fontFileEnum, + griffelFontFaces, + griffelFontFamilies, + cssFontFaces, + cssFontFamilies, + headlessFontVariants, + replaceRegion, +}; diff --git a/packages/react-icons/scripts/generate-font-styles.test.js b/packages/react-icons/scripts/generate-font-styles.test.js new file mode 100644 index 00000000000..b36e1bf3f8f --- /dev/null +++ b/packages/react-icons/scripts/generate-font-styles.test.js @@ -0,0 +1,72 @@ +// @ts-check +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, it, expect } from 'vitest'; + +import { FONT_FAMILIES } from './font-families'; +import { buildTargets, cssFontFaces, griffelFontFaces } from './generate-font-styles'; + +const SRC_DIR = path.join(__dirname, '../src'); + +describe('font-families manifest', () => { + it('pins the FontFile enum values baked into the generated atoms', () => { + // Changing these renumbers ~24k generated `createFluentFontIcon(name, codepoint, , size)` calls. + expect(Object.fromEntries(FONT_FAMILIES.map(({ key, value }) => [key, value]))).toEqual({ + Filled: 0, + Regular: 1, + Resizable: 2, + Light: 3, + }); + }); + + it('has unique family names, variants and font files', () => { + for (const field of /** @type {const} */ (['key', 'family', 'variant', 'file'])) { + const values = FONT_FAMILIES.map((definition) => definition[field]); + expect(new Set(values).size, `duplicate ${field}`).toBe(values.length); + } + }); + + it('has exactly one resizable family', () => { + expect(FONT_FAMILIES.filter((definition) => definition.resizable)).toHaveLength(1); + }); +}); + +describe('generate-font-styles', () => { + it('declares every family in both the headless CSS and the Griffel styles', () => { + const expected = FONT_FAMILIES.map(({ family }) => family).sort(); + + expect(familiesOf(cssFontFaces('../../utils/fonts/').join('\n')).sort()).toEqual(expected); + expect(familiesOf(griffelFontFaces().join('\n')).sort()).toEqual(expected); + }); + + it('gives every @font-face a font-display so no family silently renders unstyled', () => { + for (const css of [cssFontFaces('./').join('\n'), griffelFontFaces().join('\n')]) { + const faces = css.split('@font-face').slice(1); + expect(faces).toHaveLength(FONT_FAMILIES.length); + for (const face of faces) { + expect(face).toMatch(/font-display:\s*"?block"?;/); + } + } + }); + + it('checked-in sources match the generated regions', () => { + for (const [relativePath, regions] of Object.entries(buildTargets())) { + const content = fs.readFileSync(path.join(SRC_DIR, relativePath), 'utf8'); + for (const [name, lines] of Object.entries(regions)) { + expect(content, `${relativePath} — run \`yarn generate:font-styles\``).toContain(lines.join('\n')); + } + } + }); +}); + +/** + * @param {string} css + * @returns {string[]} + */ +function familiesOf(css) { + return [...css.matchAll(/font-family:\s*'?([\w-]+)'?;/g)].map((match) => match[1]); +} diff --git a/packages/react-icons/src/headless/fonts/createFluentFontIcon.tsx b/packages/react-icons/src/headless/fonts/createFluentFontIcon.tsx index 2335124b587..460f4fd8f9d 100644 --- a/packages/react-icons/src/headless/fonts/createFluentFontIcon.tsx +++ b/packages/react-icons/src/headless/fonts/createFluentFontIcon.tsx @@ -15,12 +15,14 @@ export type FluentFontIcon = React.FC = { [FontFile.Filled]: 'filled', [FontFile.Regular]: 'regular', [FontFile.Resizable]: 'resizable', [FontFile.Light]: 'light', }; +// #endregion @generated font-variants /** * Headless createFluentFontIcon — font icon factory without Griffel. diff --git a/packages/react-icons/src/headless/fonts/styles.css b/packages/react-icons/src/headless/fonts/styles.css index a0dd6df3774..3be3ba3a3cc 100644 --- a/packages/react-icons/src/headless/fonts/styles.css +++ b/packages/react-icons/src/headless/fonts/styles.css @@ -7,6 +7,7 @@ * import '@fluentui/react-icons/headless/fonts/styles.css'; */ +/* #region @generated font-faces */ @font-face { font-family: 'FluentSystemIconsFilled'; font-display: block; @@ -26,19 +27,20 @@ } @font-face { - font-family: 'FluentSystemIconsLight'; + font-family: 'FluentSystemIcons'; font-display: block; src: - url('../../utils/fonts/FluentSystemIcons-Light.woff2') format('woff2'), - url('../../utils/fonts/FluentSystemIcons-Light.woff') format('woff'), - url('../../utils/fonts/FluentSystemIcons-Light.ttf') format('truetype'); + url('../../utils/fonts/FluentSystemIcons-Resizable.woff2') format('woff2'), + url('../../utils/fonts/FluentSystemIcons-Resizable.woff') format('woff'), + url('../../utils/fonts/FluentSystemIcons-Resizable.ttf') format('truetype'); } @font-face { - font-family: 'FluentSystemIcons'; + font-family: 'FluentSystemIconsLight'; font-display: block; src: - url('../../utils/fonts/FluentSystemIcons-Resizable.woff2') format('woff2'), - url('../../utils/fonts/FluentSystemIcons-Resizable.woff') format('woff'), - url('../../utils/fonts/FluentSystemIcons-Resizable.ttf') format('truetype'); + url('../../utils/fonts/FluentSystemIcons-Light.woff2') format('woff2'), + url('../../utils/fonts/FluentSystemIcons-Light.woff') format('woff'), + url('../../utils/fonts/FluentSystemIcons-Light.ttf') format('truetype'); } +/* #endregion @generated font-faces */ diff --git a/packages/react-icons/src/headless/styles.css b/packages/react-icons/src/headless/styles.css index a41a927551a..5cf6fac21e1 100644 --- a/packages/react-icons/src/headless/styles.css +++ b/packages/react-icons/src/headless/styles.css @@ -57,6 +57,7 @@ } /* ===== Font Icon Family Variants ===== */ +/* #region @generated font-families */ [data-fui-icon-font='filled'] { font-family: 'FluentSystemIconsFilled'; } @@ -69,3 +70,4 @@ [data-fui-icon-font='light'] { font-family: 'FluentSystemIconsLight'; } +/* #endregion @generated font-families */ diff --git a/packages/react-icons/src/utils/fonts/createFluentFontIcon.shared.ts b/packages/react-icons/src/utils/fonts/createFluentFontIcon.shared.ts index aac0acc85bc..40263ef043b 100644 --- a/packages/react-icons/src/utils/fonts/createFluentFontIcon.shared.ts +++ b/packages/react-icons/src/utils/fonts/createFluentFontIcon.shared.ts @@ -1,6 +1,8 @@ +// #region @generated font-file-enum export const enum FontFile { Filled = 0, Regular = 1, Resizable = 2, Light = 3, } +// #endregion @generated font-file-enum diff --git a/packages/react-icons/src/utils/fonts/createFluentFontIcon.styles.ts b/packages/react-icons/src/utils/fonts/createFluentFontIcon.styles.ts index dbcd9a98847..7cc84015a10 100644 --- a/packages/react-icons/src/utils/fonts/createFluentFontIcon.styles.ts +++ b/packages/react-icons/src/utils/fonts/createFluentFontIcon.styles.ts @@ -1,61 +1,59 @@ import { makeStyles, makeStaticStyles } from '@griffel/react'; -import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; -import fontFilledWoff from './FluentSystemIcons-Filled.woff'; +import { FontFile } from './createFluentFontIcon.shared'; + +// #region @generated font-faces import fontFilledWoff2 from './FluentSystemIcons-Filled.woff2'; +import fontFilledWoff from './FluentSystemIcons-Filled.woff'; +import fontFilledTtf from './FluentSystemIcons-Filled.ttf'; -import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; -import fontRegularWoff from './FluentSystemIcons-Regular.woff'; import fontRegularWoff2 from './FluentSystemIcons-Regular.woff2'; +import fontRegularWoff from './FluentSystemIcons-Regular.woff'; +import fontRegularTtf from './FluentSystemIcons-Regular.ttf'; -import fontLightTtf from './FluentSystemIcons-Light.ttf'; -import fontLightWoff from './FluentSystemIcons-Light.woff'; -import fontLightWoff2 from './FluentSystemIcons-Light.woff2'; - -import fontOneSizeTtf from './FluentSystemIcons-Resizable.ttf'; -import fontOneSizeWoff from './FluentSystemIcons-Resizable.woff'; -import fontOneSizeWoff2 from './FluentSystemIcons-Resizable.woff2'; -import { FontFile } from './createFluentFontIcon.shared'; +import fontResizableWoff2 from './FluentSystemIcons-Resizable.woff2'; +import fontResizableWoff from './FluentSystemIcons-Resizable.woff'; +import fontResizableTtf from './FluentSystemIcons-Resizable.ttf'; -const FONT_FAMILY_MAP = { - [FontFile.Filled]: 'FluentSystemIconsFilled', - [FontFile.Regular]: 'FluentSystemIconsRegular', - [FontFile.Resizable]: 'FluentSystemIcons', - [FontFile.Light]: 'FluentSystemIconsLight', -} as const; +import fontLightWoff2 from './FluentSystemIcons-Light.woff2'; +import fontLightWoff from './FluentSystemIcons-Light.woff'; +import fontLightTtf from './FluentSystemIcons-Light.ttf'; export const useStaticStyles = makeStaticStyles(` @font-face { - font-family: ${FONT_FAMILY_MAP[FontFile.Filled]}; + font-family: FluentSystemIconsFilled; font-display: "block"; - src: url(${JSON.stringify(fontFilledWoff2)}) format("woff2"), + src: + url(${JSON.stringify(fontFilledWoff2)}) format("woff2"), url(${JSON.stringify(fontFilledWoff)}) format("woff"), url(${JSON.stringify(fontFilledTtf)}) format("truetype"); } @font-face { - font-family: ${FONT_FAMILY_MAP[FontFile.Regular]}; + font-family: FluentSystemIconsRegular; font-display: "block"; - src: url(${JSON.stringify(fontRegularWoff2)}) format("woff2"), + src: + url(${JSON.stringify(fontRegularWoff2)}) format("woff2"), url(${JSON.stringify(fontRegularWoff)}) format("woff"), url(${JSON.stringify(fontRegularTtf)}) format("truetype"); } - @font-face { - font-family: ${FONT_FAMILY_MAP[FontFile.Light]}; + font-family: FluentSystemIcons; font-display: "block"; - src: url(${JSON.stringify(fontLightWoff2)}) format("woff2"), - url(${JSON.stringify(fontLightWoff)}) format("woff"), - url(${JSON.stringify(fontLightTtf)}) format("truetype"); + src: + url(${JSON.stringify(fontResizableWoff2)}) format("woff2"), + url(${JSON.stringify(fontResizableWoff)}) format("woff"), + url(${JSON.stringify(fontResizableTtf)}) format("truetype"); } - @font-face { - font-family: ${FONT_FAMILY_MAP[FontFile.Resizable]}; + font-family: FluentSystemIconsLight; font-display: "block"; - src: url(${JSON.stringify(fontOneSizeWoff2)}) format("woff2"), - url(${JSON.stringify(fontOneSizeWoff)}) format("woff"), - url(${JSON.stringify(fontOneSizeTtf)}) format("truetype"); + src: + url(${JSON.stringify(fontLightWoff2)}) format("woff2"), + url(${JSON.stringify(fontLightWoff)}) format("woff"), + url(${JSON.stringify(fontLightTtf)}) format("truetype"); } `); +// #endregion @generated font-faces export const useRootStyles = makeStyles({ root: { @@ -87,6 +85,7 @@ export const useRootStyles = makeStyles({ // Rather than setting forced-color-adjust to 'none' or 'auto', we leave this value // default, inherited from the parent control. }, + // #region @generated font-families [FontFile.Filled]: { fontFamily: 'FluentSystemIconsFilled', }, @@ -99,4 +98,5 @@ export const useRootStyles = makeStyles({ [FontFile.Light]: { fontFamily: 'FluentSystemIconsLight', }, + // #endregion @generated font-families });