The procedural-glyph-engine dep pointed at a non-portable file:/private/tmp/orby-pkg
path, breaking npm ci in Docker and every main deploy since v0.20.0 (the build
cache masked it until it busted ~Aug 5). Vendor Orby v5.0.0 into web/vendor/,
switch the dep to file:../vendor, and use npm install in the web Dockerfile
(file: deps need install, not ci). Cherry-picked from 3cd4cf9.
688 lines
18 KiB
JavaScript
688 lines
18 KiB
JavaScript
import { FIELD_ALIASES, FIELD_IDS } from "./fields.js";
|
|
import { SPRITE_ALIASES, SPRITE_IDS } from "./sprites.js";
|
|
|
|
export const TRANSITION_IDS = Object.freeze([
|
|
"field-morph",
|
|
"seeded-dissolve",
|
|
"radial-cascade",
|
|
"angular-sweep",
|
|
"scanline",
|
|
"contour-trace",
|
|
"path-draw",
|
|
"axis-flip",
|
|
"cluster-dissolve",
|
|
"neighbor-ignite",
|
|
"glitch-bands",
|
|
"instant",
|
|
]);
|
|
|
|
export const PIXEL_SWITCH_IDS = Object.freeze([
|
|
"ordered-dither",
|
|
"temporal-blue-noise",
|
|
"threshold-hysteresis",
|
|
"sdf-wavefront",
|
|
"contour-trace",
|
|
"curl-advect",
|
|
"neighbor-propagation",
|
|
"radial-cascade",
|
|
"path-draw",
|
|
"axis-flip",
|
|
"seeded-dissolve",
|
|
"field-morph",
|
|
]);
|
|
|
|
export const PIXEL_SHAPE_IDS = Object.freeze([
|
|
"disc",
|
|
"square",
|
|
"diamond",
|
|
"capsule",
|
|
"line",
|
|
"ring",
|
|
"cross",
|
|
"square-cross",
|
|
"square-cross-ring",
|
|
]);
|
|
|
|
const TRANSITION_ALIASES = Object.freeze({
|
|
dissolve: "seeded-dissolve",
|
|
radial: "radial-cascade",
|
|
bloom: "radial-cascade",
|
|
spiral: "angular-sweep",
|
|
wave: "scanline",
|
|
shutter: "axis-flip",
|
|
glitch: "glitch-bands",
|
|
"sdf-wavefront": "contour-trace",
|
|
"neighbor-propagation": "neighbor-ignite",
|
|
});
|
|
|
|
const PIXEL_SWITCH_ALIASES = Object.freeze({
|
|
"cluster-dissolve": "seeded-dissolve",
|
|
"neighbor-ignite": "neighbor-propagation",
|
|
});
|
|
|
|
const PIXEL_SHAPE_ALIASES = Object.freeze({
|
|
circle: "disc",
|
|
"rounded-square": "square",
|
|
dot: "disc",
|
|
});
|
|
|
|
const ENGINE_OPTION_KEYS = Object.freeze([
|
|
"ariaLive",
|
|
"autoResize",
|
|
"autoplay",
|
|
"background",
|
|
"canvas",
|
|
"contrast",
|
|
"density",
|
|
"direction",
|
|
"dprMax",
|
|
"field",
|
|
"fieldMode",
|
|
"fps",
|
|
"gridSize",
|
|
"interactive",
|
|
"nonErrorPalette",
|
|
"offPixels",
|
|
"orbBoundary",
|
|
"orbBackgroundColor",
|
|
"orbBackgroundMode",
|
|
"package",
|
|
"palette",
|
|
"paletteOverride",
|
|
"pixelShape",
|
|
"pixelSwitch",
|
|
"previewMode",
|
|
"progress",
|
|
"quality",
|
|
"reducedMotion",
|
|
"seed",
|
|
"speed",
|
|
"sprite",
|
|
"targetGlyph",
|
|
"transition",
|
|
"version",
|
|
]);
|
|
|
|
const NUMBER_OPTIONS = Object.freeze({
|
|
contrast: { minimum: 0.25, maximum: 2 },
|
|
density: { minimum: 0.35, maximum: 1.6 },
|
|
dprMax: { minimum: 0.25, maximum: 8 },
|
|
fps: { minimum: 1, maximum: 120, integer: true },
|
|
gridSize: { minimum: 8, maximum: 96, integer: true },
|
|
progress: { minimum: 0, maximum: 1 },
|
|
speed: { minimum: 0.05, maximum: 5 },
|
|
});
|
|
|
|
const BOOLEAN_OPTIONS = Object.freeze([
|
|
"autoResize",
|
|
"autoplay",
|
|
"background",
|
|
"interactive",
|
|
"offPixels",
|
|
]);
|
|
|
|
const normalizeKey = (value) =>
|
|
String(value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s_]+/g, "-");
|
|
|
|
function distanceBetween(left, right) {
|
|
const a = [...String(left)];
|
|
const b = [...String(right)];
|
|
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
const current = new Array(b.length + 1);
|
|
|
|
for (let row = 1; row <= a.length; row += 1) {
|
|
current[0] = row;
|
|
for (let column = 1; column <= b.length; column += 1) {
|
|
current[column] = Math.min(
|
|
current[column - 1] + 1,
|
|
previous[column] + 1,
|
|
previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1),
|
|
);
|
|
}
|
|
for (let column = 0; column <= b.length; column += 1) {
|
|
previous[column] = current[column];
|
|
}
|
|
}
|
|
return previous[b.length];
|
|
}
|
|
|
|
export function nearestName(value, candidates, options = {}) {
|
|
const input = normalizeKey(value);
|
|
if (!input || !Array.isArray(candidates) || candidates.length === 0) {
|
|
return null;
|
|
}
|
|
let nearest = null;
|
|
let nearestDistance = Infinity;
|
|
for (const candidate of candidates) {
|
|
const distance = distanceBetween(input, normalizeKey(candidate));
|
|
if (distance < nearestDistance) {
|
|
nearest = candidate;
|
|
nearestDistance = distance;
|
|
}
|
|
}
|
|
const maximum =
|
|
options.maxDistance ?? Math.max(2, Math.floor(input.length * 0.34));
|
|
return nearestDistance <= maximum ? nearest : null;
|
|
}
|
|
|
|
export class JoanConfigurationError extends RangeError {
|
|
constructor(message, details = {}) {
|
|
super(message);
|
|
this.name = "JoanConfigurationError";
|
|
this.code = details.code || "invalid_configuration";
|
|
this.path = details.path || null;
|
|
this.value = details.value;
|
|
this.suggestion = details.suggestion || null;
|
|
this.allowed = details.allowed ? [...details.allowed] : null;
|
|
}
|
|
|
|
toIssue() {
|
|
return {
|
|
code: this.code,
|
|
message: this.message,
|
|
path: this.path,
|
|
value: this.value,
|
|
suggestion: this.suggestion,
|
|
severity: "error",
|
|
};
|
|
}
|
|
}
|
|
|
|
function aliasLookup(aliases) {
|
|
return new Map(
|
|
Object.entries(aliases || {}).map(([alias, canonical]) => [
|
|
normalizeKey(alias),
|
|
canonical,
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function validateKnownValue(value, allowed, options = {}) {
|
|
const label = options.label || "value";
|
|
const path = options.path || label;
|
|
const normalized = normalizeKey(value);
|
|
const canonical = new Map(
|
|
allowed.map((candidate) => [normalizeKey(candidate), candidate]),
|
|
);
|
|
if (canonical.has(normalized)) return canonical.get(normalized);
|
|
|
|
const aliases = aliasLookup(options.aliases);
|
|
if (aliases.has(normalized)) return aliases.get(normalized);
|
|
|
|
const accepted = [...allowed, ...Object.keys(options.aliases || {})];
|
|
const suggestion = nearestName(value, accepted);
|
|
const suffix = suggestion ? ` Did you mean "${suggestion}"?` : "";
|
|
throw new JoanConfigurationError(
|
|
`Unknown ${label} "${String(value)}".${suffix}`,
|
|
{
|
|
code: `unknown_${label.replaceAll(" ", "_")}`,
|
|
path,
|
|
value,
|
|
suggestion,
|
|
allowed,
|
|
},
|
|
);
|
|
}
|
|
|
|
export const validateSpriteId = (value) =>
|
|
validateKnownValue(value, SPRITE_IDS, {
|
|
aliases: SPRITE_ALIASES,
|
|
label: "sprite",
|
|
path: "sprite",
|
|
});
|
|
|
|
export const validateFieldId = (value) =>
|
|
validateKnownValue(value, FIELD_IDS, {
|
|
aliases: FIELD_ALIASES,
|
|
label: "field",
|
|
path: "field",
|
|
});
|
|
|
|
export const validateTransition = (value) =>
|
|
validateKnownValue(
|
|
typeof value === "string"
|
|
? value
|
|
: value?.name || value?.type || value?.enter,
|
|
TRANSITION_IDS,
|
|
{
|
|
aliases: TRANSITION_ALIASES,
|
|
label: "transition",
|
|
path: "transition",
|
|
},
|
|
);
|
|
|
|
export const validatePixelShape = (value) =>
|
|
validateKnownValue(
|
|
typeof value === "string" ? value : value?.shape || value?.name,
|
|
PIXEL_SHAPE_IDS,
|
|
{
|
|
aliases: PIXEL_SHAPE_ALIASES,
|
|
label: "pixel shape",
|
|
path: "pixelShape",
|
|
},
|
|
);
|
|
|
|
export const validatePixelSwitch = (value) =>
|
|
validateKnownValue(
|
|
typeof value === "string"
|
|
? value
|
|
: value?.mode || value?.name || value?.type,
|
|
PIXEL_SWITCH_IDS,
|
|
{
|
|
aliases: PIXEL_SWITCH_ALIASES,
|
|
label: "pixel switch",
|
|
path: "pixelSwitch",
|
|
},
|
|
);
|
|
|
|
function issueFrom(error, fallbackPath) {
|
|
if (error instanceof JoanConfigurationError) return error.toIssue();
|
|
return {
|
|
code: "invalid_configuration",
|
|
message: error?.message || "Invalid engine configuration.",
|
|
path: fallbackPath || null,
|
|
value: undefined,
|
|
suggestion: null,
|
|
severity: "error",
|
|
};
|
|
}
|
|
|
|
function inspectNumber(key, value, definition, coerce) {
|
|
if (!coerce && typeof value !== "number") {
|
|
throw new JoanConfigurationError(`${key} must be a number.`, {
|
|
code: "invalid_type",
|
|
path: key,
|
|
value,
|
|
});
|
|
}
|
|
let next = coerce ? Number(value) : value;
|
|
if (!Number.isFinite(next)) {
|
|
throw new JoanConfigurationError(`${key} must be a finite number.`, {
|
|
code: "invalid_number",
|
|
path: key,
|
|
value,
|
|
});
|
|
}
|
|
if (definition.integer && !coerce && !Number.isInteger(next)) {
|
|
throw new JoanConfigurationError(`${key} must be an integer.`, {
|
|
code: "invalid_number",
|
|
path: key,
|
|
value,
|
|
});
|
|
}
|
|
if (definition.integer) next = Math.round(next);
|
|
if (next < definition.minimum || next > definition.maximum) {
|
|
if (!coerce) {
|
|
throw new JoanConfigurationError(
|
|
`${key} must be between ${definition.minimum} and ${definition.maximum}.`,
|
|
{
|
|
code: "out_of_range",
|
|
path: key,
|
|
value,
|
|
},
|
|
);
|
|
}
|
|
next = Math.min(Math.max(next, definition.minimum), definition.maximum);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
function validatePalette(value, path, { allowVariants = true } = {}) {
|
|
if (value === null) return null;
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new JoanConfigurationError(`${path} must be a palette object or null.`, {
|
|
code: "invalid_type",
|
|
path,
|
|
value,
|
|
});
|
|
}
|
|
const allowed = new Set([
|
|
"name",
|
|
"background",
|
|
"shadow",
|
|
"off",
|
|
"ink",
|
|
"accent",
|
|
"glow",
|
|
...(allowVariants ? ["variants"] : []),
|
|
]);
|
|
const next = {};
|
|
for (const [key, color] of Object.entries(value)) {
|
|
if (!allowed.has(key)) {
|
|
const suggestion = nearestName(key, [...allowed]);
|
|
throw new JoanConfigurationError(
|
|
`Unknown ${path} channel "${key}".${
|
|
suggestion ? ` Did you mean "${suggestion}"?` : ""
|
|
}`,
|
|
{
|
|
code: "unknown_palette_channel",
|
|
path: `${path}.${key}`,
|
|
value: color,
|
|
suggestion,
|
|
},
|
|
);
|
|
}
|
|
if (key === "variants") {
|
|
if (!color || typeof color !== "object" || Array.isArray(color)) {
|
|
throw new JoanConfigurationError(`${path}.variants must be an object.`, {
|
|
code: "invalid_type",
|
|
path: `${path}.variants`,
|
|
value: color,
|
|
});
|
|
}
|
|
next.variants = Object.fromEntries(
|
|
Object.entries(color).map(([name, palette]) => [
|
|
name,
|
|
validatePalette(palette, `${path}.variants.${name}`, {
|
|
allowVariants: false,
|
|
}),
|
|
]),
|
|
);
|
|
continue;
|
|
}
|
|
if (typeof color !== "string" || !color.trim()) {
|
|
throw new JoanConfigurationError(`${path}.${key} must be a CSS color string.`, {
|
|
code: "invalid_type",
|
|
path: `${path}.${key}`,
|
|
value: color,
|
|
});
|
|
}
|
|
next[key] = color.trim();
|
|
}
|
|
return next;
|
|
}
|
|
|
|
const PORTABLE_COLOR_KEYWORDS = new Set([
|
|
"aqua",
|
|
"black",
|
|
"blue",
|
|
"fuchsia",
|
|
"gray",
|
|
"green",
|
|
"grey",
|
|
"lime",
|
|
"maroon",
|
|
"navy",
|
|
"olive",
|
|
"orange",
|
|
"purple",
|
|
"red",
|
|
"silver",
|
|
"teal",
|
|
"transparent",
|
|
"white",
|
|
"yellow",
|
|
]);
|
|
|
|
function validateOptionalColor(value, path) {
|
|
if (value === null) return null;
|
|
if (typeof value !== "string" || !value.trim()) {
|
|
throw new JoanConfigurationError(`${path} must be a CSS color string or null.`, {
|
|
code: "invalid_type",
|
|
path,
|
|
value,
|
|
});
|
|
}
|
|
const candidate = value.trim();
|
|
if (candidate.toLowerCase() === "transparent") return null;
|
|
const functionMatch =
|
|
/^(rgb|rgba|hsl|hsla)\(([\d\s.,%+/-]+)\)$/i.exec(candidate);
|
|
const portable =
|
|
/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(candidate) ||
|
|
PORTABLE_COLOR_KEYWORDS.has(candidate.toLowerCase()) ||
|
|
(Boolean(functionMatch) && /\d/.test(functionMatch[2]));
|
|
if (!portable) {
|
|
throw new JoanConfigurationError(
|
|
`${path} must be a portable CSS color (hex, rgb, hsl, a supported keyword) or null.`,
|
|
{
|
|
code: "invalid_color",
|
|
path,
|
|
value,
|
|
},
|
|
);
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export function inspectEngineOptions(input, options = {}) {
|
|
const coerce = options.coerce !== false;
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
const error = new JoanConfigurationError(
|
|
"Engine options must be an object.",
|
|
{ code: "invalid_type", path: "options", value: input },
|
|
);
|
|
return { ok: false, value: {}, issues: [error.toIssue()] };
|
|
}
|
|
|
|
const value = { ...input };
|
|
const issues = [];
|
|
const allowedKeys = new Set(ENGINE_OPTION_KEYS);
|
|
if (options.allowUnknown !== true) {
|
|
for (const key of Object.keys(value)) {
|
|
if (allowedKeys.has(key)) continue;
|
|
const suggestion = nearestName(key, ENGINE_OPTION_KEYS);
|
|
issues.push({
|
|
code: "unknown_option",
|
|
message: `Unknown engine option "${key}".${
|
|
suggestion ? ` Did you mean "${suggestion}"?` : ""
|
|
}`,
|
|
path: key,
|
|
value: value[key],
|
|
suggestion,
|
|
severity: "error",
|
|
});
|
|
delete value[key];
|
|
}
|
|
}
|
|
|
|
const apply = (key, callback) => {
|
|
if (!Object.hasOwn(value, key) || value[key] === undefined) return;
|
|
try {
|
|
value[key] = callback(value[key]);
|
|
} catch (error) {
|
|
issues.push(issueFrom(error, key));
|
|
delete value[key];
|
|
}
|
|
};
|
|
|
|
apply("sprite", (sprite) =>
|
|
sprite && typeof sprite === "object" ? validateRecipe(sprite) : validateSpriteId(sprite),
|
|
);
|
|
apply("field", (field) => (field === null ? null : validateFieldId(field)));
|
|
apply("transition", (transition) =>
|
|
transition === null
|
|
? null
|
|
: transition && typeof transition === "object"
|
|
? {
|
|
...transition,
|
|
name: validateTransition(
|
|
transition.name || transition.type || transition.enter,
|
|
),
|
|
}
|
|
: validateTransition(transition),
|
|
);
|
|
apply("pixelShape", (shape) =>
|
|
shape === null ? null : validatePixelShape(shape),
|
|
);
|
|
apply("pixelSwitch", (pixelSwitch) =>
|
|
pixelSwitch === null
|
|
? null
|
|
: pixelSwitch && typeof pixelSwitch === "object"
|
|
? {
|
|
...pixelSwitch,
|
|
mode: validatePixelSwitch(
|
|
pixelSwitch.mode || pixelSwitch.name || pixelSwitch.type,
|
|
),
|
|
}
|
|
: validatePixelSwitch(pixelSwitch),
|
|
);
|
|
apply("fieldMode", (mode) =>
|
|
validateKnownValue(mode, ["recipe", "override"], {
|
|
label: "field mode",
|
|
path: "fieldMode",
|
|
}),
|
|
);
|
|
apply("quality", (quality) =>
|
|
validateKnownValue(quality, ["low", "balanced", "high", "auto"], {
|
|
label: "quality",
|
|
path: "quality",
|
|
}),
|
|
);
|
|
apply("previewMode", (mode) =>
|
|
validateKnownValue(mode, ["fit", "actual"], {
|
|
label: "preview mode",
|
|
path: "previewMode",
|
|
}),
|
|
);
|
|
apply("orbBoundary", (boundary) =>
|
|
validateKnownValue(boundary, ["defined", "gestalt"], {
|
|
label: "orb boundary",
|
|
path: "orbBoundary",
|
|
}),
|
|
);
|
|
apply("orbBackgroundColor", (color) =>
|
|
validateOptionalColor(color, "orbBackgroundColor"),
|
|
);
|
|
apply("orbBackgroundMode", (mode) =>
|
|
validateKnownValue(mode, ["none", "solid", "pixelated"], {
|
|
label: "orb background mode",
|
|
path: "orbBackgroundMode",
|
|
}),
|
|
);
|
|
apply("reducedMotion", (mode) => {
|
|
if (typeof mode === "boolean" || mode === "system") return mode;
|
|
throw new JoanConfigurationError(
|
|
'reducedMotion must be true, false, or "system".',
|
|
{ code: "invalid_type", path: "reducedMotion", value: mode },
|
|
);
|
|
});
|
|
|
|
for (const [key, definition] of Object.entries(NUMBER_OPTIONS)) {
|
|
apply(key, (number) => inspectNumber(key, number, definition, coerce));
|
|
}
|
|
for (const key of BOOLEAN_OPTIONS) {
|
|
apply(key, (boolean) => {
|
|
if (typeof boolean === "boolean") return boolean;
|
|
if (coerce && (boolean === "true" || boolean === "false")) {
|
|
return boolean === "true";
|
|
}
|
|
throw new JoanConfigurationError(`${key} must be a boolean.`, {
|
|
code: "invalid_type",
|
|
path: key,
|
|
value: boolean,
|
|
});
|
|
});
|
|
}
|
|
for (const key of ["palette", "paletteOverride", "nonErrorPalette"]) {
|
|
apply(key, (palette) => validatePalette(palette, key));
|
|
}
|
|
|
|
return { ok: issues.length === 0, value, issues };
|
|
}
|
|
|
|
export function validateEngineOptions(input, options = {}) {
|
|
const strict = options.strict !== false;
|
|
const result = inspectEngineOptions(input, {
|
|
...options,
|
|
coerce: options.coerce ?? !strict,
|
|
});
|
|
if (!result.ok && strict) {
|
|
const issue = result.issues[0];
|
|
throw new JoanConfigurationError(issue.message, issue);
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
export function validateRecipe(input) {
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
throw new JoanConfigurationError("A recipe must be an object.", {
|
|
code: "invalid_type",
|
|
path: "recipe",
|
|
value: input,
|
|
});
|
|
}
|
|
if (typeof input.id !== "string" || !input.id.trim()) {
|
|
throw new JoanConfigurationError("A recipe requires a non-empty id.", {
|
|
code: "missing_recipe_id",
|
|
path: "recipe.id",
|
|
value: input.id,
|
|
});
|
|
}
|
|
if (typeof input.glyph !== "string" || !input.glyph.trim()) {
|
|
throw new JoanConfigurationError("A recipe requires a glyph id.", {
|
|
code: "missing_recipe_glyph",
|
|
path: "recipe.glyph",
|
|
value: input.glyph,
|
|
});
|
|
}
|
|
|
|
const recipe = { ...input, id: input.id.trim(), glyph: input.glyph.trim() };
|
|
if (recipe.field !== undefined) recipe.field = validateFieldId(recipe.field);
|
|
if (Array.isArray(recipe.fieldMix)) {
|
|
recipe.fieldMix = recipe.fieldMix.map((layer, index) => {
|
|
if (!layer || typeof layer !== "object") {
|
|
throw new JoanConfigurationError(`recipe.fieldMix[${index}] must be an object.`, {
|
|
code: "invalid_type",
|
|
path: `recipe.fieldMix[${index}]`,
|
|
value: layer,
|
|
});
|
|
}
|
|
return { ...layer, field: validateFieldId(layer.field) };
|
|
});
|
|
} else if (!recipe.field) {
|
|
throw new JoanConfigurationError("A recipe requires field or fieldMix.", {
|
|
code: "missing_recipe_field",
|
|
path: "recipe.field",
|
|
value: recipe.field,
|
|
});
|
|
}
|
|
|
|
if (recipe.pixelShape !== undefined) {
|
|
recipe.pixelShape = validatePixelShape(recipe.pixelShape);
|
|
}
|
|
if (recipe.pixel?.shape !== undefined) {
|
|
recipe.pixel = {
|
|
...recipe.pixel,
|
|
shape: validatePixelShape(recipe.pixel.shape),
|
|
};
|
|
}
|
|
if (recipe.pixelSwitch !== undefined) {
|
|
if (typeof recipe.pixelSwitch === "string") {
|
|
recipe.pixelSwitch = validatePixelSwitch(recipe.pixelSwitch);
|
|
} else if (recipe.pixelSwitch?.mode !== undefined) {
|
|
recipe.pixelSwitch = {
|
|
...recipe.pixelSwitch,
|
|
mode: validatePixelSwitch(recipe.pixelSwitch.mode),
|
|
};
|
|
}
|
|
}
|
|
if (recipe.transition !== undefined) {
|
|
if (typeof recipe.transition === "string") {
|
|
recipe.transition = validateTransition(recipe.transition);
|
|
} else if (recipe.transition && typeof recipe.transition === "object") {
|
|
const transition = { ...recipe.transition };
|
|
for (const key of ["name", "type", "enter", "exit"]) {
|
|
if (transition[key] !== undefined) {
|
|
transition[key] = validateTransition(transition[key]);
|
|
}
|
|
}
|
|
recipe.transition = transition;
|
|
}
|
|
}
|
|
return recipe;
|
|
}
|
|
|
|
function deepFreeze(value, seen = new WeakSet()) {
|
|
if (!value || typeof value !== "object" || seen.has(value)) return value;
|
|
seen.add(value);
|
|
for (const nested of Object.values(value)) deepFreeze(nested, seen);
|
|
return Object.freeze(value);
|
|
}
|
|
|
|
export function defineRecipe(recipe) {
|
|
return deepFreeze(validateRecipe(recipe));
|
|
}
|