Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/strong-pigs-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashicorp/flight-icons": minor
---

Added additional build and sync scripts which generate loader modules and types to support the transition from Flight to Carbon icons systems.
16 changes: 16 additions & 0 deletions packages/flight-icons/catalog.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@

import type { IconName } from './svg';

export interface IconAsset {
iconName: IconName;
category: string;
size: string;
[key: string]: unknown;
}

export interface IconCatalog {
assets: IconAsset[];
}

declare const catalog: IconCatalog;
export default catalog;
1 change: 1 addition & 0 deletions packages/flight-icons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"build": "ts-node --transpile-only ./scripts/build.ts"
},
"devDependencies": {
"@carbon/icons": "^11.76.0",
"@figma-export/cli": "^6.0.2",
"@figma-export/core": "^6.3.0",
"@figma-export/output-components-as-svg": "^6.0.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import svgstore from 'svgstore';

import { ConfigData } from '../@types/ConfigData';
import { AssetsCatalog } from '../@types/AssetsCatalog';
import replaceDynamicColor from './replaceDynamicColor';

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

Expand Down Expand Up @@ -41,11 +42,7 @@ export async function generateBundleSVGSprite({ config, catalog } : { config: Co
// add the SVGs to the sprite
for(const { fileName } of catalog.assets) {
let svgSource = await fs.readFile(`${tempSVGFolderPath}/${fileName}.svg`, 'utf8');
// completely remove any "fill" attribute that has #000001 ("dynamic" color in Figma, equivalent of "currentColor") as value
// 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)
// 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
// 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
svgSource = svgSource.replace(/ fill="#000001"/gi, '');
svgSource = replaceDynamicColor(svgSource);
// add the processed SVG content to the sprite (notice: the first argument is the symbol ID)
sprites.add(`flight-${fileName}`, svgSource);
}
Expand Down
158 changes: 158 additions & 0 deletions packages/flight-icons/scripts/build-parts/generateBundleSymbolJS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Copyright IBM Corp. 2021, 2025
* SPDX-License-Identifier: MPL-2.0
*/

import fs from 'fs-extra';
import prettier from 'prettier';
import path from 'path';
import replaceDynamicColor from './replaceDynamicColor';

import { ConfigData } from '../@types/ConfigData';
import { AssetsCatalog } from '../@types/AssetsCatalog';

const prettierConfig = {
tabWidth: 4,
singleQuote: true,
trailingComma: 'none'
} as const;

// important: if you update this function, update the identical one in `packages/components/src/services/hds-icon-registry.ts` as well (and vice versa)
function makeDomSafeId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
}

// important: if you update this function, update the identical one in `packages/components/src/services/hds-icon-registry.ts` as well (and vice versa)
function makeSymbolIdFromKey(key: string): string {
return `hds-icon-${makeDomSafeId(key)}`;
}

const getSymbolModule = (sourceSvg: string, id: string): string => {
const viewBoxMatch = sourceSvg.match(/viewBox="([^"]+)"/);
const viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 16 16';

let innerContent = sourceSvg
.replace(/<svg[^>]*>/, '')
.replace(/<\/svg>/, '');

innerContent = replaceDynamicColor(innerContent);

return `export default \`<symbol id="${id}" viewBox="${viewBox}">${innerContent}</symbol>\`;`;
};

export async function generateBundleSymbolJS({ config, catalog }: { config: ConfigData, catalog: AssetsCatalog }): Promise<void> {
const tempSVGFolderPath = config.tempFolder;
const mappingFilePath = path.resolve(__dirname, '../hds-carbon-icon-map.json');
const carbonIconsPath = path.resolve(__dirname, '../../node_modules/@carbon/icons/svg');

let mapping: Record<string, string> = {};
try {
mapping = await fs.readJSON(mappingFilePath);
} catch {
console.warn('⚠️ Map file not found.');
}

// Define folders
const outputFolder = `${config.mainFolder}/symbol-js`;
const flightFolder = `${outputFolder}/flight`;
const carbonFolder = `${outputFolder}/carbon`;

// Ensure folders exist (but empty)
await fs.emptyDir(outputFolder);
await fs.ensureDir(flightFolder);
await fs.ensureDir(carbonFolder);

const registry: Record<string, { flight: Record<string, string>, carbon: string | null }> = {};

for (const { fileName } of catalog.assets) {
const match = fileName.match(/^(.*)-(16|24)$/);

if (match) {
const [, baseName, size] = match;

if (!registry[baseName]) {
registry[baseName] = { flight: {}, carbon: null };
}

// --- FLIGHT ---
try {
const key = `flight-${baseName}-${size}`;
const symbolId = makeSymbolIdFromKey(key);

const flightSource = await fs.readFile(`${tempSVGFolderPath}/${fileName}.svg`, 'utf8');
const flightContent = await prettier.format(
getSymbolModule(flightSource, symbolId),
{ ...prettierConfig, parser: 'typescript' }
);

await fs.writeFile(`${flightFolder}/${fileName}.js`, flightContent);
} catch (err) {
console.error(`Error reading Flight icon: ${fileName}`, err);
}

registry[baseName].flight[size] = `() => import('./flight/${fileName}.js')`;

// --- CARBON ---
const carbonName = mapping[baseName];

if (carbonName && !registry[baseName].carbon) {
const carbonPath = path.join(carbonIconsPath, '32', `${carbonName}.svg`);

if (fs.existsSync(carbonPath)) {
const key = `carbon-${baseName}`;
const symbolId = makeSymbolIdFromKey(key);

const carbonSource = await fs.readFile(carbonPath, 'utf8');
const carbonContent = await prettier.format(getSymbolModule(carbonSource, symbolId), { ...prettierConfig, parser: 'typescript' });

await fs.writeFile(`${carbonFolder}/${baseName}.js`, carbonContent);

registry[baseName].carbon = `() => import('./carbon/${baseName}.js')`;
} else {
console.warn(`⚠️ Carbon icon missing: ${carbonName} (size 32) - Mapped from Flight icon ${fileName}`);
}
}
}
}

// Generate Registry (JS + Types)
const registryLines = Object.entries(registry).map(([baseName, data]) => {
const flightSizes = Object.entries(data.flight)
.map(([size, imp]) => `'${size}': ${imp}`)
.join(', ');

const carbonImp = data.carbon ? data.carbon : 'null';

return `'${baseName}': {
flight: { ${flightSizes} },
carbon: ${carbonImp}
},`;
});

const registryJsContent = await prettier.format(`
export const IconRegistry = {
${registryLines.join('\n')}
};
`, { ...prettierConfig, parser: 'babel' });

await fs.writeFile(`${outputFolder}/registry.js`, registryJsContent);

const registryDtsContent = await prettier.format(`
import type { IconName } from '../svg';

export interface HdsIconModule {
default: string;
}

export interface HdsIconRegistryEntry {
flight: {
[size: string]: () => Promise<HdsIconModule>;
};
carbon: (() => Promise<HdsIconModule>) | null;
}

export declare const IconRegistry: Record<IconName, HdsIconRegistryEntry>;
`, { ...prettierConfig, parser: 'typescript' });

await fs.writeFile(`${outputFolder}/registry.d.ts`, registryDtsContent);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Copyright IBM Corp. 2021, 2025
* SPDX-License-Identifier: MPL-2.0
*/

export default function replaceDynamicColor(source: string) {
// completely remove any "fill" attribute that has #000001 ("dynamic" color in Figma, equivalent of "currentColor") as value
// 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)
// 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
// 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
return source.replace(/ fill="#000001"/gi, '');
}
5 changes: 5 additions & 0 deletions packages/flight-icons/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import chalk from 'chalk';

import { optimizeAssetsSVG } from './build-parts/optimizeAssetsSVG';
import { generateBundleSVG } from './build-parts/generateBundleSVG';
import { generateBundleSymbolJS } from './build-parts/generateBundleSymbolJS';
import { generateBundleSVGSprite } from './build-parts/generateBundleSVGSprite';
import { generateBundleSVGReact } from './build-parts/generateBundleSVGReact';

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

// generate the bundle for the SVG JS files
console.log('Generating bundle for SVG JS files');
await generateBundleSymbolJS({ config, catalog });

// generate the bundle for the SVG sprite
console.log('Generating bundle for SVG sprite');
await generateBundleSVGSprite({ config, catalog });
Expand Down
Loading