- commit
- f014da595a471c42b0711719e5dc724730cd0b96
- parent
- 78137b13dd115626eba1c03cdf5217da4b7e11fc
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 18 | ++++++++---------- |
| A | src/ | 19 | +++++++++++++++++++ |
| M | src/ | 13 | +++++-------- |
| M | src/ | 21 | +++++++++------------ |
| M | src/ | 12 | ++++++------ |
| M | src/ | 7 | ++++--- |
| M | src/ | 4 | ++-- |
@@ -1,14 +1,12 @@
11 import { levels as builtInLevels } from './levels.js';22 import { MOD_SCHEMA_VERSION, validateMod } from './mods.js';3+import { cloneData } from './data/clone.js';34 45 export const CORE_PACK_ID = 'core';56 const LOCAL_LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;67 const ORIGINS = new Set(['local', 'url', 'bundled']);78 89 function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }9-function clone(value) {10- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));11-}1210 function fail(errors) {1311 const list = Array.isArray(errors) ? errors : [errors];1412 const error = new Error(`Cannot register pack:\n- ${list.join('\n- ')}`);@@ -24,7 +22,7 @@ function fail(errors) {
2422 export function createCorePack(sourceLevels = builtInLevels) {2523 return {2624 manifest: { id: CORE_PACK_ID, name: 'Miau Core', version: '1.0.0', engine: MOD_SCHEMA_VERSION },27- levels: clone(sourceLevels),25+ levels: cloneData(sourceLevels),2826 palette: {},2927 vocabulary: [],3028 };@@ -51,7 +49,7 @@ function validatePack(pack) {
5149 5250 function makePackRecord(pack) {5351 validatePack(pack);54- const normalized = clone(pack);52+ const normalized = cloneData(pack);5553 const packId = normalized.manifest.id;5654 const entries = normalized.levels.map(level => {5755 const localId = level.id;@@ -134,24 +132,24 @@ export function createCatalog(options = {}) {
134132 function getPacks() {135133 return order.map(id => {136134 const pack = packs.get(id);137- return { id: pack.id, origin: pack.origin, manifest: clone(pack.manifest), levelCount: pack.levels.length };135+ return { id: pack.id, origin: pack.origin, manifest: cloneData(pack.manifest), levelCount: pack.levels.length };138136 });139137 }140138 /** Return a validated deep clone of the original pack, or undefined. */141139 function getPack(id) {142140 const pack = packs.get(id);143- return pack ? clone(pack.source) : undefined;141+ return pack ? cloneData(pack.source) : undefined;144142 }145143 function getLevels() {146144 return order.flatMap(packId => packs.get(packId).levels.map(entry => ({147- ...clone(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,148- packName: entry.packName, origin: packs.get(entry.packId).origin, palette: clone(entry.palette),145+ ...cloneData(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,146+ packName: entry.packName, origin: packs.get(entry.packId).origin, palette: cloneData(entry.palette),149147 })));150148 }151149 function getLevel(identifier) {152150 const all = getLevels();153151 const found = typeof identifier === 'number' ? all[identifier] : all.find(level => level.id === identifier);154- return found ? clone(found) : undefined;152+ return found ? cloneData(found) : undefined;155153 }156154 function setActiveLevel(identifier) {157155 const level = getLevel(identifier);@@ -0,0 +1,19 @@
1+/**2+ * Clone authored data at module boundaries.3+ *4+ * Runtime pack records use structuredClone when available. Shared payloads5+ * use cloneJsonData to materialize the JSON representation before encoding.6+ */7+export function cloneData(value) {8+ return typeof structuredClone === 'function'9+ ? structuredClone(value)10+ : JSON.parse(JSON.stringify(value));11+}12+13+export function cloneJsonData(value, label = 'data') {14+ try {15+ return JSON.parse(JSON.stringify(value));16+ } catch (error) {17+ throw new TypeError(`${label} must contain JSON data: ${error.message}`);18+ }19+}@@ -1,14 +1,11 @@
11 import { CORE_PACK_ID, createCatalog } from './catalog.js';2+import { cloneData } from './data/clone.js';23 34 export const PACK_LIBRARY_SCHEMA = 1;45 export const DEFAULT_PACK_LIBRARY_KEY = 'miau.pack-library.v1';56 export const MAX_STORED_PACKS = 128;67 export const MAX_STORED_CHARACTERS = 2 * 1024 * 1024;78 8-const clone = value => typeof structuredClone === 'function'9- ? structuredClone(value)10- : JSON.parse(JSON.stringify(value));11-129 function discoverStorage(storage) {1310 if (storage !== undefined) return storage;1411 try { return globalThis.localStorage; } catch { return null; }@@ -41,7 +38,7 @@ export function createPackLibrary(options = {}) {
4138 4239 function persist() {4340 const packs = snapshot(catalog);44- memorySnapshot = clone(packs);41+ memorySnapshot = cloneData(packs);4542 if (!storage || typeof storage.setItem !== 'function') {4643 storageError ||= 'localStorage is unavailable';4744 return false;@@ -89,7 +86,7 @@ export function createPackLibrary(options = {}) {
8986 for (const [index, pack] of data.packs.entries()) {9087 try {9188 catalog.register(pack, { origin: 'local' });92- memorySnapshot.push(clone(pack));89+ memorySnapshot.push(cloneData(pack));9390 } catch (error) {9491 errors.push(`packs[${index}]: ${error instanceof Error ? error.message : String(error)}`);9592 }@@ -107,7 +104,7 @@ export function createPackLibrary(options = {}) {
107104 let suffix = 2;108105 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;109106 while (catalog.getPack(forkId())) suffix++;110- candidate = clone(pack);107+ candidate = cloneData(pack);111108 candidate.manifest.id = forkId();112109 candidate.manifest.forkOf = original;113110 }@@ -133,7 +130,7 @@ export function createPackLibrary(options = {}) {
133130 removePack,134131 getPacks: () => catalog.getPacks(),135132 getPack: id => catalog.getPack(id),136- get persistedPacks() { return clone(memorySnapshot); },133+ get persistedPacks() { return cloneData(memorySnapshot); },137134 get persistence() {138135 return { mode: storageError ? 'memory' : 'storage', error: storageError };139136 },@@ -2,10 +2,7 @@
22 import { loadMod } from './mods.js';33 import { createCatalog } from './catalog.js';44 import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';5-6-function clone(value) {7- return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));8-}5+import { cloneData } from './data/clone.js';96 107 function errorDetails(error) {118 return Array.isArray(error?.errors) ? error.errors.map(String)@@ -71,8 +68,8 @@ function sourceFromView(view) {
7168 if (!view.id || !Array.isArray(view.levels)) return view;7269 return {7370 manifest: manifestFromView(view),74- vocabulary: clone(view.vocabulary || []),75- palette: clone(view.palette || {}),71+ vocabulary: cloneData(view.vocabulary || []),72+ palette: cloneData(view.palette || {}),7673 levels: view.levels.map(level => {7774 const { globalId, localId, packId, packName, origin, palette, ...authored } = level;7875 return authored;@@ -81,7 +78,7 @@ function sourceFromView(view) {
8178 }8279 8380 function createPackView(catalog, source, origin, previews, conflict = null) {84- const data = clone(source);81+ const data = cloneData(source);8582 const view = {8683 id: data.manifest.id,8784 name: data.manifest.name,@@ -90,17 +87,17 @@ function createPackView(catalog, source, origin, previews, conflict = null) {
9087 engine: data.manifest.engine,9188 forkOf: data.manifest.forkOf ?? null,9289 origin: origin || null,93- vocabulary: clone(data.vocabulary || []),90+ vocabulary: cloneData(data.vocabulary || []),9491 levels: data.levels.map(level => ({95- ...clone(level),92+ ...cloneData(level),9693 globalId: `${data.manifest.id}:${level.id}`,9794 localId: level.id,9895 packId: data.manifest.id,9996 packName: data.manifest.name,10097 origin: origin || null,101- palette: clone(data.palette || {}),98+ palette: cloneData(data.palette || {}),10299 })),103- palette: clone(data.palette || {}),100+ palette: cloneData(data.palette || {}),104101 conflict,105102 };106103 previews.set(view, data);@@ -163,7 +160,7 @@ export function createPackService(options = {}) {
163160 let suffix = 2;164161 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;165162 while (catalog.getPack(forkId())) suffix++;166- candidate = clone(source);163+ candidate = cloneData(source);167164 candidate.manifest.id = forkId();168165 candidate.manifest.forkOf = original;169166 }@@ -1,5 +1,6 @@
11 import { drawBoard } from '../render/board.js';22 import { createGame } from '../engine.js';3+import { cloneData } from '../data/clone.js';34 45 const FONT = 'Recursive, ui-monospace, monospace';56 const COLORS = Object.freeze({@@ -8,7 +9,6 @@ const COLORS = Object.freeze({
89 YOU: '#FF4B3E', WIN: '#2EE6A8', accent: '#FFB020', danger: '#FF4FA3',910 });1011 11-const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));1212 const manifestOf = pack => pack?.manifest || pack || {};1313 const levelList = pack => Array.isArray(pack?.levels) ? pack.levels : [];1414 const packName = pack => manifestOf(pack).name || pack?.name || 'Untitled pack';@@ -35,8 +35,8 @@ function fitLabel(ctx, value, maxWidth, size = 16, weight = 800) {
3535 3636 /** Canvas-only Gift controller. It never installs a pack itself. */3737 export function createGiftScene({ pack = null, collision = false, reducedMotion = false, onAction } = {}) {38- let preview = pack ? clone(pack) : null;39- let collides = collision ? (typeof collision === 'object' ? clone(collision) : { id: '', name: 'this pack' }) : null;38+ let preview = pack ? cloneData(pack) : null;39+ let collides = collision ? (typeof collision === 'object' ? cloneData(collision) : { id: '', name: 'this pack' }) : null;4040 let mode = 'prompt';4141 let selected = 0;4242 let open = Boolean(pack);@@ -44,13 +44,13 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
4444 const primaryChoices = () => ['keep', 'later'];4545 const collisionChoices = () => ['replace', 'copy', 'later'];4646 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();47- function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? clone(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? clone(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }47+ function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? cloneData(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? cloneData(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }4848 function show(value = preview, options = {}) { return setPack(value, options); }4949 function hide() { open = false; buttons = []; return api; }5050 function choose(action = choices()[selected]) {5151 if (!open || !preview) return { ok: false, action: null };5252 if (action === 'keep' && collides) { mode = 'collision'; selected = 0; return { ok: true, action: 'collision' }; }53- const result = { ok: true, action, pack: clone(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };53+ const result = { ok: true, action, pack: cloneData(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };5454 hide(); onAction?.(result); return result;5555 }5656 function move(direction) { const values = choices(); if (direction === 'up' || direction === 'left') selected = (selected - 1 + values.length) % values.length; if (direction === 'down' || direction === 'right') selected = (selected + 1) % values.length; return values[selected]; }@@ -128,7 +128,7 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
128128 hintMetrics.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }) => { ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, top + cardHeight - 25, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, top + cardHeight - 25, { size: labelSize, weight: 700, color: COLORS.muted }); hintX += itemWidth + hintGap; }); ctx.restore();129129 return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: top + cardHeight - 25, selected: selectedAction, now };130130 }131- const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? clone(preview) : null; }, get collision() { return collides; } };131+ const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; } };132132 return Object.freeze(api);133133 }134134 @@ -1,3 +1,5 @@
1+import { cloneData } from '../data/clone.js';2+13 /**24 * Canvas Workshop scene.35 *@@ -12,7 +14,6 @@ export const WORKSHOP_COLORS = Object.freeze({
1214 YOU: '#FF4B3E', WIN: '#2EE6A8', PUSH: '#FFB020', STOP: '#8B91FF',1315 });1416 15-const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));1617 const actionsFor = pack => ['play', 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];1718 const actionLabel = { play: 'PLAY', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };1819 const FONT = "'Recursive', ui-monospace, monospace";@@ -43,7 +44,7 @@ function drawKeyHint(ctx, key, verb, right, y, colors) {
4344 function normalizePacks(service, packs) {4445 const source = packs || service?.listPacks?.() || [];4546 return source.map(pack => ({46- ...clone(pack),47+ ...cloneData(pack),4748 actions: actionsFor(pack),4849 levelCount: pack.levelCount ?? pack.levels?.length ?? 0,4950 }));@@ -269,7 +270,7 @@ export function createWorkshopScene(options = {}) {
269270 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',270271 draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,271272 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },272- get hitLayout() { return hitLayout ? clone(hitLayout) : null; },273+ get hitLayout() { return hitLayout ? cloneData(hitLayout) : null; },273274 });274275 }275276 @@ -6,6 +6,7 @@
66 * exportCommonJS(pack) -> deterministic CommonJS source containing only data77 */88 import { validateMod, MOD_SCHEMA_VERSION } from './mods.js';9+import { cloneJsonData } from './data/clone.js';910 1011 export const SHARE_SCHEMA_VERSION = 1;1112 export const MAX_SHARED_JSON_BYTES = 48 * 1024;@@ -29,8 +30,7 @@ function stableStringify(value) {
2930 function byteLength(text) { return new TextEncoder().encode(text).byteLength; }3031 3132 function normalizePack(pack) {32- let copy;33- try { copy = JSON.parse(JSON.stringify(pack)); } catch (error) { throw new TypeError(`shared pack must contain JSON data: ${error.message}`); }33+ const copy = cloneJsonData(pack, 'shared pack');3434 if (!isObject(copy)) throw new TypeError('shared pack must be an object');3535 copy.manifest = { ...copy.manifest, engine: copy.manifest?.engine ?? MOD_SCHEMA_VERSION };3636 copy.levels = Array.isArray(copy.levels) ? copy.levels.map(level => ({