Skip to content

Commit 1f314fa

Browse files
authored
feat(cli): generate config from tokens folder (#4207)
1 parent fa5f985 commit 1f314fa

5 files changed

Lines changed: 351 additions & 1 deletion

File tree

.changeset/polite-planes-lay.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@digdir/designsystemet": patch
3+
---
4+
5+
New command that lets you generate a config file from your design tokens:
6+
`npx @digdir/designsystemet generate-config-from-tokens --dir <path to design tokens>`
7+
- This command does not include any overrides you may have done.

packages/cli/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,11 @@ npx @digdir/designsystemet tokens build
105105
#### Complex config example
106106

107107
Have a look at the `*.config.json` files under the `packages/cli` in the Github repo for more complex examples.
108+
109+
#### Create config from existing tokens
110+
111+
You can get a minimal config file, meaning without overrides, generated from existing design tokens using the following command:
112+
113+
```sh
114+
npx @digdir/designsystemet generate-config-from-tokens --dir <path to design tokens>
115+
```

packages/cli/bin/designsystemet.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import migrations from '../src/migrations/index.js';
88
import { buildTokens } from '../src/tokens/build.js';
99
import { writeTokens } from '../src/tokens/create/write.js';
1010
import { cliOptions, createTokens } from '../src/tokens/create.js';
11+
import { generateConfigFromTokens } from '../src/tokens/generate-config.js';
1112
import type { Theme } from '../src/tokens/types.js';
1213
import { cleanDir } from '../src/utils.js';
1314
import { parseBuildConfig, parseCreateConfig, readConfigFile } from './config.js';
@@ -127,6 +128,37 @@ function makeTokenCommands() {
127128

128129
program.addCommand(makeTokenCommands());
129130

131+
program
132+
.command('generate-config-from-tokens')
133+
.description('Generate a config file from existing design tokens. Will not include overrides.')
134+
.option('-d, --dir <string>', 'Path to design tokens directory', DEFAULT_TOKENS_CREATE_DIR)
135+
.option('-o, --out <string>', 'Output path for config file', DEFAULT_CONFIG_FILE)
136+
.option('--dry [boolean]', 'Dry run - show config without writing file', parseBoolean, false)
137+
.action(async (opts) => {
138+
console.log(figletAscii);
139+
const { dry } = opts;
140+
const tokensDir = typeof opts.dir === 'string' ? opts.dir : DEFAULT_TOKENS_CREATE_DIR;
141+
const outFile = typeof opts.out === 'string' ? opts.out : DEFAULT_CONFIG_FILE;
142+
143+
try {
144+
const config = await generateConfigFromTokens({
145+
tokensDir,
146+
outFile: dry ? undefined : outFile,
147+
dry,
148+
});
149+
150+
if (dry) {
151+
console.log();
152+
console.log('Generated config (dry run):');
153+
console.log(JSON.stringify(config, null, 2));
154+
}
155+
} catch (error) {
156+
console.error(pc.redBright('Error generating config:'));
157+
console.error(error instanceof Error ? error.message : String(error));
158+
process.exit(1);
159+
}
160+
});
161+
130162
program
131163
.command('migrate')
132164
.description('run a Designsystemet migration')

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
"test:tokens-build-config-tailwind": "pnpm run designsystemet tokens build -t ./temp/config/design-tokens -o ./temp/config/build --clean --experimental-tailwind",
5353
"test:tokens-create-and-build-options": "pnpm test:tokens-create-options && pnpm test:tokens-build",
5454
"test:tokens-create-and-build-config": "pnpm test:tokens-create-config && pnpm test:tokens-build-config",
55-
"test": "node -v && pnpm test:tokens-create-and-build-options && pnpm test:tokens-create-and-build-config",
55+
"test:generate-config-from-tokens": "pnpm run designsystemet generate-config-from-tokens -d ../../internal/design-tokens --dry",
56+
"test": "node -v && pnpm test:tokens-create-and-build-options && pnpm test:generate-config-from-tokens && pnpm test:tokens-create-and-build-config",
5657
"digdir:tokens-build": "pnpm run designsystemet tokens build -t ../../internal/design-tokens -o ../../packages/theme/brand --clean --experimental-tailwind",
5758
"digdir:tokens-create": "pnpm run designsystemet tokens create --config ./configs/digdir.config.json",
5859
"update:template": "tsx ./src/scripts/update-template.ts",
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
import pc from 'picocolors';
4+
import type { CssColor } from '../colors/types.js';
5+
import type { CreateConfigSchema } from '../config.js';
6+
7+
type TokenValue = {
8+
$type: string;
9+
$value: string;
10+
};
11+
12+
type TokenObject = {
13+
[key: string]: TokenValue | TokenObject;
14+
};
15+
16+
/**
17+
* Reads a JSON file and returns its content as an object
18+
*/
19+
async function readJsonFile(filePath: string): Promise<TokenObject> {
20+
try {
21+
const content = await fs.readFile(filePath, 'utf-8');
22+
return JSON.parse(content) as TokenObject;
23+
} catch (err) {
24+
throw new Error(`Failed to read token file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
25+
}
26+
}
27+
28+
/**
29+
* Extract the base hex color from a color scale (color.12)
30+
*/
31+
function extractBaseColor(colorScale: TokenObject): string | null {
32+
if ('12' in colorScale && typeof colorScale['12'] === 'object' && '$value' in colorScale['12']) {
33+
const token = colorScale['12'] as TokenValue;
34+
if (token.$type === 'color') {
35+
return token.$value;
36+
}
37+
}
38+
return null;
39+
}
40+
41+
/**
42+
* Discovers theme names from the primitives/modes/color-scheme/light/
43+
*/
44+
async function discoverThemes(tokensDir: string): Promise<string[]> {
45+
const lightModePath = path.join(tokensDir, 'themes');
46+
47+
try {
48+
const files = await fs.readdir(lightModePath);
49+
const themes = files.filter((file) => file.endsWith('.json')).map((file) => file.replace('.json', ''));
50+
51+
return themes;
52+
} catch {
53+
throw new Error(`Could not find themes. Make sure ${pc.blue(lightModePath)} exists and contains theme JSON files.`);
54+
}
55+
}
56+
57+
/**
58+
* Reads token information for a specific theme from primitives/modes/color-scheme/light/<theme>.json
59+
*/
60+
async function readThemeTokens(tokensDir: string, themeName: string): Promise<TokenObject> {
61+
const themePath = path.join(tokensDir, 'primitives', 'modes', 'color-scheme', 'light', `${themeName}.json`);
62+
return readJsonFile(themePath);
63+
}
64+
65+
/**
66+
* Reads the theme configuration from themes/<theme>.json
67+
*/
68+
async function readThemeConfig(tokensDir: string, themeName: string): Promise<TokenObject | null> {
69+
const themeConfigPath = path.join(tokensDir, 'themes', `${themeName}.json`);
70+
71+
try {
72+
return await readJsonFile(themeConfigPath);
73+
} catch {
74+
return null;
75+
}
76+
}
77+
78+
/**
79+
* Extract border-radius base value from theme config
80+
*/
81+
function extractBorderRadius(themeConfig: TokenObject | null): number | undefined {
82+
if (!themeConfig || !('border-radius' in themeConfig)) {
83+
return undefined;
84+
}
85+
86+
const borderRadius = themeConfig['border-radius'] as TokenObject;
87+
if ('base' in borderRadius && typeof borderRadius.base === 'object' && '$value' in borderRadius.base) {
88+
const token = borderRadius.base as TokenValue;
89+
return Number(token.$value);
90+
}
91+
92+
return undefined;
93+
}
94+
95+
/**
96+
* Extract font family from theme config
97+
*/
98+
function extractFontFamily(themeConfig: TokenObject | null): string | undefined {
99+
if (!themeConfig || !('font-family' in themeConfig)) {
100+
return undefined;
101+
}
102+
103+
const fontFamily = themeConfig['font-family'];
104+
if (typeof fontFamily === 'object' && '$value' in fontFamily) {
105+
const token = fontFamily as TokenValue;
106+
const value = token.$value;
107+
108+
if (value.startsWith('{') && value.endsWith('}')) {
109+
return undefined;
110+
}
111+
return value;
112+
}
113+
114+
return undefined;
115+
}
116+
117+
/**
118+
* Reads the typography configuration from primitives/modes/typography/primary/<theme>.json
119+
*/
120+
async function readTypographyConfig(tokensDir: string, themeName: string): Promise<TokenObject | null> {
121+
const typographyConfigPath = path.join(
122+
tokensDir,
123+
'primitives',
124+
'modes',
125+
'typography',
126+
'primary',
127+
`${themeName}.json`,
128+
);
129+
130+
try {
131+
return await readJsonFile(typographyConfigPath);
132+
} catch {
133+
return null;
134+
}
135+
}
136+
137+
/**
138+
* Extract font family from typography primitives
139+
*/
140+
function extractFontFamilyFromPrimitives(typographyConfig: TokenObject | null, themeName: string): string | undefined {
141+
if (!typographyConfig) {
142+
return undefined;
143+
}
144+
145+
const themeTypography = typographyConfig[themeName] as TokenObject | undefined;
146+
if (!themeTypography || !('font-family' in themeTypography)) {
147+
return undefined;
148+
}
149+
150+
const fontFamily = themeTypography['font-family'];
151+
if (typeof fontFamily === 'object' && '$value' in fontFamily) {
152+
const token = fontFamily as TokenValue;
153+
return token.$value;
154+
}
155+
156+
return undefined;
157+
}
158+
159+
/**
160+
* Categorizes colors into main, support, and neutral based on color names
161+
*/
162+
function categorizeColors(
163+
themeTokens: TokenObject,
164+
themeName: string,
165+
): {
166+
main: Record<string, CssColor>;
167+
support: Record<string, CssColor>;
168+
neutral: CssColor | null;
169+
} {
170+
const main: Record<string, CssColor> = {};
171+
const support: Record<string, CssColor> = {};
172+
let neutral: CssColor | null = null;
173+
174+
// Reserved colors
175+
const builtInColors = ['neutral', 'info', 'success', 'warning', 'danger'];
176+
const specialKeys = ['link'];
177+
178+
const themeColors = themeTokens[themeName] as TokenObject | undefined;
179+
if (!themeColors) {
180+
return { main, support, neutral };
181+
}
182+
183+
for (const [colorName, colorValue] of Object.entries(themeColors)) {
184+
if (specialKeys.includes(colorName)) {
185+
continue;
186+
}
187+
188+
if (typeof colorValue === 'object' && !('$value' in colorValue)) {
189+
const baseColor = extractBaseColor(colorValue as TokenObject);
190+
191+
if (baseColor) {
192+
if (colorName === 'neutral') {
193+
neutral = baseColor as CssColor;
194+
} else if (builtInColors.includes(colorName)) {
195+
} else if (colorName === 'accent') {
196+
// Accent is typically the main color
197+
main[colorName] = baseColor as CssColor;
198+
} else {
199+
// All other colors are support colors (brand1, brand2, etc.)
200+
support[colorName] = baseColor as CssColor;
201+
}
202+
}
203+
}
204+
}
205+
206+
return { main, support, neutral };
207+
}
208+
209+
export type GenerateConfigOptions = {
210+
tokensDir: string;
211+
outFile?: string;
212+
dry?: boolean;
213+
};
214+
215+
/**
216+
* Generates a config file from existing design tokens
217+
*/
218+
export async function generateConfigFromTokens(options: GenerateConfigOptions): Promise<CreateConfigSchema> {
219+
const { tokensDir, dry = false } = options;
220+
221+
console.log(`\nReading tokens from ${pc.blue(tokensDir)}`);
222+
223+
// Discover themes
224+
const themes = await discoverThemes(tokensDir);
225+
226+
if (themes.length === 0) {
227+
throw new Error(`\nNo themes found in ${pc.blue(tokensDir)}`);
228+
}
229+
230+
console.log(`\nFound ${pc.green(String(themes.length))} theme(s): ${themes.map((t) => pc.cyan(t)).join(', ')}`);
231+
232+
// Generate config for each theme
233+
const config: CreateConfigSchema = {
234+
outDir: tokensDir,
235+
themes: {},
236+
};
237+
238+
for (const themeName of themes) {
239+
console.log(`\nProcessing theme ${pc.cyan(themeName)}...`);
240+
241+
// Read theme tokens
242+
const themeTokens = await readThemeTokens(tokensDir, themeName);
243+
const themeConfig = await readThemeConfig(tokensDir, themeName);
244+
const typographyConfig = await readTypographyConfig(tokensDir, themeName);
245+
246+
// Extract colors
247+
const { main, support, neutral } = categorizeColors(themeTokens, themeName);
248+
249+
if (Object.keys(main).length === 0) {
250+
console.warn(pc.yellow(`\nWarning: No main colors found for theme ${themeName}`));
251+
}
252+
253+
if (!neutral) {
254+
console.warn(pc.yellow(`\nWarning: No neutral color found for theme ${themeName}`));
255+
continue; // Skip this theme as neutral is required
256+
}
257+
258+
const borderRadius = extractBorderRadius(themeConfig);
259+
const fontFamily = extractFontFamily(themeConfig) ?? extractFontFamilyFromPrimitives(typographyConfig, themeName);
260+
261+
config.themes[themeName] = {
262+
colors: {
263+
main,
264+
support,
265+
neutral,
266+
},
267+
borderRadius,
268+
typography: fontFamily ? { fontFamily } : undefined,
269+
};
270+
271+
console.log(
272+
`\n✅ Main colors: ${
273+
Object.keys(main)
274+
.map((c) => pc.cyan(c))
275+
.join(', ') || pc.dim('none')
276+
}`,
277+
);
278+
console.log(
279+
`\n✅ Support colors: ${
280+
Object.keys(support)
281+
.map((c) => pc.cyan(c))
282+
.join(', ') || pc.dim('none')
283+
}`,
284+
);
285+
console.log(`\n✅ Neutral: ${pc.cyan(neutral)}`);
286+
if (borderRadius !== undefined) {
287+
console.log(`\n✅ Border radius: ${pc.cyan(String(borderRadius))}`);
288+
}
289+
if (fontFamily) {
290+
console.log(`\n✅ Font family: ${pc.cyan(fontFamily)}`);
291+
}
292+
}
293+
294+
if (!dry && options.outFile) {
295+
const configJson = JSON.stringify(config, null, 2);
296+
await fs.writeFile(options.outFile, configJson, 'utf-8');
297+
console.log();
298+
console.log(`\n✅ Config file written to ${pc.blue(options.outFile)}`);
299+
}
300+
301+
return config;
302+
}

0 commit comments

Comments
 (0)