Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* The same entries, thresholds and assertions run against both webpack and rspack; only the
* CSS-extraction and HTML plugins differ, so they are injected by the bundler-specific configs.
*/
const { resolve } = require('path');
const { resolve, join } = require('path');
const { readFileSync } = require('fs');

const { default: FluentUIReactIconsFontSubsettingPlugin } = require('../lib/');

Expand All @@ -29,6 +30,22 @@ const entries = {
useAtomicLoader: true,
assertNoGriffel: true,
},
// Async chunks: one icon is eager, the other reachable only through `import()`, and they live in
// different font families. The size ceiling cannot police this on its own — losing the async
// glyph makes the font *smaller* — so glyph counts are asserted too (`.notdef` is always glyph 0).
lazyAtoms: {
src: './src/lazy-atoms.js',
threshold: 2 * 1_024, // 2 KB
fontGlyphCounts: { 'FluentSystemIcons-Resizable': 2, 'FluentSystemIcons-Filled': 2 },
},
// The harder variant: both icons are sized+Filled, so a *single* emitted font must carry glyphs
// contributed by two different chunks. Fonts are subset per family across the whole build, not
// per chunk, so the eager half must not subset the async half's glyph away.
lazySharedFontFamily: {
src: './src/lazy-shared-family.js',
threshold: 2 * 1_024, // 2 KB
fontGlyphCounts: { 'FluentSystemIcons-Filled': 3 },
},
};

/**
Expand Down Expand Up @@ -138,7 +155,7 @@ function createConfig(name, entry, adapter, isDevServer) {
* Fails the build when a font asset was not subset, or when a headless entry leaked Griffel.
*
* @param {string} name
* @param {{ threshold: number, assertNoGriffel?: boolean }} entry
* @param {{ threshold: number, assertNoGriffel?: boolean, fontGlyphCounts?: Record<string, number> }} entry
* @param {BundlerAdapter} adapter
*/
function createAssertionPlugin(name, entry, adapter) {
Expand All @@ -160,6 +177,26 @@ function createAssertionPlugin(name, entry, adapter) {
}
}

for (const [fontBaseName, expectedGlyphs] of Object.entries(entry.fontGlyphCounts ?? {})) {
// Only .ttf is inspected; .woff/.woff2 wrap the same glyphs in a compressed container.
const asset = fontAssets.find(({ name: assetName }) =>
new RegExp(`^${fontBaseName}[.-][^/]*\\.ttf$`).test(assetName),
);

if (!asset) {
throw new Error(`[${adapter.name}/${name}] No emitted .ttf asset for "${fontBaseName}".`);
}

// `afterEmit` downgrades sources to size-only, so the bytes come back off disk.
const glyphCount = readGlyphCount(readFileSync(join(compiler.outputPath, asset.name)));
Comment on lines +190 to +191
if (glyphCount < expectedGlyphs) {
throw new Error(
`[${adapter.name}/${name}] Asset "${asset.name}" has ${glyphCount} glyphs, expected at least ` +
`${expectedGlyphs} (including .notdef) — an icon that should have been kept was subset away.`,
);
}
}

// Headless builds must not pull in Griffel.
if (entry.assertNoGriffel) {
for (const m of compilation.modules) {
Expand All @@ -177,4 +214,27 @@ function createAssertionPlugin(name, entry, adapter) {
};
}

/**
* Reads `numGlyphs` out of a TrueType font's `maxp` table.
*
* Byte sizes make a poor correctness signal here: a font that wrongly dropped a glyph is *smaller*,
* so it slips under any ceiling. The glyph count says outright whether an icon survived.
*
* @param {Buffer} ttf
* @returns {number}
*/
function readGlyphCount(ttf) {
const tableCount = ttf.readUInt16BE(4);

for (let i = 0; i < tableCount; i++) {
// Table directory: 12-byte header, then 16 bytes per record (tag, checksum, offset, length).
const record = 12 + i * 16;
if (ttf.toString('ascii', record, record + 4) === 'maxp') {
return ttf.readUInt16BE(ttf.readUInt32BE(record + 8) + 4);
}
}

throw new Error('Font has no `maxp` table — not a TrueType font?');
}

module.exports = { makeConfigs, entries };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @ts-check
// Reached only via `import()`, so its icon exists solely in an async chunk.
import { XboxConsole24Filled } from '@fluentui/react-icons/fonts/xbox-console';

export { XboxConsole24Filled };
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @ts-check
// The eager half. Its sibling is reachable only through a dynamic import, so the two icons land in
// different chunks — and, deliberately, in different font families.
import { GamesFilled } from '@fluentui/react-icons/fonts/games';

console.dir({ GamesFilled });

import('./lazy-atoms.async').then((m) => console.dir(m));
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// @ts-check
// Shares the Filled family with the eager half, but is reachable only through `import()`.
import { XboxConsole24Filled } from '@fluentui/react-icons/fonts/xbox-console';

export { XboxConsole24Filled };
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @ts-check
// Same font family as its async sibling: both icons are sized+Filled, so one emitted font file has
// to carry glyphs contributed from two different chunks.
import { Games24Filled } from '@fluentui/react-icons/fonts/games';

console.dir({ Games24Filled });

import('./lazy-shared-family.async').then((m) => console.dir(m));
Loading