diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fc05d..63063df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,21 @@ Prefix the change with one of these keywords: - `baseStyles.spacing.padding` accepts `x` (right and left) and `y` (top and bottom) alongside the four sides, and a side set on its own wins over its axis. Without an explicit `x`/`y`, an axis takes the value both of its sides share, so existing four-side configs get the axis tokens with no change. theme.json still receives the four sides. +- Generated token keys, so component libraries can pull token groups into their props, story options and utility classes instead of keeping the lists by hand. No config needed: + - `-tokens.js` exports `TokenKeys`: every category's keys exactly as they appear in the CSS variable names (kebab-case for `layout` and `viewport`), as identity maps that spread straight into a prop-class map. `-tokens.d.ts` types each map key by key, so a removed token fails type-checking wherever its key is still used. + + ```ts + import { mylibTokenKeys } from './mylib-tokens'; + + export const radiusClasses = { + none: 'none', + ...mylibTokenKeys.radius, + }; + export type RadiusKey = keyof typeof radiusClasses; + ``` + + - `_-variables.scss` follows each `scssVars` category with a map of the same tokens, e.g. `$mylib-spacing: ('x-small': $mylib-spacing-x-small, …)`, for `@each $step in map.keys(…)` loops. + ### Changed - `layout.css` no longer declares the root padding and block-gap custom properties in a `body { }` block. They keep their names and now live on `:root` in `tokens.css`, which `layout.css` already depended on for spacing values. The `.has-global-padding` rules are emitted only when horizontal root padding is configured. diff --git a/CLAUDE.md b/CLAUDE.md index 04ad6c8..b9735e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,10 @@ User-facing category names map to internal names via `INPUT_CATEGORY_MAP` (e.g. The exclusion logic for `settings.custom.*` lives in `theme-json.ts` (one `if (entry.cssOnly) continue;` guard in the custom loop). The fallback for baseStyles references lives in `resolveBaseStyleValueForThemeJson`, which only emits a preset var when `ref.wpPreset && ref.slug` are both truthy. +### Token Keys + +`tokens.js` also exports `{prefix}TokenKeys` — each category's keys exactly as they appear in CSS variable names (kebab-case for `directMap` categories), as identity maps (`{ sm: 'sm' }`) typed key by key in `tokens.d.ts`, so a component library can spread a group into its own prop-class map — and `_variables.scss` follows each `scssVars` category with a `$prefix-{segment}` map of the same tokens. Both are derived from the token table with no config option. The derived `root` spacing group isn't a category and has no key map. + ### Base Styles `baseStyles` in config generates two outputs from the same data: diff --git a/dist/generators/tokens-js.d.ts.map b/dist/generators/tokens-js.d.ts.map index 7483d3f..cb5f45c 100644 --- a/dist/generators/tokens-js.d.ts.map +++ b/dist/generators/tokens-js.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"tokens-js.d.ts","sourceRoot":"","sources":["../../src/generators/tokens-js.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA8B7C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAQ3D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CA2D1D"} \ No newline at end of file +{"version":3,"file":"tokens-js.d.ts","sourceRoot":"","sources":["../../src/generators/tokens-js.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAsD7C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAoB3D;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAuE1D"} \ No newline at end of file diff --git a/dist/generators/tokens-js.js b/dist/generators/tokens-js.js index 17d3a48..1b0c4ae 100644 --- a/dist/generators/tokens-js.js +++ b/dist/generators/tokens-js.js @@ -1,4 +1,4 @@ -import { CATEGORY_REGISTRY, CATEGORY_ORDER, DEFAULT_FLUID, kebabToCamel } from '../types.js'; +import { CATEGORY_REGISTRY, CATEGORY_ORDER, DEFAULT_FLUID, camelToKebab, kebabToCamel } from '../types.js'; import { buildFluidClamp } from './fluid.js'; import { buildRootTokens } from './root-tokens.js'; const VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; @@ -22,12 +22,43 @@ function toJsIdentifier(key) { const camel = kebabToCamel(key).replace(/-+([0-9])/g, '$1'); return camel.replace(/^(\d+)([A-Za-z])/, (_, digits, letter) => letter.toLowerCase() + digits); } +/** + * Each category's token keys exactly as they appear in the CSS variable names + * (`--{prefix}--{segment}-{key}`), so a component library can build class-name + * props, story options and utility classes from the config instead of copying + * the lists by hand. The derived `root` group isn't a category and is excluded. + */ +function collectTokenKeys(config) { + const result = []; + for (const category of CATEGORY_ORDER) { + const group = config.tokens[category]; + if (!group) + continue; + const def = CATEGORY_REGISTRY[category]; + // Layout keys are camelCase in config but kebab-case in CSS variables + const keys = Object.keys(group).map(key => (def.directMap ? camelToKebab(key) : key)); + if (keys.length > 0) { + result.push({ jsKey: toJsIdentifier(def.cssSegment), keys }); + } + } + return result; +} export function generateTokensDts(config) { + const name = toJsIdentifier(config.prefix); + // Each map is typed key by key, so `keyof typeof keys.radius` is the union of + // real keys and a removed token fails type-checking where it's used. + const keyLines = collectTokenKeys(config).map(({ jsKey, keys }) => { + const members = keys.map(key => `readonly ${emitKey(key)}: ${quote(key)}`).join('; '); + return ` readonly ${emitKey(jsKey)}: { ${members} };`; + }); return [ '/* Auto-generated by component2block — do not edit manually */', '', - `export declare const ${toJsIdentifier(config.prefix)}Tokens: Record>;`, - `export default ${toJsIdentifier(config.prefix)}Tokens;`, + `export declare const ${name}Tokens: Record>;`, + `export declare const ${name}TokenKeys: {`, + ...keyLines, + '};', + `export default ${name}Tokens;`, '', ].join('\n'); } @@ -77,6 +108,17 @@ export function generateTokensJs(config) { }); lines.push('};'); lines.push(''); + // Identity maps ({ sm: 'sm' }) so a component library can spread a category + // straight into its own prop-class map: { none: 'none', ...keys.radius }. + const tokenKeys = collectTokenKeys(config); + lines.push(`export const ${toJsIdentifier(config.prefix)}TokenKeys = {`); + tokenKeys.forEach(({ jsKey, keys }, catIdx) => { + const lastCat = catIdx === tokenKeys.length - 1; + const entries = keys.map(key => `${emitKey(key)}: ${quote(key)}`).join(', '); + lines.push(` ${emitKey(jsKey)}: { ${entries} }${lastCat ? '' : ','}`); + }); + lines.push('};'); + lines.push(''); lines.push(`export default ${toJsIdentifier(config.prefix)}Tokens;`); lines.push(''); return lines.join('\n'); diff --git a/dist/generators/tokens-js.js.map b/dist/generators/tokens-js.js.map index e51ba75..8fe9737 100644 --- a/dist/generators/tokens-js.js.map +++ b/dist/generators/tokens-js.js.map @@ -1 +1 @@ -{"version":3,"file":"tokens-js.js","sourceRoot":"","sources":["../../src/generators/tokens-js.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"tokens-js.js","sourceRoot":"","sources":["../../src/generators/tokens-js.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/generators/tokens-scss.d.ts b/dist/generators/tokens-scss.d.ts index a78e5a1..b4ef79f 100644 --- a/dist/generators/tokens-scss.d.ts +++ b/dist/generators/tokens-scss.d.ts @@ -2,8 +2,9 @@ import type { C2bConfig, TokenGroup } from '../types.js'; /** * Generate `_variables.scss` content. Only emits categories listed in * `config.scssVars`, iterated in registry order so the file stays grouped - * consistently. Returns `null` when `scssVars` is empty — the caller should - * skip writing the file. + * consistently. Each category's variables are followed by a map of the same + * tokens, so Sass can loop over the keys. Returns `null` when `scssVars` is + * empty — the caller should skip writing the file. */ export declare function generateTokensScss(config: C2bConfig): string | null; /** diff --git a/dist/generators/tokens-scss.d.ts.map b/dist/generators/tokens-scss.d.ts.map index 46c166e..60e8811 100644 --- a/dist/generators/tokens-scss.d.ts.map +++ b/dist/generators/tokens-scss.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"tokens-scss.d.ts","sourceRoot":"","sources":["../../src/generators/tokens-scss.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CAkCnE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,EAAE,CAoC3F"} \ No newline at end of file +{"version":3,"file":"tokens-scss.d.ts","sourceRoot":"","sources":["../../src/generators/tokens-scss.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,CA2CnE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,EAAE,CAoC3F"} \ No newline at end of file diff --git a/dist/generators/tokens-scss.js b/dist/generators/tokens-scss.js index 2ff1b41..9531de1 100644 --- a/dist/generators/tokens-scss.js +++ b/dist/generators/tokens-scss.js @@ -3,8 +3,9 @@ import { buildFluidClamp } from './fluid.js'; /** * Generate `_variables.scss` content. Only emits categories listed in * `config.scssVars`, iterated in registry order so the file stays grouped - * consistently. Returns `null` when `scssVars` is empty — the caller should - * skip writing the file. + * consistently. Each category's variables are followed by a map of the same + * tokens, so Sass can loop over the keys. Returns `null` when `scssVars` is + * empty — the caller should skip writing the file. */ export function generateTokensScss(config) { const scssVars = config.scssVars ?? []; @@ -23,12 +24,20 @@ export function generateTokensScss(config) { const def = CATEGORY_REGISTRY[category]; lines.push(''); lines.push(`// ${def.label}`); + const mapEntries = []; for (const [key, entry] of Object.entries(group)) { const cssKey = def.directMap ? camelToKebab(key) : key; const varName = `$${config.prefix}-${def.cssSegment}-${cssKey}`; const clampValue = entry.fluid ? buildFluidClamp(entry, config.fluid ?? DEFAULT_FLUID) : null; lines.push(`${varName}: ${clampValue ?? entry.value};`); + mapEntries.push(` '${cssKey}': ${varName},`); } + // Keys are quoted so digit-leading ones like `3-x-small` stay strings + // instead of parsing as a number with a unit. + lines.push(''); + lines.push(`$${config.prefix}-${def.cssSegment}: (`); + lines.push(...mapEntries); + lines.push(');'); } if (selected.has('viewport')) { lines.push(...buildViewportMixins(config.prefix, config.tokens.viewport)); diff --git a/dist/generators/tokens-scss.js.map b/dist/generators/tokens-scss.js.map index 7d4abcd..7644d3b 100644 --- a/dist/generators/tokens-scss.js.map +++ b/dist/generators/tokens-scss.js.map @@ -1 +1 @@ -{"version":3,"file":"tokens-scss.js","sourceRoot":"","sources":["../../src/generators/tokens-scss.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"tokens-scss.js","sourceRoot":"","sources":["../../src/generators/tokens-scss.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/docs/config/README.md b/docs/config/README.md index 05aea37..7a1e6e0 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -285,7 +285,7 @@ Each breakpoint produces a **complementary pair** — `below-*` uses `<=`, `abov These mixins are SCSS-only. WordPress never sees them — it reads the `settings.viewport` values from theme.json and builds its own queries for `@mobile` / `@tablet` block styles. The mixins exist so your component CSS can change at the same widths the editor does. -**JavaScript** consumers get the raw values**JavaScript** consumers get the raw values from `tokens.js`, ready for `matchMedia`: +**JavaScript** consumers get the raw values from `tokens.js`, ready for `matchMedia`: ```js import tokens from './design-system-tokens.js'; diff --git a/docs/guides/tokens.md b/docs/guides/tokens.md index 406fa64..cb9087c 100644 --- a/docs/guides/tokens.md +++ b/docs/guides/tokens.md @@ -266,6 +266,48 @@ Reference the generated CSS variables in component SCSS: The variable pattern is always `--{prefix}--{css-segment}-{key}`. +### Token keys + +`tokens.js` also exports every category's keys, exactly as they appear in the CSS variable names. Each category is an identity map, so you can pull the groups you want into your component library's own prop-class maps: + +```js +export const mylibTokenKeys = { + spacing: { 'x-small': 'x-small', small: 'small', medium: 'medium', large: 'large' }, + radius: { sm: 'sm', md: 'md', lg: 'lg', full: 'full' }, + layout: { 'content-size': 'content-size', 'wide-size': 'wide-size' }, +}; +``` + +Spread a group into a map and add any options that aren't tokens, like `none`, alongside it. `tokens.d.ts` types each map key by key, so `keyof typeof` gives the full union: + +```tsx +import { mylibTokenKeys } from '../styles/mylib-tokens'; + +export const radiusClasses = { + none: 'none', + ...mylibTokenKeys.radius, +}; + +export type RadiusKey = keyof typeof radiusClasses; // 'none' | 'sm' | 'md' | 'lg' | 'full' +``` + +Storybook options can come from the same map with `Object.keys(radiusClasses)`. Remove a token from the config and every place that still uses its key fails type-checking, instead of compiling to a class with no rule behind it. + +On the Sass side, each category listed in `scssVars` also gets a map of the same tokens in `_{prefix}-variables.scss`. The keys are quoted, so digit-leading ones like `2-x-small` stay strings: + +```scss +@use 'sass:map'; +@use '../styles/mylib-variables' as ds; + +@each $step in map.keys(ds.$mylib-spacing) { + .mylib-stack--gap-#{$step} { + gap: var(--mylib--spacing-#{$step}); + } +} +``` + +Keys for `layout` and `viewport` are kebab-case (`content-size`), matching the CSS variables rather than the camelCase config keys. The root spacing tokens below aren't a category, so they have no key list. + ### Root spacing tokens `baseStyles.spacing` adds a group of tokens that isn't a token category: the root padding and block gap. `tokens.css` and `tokens.wp.css` declare them on `:root`, and `tokens.js` mirrors them as `root`. @@ -306,7 +348,7 @@ CSS custom properties don't work inside a `@media` condition. Opt into SCSS vari } ``` -Each breakpoint gives a complementary pair (`<=` below, `>` above), so nothing overlaps at the boundary — see [viewport](../config/README.md#viewport). `scssVars` accepts any category, so the same opt-in gives you `$mylib-spacing-md` for Sass math or `@media` arithmetic. +Each breakpoint gives a complementary pair (`<=` below, `>` above), so nothing overlaps at the boundary — see [viewport](../config/README.md#viewport). `scssVars` accepts any category, so the same opt-in gives you `$mylib-spacing-md` for Sass math or `@media` arithmetic, plus a `$mylib-spacing` map to loop over (see [Token keys](#token-keys)). ## Updating Tokens diff --git a/src/generators/tokens-js.ts b/src/generators/tokens-js.ts index f216ac7..7ecdc36 100644 --- a/src/generators/tokens-js.ts +++ b/src/generators/tokens-js.ts @@ -28,12 +28,48 @@ function toJsIdentifier(key: string): string { return camel.replace(/^(\d+)([A-Za-z])/, (_, digits, letter) => letter.toLowerCase() + digits); } +/** + * Each category's token keys exactly as they appear in the CSS variable names + * (`--{prefix}--{segment}-{key}`), so a component library can build class-name + * props, story options and utility classes from the config instead of copying + * the lists by hand. The derived `root` group isn't a category and is excluded. + */ +function collectTokenKeys(config: C2bConfig): Array<{ jsKey: string; keys: string[] }> { + const result: Array<{ jsKey: string; keys: string[] }> = []; + + for (const category of CATEGORY_ORDER) { + const group = config.tokens[category]; + if (!group) continue; + + const def = CATEGORY_REGISTRY[category]; + // Layout keys are camelCase in config but kebab-case in CSS variables + const keys = Object.keys(group).map(key => (def.directMap ? camelToKebab(key) : key)); + if (keys.length > 0) { + result.push({ jsKey: toJsIdentifier(def.cssSegment), keys }); + } + } + + return result; +} + export function generateTokensDts(config: C2bConfig): string { + const name = toJsIdentifier(config.prefix); + + // Each map is typed key by key, so `keyof typeof keys.radius` is the union of + // real keys and a removed token fails type-checking where it's used. + const keyLines = collectTokenKeys(config).map(({ jsKey, keys }) => { + const members = keys.map(key => `readonly ${emitKey(key)}: ${quote(key)}`).join('; '); + return ` readonly ${emitKey(jsKey)}: { ${members} };`; + }); + return [ '/* Auto-generated by component2block — do not edit manually */', '', - `export declare const ${toJsIdentifier(config.prefix)}Tokens: Record>;`, - `export default ${toJsIdentifier(config.prefix)}Tokens;`, + `export declare const ${name}Tokens: Record>;`, + `export declare const ${name}TokenKeys: {`, + ...keyLines, + '};', + `export default ${name}Tokens;`, '', ].join('\n'); } @@ -91,6 +127,18 @@ export function generateTokensJs(config: C2bConfig): string { lines.push(` }${lastCat ? '' : ','}`); }); + lines.push('};'); + lines.push(''); + + // Identity maps ({ sm: 'sm' }) so a component library can spread a category + // straight into its own prop-class map: { none: 'none', ...keys.radius }. + const tokenKeys = collectTokenKeys(config); + lines.push(`export const ${toJsIdentifier(config.prefix)}TokenKeys = {`); + tokenKeys.forEach(({ jsKey, keys }, catIdx) => { + const lastCat = catIdx === tokenKeys.length - 1; + const entries = keys.map(key => `${emitKey(key)}: ${quote(key)}`).join(', '); + lines.push(` ${emitKey(jsKey)}: { ${entries} }${lastCat ? '' : ','}`); + }); lines.push('};'); lines.push(''); lines.push(`export default ${toJsIdentifier(config.prefix)}Tokens;`); diff --git a/src/generators/tokens-scss.ts b/src/generators/tokens-scss.ts index fd6b0c6..9edd01c 100644 --- a/src/generators/tokens-scss.ts +++ b/src/generators/tokens-scss.ts @@ -5,8 +5,9 @@ import { buildFluidClamp } from './fluid.js'; /** * Generate `_variables.scss` content. Only emits categories listed in * `config.scssVars`, iterated in registry order so the file stays grouped - * consistently. Returns `null` when `scssVars` is empty — the caller should - * skip writing the file. + * consistently. Each category's variables are followed by a map of the same + * tokens, so Sass can loop over the keys. Returns `null` when `scssVars` is + * empty — the caller should skip writing the file. */ export function generateTokensScss(config: C2bConfig): string | null { const scssVars = config.scssVars ?? []; @@ -27,12 +28,21 @@ export function generateTokensScss(config: C2bConfig): string | null { lines.push(''); lines.push(`// ${def.label}`); + const mapEntries: string[] = []; for (const [key, entry] of Object.entries(group)) { const cssKey = def.directMap ? camelToKebab(key) : key; const varName = `$${config.prefix}-${def.cssSegment}-${cssKey}`; const clampValue = entry.fluid ? buildFluidClamp(entry, config.fluid ?? DEFAULT_FLUID) : null; lines.push(`${varName}: ${clampValue ?? entry.value};`); + mapEntries.push(` '${cssKey}': ${varName},`); } + + // Keys are quoted so digit-leading ones like `3-x-small` stay strings + // instead of parsing as a number with a unit. + lines.push(''); + lines.push(`$${config.prefix}-${def.cssSegment}: (`); + lines.push(...mapEntries); + lines.push(');'); } if (selected.has('viewport')) { diff --git a/tests/tokens-js.test.ts b/tests/tokens-js.test.ts index d5b965a..b9bdb7a 100644 --- a/tests/tokens-js.test.ts +++ b/tests/tokens-js.test.ts @@ -243,15 +243,23 @@ describe('generateTokensJs — hyphenated prefixes produce valid identifiers', ( }); }); -// Compiles the generated module and hands back the token object, so a test can +// Compiles the generated module and hands back one of its exports, so a test can // assert on real values instead of on substrings. Any syntax error in an emitted // key or value throws here — which is the point: every assertion that reads a -// value through this helper is also a guard that the output actually parses. -function evalTokens(output: string): Record> { +// value through these helpers is also a guard that the output actually parses. +function evalExport(output: string, suffix: 'Tokens' | 'TokenKeys'): unknown { const body = output - .replace(/^export const (\w+) = \{/m, 'const $1 = {') - .replace(/^export default (\w+);$/m, 'return $1;'); - return new Function(body)() as Record>; + .replace(/^export const /gm, 'const ') + .replace(/^export default (\w+)Tokens;$/m, `return $1${suffix};`); + return new Function(body)(); +} + +function evalTokens(output: string): Record> { + return evalExport(output, 'Tokens') as Record>; +} + +function evalTokenKeys(output: string): Record> { + return evalExport(output, 'TokenKeys') as Record>; } describe('generateTokensJs — emitted output is valid JavaScript', () => { @@ -356,3 +364,92 @@ describe('generateTokensJs — root spacing tokens', () => { expect(evalTokens(generateTokensJs({ ...rootConfig, baseStyles: undefined })).root).toBeUndefined(); }); }); + +describe('generateTokensJs — token keys', () => { + const keysConfig: C2bConfig = { + prefix: 'test', + srcDir: 'src/styles', + outputDir: 'dist/wp', + bundleFonts: false, + tokens: { + colorPalette: { + primary: { value: '#0073aa', slug: 'primary', name: 'Primary' }, + 'primary-hover': { value: '#005a87', cssOnly: true }, + }, + spacing: { + '2-x-small': { value: '0.25rem' }, + 'x-small': { value: '0.5rem' }, + }, + zIndex: { '100': { value: '100' } }, + layout: { contentSize: { value: '768px' }, wideSize: { value: '1200px' } }, + }, + baseStyles: { spacing: { padding: { x: 'x-small' } } }, + }; + + it('maps each category\'s keys to themselves, as they appear in the CSS variable names, without the root group', () => { + expect(evalTokenKeys(generateTokensJs(keysConfig))).toEqual({ + color: { primary: 'primary', 'primary-hover': 'primary-hover' }, + spacing: { '2-x-small': '2-x-small', 'x-small': 'x-small' }, + z: { '100': '100' }, + layout: { 'content-size': 'content-size', 'wide-size': 'wide-size' }, + }); + }); + + it('emits one line per category, quoting keys that are not identifiers', () => { + expect(generateTokensJs(keysConfig)).toContain( + " spacing: { '2-x-small': '2-x-small', 'x-small': 'x-small' },", + ); + }); + + it('spreads straight into a prop-class map', () => { + const gapClasses = { none: 'none', ...evalTokenKeys(generateTokensJs(keysConfig)).spacing }; + expect(Object.keys(gapClasses)).toEqual(['none', '2-x-small', 'x-small']); + }); + + it('emits categories in registry order', () => { + expect(Object.keys(evalTokenKeys(generateTokensJs(keysConfig)))).toEqual(['color', 'spacing', 'z', 'layout']); + }); + + it('leaves the camelCased tokens object as it was', () => { + expect(evalTokens(generateTokensJs(keysConfig)).spacing).toEqual({ x2Small: '0.25rem', xSmall: '0.5rem' }); + }); + + it('names the export after a hyphenated prefix', () => { + const output = generateTokensJs({ ...keysConfig, prefix: 'design-system' }); + expect(output).toContain('export const designSystemTokenKeys = {'); + expect(evalTokenKeys(output).spacing).toEqual({ '2-x-small': '2-x-small', 'x-small': 'x-small' }); + }); + + it('emits a valid empty object when no categories are defined', () => { + expect(evalTokenKeys(generateTokensJs({ ...keysConfig, tokens: {}, baseStyles: undefined }))).toEqual({}); + }); +}); + +describe('generateTokensDts — token keys', () => { + const dts = generateTokensDts({ + prefix: 'test', + srcDir: 'src/styles', + outputDir: 'dist/wp', + bundleFonts: false, + tokens: { + spacing: { '2-x-small': { value: '0.25rem' }, 'x-small': { value: '0.5rem' } }, + layout: { contentSize: { value: '768px' } }, + }, + }); + + it('types each category map key by key', () => { + expect(dts).toContain( + [ + 'export declare const testTokenKeys: {', + " readonly spacing: { readonly '2-x-small': '2-x-small'; readonly 'x-small': 'x-small' };", + " readonly layout: { readonly 'content-size': 'content-size' };", + '};', + ].join('\n'), + ); + }); + + it('keeps the tokens declaration loosely typed', () => { + expect(dts).toContain('export declare const testTokens: Record>;'); + expect(dts).toContain('export default testTokens;'); + }); +}); diff --git a/tests/tokens-scss.test.ts b/tests/tokens-scss.test.ts index de85328..07ebcbf 100644 --- a/tests/tokens-scss.test.ts +++ b/tests/tokens-scss.test.ts @@ -204,10 +204,20 @@ describe('generateTokensScss — snapshot', () => { '$rds-spacing-sm: 0.5rem;', '$rds-spacing-md: 1rem;', '', + '$rds-spacing: (', + " 'sm': $rds-spacing-sm,", + " 'md': $rds-spacing-md,", + ');', + '', '// Viewport', '$rds-viewport-mobile: 500px;', '$rds-viewport-tablet: 800px;', '', + '$rds-viewport: (', + " 'mobile': $rds-viewport-mobile,", + " 'tablet': $rds-viewport-tablet,", + ');', + '', '// Viewport media queries', '// Built from the settings.viewport breakpoints WordPress uses for responsive', '// block styles. Each below-/above- pair is an exact complement, so no viewport', @@ -311,3 +321,60 @@ describe('generateTokensScss — viewport media-query mixins', () => { expect(output).not.toContain('Viewport media queries'); }); }); + +describe('generateTokensScss — token maps', () => { + it("follows each category's variables with a map of the same tokens", () => { + const output = generateTokensScss({ + prefix: 'test', + srcDir: 'src/styles', + themeDir: 'dist/wp', + bundleFonts: false, + scssVars: ['spacing'], + tokens: { + spacing: { + '3-x-small': { value: '0.175rem' }, + md: { value: '1rem' }, + }, + }, + })!; + expect(output).toContain( + [ + '$test-spacing-md: 1rem;', + '', + '$test-spacing: (', + " '3-x-small': $test-spacing-3-x-small,", + " 'md': $test-spacing-md,", + ');', + ].join('\n'), + ); + }); + + it('uses kebab-case keys for the layout map', () => { + const output = generateTokensScss({ + prefix: 'test', + srcDir: 'src/styles', + themeDir: 'dist/wp', + bundleFonts: false, + scssVars: ['layout'], + tokens: { + layout: { contentSize: { value: '645px' } }, + }, + })!; + expect(output).toContain(" 'content-size': $test-layout-content-size,"); + }); + + it('emits no map for categories left out of scssVars', () => { + const output = generateTokensScss({ + prefix: 'test', + srcDir: 'src/styles', + themeDir: 'dist/wp', + bundleFonts: false, + scssVars: ['spacing'], + tokens: { + colorPalette: { primary: { value: '#0073aa' } }, + spacing: { md: { value: '1rem' } }, + }, + })!; + expect(output).not.toContain('$test-color'); + }); +});