Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@cobalt-ui/core

Package Overview
Dependencies
Maintainers
1
Versions
49
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@cobalt-ui/core - npm Package Compare versions

Comparing version 1.7.0 to 1.7.2

6

CHANGELOG.md
# @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[];

502

dist/parse/tokens-studio.js

@@ -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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc