import { parseColour } from "./parse"; import type { PaletteEntry } from "./types"; const MAX_SOURCE = 2 * 1024 * 1024; const MAX_TOKENS = 10_000; const MAX_DEPTH = 32; interface TokenRecord { path: string[]; object: Record; inheritedType?: string; } interface TokenIndex { byPath: ReadonlyMap; byObject: WeakMap; byValue: WeakMap; } export interface DtcgDiagnostic { path: string; severity: "warning" | "error"; message: string; } export interface DtcgImport { entries: PaletteEntry[]; diagnostics: DtcgDiagnostic[]; skippedNonColourTokens: number; schema?: string; } function pathText(path: readonly string[]): string { return path.join(".") || "(root)"; } function collectTokens(root: Record): { records: TokenRecord[]; diagnostics: DtcgDiagnostic[]; } { const records: TokenRecord[] = []; const diagnostics: DtcgDiagnostic[] = []; const visit = ( object: Record, path: string[], inheritedType: string | undefined, depth: number, ) => { if (depth > MAX_DEPTH) throw new Error(`DTCG group nesting exceeds ${MAX_DEPTH} levels.`); const localType = typeof object.$type === "string" ? object.$type : inheritedType; if ("$value" in object || "$ref" in object) { if (records.length >= MAX_TOKENS) throw new Error( `DTCG input exceeds ${MAX_TOKENS.toLocaleString()} tokens.`, ); records.push({ path, object, ...(localType ? { inheritedType: localType } : {}), }); return; } for (const [name, value] of Object.entries(object)) { if (name === "$root") { if (value && typeof value === "object" && !Array.isArray(value)) visit(value as Record, path, localType, depth + 1); else diagnostics.push({ path: pathText(path), severity: "error", message: "$root must contain a token object.", }); continue; } if (name.startsWith("$")) continue; const nextPath = [...path, name]; if (/[$.{}]/u.test(name) || name.length > 200) diagnostics.push({ path: pathText(nextPath), severity: "error", message: "Token/group names must be at most 200 characters and cannot contain $, period, or braces.", }); if (/[$.{}]/u.test(name) || name.length > 200) continue; if (!value || typeof value !== "object" || Array.isArray(value)) { diagnostics.push({ path: pathText(nextPath), severity: "warning", message: "Non-object group member was skipped.", }); continue; } visit(value as Record, nextPath, localType, depth + 1); } }; visit(root, [], undefined, 0); return { records, diagnostics }; } function jsonPointer(root: unknown, pointer: string): unknown { // DTCG 2025.10 specifies #/ as its document-root spelling. Accept the RFC // 6901 empty fragment as well for interoperable local documents. if (pointer === "#" || pointer === "#/") return root; if (!pointer.startsWith("#/")) throw new Error( "Only local JSON Pointer references beginning #/ are supported.", ); let value = root; for (const raw of pointer.slice(2).split("/")) { let decoded: string; try { decoded = decodeURIComponent(raw); } catch { throw new Error(`JSON Pointer has invalid percent encoding: ${pointer}`); } if (/~(?:[^01]|$)/u.test(decoded)) throw new Error(`JSON Pointer has invalid ~ escaping: ${pointer}`); const key = decoded.replaceAll("~1", "/").replaceAll("~0", "~"); if (!value || typeof value !== "object" || !Object.hasOwn(value, key)) throw new Error(`JSON Pointer does not resolve: ${pointer}`); value = (value as Record)[key]; } return value; } function resolveEmbeddedReferences( value: unknown, root: Record, index: TokenIndex, visiting: Set, depth: number, ): unknown { if (depth > 64) throw new Error("DTCG value/reference nesting exceeds 64 levels."); if (Array.isArray(value)) return value.map((item) => resolveEmbeddedReferences(item, root, index, visiting, depth + 1), ); if (!value || typeof value !== "object") return value; const object = value as Record; if ("$ref" in object) { if (typeof object.$ref !== "string") throw new Error("$ref must be a JSON Pointer string."); if (Object.keys(object).length !== 1) throw new Error( "A property-level $ref object cannot have sibling fields.", ); const key = `pointer:${object.$ref}`; if (visiting.has(key)) throw new Error("JSON Pointer cycle detected."); visiting.add(key); try { const resolved = jsonPointer(root, object.$ref); if ( resolved && typeof resolved === "object" && !Array.isArray(resolved) && ("$value" in resolved || "$ref" in resolved) ) { const target = index.byObject.get(resolved); if (target) return resolveValue(target, root, index, visiting); } return resolveEmbeddedReferences( resolved, root, index, visiting, depth + 1, ); } finally { visiting.delete(key); } } return Object.fromEntries( Object.entries(object).map(([name, item]) => [ name, resolveEmbeddedReferences(item, root, index, visiting, depth + 1), ]), ); } function tokenPathReference(value: string, index: TokenIndex): TokenRecord { const match = /^\{([^{}]+)\}$/u.exec(value); if (!match) throw new Error("Token reference must use a complete {group.token} path."); const found = index.byPath.get(match[1]!); if (!found) throw new Error(`Token reference does not resolve: ${value}`); return found; } function resolveValue( record: TokenRecord, root: Record, index: TokenIndex, visiting: Set, ): unknown { const key = pathText(record.path); if (visiting.has(key)) throw new Error("Token reference cycle detected."); visiting.add(key); try { if ("$ref" in record.object) { if (typeof record.object.$ref !== "string") throw new Error("$ref must be a JSON Pointer string."); const resolved = jsonPointer(root, record.object.$ref); if (resolved && typeof resolved === "object") { const target = index.byObject.get(resolved); if (target) return resolveValue(target, root, index, visiting); } return resolveEmbeddedReferences(resolved, root, index, visiting, 0); } const value = record.object.$value; if (typeof value === "string" && /^\{[^{}]+\}$/u.test(value)) return resolveValue( tokenPathReference(value, index), root, index, visiting, ); return resolveEmbeddedReferences(value, root, index, visiting, 0); } finally { visiting.delete(key); } } function referencedRecord( record: TokenRecord, root: Record, index: TokenIndex, ): TokenRecord | undefined { if ("$ref" in record.object) { if (typeof record.object.$ref !== "string") throw new Error("$ref must be a JSON Pointer string."); const resolved = jsonPointer(root, record.object.$ref); if (resolved && typeof resolved === "object") return index.byObject.get(resolved) ?? index.byValue.get(resolved); return undefined; } const value = record.object.$value; return typeof value === "string" && /^\{[^{}]+\}$/u.test(value) ? tokenPathReference(value, index) : undefined; } function resolveType( record: TokenRecord, root: Record, index: TokenIndex, visiting: Set, ): string | undefined { const direct = typeof record.object.$type === "string" ? record.object.$type : record.inheritedType; if (direct) return direct; const key = pathText(record.path); if (visiting.has(key)) throw new Error("Token reference cycle detected."); visiting.add(key); try { const target = referencedRecord(record, root, index); return target ? resolveType(target, root, index, visiting) : undefined; } finally { visiting.delete(key); } } function componentArray(value: unknown): number[] { if ( !Array.isArray(value) || value.length !== 3 || value.some( (component) => typeof component !== "number" || !Number.isFinite(component), ) ) throw new Error( "Color components must be an array of three finite numbers; 'none' components are not supported.", ); return value as number[]; } function inRange(value: number, minimum: number, maximum: number): boolean { return value >= minimum && value <= maximum; } function validateComponents(space: string, components: readonly number[]) { const [first, second, third] = components as [number, number, number]; const rgbLike = new Set([ "srgb", "srgb-linear", "display-p3", "a98-rgb", "prophoto-rgb", "rec2020", "xyz-d50", "xyz-d65", ]); if (rgbLike.has(space)) { if (!components.every((component) => inRange(component, 0, 1))) throw new Error(`${space} components must each be from 0 to 1.`); return; } if (space === "hsl" || space === "hwb") { if ( !inRange(first, 0, 360) || first === 360 || !inRange(second, 0, 100) || !inRange(third, 0, 100) ) throw new Error( `${space.toUpperCase()} requires hue 0–<360 and percentage components 0–100.`, ); return; } if (space === "lab") { if (!inRange(first, 0, 100)) throw new Error("Lab lightness must be from 0 to 100."); return; } if (space === "lch") { if ( !inRange(first, 0, 100) || second < 0 || !inRange(third, 0, 360) || third === 360 ) throw new Error( "LCH requires lightness 0–100, non-negative chroma and hue 0–<360.", ); return; } if (space === "oklab") { if (!inRange(first, 0, 1)) throw new Error("OKLab lightness must be from 0 to 1."); return; } if (space === "oklch") { if ( !inRange(first, 0, 1) || second < 0 || !inRange(third, 0, 360) || third === 360 ) throw new Error( "OKLCH requires lightness 0–1, non-negative chroma and hue 0–<360.", ); return; } throw new Error(`Unsupported DTCG colorSpace: ${space}.`); } function dtcgColour(value: unknown): PaletteEntry["colour"] { if (typeof value === "string") return parseColour(value); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Color $value must be a DTCG color object."); const object = value as Record; if (typeof object.colorSpace !== "string") throw new Error("Color $value requires a colorSpace string."); if ( object.hex !== undefined && (typeof object.hex !== "string" || !/^#[0-9a-f]{6}$/iu.test(object.hex)) ) throw new Error("Optional color hex fallback must use exactly #RRGGBB."); const components = componentArray(object.components); validateComponents(object.colorSpace, components); const alpha = object.alpha === undefined ? 1 : object.alpha; if ( typeof alpha !== "number" || !Number.isFinite(alpha) || alpha < 0 || alpha > 1 ) throw new Error("Color alpha must be a finite number from 0 to 1."); const slash = alpha < 1 ? ` / ${alpha}` : ""; const [first, second, third] = components; const css = object.colorSpace === "hsl" ? `hsl(${first} ${second}% ${third}%${slash})` : object.colorSpace === "hwb" ? `hwb(${first} ${second}% ${third}%${slash})` : ["lab", "lch"].includes(object.colorSpace) ? `${object.colorSpace}(${first}% ${second} ${third}${slash})` : ["oklab", "oklch"].includes(object.colorSpace) ? `${object.colorSpace}(${first} ${second} ${third}${slash})` : `color(${object.colorSpace} ${components.join(" ")}${slash})`; return parseColour(css); } export function parseDtcgTokens(source: string): DtcgImport { if (source.length > MAX_SOURCE) throw new Error("DTCG input exceeds the 2 MiB limit."); let parsed: unknown; try { parsed = JSON.parse(source); } catch { throw new Error("DTCG input is not valid JSON."); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("DTCG document must be a JSON object."); const root = parsed as Record; const inventory = collectTokens(root); const byPath = new Map( inventory.records.map((record) => [pathText(record.path), record]), ); const byObject = new WeakMap(); const byValue = new WeakMap(); for (const record of inventory.records) { byObject.set(record.object, record); const value = record.object.$value; if (value && typeof value === "object") byValue.set(value, record); } const index: TokenIndex = { byPath, byObject, byValue }; const diagnostics = [...inventory.diagnostics]; const entries: PaletteEntry[] = []; let skippedNonColourTokens = 0; for (const record of inventory.records) { const path = pathText(record.path); try { const type = resolveType(record, root, index, new Set()); if (type !== "color") { skippedNonColourTokens += 1; if (!type) diagnostics.push({ path, severity: "error", message: "Token has no explicit, inherited, or reference-resolved $type; type guessing is not allowed.", }); continue; } const raw = resolveValue(record, root, index, new Set()); if (typeof raw === "string" && !/^\{[^{}]+\}$/u.test(raw)) diagnostics.push({ path, severity: "warning", message: "Legacy string color value was accepted; DTCG 2025.10 uses a structured color object.", }); entries.push({ name: record.path.length ? record.path.join("-") : "root", colour: dtcgColour(raw), source: JSON.stringify(raw), }); } catch (error) { diagnostics.push({ path, severity: "error", message: error instanceof Error ? error.message : "Color token could not be resolved.", }); } } return { entries, diagnostics, skippedNonColourTokens, ...(typeof root.$schema === "string" ? { schema: root.$schema } : {}), }; }