From 31096992efaad25d79aacb0897553bd9b617c861 Mon Sep 17 00:00:00 2001 From: Une Sofie Kinn Ekroll Date: Wed, 8 Jan 2025 16:05:11 +0100 Subject: [PATCH] feat(cli): support json configuration for `tokens create` (#2847) Closes #2846 Some things to note: - Most CLI options are no longer required, although they must be set either in JSON or through CLI. This could be confusing. I tried to look in to building options conditionally, so that e.g. options could be required if `--json` was not used or default config was not found, but this doesn't seem to be possible - The complete config is validated, so users will still get an error if options are missing. We could probably work on making the errors more clear. - CLI options take priority over JSON config. This is usually how CLI tools work. - Because of this, we need to know whether an option was supplied or not. This means we have to check if the option is set through the CLI or is a default value, before we decide whether it should overwrite the JSON config. The function `getExplicitOptionOnly()` has been added for this purpose. --------- Co-authored-by: Michael Marszalek --- .changeset/fuzzy-snails-arrive.md | 5 + packages/cli/bin/config.ts | 29 +++ packages/cli/bin/designsystemet.ts | 185 +++++++++++++++--- packages/cli/bin/options.ts | 29 +++ packages/cli/package.json | 13 +- packages/cli/src/tokens/create.ts | 10 +- packages/cli/src/tokens/index.ts | 3 +- packages/cli/src/tokens/types.ts | 10 +- .../test-tokens-create-complex.config.json | 43 ++++ packages/cli/test-tokens-create.config.json | 22 +++ yarn.lock | 18 ++ 11 files changed, 319 insertions(+), 48 deletions(-) create mode 100644 .changeset/fuzzy-snails-arrive.md create mode 100644 packages/cli/bin/config.ts create mode 100644 packages/cli/bin/options.ts create mode 100644 packages/cli/test-tokens-create-complex.config.json create mode 100644 packages/cli/test-tokens-create.config.json diff --git a/.changeset/fuzzy-snails-arrive.md b/.changeset/fuzzy-snails-arrive.md new file mode 100644 index 0000000000..a8762f1f92 --- /dev/null +++ b/.changeset/fuzzy-snails-arrive.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": patch +--- + +Add json config file support for `tokens create` diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts new file mode 100644 index 0000000000..345c66975c --- /dev/null +++ b/packages/cli/bin/config.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { convertToHex } from '../src/colors/index.js'; + +/** + * This defines the structure of the JSON config file + */ +export 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. + */ +export const combinedConfigSchema = configFileSchema.required(); +export type CombinedConfigSchema = z.infer; diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index baf7d573e6..973b1b9499 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -1,14 +1,19 @@ #!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; import { Argument, createCommand, program } from '@commander-js/extra-typings'; import chalk from 'chalk'; +import * as R from 'ramda'; +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'; +import { type CombinedConfigSchema, combinedConfigSchema, configFileSchema } from './config.js'; +import { type OptionGetter, getExplicitOptionOnly, getExplicitOrDefaultOption } from './options.js'; program.name('designsystemet').description('CLI for working with Designsystemet').showHelpAfterError(); @@ -16,6 +21,9 @@ 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') @@ -43,42 +51,119 @@ function makeTokenCommands() { tokenCmd .command('create') .description('Create Designsystemet tokens') - .requiredOption(`-m, --${colorCliOptions.main} `, `Main colors`, parseColorValues) - .requiredOption(`-s, --${colorCliOptions.support} `, `Support colors`, parseColorValues) - .requiredOption(`-n, --${colorCliOptions.neutral} `, `Neutral hex color`, convertToHex) + .option(`-m, --${colorCliOptions.main} `, `Main colors`, parseColorValues) + .option(`-s, --${colorCliOptions.support} `, `Support colors`, parseColorValues) + .option(`-n, --${colorCliOptions.neutral} `, `Neutral hex color`, convertToHex) .option('-o, --out-dir ', `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 ', `Font family`, 'Inter') - .option('-b, --border-radius ', `Unitless base border-radius in px`, '4') - .option('--theme ', `Theme name`, 'theme') - .action(async (opts) => { - const { theme, fontFamily, outDir } = opts; + .option('-f, --font-family ', `Font family`, DEFAULT_FONT) + .option( + '-b, --border-radius ', + `Unitless base border-radius in px`, + (radiusAsString) => Number(radiusAsString), + 4, + ) + .option('--theme ', 'Theme name (ignored when using JSON config file)', DEFAULT_THEME_NAME) + .option('--json ', `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 getThemeDefaults = (optionGetter: OptionGetter) => + noUndefined({ + colors: noUndefined({ + main: optionGetter(cmd, 'mainColors'), + support: optionGetter(cmd, 'supportColors'), + neutral: optionGetter(cmd, 'neutralColor'), + }), + typography: noUndefined({ + fontFamily: optionGetter(cmd, 'fontFamily'), + }), + borderRadius: optionGetter(cmd, 'borderRadius'), + }); + + const propsFromOpts = noUndefined({ + outDir: getExplicitOptionOnly(cmd, 'outDir'), + themes: propsFromJson?.themes + ? // For each theme specified in the JSON config, we override the config values + // with the explicitly set options from the CLI. + R.map(() => getThemeDefaults(getExplicitOptionOnly), propsFromJson.themes) + : // If there are no themes specified in the JSON config, we use both explicit + // and default theme options from the CLI. + { + [opts.theme]: getThemeDefaults(getExplicitOrDefaultOption), + }, + }); + console.log(propsFromOpts); + + 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; @@ -117,6 +202,52 @@ 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 = {}): Record { const [name, hex] = value.split(':'); previous[name] = convertToHex(hex); diff --git a/packages/cli/bin/options.ts b/packages/cli/bin/options.ts new file mode 100644 index 0000000000..da3eeb6dc6 --- /dev/null +++ b/packages/cli/bin/options.ts @@ -0,0 +1,29 @@ +import type { Command, OptionValueSource, OptionValues } from '@commander-js/extra-typings'; + +const getOptionIfMatchingSource = + (...sources: OptionValueSource[]) => + ( + command: Command, + option: K, + ) => { + const source = command.getOptionValueSource(option); + console.log(sources, source); + if (sources.includes(source)) { + return command.getOptionValue(option); + } + }; + +export type OptionGetter = ReturnType; + +/** + * Get an option value if it is explicitly supplied to the CLI command. + * 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. + */ +export const getExplicitOptionOnly = getOptionIfMatchingSource('cli'); + +/** + * This function is basically the default behaviour, unlike {@link getExplicitOptionOnly}. + * It is provided so that the program can choose its behaviour as needed. + */ +export const getExplicitOrDefaultOption = getOptionIfMatchingSource('cli', 'default'); diff --git a/packages/cli/package.json b/packages/cli/package.json index d098e95cf8..51645feb5a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -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", diff --git a/packages/cli/src/tokens/create.ts b/packages/cli/src/tokens/create.ts index 6146f0a2ff..b550521966 100644 --- a/packages/cli/src/tokens/create.ts +++ b/packages/cli/src/tokens/create.ts @@ -1,7 +1,7 @@ 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', @@ -9,12 +9,6 @@ export const colorCliOptions = { 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'; @@ -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 = { diff --git a/packages/cli/src/tokens/index.ts b/packages/cli/src/tokens/index.ts index 8560472598..c50f62ac31 100644 --- a/packages/cli/src/tokens/index.ts +++ b/packages/cli/src/tokens/index.ts @@ -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'; diff --git a/packages/cli/src/tokens/types.ts b/packages/cli/src/tokens/types.ts index 1b10af28cc..ded514d324 100644 --- a/packages/cli/src/tokens/types.ts +++ b/packages/cli/src/tokens/types.ts @@ -44,13 +44,7 @@ export type Collection = string | 'global'; export type Theme = { name: string; - colors: { - main: Record; - support: Record; - neutral: CssColor; - }; - typography: { - fontFamily: string; - }; + colors: Colors; + typography: Typography; borderRadius: number; }; diff --git a/packages/cli/test-tokens-create-complex.config.json b/packages/cli/test-tokens-create-complex.config.json new file mode 100644 index 0000000000..3b6814354c --- /dev/null +++ b/packages/cli/test-tokens-create-complex.config.json @@ -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 + } + } +} diff --git a/packages/cli/test-tokens-create.config.json b/packages/cli/test-tokens-create.config.json new file mode 100644 index 0000000000..764f188331 --- /dev/null +++ b/packages/cli/test-tokens-create.config.json @@ -0,0 +1,22 @@ +{ + "outDir": "./test-tokens-create", + "themes": { + "digdir": { + "colors": { + "main": { + "accent": "#0062BA" + }, + "support": { + "brand1": "#F45F63", + "brand2": "#E5AA20", + "brand3": "#1E98F5" + }, + "neutral": "#1E2B3C" + }, + "typography": { + "fontFamily": "Inter" + }, + "borderRadius": 99 + } + } +} diff --git a/yarn.lock b/yarn.lock index a9cea27bec..8a9b12d13b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1784,6 +1784,8 @@ __metadata: tsup: "npm:^8.2.4" tsx: "npm:^4.16.5" typescript: "npm:^5.5.4" + zod: "npm:^3.23.8" + zod-validation-error: "npm:^3.4.0" bin: designsystemet: dist/bin/designsystemet.js languageName: unknown @@ -18524,6 +18526,22 @@ __metadata: languageName: node linkType: hard +"zod-validation-error@npm:^3.4.0": + version: 3.4.0 + resolution: "zod-validation-error@npm:3.4.0" + peerDependencies: + zod: ^3.18.0 + checksum: 10/b98b1bbba14a3bb31649a1566c8c5a5213ec70dcaa2cbb1e89db00d56648a446225b35a8f6768471730d7013f4f141cd70c2b9740d69e6433ebfa148aecdac2f + languageName: node + linkType: hard + +"zod@npm:^3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 10/846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1 + languageName: node + linkType: hard + "zustand@npm:^4.5.4": version: 4.5.4 resolution: "zustand@npm:4.5.4"