Skip to content

Commit

Permalink
feat(cli): support json configuration for tokens create
Browse files Browse the repository at this point in the history
  • Loading branch information
unekinn committed Jan 7, 2025
1 parent d4c1ddb commit 1fd3e22
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 49 deletions.
222 changes: 194 additions & 28 deletions packages/cli/bin/designsystemet.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,55 @@
#!/usr/bin/env node
import { Argument, createCommand, program } from '@commander-js/extra-typings';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Argument, type Command, type OptionValues, createCommand, program } from '@commander-js/extra-typings';
import chalk from 'chalk';
import * as R from 'ramda';
import { z } from 'zod';
import { fromError } from 'zod-validation-error';

import { convertToHex } from '../src/colors/index.js';
import type { CssColor } from '../src/colors/types.js';
import migrations from '../src/migrations/index.js';
import { buildTokens } from '../src/tokens/build.js';
import { colorCliOptions, createTokens } from '../src/tokens/create.js';
import type { Theme } from '../src/tokens/types.js';
import { writeTokens } from '../src/tokens/write.js';

/**
* This defines the structure of the JSON config file
*/
const configFileSchema = z.object({
outDir: z.string().optional(),
themes: z.record(
z.object({
colors: z.object({
main: z.record(z.string().transform(convertToHex)),
support: z.record(z.string().transform(convertToHex)),
neutral: z.string().transform(convertToHex),
}),
typography: z.object({
fontFamily: z.string(),
}),
borderRadius: z.number(),
}),
),
});

/**
* This defines the structure of the final configuration after combining the config file,
* command-line options and default values.
*/
const combinedConfigSchema = configFileSchema.required();
type CombinedConfigSchema = z.infer<typeof combinedConfigSchema>;

program.name('designsystemet').description('CLI for working with Designsystemet').showHelpAfterError();

function makeTokenCommands() {
const tokenCmd = createCommand('tokens');
const DEFAULT_TOKENS_DIR = './design-tokens';
const DEFAULT_BUILD_DIR = './design-tokens-build';
const DEFAULT_FONT = 'Inter';
const DEFAULT_THEME_NAME = 'theme';
const DEFAULT_CONFIG_FILE = 'designsystemet.config.json';

tokenCmd
.command('build')
Expand Down Expand Up @@ -43,42 +77,113 @@ function makeTokenCommands() {
tokenCmd
.command('create')
.description('Create Designsystemet tokens')
.requiredOption(`-m, --${colorCliOptions.main} <name:hex...>`, `Main colors`, parseColorValues)
.requiredOption(`-s, --${colorCliOptions.support} <name:hex...>`, `Support colors`, parseColorValues)
.requiredOption(`-n, --${colorCliOptions.neutral} <hex>`, `Neutral hex color`, convertToHex)
.option(`-m, --${colorCliOptions.main} <name:hex...>`, `Main colors`, parseColorValues)
.option(`-s, --${colorCliOptions.support} <name:hex...>`, `Support colors`, parseColorValues)
.option(`-n, --${colorCliOptions.neutral} <hex>`, `Neutral hex color`, convertToHex)
.option('-o, --out-dir <string>', `Output directory for created ${chalk.blue('design-tokens')}`, DEFAULT_TOKENS_DIR)
.option('--dry [boolean]', `Dry run for created ${chalk.blue('design-tokens')}`, false)
.option('-f, --font-family <string>', `Font family`, 'Inter')
.option('-b, --border-radius <number>', `Unitless base border-radius in px`, '4')
.option('--theme <string>', `Theme name`, 'theme')
.action(async (opts) => {
const { theme, fontFamily, outDir } = opts;
.option('-f, --font-family <string>', `Font family`, DEFAULT_FONT)
.option(
'-b, --border-radius <number>',
`Unitless base border-radius in px`,
(radiusAsString) => Number(radiusAsString),
4,
)
.option('--theme <string>', 'Theme name (ignored when using JSON config file)', DEFAULT_THEME_NAME)
.option('--json <string>', `Path to JSON config file (default: "${DEFAULT_CONFIG_FILE}")`, (value) =>
parseJsonConfig(value, { allowFileNotFound: false }),
)
.action(async (opts, cmd) => {
const dry = Boolean(opts.dry);
const borderRadius = Number(opts.borderRadius);
console.log(`Creating tokens with options ${chalk.green(JSON.stringify(opts, null, 2))}`);

const themeOptions: Theme = {
name: theme,
colors: {
main: opts.mainColors,
support: opts.supportColors,
neutral: opts.neutralColor,
},
typography: {
fontFamily: fontFamily,
},
borderRadius,
};

if (dry) {
console.log(`Performing dry run, no files will be written`);
}

const tokens = createTokens(themeOptions);
/*
* Get json config file by looking for the optional default file, or using --json option if supplied.
* The file must exist if specified through --json, but is not required otherwise.
*/
const configFile = await (opts.json
? opts.json
: parseJsonConfig(DEFAULT_CONFIG_FILE, { allowFileNotFound: true }));
const propsFromJson = configFile?.config;

if (propsFromJson) {
/*
* Check that we're not creating multiple themes with different color names.
* For the themes' modes to work in Figma and when building css, the color names must be consistent
*/
const themeColors = Object.values(propsFromJson?.themes ?? {}).map(
(x) => new Set([...R.keys(x.colors.main), ...R.keys(x.colors.support)]),
);
if (!R.all(R.equals(R.__, themeColors[0]), themeColors)) {
console.error(
chalk.redBright(
`In JSON config ${configFile.path}, all themes must have the same custom color names, but we found:`,
),
);
const themeNames = R.keys(propsFromJson.themes ?? {});
themeColors.forEach((colors, index) => {
const colorNames = Array.from(colors);
console.log(` - ${themeNames[index]}: ${colorNames.join(', ')}`);
});
console.log();
process.exit(1);
}
}

/*
* Create final config from JSON config file and command-line options
*/
const noUndefined = R.reject(R.isNil);

const defaultThemeConfig = {
colors: noUndefined({
main: getExplicitOption(cmd, 'mainColors'),
support: getExplicitOption(cmd, 'supportColors'),
neutral: getExplicitOption(cmd, 'neutralColor'),
}),
typography: noUndefined({
fontFamily: getExplicitOption(cmd, 'fontFamily'),
}),
borderRadius: getExplicitOption(cmd, 'borderRadius'),
};

const propsFromOpts = noUndefined({
outDir: getExplicitOption(cmd, 'outDir'),
themes: propsFromJson?.themes
? Object.fromEntries(Object.keys(propsFromJson.themes).map((themeName) => [themeName, defaultThemeConfig]))
: {
[opts.theme]: defaultThemeConfig,
},
});

const unvalidatedConfig = R.mergeDeepRight(propsFromJson ?? {}, propsFromOpts);

/*
* Check that the config is valid
*/
let config: CombinedConfigSchema;
try {
config = combinedConfigSchema.parse(unvalidatedConfig);
} catch (err) {
console.error(chalk.redBright('Invalid config after combining defaults, json config and options'));
const validationError = makeFriendlyError(err);
console.error(validationError.toString());
process.exit(1);
}

await writeTokens({ outDir, tokens, theme: themeOptions, dry });
console.log(`Creating tokens with configuration ${chalk.green(JSON.stringify(config, null, 2))}`);

return Promise.resolve();
/*
* Create and write tokens for each theme
*/
for (const [name, themeWithoutName] of Object.entries(config.themes)) {
const theme = { name, ...themeWithoutName };
const tokens = createTokens(theme);
await writeTokens({ outDir: config.outDir, tokens, theme, dry });
}
});

return tokenCmd;
Expand Down Expand Up @@ -117,8 +222,69 @@ program

await program.parseAsync(process.argv);

async function parseJsonConfig(
jsonPath: string,
options: {
allowFileNotFound: boolean;
},
) {
const resolvedPath = path.resolve(process.cwd(), jsonPath);

let jsonFile: string;
try {
jsonFile = await fs.readFile(resolvedPath, { encoding: 'utf-8' });
} catch (err) {
if (err instanceof Error) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ENOENT' && options.allowFileNotFound) {
// Suppress error when the file isn't found, instead the promise returns undefined.
return;
}
}
throw err;
}
try {
return {
path: jsonPath,
config: await configFileSchema.parseAsync(JSON.parse(jsonFile)),
};
} catch (err) {
console.error(chalk.redBright(`Invalid json config in ${jsonPath}`));
const validationError = makeFriendlyError(err);
console.error(validationError.toString());
process.exit(1);
}
}

function makeFriendlyError(err: unknown) {
return fromError(err, {
messageBuilder: (issues) =>
issues
.map((issue) => {
const issuePath = issue.path.join('.');
return ` - ${chalk.red(issuePath)}: ${issue.message} (${chalk.dim(issue.code)})`;
})
.join('\n'),
});
}

function parseColorValues(value: string, previous: Record<string, CssColor> = {}): Record<string, CssColor> {
const [name, hex] = value.split(':');
previous[name] = convertToHex(hex);
return previous;
}

/**
* Get an option value if it has been explicitly supplied on the command line.
* The difference between this and using the option directly is that we return undefined
* instead of the default value if the option was not explicitly set.
*/
function getExplicitOption<Args extends unknown[], Opts extends OptionValues, K extends keyof Opts>(
command: Command<Args, Opts>,
option: K,
) {
const source = command.getOptionValueSource(option);
if (source === 'cli') {
return command.getOptionValue(option);
}
}
13 changes: 9 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@
"build": "tsup && yarn build:types",
"build:types": "tsc --emitDeclarationOnly --declaration",
"types": "tsc --noEmit",
"test:tokens-create": "yarn designsystemet tokens create -m dominant:#007682 secondary:#ff0000 -n #003333 -s support1:#12404f support2:#0054a6 support3:#942977 -b 99 -o ./test-tokens-create",
"test:tokens-create-options": "yarn designsystemet tokens create -m dominant:#007682 complimentary:#ff0000 -n #003333 -s support1:#12404f support2:#0054a6 support3:#942977 -b 99 -o ./test-tokens-create",
"test:tokens-create-json": "yarn designsystemet tokens create --json ./test-tokens-create-complex.config.json",
"test:tokens-build": "yarn designsystemet tokens build -t ./test-tokens-create -o ./test-tokens-build",
"test:tokens-create-and-build": "rimraf test-tokens-create && rimraf test-tokens-build && yarn test:tokens-create && yarn test:tokens-build",
"test": "yarn test:tokens-create-and-build",
"test:tokens-create-and-build-options": "yarn clean:test-tokens && yarn test:tokens-create-options && yarn test:tokens-build",
"test:tokens-create-and-build-json": "yarn clean:test-tokens && yarn test:tokens-create-json && yarn test:tokens-build",
"test": "yarn test:tokens-create-and-build-options && yarn test:tokens-create-and-build-json",
"clean": "rimraf dist",
"clean:test-tokens": "rimraf test-tokens-create && rimraf test-tokens-build",
"clean:theme": "yarn workspace @digdir/designsystemet-theme clean",
"update:template": "tsx ./src/tokens/template.ts",
"internal:tokens-create-digdir": "yarn designsystemet tokens create --theme theme -m accent:#0062BA -n #1E2B3C -s brand1:#F45F63 brand2:#E5AA20 brand3:#1E98F5 -o ./internal/design-tokens",
Expand All @@ -65,7 +68,9 @@
"prompts": "^2.4.2",
"ramda": "^0.30.1",
"rimraf": "^6.0.1",
"style-dictionary": "^4.0.1"
"style-dictionary": "^4.0.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@types/apca-w3": "^0.1.3",
Expand Down
10 changes: 2 additions & 8 deletions packages/cli/src/tokens/create.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import * as R from 'ramda';
import { baseColors, generateColorScale } from '../colors/index.js';
import type { ColorInfo, ColorScheme } from '../colors/types.js';
import type { Colors, Tokens, Tokens1ary, TokensSet, Typography } from './types.js';
import type { Colors, Theme, Tokens, Tokens1ary, TokensSet, Typography } from './types.js';

export const colorCliOptions = {
main: 'main-colors',
support: 'support-colors',
neutral: 'neutral-color',
} as const;

export type CreateTokensOptions = {
colors: Colors;
typography: Typography;
name: string;
};

const createColorTokens = (colorArray: ColorInfo[]): Tokens1ary => {
const obj: Tokens1ary = {};
const $type = 'color';
Expand Down Expand Up @@ -93,7 +87,7 @@ const generateGlobalTokens = (colorScheme: ColorScheme) => {
};
};

export const createTokens = (opts: CreateTokensOptions) => {
export const createTokens = (opts: Theme) => {
const { colors, typography, name } = opts;

const tokens: Tokens = {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/tokens/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { type CreateTokensOptions, createTokens, colorCliOptions } from './create.js';
export { createTokens, colorCliOptions } from './create.js';
export type { Theme as CreateTokensOptions } from './types.js';
10 changes: 2 additions & 8 deletions packages/cli/src/tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,7 @@ export type Collection = string | 'global';

export type Theme = {
name: string;
colors: {
main: Record<string, CssColor>;
support: Record<string, CssColor>;
neutral: CssColor;
};
typography: {
fontFamily: string;
};
colors: Colors;
typography: Typography;
borderRadius: number;
};
43 changes: 43 additions & 0 deletions packages/cli/test-tokens-create-complex.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"outDir": "./test-tokens-create",
"themes": {
"some-org": {
"colors": {
"main": {
"dominant": "#0062BA",
"complimentary": "#94237C"
},
"support": {
"first": "#F45F63",
"second": "#E5AA20",
"third": "#1E98F5",
"fourth": "#F167EC"
},
"neutral": "#303030"
},
"typography": {
"fontFamily": "Inter"
},
"borderRadius": 8
},
"other-org": {
"colors": {
"main": {
"dominant": "#ffaaaa",
"complimentary": "#00ff00"
},
"support": {
"first": "#abcdef",
"second": "#123456",
"third": "#994a22",
"fourth": "#3d5f30"
},
"neutral": "#c05030"
},
"typography": {
"fontFamily": "Roboto"
},
"borderRadius": 99
}
}
}
Loading

0 comments on commit 1fd3e22

Please sign in to comment.