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)); }