192 lines
7.6 KiB
TypeScript
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("");
|
|
}
|