Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/theme/src/scripts/build-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { fileURLToPath } from 'node:url';
import { animate } from '../tokens/primitives/animations/animate.js';
import { animateExtras, keyframes } from '../tokens/primitives/animations/keyframes.js';
import { breakpoints } from '../tokens/primitives/breakpoints.js';
import { buildTrees, flatten } from './compile-primitives.js';
import { compilePrimitivesVars } from './compile-primitives.js';
import { compileThemeCss, compileThemeVars } from './compile-theme.js';
import { containersData } from '../tokens/semantic/containers.data.js';
import { spacingsData } from '../tokens/semantic/spacings.data.js';
Expand All @@ -41,10 +41,8 @@ const BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'];
const kebab = (s) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);

// ─── 1. Primitives ──────────────────────────────────────────────────────────
const flattenPrimitives = () => {
const { refsTree, varsTree } = buildTrees();
return flatten(varsTree, refsTree);
};
// compilePrimitivesVars throws on any unresolved tokenRef (see refs.js).
const flattenPrimitives = () => compilePrimitivesVars();

// ─── 2. Semantic flattening ─────────────────────────────────────────────────
const splitResponsive = (value) => {
Expand Down
12 changes: 8 additions & 4 deletions packages/theme/src/scripts/compile-primitives.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { fontWeight } from '../tokens/primitives/typography/font-weight.js'
import { leading } from '../tokens/primitives/typography/leading.js'
import { lineHeight } from '../tokens/primitives/typography/line-height.js'
import { tracking } from '../tokens/primitives/typography/tracking.js'
import { isTokenRef } from './refs.js'
import { assertResolvedRefs, isTokenRef } from './refs.js'

/**
* Returns two trees:
Expand Down Expand Up @@ -123,17 +123,18 @@ const varNameFor = (path) => {
return `--${segments.join('-')}`
}

export const flatten = (obj, refsTree, prefix = []) => {
export const flatten = (obj, refsTree, prefix = [], unresolved = null) => {
const result = {}
Object.entries(obj).forEach(([key, value]) => {
const nextPath = [...prefix, key]
if (isTokenRef(value)) {
const resolved = resolveRef(value.__ref, refsTree)
if (resolved == null) unresolved?.push(`${varNameFor(nextPath)} → ${value.__ref}`)
result[varNameFor(nextPath)] = resolved ?? value.__ref
return
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flatten(value, refsTree, nextPath))
Object.assign(result, flatten(value, refsTree, nextPath, unresolved))
return
}
if (typeof value === 'string' || typeof value === 'number') {
Expand All @@ -145,7 +146,10 @@ export const flatten = (obj, refsTree, prefix = []) => {

export const compilePrimitivesVars = () => {
const { refsTree, varsTree } = buildTrees()
return flatten(varsTree, refsTree)
const unresolved = []
const vars = flatten(varsTree, refsTree, [], unresolved)
assertResolvedRefs('primitives', unresolved)
return vars
}

export const compilePrimitivesCss = () => {
Expand Down
18 changes: 12 additions & 6 deletions packages/theme/src/scripts/compile-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { ring } from '../tokens/theme/ring.js'
import { secondary } from '../tokens/theme/secondary.js'
import { surfaces } from '../tokens/theme/surfaces.js'
import { text } from '../tokens/theme/text.js'
import { isTokenRef } from './refs.js'
import { assertResolvedRefs, isTokenRef } from './refs.js'

const VARIANTS = ['light', 'dark']

Expand Down Expand Up @@ -58,23 +58,25 @@ const resolveRef = (ref, surfacesResolved) => {
return null
}

const resolveGroup = (group, surfacesResolved) => {
const resolveGroup = (group, surfacesResolved, variant, unresolved) => {
const result = {}
Object.entries(group).forEach(([key, value]) => {
if (!isTokenRef(value)) return
const resolved = resolveRef(value.__ref, surfacesResolved)
if (resolved != null) result[`--${key}`] = String(resolved)
else unresolved.push(`[${variant}] --${key} → ${value.__ref}`)
})
return result
}

const compileVariant = (variant) => {
const compileVariant = (variant, unresolved) => {
// Surfaces resolved first — other groups depend on them.
const surfacesResolved = {}
Object.entries(surfaces[variant]).forEach(([key, value]) => {
if (!isTokenRef(value)) return
const v = resolveRef(value.__ref, {})
if (v != null) surfacesResolved[key] = String(v)
else unresolved.push(`[${variant}] --${key} → ${value.__ref}`)
})

const vars = {}
Expand All @@ -96,12 +98,16 @@ const compileVariant = (variant) => {
danger[variant],
info[variant]
]
groups.forEach((g) => Object.assign(vars, resolveGroup(g, surfacesResolved)))
groups.forEach((g) => Object.assign(vars, resolveGroup(g, surfacesResolved, variant, unresolved)))
return vars
}

export const compileThemeVars = () =>
Object.fromEntries(VARIANTS.map((v) => [v, compileVariant(v)]))
export const compileThemeVars = () => {
const unresolved = []
const vars = Object.fromEntries(VARIANTS.map((v) => [v, compileVariant(v, unresolved)]))
assertResolvedRefs('semantic theme tokens', unresolved)
return vars
}

export const compileThemeCss = () => {
const { light, dark } = compileThemeVars()
Expand Down
11 changes: 11 additions & 0 deletions packages/theme/src/scripts/refs.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,14 @@
export const tokenRef = (path) => ({ __ref: path })

export const isTokenRef = (value) => typeof value === 'object' && value !== null && '__ref' in value

/**
* Fail loudly on unresolved refs. A typo'd token must never silently vanish
* from the compiled CSS or ship as a raw `path.to.token` string — the compilers
* collect every miss and this throws once with the full list.
*/
export const assertResolvedRefs = (context, unresolved) => {
if (unresolved.length === 0) return
const lines = unresolved.map((entry) => ` ${entry}`).join('\n')
throw new Error(`[theme] ${unresolved.length} unresolved tokenRef(s) in ${context}:\n${lines}`)
}
46 changes: 30 additions & 16 deletions packages/theme/src/scripts/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Resolve token refs to CSS variable map.
*/

import { isTokenRef } from './refs.js'
import { assertResolvedRefs, isTokenRef } from './refs.js'

const getValueByPath = (obj, path) =>
path.split('.').reduce((acc, key) => {
Expand All @@ -12,16 +12,18 @@ const getValueByPath = (obj, path) =>
return undefined
}, obj)

const flattenToCssVars = (obj, prefix = []) => {
const flattenToCssVars = (obj, prefix = [], refKeys = null) => {
const result = {}
Object.entries(obj).forEach(([key, value]) => {
const nextPath = [...prefix, key]
if (value && typeof value === 'object' && !Array.isArray(value) && !isTokenRef(value)) {
Object.assign(result, flattenToCssVars(value, nextPath))
Object.assign(result, flattenToCssVars(value, nextPath, refKeys))
return
}
if (isTokenRef(value)) {
result[`--${nextPath.join('-')}`] = value.__ref
const name = `--${nextPath.join('-')}`
refKeys?.add(name)
result[name] = value.__ref
return
}
if (typeof value === 'string' || typeof value === 'number') {
Expand Down Expand Up @@ -75,33 +77,45 @@ export const resolveRefsToCssVars = (tokens) => {
return null
}

const resolveSemantic = (semantic) => {
const flattened = flattenToCssVars(semantic)
const resolveSemantic = (semantic, variant, unresolved) => {
const refKeys = new Set()
const flattened = flattenToCssVars(semantic, [], refKeys)
return Object.fromEntries(
Object.entries(flattened).map(([key, value]) => {
if (value.startsWith('brand.')) {
return [key, resolveBrandRef(value) ?? value]
const resolved = resolveBrandRef(value)
if (resolved == null) unresolved.push(`[${variant}] ${key} → ${value}`)
return [key, resolved ?? value]
}
if (value.startsWith('primitives.') || value.startsWith('surfacePrimitives.')) {
const resolved = getValueByPath(baseForResolve, value)
return [
key,
typeof resolved === 'string' || typeof resolved === 'number' ? String(resolved) : value
]
const ok = typeof resolved === 'string' || typeof resolved === 'number'
if (!ok) unresolved.push(`[${variant}] ${key} → ${value}`)
return [key, ok ? String(resolved) : value]
}
// A ref whose prefix no branch above understands is unresolved too.
if (refKeys.has(key)) unresolved.push(`[${variant}] ${key} → ${value}`)
return [key, value]
})
)
}

return {
const unresolved = []
const baseRefKeys = new Set()
const baseVars = flattenToCssVars(baseForVars, [], baseRefKeys)
// Nothing resolves refs in the base tree, so any ref there is a miss.
baseRefKeys.forEach((name) => unresolved.push(`${name} → ${baseVars[name]}`))

const result = {
light: {
...flattenToCssVars(baseForVars),
...resolveSemantic(lightSemantic)
...baseVars,
...resolveSemantic(lightSemantic, 'light', unresolved)
},
dark: {
...flattenToCssVars(baseForVars),
...resolveSemantic(darkSemantic)
...baseVars,
...resolveSemantic(darkSemantic, 'dark', unresolved)
}
}
assertResolvedRefs('semantic colors', unresolved)
return result
}
Loading