Skip to content

Commit ea9cae3

Browse files
authored
fix(eds-tokens): emit nested typography TS with inlined size extras (#4915)
* fix(eds-tokens): emit nested typography TS with inlined size extras Closes #4901. The per-axis TS files (font-weight-*, line-height-*, tracking-*) and the per-size files (font-size-*) silently picked one cell of the family × size matrix because Style Dictionary cannot represent runtime mode switching. These files are now removed; the two font-family files become the only TS surface. Each size cell in font-family-{ui,header}.ts is now self-contained: nested fontWeight/tracking/lineHeight objects (so consumers can derive variant types via keyof) plus inlined iconSize/gapHorizontal/gapVertical extras. Implementation: - New splitLeafPrefixes option on typescriptNestedFormat splits hyphenated leaf segments (font-weight-lighter → fontWeight.lighter) before nesting. Affects TS output only; CSS variable names unchanged. - Per-size builds still emit temporary TS files which a post-step parses for the three family-independent extras and splices into each size cell of both family files. The regex is line-anchored to avoid matching nested-axis closers and throws if a size cell can't be located. - Smoke tests in src/__tests__/typography-shape.test.ts assert the emitted structure: every size cell has the expected nested keys, inlined extras are family-independent (ui matches header per size), and keyof narrows to the expected literal types. * docs(eds-tokens): document the nested typography TS surface - README.md: add a "Typography (non-CSS targets)" subsection describing direct matrix import via @equinor/eds-tokens/ts/typography/font-family-*, with a keyof example for variant types and a React Native note. Also fix pre-existing data-text-size → data-font-size typos in the data attribute examples. - CLAUDE.md: expand the Typography output section to explain the nested shape, the splitLeafPrefixes mechanism, the build-time splice of family-independent size extras, and the build-order dependency on build/ts/typography/. - instructions/typography.md: replace the previous TypeScript Tokens section (which documented importing the now-removed per-axis files) with Direct token import + Deriving variant types + React Native subsections, plus a "What's not exported" note explaining why other axis files don't ship as TS. * refactor(eds-tokens): apply review feedback on typography build Per Claude review on #4915: - Replace the local sizeKeyToCamel map with a call to toCamelCase (re-exported from @equinor/eds-tokens-build) so the size-key → identifier conversion has a single source of truth. - Derive the splice loop's family list from fontFamilyConfig instead of a hardcoded ['ui', 'header'] literal — adding a third family now automatically extends the splice. - Add a smoke-test assertion that build/ts/typography/ contains only the two family files, catching cleanup-whitelist regressions. - Document splitLeafPrefixes first-match-wins semantics on BuildNestedObjectOptions. Emitted font-family-{ui,header}.ts files are byte-identical to before the refactor — verified by diff against pre-refactor snapshots.
1 parent 7226fc4 commit ea9cae3

35 files changed

Lines changed: 1490 additions & 1156 deletions

packages/eds-tokens-build/src/__tests__/typescriptNested.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,108 @@ describe('typescriptNested format', () => {
123123
},
124124
})
125125
})
126+
127+
describe('with splitLeafPrefixes', () => {
128+
it('splits a matching hyphenated leaf into two nested segments', () => {
129+
const tokens = [
130+
mockToken(['font-family-size', 'xs', 'font-weight-lighter'], '300'),
131+
mockToken(['font-family-size', 'xs', 'font-weight-normal'], '400'),
132+
mockToken(['font-family-size', 'xs', 'font-weight-bolder'], '500'),
133+
] as TransformedToken[]
134+
135+
const result = buildNestedObject(tokens, {
136+
splitLeafPrefixes: ['font-weight'],
137+
})
138+
139+
expect(result).toEqual({
140+
fontFamilySize: {
141+
xs: {
142+
fontWeight: {
143+
lighter: '300',
144+
normal: '400',
145+
bolder: '500',
146+
},
147+
},
148+
},
149+
})
150+
})
151+
152+
it('leaves leaves matching the prefix exactly (no suffix) alone', () => {
153+
const tokens = [
154+
mockToken(['font-family-size', 'xs', 'font-size'], '12'),
155+
] as TransformedToken[]
156+
157+
const result = buildNestedObject(tokens, {
158+
splitLeafPrefixes: ['font-size'],
159+
})
160+
161+
expect(result).toEqual({
162+
fontFamilySize: {
163+
xs: {
164+
fontSize: '12',
165+
},
166+
},
167+
})
168+
})
169+
170+
it('handles multiple prefixes on different tokens', () => {
171+
const tokens = [
172+
mockToken(['size', 'xs', 'font-weight-bolder'], '500'),
173+
mockToken(['size', 'xs', 'tracking-tight'], '-1'),
174+
mockToken(['size', 'xs', 'line-height-default'], '16'),
175+
mockToken(['size', 'xs', 'font-size'], '12'),
176+
] as TransformedToken[]
177+
178+
const result = buildNestedObject(tokens, {
179+
splitLeafPrefixes: ['font-weight', 'tracking', 'line-height'],
180+
})
181+
182+
expect(result).toEqual({
183+
size: {
184+
xs: {
185+
fontWeight: { bolder: '500' },
186+
tracking: { tight: '-1' },
187+
lineHeight: { default: '16' },
188+
fontSize: '12',
189+
},
190+
},
191+
})
192+
})
193+
194+
it('only splits the leaf segment, not intermediate segments', () => {
195+
const tokens = [
196+
mockToken(['font-weight-lighter', 'xs'], '300'),
197+
] as TransformedToken[]
198+
199+
const result = buildNestedObject(tokens, {
200+
splitLeafPrefixes: ['font-weight'],
201+
})
202+
203+
expect(result).toEqual({
204+
fontWeightLighter: { xs: '300' },
205+
})
206+
})
207+
208+
it('is a no-op when no prefix matches', () => {
209+
const tokens = [
210+
mockToken(['size', 'xs', 'font-size'], '12'),
211+
mockToken(['size', 'xs', 'icon-size'], '16'),
212+
] as TransformedToken[]
213+
214+
const result = buildNestedObject(tokens, {
215+
splitLeafPrefixes: ['font-weight', 'tracking'],
216+
})
217+
218+
expect(result).toEqual({
219+
size: {
220+
xs: {
221+
fontSize: '12',
222+
iconSize: '16',
223+
},
224+
},
225+
})
226+
})
227+
})
126228
})
127229

128230
describe('typescriptNestedFormat', () => {
@@ -286,6 +388,53 @@ describe('typescriptNested format', () => {
286388
// Pure numeric keys should still be quoted
287389
expect(result).toContain("'1': '#111111'")
288390
})
391+
392+
it('passes splitLeafPrefixes through to the nested builder', () => {
393+
const tokens = [
394+
mockToken(['font-family-size', 'xs', 'font-weight-lighter'], '300'),
395+
mockToken(['font-family-size', 'xs', 'font-weight-normal'], '400'),
396+
mockToken(['font-family-size', 'xs', 'tracking-tight'], '-1.15'),
397+
mockToken(['font-family-size', 'xs', 'tracking-normal'], '0'),
398+
mockToken(['font-family-size', 'xs', 'line-height-default'], '16'),
399+
mockToken(['font-family-size', 'xs', 'font-size'], '12'),
400+
] as TransformedToken[]
401+
402+
const result = typescriptNestedFormat({
403+
dictionary: { allTokens: tokens, tokens: {}, unfilteredTokens: {} },
404+
options: {
405+
rootName: 'typography',
406+
splitLeafPrefixes: ['font-weight', 'tracking', 'line-height'],
407+
},
408+
file: { destination: 'test.ts' },
409+
platform: {},
410+
} as unknown as FormatFnArguments)
411+
412+
expect(result).toMatchInlineSnapshot(`
413+
"/**
414+
* Do not edit directly, this file was auto-generated.
415+
*/
416+
417+
export const typography = {
418+
fontFamilySize: {
419+
xs: {
420+
fontWeight: {
421+
lighter: 300,
422+
normal: 400,
423+
},
424+
tracking: {
425+
tight: -1.15,
426+
normal: 0,
427+
},
428+
lineHeight: {
429+
default: 16,
430+
},
431+
fontSize: 12,
432+
},
433+
},
434+
} as const
435+
"
436+
`)
437+
})
289438
})
290439
})
291440

packages/eds-tokens-build/src/format/typescriptNested.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,61 @@ function formatKey(key: string): string {
5454

5555
type NestedObject = { [key: string]: NestedObject | string }
5656

57+
export type BuildNestedObjectOptions = {
58+
/**
59+
* Hyphenated prefixes (e.g. `'font-weight'`) that, when found at the start of a
60+
* token-path leaf, are split into two segments. `font-weight-lighter` becomes
61+
* `['font-weight', 'lighter']`, producing nested output `fontWeight: { lighter }`
62+
* instead of flat `fontWeightLighter`.
63+
*
64+
* Useful when the source token JSON encodes axis variants as hyphenated leaf
65+
* keys, and the consuming code wants the axis exposed as a nested object so
66+
* variant names can be derived via `keyof`.
67+
*
68+
* If multiple prefixes could match a leaf (e.g. `'font-weight'` and
69+
* `'font-weight-extra'`), array iteration order wins — first match applied.
70+
*/
71+
splitLeafPrefixes?: readonly string[]
72+
}
73+
74+
/**
75+
* If the leaf of `path` starts with one of the given hyphenated prefixes, split
76+
* it into `[prefix, rest]` and return a new path. Otherwise return `path`
77+
* unchanged. The match requires the prefix be followed by `-` and at least one
78+
* more character, so a bare leaf equal to the prefix (e.g. `font-size`) is left
79+
* alone.
80+
*/
81+
function splitLeafForPrefixes(
82+
path: string[],
83+
prefixes: readonly string[],
84+
): string[] {
85+
if (path.length === 0) return path
86+
const leaf = path[path.length - 1]
87+
for (const prefix of prefixes) {
88+
const marker = `${prefix}-`
89+
if (leaf.startsWith(marker) && leaf.length > marker.length) {
90+
return [...path.slice(0, -1), prefix, leaf.slice(marker.length)]
91+
}
92+
}
93+
return path
94+
}
95+
5796
/**
5897
* Build a nested object from Style Dictionary tokens using their path arrays.
5998
* Each path segment is converted to camelCase.
6099
*/
61-
export function buildNestedObject(tokens: TransformedToken[]): NestedObject {
100+
export function buildNestedObject(
101+
tokens: TransformedToken[],
102+
options?: BuildNestedObjectOptions,
103+
): NestedObject {
62104
const root: NestedObject = {}
105+
const splitPrefixes = options?.splitLeafPrefixes
63106

64107
for (const token of tokens) {
65-
const segments = token.path.map(toCamelCase)
108+
const rawPath = splitPrefixes
109+
? splitLeafForPrefixes(token.path, splitPrefixes)
110+
: token.path
111+
const segments = rawPath.map(toCamelCase)
66112
let current = root
67113

68114
for (let i = 0; i < segments.length - 1; i++) {
@@ -115,13 +161,19 @@ function serializeObject(obj: NestedObject, indent = 2): string {
115161
*
116162
* Options:
117163
* - rootName: the export name (e.g. "color"). Defaults to "tokens".
164+
* - splitLeafPrefixes: optional list of hyphenated prefixes (e.g. `'font-weight'`)
165+
* that should be split into a nested key when found at the start of a leaf path
166+
* segment. See `BuildNestedObjectOptions`.
118167
*/
119168
export function typescriptNestedFormat({
120169
dictionary,
121170
options,
122171
}: FormatFnArguments): string {
123172
const rootName = (options?.rootName as string) ?? 'tokens'
124-
const nested = buildNestedObject(dictionary.allTokens)
173+
const splitLeafPrefixes = options?.splitLeafPrefixes as
174+
| readonly string[]
175+
| undefined
176+
const nested = buildNestedObject(dictionary.allTokens, { splitLeafPrefixes })
125177

126178
const header = `/**\n * Do not edit directly, this file was auto-generated.\n */\n`
127179

packages/eds-tokens-build/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export { FONT_QUOTE_NAME } from './transform/fontQuote'
33
export { PX_FORMATTED_NAME } from './transform/pxFormatted'
44
export { PX_TO_REM_NAME } from './transform/pxToRem'
55
export { _extend, tsBuildPath } from './utils'
6-
export { typescriptNestedFormat } from './format/typescriptNested'
6+
export { typescriptNestedFormat, toCamelCase } from './format/typescriptNested'
77
export { createLightDarkTransform } from './transform/lightDark'
88
export { PX_TRANSFORM_NAME } from './transform/pxTransform'
99
export { fontQuote } from './transform/fontQuote'

packages/eds-tokens/CLAUDE.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,43 @@ Five independent axes, each controlled by a `data-*` attribute:
109109
- **Line height** (`🅰️ Line height.*.json`) — `data-line-height`: `default`, `squished`
110110
- **Tracking** (`🅰️ Tracking.*.json`) — `data-tracking`: `tight`, `normal`, `wide`, `loose`
111111

112-
Output: `build/css/typography/` (CSS) and `build/ts/typography/` (TypeScript nested objects)
112+
Output: `build/css/typography/` (CSS, all five axes) and `build/ts/typography/` (TypeScript: `font-family-{ui,header}.ts` only — two self-contained matrices, one per family).
113+
114+
#### Why TS output is minimal
115+
116+
Style Dictionary cannot represent runtime mode switching (which is what `data-*` cascade gives the CSS output). A TS file for `tracking-wide`, `font-weight-normal`, `line-height-default`, or any single `font-size-md` row would have to bake one cell from the size × family matrix and silently be wrong for any other combination. The build therefore emits TS only for the two family matrices.
117+
118+
Each emitted file is shaped so consumers can read all five axes off a single size cell:
119+
120+
```ts
121+
typography.fontFamilySize.md = {
122+
fontSize: 14,
123+
tracking: { tight, normal, wide },
124+
fontWeight: { lighter, normal, bolder },
125+
lineHeight: { default, squished },
126+
iconSize: 20, // family-independent, spliced in at build time
127+
gapHorizontal: 8.5,
128+
gapVertical: 8.5,
129+
}
130+
```
131+
132+
The nested axes are produced via `splitLeafPrefixes` on the `typescriptNestedFormat` (declared in `eds-tokens-build`). Source JSON encodes axis variants as hyphenated leaves (`font-weight-lighter`); the format splits those into two segments before nesting. CSS output is unaffected — its format uses the unsplit path for variable naming.
133+
134+
Variant names are derivable directly from the data:
135+
136+
```ts
137+
type Weight = keyof typeof ui.fontFamilySize.md.fontWeight // 'lighter' | 'normal' | 'bolder'
138+
```
139+
140+
Figma remains the single source of truth — when a Figma sync regenerates the family files, consumer types track automatically.
141+
142+
#### Build-time splicing of size extras
143+
144+
`createSpacingAndTypographyVariables.ts` runs the per-size font-size builds with `rootName`/`tsBuildPath` set, which produces 10 temporary `font-size-{xs..6xl}.ts` files. A post-step parses each for `iconSize`/`gapHorizontal`/`gapVertical` (Style Dictionary emits them with already-resolved numeric values), injects the three values into the corresponding size cell of `font-family-{ui,header}.ts`, and deletes the temporary per-size files. The injection uses a line-anchored regex over content this same script just emitted, and throws if a size cell can't be located — failures are loud rather than silent. This pragmatic choice avoids the awkwardness of cross-source aggregation in Style Dictionary's resolved-value APIs.
145+
146+
#### Build-order dependency
147+
148+
The smoke test in `src/__tests__/typography-shape.test.ts` imports from `build/ts/typography/`, so those files must exist before `pnpm run test` runs. They are committed to git (build/ is gitignored but tracked), so a fresh `pnpm install && pnpm run build` works. But `pnpm run clean` (which is `rimraf build`) removes them — after a clean, you must run `pnpm run build:variables` before `test` will succeed.
113149
114150
### Elevation
115151

packages/eds-tokens/README.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,37 @@ const padding = comfortableSpacing.SPACING_INLINE_MD
114114
const borderRadius = comfortableSpacing.SPACING_BORDER_RADIUS_ROUNDED
115115
```
116116

117+
#### Typography (non-CSS targets)
118+
119+
Typography is composed at runtime from five orthogonal axes. The CSS bundle resolves them via `data-*` attributes; non-CSS consumers (React Native, SSR, design tooling) import the family-keyed matrix directly:
120+
121+
```typescript
122+
import { typography as ui } from '@equinor/eds-tokens/ts/typography/font-family-ui'
123+
import { typography as header } from '@equinor/eds-tokens/ts/typography/font-family-header'
124+
125+
const md = ui.fontFamilySize.md
126+
127+
const style = {
128+
fontFamily: ui.typography.fontFamily,
129+
fontSize: md.fontSize,
130+
fontWeight: md.fontWeight.normal,
131+
lineHeight: md.lineHeight.default,
132+
letterSpacing: md.tracking.normal,
133+
}
134+
// { fontFamily: 'Inter', fontSize: 14, fontWeight: 400,
135+
// lineHeight: 20, letterSpacing: 0 }
136+
```
137+
138+
Each size cell exposes `fontSize`, nested `fontWeight` / `tracking` / `lineHeight` objects, and inlined `iconSize` / `gapHorizontal` / `gapVertical` extras for chip- and button-like layouts. Variant names can be derived with `keyof`:
139+
140+
```typescript
141+
type Weight = keyof typeof ui.fontFamilySize.md.fontWeight // 'lighter' | 'normal' | 'bolder'
142+
```
143+
144+
For React Native, coerce `fontWeight` to a string at the call site (`String(md.fontWeight.normal)`) so it slots into `<Text style>`.
145+
146+
See [`instructions/typography.md`](./instructions/typography.md) for the full axis model.
147+
117148
### Importing variables as JSON
118149
119150
The variables are available in two formats:
@@ -167,7 +198,7 @@ The typography system requires two font families: **Equinor** (headings) and **I
167198
168199
### Typography variables that adapt to data-attributes
169200
* Font family setup (UI and Header fonts)
170-
* Font size data attributes (`[data-text-size='xs']`, `[data-text-size='sm']`, etc.)
201+
* Font size data attributes (`[data-font-size='xs']`, `[data-font-size='sm']`, etc.)
171202
* Line height data attributes (`[data-line-height='default']`, `[data-line-height='squished']`)
172203
* Font weight data attributes (`[data-font-weight='lighter']`, `[data-font-weight='normal']`, `[data-font-weight='bolder']`)
173204
* Letter spacing data attributes (`[data-tracking='tight']`, `[data-tracking='normal']`, `[data-tracking='wide']`)
@@ -194,17 +225,17 @@ Set typography properties using data attributes:
194225

195226
```html
196227
<!-- UI font with medium size -->
197-
<p data-font-family="ui" data-text-size="md" data-line-height="default">
228+
<p data-font-family="ui" data-font-size="md" data-line-height="default">
198229
UI font text
199230
</p>
200231

201232
<!-- Header font with extra large size and bolder weight -->
202-
<h1 data-font-family="header" data-text-size="xl" data-font-weight="bolder">
233+
<h1 data-font-family="header" data-font-size="xl" data-font-weight="bolder">
203234
Header font text
204235
</h1>
205236

206237
<!-- Baseline grid alignment -->
207-
<p data-font-family="ui" data-text-size="md" data-baseline="grid">
238+
<p data-font-family="ui" data-font-size="md" data-baseline="grid">
208239
Aligned to 4px baseline grid
209240
</p>
210241
```
@@ -269,7 +300,7 @@ The foundation CSS includes baseline grid alignment for consistent vertical rhyt
269300
* `data-baseline="center"` -- Centers text vertically while maintaining 4px grid alignment
270301

271302
```html
272-
<p data-font-family="ui" data-text-size="md" data-baseline="grid">
303+
<p data-font-family="ui" data-font-size="md" data-baseline="grid">
273304
Text aligned to baseline grid
274305
</p>
275306
```

0 commit comments

Comments
 (0)