@cobalt-ui/core
Advanced tools
Comparing version 1.7.0 to 1.7.2
# @cobalt-ui/core | ||
## 1.7.2 | ||
### Patch Changes | ||
- [#205](https://github.com/drwpow/cobalt-ui/pull/205) [`5f39eb577ce4ede00d479d2ecc73bd087aa584c2`](https://github.com/drwpow/cobalt-ui/commit/5f39eb577ce4ede00d479d2ecc73bd087aa584c2) Thanks [@drwpow](https://github.com/drwpow)! - Improve parsing of Tokens Studio files | ||
## 1.7.0 | ||
@@ -4,0 +10,0 @@ |
import type { Group } from '../token.js'; | ||
export interface TSTokenBase { | ||
description?: string; | ||
} | ||
export interface TSBorderToken extends TSTokenBase { | ||
type: 'border'; | ||
value: { | ||
color?: TSColorToken['value']; | ||
width?: string; | ||
style?: string; | ||
}; | ||
} | ||
export interface TSBorderRadiusToken extends TSTokenBase { | ||
type: 'borderRadius'; | ||
value: string; | ||
} | ||
export interface TSBorderWidthToken extends TSTokenBase { | ||
type: 'borderWidth'; | ||
value: string; | ||
} | ||
export interface TSBoxShadowToken extends TSTokenBase { | ||
type: 'boxShadow'; | ||
value: { | ||
x: string; | ||
y: string; | ||
blur: string; | ||
spread: string; | ||
color?: TSColorToken['value']; | ||
inset?: boolean; | ||
}; | ||
} | ||
export interface TSColorToken extends TSTokenBase { | ||
type: 'color'; | ||
value: string; | ||
} | ||
export interface TSDimensionToken extends TSTokenBase { | ||
type: 'dimension'; | ||
value: string; | ||
} | ||
export interface TSFontFamiliesToken extends TSTokenBase { | ||
type: 'fontFamilies'; | ||
value: string[]; | ||
} | ||
export interface TSFontSizesToken extends TSTokenBase { | ||
type: 'fontSizes'; | ||
value: number | string; | ||
} | ||
export interface TSFontWeightsToken extends TSTokenBase { | ||
type: 'fontWeights'; | ||
value: number | string; | ||
} | ||
export interface TSLetterSpacingToken extends TSTokenBase { | ||
type: 'letterSpacing'; | ||
value: number | string; | ||
} | ||
export interface TSLineHeightsToken extends TSTokenBase { | ||
type: 'lineHeights'; | ||
value: number | string; | ||
} | ||
export interface TSOpacityToken extends TSTokenBase { | ||
type: 'opacity'; | ||
value: number | string; | ||
} | ||
export interface TSParagraphSpacingToken extends TSTokenBase { | ||
type: 'paragraphSpacing'; | ||
value: string; | ||
} | ||
export interface TSSizingToken extends TSTokenBase { | ||
type: 'sizing'; | ||
value: string; | ||
} | ||
export interface TSSpacingToken extends TSTokenBase { | ||
type: 'spacing'; | ||
value: string; | ||
} | ||
export interface TSTextCaseToken extends TSTokenBase { | ||
type: 'textCase'; | ||
value: number; | ||
} | ||
export interface TSTextDecorationToken extends TSTokenBase { | ||
type: 'textDecoration'; | ||
value: number; | ||
} | ||
export interface TSTypographyToken extends TSTokenBase { | ||
type: 'typography'; | ||
value: Record<string, string | number>; | ||
} | ||
export type TSToken = TSBorderToken | TSBorderRadiusToken | TSBorderWidthToken | TSBoxShadowToken | TSColorToken | TSDimensionToken | TSFontFamiliesToken | TSFontSizesToken | TSFontWeightsToken | TSLetterSpacingToken | TSLineHeightsToken | TSOpacityToken | TSParagraphSpacingToken | TSSizingToken | TSSpacingToken | TSTextCaseToken | TSTextDecorationToken | TSTypographyToken; | ||
export declare function convertTokensStudioFormat(rawTokens: Record<string, unknown>): { | ||
@@ -3,0 +90,0 @@ errors?: string[]; |
@@ -6,3 +6,38 @@ /** | ||
*/ | ||
import { getAliasID, isAlias } from '@cobalt-ui/utils'; | ||
import { parseAlias } from '@cobalt-ui/utils'; | ||
// I’m not sure this is comprehensive at all but better than nothing | ||
const FONT_WEIGHTS = { | ||
thin: 100, | ||
hairline: 100, | ||
'extra-light': 200, | ||
extralight: 200, | ||
'extra light': 200, | ||
'ultra-light': 200, | ||
ultralight: 200, | ||
'ultra light': 200, | ||
light: 300, | ||
normal: 400, | ||
regular: 400, | ||
book: 400, | ||
medium: 500, | ||
'semi bold': 600, | ||
semibold: 600, | ||
'semi-bold': 600, | ||
'demi bold': 600, | ||
'demi-bold': 600, | ||
demibold: 600, | ||
bold: 700, | ||
'extra bold': 800, | ||
'extra-bold': 800, | ||
extrabold: 800, | ||
black: 900, | ||
heavy: 900, | ||
'extra black': 950, | ||
'extra-black': 950, | ||
extrablack: 950, | ||
'ultra black': 950, | ||
ultrablack: 950, | ||
'ultra-black': 950, | ||
}; | ||
const ALIAS_RE = /{[^}]+}/g; | ||
export function convertTokensStudioFormat(rawTokens) { | ||
@@ -17,221 +52,313 @@ const errors = []; | ||
for (const p of parts) { | ||
if (!(p in tokenNode)) | ||
if (!(p in tokenNode)) { | ||
tokenNode[p] = {}; | ||
} | ||
tokenNode = tokenNode[p]; | ||
} | ||
// hack: remove empty descriptions | ||
if (value.$description === undefined) { | ||
delete value.$description; | ||
} | ||
tokenNode[id] = value; | ||
} | ||
function resolveAlias(alias, path) { | ||
if (typeof alias !== 'string' || !alias.includes('{')) { | ||
return; | ||
} | ||
const matches = alias.match(ALIAS_RE); | ||
if (!matches) { | ||
return; | ||
} | ||
let resolved = alias; | ||
matchLoop: for (const match of matches) { | ||
const { id } = parseAlias(match); | ||
const tokenAliasPath = id.split('.'); | ||
if (get(rawTokens, tokenAliasPath)) { | ||
continue; // this is complete and correct | ||
} | ||
// if this alias is missing its top-level namespace, try and resolve it | ||
const namespaces = Object.keys(rawTokens); | ||
namespaces.sort((a, b) => (a === path[0] ? -1 : b === path[0] ? 1 : 0)); | ||
for (const namespace of namespaces) { | ||
if (get(rawTokens, [namespace, ...tokenAliasPath])) { | ||
resolved = resolved.replace(match, `{${namespace}.${id}}`); | ||
continue matchLoop; | ||
} | ||
} | ||
errors.push(`Could not resolve alias "${match}"`); | ||
} | ||
return resolved; | ||
} | ||
function walk(node, path) { | ||
if (!node || typeof node !== 'object') | ||
if (!node || typeof node !== 'object') { | ||
return; | ||
for (const [k, v] of Object.entries(node)) { | ||
if (k.startsWith('$')) | ||
continue; // don’t scan meta properties like $themes or $metadata | ||
// token | ||
if (!!v && typeof v === 'object' && 'type' in v && 'value' in v) { | ||
const tokenID = [...path, k].join('.'); | ||
// resolve aliases | ||
const tokenSet = path[0]; | ||
if (typeof v.value === 'string') { | ||
if (v.value.trim().startsWith('{') && !v.value.trim().startsWith(`{${tokenSet}`)) { | ||
v.value = v.value.trim().replace('{', `{${tokenSet}.`); | ||
} | ||
} | ||
for (const k in node) { | ||
const tokenPath = [...path, k]; | ||
const tokenID = tokenPath.join('.'); | ||
const v = node[k]; | ||
if (!v || typeof v !== 'object') { | ||
continue; | ||
} | ||
// skip metatadata (e.g. "$themes" or "$metadata") | ||
if (k.startsWith('$')) { | ||
continue; | ||
} | ||
// resolve aliases (Tokens Studio’s top-level namespaces may or may not be discarded) | ||
const alias = resolveAlias(v.value, path); | ||
if (alias) { | ||
v.value = alias; | ||
} | ||
// transform core types | ||
switch (v.type) { | ||
case 'border': { | ||
addToken({ | ||
$type: 'border', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
else if (v.value && typeof v.value === 'object') { | ||
for (const [property, propertyValue] of Object.entries(v.value)) { | ||
if (typeof propertyValue !== 'string') | ||
continue; | ||
if (propertyValue.trim().startsWith('{') && !propertyValue.trim().startsWith(`{${tokenSet}`)) { | ||
v.value[property] = v.value[property].trim().replace('{', `{${tokenSet}.`); | ||
} | ||
} | ||
} | ||
switch (v.type) { | ||
case 'border': { | ||
addToken({ $type: 'border', $value: v.value }, [...path, k]); | ||
case 'borderRadius': { | ||
if (typeof v.value !== 'string') { | ||
addToken({ | ||
// @ts-expect-error invalid token: surface error | ||
$type: 'borderRadius', | ||
$value: v.value, | ||
$description: v.description, | ||
}, [...path, tokenID]); | ||
break; | ||
} | ||
case 'borderRadius': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string') { | ||
const values = v.value | ||
.split(' ') | ||
.map((s) => resolveAlias(s, path) || s) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({ $type: 'dimension', $value: v.value.trim(), $description: v.description }, tokenPath); | ||
} | ||
else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
// Tokens Studio doesn’t support the "/" character … right? | ||
warnings.push(`Token "${tokenID}" is a multi value borderRadius token. Expanding into ${tokenID}TopLeft, ${tokenID}TopRight, ${tokenID}BottomRight, and ${tokenID}BottomLeft.`); | ||
let order = [values[0], values[1], values[0], values[1]]; // TL, BR | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]]; // TL, TR/BL, BR | ||
else if (values.length === 4) | ||
order = [values[0], values[1], values[2], values[3]]; // TL, TR, BR, BL | ||
addToken({ $type: 'dimension', $value: order[0], $description: v.description }, [...path, `${k}TopLeft`]); | ||
addToken({ $type: 'dimension', $value: order[1], $description: v.description }, [...path, `${k}TopRight`]); | ||
addToken({ $type: 'dimension', $value: order[2], $description: v.description }, [...path, `${k}BottomRight`]); | ||
addToken({ $type: 'dimension', $value: order[3], $description: v.description }, [...path, `${k}BottomLeft`]); | ||
} | ||
else { | ||
addToken({ | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({ $type: 'borderRadius', $value: v.value }, [...path, k]); | ||
break; | ||
} | ||
const values = v.value | ||
.split(' ') | ||
.map((s) => s.trim()) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({ $type: 'dimension', $value: v.value.trim() }, [...path, k]); | ||
} | ||
else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
// Tokens Studio doesn’t support the "/" character … right? | ||
warnings.push(`Token "${tokenID}" is a multi value borderRadius token. Expanding into ${tokenID}TopLeft, ${tokenID}TopRight, ${tokenID}BottomRight, and ${tokenID}BottomLeft.`); | ||
let order = [values[0], values[1], values[0], values[1]]; // TL, BR | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]]; // TL, TR/BL, BR | ||
else if (values.length === 4) | ||
order = [values[0], values[1], values[2], values[3]]; // TL, TR, BR, BL | ||
addToken({ $type: 'dimension', $value: order[0] }, [...path, `${k}TopLeft`]); | ||
addToken({ $type: 'dimension', $value: order[1] }, [...path, `${k}TopRight`]); | ||
addToken({ $type: 'dimension', $value: order[2] }, [...path, `${k}BottomRight`]); | ||
addToken({ $type: 'dimension', $value: order[3] }, [...path, `${k}BottomLeft`]); | ||
} | ||
else { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({ $type: 'borderRadius', $value: v.value }, [...path, k]); | ||
} | ||
break; | ||
$type: 'borderRadius', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
} | ||
case 'boxShadow': { | ||
// invalid token: surface error | ||
if (!v.value || typeof v.value !== 'object') { | ||
addToken({ $type: 'shadow', $value: v.value }, [...path, k]); | ||
break; | ||
} | ||
break; | ||
} | ||
case 'boxShadow': { | ||
// invalid token: surface error | ||
if (!v.value || typeof v.value !== 'object') { | ||
addToken({ | ||
$type: 'shadow', | ||
$value: [ | ||
{ | ||
offsetX: v.value.x, | ||
offsetY: v.value.y, | ||
blur: v.value.blur, | ||
spread: v.value.spread, | ||
color: v.value.color, | ||
inset: v.value.inset ?? false, | ||
// type: ignore??? | ||
}, | ||
], | ||
}, [...path, k]); | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'color': { | ||
// …because gradient tokens share the same type why not :/ | ||
if (v.value.includes('linear-gradient(')) { | ||
const stops = []; | ||
const [_, ...rawStops] = v.value.replace(')', '').split(','); | ||
for (const s of rawStops) { | ||
let [color, position] = s.trim().split(' '); | ||
// normalize color | ||
// why do aliases follow a different syntax here entirely :/ | ||
if (color.includes('$')) | ||
color = `{${tokenSet}.${color.replace('$', '')}}`; | ||
// normalize position | ||
if (position.includes('%')) | ||
position = parseFloat(position) / 100; | ||
else if (typeof position === 'string' && position.length) | ||
position = parseFloat(position); | ||
stops.push({ color, position }); | ||
addToken({ | ||
$type: 'shadow', | ||
$value: [ | ||
{ | ||
offsetX: v.value.x ?? 0, | ||
offsetY: v.value.y ?? 0, | ||
blur: v.value.blur ?? 0, | ||
spread: v.value.spread ?? 0, | ||
color: v.value.color ?? '#000000', | ||
inset: v.value.inset ?? false, | ||
// type: ignore??? | ||
}, | ||
], | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'color': { | ||
// …because gradient tokens share the same type why not :/ | ||
if (v.value.includes('linear-gradient(')) { | ||
const stops = []; | ||
const [_, ...rawStops] = v.value.replace(')', '').split(','); | ||
for (const s of rawStops) { | ||
let [colorRaw = '', positionRaw = ''] = s.trim().split(' '); | ||
let color = colorRaw; | ||
if (color.startsWith('$')) { | ||
color = `{${color.replace('$', '')}}`; | ||
} | ||
addToken({ $type: 'gradient', $value: stops }, [...path, k]); | ||
break; | ||
color = resolveAlias(color, path) || color; | ||
let position = positionRaw; | ||
if (positionRaw.includes('%')) { | ||
position = parseFloat(positionRaw) / 100; | ||
} | ||
position = resolveAlias(position, path) || position; | ||
stops.push({ color, position: position }); | ||
} | ||
addToken({ $type: 'color', $value: v.value }, [...path, k]); | ||
break; | ||
addToken({ | ||
$type: 'gradient', | ||
$value: stops, | ||
$description: v.description, | ||
}, tokenPath); | ||
} | ||
case 'fontFamilies': { | ||
addToken({ $type: 'fontFamily', $value: v.value }, [...path, k]); | ||
break; | ||
else { | ||
addToken({ | ||
$type: 'color', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
} | ||
case 'borderWidth': | ||
case 'dimension': | ||
case 'fontSizes': | ||
case 'letterSpacing': | ||
case 'lineHeights': | ||
case 'opacity': | ||
case 'sizing': { | ||
// this is a number if this is unitless | ||
const isNumber = typeof v.value === 'number' || (typeof v.value === 'string' && String(Number(v.value)) === v.value); | ||
if (isNumber) { | ||
addToken({ $type: 'number', $value: Number(v.value) }, [...path, k]); | ||
} | ||
else { | ||
addToken({ $type: 'dimension', $value: v.value }, [...path, k]); | ||
} | ||
break; | ||
} | ||
case 'fontFamilies': { | ||
addToken({ | ||
$type: 'fontFamily', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'borderWidth': | ||
case 'dimension': | ||
case 'fontSizes': | ||
case 'letterSpacing': | ||
case 'lineHeights': | ||
case 'opacity': | ||
case 'paragraphSpacing': | ||
case 'sizing': { | ||
const maybeNumber = parseFloat(String(v.value)); | ||
const isNumber = typeof v.value === 'number' || String(maybeNumber) === String(v.value); | ||
addToken({ | ||
$type: isNumber ? 'number' : 'dimension', | ||
$value: (isNumber ? maybeNumber : v.value), | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'fontWeights': { | ||
addToken({ | ||
$type: 'fontWeight', | ||
$value: (FONT_WEIGHTS[String(v.value).toLowerCase()] || parseInt(String(v.value), 10) || v.value), | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'spacing': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string' || alias) { | ||
addToken({ | ||
// @ts-expect-error invalid value type; throw error | ||
$type: 'spacing', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
case 'fontWeights': { | ||
addToken({ $type: 'fontWeight', $value: parseInt(v.value, 10) || v.value }, [...path, k]); | ||
break; | ||
const values = v.value | ||
.split(' ') | ||
.map((s) => resolveAlias(s, path) || s) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({ $type: 'dimension', $value: v.value, $description: v.description }, tokenPath); | ||
} | ||
case 'spacing': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string') { | ||
else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
warnings.push(`Token "${tokenID}" is a multi value spacing token. Expanding into ${tokenID}Top, ${tokenID}Right, ${tokenID}Bottom, and ${tokenID}Left.`); | ||
let order = [values[0], values[1], values[0], values[1]]; // TB, RL | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]]; // T, RL, B | ||
else if (values.length === 4) | ||
order = [values[0], values[1], values[2], values[3]]; // T, R, B, L | ||
addToken({ $type: 'dimension', $value: order[0], $description: v.description }, [...path, `${k}Top`]); | ||
addToken({ $type: 'dimension', $value: order[1], $description: v.description }, [...path, `${k}Right`]); | ||
addToken({ $type: 'dimension', $value: order[2], $description: v.description }, [...path, `${k}Bottom`]); | ||
addToken({ $type: 'dimension', $value: order[3], $description: v.description }, [...path, `${k}Left`]); | ||
} | ||
else { | ||
addToken({ | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({ $type: 'spacing', $value: v.value }, [...path, k]); | ||
break; | ||
} | ||
const values = v.value | ||
.split(' ') | ||
.map((s) => s.trim()) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({ $type: 'dimension', $value: v.value.trim() }, [...path, k]); | ||
} | ||
else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
warnings.push(`Token "${tokenID}" is a multi value spacing token. Expanding into ${tokenID}Top, ${tokenID}Right, ${tokenID}Bottom, and ${tokenID}Left.`); | ||
let order = [values[0], values[1], values[0], values[1]]; // TB, RL | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]]; // T, RL, B | ||
else if (values.length === 4) | ||
order = [values[0], values[1], values[2], values[3]]; // T, R, B, L | ||
addToken({ $type: 'dimension', $value: order[0] }, [...path, `${k}Top`]); | ||
addToken({ $type: 'dimension', $value: order[1] }, [...path, `${k}Right`]); | ||
addToken({ $type: 'dimension', $value: order[2] }, [...path, `${k}Bottom`]); | ||
addToken({ $type: 'dimension', $value: order[3] }, [...path, `${k}Left`]); | ||
} | ||
else { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({ $type: 'spacing', $value: v.value }, [...path, k]); | ||
} | ||
break; | ||
$type: 'spacing', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
} | ||
case 'textDecoration': | ||
case 'textCase': { | ||
// ignore; these either get used in "typography" token or silently skipped | ||
break; | ||
} | ||
case 'typography': { | ||
// fortunately, the Tokens Studio spec is inconsistent with their "typography" tokens | ||
// in that they match DTCG (even though `fontFamilies` [sic] tokens exist) | ||
// unfortunately, `textCase` and `textDecoration` are special and have to be flattened | ||
if (!!v.value && typeof v.value === 'object') { | ||
for (const property of ['textCase', 'textDecoration']) { | ||
if (property in v.value && isAlias(v.value[property])) { | ||
const aliasHistory = new Set(); | ||
// attempt lookup; abandon if not | ||
const firstLookup = getAliasID(v.value[property]).split('.'); | ||
let newValue = get(rawTokens, [...firstLookup, 'value']) ?? get(rawTokens, [tokenSet, ...firstLookup, 'value']); | ||
if (typeof newValue === 'string') | ||
aliasHistory.add(newValue); | ||
// note: check for circular refs, just in case Token Studio doesn’t handle that | ||
while (typeof newValue === 'string' && isAlias(newValue)) { | ||
const nextLookup = getAliasID(newValue).split('.'); | ||
newValue = get(rawTokens, [...nextLookup, 'value']) ?? get(rawTokens, [tokenSet, ...nextLookup, 'value']); | ||
if (typeof newValue === 'string' && aliasHistory.has(newValue)) { | ||
errors.push(`Alias "${v.value[property]}" is a circular reference`); | ||
newValue = undefined; | ||
break; | ||
} | ||
case 'textDecoration': | ||
case 'textCase': { | ||
// ignore; these either get used in "typography" token or silently skipped | ||
break; | ||
} | ||
case 'typography': { | ||
// fortunately, the Tokens Studio spec is inconsistent with their "typography" tokens | ||
// in that they match DTCG (even though `fontFamilies` [sic] tokens exist) | ||
if (v.value && typeof v.value === 'object') { | ||
for (const property in v.value) { | ||
const propertyAlias = resolveAlias(v.value[property], path); | ||
if (propertyAlias) { | ||
// TODO: remove this once string tokens are supported | ||
if (property === 'textCase' || property === 'textDecoration') { | ||
let currentAlias = propertyAlias; | ||
const aliasHistory = new Set([v.value[property], propertyAlias]); | ||
let finalValue; | ||
while (!finalValue) { | ||
const propertyPath = parseAlias(currentAlias).id.split('.'); | ||
const maybeToken = get(rawTokens, propertyPath); | ||
if (!maybeToken || typeof maybeToken !== 'object' || !maybeToken.value) { | ||
errors.push(`Couldn’t find ${currentAlias}`); | ||
break; | ||
} | ||
if (typeof newValue === 'string') | ||
aliasHistory.add(newValue); | ||
const nextAlias = resolveAlias(maybeToken.value, propertyPath); | ||
if (!nextAlias) { | ||
finalValue = maybeToken.value; | ||
break; | ||
} | ||
if (aliasHistory.has(nextAlias)) { | ||
errors.push(`Circular alias ${propertyAlias} can’t be resolved`); | ||
break; | ||
} | ||
currentAlias = nextAlias; | ||
aliasHistory.add(currentAlias); | ||
} | ||
// lookup successful! save | ||
if (newValue) | ||
v.value[property] = newValue; | ||
// lookup failed; remove | ||
else | ||
delete v.value[property]; | ||
if (finalValue) { | ||
v.value[property] = finalValue; // resolution worked | ||
} | ||
else { | ||
delete v.value[property]; // give up | ||
} | ||
} | ||
else { | ||
v.value[property] = propertyAlias; // otherwise, resolve | ||
} | ||
} | ||
else { | ||
if (property === 'fontWeights') { | ||
v.value[property] = FONT_WEIGHTS[String(v.value[property]).toLowerCase()] || v.value[property]; | ||
} | ||
const maybeNumber = parseFloat(String(v.value[property])); | ||
if (String(maybeNumber) === v.value[property]) { | ||
v.value[property] = maybeNumber; | ||
} | ||
} | ||
} | ||
addToken({ $type: 'typography', $value: v.value }, [...path, k]); | ||
break; | ||
} | ||
addToken({ | ||
$type: 'typography', | ||
$value: v.value, | ||
$description: v.description, | ||
}, tokenPath); | ||
break; | ||
} | ||
continue; | ||
} | ||
// group | ||
walk(v, [...path, k]); | ||
walk(v, tokenPath); | ||
} | ||
@@ -252,4 +379,5 @@ } | ||
for (const p of path) { | ||
if (!node || typeof node !== 'object' || !(p in node)) | ||
break; | ||
if (!node || typeof node !== 'object' || !(p in node)) { | ||
return undefined; | ||
} | ||
node = node[p]; | ||
@@ -256,0 +384,0 @@ } |
{ | ||
"name": "@cobalt-ui/core", | ||
"description": "Parser/validator for the Design Tokens Community Group (DTCG) standard.", | ||
"version": "1.7.0", | ||
"version": "1.7.2", | ||
"author": { | ||
@@ -6,0 +6,0 @@ "name": "Drew Powers", |
@@ -6,5 +6,167 @@ /** | ||
*/ | ||
import {getAliasID, isAlias} from '@cobalt-ui/utils'; | ||
import {parseAlias} from '@cobalt-ui/utils'; | ||
import type {GradientStop, Group, Token} from '../token.js'; | ||
// I’m not sure this is comprehensive at all but better than nothing | ||
const FONT_WEIGHTS: Record<string, number> = { | ||
thin: 100, | ||
hairline: 100, | ||
'extra-light': 200, | ||
extralight: 200, | ||
'extra light': 200, | ||
'ultra-light': 200, | ||
ultralight: 200, | ||
'ultra light': 200, | ||
light: 300, | ||
normal: 400, | ||
regular: 400, | ||
book: 400, | ||
medium: 500, | ||
'semi bold': 600, | ||
semibold: 600, | ||
'semi-bold': 600, | ||
'demi bold': 600, | ||
'demi-bold': 600, | ||
demibold: 600, | ||
bold: 700, | ||
'extra bold': 800, | ||
'extra-bold': 800, | ||
extrabold: 800, | ||
black: 900, | ||
heavy: 900, | ||
'extra black': 950, | ||
'extra-black': 950, | ||
extrablack: 950, | ||
'ultra black': 950, | ||
ultrablack: 950, | ||
'ultra-black': 950, | ||
}; | ||
const ALIAS_RE = /{[^}]+}/g; | ||
export interface TSTokenBase { | ||
description?: string; | ||
} | ||
export interface TSBorderToken extends TSTokenBase { | ||
type: 'border'; | ||
value: { | ||
color?: TSColorToken['value']; | ||
width?: string; | ||
style?: string; | ||
}; | ||
} | ||
export interface TSBorderRadiusToken extends TSTokenBase { | ||
type: 'borderRadius'; | ||
value: string; | ||
} | ||
export interface TSBorderWidthToken extends TSTokenBase { | ||
type: 'borderWidth'; | ||
value: string; | ||
} | ||
export interface TSBoxShadowToken extends TSTokenBase { | ||
type: 'boxShadow'; | ||
value: { | ||
x: string; | ||
y: string; | ||
blur: string; | ||
spread: string; | ||
color?: TSColorToken['value']; | ||
inset?: boolean; | ||
}; | ||
} | ||
export interface TSColorToken extends TSTokenBase { | ||
type: 'color'; | ||
value: string; | ||
} | ||
export interface TSDimensionToken extends TSTokenBase { | ||
type: 'dimension'; | ||
value: string; | ||
} | ||
export interface TSFontFamiliesToken extends TSTokenBase { | ||
type: 'fontFamilies'; | ||
value: string[]; | ||
} | ||
export interface TSFontSizesToken extends TSTokenBase { | ||
type: 'fontSizes'; | ||
value: number | string; | ||
} | ||
export interface TSFontWeightsToken extends TSTokenBase { | ||
type: 'fontWeights'; | ||
value: number | string; | ||
} | ||
export interface TSLetterSpacingToken extends TSTokenBase { | ||
type: 'letterSpacing'; | ||
value: number | string; | ||
} | ||
export interface TSLineHeightsToken extends TSTokenBase { | ||
type: 'lineHeights'; | ||
value: number | string; | ||
} | ||
export interface TSOpacityToken extends TSTokenBase { | ||
type: 'opacity'; | ||
value: number | string; | ||
} | ||
export interface TSParagraphSpacingToken extends TSTokenBase { | ||
type: 'paragraphSpacing'; | ||
value: string; | ||
} | ||
export interface TSSizingToken extends TSTokenBase { | ||
type: 'sizing'; | ||
value: string; | ||
} | ||
export interface TSSpacingToken extends TSTokenBase { | ||
type: 'spacing'; | ||
value: string; | ||
} | ||
export interface TSTextCaseToken extends TSTokenBase { | ||
type: 'textCase'; | ||
value: number; | ||
} | ||
export interface TSTextDecorationToken extends TSTokenBase { | ||
type: 'textDecoration'; | ||
value: number; | ||
} | ||
export interface TSTypographyToken extends TSTokenBase { | ||
type: 'typography'; | ||
value: Record<string, string | number>; | ||
} | ||
export type TSToken = | ||
| TSBorderToken | ||
| TSBorderRadiusToken | ||
| TSBorderWidthToken | ||
| TSBoxShadowToken | ||
| TSColorToken | ||
| TSDimensionToken | ||
| TSFontFamiliesToken | ||
| TSFontSizesToken | ||
| TSFontWeightsToken | ||
| TSLetterSpacingToken | ||
| TSLineHeightsToken | ||
| TSOpacityToken | ||
| TSParagraphSpacingToken | ||
| TSSizingToken | ||
| TSSpacingToken | ||
| TSTextCaseToken | ||
| TSTextDecorationToken | ||
| TSTypographyToken; | ||
export function convertTokensStudioFormat(rawTokens: Record<string, unknown>): {errors?: string[]; warnings?: string[]; result: Group} { | ||
@@ -20,213 +182,352 @@ const errors: string[] = []; | ||
for (const p of parts) { | ||
if (!(p in tokenNode)) tokenNode[p] = {}; | ||
if (!(p in tokenNode)) { | ||
tokenNode[p] = {}; | ||
} | ||
tokenNode = tokenNode[p] as Group; | ||
} | ||
// hack: remove empty descriptions | ||
if (value.$description === undefined) { | ||
delete value.$description; | ||
} | ||
tokenNode[id] = value; | ||
} | ||
function resolveAlias(alias: unknown, path: string[]): string | undefined { | ||
if (typeof alias !== 'string' || !alias.includes('{')) { | ||
return; | ||
} | ||
const matches = alias.match(ALIAS_RE); | ||
if (!matches) { | ||
return; | ||
} | ||
let resolved = alias; | ||
matchLoop: for (const match of matches) { | ||
const {id} = parseAlias(match); | ||
const tokenAliasPath = id.split('.'); | ||
if (get(rawTokens, tokenAliasPath)) { | ||
continue; // this is complete and correct | ||
} | ||
// if this alias is missing its top-level namespace, try and resolve it | ||
const namespaces = Object.keys(rawTokens); | ||
namespaces.sort((a, b) => (a === path[0] ? -1 : b === path[0] ? 1 : 0)); | ||
for (const namespace of namespaces) { | ||
if (get(rawTokens, [namespace, ...tokenAliasPath])) { | ||
resolved = resolved.replace(match, `{${namespace}.${id}}`); | ||
continue matchLoop; | ||
} | ||
} | ||
errors.push(`Could not resolve alias "${match}"`); | ||
} | ||
return resolved; | ||
} | ||
function walk(node: unknown, path: string[]): void { | ||
if (!node || typeof node !== 'object') return; | ||
for (const [k, v] of Object.entries(node)) { | ||
if (k.startsWith('$')) continue; // don’t scan meta properties like $themes or $metadata | ||
if (!node || typeof node !== 'object') { | ||
return; | ||
} | ||
for (const k in node) { | ||
const tokenPath = [...path, k]; | ||
const tokenID = tokenPath.join('.'); | ||
const v = (node as Record<string, unknown>)[k] as TSToken; | ||
// token | ||
if (!!v && typeof v === 'object' && 'type' in v && 'value' in v) { | ||
const tokenID = [...path, k].join('.'); | ||
if (!v || typeof v !== 'object') { | ||
continue; | ||
} | ||
// resolve aliases | ||
const tokenSet = path[0]!; | ||
if (typeof v.value === 'string') { | ||
if (v.value.trim().startsWith('{') && !v.value.trim().startsWith(`{${tokenSet}`)) { | ||
v.value = v.value.trim().replace('{', `{${tokenSet}.`); | ||
} | ||
} else if (v.value && typeof v.value === 'object') { | ||
for (const [property, propertyValue] of Object.entries(v.value)) { | ||
if (typeof propertyValue !== 'string') continue; | ||
if (propertyValue.trim().startsWith('{') && !propertyValue.trim().startsWith(`{${tokenSet}`)) { | ||
v.value[property] = v.value[property].trim().replace('{', `{${tokenSet}.`); | ||
} | ||
} | ||
// skip metatadata (e.g. "$themes" or "$metadata") | ||
if (k.startsWith('$')) { | ||
continue; | ||
} | ||
// resolve aliases (Tokens Studio’s top-level namespaces may or may not be discarded) | ||
const alias = resolveAlias(v.value, path); | ||
if (alias) { | ||
v.value = alias; | ||
} | ||
// transform core types | ||
switch (v.type) { | ||
case 'border': { | ||
addToken( | ||
{ | ||
$type: 'border', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
switch (v.type) { | ||
case 'border': { | ||
addToken({$type: 'border', $value: v.value}, [...path, k]); | ||
case 'borderRadius': { | ||
if (typeof v.value !== 'string') { | ||
addToken( | ||
{ | ||
// @ts-expect-error invalid token: surface error | ||
$type: 'borderRadius', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
[...path, tokenID], | ||
); | ||
break; | ||
} | ||
case 'borderRadius': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string') { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({$type: 'borderRadius', $value: v.value}, [...path, k]); | ||
break; | ||
} | ||
const values = (v.value as string) | ||
.split(' ') | ||
.map((s) => s.trim()) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({$type: 'dimension', $value: v.value.trim()}, [...path, k]); | ||
} else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
// Tokens Studio doesn’t support the "/" character … right? | ||
warnings.push(`Token "${tokenID}" is a multi value borderRadius token. Expanding into ${tokenID}TopLeft, ${tokenID}TopRight, ${tokenID}BottomRight, and ${tokenID}BottomLeft.`); | ||
let order = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TL, BR | ||
if (values.length === 3) order = [values[0], values[1], values[2], values[1]] as [string, string, string, string]; // TL, TR/BL, BR | ||
else if (values.length === 4) order = [values[0], values[1], values[2], values[3]] as [string, string, string, string]; // TL, TR, BR, BL | ||
addToken({$type: 'dimension', $value: order[0]}, [...path, `${k}TopLeft`]); | ||
addToken({$type: 'dimension', $value: order[1]}, [...path, `${k}TopRight`]); | ||
addToken({$type: 'dimension', $value: order[2]}, [...path, `${k}BottomRight`]); | ||
addToken({$type: 'dimension', $value: order[3]}, [...path, `${k}BottomLeft`]); | ||
} else { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({$type: 'borderRadius', $value: v.value}, [...path, k]); | ||
} | ||
break; | ||
const values = (v.value as string) | ||
.split(' ') | ||
.map((s) => resolveAlias(s, path) || s) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({$type: 'dimension', $value: v.value.trim(), $description: v.description}, tokenPath); | ||
} else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
// Tokens Studio doesn’t support the "/" character … right? | ||
warnings.push(`Token "${tokenID}" is a multi value borderRadius token. Expanding into ${tokenID}TopLeft, ${tokenID}TopRight, ${tokenID}BottomRight, and ${tokenID}BottomLeft.`); | ||
let order = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TL, BR | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]] as [string, string, string, string]; // TL, TR/BL, BR | ||
else if (values.length === 4) order = [values[0], values[1], values[2], values[3]] as [string, string, string, string]; // TL, TR, BR, BL | ||
addToken({$type: 'dimension', $value: order[0], $description: v.description}, [...path, `${k}TopLeft`]); | ||
addToken({$type: 'dimension', $value: order[1], $description: v.description}, [...path, `${k}TopRight`]); | ||
addToken({$type: 'dimension', $value: order[2], $description: v.description}, [...path, `${k}BottomRight`]); | ||
addToken({$type: 'dimension', $value: order[3], $description: v.description}, [...path, `${k}BottomLeft`]); | ||
} else { | ||
addToken( | ||
{ | ||
// @ts-expect-error invalid value type; throw error | ||
$type: 'borderRadius', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
} | ||
case 'boxShadow': { | ||
// invalid token: surface error | ||
if (!v.value || typeof v.value !== 'object') { | ||
addToken({$type: 'shadow', $value: v.value}, [...path, k]); | ||
break; | ||
} | ||
break; | ||
} | ||
case 'boxShadow': { | ||
// invalid token: surface error | ||
if (!v.value || typeof v.value !== 'object') { | ||
addToken( | ||
{ | ||
$type: 'shadow', | ||
$value: [ | ||
{ | ||
offsetX: v.value.x, | ||
offsetY: v.value.y, | ||
blur: v.value.blur, | ||
spread: v.value.spread, | ||
color: v.value.color, | ||
inset: v.value.inset ?? false, | ||
// type: ignore??? | ||
}, | ||
], | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
[...path, k], | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'color': { | ||
// …because gradient tokens share the same type why not :/ | ||
if (v.value.includes('linear-gradient(')) { | ||
const stops: GradientStop[] = []; | ||
const [_, ...rawStops] = v.value.replace(')', '').split(','); | ||
for (const s of rawStops) { | ||
let [color, position] = s.trim().split(' '); | ||
// normalize color | ||
// why do aliases follow a different syntax here entirely :/ | ||
if (color.includes('$')) color = `{${tokenSet}.${color.replace('$', '')}}`; | ||
// normalize position | ||
if (position.includes('%')) position = parseFloat(position) / 100; | ||
else if (typeof position === 'string' && position.length) position = parseFloat(position); | ||
stops.push({color, position}); | ||
addToken( | ||
{ | ||
$type: 'shadow', | ||
$value: [ | ||
{ | ||
offsetX: v.value.x ?? 0, | ||
offsetY: v.value.y ?? 0, | ||
blur: v.value.blur ?? 0, | ||
spread: v.value.spread ?? 0, | ||
color: v.value.color ?? '#000000', | ||
inset: v.value.inset ?? false, | ||
// type: ignore??? | ||
}, | ||
], | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'color': { | ||
// …because gradient tokens share the same type why not :/ | ||
if (v.value.includes('linear-gradient(')) { | ||
const stops: GradientStop[] = []; | ||
const [_, ...rawStops] = v.value.replace(')', '').split(','); | ||
for (const s of rawStops) { | ||
let [colorRaw = '', positionRaw = ''] = s.trim().split(' '); | ||
let color = colorRaw; | ||
if (color.startsWith('$')) { | ||
color = `{${color.replace('$', '')}}`; | ||
} | ||
addToken({$type: 'gradient', $value: stops}, [...path, k]); | ||
break; | ||
color = resolveAlias(color, path) || color; | ||
let position: string | number = positionRaw; | ||
if (positionRaw.includes('%')) { | ||
position = parseFloat(positionRaw) / 100; | ||
} | ||
position = resolveAlias(position, path) || position; | ||
stops.push({color, position: position as number}); | ||
} | ||
addToken({$type: 'color', $value: v.value}, [...path, k]); | ||
break; | ||
addToken( | ||
{ | ||
$type: 'gradient', | ||
$value: stops, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
} else { | ||
addToken( | ||
{ | ||
$type: 'color', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
} | ||
case 'fontFamilies': { | ||
addToken({$type: 'fontFamily', $value: v.value}, [...path, k]); | ||
break; | ||
} | ||
case 'fontFamilies': { | ||
addToken( | ||
{ | ||
$type: 'fontFamily', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'borderWidth': | ||
case 'dimension': | ||
case 'fontSizes': | ||
case 'letterSpacing': | ||
case 'lineHeights': | ||
case 'opacity': | ||
case 'paragraphSpacing': | ||
case 'sizing': { | ||
const maybeNumber = parseFloat(String(v.value)); | ||
const isNumber = typeof v.value === 'number' || String(maybeNumber) === String(v.value); | ||
addToken( | ||
{ | ||
$type: isNumber ? 'number' : 'dimension', | ||
$value: (isNumber ? maybeNumber : v.value) as any, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'fontWeights': { | ||
addToken( | ||
{ | ||
$type: 'fontWeight', | ||
$value: (FONT_WEIGHTS[String(v.value).toLowerCase()] || parseInt(String(v.value), 10) || v.value) as number, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'spacing': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string' || alias) { | ||
addToken( | ||
{ | ||
// @ts-expect-error invalid value type; throw error | ||
$type: 'spacing', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
case 'borderWidth': | ||
case 'dimension': | ||
case 'fontSizes': | ||
case 'letterSpacing': | ||
case 'lineHeights': | ||
case 'opacity': | ||
case 'sizing': { | ||
// this is a number if this is unitless | ||
const isNumber = typeof v.value === 'number' || (typeof v.value === 'string' && String(Number(v.value)) === v.value); | ||
if (isNumber) { | ||
addToken({$type: 'number', $value: Number(v.value)}, [...path, k]); | ||
} else { | ||
addToken({$type: 'dimension', $value: v.value}, [...path, k]); | ||
} | ||
break; | ||
const values = (v.value as string) | ||
.split(' ') | ||
.map((s) => resolveAlias(s, path) || s) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({$type: 'dimension', $value: v.value, $description: v.description}, tokenPath); | ||
} else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
warnings.push(`Token "${tokenID}" is a multi value spacing token. Expanding into ${tokenID}Top, ${tokenID}Right, ${tokenID}Bottom, and ${tokenID}Left.`); | ||
let order: [string, string, string, string] = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TB, RL | ||
if (values.length === 3) | ||
order = [values[0], values[1], values[2], values[1]] as [string, string, string, string]; // T, RL, B | ||
else if (values.length === 4) order = [values[0], values[1], values[2], values[3]] as [string, string, string, string]; // T, R, B, L | ||
addToken({$type: 'dimension', $value: order[0], $description: v.description}, [...path, `${k}Top`]); | ||
addToken({$type: 'dimension', $value: order[1], $description: v.description}, [...path, `${k}Right`]); | ||
addToken({$type: 'dimension', $value: order[2], $description: v.description}, [...path, `${k}Bottom`]); | ||
addToken({$type: 'dimension', $value: order[3], $description: v.description}, [...path, `${k}Left`]); | ||
} else { | ||
addToken( | ||
{ | ||
// @ts-expect-error invalid value type; throw error | ||
$type: 'spacing', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
} | ||
case 'fontWeights': { | ||
addToken({$type: 'fontWeight', $value: parseInt(v.value, 10) || v.value}, [...path, k]); | ||
break; | ||
} | ||
case 'spacing': { | ||
// invalid token: surface error | ||
if (typeof v.value !== 'string') { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({$type: 'spacing', $value: v.value}, [...path, k]); | ||
break; | ||
} | ||
const values = (v.value as string) | ||
.split(' ') | ||
.map((s) => s.trim()) | ||
.filter(Boolean); | ||
if (values.length === 1) { | ||
addToken({$type: 'dimension', $value: v.value.trim()}, [...path, k]); | ||
} else if (values.length === 2 || values.length === 3 || values.length === 4) { | ||
warnings.push(`Token "${tokenID}" is a multi value spacing token. Expanding into ${tokenID}Top, ${tokenID}Right, ${tokenID}Bottom, and ${tokenID}Left.`); | ||
let order: [string, string, string, string] = [values[0], values[1], values[0], values[1]] as [string, string, string, string]; // TB, RL | ||
if (values.length === 3) order = [values[0], values[1], values[2], values[1]] as [string, string, string, string]; // T, RL, B | ||
else if (values.length === 4) order = [values[0], values[1], values[2], values[3]] as [string, string, string, string]; // T, R, B, L | ||
addToken({$type: 'dimension', $value: order[0]}, [...path, `${k}Top`]); | ||
addToken({$type: 'dimension', $value: order[1]}, [...path, `${k}Right`]); | ||
addToken({$type: 'dimension', $value: order[2]}, [...path, `${k}Bottom`]); | ||
addToken({$type: 'dimension', $value: order[3]}, [...path, `${k}Left`]); | ||
} else { | ||
// @ts-expect-error invalid value type; throw error | ||
addToken({$type: 'spacing', $value: v.value}, [...path, k]); | ||
} | ||
break; | ||
} | ||
case 'textDecoration': | ||
case 'textCase': { | ||
// ignore; these either get used in "typography" token or silently skipped | ||
break; | ||
} | ||
case 'typography': { | ||
// fortunately, the Tokens Studio spec is inconsistent with their "typography" tokens | ||
// in that they match DTCG (even though `fontFamilies` [sic] tokens exist) | ||
// unfortunately, `textCase` and `textDecoration` are special and have to be flattened | ||
if (!!v.value && typeof v.value === 'object') { | ||
for (const property of ['textCase', 'textDecoration']) { | ||
if (property in v.value && isAlias(v.value[property])) { | ||
const aliasHistory = new Set<string>(); | ||
// attempt lookup; abandon if not | ||
const firstLookup = getAliasID(v.value[property]).split('.'); | ||
let newValue = get(rawTokens, [...firstLookup, 'value']) ?? get(rawTokens, [tokenSet, ...firstLookup, 'value']); | ||
if (typeof newValue === 'string') aliasHistory.add(newValue); | ||
// note: check for circular refs, just in case Token Studio doesn’t handle that | ||
while (typeof newValue === 'string' && isAlias(newValue)) { | ||
const nextLookup = getAliasID(newValue).split('.'); | ||
newValue = get(rawTokens, [...nextLookup, 'value']) ?? get(rawTokens, [tokenSet, ...nextLookup, 'value']); | ||
if (typeof newValue === 'string' && aliasHistory.has(newValue)) { | ||
errors.push(`Alias "${v.value[property]}" is a circular reference`); | ||
newValue = undefined; | ||
break; | ||
} | ||
case 'textDecoration': | ||
case 'textCase': { | ||
// ignore; these either get used in "typography" token or silently skipped | ||
break; | ||
} | ||
case 'typography': { | ||
// fortunately, the Tokens Studio spec is inconsistent with their "typography" tokens | ||
// in that they match DTCG (even though `fontFamilies` [sic] tokens exist) | ||
if (v.value && typeof v.value === 'object') { | ||
for (const property in v.value) { | ||
const propertyAlias = resolveAlias(v.value[property], path); | ||
if (propertyAlias) { | ||
// TODO: remove this once string tokens are supported | ||
if (property === 'textCase' || property === 'textDecoration') { | ||
let currentAlias = propertyAlias; | ||
const aliasHistory = new Set<string>([v.value[property] as string, propertyAlias]); | ||
let finalValue: string | undefined; | ||
while (!finalValue) { | ||
const propertyPath = parseAlias(currentAlias).id.split('.'); | ||
const maybeToken = get(rawTokens, propertyPath); | ||
if (!maybeToken || typeof maybeToken !== 'object' || !(maybeToken as TSToken).value) { | ||
errors.push(`Couldn’t find ${currentAlias}`); | ||
break; | ||
} | ||
if (typeof newValue === 'string') aliasHistory.add(newValue); | ||
const nextAlias = resolveAlias((maybeToken as TSToken).value, propertyPath); | ||
if (!nextAlias) { | ||
finalValue = (maybeToken as any).value; | ||
break; | ||
} | ||
if (aliasHistory.has(nextAlias)) { | ||
errors.push(`Circular alias ${propertyAlias} can’t be resolved`); | ||
break; | ||
} | ||
currentAlias = nextAlias; | ||
aliasHistory.add(currentAlias); | ||
} | ||
// lookup successful! save | ||
if (newValue) v.value[property] = newValue; | ||
// lookup failed; remove | ||
else delete v.value[property]; | ||
if (finalValue) { | ||
v.value[property] = finalValue; // resolution worked | ||
} else { | ||
delete v.value[property]; // give up | ||
} | ||
} else { | ||
v.value[property] = propertyAlias; // otherwise, resolve | ||
} | ||
} else { | ||
if (property === 'fontWeights') { | ||
v.value[property] = FONT_WEIGHTS[String(v.value[property]).toLowerCase()] || (v.value[property] as string); | ||
} | ||
const maybeNumber = parseFloat(String(v.value[property])); | ||
if (String(maybeNumber) === v.value[property]) { | ||
v.value[property] = maybeNumber; | ||
} | ||
} | ||
} | ||
addToken({$type: 'typography', $value: v.value}, [...path, k]); | ||
break; | ||
} | ||
addToken( | ||
{ | ||
$type: 'typography', | ||
$value: v.value, | ||
$description: v.description, | ||
}, | ||
tokenPath, | ||
); | ||
break; | ||
} | ||
continue; | ||
} | ||
// group | ||
walk(v, [...path, k]); | ||
walk(v, tokenPath); | ||
} | ||
@@ -250,3 +551,5 @@ } | ||
for (const p of path) { | ||
if (!node || typeof node !== 'object' || !(p in node)) break; | ||
if (!node || typeof node !== 'object' || !(p in node)) { | ||
return undefined; | ||
} | ||
node = node[p] as any; | ||
@@ -253,0 +556,0 @@ } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
660044
4829