Skip to content

Commit dfddc7d

Browse files
authored
[icons] Add icon resource scripts and gate compile on verify (#8047)
1 parent 3b52e8b commit dfddc7d

16 files changed

Lines changed: 778 additions & 31 deletions

packages/icons/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,12 @@ npm install --save @blueprintjs/icons
1313
```
1414

1515
### [Full Documentation](http://blueprintjs.com/docs) | [Source Code](https://github.com/palantir/blueprint)
16+
17+
## Adding new icons (repo contributors)
18+
19+
1. Add the 16px SVG under `resources/icons/16px` and the 20px SVG under `resources/icons/20px`, same kebab-case basename in each. The basename then becomes the `iconName`.
20+
2. Run `pnpm --filter @blueprintjs/icons icons:add`. It checks the pair, runs SVGO on the SVGs, and appends a row to `packages/icons/icons.json` when the icon isn’t listed yet.
21+
3. Fill in `tags` and `group` for the new row.
22+
4. Run `pnpm --filter @blueprintjs/icons icons:verify` before you ship the change.
23+
24+
To normalize every icon SVG in the repo at once: `pnpm --filter @blueprintjs/icons icons:format`.

packages/icons/icons.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@
170170
{
171171
"displayName": "Predictive analysis",
172172
"iconName": "predictive-analysis",
173-
"tags": "analysis, investigation, search, study, test, brain, it\u2019s a brain, definitely a brain, could mean anything, but, yep, still a brain",
173+
"tags": "analysis, investigation, search, study, test, brain",
174174
"group": "action",
175175
"codepoint": 62076
176176
},

packages/icons/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
],
1818
"scripts": {
1919
"clean": "rm -rf dist/* || rm -rf lib/**/* || rm -rf src/generated/* || true",
20-
"compile": "npm-run-all -s \"generate-icon-src\" -p \"compile:*\" -p \"copy:*\"",
20+
"compile": "npm-run-all -s \"icons:verify\" \"generate-icon-src\" -p \"compile:*\" -p \"copy:*\"",
2121
"compile:esm": "tsc -p ./src/tsconfig.build.json",
2222
"compile:cjs": "tsc -p ./src/tsconfig.build.json -m commonjs --verbatimModuleSyntax false --outDir lib/cjs",
2323
"compile:esnext": "tsc -p ./src/tsconfig.build.json -t esnext --outDir lib/esnext",
@@ -28,6 +28,9 @@
2828
"dist": "run-s \"dist:*\"",
2929
"dist:css": "css-dist lib/css",
3030
"dist:verify": "assert-package-layout",
31+
"icons:add": "node scripts/add-icons.mjs",
32+
"icons:format": "node scripts/format-icons.mjs",
33+
"icons:verify": "node scripts/verify-icons.mjs",
3134
"generate-icon-src": "node scripts/generate-icon-fonts.mjs && node scripts/generate-icon-paths.mjs && node scripts/generate-icon-components.mjs",
3235
"lint": "run-p lint:scss lint:es",
3336
"lint:scss": "sass-lint",
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2026 Palantir Technologies, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
// @ts-check
17+
18+
import { capitalCase } from "change-case";
19+
import { readFileSync, writeFileSync } from "node:fs";
20+
import { join, resolve } from "node:path";
21+
import { pathToFileURL } from "node:url";
22+
23+
import { createCliLogger } from "./cliLogger.mjs";
24+
import {
25+
getIconNamesInDirectory,
26+
ICON_SIZES_PX,
27+
iconDirectoryParityDiff,
28+
iconResourcesDir,
29+
readIconsManifestFile,
30+
repoRelative,
31+
} from "./common.mjs";
32+
import { canonicalIconName, ICON_NAME_PATTERN } from "./iconNaming.mjs";
33+
import { optimizeSvg } from "./iconSvgoConfig.mjs";
34+
35+
const ICONS_JSON_PATH = resolve(import.meta.dirname, "../icons.json");
36+
37+
export { ICON_NAME_PATTERN, canonicalIconName } from "./iconNaming.mjs";
38+
const logger = createCliLogger("icons:add");
39+
40+
/**
41+
* @typedef {import("./common.mjs").IconMetadata} IconMetadata
42+
*/
43+
44+
/**
45+
* @typedef {Object} AddIconsResult
46+
* @property {string[]} newIconNames
47+
* @property {Array<{path: string, iconName: string}>} optimizedFiles
48+
* @property {IconMetadata[]} addedManifestEntries
49+
*/
50+
51+
/**
52+
* @returns {Promise<AddIconsResult>}
53+
*/
54+
export async function addIcons() {
55+
const icons16Dir = join(iconResourcesDir, "16px");
56+
const icons20Dir = join(iconResourcesDir, "20px");
57+
58+
logger.header("Scanning icon resources");
59+
logger.info(`16px: ${repoRelative(icons16Dir)}`);
60+
logger.info(`20px: ${repoRelative(icons20Dir)}`);
61+
const iconNames16 = getIconNamesInDirectory(icons16Dir);
62+
const iconNames20 = getIconNamesInDirectory(icons20Dir);
63+
const manifest = readIconsManifestFile(ICONS_JSON_PATH);
64+
65+
const parityIssues = getParityIssues(iconNames16, iconNames20);
66+
if (parityIssues.length > 0) {
67+
throw new Error(formatIssues("Icon size parity check failed", parityIssues));
68+
}
69+
70+
const pairedIconNames = [...iconNames16].sort();
71+
const manifestNames = new Set(manifest.map(icon => icon.iconName));
72+
73+
logger.header("Normalizing paired SVGs (SVGO)");
74+
75+
/** @type {Array<{path: string, iconName: string}>} */
76+
const optimizedFiles = [];
77+
for (const iconName of pairedIconNames) {
78+
for (const size of ICON_SIZES_PX) {
79+
const path = join(iconResourcesDir, size, `${iconName}.svg`);
80+
const source = readFileSync(path, "utf8");
81+
const optimized = optimizeSvg(source, path);
82+
if (optimized !== source) {
83+
writeFileSync(path, optimized);
84+
optimizedFiles.push({ iconName, path });
85+
}
86+
}
87+
}
88+
89+
const newIconNames = pairedIconNames.filter(iconName => !manifestNames.has(iconName));
90+
91+
/** @type {IconMetadata[]} */
92+
let addedManifestEntries = [];
93+
94+
if (newIconNames.length > 0) {
95+
const invalidNames = newIconNames.filter(iconName => !ICON_NAME_PATTERN.test(iconName));
96+
if (invalidNames.length > 0) {
97+
throw new Error(
98+
formatIssues(
99+
"Invalid icon file names (use lowercase kebab-case basenames; see icons:verify)",
100+
invalidNames.map(name => `"${name}" (canonical: "${canonicalIconName(name)}")`),
101+
),
102+
);
103+
}
104+
105+
logger.header("Appending new icons to manifest");
106+
logger.success(`Found ${newIconNames.length} new icon pair(s) not yet in icons.json.`);
107+
logger.item(newIconNames.join(", "));
108+
109+
if (manifest.length === 0) {
110+
throw new Error(
111+
formatIssues("Cannot append new icons to an empty icons.json", ["Restore packages/icons/icons.json."]),
112+
);
113+
}
114+
115+
const maxCodepoint = manifest.reduce((acc, icon) => Math.max(acc, icon.codepoint), Number.MIN_SAFE_INTEGER);
116+
117+
/* eslint-disable sort-keys */
118+
addedManifestEntries = newIconNames.map((iconName, index) => ({
119+
displayName: capitalCase(iconName),
120+
iconName,
121+
tags: "",
122+
group: "",
123+
codepoint: maxCodepoint + index + 1,
124+
}));
125+
/* eslint-enable sort-keys */
126+
127+
const updatedManifest = [...manifest, ...addedManifestEntries];
128+
writeIconsManifest(ICONS_JSON_PATH, updatedManifest);
129+
}
130+
131+
printPostRunSummary({
132+
addedManifestEntries,
133+
newIconNames,
134+
optimizedFiles,
135+
pairedCount: pairedIconNames.length,
136+
});
137+
138+
return { addedManifestEntries, newIconNames, optimizedFiles };
139+
}
140+
141+
/**
142+
* @param {Set<string>} iconNames16
143+
* @param {Set<string>} iconNames20
144+
*/
145+
export function getParityIssues(iconNames16, iconNames20) {
146+
const { onlyIn16, onlyIn20 } = iconDirectoryParityDiff(iconNames16, iconNames20);
147+
return [
148+
...onlyIn16.map(name => `"${name}" exists in resources/icons/16px but not resources/icons/20px`),
149+
...onlyIn20.map(name => `"${name}" exists in resources/icons/20px but not resources/icons/16px`),
150+
];
151+
}
152+
153+
/**
154+
* @param {string} jsonPath
155+
* @param {IconMetadata[]} manifest
156+
*/
157+
export function writeIconsManifest(jsonPath, manifest) {
158+
writeFileSync(jsonPath, `${JSON.stringify(manifest, null, 4)}\n`);
159+
}
160+
161+
/**
162+
* @param {string} title
163+
* @param {string[]} issues
164+
*/
165+
export function formatIssues(title, issues) {
166+
return [title, ...issues.map(issue => `- ${issue}`)].join("\n");
167+
}
168+
169+
/**
170+
* @param {{
171+
* addedManifestEntries: IconMetadata[];
172+
* newIconNames: string[];
173+
* optimizedFiles: Array<{ path: string; iconName: string }>;
174+
* pairedCount: number;
175+
* }} args
176+
*/
177+
function printPostRunSummary({ newIconNames, pairedCount, optimizedFiles, addedManifestEntries }) {
178+
logger.header("Summary");
179+
logger.info(`Paired icons checked: ${pairedCount}`);
180+
if (optimizedFiles.length === 0) {
181+
logger.success("Normalized 0 SVG file(s) (already matched canonical SVGO output).");
182+
} else {
183+
logger.success(`Normalized ${optimizedFiles.length} SVG file(s).`);
184+
}
185+
logger.success(
186+
`Appended ${addedManifestEntries.length} manifest entr${addedManifestEntries.length === 1 ? "y" : "ies"}.`,
187+
);
188+
for (const entry of addedManifestEntries) {
189+
logger.item(`${entry.iconName} -> codepoint ${entry.codepoint}`);
190+
}
191+
if (addedManifestEntries.length > 0) {
192+
logger.warn('Next step: manually update "tags" and "group" for new entries in packages/icons/icons.json.');
193+
logger.info(`Added icon names: ${newIconNames.join(", ")}`);
194+
}
195+
}
196+
197+
async function main() {
198+
try {
199+
await addIcons();
200+
} catch (error) {
201+
const message = error instanceof Error ? error.message : String(error);
202+
logger.error(message);
203+
process.exitCode = 1;
204+
}
205+
}
206+
207+
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
208+
await main();
209+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 Palantir Technologies, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
// @ts-check
17+
18+
const ANSI = {
19+
blue: "\u001B[34m",
20+
bold: "\u001B[1m",
21+
green: "\u001B[32m",
22+
red: "\u001B[31m",
23+
reset: "\u001B[0m",
24+
yellow: "\u001B[33m",
25+
};
26+
27+
function shouldUseColor() {
28+
if (process.env.NO_COLOR != null) {
29+
return false;
30+
}
31+
if (process.env.FORCE_COLOR === "0") {
32+
return false;
33+
}
34+
return process.stdout.isTTY === true;
35+
}
36+
37+
/**
38+
* @param {string} text
39+
* @param {string} color
40+
*/
41+
function colorize(text, color) {
42+
if (!shouldUseColor()) {
43+
return text;
44+
}
45+
return `${ANSI.bold}${color}${text}${ANSI.reset}`;
46+
}
47+
48+
/**
49+
* @param {string} scope
50+
*/
51+
export function createCliLogger(scope) {
52+
const prefix = `[${scope}]`;
53+
return {
54+
error(message) {
55+
console.error(`${colorize("✗", ANSI.red)} ${prefix} ${message}`);
56+
},
57+
header(message) {
58+
console.info(`${colorize(prefix, ANSI.blue)} ${colorize(message, ANSI.blue)}`);
59+
},
60+
info(message) {
61+
console.info(`${prefix} ${message}`);
62+
},
63+
item(message) {
64+
console.info(` • ${message}`);
65+
},
66+
success(message) {
67+
console.info(`${colorize("✓", ANSI.green)} ${prefix} ${message}`);
68+
},
69+
warn(message) {
70+
console.warn(`${colorize("!", ANSI.yellow)} ${prefix} ${message}`);
71+
},
72+
};
73+
}

0 commit comments

Comments
 (0)