0.28.1 — vendor @joan/procedural-glyph-engine for portable SPA builds (fixes deploy)
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.
This commit is contained in:
687
web/vendor/src/config.js
vendored
Normal file
687
web/vendor/src/config.js
vendored
Normal file
@@ -0,0 +1,687 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user