Skip to content

Commit e5aa15d

Browse files
authored
chore(CI): extract icons from figma (#39)
1 parent c1126d3 commit e5aa15d

File tree

11 files changed

+947
-0
lines changed

11 files changed

+947
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Download Icons Action
2+
3+
This action script fetches icons from Figma and produces two icon output folders:
4+
5+
- `auto-generated-icons/`: raw SVGs exported from Figma.
6+
- `auto-generated-optimized-icons/`: optimized SVGs after SVGO processing.
7+
8+
When the script finishes, it also writes:
9+
10+
- `icon-output.md`: a generated report containing source/optimized counts and the list of optimized icon filenames.
11+
12+
## Notes
13+
14+
- The script verifies optimized SVGs against source SVG rendering to catch unexpected visual differences.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: "Download Icons"
2+
description: "Download and setup icons"
3+
author: "Qlik"
4+
5+
inputs:
6+
figma-token:
7+
description: "Figma API token for authentication"
8+
required: true
9+
type: string
10+
11+
runs:
12+
using: "composite"
13+
steps:
14+
- name: Set NPM Token
15+
shell: bash
16+
working-directory: .github/actions/download-icons
17+
run: node fetch.js
18+
env:
19+
FIGMA_TOKEN: ${{ inputs.figma-token }}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as Figma from "figma-api";
2+
import * as fs from "fs";
3+
import { compareAllSvgs } from "./utils/compare.js";
4+
import { downloadFrameImages } from "./utils/download.js";
5+
import { optimizeAllSVGsInFolder } from "./utils/optimize-svg.js";
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+
const api = new Figma.Api({
45+
personalAccessToken: token,
46+
});
47+
48+
const fileKey = process.env.FIGMA_FILE_ID || "GQZX9DHncxo9fRYV4cOaWV"; //Sprout Icons file
49+
const file = await api.getFile(fileKey);
50+
const page = file.document.children.find((child) => child.name === "Icons [EXPORT]");
51+
52+
// eslint-disable-next-line no-console
53+
console.log(`Pages in file: ${file.document.children.map((c) => c.name).join(", ")}`);
54+
// eslint-disable-next-line no-console
55+
console.log(`Found page "Icons [EXPORT]": ${!!page}`);
56+
57+
// Data to share across process
58+
const ctx = {
59+
api,
60+
fileKey,
61+
dir: INTERIM_DIRECTORY,
62+
};
63+
64+
// eslint-disable-next-line no-console
65+
console.log(`Downloading images for file: ${file.name}`);
66+
67+
const componentNodeInfos = [];
68+
//const privateComponents: Figma.Node[] = [];
69+
const publicComponents = [];
70+
71+
const iconFrame = page.children.find((child) => child.name === "Icons");
72+
73+
// eslint-disable-next-line no-console
74+
console.log(`Top-level children of page: ${page.children.map((c) => c.name).join(", ")}`);
75+
// eslint-disable-next-line no-console
76+
console.log(`Found "Icons" frame: ${!!iconFrame}`);
77+
78+
const cardFrames = findCardFrames(iconFrame);
79+
80+
// eslint-disable-next-line no-console
81+
console.log(`Found ${cardFrames.length} Card frames`);
82+
83+
cardFrames.forEach((node) => {
84+
//Sections marks optional category
85+
86+
const iconComponentNodes = node.children[1].children.filter((n) => n.type === "COMPONENT");
87+
const categoryName = iconComponentNodes.length > 1 ? node.children[0].children[0].characters.trim() : null; //Category is only applied if multiple icons
88+
89+
iconComponentNodes.forEach((child) => {
90+
const componentNode = child;
91+
componentNodeInfos.push({
92+
node: componentNode,
93+
category: categoryName,
94+
meta: file.components[componentNode.id],
95+
});
96+
});
97+
});
98+
99+
componentNodeInfos.forEach((nodeInfo) => {
100+
//if (getVisibility(node) === "public") {
101+
publicComponents.push(nodeInfo.node);
102+
// } else {
103+
// privateComponents.push(node);
104+
// }
105+
});
106+
107+
if (publicComponents.length > 0) {
108+
// eslint-disable-next-line no-console
109+
console.log(`Downloading ${publicComponents.length} icons`);
110+
await downloadFrameImages(publicComponents, ctx);
111+
} else {
112+
// eslint-disable-next-line no-console
113+
console.warn("No icons found — check page/frame names match the Figma file structure.");
114+
}
115+
// if (privateComponents.length > 0) {
116+
// await downloadFrameImages(privateComponents, ctx, 'private');
117+
// }
118+
119+
//Optimize SVGs
120+
optimizeAllSVGsInFolder(INTERIM_DIRECTORY, TARGET_DIRECTORY);
121+
122+
//Test SVGs for discrepancies
123+
compareAllSvgs(INTERIM_DIRECTORY, TARGET_DIRECTORY)
124+
.then(() => {
125+
// eslint-disable-next-line no-console
126+
console.log("\x1b[32m\x1b[1mAll SVGs are identical.\x1b[0m");
127+
})
128+
.catch((err) => {
129+
// eslint-disable-next-line no-console
130+
console.log("Some SVGs differ", err);
131+
});
132+
}
133+
134+
main()
135+
.then(() => {
136+
// eslint-disable-next-line no-console
137+
console.log("SVGS generated and tested!");
138+
})
139+
.catch((err) => {
140+
// eslint-disable-next-line no-console
141+
console.error("SVGS did not work or did not pass tests", err);
142+
process.exit(1);
143+
});
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.js";
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)