import type { DTCGGroup, DTCGTokenFile, DTCGTokenType, ResolvedDTCGToken, } from "../dtcg.js"; import { hexToRgb } from "./color.js"; import type { ParsedToken, TokenParseOutput } from "./types.js"; const DTCG_META_KEYS = new Set([ "$type", "$value", "$description", "$deprecated", "$extensions", "$extends", ]); const MAX_ALIAS_DEPTH = 10; export function isDTCGFile(filePath: string): boolean { return ( filePath.endsWith(".tokens.json") || filePath.endsWith(".tokens") || filePath.endsWith("design-tokens.json") || filePath.endsWith("tokens.json") ); } function mapDTCGTypeToCategory(type: DTCGTokenType, tokenPath: string): string { switch (type) { case "color": return "colors"; case "dimension": if (/radius/i.test(tokenPath)) return "radius"; return "spacing"; case "fontFamily": case "fontWeight": return "typography"; case "shadow": return "shadows"; case "border": return "borders"; case "duration": case "cubicBezier": case "transition": return "transitions"; case "typography": return "typography"; case "gradient": return "colors"; case "strokeStyle": return "borders"; case "number": return "other"; default: return "other"; } } function colorValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; if (typeof obj.hex === "string") { if (obj.alpha !== undefined && typeof obj.alpha === "number" && obj.alpha < 1) { const rgb = hexToRgb(obj.hex); if (rgb) { return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${obj.alpha})`; } } return obj.hex; } if (Array.isArray(obj.components)) { const comps = obj.components as number[]; if (comps.length >= 3) { const alpha = obj.alpha ?? (comps.length >= 4 ? comps[3] : 1); if (typeof alpha === "number" && alpha < 1) { return `rgba(${Math.round(comps[0] * 255)}, ${Math.round(comps[1] * 255)}, ${Math.round(comps[2] * 255)}, ${alpha})`; } if (comps.every((c) => c <= 1)) { return `rgb(${Math.round(comps[0] * 255)}, ${Math.round(comps[1] * 255)}, ${Math.round(comps[2] * 255)})`; } return `rgb(${comps[0]}, ${comps[1]}, ${comps[2]})`; } } } return String(value); } function dimensionValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; if (typeof obj.value === "number" && typeof obj.unit === "string") { return `${obj.value}${obj.unit}`; } } return String(value); } function shadowValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) { return value.map((v) => shadowSingleToCSS(v)).join(", "); } return shadowSingleToCSS(value); } function shadowSingleToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; const parts: string[] = []; if (obj.inset) parts.push("inset"); parts.push(dimensionValueToCSS(obj.offsetX)); parts.push(dimensionValueToCSS(obj.offsetY)); parts.push(dimensionValueToCSS(obj.blur)); if (obj.spread !== undefined) parts.push(dimensionValueToCSS(obj.spread)); parts.push(colorValueToCSS(obj.color)); return parts.join(" "); } return String(value); } function borderValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; return `${dimensionValueToCSS(obj.width)} ${obj.style ?? "solid"} ${colorValueToCSS(obj.color)}`; } return String(value); } function typographyValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; const parts: string[] = []; if (obj.fontWeight) parts.push(String(obj.fontWeight)); if (obj.fontSize) parts.push(dimensionValueToCSS(obj.fontSize)); if (obj.lineHeight) parts.push(`/ ${obj.lineHeight}`); if (obj.fontFamily) { const family = Array.isArray(obj.fontFamily) ? obj.fontFamily.join(", ") : String(obj.fontFamily); parts.push(family); } return parts.join(" "); } return String(value); } function cubicBezierValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value) && value.length === 4) { return `cubic-bezier(${value.join(", ")})`; } return String(value); } function transitionValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (value && typeof value === "object") { const obj = value as Record; const parts: string[] = []; if (obj.duration) parts.push(String(obj.duration)); if (obj.timingFunction) parts.push(cubicBezierValueToCSS(obj.timingFunction)); if (obj.delay) parts.push(String(obj.delay)); return parts.join(" "); } return String(value); } function gradientValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) { const stops = value.map((stop) => { if (stop && typeof stop === "object") { const s = stop as Record; const color = colorValueToCSS(s.color); const position = typeof s.position === "number" ? ` ${s.position * 100}%` : ""; return `${color}${position}`; } return String(stop); }); return `linear-gradient(${stops.join(", ")})`; } return String(value); } function fontFamilyValueToCSS(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return value.join(", "); return String(value); } function valueToCSS(type: DTCGTokenType, value: unknown): string { switch (type) { case "color": return colorValueToCSS(value); case "dimension": return dimensionValueToCSS(value); case "shadow": return shadowValueToCSS(value); case "border": return borderValueToCSS(value); case "typography": return typographyValueToCSS(value); case "cubicBezier": return cubicBezierValueToCSS(value); case "transition": return transitionValueToCSS(value); case "gradient": return gradientValueToCSS(value); case "fontFamily": return fontFamilyValueToCSS(value); case "fontWeight": case "duration": case "number": return String(value); case "strokeStyle": return typeof value === "string" ? value : String(value); default: return String(value); } } function isAlias(value: unknown): value is string { return typeof value === "string" && /^\{.+\}$/.test(value); } function resolveAliasPath(alias: string): string { return alias.slice(1, -1); } function resolveAlias( alias: string, root: DTCGTokenFile, visited: Set, depth: number, ): unknown { if (depth > MAX_ALIAS_DEPTH) { throw new Error(`Circular alias detected: ${alias} (max depth ${MAX_ALIAS_DEPTH} reached)`); } const path = resolveAliasPath(alias); if (visited.has(path)) { throw new Error(`Circular alias detected: ${[...visited, path].join(" -> ")}`); } visited.add(path); const parts = path.split("."); let current: unknown = root; for (const part of parts) { if (current && typeof current === "object" && part in (current as Record)) { current = (current as Record)[part]; } else { throw new Error(`Alias reference "${alias}" could not be resolved: "${part}" not found in path "${path}"`); } } if (current && typeof current === "object" && "$value" in (current as Record)) { const resolvedValue = (current as Record).$value; if (isAlias(resolvedValue)) { return resolveAlias(resolvedValue, root, visited, depth + 1); } return resolvedValue; } if (isAlias(current)) { return resolveAlias(current, root, visited, depth + 1); } return current; } function resolveExtends( group: DTCGGroup, root: DTCGTokenFile, visited: Set, ): DTCGGroup { if (!group.$extends) return group; const extendsPath = group.$extends; if (visited.has(extendsPath)) { throw new Error(`Circular $extends detected: ${[...visited, extendsPath].join(" -> ")}`); } visited.add(extendsPath); const parts = extendsPath.split("."); let parent: unknown = root; for (const part of parts) { if (parent && typeof parent === "object" && part in (parent as Record)) { parent = (parent as Record)[part]; } else { throw new Error(`$extends reference "${extendsPath}" could not be resolved`); } } if (!parent || typeof parent !== "object") { throw new Error(`$extends target "${extendsPath}" is not a group`); } const resolvedParent = resolveExtends(parent as DTCGGroup, root, visited); const merged: Record = { ...resolvedParent }; for (const [key, value] of Object.entries(group)) { if (key === "$extends") continue; merged[key] = value; } return merged as DTCGGroup; } function walkTokenTree( node: DTCGGroup, root: DTCGTokenFile, path: string[], inheritedType: DTCGTokenType | undefined, tokens: ResolvedDTCGToken[], ): void { const resolved = resolveExtends(node, root, new Set()); const currentType = resolved.$type ?? inheritedType; for (const [key, child] of Object.entries(resolved)) { if (DTCG_META_KEYS.has(key)) continue; if (typeof child !== "object" || child === null) continue; const childObj = child as Record; const childPath = [...path, key]; if ("$value" in childObj) { const tokenType = (childObj.$type as DTCGTokenType | undefined) ?? currentType; if (!tokenType) continue; let rawValue = childObj.$value; if (isAlias(rawValue)) { try { rawValue = resolveAlias(rawValue, root, new Set(), 0); } catch { // Keep raw alias value if resolution fails. } } tokens.push({ path: childPath.join("."), type: tokenType, rawValue, cssValue: valueToCSS(tokenType, rawValue), description: childObj.$description as string | undefined, deprecated: childObj.$deprecated as boolean | string | undefined, extensions: childObj.$extensions as Record | undefined, }); } else { walkTokenTree(childObj as DTCGGroup, root, childPath, currentType, tokens); } } } function detectDTCGPrefix(tokens: ResolvedDTCGToken[], root: DTCGTokenFile): string { const extensions = root.$extensions as Record | undefined; if (extensions) { const fragmentsExt = extensions["com.usefragments"] as Record | undefined; if (fragmentsExt?.prefix && typeof fragmentsExt.prefix === "string") { const p = fragmentsExt.prefix.replace(/-$/, ""); return `--${p}-`; } } const topLevelKeys = Object.keys(root).filter((k) => !DTCG_META_KEYS.has(k)); if (topLevelKeys.length === 1) { return `--${topLevelKeys[0]}-`; } if (topLevelKeys.length > 1) { return "--"; } if (tokens.length === 0) return "--"; const firstParts = tokens[0].path.split("."); if (firstParts.length > 0) { return `--${firstParts[0]}-`; } return "--"; } function tokenPathToCSSName(path: string, prefix: string): string { const suffix = path.replace(/\./g, "-"); const normalizedPrefix = prefix.endsWith("-") ? prefix : `${prefix}-`; const prefixBase = normalizedPrefix.replace(/^--/, "").replace(/-$/, ""); if (suffix.startsWith(prefixBase + "-") || suffix === prefixBase) { return `--${suffix}`; } return `${normalizedPrefix}${suffix}`; } export function parseDtcgTokens(content: string, filePath = "tokens.tokens.json"): TokenParseOutput { const root: DTCGTokenFile = JSON.parse(content); const resolvedTokens: ResolvedDTCGToken[] = []; walkTokenTree(root, root, [], undefined, resolvedTokens); const prefix = detectDTCGPrefix(resolvedTokens, root); const categories: Record = {}; for (const token of resolvedTokens) { const category = mapDTCGTypeToCategory(token.type, token.path); const parsed: ParsedToken = { name: tokenPathToCSSName(token.path, prefix), value: token.cssValue, category, description: token.description, }; categories[category] ??= []; categories[category].push(parsed); } return { prefix, categories, total: resolvedTokens.length, }; } export const parseDTCGFile = parseDtcgTokens;