From efb220aeb0853e245d64e551d3cfc72588322673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robson=20J=C3=BAnior?= Date: Sun, 26 Jul 2026 21:11:36 -0300 Subject: [PATCH] [NO-ISSUE] chore(theme): fail token build on unresolved tokenRef --- packages/theme/src/scripts/build-tokens.mjs | 8 ++-- .../theme/src/scripts/compile-primitives.js | 12 +++-- packages/theme/src/scripts/compile-theme.js | 18 +++++--- packages/theme/src/scripts/refs.js | 11 +++++ packages/theme/src/scripts/resolve.js | 46 ++++++++++++------- 5 files changed, 64 insertions(+), 31 deletions(-) diff --git a/packages/theme/src/scripts/build-tokens.mjs b/packages/theme/src/scripts/build-tokens.mjs index 187111bc6..20748f16d 100644 --- a/packages/theme/src/scripts/build-tokens.mjs +++ b/packages/theme/src/scripts/build-tokens.mjs @@ -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'; @@ -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) => { diff --git a/packages/theme/src/scripts/compile-primitives.js b/packages/theme/src/scripts/compile-primitives.js index 0f5a58021..e5a7e65ad 100644 --- a/packages/theme/src/scripts/compile-primitives.js +++ b/packages/theme/src/scripts/compile-primitives.js @@ -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: @@ -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') { @@ -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 = () => { diff --git a/packages/theme/src/scripts/compile-theme.js b/packages/theme/src/scripts/compile-theme.js index 4817502a2..7e2adc87d 100644 --- a/packages/theme/src/scripts/compile-theme.js +++ b/packages/theme/src/scripts/compile-theme.js @@ -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'] @@ -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 = {} @@ -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() diff --git a/packages/theme/src/scripts/refs.js b/packages/theme/src/scripts/refs.js index 47e55915a..ec225ad5f 100644 --- a/packages/theme/src/scripts/refs.js +++ b/packages/theme/src/scripts/refs.js @@ -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}`) +} diff --git a/packages/theme/src/scripts/resolve.js b/packages/theme/src/scripts/resolve.js index 4b01fb3f4..f78e3b5d2 100644 --- a/packages/theme/src/scripts/resolve.js +++ b/packages/theme/src/scripts/resolve.js @@ -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) => { @@ -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') { @@ -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 }