@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
JavaScript
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