Skip to content

Commit 3726399

Browse files
committed
chore(CI): fix icons script
1 parent e028323 commit 3726399

File tree

5 files changed

+535
-0
lines changed

5 files changed

+535
-0
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import * as Figma from "figma-api";
2+
import * as fs from "fs";
3+
import { compareAllSvgs } from "./utils/compare.mjs";
4+
import { downloadFrameImages } from "./utils/download.mjs";
5+
import { optimizeAllSVGsInFolder } from "./utils/optimize-svg.mjs";
6+
7+
const INTERIM_DIRECTORY = "auto-generated-icons";
8+
const TARGET_DIRECTORY = "auto-generated-optimized-icons";
9+
10+
function createFolder(directory) {
11+
if (!fs.existsSync(directory)) {
12+
fs.mkdirSync(directory);
13+
}
14+
}
15+
16+
function findCardFrames(node) {
17+
const cardFrames = [];
18+
19+
if (
20+
node.type === "FRAME" &&
21+
node.name === "Cards [DO NOT CHANGE NAME - REQUIRED FOR EXPORT]" &&
22+
"children" in node &&
23+
node.children
24+
) {
25+
node.children.forEach((child) => {
26+
cardFrames.push(child);
27+
});
28+
}
29+
30+
if ("children" in node && node.children) {
31+
node.children.forEach((child) => {
32+
cardFrames.push(...findCardFrames(child));
33+
});
34+
}
35+
36+
return cardFrames;
37+
}
38+
39+
async function main() {
40+
createFolder(INTERIM_DIRECTORY);
41+
42+
// Connect to Figma and get the file
43+
const token = process.env.FIGMA_ACCESS_TOKEN || "";
44+
if (!token) {
45+
throw new Error("FIGMA_ACCESS_TOKEN environment variable is required");
46+
}
47+
const api = new Figma.Api({
48+
personalAccessToken: token,
49+
});
50+
51+
const fileKey = process.env.FIGMA_FILE_ID || "GQZX9DHncxo9fRYV4cOaWV"; //Sprout Icons file
52+
const file = await api.getFile(fileKey);
53+
const page = file.document.children.find((child) => child.name === "Icons [EXPORT]");
54+
55+
// eslint-disable-next-line no-console
56+
console.log(`Pages in file: ${file.document.children.map((c) => c.name).join(", ")}`);
57+
// eslint-disable-next-line no-console
58+
console.log(`Found page "Icons [EXPORT]": ${!!page}`);
59+
60+
// Data to share across process
61+
const ctx = {
62+
api,
63+
fileKey,
64+
dir: INTERIM_DIRECTORY,
65+
};
66+
67+
// eslint-disable-next-line no-console
68+
console.log(`Downloading images for file: ${file.name}`);
69+
70+
const componentNodeInfos = [];
71+
//const privateComponents: Figma.Node[] = [];
72+
const publicComponents = [];
73+
74+
const iconFrame = page.children.find((child) => child.name === "Icons");
75+
76+
// eslint-disable-next-line no-console
77+
console.log(`Top-level children of page: ${page.children.map((c) => c.name).join(", ")}`);
78+
// eslint-disable-next-line no-console
79+
console.log(`Found "Icons" frame: ${!!iconFrame}`);
80+
81+
const cardFrames = findCardFrames(iconFrame);
82+
83+
// eslint-disable-next-line no-console
84+
console.log(`Found ${cardFrames.length} Card frames`);
85+
86+
cardFrames.forEach((node) => {
87+
//Sections marks optional category
88+
89+
const iconComponentNodes = node.children[1].children.filter((n) => n.type === "COMPONENT");
90+
const categoryName = iconComponentNodes.length > 1 ? node.children[0].children[0].characters.trim() : null; //Category is only applied if multiple icons
91+
92+
iconComponentNodes.forEach((child) => {
93+
const componentNode = child;
94+
componentNodeInfos.push({
95+
node: componentNode,
96+
category: categoryName,
97+
meta: file.components[componentNode.id],
98+
});
99+
});
100+
});
101+
102+
componentNodeInfos.forEach((nodeInfo) => {
103+
//if (getVisibility(node) === "public") {
104+
publicComponents.push(nodeInfo.node);
105+
// } else {
106+
// privateComponents.push(node);
107+
// }
108+
});
109+
110+
if (publicComponents.length > 0) {
111+
// eslint-disable-next-line no-console
112+
console.log(`Downloading ${publicComponents.length} icons`);
113+
await downloadFrameImages(publicComponents, ctx);
114+
} else {
115+
// eslint-disable-next-line no-console
116+
console.warn("No icons found — check page/frame names match the Figma file structure.");
117+
}
118+
// if (privateComponents.length > 0) {
119+
// await downloadFrameImages(privateComponents, ctx, 'private');
120+
// }
121+
122+
//Optimize SVGs
123+
optimizeAllSVGsInFolder(INTERIM_DIRECTORY, TARGET_DIRECTORY);
124+
125+
//Test SVGs for discrepancies
126+
compareAllSvgs(INTERIM_DIRECTORY, TARGET_DIRECTORY)
127+
.then(() => {
128+
// eslint-disable-next-line no-console
129+
console.log("\x1b[32m\x1b[1mAll SVGs are identical.\x1b[0m");
130+
})
131+
.catch((err) => {
132+
// eslint-disable-next-line no-console
133+
console.log("Some SVGs differ", err);
134+
});
135+
}
136+
137+
main()
138+
.then(() => {
139+
// eslint-disable-next-line no-console
140+
console.log("SVGS generated and tested!");
141+
})
142+
.catch((err) => {
143+
// eslint-disable-next-line no-console
144+
console.error("SVGS did not work or did not pass tests", err);
145+
process.exit(1);
146+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const cleanString = (str) =>
2+
str
3+
.trim()
4+
.replace(/&/g, "and")
5+
.replace(/[^\w\s-]/g, "")
6+
.replace(/ /g, "_")
7+
.toLowerCase();
8+
9+
export { cleanString };
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import { PNG } from "pngjs";
4+
import sharp from "sharp";
5+
6+
// Function to render an SVG to a PNG buffer
7+
async function renderSvgToPng(svgPath, outputPath) {
8+
const svgBuffer = fs.readFileSync(svgPath);
9+
await sharp(svgBuffer).threshold(128).png().toFile(outputPath);
10+
return Promise.resolve();
11+
}
12+
13+
// Function to compare two PNG images
14+
async function comparePngs(pngPath1, pngPath2, diffPath) {
15+
const { default: pixelmatch } = await import("pixelmatch");
16+
17+
const img1 = PNG.sync.read(fs.readFileSync(pngPath1));
18+
const img2 = PNG.sync.read(fs.readFileSync(pngPath2));
19+
20+
const { width, height } = img1;
21+
const diff = new PNG({ width, height });
22+
23+
const numDiffPixels = pixelmatch(
24+
img1.data,
25+
img2.data,
26+
diff.data,
27+
width,
28+
height,
29+
{ threshold: 0.1 }, // Adjust threshold as needed
30+
);
31+
32+
fs.writeFileSync(diffPath, PNG.sync.write(diff));
33+
return numDiffPixels;
34+
}
35+
36+
// Main function to compare two SVGs
37+
async function compareSvgs(svgPath1, svgPath2) {
38+
const pngPath1 = "output1.png";
39+
const pngPath2 = "output2.png";
40+
const diffPath = "diff.png";
41+
42+
// Render SVGs to PNGs
43+
await renderSvgToPng(svgPath1, pngPath1);
44+
await renderSvgToPng(svgPath2, pngPath2);
45+
46+
// Compare the PNGs
47+
const numDiffPixels = await comparePngs(pngPath1, pngPath2, diffPath);
48+
49+
if (numDiffPixels === 0) {
50+
// eslint-disable-next-line no-console
51+
console.log("The SVGs are identical.");
52+
} else if (numDiffPixels < 24) {
53+
console.warn(`The SVGs differ by ${numDiffPixels} pixels, deemed acceptable. Please check manually.`);
54+
} else {
55+
throw new Error(
56+
`The Icon ${svgPath1.replace(/.*\/(.*)\.svg/, "$1")} differ by ${numDiffPixels} pixels. See diff.png for details.`,
57+
);
58+
}
59+
60+
return Promise.resolve();
61+
}
62+
// Function to get all SVG files in a directory
63+
function getSvgFiles(directory) {
64+
return fs
65+
.readdirSync(directory)
66+
.filter((file) => file.endsWith(".svg"))
67+
.map((file) => path.join(directory, file));
68+
}
69+
70+
// Main function to compare all SVGs in two directories
71+
export async function compareAllSvgs(dir1, dir2) {
72+
const svgFiles1 = getSvgFiles(dir1);
73+
74+
for (const file1 of svgFiles1) {
75+
const fileName = path.basename(file1);
76+
const file2 = path.join(dir2, fileName);
77+
78+
if (fs.existsSync(file2)) {
79+
// eslint-disable-next-line no-console
80+
81+
console.log(`Comparing ${file2.split("/")[1].replace(".svg", "")}`);
82+
await compareSvgs(file1, file2);
83+
} else {
84+
// eslint-disable-next-line no-console
85+
console.log(`File ${fileName} does not exist in ${dir2}`);
86+
}
87+
}
88+
89+
return Promise.resolve();
90+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as fs from "fs";
2+
import * as http from "http";
3+
import * as https from "https";
4+
import { pipeline } from "stream/promises";
5+
import { cleanString } from "./cleanString.mjs";
6+
7+
function downloadWithRedirects(uri, filename, redirectsLeft = 5) {
8+
return new Promise((resolve, reject) => {
9+
const parsedUrl = new URL(uri);
10+
const client = parsedUrl.protocol === "https:" ? https : http;
11+
12+
const req = client.get(parsedUrl, (response) => {
13+
const { statusCode = 0, headers } = response;
14+
15+
if (statusCode >= 300 && statusCode < 400 && headers.location) {
16+
response.resume();
17+
18+
if (redirectsLeft <= 0) {
19+
reject(new Error(`Too many redirects while downloading ${uri}`));
20+
return;
21+
}
22+
23+
const redirectUrl = new URL(headers.location, parsedUrl).toString();
24+
downloadWithRedirects(redirectUrl, filename, redirectsLeft - 1)
25+
.then(resolve)
26+
.catch(reject);
27+
return;
28+
}
29+
30+
if (statusCode < 200 || statusCode >= 300) {
31+
response.resume();
32+
reject(new Error(`Failed to download ${uri}. HTTP status: ${statusCode}`));
33+
return;
34+
}
35+
36+
pipeline(response, fs.createWriteStream(filename))
37+
.then(() => resolve(undefined))
38+
.catch(reject);
39+
});
40+
41+
req.on("error", reject);
42+
});
43+
}
44+
45+
export function download(uri, filename) {
46+
return downloadWithRedirects(uri, filename);
47+
}
48+
49+
export async function downloadFrameImages(imageFrames, ctx) {
50+
// Fail early if any two icons would resolve to the same filename
51+
const seenNames = new Map();
52+
for (const frame of imageFrames) {
53+
const cleaned = cleanString(frame.name);
54+
if (seenNames.has(cleaned)) {
55+
throw new Error(
56+
`Filename collision: "${frame.name}" and "${seenNames.get(cleaned)}" both resolve to "${cleaned}.svg"`,
57+
);
58+
}
59+
seenNames.set(cleaned, frame.name);
60+
}
61+
62+
// Ask Figma to generate the images
63+
const imgs = await ctx.api.getImage(ctx.fileKey, {
64+
ids: imageFrames.map((n) => n.id).join(","),
65+
scale: 1,
66+
format: "svg",
67+
});
68+
69+
// eslint-disable-next-line no-console
70+
console.log(`Downloading ${imageFrames.length} images`);
71+
// Download the generated images into the assets folder
72+
const downloads = Object.entries(imgs.images).map(([, url], i) =>
73+
url ? download(url, `./${ctx.dir}/${cleanString(imageFrames[i].name)}.svg`) : Promise.resolve(),
74+
);
75+
await Promise.all(downloads);
76+
}

0 commit comments

Comments
 (0)