462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
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 } : {}),
|
||
};
|
||
}
|