Files
WRNexusJS/packages/design-studio/src/decisions.ts
T

192 lines
7.6 KiB
TypeScript

import {
resolveDesignSystem,
validateDesignSystem,
type DesignSystem,
type DesignDiagnostic,
} from "@wrnexus/design-system";
export type DecisionLock = string;
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
export function decisionPath(path: string): string[] {
if (typeof path !== "string" || path.length > 512 || !path.startsWith("/"))
throw Error("WRN-STUDIO-LOCK-PATH: use a JSON pointer.");
const parts = path
.slice(1)
.split("/")
.map((p) => {
if (/~(?![01])/u.test(p)) throw Error("Invalid pointer escape.");
return p.replace(/~1/g, "/").replace(/~0/g, "~");
});
if (
!["tokens", "modes", "densities", "recipes", "name", "id", "version", "defaultMode"].includes(
parts[0]!,
) ||
parts.some((p) => !p || forbidden.has(p)) ||
parts.length > 8
)
throw Error("WRN-STUDIO-LOCK-PATH: unsupported decision path.");
return parts;
}
export const pointerPart = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
export function readDecision(value: unknown, path: string): unknown {
let current = value;
for (const part of decisionPath(path)) {
if (!current || typeof current !== "object" || !Object.hasOwn(current, part)) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
export function writeDecision(system: DesignSystem, path: string, value: unknown) {
const parts = decisionPath(path);
let target = system as unknown as Record<string, unknown>;
for (const part of parts.slice(0, -1)) {
if (!Object.hasOwn(target, part)) target[part] = {};
if (!target[part] || typeof target[part] !== "object" || Array.isArray(target[part]))
throw Error("WRN-STUDIO-PATCH: parent must be an object.");
target = target[part] as Record<string, unknown>;
}
const last = parts.at(-1)!;
if (value === undefined) delete target[last];
else target[last] = structuredClone(value);
}
export function validateLocks(locks: unknown): asserts locks is DecisionLock[] {
if (
!Array.isArray(locks) ||
locks.length > 128 ||
locks.some((p) => typeof p !== "string") ||
new Set(locks).size !== locks.length
)
throw Error("WRN-STUDIO-LOCKS: provide up to 128 unique decision paths.");
for (const path of locks) decisionPath(path);
}
function references(value: unknown, result = new Set<string>()) {
if (value && typeof value === "object") {
if ("ref" in value && typeof value.ref === "string") result.add(value.ref);
else for (const child of Object.values(value)) references(child, result);
}
return result;
}
function tokenNames(system: DesignSystem, path: string) {
const [kind, layer, name] = decisionPath(path);
const names = new Set<string>();
if (kind === "tokens")
for (const token of Object.keys(system.tokens)) if (!layer || token === layer) names.add(token);
if (kind === "modes" || kind === "densities") {
const layers = system[kind] ?? {};
for (const [key, tokens] of Object.entries(layers))
if (!layer || key === layer)
for (const token of Object.keys(tokens)) if (!name || name === token) names.add(token);
}
if (kind === "recipes") references(readDecision(system, path), names);
return names;
}
function configurations(a: DesignSystem, b: DesignSystem) {
const modes = [
undefined,
...new Set([...Object.keys(a.modes ?? {}), ...Object.keys(b.modes ?? {})]),
];
const densities = [
undefined,
...new Set([...Object.keys(a.densities ?? {}), ...Object.keys(b.densities ?? {})]),
];
if (modes.length * densities.length > 128)
throw Error("WRN-STUDIO-LOCK-LIMIT: too many lock configurations.");
return modes.flatMap((mode) => densities.map((density) => ({ mode, density })));
}
/** Locks protect raw decisions and effective referenced values, including aliases and modes. */
export function checkDecisionLocks(
before: DesignSystem,
after: DesignSystem,
locks: DecisionLock[],
): DesignDiagnostic[] {
validateLocks(locks);
if (!locks.length) return [];
const diagnostics: DesignDiagnostic[] = [];
const fail = (path: string, message: string) =>
diagnostics.push({ code: "WRN-STUDIO-LOCK", path, message });
const configurationsList = configurations(before, after);
const resolve = (system: DesignSystem, mode?: string, density?: string) =>
resolveDesignSystem(system, {
mode: mode && Object.hasOwn(system.modes ?? {}, mode) ? mode : undefined,
density: density && Object.hasOwn(system.densities ?? {}, density) ? density : undefined,
}).tokens;
const pairs = configurationsList.map(({ mode, density }) => ({
mode,
density,
before: resolve(before, mode, density),
after: resolve(after, mode, density),
}));
for (const path of locks) {
if (JSON.stringify(readDecision(before, path)) !== JSON.stringify(readDecision(after, path))) {
fail(path, "This decision is locked. Unlock it before changing or removing it.");
continue;
}
const names = tokenNames(before, path);
let changed = false;
for (const pair of pairs) {
const parts = decisionPath(path);
if (parts[0] === "modes" && parts[1] && pair.mode !== parts[1]) continue;
if (parts[0] === "densities" && parts[1] && pair.density !== parts[1]) continue;
for (const name of names)
if (
JSON.stringify({ type: pair.before[name]?.type, value: pair.before[name]?.value }) !==
JSON.stringify({ type: pair.after[name]?.type, value: pair.after[name]?.value })
) {
changed = true;
break;
}
if (changed) break;
}
if (changed)
fail(path, "A referenced token or mode changes the effective value of this locked decision.");
}
return diagnostics;
}
/** Local directions copy lock dependencies; arbitrary provider patches must pass checks unchanged. */
export function preserveDecisionLocks(
before: DesignSystem,
after: DesignSystem,
locks: DecisionLock[],
) {
validateLocks(locks);
const next = structuredClone(after);
const names = new Set<string>();
for (const path of locks) {
writeDecision(next, path, readDecision(before, path));
for (const name of tokenNames(before, path)) names.add(name);
}
// The default selection is part of an effective token/recipe decision.
if (names.size) next.defaultMode = before.defaultMode;
for (const name of names) {
for (const token of [
before.tokens[name],
...Object.values(before.modes ?? {}).map((t) => t[name]),
...Object.values(before.densities ?? {}).map((t) => t[name]),
])
for (const ref of references(token)) names.add(ref);
writeDecision(next, "/tokens/" + pointerPart(name), before.tokens[name]);
for (const kind of ["modes", "densities"] as const)
for (const layer of new Set([
...Object.keys(before[kind] ?? {}),
...Object.keys(next[kind] ?? {}),
]))
writeDecision(
next,
`/${kind}/${pointerPart(layer)}/${pointerPart(name)}`,
before[kind]?.[layer]?.[name],
);
}
if (validateDesignSystem(next).length)
throw Error("WRN-STUDIO-LOCK: locked decisions cannot be combined with this direction.");
const failures = checkDecisionLocks(before, next, locks);
if (failures.length) throw Error(failures.map((d) => d.message).join(" "));
return next;
}
export async function designRevision(system: DesignSystem, locks: DecisionLock[] = []) {
validateLocks(locks);
const bytes = new TextEncoder().encode(JSON.stringify({ system, locks: [...locks].sort() }));
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}