Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import process from "node:process";
const DEFAULT_FILE_KEY = "Co6vXnF5SiQMcJ7UoJvZX6";
const OUTPUT_FILENAME = "nova-export-clean-variables.json";
const FIGMA_API = "https://api.figma.com/v1";
function joinRelativePath(...args) {
return join(import.meta.dirname, ...args);
}
function die(message) {
// eslint-disable-next-line no-console
console.error(message);
process.exit(1);
}
const token = process.env.FIGMA_ACCESS_TOKEN || process.env.FIGMA_TOKEN;
if (!token) {
die(
"FIGMA_ACCESS_TOKEN is not set.\n" +
"Create a Figma personal access token with the `file_variables:read` scope at\n" +
" FIGMA_ACCESS_TOKEN=figd_... node src/fetch-figma-variables.mjs"
);
}
const fileKey = process.env.FIGMA_FILE_KEY || DEFAULT_FILE_KEY;
const url = `${FIGMA_API}/files/${fileKey}/variables/local`;
const response = await fetch(url, {
headers: { "X-Figma-Token": token },
});
if (!response.ok) {
const body = await response.text();
die(
`Figma API request failed (${response.status} ${response.statusText}) for ${url}\n${body}`
);
}
const { meta } = await response.json();
const { variables, variableCollections } = meta;
function to255(c) {
return Math.round(c * 255);
}
function toHex(n) {
return n.toString(16).padStart(2, "0").toUpperCase();
}
function formatColor({ r, g, b, a }) {
const R = to255(r);
const G = to255(g);
const B = to255(b);
if (a === 1) {
return `#${toHex(R)}${toHex(G)}${toHex(B)}`;
}
return `rgba(${R}, ${G}, ${B}, ${a})`;
}
function formatValue(value) {
if (value && typeof value === "object") {
if (value.type === "VARIABLE_ALIAS") {
const target = variables[value.id];
if (!target) {
// eslint-disable-next-line no-console
console.warn(`Alias target missing for id=${value.id}; skipping.`);
return undefined;
}
return `{${target.name}}`;
}
if ("r" in value) {
return formatColor(value);
}
}
return value;
}
function insertAtPath(tree, pathSegments, leafKey, leafValue) {
let cursor = tree;
for (const seg of pathSegments) {
if (!(seg in cursor) || typeof cursor[seg] !== "object") {
cursor[seg] = {};
}
cursor = cursor[seg];
}
cursor[leafKey] = leafValue;
}
// Figma allows multiple collections with the same name. Merge them into a
// single top-level bucket (which is what the Clean Variables To JSON plugin
// does) so downstream code can address the collection by its display name.
const result = {};
for (const collection of Object.values(variableCollections)) {
if (!(collection.name in result)) {
result[collection.name] = {};
}
const bucket = result[collection.name];
for (const variableId of collection.variableIds) {
const variable = variables[variableId];
if (!variable) {
continue;
}
const segments = variable.name.split("/");
for (const mode of collection.modes) {
const raw = variable.valuesByMode[mode.modeId];
if (raw === undefined) {
continue;
}
const formatted = formatValue(raw);
if (formatted === undefined) {
continue;
}
insertAtPath(bucket, segments, mode.name, formatted);
}
}
}
// The REST API's key order (both for collections and for variables inside
// each collection) differs from the Figma plugin that produced the existing
// export. Without this, a refresh produces thousands of lines of purely
// structural churn on top of any real data changes. Walk the existing file
// and reorder `result` to match, appending anything new at the end of its
// parent object.
const outputPath = joinRelativePath(OUTPUT_FILENAME);
function reorderToMatch(fresh, existing) {
if (
!fresh ||
typeof fresh !== "object" ||
!existing ||
typeof existing !== "object" ||
Array.isArray(fresh) ||
Array.isArray(existing)
) {
return fresh;
}
const reordered = {};
for (const key of Object.keys(existing)) {
if (key in fresh) {
reordered[key] = reorderToMatch(fresh[key], existing[key]);
}
}
for (const key of Object.keys(fresh)) {
if (!(key in reordered)) {
reordered[key] = fresh[key];
}
}
return reordered;
}
const ordered = existsSync(outputPath)
? reorderToMatch(result, JSON.parse(readFileSync(outputPath, "utf8")))
: result;
writeFileSync(outputPath, JSON.stringify(ordered, null, 2) + "\n");
// eslint-disable-next-line no-console
console.log(`Wrote ${outputPath}`);