@terrazzo/cli
Version:
CLI for managing design tokens using the Design Tokens Community Group (DTCG) standard and generating code for any platform via plugins.
224 lines • 9.17 kB
JavaScript
import { exec } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { confirm, intro, multiselect, outro, select, spinner } from '@clack/prompts';
import { pluralize } from '@terrazzo/token-tools';
import { detect } from 'detect-package-manager';
import { generate } from 'escodegen';
import { parseModule } from 'meriyah';
import { DEFAULT_CONFIG_PATH, DEFAULT_TOKENS_PATH, cwd, loadConfig, printError } from './shared.js';
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'],
},
};
export async function initCmd({ logger }) {
try {
intro('⛋ Welcome to Terrazzo');
const packageManager = await detect({ cwd: fileURLToPath(cwd) });
// TODO: pass in CLI flags?
const { config, configPath } = await loadConfig({ cmd: 'init', flags: {}, logger });
const relConfigPath = configPath
? path.relative(fileURLToPath(cwd), fileURLToPath(new URL(configPath)))
: undefined;
let tokensPath = config.tokens[0];
let startFromDS = !(tokensPath && fs.existsSync(tokensPath));
// 1. tokens
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) {
// TODO: support multiple tokens files?
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');
}
}
// 2. plugins
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}`);
// note: thi sis async to show the spinner
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');
// 2a. 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).
ast.body.push(...plugins.map((p) => ({
type: 'ImportDeclaration',
source: { type: 'Literal', value: p.package },
specifiers: [{ type: 'ImportDefaultSpecifier', local: { type: 'Identifier', name: p.specifier } }],
attributes: [],
})));
// 2b. add plugins to config.plugins
if (!astExport) {
logger.error({ group: 'config', message: `SyntaxError: ${relConfigPath} does not have default export.` });
return;
}
const astConfig = (astExport.declaration.type === 'CallExpression'
? // export default defineConfig({ ... })
astExport.declaration.arguments[0]
: // export default { ... }
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,
});
}
// 2c. update new file (and we’ll probably format it wrong but hey)
fs.writeFileSync(configPath, generate(ast, {
format: {
indent: { style: ' ' },
quotes: 'single',
semicolons: true,
},
}));
}
else {
// 2a. write new config file (easy)
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);
}
}
//# sourceMappingURL=init.js.map