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
9 changes: 9 additions & 0 deletions packages/svg-sprites/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export const Icon = (props: IconProps) => {
};
```

The package also emits grouped sprite files following the local bundle format, for example:

```text
fluent_icons_20_regular.sprite.svg
fluent_icons_20_filled.sprite.svg
```

Those files contain all symbols for a given size/style pairing, with ids like `access_time`, `add`, and `alert` instead of repeating the size/style suffix inside each id.

## Development

### Building Sprites
Expand Down
25 changes: 21 additions & 4 deletions packages/svg-sprites/build-verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,34 @@ describe('Build Verification', () => {
// Sprite should be a valid SVG
expect(content, `${file} should contain <svg`).toMatch(/<svg\b/);

// Should contain a <symbol> with matching id
const expectedId = path.basename(file, '.sprite.svg');
expect(content, `${file} should contain <symbol> with id="${expectedId}"`).toMatch(
new RegExp(`<symbol[^>]+id="${expectedId}"`),
);
const isGroupedSprite = /^fluent_icons_\d+_(regular|filled|light|color)\.sprite\.svg$/.test(file);

if (isGroupedSprite) {
expect(content, `${file} should contain <symbol> elements`).toMatch(/<symbol\b/);
} else {
// Should contain a <symbol> with matching id
expect(content, `${file} should contain <symbol> with id="${expectedId}"`).toMatch(
new RegExp(`<symbol[^>]+id="${expectedId}"`),
);
}

// Symbol should have viewBox
expect(content, `${file} symbol should have viewBox`).toMatch(/<symbol[^>]+viewBox="/);
}
});

it('should group all icons for a size and style into one sprite', async () => {
const file = 'fluent_icons_20_regular.sprite.svg';
const content = await readFile(path.join(SPRITES_DIR, file), 'utf8');
const symbolIds = [...content.matchAll(/<symbol[^>]+id="([^"]+)"/g)].map((match) => match[1]);

expect(symbolIds.length, `${file} should contain multiple symbols`).toBeGreaterThan(1);
expect(new Set(symbolIds).size, `${file} should contain unique symbol ids`).toBe(symbolIds.length);
expect(symbolIds).toContain('access_time');
expect(symbolIds).toContain('add');
});

// TODO: to enable this we would need to update snapshot during release - lets avoid that for now
it.skip('should have a stable set of sprite files (snapshot)', async () => {
const entries = await readdir(SPRITES_DIR);
Expand Down
74 changes: 69 additions & 5 deletions packages/svg-sprites/generate-sprites.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,43 @@ function processArgs() {
return { ICONS_DIR, SPRITES_DIR, NUM_WORKERS };
}

/**
* Parses an icon filename into its semantic id + variant metadata.
* @param {string} fileName
* @returns {{ iconId: string, size?: string, style?: string, fileName: string }}
*/
function parseIconMeta(fileName) {
const withoutExt = path.basename(fileName, '.svg');
const match = withoutExt.match(/^(.*)_(\d+)_(regular|filled|light|color)$/);

if (match) {
return {
iconId: match[1],
size: match[2],
style: match[3],
fileName: withoutExt,
};
}

return { iconId: withoutExt, fileName: withoutExt };
}

/**
* Builds a combined sprite file containing multiple symbols.
* @param {{ iconId: string, iconPath: string }[]} entries
* @returns {Promise<string>}
*/
async function createCombinedSprite(entries) {
const sprites = svgstore();

for (const entry of entries) {
const iconContent = await fs.readFile(entry.iconPath, 'utf-8');
sprites.add(entry.iconId, iconContent);
}

return sprites.toString();
}

/**
* Creates a sprite SVG file from a single icon SVG using svgstore
* @param {string} iconPath - Path to the icon file
Expand Down Expand Up @@ -140,7 +177,7 @@ async function main() {

console.log(`📊 Processing ${svgFiles.length} icons with ${NUM_WORKERS} workers...`);

// Split work into batches (one per CPU core)
// Build the existing one-icon-per-file sprite set.
const batchSize = Math.ceil(svgFiles.length / NUM_WORKERS);
const batches = [];

Expand All @@ -152,18 +189,45 @@ async function main() {
}
}

// Process all batches in parallel
const results = await Promise.all(batches);

// Flatten results
const allResults = results.flat();
const successful = allResults.filter((r) => r.success).length;
const failed = allResults.filter((r) => !r.success);

// Also generate combined files grouped by size and style, matching the local bundle layout.
const groupedBySizeAndStyle = new Map();

for (const file of svgFiles) {
const meta = parseIconMeta(file);
if (!meta.size || !meta.style) {
continue;
}

const key = `${meta.size}_${meta.style}`;
const bucket = groupedBySizeAndStyle.get(key) ?? [];
bucket.push({
iconId: meta.iconId,
iconPath: path.join(ICONS_DIR, file),
});
groupedBySizeAndStyle.set(key, bucket);
}

const combinedFiles = [];

for (const [key, entries] of groupedBySizeAndStyle) {
const spriteContent = await createCombinedSprite(entries);
const outputFile = `fluent_icons_${key}.sprite.svg`;
const outputPath = path.join(SPRITES_DIR, outputFile);
await fs.writeFile(outputPath, spriteContent, 'utf-8');
combinedFiles.push(outputFile);
}

const duration = ((Date.now() - startTime) / 1000).toFixed(2);
const durationNum = parseFloat(duration);

console.log(`\n✅ Generated ${successful} sprites in ${duration}s`);
console.log(
`\n✅ Generated ${successful} per-icon sprites and ${combinedFiles.length} grouped sprites in ${duration}s`,
);
console.log(`⚡ Performance: ${(svgFiles.length / durationNum).toFixed(0)} sprites/second`);

if (failed.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/svg-sprites/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"optimize": "yarn run -T svgo --config svgo.config.js --folder=./icons --precision=2",
"unfill": "node unfill.js --path=./icons/",
"sprites": "node generate-sprites.js && rm -rf ./icons",
"build": "yarn copy && yarn rename && yarn unfill && yarn optimize && yarn sprites",
"build": "yarn clean && yarn copy && yarn rename && yarn unfill && yarn optimize && yarn sprites",
"build-verify": "yarn run -T vitest run build-verify.test.js"
}
}