Release Colour Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 07:11:11 +02:00
parent 0c1fc94b58
commit ed030cada6
34 changed files with 1446 additions and 74 deletions
+461
View File
@@ -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 0100.`,
);
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 0100, 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 01, 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 } : {}),
};
}