@@ -0,0 +1,461 @@
|
||||
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<string, unknown>;
|
||||
inheritedType?: string;
|
||||
}
|
||||
|
||||
interface TokenIndex {
|
||||
byPath: ReadonlyMap<string, TokenRecord>;
|
||||
byObject: WeakMap<object, TokenRecord>;
|
||||
byValue: WeakMap<object, TokenRecord>;
|
||||
}
|
||||
|
||||
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<string, unknown>): {
|
||||
records: TokenRecord[];
|
||||
diagnostics: DtcgDiagnostic[];
|
||||
} {
|
||||
const records: TokenRecord[] = [];
|
||||
const diagnostics: DtcgDiagnostic[] = [];
|
||||
const visit = (
|
||||
object: Record<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, unknown>)[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveEmbeddedReferences(
|
||||
value: unknown,
|
||||
root: Record<string, unknown>,
|
||||
index: TokenIndex,
|
||||
visiting: Set<string>,
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
index: TokenIndex,
|
||||
visiting: Set<string>,
|
||||
): 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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
index: TokenIndex,
|
||||
visiting: Set<string>,
|
||||
): 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const inventory = collectTokens(root);
|
||||
const byPath = new Map(
|
||||
inventory.records.map((record) => [pathText(record.path), record]),
|
||||
);
|
||||
const byObject = new WeakMap<object, TokenRecord>();
|
||||
const byValue = new WeakMap<object, TokenRecord>();
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user