Skip to content

Commit b1601eb

Browse files
committed
fix(icons): build static icon components from resource SVG paths
1 parent a6ab9ab commit b1601eb

File tree

6 files changed

+74
-112
lines changed

6 files changed

+74
-112
lines changed

packages/icons/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@
6969
"react": "^18.3.1",
7070
"react-dom": "^18.3.1",
7171
"react-test-renderer": "^18.3.1",
72-
"svg-parser": "^2.0.4",
7372
"typescript": "~5.9.3",
7473
"webpack-cli": "^5.1.4"
7574
},
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2024 Palantir Technologies, Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// @ts-check
18+
19+
import { readFileSync } from "node:fs";
20+
import { join } from "node:path";
21+
22+
import { svgOptimizer } from "@blueprintjs/node-build-scripts";
23+
24+
import { iconResourcesDir } from "./common.mjs";
25+
26+
/**
27+
* Extracts path `d` strings from an on-disk icon SVG. This matches the pipeline used for
28+
* {@link generate-icon-paths.mjs} and the path modules consumed by {@code <Icon />} from core.
29+
*
30+
* @param {16 | 20} iconSize
31+
* @param {string} iconName
32+
* @returns {Promise<string[]>}
33+
*/
34+
export async function extractPathsFromResourceSvg(iconSize, iconName) {
35+
const filepath = join(iconResourcesDir, `${iconSize}px`, `${iconName}.svg`);
36+
const svg = readFileSync(filepath, "utf-8");
37+
const optimizedSvg = await svgOptimizer.optimize(svg, { path: filepath });
38+
/** @type string[] */
39+
const paths = [];
40+
const re = /\sd="([^"]+)"/g;
41+
let m;
42+
while ((m = re.exec(optimizedSvg.data)) !== null) {
43+
paths.push(m[1].replace(/[\n\t]/g, ""));
44+
}
45+
return paths;
46+
}

packages/icons/scripts/generate-icon-components.mjs

Lines changed: 13 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
/**
1717
* @fileoverview Generates SVG React components for each icon.
18-
* N.B. we expect ../src/generated/ to contain SVG definitions of all the icons already
19-
* generated by fantasticon (in other words, run ./generate-icon-fonts.js first).
18+
*
19+
* Paths are taken from the same resource SVGs as {@link generate-icon-paths.mjs} (used by {@code <Icon />}).
20+
* We intentionally do not use glyph paths from the generated icon font: those live in an upscaled coordinate
21+
* system and require fragile scale/translate transforms (see {@link ICON_RASTER_SCALING_FACTOR} in common.mjs).
2022
*/
2123

2224
// @ts-check
@@ -25,24 +27,12 @@ import { pascalCase } from "change-case";
2527
import Handlebars from "handlebars";
2628
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2729
import { join, resolve } from "node:path";
28-
import { parse } from "svg-parser";
2930

30-
import { generatedComponentsDir, generatedSrcDir, ICON_RASTER_SCALING_FACTOR, ICON_SIZES } from "./common.mjs";
31+
import { generatedComponentsDir, generatedSrcDir, iconsMetadata } from "./common.mjs";
32+
import { extractPathsFromResourceSvg } from "./extractPathsFromResourceSvg.mjs";
3133

3234
Handlebars.registerHelper("pascalCase", iconName => pascalCase(iconName));
3335

34-
/**
35-
* Notes on icon component template implementation:
36-
*
37-
* The components rendered by this template (`<AddClip>`, `<Calendar>`, etc.) rely on a centered scale `transform` to
38-
* display their SVG paths correctly.
39-
*
40-
* In this template, the `<path>` element applies `transform-origin` using the `style` attribute rather than
41-
* `transformOrigin`. Although `trasformOrigin` was added as a supported SVG attribute to React in 2023,
42-
* it is still difficult to use without compile-time and/or runtime errors, see:
43-
* - https://github.com/facebook/react/pull/26130
44-
* - https://github.com/palantir/blueprint/issues/6591
45-
*/
4636
const iconComponentTemplate = Handlebars.compile(
4737
readFileSync(resolve(import.meta.dirname, "iconComponent.tsx.hbs"), "utf8"),
4838
);
@@ -51,50 +41,21 @@ const componentsIndexTemplate = Handlebars.compile(
5141
);
5242
const indexTemplate = Handlebars.compile(readFileSync(resolve(import.meta.dirname, "index.ts.hbs"), "utf8"));
5343

54-
/** @type { { 16: {[iconName: string]: string}; 20: {[iconName: string]: string} } } */
55-
const iconPaths = {
56-
[ICON_SIZES[0]]: {},
57-
[ICON_SIZES[1]]: {},
58-
};
59-
60-
// parse icon paths from the generated SVG font
61-
for (const iconSize of ICON_SIZES) {
62-
const iconFontSvgDocument = readFileSync(
63-
join(generatedSrcDir, `${iconSize}px/blueprint-icons-${iconSize}.svg`),
64-
"utf8",
65-
);
66-
67-
console.info(`Parsing SVG glyphs from generated ${iconSize}px SVG icon font...`);
68-
parseIconGlyphs(iconFontSvgDocument, (iconName, iconPath) => {
69-
iconPaths[iconSize][iconName] = iconPath;
70-
});
71-
console.info(`Parsed ${Object.keys(iconPaths[iconSize]).length} ${iconSize}px icons.`);
72-
}
73-
74-
// clear existing icon components
7544
console.info("Clearing existing icon modules...");
7645
rmSync(generatedComponentsDir, { recursive: true, force: true });
7746

78-
// generate an ES module for each icon
7947
console.info("Generating ES modules for each icon...");
8048
mkdirSync(generatedComponentsDir, { recursive: true });
8149

82-
for (const [iconName, icon16pxPath] of Object.entries(iconPaths[16])) {
83-
const icon20pxPath = iconPaths[20][iconName];
84-
if (icon20pxPath === undefined) {
85-
console.error(`Could not find corresponding 20px icon path for ${iconName}, skipping!`);
86-
continue;
87-
}
50+
for (const { iconName } of iconsMetadata) {
51+
const paths16 = await extractPathsFromResourceSvg(16, iconName);
52+
const paths20 = await extractPathsFromResourceSvg(20, iconName);
8853
writeFileSync(
8954
join(generatedComponentsDir, `${iconName}.tsx`),
90-
// Notes on icon component template implementation:
91-
// - path "translation" transform must use "viewbox" dimensions, not "size", in order to avoid issues
92-
// like https://github.com/palantir/blueprint/issues/6220
9355
iconComponentTemplate({
9456
iconName,
95-
icon16pxPath,
96-
icon20pxPath,
97-
pathScaleFactor: 1 / ICON_RASTER_SCALING_FACTOR,
57+
paths16Json: JSON.stringify(paths16),
58+
paths20Json: JSON.stringify(paths20),
9859
}),
9960
);
10061
}
@@ -103,43 +64,16 @@ console.info(`Writing index file for all icon modules...`);
10364
writeFileSync(
10465
join(generatedComponentsDir, "index.ts"),
10566
componentsIndexTemplate({
106-
iconNames: Object.keys(iconPaths[16]),
67+
iconNames: iconsMetadata.map(i => i.iconName),
10768
}),
10869
);
10970

11071
console.info(`Writing index file for package...`);
11172
writeFileSync(
11273
join(generatedSrcDir, "index.ts"),
11374
indexTemplate({
114-
iconNames: Object.keys(iconPaths[16]),
75+
iconNames: iconsMetadata.map(i => i.iconName),
11576
}),
11677
);
11778

11879
console.info("Done.");
119-
120-
/**
121-
* Parse all icons of a given size from the SVG font generated by fantasticon.
122-
* At this point we've already optimized the icon SVGs through svgo (via fantasticon), so
123-
* we avoid duplicating that work by reading the generated glyphs here.
124-
*
125-
* @param {string} iconFontSvgDocument
126-
* @param {(iconName: string, iconPath: string) => void} cb iterator for each icon path
127-
*/
128-
function parseIconGlyphs(iconFontSvgDocument, cb) {
129-
const rootNode = parse(iconFontSvgDocument);
130-
// @ts-ignore
131-
const defs = rootNode.children[0].children[0];
132-
const glyphs = defs.children[0].children.filter(node => node.tagName === "glyph");
133-
134-
for (const glyph of glyphs) {
135-
const name = glyph.properties["glyph-name"];
136-
137-
// HACKHACK: for some reason, there are duplicates with the suffix "-1", so we ignore those
138-
if (name.endsWith("-1")) {
139-
continue;
140-
}
141-
142-
const path = glyph.properties["d"];
143-
cb(name, path);
144-
}
145-
}

packages/icons/scripts/generate-icon-paths.mjs

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,10 @@
2222
// @ts-check
2323

2424
import { pascalCase } from "change-case";
25-
import { readFileSync } from "node:fs";
26-
import { join } from "node:path";
2725

28-
import { svgOptimizer } from "@blueprintjs/node-build-scripts";
26+
import { ICON_SIZES, iconsMetadata, writeLinesToFile } from "./common.mjs";
27+
import { extractPathsFromResourceSvg } from "./extractPathsFromResourceSvg.mjs";
2928

30-
import { ICON_SIZES, iconResourcesDir, iconsMetadata, writeLinesToFile } from "./common.mjs";
3129
const ICON_NAMES = iconsMetadata.map(icon => icon.iconName);
3230

3331
for (const iconSize of ICON_SIZES) {
@@ -36,7 +34,7 @@ for (const iconSize of ICON_SIZES) {
3634
for (const [iconName, pathStrings] of Object.entries(iconPaths)) {
3735
const line =
3836
pathStrings.length > 0
39-
? `export default [${pathStrings.join(", ")}];`
37+
? `export default [${pathStrings.map(p => JSON.stringify(p)).join(", ")}];`
4038
: // special case for "blank" icon - we need an explicit typedef
4139
`const p: string[] = []; export default p;`;
4240

@@ -61,15 +59,7 @@ async function getIconPaths(iconSize) {
6159
/** @type Record<string, string[]> */
6260
const iconPaths = {};
6361
for (const iconName of ICON_NAMES) {
64-
const filepath = join(iconResourcesDir, `${iconSize}px/${iconName}.svg`);
65-
const svg = readFileSync(filepath, "utf-8");
66-
const optimizedSvg = await svgOptimizer.optimize(svg, { path: filepath });
67-
const pathStrings = (optimizedSvg.data.match(/ d="[^"]+"/g) || [])
68-
// strip off leading 'd="'
69-
.map(s => s.slice(3))
70-
// strip out newlines and tabs, but keep other whitespace
71-
.map(s => s.replace(/[\n\t]/g, ""));
72-
iconPaths[iconName] = pathStrings;
62+
iconPaths[iconName] = await extractPathsFromResourceSvg(iconSize, iconName);
7363
}
7464
console.info(`Parsed ${Object.keys(iconPaths).length} ${iconSize}px icons.`);
7565
return iconPaths;

packages/icons/scripts/iconComponent.tsx.hbs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,22 @@ import type { SVGIconProps } from "../../svgIconProps";
1818
import { IconSize } from "../../iconTypes";
1919
import { SVGIconContainer } from "../../svgIconContainer";
2020

21+
/** Path data for the 16px grid; matches {@link generate-icon-paths.mjs} / {@code <Icon />} from core. */
22+
const PATHS_16 = {{{paths16Json}}} as readonly string[];
23+
24+
/** Path data for the 20px grid; matches {@link generate-icon-paths.mjs} / {@code <Icon />} from core. */
25+
const PATHS_20 = {{{paths20Json}}} as readonly string[];
26+
2127
export const {{pascalCase iconName}}: React.FC<SVGIconProps> = React.forwardRef<any, SVGIconProps>((props, ref) => {
2228
const isLarge = (props.size ?? IconSize.STANDARD) >= IconSize.LARGE;
23-
const pixelGridSize = isLarge ? IconSize.LARGE : IconSize.STANDARD;
24-
const translation = `${-1 * pixelGridSize / {{pathScaleFactor}} / 2}`;
25-
const style = { transformOrigin: "center" };
29+
const paths = isLarge ? PATHS_20 : PATHS_16;
2630
return (
2731
<SVGIconContainer iconName="{{iconName}}" ref={ref} {...props}>
28-
<path
29-
d={isLarge ? "{{icon20pxPath}}" : "{{icon16pxPath}}"}
30-
fillRule="evenodd"
31-
transform={`scale({{pathScaleFactor}}, -{{pathScaleFactor}}) translate(${translation}, ${translation})`}
32-
style={style}
33-
/>
32+
{paths.map((d, i) => (
33+
<path key={i} d={d} fillRule="evenodd" />
34+
))}
3435
</SVGIconContainer>
35-
);
36+
);
3637
});
3738
{{pascalCase iconName}}.displayName = `Blueprint6.Icon.{{pascalCase iconName}}`;
3839
export default {{pascalCase iconName}};

pnpm-lock.yaml

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)