Skip to content

Commit 4b272b6

Browse files
authored
[Project Solar / Phase 1 / Cherry-pick] Flight icons tokens pipeline / Generation of symbol-js (Part 1: Code) (#3709)
1 parent cf46722 commit 4b272b6

File tree

10 files changed

+569
-36
lines changed

10 files changed

+569
-36
lines changed

.changeset/strong-pigs-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hashicorp/flight-icons": minor
3+
---
4+
5+
Added additional build and sync scripts which generate loader modules and types to support the transition from Flight to Carbon icons systems.

packages/flight-icons/catalog.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
import type { IconName } from './svg';
3+
4+
export interface IconAsset {
5+
iconName: IconName;
6+
category: string;
7+
size: string;
8+
[key: string]: unknown;
9+
}
10+
11+
export interface IconCatalog {
12+
assets: IconAsset[];
13+
}
14+
15+
declare const catalog: IconCatalog;
16+
export default catalog;

packages/flight-icons/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"build": "ts-node --transpile-only ./scripts/build.ts"
2323
},
2424
"devDependencies": {
25+
"@carbon/icons": "^11.76.0",
2526
"@figma-export/cli": "^6.0.2",
2627
"@figma-export/core": "^6.3.0",
2728
"@figma-export/output-components-as-svg": "^6.0.1",

packages/flight-icons/scripts/build-parts/generateBundleSVGSprite.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import svgstore from 'svgstore';
1010

1111
import { ConfigData } from '../@types/ConfigData';
1212
import { AssetsCatalog } from '../@types/AssetsCatalog';
13+
import replaceDynamicColor from './replaceDynamicColor';
1314

1415
export async function generateBundleSVGSprite({ config, catalog } : { config: ConfigData, catalog: AssetsCatalog }): Promise<void> {
1516

@@ -41,11 +42,7 @@ export async function generateBundleSVGSprite({ config, catalog } : { config: Co
4142
// add the SVGs to the sprite
4243
for(const { fileName } of catalog.assets) {
4344
let svgSource = await fs.readFile(`${tempSVGFolderPath}/${fileName}.svg`, 'utf8');
44-
// completely remove any "fill" attribute that has #000001 ("dynamic" color in Figma, equivalent of "currentColor") as value
45-
// the reason for this is that the Ember addon uses the "fill" attribute with "currentColor" value (or a value passed as prop by the user)
46-
// to set the color of the children <path> elements (see https://github.com/hashicorp/flight/issues/200) so the only way to have it work properly
47-
// is not to use `fill="currentColor"` on the <path> elements, but to completely remove the fill attribute so it's inherited from the parent <SVG> element
48-
svgSource = svgSource.replace(/ fill="#000001"/gi, '');
45+
svgSource = replaceDynamicColor(svgSource);
4946
// add the processed SVG content to the sprite (notice: the first argument is the symbol ID)
5047
sprites.add(`flight-${fileName}`, svgSource);
5148
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Copyright IBM Corp. 2021, 2025
3+
* SPDX-License-Identifier: MPL-2.0
4+
*/
5+
6+
import fs from 'fs-extra';
7+
import prettier from 'prettier';
8+
import path from 'path';
9+
import replaceDynamicColor from './replaceDynamicColor';
10+
11+
import { ConfigData } from '../@types/ConfigData';
12+
import { AssetsCatalog } from '../@types/AssetsCatalog';
13+
14+
const prettierConfig = {
15+
tabWidth: 4,
16+
singleQuote: true,
17+
trailingComma: 'none'
18+
} as const;
19+
20+
// important: if you update this function, update the identical one in `packages/components/src/services/hds-icon-registry.ts` as well (and vice versa)
21+
function makeDomSafeId(value: string): string {
22+
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
23+
}
24+
25+
// important: if you update this function, update the identical one in `packages/components/src/services/hds-icon-registry.ts` as well (and vice versa)
26+
function makeSymbolIdFromKey(key: string): string {
27+
return `hds-icon-${makeDomSafeId(key)}`;
28+
}
29+
30+
const getSymbolModule = (sourceSvg: string, id: string): string => {
31+
const viewBoxMatch = sourceSvg.match(/viewBox="([^"]+)"/);
32+
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 16 16';
33+
34+
let innerContent = sourceSvg
35+
.replace(/<svg[^>]*>/, '')
36+
.replace(/<\/svg>/, '');
37+
38+
innerContent = replaceDynamicColor(innerContent);
39+
40+
return `export default \`<symbol id="${id}" viewBox="${viewBox}">${innerContent}</symbol>\`;`;
41+
};
42+
43+
export async function generateBundleSymbolJS({ config, catalog }: { config: ConfigData, catalog: AssetsCatalog }): Promise<void> {
44+
const tempSVGFolderPath = config.tempFolder;
45+
const mappingFilePath = path.resolve(__dirname, '../hds-carbon-icon-map.json');
46+
const carbonIconsPath = path.resolve(__dirname, '../../node_modules/@carbon/icons/svg');
47+
48+
let mapping: Record<string, string> = {};
49+
try {
50+
mapping = await fs.readJSON(mappingFilePath);
51+
} catch {
52+
console.warn('⚠️ Map file not found.');
53+
}
54+
55+
// Define folders
56+
const outputFolder = `${config.mainFolder}/symbol-js`;
57+
const flightFolder = `${outputFolder}/flight`;
58+
const carbonFolder = `${outputFolder}/carbon`;
59+
60+
// Ensure folders exist (but empty)
61+
await fs.emptyDir(outputFolder);
62+
await fs.ensureDir(flightFolder);
63+
await fs.ensureDir(carbonFolder);
64+
65+
const registry: Record<string, { flight: Record<string, string>, carbon: string | null }> = {};
66+
67+
for (const { fileName } of catalog.assets) {
68+
const match = fileName.match(/^(.*)-(16|24)$/);
69+
70+
if (match) {
71+
const [, baseName, size] = match;
72+
73+
if (!registry[baseName]) {
74+
registry[baseName] = { flight: {}, carbon: null };
75+
}
76+
77+
// --- FLIGHT ---
78+
try {
79+
const key = `flight-${baseName}-${size}`;
80+
const symbolId = makeSymbolIdFromKey(key);
81+
82+
const flightSource = await fs.readFile(`${tempSVGFolderPath}/${fileName}.svg`, 'utf8');
83+
const flightContent = await prettier.format(
84+
getSymbolModule(flightSource, symbolId),
85+
{ ...prettierConfig, parser: 'typescript' }
86+
);
87+
88+
await fs.writeFile(`${flightFolder}/${fileName}.js`, flightContent);
89+
} catch (err) {
90+
console.error(`Error reading Flight icon: ${fileName}`, err);
91+
}
92+
93+
registry[baseName].flight[size] = `() => import('./flight/${fileName}.js')`;
94+
95+
// --- CARBON ---
96+
const carbonName = mapping[baseName];
97+
98+
if (carbonName && !registry[baseName].carbon) {
99+
const carbonPath = path.join(carbonIconsPath, '32', `${carbonName}.svg`);
100+
101+
if (fs.existsSync(carbonPath)) {
102+
const key = `carbon-${baseName}`;
103+
const symbolId = makeSymbolIdFromKey(key);
104+
105+
const carbonSource = await fs.readFile(carbonPath, 'utf8');
106+
const carbonContent = await prettier.format(getSymbolModule(carbonSource, symbolId), { ...prettierConfig, parser: 'typescript' });
107+
108+
await fs.writeFile(`${carbonFolder}/${baseName}.js`, carbonContent);
109+
110+
registry[baseName].carbon = `() => import('./carbon/${baseName}.js')`;
111+
} else {
112+
console.warn(`⚠️ Carbon icon missing: ${carbonName} (size 32) - Mapped from Flight icon ${fileName}`);
113+
}
114+
}
115+
}
116+
}
117+
118+
// Generate Registry (JS + Types)
119+
const registryLines = Object.entries(registry).map(([baseName, data]) => {
120+
const flightSizes = Object.entries(data.flight)
121+
.map(([size, imp]) => `'${size}': ${imp}`)
122+
.join(', ');
123+
124+
const carbonImp = data.carbon ? data.carbon : 'null';
125+
126+
return `'${baseName}': {
127+
flight: { ${flightSizes} },
128+
carbon: ${carbonImp}
129+
},`;
130+
});
131+
132+
const registryJsContent = await prettier.format(`
133+
export const IconRegistry = {
134+
${registryLines.join('\n')}
135+
};
136+
`, { ...prettierConfig, parser: 'babel' });
137+
138+
await fs.writeFile(`${outputFolder}/registry.js`, registryJsContent);
139+
140+
const registryDtsContent = await prettier.format(`
141+
import type { IconName } from '../svg';
142+
143+
export interface HdsIconModule {
144+
default: string;
145+
}
146+
147+
export interface HdsIconRegistryEntry {
148+
flight: {
149+
[size: string]: () => Promise<HdsIconModule>;
150+
};
151+
carbon: (() => Promise<HdsIconModule>) | null;
152+
}
153+
154+
export declare const IconRegistry: Record<IconName, HdsIconRegistryEntry>;
155+
`, { ...prettierConfig, parser: 'typescript' });
156+
157+
await fs.writeFile(`${outputFolder}/registry.d.ts`, registryDtsContent);
158+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright IBM Corp. 2021, 2025
3+
* SPDX-License-Identifier: MPL-2.0
4+
*/
5+
6+
export default function replaceDynamicColor(source: string) {
7+
// completely remove any "fill" attribute that has #000001 ("dynamic" color in Figma, equivalent of "currentColor") as value
8+
// the reason for this is that the Ember addon uses the "fill" attribute with "currentColor" value (or a value passed as prop by the user)
9+
// to set the color of the children <path> elements (see https://github.com/hashicorp/flight/issues/200) so the only way to have it work properly
10+
// is not to use `fill="currentColor"` on the <path> elements, but to completely remove the fill attribute so it's inherited from the parent <SVG> element
11+
return source.replace(/ fill="#000001"/gi, '');
12+
}

packages/flight-icons/scripts/build.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import chalk from 'chalk';
1010

1111
import { optimizeAssetsSVG } from './build-parts/optimizeAssetsSVG';
1212
import { generateBundleSVG } from './build-parts/generateBundleSVG';
13+
import { generateBundleSymbolJS } from './build-parts/generateBundleSymbolJS';
1314
import { generateBundleSVGSprite } from './build-parts/generateBundleSVGSprite';
1415
import { generateBundleSVGReact } from './build-parts/generateBundleSVGReact';
1516

@@ -59,6 +60,10 @@ async function build() {
5960
console.log('Generating bundle for standalone SVG files');
6061
await generateBundleSVG({ config, catalog });
6162

63+
// generate the bundle for the SVG JS files
64+
console.log('Generating bundle for SVG JS files');
65+
await generateBundleSymbolJS({ config, catalog });
66+
6267
// generate the bundle for the SVG sprite
6368
console.log('Generating bundle for SVG sprite');
6469
await generateBundleSVGSprite({ config, catalog });

0 commit comments

Comments
 (0)