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.

726 lines (715 loc) 23.1 kB
import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; import { build, defineConfig as defineConfig$1, getObjMembers, parse, traverse } from "@terrazzo/parser"; import fs, { createReadStream, createWriteStream } from "node:fs"; import path from "node:path"; import pc from "picocolors"; import chokidar from "chokidar"; import yamlToMomoa from "yaml-to-momoa"; import { exec } from "node:child_process"; import { confirm, intro, multiselect, outro, select, spinner } from "@clack/prompts"; import { isAlias, pluralize } from "@terrazzo/token-tools"; import { detect } from "detect-package-manager"; import { generate } from "escodegen"; import { parseModule } from "meriyah"; import { readdir } from "node:fs/promises"; import { Readable, Writable } from "node:stream"; import { serve } from "@hono/node-server"; import mime from "mime"; import { parse as parse$1, print } from "@humanwhocodes/momoa"; //#region src/shared.ts const cwd = new URL(`${pathToFileURL(process.cwd())}/`); const DEFAULT_CONFIG_PATH = new URL("./terrazzo.config.mjs", cwd); const DEFAULT_TOKENS_PATH = new URL("./tokens.json", cwd); const GREEN_CHECK = pc.green("✔"); /** Load config */ async function loadConfig({ cmd, flags, logger }) { try { let config = { tokens: [DEFAULT_TOKENS_PATH], outDir: new URL("./tokens/", cwd), plugins: [], lint: { build: { enabled: true }, rules: {} }, ignore: { tokens: [], deprecated: false } }; 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); } const resolvedConfigPath = resolveConfig(configPath); if (resolvedConfigPath) try { const mod = await import(resolvedConfigPath); if (!mod.default) throw new Error(`No default export found in ${path.relative(cwd.href, resolvedConfigPath)}. See https://terrazzo.dev/docs/cli for instructions.`); config = defineConfig$1(mod.default, { cwd, logger }); } 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`." }); return { config, configPath: resolvedConfigPath }; } catch (err) { printError(err.message); 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 (!fs.existsSync(tokenPaths[0])) { const yamlPath = new URL("./tokens.yaml", cwd); if (fs.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" }); if (process.env.FIGMA_ACCESS_TOKEN) headers.set("X-FIGMA-TOKEN", process.env.FIGMA_ACCESS_TOKEN); else logger.warn({ group: "config", message: "FIGMA_ACCESS_TOKEN not set" }); const res$1 = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables/local`, { method: "GET", headers }); if (res$1.ok) allTokens.push({ filename, src: await res$1.text() }); const message = res$1.status !== 404 ? JSON.stringify(await res$1.json(), void 0, 2) : ""; logger.error({ group: "config", message: `Figma responded with ${res$1.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 (fs.existsSync(filename)) allTokens.push({ filename, src: fs.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) { const configPath = new URL(filename, cwd); if (fs.existsSync(configPath)) return configPath.href; return void 0; } return [ ".js", ".mjs", ".cjs" ].map((ext) => new URL(`./terrazzo.config${ext}`, cwd)).find((configPath) => fs.existsSync(configPath))?.href; } /** Resolve tokens.json path (for lint command) */ function resolveTokenPath(filename, { logger }) { const tokensPath = new URL(filename, cwd); if (!fs.existsSync(tokensPath)) logger.error({ group: "config", message: `Could not locate ${filename}. Does the file exist?` }); else if (!fs.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.js"}` }); 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; } let { tokens, sources } = await parse(rawSchemas, { config, logger, yamlToMomoa }); let result = await build(tokens, { 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, yamlToMomoa }); tokens = parseResult.tokens; sources = parseResult.sources; result = await build(tokens, { 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}`)); } } const tokenWatcher = chokidar.watch(config.tokens.map((filename) => fileURLToPath(filename))); tokenWatcher.on("change", async (filename) => { await rebuild({ messageBefore: `${pc.dim(dt.format(/* @__PURE__ */ new Date()))} ${pc.green("tz")}} ${pc.yellow(filename)} updated ${GREEN_CHECK}` }); }); const configWatcher = chokidar.watch(resolveConfig(configPath)); configWatcher.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); fs.mkdirSync(new URL(".", output), { recursive: true }); fs.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 tokenPaths = positionals.slice(1).length ? positionals.slice(1).map((tokenPath) => resolveTokenPath(tokenPath, { logger })) : config.tokens; const sources = await loadTokens(tokenPaths, { 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/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) init Create a starter tokens.json file lab Manage your tokens with a web interface [options] --help Show this message --config, -c Path to config (default: ./terrazzo.config.js) --quiet Suppress warnings `); } //#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 DTCG_ROOT_URL = "https://raw.githubusercontent.com/terrazzoapp/dtcg-examples/refs/heads/main/"; const DESIGN_SYSTEMS = { "adobe-spectrum": { name: "Spectrum", author: "Adobe", tokens: ["adobe-spectrum.json"] }, "apple-hig": { name: "Human Interface Guidelines", author: "Apple", tokens: ["apple-hig.json"] }, "figma-sds": { name: "Simple Design System", author: "Figma", tokens: ["figma-sds.json"] }, "github-primer": { name: "Primer", author: "GitHub", tokens: ["github-primer.json"] }, "ibm-carbon": { name: "Carbon", author: "IBM", tokens: ["ibm-carbon.json"] }, "microsoft-fluent": { name: "Fluent", author: "Microsoft", tokens: ["microsoft-fluent.json"] }, radix: { name: "Radix", author: "Radix", tokens: ["radix.json"] }, "salesforce-lightning": { name: "Lightning", author: "Salesforce", tokens: ["salesforce-lightning.json"] }, "shopify-polaris": { name: "Polaris", author: "Shopify", tokens: ["shopify-polaris.json"] } }; async function initCmd({ logger }) { try { intro("⛋ Welcome to Terrazzo"); const packageManager = await detect({ cwd: fileURLToPath(cwd) }); const { config, configPath } = await loadConfig({ cmd: "init", flags: {}, logger }); const relConfigPath = configPath ? path.relative(fileURLToPath(cwd), fileURLToPath(new URL(configPath))) : void 0; let tokensPath = config.tokens[0]; let startFromDS = !(tokensPath && fs.existsSync(tokensPath)); if (tokensPath && fs.existsSync(tokensPath)) { if (await confirm({ message: `Found tokens at ${path.relative(fileURLToPath(cwd), fileURLToPath(tokensPath))}. Overwrite with a new design system?` })) startFromDS = true; } else tokensPath = DEFAULT_TOKENS_PATH; 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"); const tokenSource = await fetch(new URL(ds.tokens[0], DTCG_ROOT_URL)).then((res) => res.text()); fs.writeFileSync(tokensPath, tokenSource); s.stop("Download complete"); } } 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-sass", label: "Sass" }, { value: "@terrazzo/plugin-tailwind", label: "Tailwind" } ], required: false }); const newPlugins = Array.isArray(pluginSelection) ? pluginSelection.filter((p) => !existingPlugins.includes(p)) : []; if (newPlugins?.length) { const plugins = newPlugins.map((p) => ({ specifier: p.replace("@terrazzo/plugin-", ""), package: p })); const pluginCount = `${newPlugins.length} ${pluralize(newPlugins.length, "plugin", "plugins")}`; const s = spinner(); s.start(`Installing ${pluginCount}`); await new Promise((resolve, reject) => { const subprocess = exec([ packageManager, INSTALL_COMMAND[packageManager], newPlugins.join(" ") ].join(" "), { cwd }); subprocess.on("error", reject); subprocess.on("exit", resolve); }); s.message("Updating config"); if (configPath) { const ast = parseModule(fs.readFileSync(configPath, "utf8")); const astExport = ast.body.find((node) => node.type === "ExportDefaultDeclaration"); ast.body.push(...plugins.map((p) => ({ type: "ImportDeclaration", source: { type: "Literal", value: p.package }, specifiers: [{ type: "ImportDefaultSpecifier", local: { type: "Identifier", name: p.specifier } }], attributes: [] }))); if (!astExport) { logger.error({ group: "config", message: `SyntaxError: ${relConfigPath} does not have default export.` }); return; } const astConfig = astExport.declaration.type === "CallExpression" ? astExport.declaration.arguments[0] : astExport.declaration; if (astConfig.type !== "ObjectExpression") { logger.error({ group: "config", message: `Config: expected object default export, received ${astConfig.type}` }); return; } 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: [] })); 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 }); fs.writeFileSync(configPath, generate(ast, { format: { indent: { style: " " }, quotes: "single", semicolons: true } })); } else fs.writeFileSync(DEFAULT_CONFIG_PATH, `import { defineConfig } from '@terrazzo/cli'; ${plugins.map((p) => `import ${p.specifier} from '${p.package}';`).join("\n")} export default defineConfig({ tokens: ['./tokens.json'], plugins: [ ${plugins.map((p) => `${p.specifier}(),`).join("\n ")} ], outDir: './dist/', lint: { /** @see https://terrazzo.app/docs/cli/lint */ }, });`); s.stop(`Installed ${pluginCount}`); } outro("⛋ Done! 🎉"); } catch (err) { printError(err.message); process.exit(1); } } //#endregion //#region src/lab.ts async function labCmd({ config, logger }) { /** TODO: handle multiple files */ const [tokenFileUrl] = config.tokens; const staticFiles = /* @__PURE__ */ new Set(); const dirEntries = await 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 url = new URL(request.url); const pathname = url.pathname; if (pathname === "/") return new Response(Readable.toWeb(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(createReadStream(tokenFileUrl)), { headers: { "Content-Type": "application/json", "Cache-Control": "no-cache" } }); else if (request.method === "POST" && request.body) { await request.body.pipeTo(Writable.toWeb(createWriteStream(tokenFileUrl))); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); } } if (staticFiles.has(pathname)) return new Response(Readable.toWeb(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/normalize.ts function findMember(name) { return function(member) { return member.name.type === "String" && member.name.value === name; }; } async function normalizeCmd(filename, { logger, output }) { try { if (!filename) { logger.error({ group: "config", message: "Expected input: `tz normalize <tokens.json> -o output.json`" }); return; } const sourceLoc = new URL(filename, cwd); if (!fs.existsSync(sourceLoc)) logger.error({ group: "config", message: `Couldn’t find ${path.relative(cwd.href, sourceLoc.href)}. Does it exist?` }); const sourceData = fs.readFileSync(sourceLoc, "utf8"); const document = parse$1(sourceData); const { tokens } = await parse([{ src: sourceData, filename: sourceLoc }], { config: defineConfig$1({}, { cwd }), logger }); 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": case "dimension": case "duration": { if (node.value.members[$valueI].value.type === "String") { const newValueContainer = parse$1(JSON.stringify({ $value: token.$value })).body; const newValueNode = newValueContainer.members.find(findMember("$value")); node.value.members[$valueI] = newValueNode; const { $extensions } = getObjMembers(node.value); if ($extensions?.type === "Object") { const { mode } = getObjMembers($extensions); if (mode?.type === "Object") for (let i = 0; i < mode.members.length; i++) { const modeName = mode.members[i].name.value; const modeValue = token.mode[modeName]; if (typeof modeValue === "string" && isAlias(modeValue)) continue; const newModeValueContainer = parse$1(JSON.stringify({ [modeName]: token.mode[modeName].$value })).body; const newModeValueNode = newModeValueContainer.members.find(findMember(modeName)); mode.members[i] = newModeValueNode; } } } break; } } } }); const outputLoc = new URL(output, cwd); fs.mkdirSync(new URL(".", outputLoc), { recursive: true }); fs.writeFileSync(outputLoc, print(document, { indent: 2 })); } catch (err) { printError(err.message); process.exit(1); } } //#endregion //#region src/version.ts function versionCmd() { const { version } = JSON.parse(fs.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; try { return pathToFileURL(require.resolve(tokenPath)); } catch (err) { console.error(err); return tokenPath; } }); return defineConfig$1(normalizedConfig, { cwd }); } //#endregion export { DEFAULT_CONFIG_PATH, DEFAULT_TOKENS_PATH, GREEN_CHECK, buildCmd, checkCmd, cwd, defineConfig, helpCmd, initCmd, labCmd, loadConfig, loadTokens, normalizeCmd, printError, printSuccess, resolveConfig, resolveTokenPath, time, versionCmd, writeFiles }; //# sourceMappingURL=index.js.map