UNPKG

@terrazzo/cli

Version:

CLI for managing design tokens using the Design Tokens Community Group (DTCG) standard and generating code for any platform via plugins.

1,620 lines (1,618 loc) 50.5 kB
import { createRequire } from "node:module"; import fsSync from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; import { build, defineConfig as defineConfig$1, parse } from "@terrazzo/parser"; import path from "node:path"; import pc from "picocolors"; import { createServer } from "vite"; import { ViteNodeRunner } from "vite-node/client"; import { ViteNodeServer } from "vite-node/server"; import { camelCase } from "scule"; import chokidar from "chokidar"; import yamlToMomoa from "yaml-to-momoa"; import * as momoa from "@humanwhocodes/momoa"; import { getObjMember, getObjMembers, traverse } from "@terrazzo/json-schema-tools"; import { isAlias, pluralize } from "@terrazzo/token-tools"; import yaml from "yaml"; import fs from "node:fs/promises"; import { merge } from "merge-anything"; import { spawn } from "node:child_process"; import { confirm, intro, multiselect, outro, select, spinner } from "@clack/prompts"; import { detect } from "detect-package-manager"; import { generate } from "escodegen"; import { parseModule } from "meriyah"; import { Readable, Writable } from "node:stream"; import mime from "mime"; //#region src/import/figma/lib.ts const KEY = ":key"; const FILE_KEY = ":file_key"; const API = { file: `https://api.figma.com/v1/files/${FILE_KEY}`, fileNodes: `https://api.figma.com/v1/files/${FILE_KEY}/nodes`, fileStyles: `https://api.figma.com/v1/files/${FILE_KEY}/styles`, localVariables: `https://api.figma.com/v1/files/${FILE_KEY}/variables/local`, publishedVariables: `https://api.figma.com/v1/files/${FILE_KEY}/variables/published`, styles: `https://api.figma.com/v1/styles/${KEY}` }; /** Wrapper around camelCase to handle more cases */ function formatName(name) { return camelCase(name.replace(/\s+/g, "-")); } const nf = new Intl.NumberFormat("en-us"); /** Wrapper around camelCase to handle more cases */ function formatNumber(number) { return nf.format(number); } /** Get File ID from design URL */ function getFileID(url) { return url.match(/^https:\/\/(www\.)?figma\.com\/design\/([^/]+)/)?.[2]; } /** * Returns Figma API auth headers based on environment variables. * Prefers OAuth (FIGMA_OAUTH_TOKEN) over PAT (FIGMA_ACCESS_TOKEN) if both are set. * Warns via logger and returns empty headers if neither is set. */ function getFigmaAuthHeaders(logger) { if (process.env.FIGMA_OAUTH_TOKEN) return { Authorization: `Bearer ${process.env.FIGMA_OAUTH_TOKEN}` }; if (process.env.FIGMA_ACCESS_TOKEN) return { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }; logger.warn({ group: "config", message: "Figma auth not configured! Set FIGMA_OAUTH_TOKEN or FIGMA_ACCESS_TOKEN" }); return {}; } /** /v1/files/:file_key */ async function getFile(fileKey, { logger }) { const res = await fetch(API.file.replace(FILE_KEY, fileKey), { method: "GET", headers: getFigmaAuthHeaders(logger) }); if (!res.ok) logger.error({ group: "import", message: `${res.status} ${await res.text()}` }); return await res.json(); } /** /v1/files/:file_key/nodes */ async function getFileNodes(fileKey, { ids, logger }) { let url = API.fileNodes.replace(FILE_KEY, fileKey); if (ids?.length) url += `?ids=${ids.join(",")}`; const res = await fetch(url, { method: "GET", headers: getFigmaAuthHeaders(logger) }); if (!res.ok) logger.error({ group: "import", message: `${res.status} ${await res.text()}` }); return await res.json(); } /** /v1/files/:file_key/styles */ async function getFileStyles(fileKey, { logger }) { const res = await fetch(API.fileStyles.replace(FILE_KEY, fileKey), { method: "GET", headers: getFigmaAuthHeaders(logger) }); if (!res.ok) logger.error({ group: "import", message: `${res.status} ${await res.text()}` }); return await res.json(); } /** /v1/files/:file_key/variables/local */ async function getFileLocalVariables(fileKey, { logger }) { const res = await fetch(API.localVariables.replace(FILE_KEY, fileKey), { method: "GET", headers: getFigmaAuthHeaders(logger) }); if (!res.ok) logger.error({ group: "import", message: `${res.status} ${await res.text}` }); return await res.json(); } /** /v1/files/:file_key/variables/published */ async function getFilePublishedVariables(fileKey, { logger }) { const res = await fetch(API.publishedVariables.replace(FILE_KEY, fileKey), { method: "GET", headers: getFigmaAuthHeaders(logger) }); if (!res.ok) logger.error({ group: "import", message: `${res.status} ${await res.text}` }); return await res.json(); } //#endregion //#region src/shared.ts const cwd = new URL(`${pathToFileURL(process.cwd())}/`); const DEFAULT_CONFIG_PATH = new URL("./terrazzo.config.ts", cwd); const DEFAULT_TOKENS_PATH = new URL("./tokens.json", cwd); const GREEN_CHECK = pc.green("✔"); /** Load config */ async function loadConfig({ cmd, flags, logger }) { /** * Vite server for loading .ts config files * TODO: remove me when Node 24 is the oldest-supported Node version */ let viteServer; try { let config = { tokens: [DEFAULT_TOKENS_PATH], outDir: new URL("./tokens/", cwd), plugins: [], lint: { build: { enabled: true }, rules: {} }, alphabetize: false, ignore: { tokens: [], deprecated: false }, permutationLimit: 1e3 }; let configPath; if (typeof flags.config === "string") { if (flags.config === "") { logger.error({ group: "config", message: "Missing path after --config flag" }); process.exit(1); } configPath = resolveConfig(flags.config); if (!configPath) logger.error({ group: "config", message: `Could not locate ${flags.config}` }); } const resolvedConfigPath = resolveConfig(configPath); if (resolvedConfigPath) try { viteServer = await createServer({ mode: "development" }); const viteNodeServer = new ViteNodeServer(viteServer); const mod = await new ViteNodeRunner({ root: viteServer.config.root, base: viteServer.config.base, fetchModule(...args) { return viteNodeServer.fetchModule(...args); }, resolveId(...args) { return viteNodeServer.resolveId(...args); } }).executeFile(resolvedConfigPath); if (!mod.default) throw new Error(`No default export found in ${resolvedConfigPath.replace(fileURLToPath(cwd), "")}. See https://terrazzo.dev/docs for instructions.`); config = defineConfig$1(mod.default, { cwd, logger }); if (flags["no-lint"]) config.lint.build.enabled = false; } catch (err) { logger.error({ group: "config", message: err.message || err }); } else if (cmd !== "init" && cmd !== "check") logger.error({ group: "config", message: "No config file found. Create one with `npx terrazzo init`." }); if (viteServer) await viteServer?.close(); return { config, configPath: resolvedConfigPath }; } catch (err) { printError(err.message); if (viteServer) await viteServer.close(); process.exit(1); } } /** load tokens */ async function loadTokens(tokenPaths, { logger }) { try { const allTokens = []; if (!Array.isArray(tokenPaths)) logger.error({ group: "config", message: `loadTokens: Expected array, received ${typeof tokenPaths}` }); if (tokenPaths.length === 1 && tokenPaths[0].href === DEFAULT_TOKENS_PATH.href) { if (!fsSync.existsSync(tokenPaths[0])) { const yamlPath = new URL("./tokens.yaml", cwd); if (fsSync.existsSync(yamlPath)) tokenPaths[0] = yamlPath; else { logger.error({ group: "config", message: `Could not locate ${path.relative(cwd.href, tokenPaths[0].href)}. To create one, run \`npx tz init\`.` }); return; } } } for (let i = 0; i < tokenPaths.length; i++) { const filename = tokenPaths[i]; if (!(filename instanceof URL)) { logger.error({ group: "config", message: `Expected URL, received ${filename}`, label: `loadTokens[${i}]` }); return; } else if (filename.protocol === "http:" || filename.protocol === "https:") try { if (filename.host === "figma.com" || filename.host === "www.figma.com") { const [_, fileKeyword, fileKey] = filename.pathname.split("/"); if (fileKeyword !== "file" || !fileKey) logger.error({ group: "config", message: `Unexpected Figma URL. Expected "https://www.figma.com/file/:file_key/:file_name?…", received "${filename.href}"` }); const headers = new Headers({ Accept: "*/*", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0", ...getFigmaAuthHeaders(logger) }); const res = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables/local`, { method: "GET", headers }); if (res.ok) allTokens.push({ filename, src: await res.text() }); const message = res.status !== 404 ? JSON.stringify(await res.json(), void 0, 2) : ""; logger.error({ group: "config", message: `Figma responded with ${res.status}${message ? `:\n${message}` : ""}` }); break; } const res = await fetch(filename, { method: "GET", headers: { Accept: "*/*", "User-Agent": "Mozilla/5.0 Gecko/20100101 Firefox/123.0" } }); allTokens.push({ filename, src: await res.text() }); } catch (err) { logger.error({ group: "config", message: `${filename.href}: ${err}` }); return; } else if (fsSync.existsSync(filename)) allTokens.push({ filename, src: fsSync.readFileSync(filename, "utf8") }); else { logger.error({ group: "config", message: `Could not locate ${path.relative(cwd.href, filename.href)}. To create one, run \`npx tz init\`.` }); return; } } return allTokens; } catch (err) { printError(err.message); process.exit(1); } } /** Print error */ function printError(message) { console.error(pc.red(`✗ ${message}`)); } /** Print success */ function printSuccess(message, startTime) { console.log(`${GREEN_CHECK} ${message}${startTime ? ` ${time(startTime)}` : ""}`); } /** Resolve config */ function resolveConfig(filename) { if (filename && fsSync.existsSync(new URL(filename, cwd))) return filename; for (const ext of [ ".ts", ".js", ".mts", ".cts", ".mjs", ".cjs" ]) { const maybeFilename = `terrazzo.config${ext}`; if (fsSync.existsSync(new URL(maybeFilename, cwd))) return fileURLToPath(new URL(maybeFilename, cwd)); } } /** Resolve tokens.json path (for lint command) */ function resolveTokenPath(filename, { logger }) { const tokensPath = new URL(filename, cwd); if (!fsSync.existsSync(tokensPath)) logger.error({ group: "config", message: `Could not locate ${filename}. Does the file exist?` }); else if (!fsSync.statSync(tokensPath).isFile()) logger.error({ group: "config", message: `Expected JSON or YAML file, received ${filename}.` }); return tokensPath; } /** Print time elapsed */ function time(start) { const diff = performance.now() - start; return pc.dim(diff < 750 ? `${Math.round(diff)}ms` : `${(diff / 1e3).toFixed(1)}s`); } //#endregion //#region src/build.ts /** tz build */ async function buildCmd({ config, configPath, flags, logger }) { try { const startTime = performance.now(); if (!Array.isArray(config.plugins) || !config.plugins.length) logger.error({ group: "config", message: `No plugins defined! Add some in ${configPath || "terrazzo.config.ts"}` }); let rawSchemas = await loadTokens(config.tokens, { logger }); if (!rawSchemas) { logger.error({ group: "config", message: `Error loading ${path.relative(fileURLToPath(cwd), fileURLToPath(config.tokens[0] || DEFAULT_TOKENS_PATH))}` }); return; } const skipLint = !config.lint.build.enabled; let { tokens, resolver, sources } = await parse(rawSchemas, { config, logger, skipLint, yamlToMomoa }); let result = await build(tokens, { resolver, sources, config, logger }); writeFiles(result, { config, logger }); if (flags.watch) { const dt = new Intl.DateTimeFormat("en-us", { hour: "2-digit", minute: "2-digit" }); async function rebuild({ messageBefore, messageAfter } = {}) { try { if (messageBefore) logger.info({ group: "plugin", label: "watch", message: messageBefore }); rawSchemas = await loadTokens(config.tokens, { logger }); if (!rawSchemas) throw new Error(`Error loading ${path.relative(fileURLToPath(cwd), fileURLToPath(config.tokens[0] || DEFAULT_TOKENS_PATH))}`); const parseResult = await parse(rawSchemas, { config, logger, skipLint, yamlToMomoa }); tokens = parseResult.tokens; sources = parseResult.sources; resolver = parseResult.resolver; result = await build(tokens, { resolver, sources, config, logger }); if (messageAfter) logger.info({ group: "plugin", label: "watch", message: messageAfter }); writeFiles(result, { config, logger }); } catch (err) { console.error(pc.red(`✗ ${err.message || err}`)); } } chokidar.watch(config.tokens.map((filename) => fileURLToPath(filename))).on("change", async (filename) => { await rebuild({ messageBefore: `${pc.dim(dt.format(/* @__PURE__ */ new Date()))} ${pc.green("tz")}} ${pc.yellow(filename)} updated ${GREEN_CHECK}` }); }); chokidar.watch(resolveConfig(configPath)).on("change", async () => { await rebuild({ messageBefore: `${pc.dim(dt.format(/* @__PURE__ */ new Date()))} ${pc.green("tz")} ${pc.yellow("Config updated. Reloading…")}` }); }); await new Promise(() => {}); } else printSuccess(`${Object.keys(tokens).length} token${Object.keys(tokens).length !== 1 ? "s" : ""} built`, startTime); } catch (err) { printError(err.message); process.exit(1); } } /** Write files */ function writeFiles(result, { config, logger }) { for (const { filename, contents } of result.outputFiles) { const output = new URL(filename, config.outDir); fsSync.mkdirSync(new URL(".", output), { recursive: true }); fsSync.writeFileSync(output, contents); logger.debug({ group: "parser", label: "buildEnd", message: `Wrote file ${fileURLToPath(output)}` }); } } //#endregion //#region src/check.ts /** tz check */ async function checkCmd({ config, logger, positionals }) { try { const startTime = performance.now(); const sources = await loadTokens(positionals.slice(1).length ? positionals.slice(1).map((tokenPath) => resolveTokenPath(tokenPath, { logger })) : config.tokens, { logger }); if (!sources?.length) { logger.error({ group: "config", message: "Couldn’t find any tokens. Run `npx tz init` to create some." }); return; } await parse(sources, { config, continueOnError: true, logger, yamlToMomoa }); printSuccess("No errors", startTime); } catch (err) { printError(err.message); process.exit(1); } } //#endregion //#region src/format.ts function findMember(name) { return function(member) { return member.name.type === "String" && member.name.value === name; }; } async function formatCmd(filename, { logger, output }) { try { if (!filename) { logger.error({ group: "config", message: "Expected input: `tz format <tokens.json> -o output.json`" }); return; } const sourceLoc = new URL(filename, cwd); if (!fsSync.existsSync(sourceLoc)) logger.error({ group: "config", message: `Couldn’t find ${path.relative(cwd.href, sourceLoc.href)}. Does it exist?` }); const sourceData = fsSync.readFileSync(sourceLoc, "utf8"); const isYaml = filename.endsWith(".yml") || filename.endsWith(".yaml") || !sourceData.startsWith("{"); const document = isYaml ? yamlToMomoa(sourceData) : momoa.parse(sourceData, { mode: "jsonc" }); const { tokens } = await parse([{ src: sourceData, filename: sourceLoc }], { config: defineConfig$1({ lint: { rules: { "core/consistent-naming": "off", "core/valid-color": "off", "core/valid-dimension": "off", "core/valid-duration": "off", "core/valid-typography": "off" } } }, { cwd }), logger, resolveAliases: false, yamlToMomoa }); traverse(document, { enter(node, _parent, nodePath) { const token = tokens[nodePath.join(".")]; if (!token || token.aliasOf || node.type !== "Member" || node.value.type !== "Object") return; const $valueI = node.value.members.findIndex(findMember("$value")); switch (token.$type) { case "color": if (node.value.members[$valueI].value.type === "String") { if (isAlias(node.value.members[$valueI].value.value)) return; const hex = node.value.members[$valueI].value.value; node.value.members[$valueI].value = momoa.parse(JSON.stringify({ ...token.$value, hex: hex.startsWith("#") ? hex.slice(0, 7) : void 0 })).body; const $extensions = getObjMember(node.value, "$extensions"); if ($extensions?.type === "Object") { const mode = getObjMember($extensions, "mode"); if (mode?.type === "Object") for (let i = 0; i < mode.members.length; i++) { const modeName = mode.members[i].name.value; const hex = mode.members[i].value.value; mode.members[i].value = momoa.parse(JSON.stringify({ ...token.mode[modeName].$value, hex: hex.startsWith("#") ? hex.slice(0, 7) : void 0 })).body; } } } break; case "dimension": case "duration": if (node.value.members[$valueI].value.type === "String") { if (isAlias(node.value.members[$valueI].value.value)) return; node.value.members[$valueI].value = formatDurationDimension(node.value.members[$valueI].value); const $extensions = getObjMember(node.value, "$extensions"); if ($extensions?.type === "Object") { const mode = getObjMember($extensions, "mode"); if (mode?.type === "Object") for (let i = 0; i < mode.members.length; i++) mode.members[i].value = formatDurationDimension(node.value.members[$valueI].value); } } break; case "typography": { if (node.value.members[$valueI]?.value.type !== "Object") return; node.value.members[$valueI].value = formatTypography(node.value.members[$valueI].value); const $extensions = getObjMember(node.value, "$extensions"); if ($extensions?.type === "Object") { const mode = getObjMember($extensions, "mode"); if (mode?.type === "Object") for (let i = 0; i < mode.members.length; i++) mode.members[i].value = formatTypography(mode.members[i].value); } } } } }); const outputLoc = new URL(output, cwd); const contents = isYaml ? yaml.stringify(JSON.parse(momoa.print(document))) : momoa.print(document, { indent: 2 }); fsSync.mkdirSync(new URL(".", outputLoc), { recursive: true }); fsSync.writeFileSync(outputLoc, contents); } catch (err) { printError(err.message); process.exit(1); } } function formatDurationDimension(node) { const value = Number.parseFloat(node.value); if (!Number.isFinite(value)) return node; node.type = "Object"; node.members = momoa.parse(JSON.stringify({ value, unit: node.value.replace(String(value), "") })).body.members; delete node.value; return node; } function formatTypography(node) { const { fontFamily, fontSize, fontWeight, letterSpacing, lineHeight } = getObjMembers(node); if (!fontFamily) node.members.push(momoa.parse("{\"fontFamily\":[\"inherit\"]}").body.members[0]); if (!fontSize) node.members.push(momoa.parse("{\"fontSize\":{\"value\":1,\"unit\":\"rem\"}}").body.members[0]); if (!fontWeight) node.members.push(momoa.parse("{\"fontWeight\":400}").body.members[0]); if (!letterSpacing) node.members.push(momoa.parse("{\"letterSpacing\":{\"value\":0,\"unit\":\"rem\"}}").body.members[0]); if (!lineHeight) node.members.push(momoa.parse("{\"lineHeight\":1}").body.members[0]); return node; } //#endregion //#region src/help.ts /** Show help */ function helpCmd() { console.log(`tz [commands] build Build token artifacts from tokens.json --watch, -w Watch tokens.json for changes and recompile --no-lint Disable linters running on build check [path] Check tokens.json for errors and run linters lint [path] (alias of check) format [path] Format your tokens --o [file] Output file normalize (alias of format) init Create a starter tokens.json file lab Manage your tokens with a web interface import [path] Import from a Figma Design file --o [file] Save imported JSON --unpublished Include unpublished Variables --skip-styles Don’t import styles --skip-variables Don’t import variables [options] --help Show this message --config, -c Path to config (default: ./terrazzo.config.ts) --quiet Suppress warnings `); } //#endregion //#region src/import/figma/styles.ts /** /v1/files/:file_key/styles */ async function getStyles(fileKey, { logger, unpublished }) { const result = { count: 0, code: { sets: { styles: { sources: [{}] } } } }; const styleNodeIDs = /* @__PURE__ */ new Set(); const stylesByID = /* @__PURE__ */ new Map(); if (unpublished) { const styles = await getFile(fileKey, { logger }); for (const [id, style] of Object.entries(styles.styles)) { styleNodeIDs.add(id); stylesByID.set(id, style); } } else { const styles = await getFileStyles(fileKey, { logger }); for (const style of styles.meta.styles) { styleNodeIDs.add(style.node_id); stylesByID.set(style.node_id, style); } } const fileNodes = await getFileNodes(fileKey, { ids: [...styleNodeIDs], logger }); result.count += styleNodeIDs.size; for (const [id, s] of stylesByID) { const styleNode = fileNodes.nodes[id]; if (!styleNode) { logger.warn({ group: "import", message: `Style ${s.name} not found in file nodes. Does it need to be published?` }); continue; } const styleType = "style_type" in s ? s.style_type : s.styleType; const tokenBase = { $type: void 0, $description: s.description || void 0, $value: void 0, $extensions: { "figma.com": { name: s.name, node_id: id, created_at: "created_at" in s ? s.created_at : void 0, updated_at: "updated_at" in s ? s.updated_at : void 0 } } }; switch (styleType) { case "FILL": { const $value = fillStyle(styleNode.document); if (!$value) logger.error({ group: "import", message: `Could not parse fill for ${s.name}`, continueOnError: true }); if (Array.isArray($value)) tokenBase.$type = "gradient"; else tokenBase.$type = "color"; tokenBase.$value = $value; break; } case "TEXT": { const $value = textStyle(styleNode.document); if (!$value) logger.error({ group: "import", message: `Could not parse text for ${s.name}`, continueOnError: true }); tokenBase.$type = "typography"; tokenBase.$value = $value; break; } case "EFFECT": { const $value = effectStyle(styleNode.document); if (!$value) logger.error({ group: "import", message: `Could not parse effect for ${s.name}`, continueOnError: true }); tokenBase.$type = "shadow"; tokenBase.$value = $value; break; } case "GRID": { const layoutGrids = gridStyles(styleNode.document); if (!layoutGrids) logger.error({ group: "import", message: `Could not parse grid for ${s.name}`, continueOnError: true }); let node = result.code.sets.styles.sources[0]; const path = s.name.split("/").map(formatName); const name = path.pop(); for (const key of path) { if (!(key in node)) node[key] = {}; node = node[key]; } node[name] = layoutGrids; break; } } if (tokenBase.$type !== void 0) { let node = result.code.sets.styles.sources[0]; const path = s.name.split("/").map(formatName); const name = path.pop(); for (const key of path) { if (!(key in node)) node[key] = {}; node = node[key]; } node[name] = tokenBase; } } return result; } /** Return a shadow token from an effect */ function effectStyle(node) { if ("effects" in node) { const shadows = node.effects.filter((e) => e.type === "DROP_SHADOW" || e.type === "INNER_SHADOW"); if (shadows.length) return shadows.map((s) => ({ inset: s.type === "INNER_SHADOW", offsetX: { value: s.offset.x, unit: "px" }, offsetY: { value: s.offset.y, unit: "px" }, blur: { value: s.radius, unit: "px" }, spread: { value: s.spread ?? 0, unit: "px" }, color: { colorSpace: "srgb", components: [ s.color.r, s.color.g, s.color.b ], alpha: s.color.a } })); } } /** Return a color or gradient token from a fill */ function fillStyle(node) { if ("fills" in node) for (const fill of node.fills) switch (fill.type) { case "SOLID": return { colorSpace: "srgb", components: [ fill.color.r, fill.color.g, fill.color.b ], alpha: fill.color.a }; case "GRADIENT_LINEAR": case "GRADIENT_RADIAL": case "GRADIENT_ANGULAR": case "GRADIENT_DIAMOND": return fill.gradientStops.map((stop) => ({ position: stop.position, color: { colorSpace: "srgb", components: [ stop.color.r, stop.color.g, stop.color.b ], alpha: stop.color.a } })); } } /** Return a dimension token from grid */ function gridStyles(node) { if (!("layoutGrids" in node) || !node.layoutGrids?.length) return; const values = {}; for (const grid of node.layoutGrids) { const pattern = grid.pattern.toLowerCase(); if (values[pattern]) continue; values[pattern] = { sectionSize: { $type: "dimension", $value: { value: grid.sectionSize, unit: "px" } }, gutterSize: { $type: "dimension", $value: { value: grid.sectionSize, unit: "px" } } }; if (grid.count > 0) values[pattern].count = { $type: "number", $value: grid.count }; } return values; } /** Return a typography token from text */ function textStyle(node) { if (!("style" in node)) return; return { fontFamily: [node.style.fontFamily], fontWeight: node.style.fontWeight, fontStyle: node.style.fontStyle, fontSize: node.style.fontSize ? { value: node.style.fontSize, unit: "px" } : { value: 1, unit: "em" }, letterSpacing: { value: node.style.letterSpacing ?? 0, unit: "px" }, lineHeight: "lineHeightPercentFontSize" in node.style ? node.style.lineHeightPercentFontSize : "lineHeightPx" in node.style ? { value: node.style.lineHeightPx, unit: "px" } : 1 }; } //#endregion //#region src/import/figma/variables.ts function getAliasID(value) { if (typeof value === "object" && value && "type" in value && value.type === "VARIABLE_ALIAS" && "id" in value && typeof value.id === "string") return value.id; } /** /v1/files/:file_key/variables/published | /v1/files/:file_key/variables/local */ async function getVariables(fileKey, { logger, unpublished, matchers }) { const result = { count: 0, remoteCount: 0, code: { sets: {}, modifiers: {} } }; const allVariables = {}; const variableCollections = {}; let finalVariables = {}; const modeIDToName = {}; const local = await getFileLocalVariables(fileKey, { logger }); for (const id of Object.keys(local.meta.variables)) allVariables[id] = local.meta.variables[id]; for (const id of Object.keys(local.meta.variableCollections)) { variableCollections[id] = local.meta.variableCollections[id]; for (const mode of local.meta.variableCollections[id].modes) modeIDToName[mode.modeId] = formatName(mode.name); } if (unpublished) finalVariables = Object.fromEntries(Object.entries(allVariables).filter(([, variable]) => !variable.hiddenFromPublishing)); else { const published = await getFilePublishedVariables(fileKey, { logger }); for (const id of Object.keys(published.meta.variables)) finalVariables[id] = allVariables[id]; } const pendingIDs = Object.keys(finalVariables); for (let i = 0; i < pendingIDs.length; i++) { const variable = finalVariables[pendingIDs[i]]; if (!variable) continue; for (const value of Object.values(variable.valuesByMode)) { const aliasID = getAliasID(value); if (!aliasID || !allVariables[aliasID] || aliasID in finalVariables) continue; finalVariables[aliasID] = allVariables[aliasID]; pendingIDs.push(aliasID); } } const remoteIDs = /* @__PURE__ */ new Set(); for (const id of Object.keys(finalVariables)) { const variable = finalVariables[id]; const collection = variableCollections[variable.variableCollectionId]; const collectionName = formatName(collection.name); const hasMultipleModes = collection.modes.length > 1; if (hasMultipleModes) { if (!(collectionName in result.code.modifiers)) result.code.modifiers[collectionName] = { contexts: Object.fromEntries(collection.modes.map((m) => [formatName(m.name), [{}]])), default: modeIDToName[collection.defaultModeId] }; } else if (!(collectionName in result.code.sets)) result.code.sets[collectionName] = { sources: [{}] }; const matches = matchers.fontFamily?.test(variable.name) && "fontFamily" || matchers.fontWeight?.test(variable.name) && "fontWeight" || matchers.number?.test(variable.name) && "number" || void 0; for (const [modeID, value] of Object.entries(variable.valuesByMode)) { const modeName = modeIDToName[modeID]; let node = result.code; if (hasMultipleModes) { if (!(modeName in result.code.modifiers[collectionName].contexts)) result.code.modifiers[collectionName].contexts[modeName] = [{}]; node = result.code.modifiers[collectionName].contexts[modeName][0]; } else node = result.code.sets[collectionName].sources[0]; const tokenBase = { $type: void 0, $description: variable.description || void 0, $value: void 0, $extensions: { "figma.com": { name: variable.name, id: variable.id, variableCollectionId: variable.variableCollectionId, codeSyntax: Object.keys(variable.codeSyntax).length ? variable.codeSyntax : void 0 } } }; const isAliasOfID = getAliasID(value); if (isAliasOfID) if (allVariables[isAliasOfID]) { tokenBase.$type = matches || { COLOR: "color", BOOLEAN: "boolean", STRING: "string", FLOAT: "dimension" }[variable.resolvedType]; tokenBase.$value = `{${allVariables[isAliasOfID].name.split("/").map(formatName).join(".")}}`; } else { remoteIDs.add(isAliasOfID); continue; } else if (matches === "fontFamily") { tokenBase.$type = "fontFamily"; tokenBase.$value = String(value).split(","); } else if (matches === "fontWeight") { tokenBase.$type = "fontWeight"; tokenBase.$value = value; } else if (matches === "number") { if (typeof value === "object") throw new Error(`Can’t coerce ${variable.name} into number type.`); tokenBase.$type = "number"; tokenBase.$value = Number(value); } else switch (variable.resolvedType) { case "BOOLEAN": case "STRING": tokenBase.$type = variable.resolvedType.toLowerCase(); tokenBase.$value = value; break; case "FLOAT": tokenBase.$type = "dimension"; tokenBase.$value = { value, unit: "px" }; break; case "COLOR": { const { r, g, b, a } = value; tokenBase.$type = "color"; tokenBase.$value = { colorSpace: "srgb", components: [ r, g, b ], alpha: a }; break; } } if (tokenBase.$value !== void 0) { const path = variable.name.split("/").map(formatName); const name = path.pop(); for (const key of path) { if (!(key in node)) node[key] = {}; node = node[key]; } node[name] = tokenBase; } } } result.count = Object.keys(finalVariables).length; result.remoteCount = remoteIDs.size; return result; } //#endregion //#region src/import/figma/index.ts async function importFromFigma({ url, logger, unpublished, skipStyles, skipVariables, fontFamilyNames = "/fontFamily$", fontWeightNames = "/fontWeight$", numberNames }) { const fileKey = getFileID(url); if (!fileKey) logger.error({ group: "import", message: `Invalid Figma URL: ${url}` }); const result = { variableCount: 0, styleCount: 0, code: { $schema: "https://www.designtokens.org/schemas/2025.10/resolver.json", version: "2025.10", resolutionOrder: [], sets: {}, modifiers: {} } }; try { const [styles, vars] = await Promise.all([!skipStyles ? getStyles(fileKey, { logger }) : null, !skipVariables ? getVariables(fileKey, { logger, unpublished, matchers: { fontFamily: fontFamilyNames ? new RegExp(fontFamilyNames) : void 0, fontWeight: fontWeightNames ? new RegExp(fontWeightNames) : void 0, number: numberNames ? new RegExp(numberNames) : void 0 } }) : null]); if (styles) { result.styleCount += styles.count; result.code = merge(result.code, styles.code); } if (vars) { result.variableCount += vars.count; result.code = merge(result.code, vars.code); if (vars.remoteCount) logger.warn({ group: "import", message: `${formatNumber(vars.remoteCount)} ${pluralize(vars.remoteCount, "Variable", "Variables")} were remote and could not be accessed. Try importing from other files to grab them.` }); } } catch (err) { logger.error({ group: "import", message: err.message }); } for (const group of ["sets", "modifiers"]) for (const name of Object.keys(result.code[group])) result.code.resolutionOrder.push({ $ref: `#/${group}/${name}` }); return result; } /** Is this a valid URL, and one belonging to a Figma file? */ function isFigmaPath(url) { try { new URL(url); return /^https:\/\/(www\.)?figma\.com\/design\/[A-Za-z0-9]+/.test(url); } catch { return false; } } //#endregion //#region src/import/index.ts async function importCmd({ flags, positionals, logger }) { const [_cmd, url] = positionals; if (!url) logger.error({ group: "import", message: "Missing import path. Expected `tz import [file]`." }); if (isFigmaPath(url)) { const { FIGMA_ACCESS_TOKEN, FIGMA_OAUTH_TOKEN } = process.env; if (!FIGMA_ACCESS_TOKEN && !FIGMA_OAUTH_TOKEN) logger.error({ group: "import", message: `Figma auth not configured! Set FIGMA_OAUTH_TOKEN (OAuth access token) or FIGMA_ACCESS_TOKEN (Personal Access Token). See https://terrazzo.app/docs/guides/import-from-figma` }); const start = performance.now(); const result = await importFromFigma({ url, logger, unpublished: flags.unpublished, skipStyles: flags["skip-styles"], skipVariables: flags["skip-variables"], fontFamilyNames: flags["font-family-names"], fontWeightNames: flags["font-weight-names"], numberNames: flags["number-names"] }); const end = performance.now() - start; if (flags.output) { const oldFile = fsSync.existsSync(flags.output) ? JSON.parse(await fs.readFile(flags.output, "utf8")) : {}; const code = { $schema: result.code.$schema, version: result.code.version, resolutionOrder: oldFile.resolutionOrder?.length ? oldFile.resolutionOrder : result.code.resolutionOrder, sets: result.code.sets, modifiers: result.code.modifiers, $defs: oldFile.$defs, $extensions: oldFile.$extensions }; await fs.writeFile(flags.output, `${JSON.stringify(code, void 0, 2)}\n`); logger.info({ group: "import", message: `Imported ${formatNumber(result.variableCount)} ${pluralize(result.variableCount, "Variable", "Variables")}, ${formatNumber(result.styleCount)} ${pluralize(result.styleCount, "Style", "Styles")} → ${flags.output}`, timing: end }); } else process.stdout.write(JSON.stringify(result.code)); return; } } //#endregion //#region src/init.ts const INSTALL_COMMAND = { npm: "install -D --silent", yarn: "add -D --silent", pnpm: "add -D --silent", bun: "install -D --silent" }; const SYNTAX_SETTINGS = { format: { indent: { style: " " }, quotes: "single", semicolons: true } }; const EXAMPLE_TOKENS_PATH = "my-tokens.tokens.json"; const DESIGN_SYSTEMS = { "adobe-spectrum": { name: "Spectrum", author: "Adobe", tokens: ["dtcg-examples/adobe-spectrum.resolver.json"] }, "apple-hig": { name: "Human Interface Guidelines", author: "Apple", tokens: ["dtcg-examples/apple-hig.resolver.json"] }, "figma-sds": { name: "Simple Design System", author: "Figma", tokens: ["dtcg-examples/figma-sds.resolver.json"] }, "github-primer": { name: "Primer", author: "GitHub", tokens: ["dtcg-examples/github-primer.resolver.json"] }, "ibm-carbon": { name: "Carbon", author: "IBM", tokens: ["dtcg-examples/ibm-carbon.resolver.json"] }, "microsoft-fluent": { name: "Fluent", author: "Microsoft", tokens: ["dtcg-examples/microsoft-fluent.resolver.json"] }, "shopify-polaris": { name: "Polaris", author: "Shopify", tokens: ["dtcg-examples/shopify-polaris.resolver.json"] } }; async function initCmd({ logger }) { try { intro("⛋ Welcome to Terrazzo"); const packageManager = await detect({ cwd: fileURLToPath(cwd) }); const { config, configPath = "terrazzo.config.ts" } = await loadConfig({ cmd: "init", flags: {}, logger }); const tokensPath = config.tokens[0]; const hasExistingConfig = fsSync.existsSync(configPath); let startFromDS = !(tokensPath && fsSync.existsSync(tokensPath)); if (tokensPath && fsSync.existsSync(tokensPath)) { if (await confirm({ message: `Found tokens at ${path.relative(fileURLToPath(cwd), fileURLToPath(tokensPath))}. Overwrite with a new design system?` })) startFromDS = true; } if (startFromDS) { const ds = DESIGN_SYSTEMS[await select({ message: "Start from existing design system?", options: [...Object.entries(DESIGN_SYSTEMS).map(([id, { author, name }]) => ({ value: id, label: `${author} ${name}` })), { value: "none", label: "None" }] })]; if (ds) { const s = spinner(); s.start("Downloading"); await new Promise((resolve, reject) => { const subprocess = spawn(packageManager, [INSTALL_COMMAND[packageManager], "dtcg-examples"], { cwd }); subprocess.on("error", reject); subprocess.on("exit", resolve); }); s.stop("Download complete"); if (hasExistingConfig) await updateConfigTokens(configPath, ds.tokens); else await newConfigFile(configPath, ds.tokens); } else startFromDS = false; } if (!hasExistingConfig) { await newConfigFile(configPath, [EXAMPLE_TOKENS_PATH]); await fs.writeFile(EXAMPLE_TOKENS_PATH, JSON.stringify(EXAMPLE_TOKENS, void 0, 2)); } const existingPlugins = config.plugins.map((p) => p.name); const pluginSelection = await multiselect({ message: "Install plugins?", options: [ { value: ["@terrazzo/plugin-css"], label: "CSS" }, { value: ["@terrazzo/plugin-js"], label: "JS + TS" }, { value: ["@terrazzo/plugin-css", "@terrazzo/plugin-sass"], label: "Sass" }, { value: ["@terrazzo/plugin-tailwind"], label: "Tailwind" } ], required: false }); const newPlugins = Array.isArray(pluginSelection) ? Array.from(new Set(pluginSelection.flat().filter((p) => !existingPlugins.includes(p)))) : []; if (newPlugins?.length) { const plugins = newPlugins.map((p) => ({ specifier: p.replace("@terrazzo/plugin-", ""), path: p })); const pluginCount = `${newPlugins.length} ${pluralize(newPlugins.length, "plugin", "plugins")}`; const s = spinner(); s.start(`Installing ${pluginCount}`); await new Promise((resolve, reject) => { const subprocess = spawn(packageManager, [INSTALL_COMMAND[packageManager], newPlugins.join(" ")], { cwd }); subprocess.on("error", reject); subprocess.on("exit", resolve); }); s.message("Updating config"); await updateConfigPlugins(configPath, plugins); s.stop(`Installed ${pluginCount}`); } if (!startFromDS && !newPlugins?.length) { outro("Nothing to do. Exiting."); return; } outro("⛋ Done! 🎉"); } catch (err) { printError(err.message); process.exit(1); } } async function newConfigFile(configPath, tokens, imports = []) { await fs.writeFile(configPath, `import { defineConfig } from '@terrazzo/cli'; ${imports.map((p) => `import ${p.specifier} from '${p.path}';`).join("\n")} export default defineConfig({ tokens: ['${tokens.join("', '")}'], plugins: [ ${imports.length ? imports.map((p) => `${p.specifier}(),`).join("\n ") : "/** @see https://terrazzo.app/docs */"} ], outDir: './dist/', lint: { /** @see https://terrazzo.app/docs/linting */ build: { enabled: true, }, rules: { 'core/valid-color': 'error', 'core/valid-dimension': 'error', 'core/valid-font-family': 'error', 'core/valid-font-weight': 'error', 'core/valid-duration': 'error', 'core/valid-cubic-bezier': 'error', 'core/valid-number': 'error', 'core/valid-link': 'error', 'core/valid-boolean': 'error', 'core/valid-string': 'error', 'core/valid-stroke-style': 'error', 'core/valid-border': 'error', 'core/valid-transition': 'error', 'core/valid-shadow': 'error', 'core/valid-gradient': 'error', 'core/valid-typography': 'error', 'core/consistent-naming': 'warn', }, }, });`); } function getConfigObjFromAst(ast, configPath) { const astExport = ast.body.find((node) => node.type === "ExportDefaultDeclaration"); if (!astExport) { const relConfigPath = configPath ? path.relative(fileURLToPath(cwd), fileURLToPath(new URL(configPath))) : void 0; throw new Error(`SyntaxError: ${relConfigPath} does not have default export.`); } const astConfig = astExport.declaration.type === "CallExpression" ? astExport.declaration.arguments[0] : astExport.declaration; if (astConfig.type !== "ObjectExpression") throw new Error(`Config: expected object default export, received ${astConfig.type}.`); return astConfig; } async function updateConfigTokens(configPath, tokens) { const ast = parseModule(await fs.readFile(configPath, "utf8")); const astConfig = getConfigObjFromAst(ast, configPath); let tokensKey = astConfig.properties.find((p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "tokens"); if (!tokensKey) { tokensKey = { type: "Property", key: { type: "Identifier", name: "tokens" }, method: false, computed: false, shorthand: false, value: { type: "ArrayExpression", elements: tokens.map((value) => ({ type: "Literal", value, raw: `'${value}'` })) } }; astConfig.properties.unshift(tokensKey); } await fs.writeFile(configPath, generate(ast, SYNTAX_SETTINGS)); } /** * Add plugin imports * note: this has the potential to duplicate plugins, but we tried our * best to filter already, and this may be the user’s fault if they * selected to install a plugin already installed. But also, this is * easily-fixable, so let’s not waste too much time here (and possibly * introduce bugs). */ async function updateConfigPlugins(configPath, plugins) { const ast = parseModule(await fs.readFile(configPath, "utf8")); ast.body.push(...plugins.map((p) => ({ type: "ImportDeclaration", source: { type: "Literal", value: p.path }, specifiers: [{ type: "ImportDefaultSpecifier", local: { type: "Identifier", name: p.specifier } }], attributes: [] }))); const astConfig = getConfigObjFromAst(ast, configPath); const pluginsArray = astConfig.properties.find((property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "plugins")?.value; const pluginsAst = plugins.map((p) => ({ type: "CallExpression", callee: { type: "Identifier", name: p.specifier }, arguments: [], optional: false })); if (pluginsArray) pluginsArray.elements.push(...pluginsAst); else astConfig.properties.push({ type: "Property", key: { type: "Identifier", name: "plugins" }, value: { type: "ArrayExpression", elements: pluginsAst }, kind: "init", computed: false, method: false, shorthand: false }); await fs.writeFile(configPath, generate(ast, SYNTAX_SETTINGS)); } const EXAMPLE_TOKENS = { color: { $description: "Color tokens", black: { "100": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .05, hex: "#0c0c0d" } }, "200": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .1, hex: "#0c0c0d" } }, "300": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .2, hex: "#0c0c0d" } }, "400": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .34, hex: "#0c0c04" } }, "500": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .7, hex: "#0c0c0d" } }, "600": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .8, hex: "#0c0c0d" } }, "700": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .85, hex: "#0c0c0d" } }, "800": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .9, hex: "#0c0c0d" } }, "900": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: .95, hex: "#0c0c0d" } }, "1000": { $type: "color", $value: { colorSpace: "srgb", components: [ .047, .047, .047 ], alpha: 0, hex: "#0c0c0d" } } } }, border: { $description: "Border tokens", default: { type: "border", $value: { color: "{color.black.900}", style: "solid", width: { value: 1, unit: "px" } } } }, radius: { $description: "Corner radius tokens", "100": { $value: { value: .25, unit: "rem" } } }, space: { $description: "Dimension tokens", "100": { $value: { value: .25, unit: "rem" } } }, typography: { $description: "Typography tokens", body: { $type: "typography", $value: { fontFamily: "{typography.family.sans}", fontSize: "{typography.scale.03}", fontWeight: "{typography.weight.regular}", letterSpacing: { value: 0, unit: "em" }, lineHeight: 1 } } } }; //#endregion //#region src/lab.ts function serve(_options, _ready) { throw new Error(`tz lab not available yet.`); } async function labCmd({ config, logger }) { /** TODO: handle multiple files */ const [tokenFileUrl] = config.tokens; const staticFiles = /* @__PURE__ */ new Set(); const dirEntries = await fs.readdir(fileURLToPath(import.meta.resolve("./lab")), { withFileTypes: true, recursive: true }); for (const entry of dirEntries) { if (entry.isFile() === false) continue; const absolutePath = `${entry.parentPath.replaceAll("\\", "/")}/${entry.name}`; staticFiles.add(absolutePath.replace(fileURLToPath(import.meta.resolve("./lab")).replaceAll("\\", "/"), "")); } const server = serve({ port: 9e3, overrideGlobalObjects: false, async fetch(request) { const pathname = new URL(request.url).pathname; if (pathname === "/") return new Response(Readable.toWeb(fsSync.createReadStream(fileURLToPath(import.meta.resolve("./lab/index.html")))), { headers: { "Content-Type": "text/html" } }); if (pathname === "/api/tokens") { if (request.method === "GET") return new Response(Readable.toWeb(fsSync.createReadStream(tokenFileUrl)), { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" } }); else if (request.method === "POST" && request.body) { await request.body.pipeTo(Writable.toWeb(fsSync.createWriteStream(tokenFileUrl))); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); } } if (staticFiles.has(pathname)) return new Response(Readable.toWeb(fsSync.createReadStream(fileURLToPath(import.meta.resolve(`./lab${pathname}`)))), { headers: { "Content-Type": mime.getType(pathname) ?? "application/octet-stream" } }); return new Response("Not found", { status: 404 }); } }, (info) => { logger.info({ group: "server", message: `Token Lab running at http://${info.address === "::" ? "localhost" : info.address}:${info.port}` }); }); /** * The cli entrypoint is going to manually exit the process after labCmd returns. */ await new Promise((resolve, reject) => { server.on("close", resolve); server.on("error", reject); }); } //#endregion //#region src/version.ts function versionCmd() { const { version } = JSON.parse(fsSync.readFileSync(new URL("../package.json", import.meta.url), "utf8")); console.log(version); } //#endregion //#region src/index.ts const require = createRequire(cwd); function defineConfig(config) { const normalizedConfig = { ...config }; if (typeof normalizedConfig.tokens === "string" || Array.isArray(normalizedConfig.tokens)) normalizedConfig.tokens = (Array.isArray(normalizedConfig.tokens) ? normalizedConfig.tokens : [normalizedConfig.tokens]).map((tokenPath) => { if (tokenPath.startsWith(".") || /^(https?|file):\/\//i.test(tokenPath)) return tokenPath; else if (fsSync.existsSync(new URL(tokenPath, cwd))) return new URL(tokenPath, cwd); try { return pathToFileURL(require.resolve(tokenPath)); } catch (err) { console.error(err); retu