@rohal12/spindle 0.52.8 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, u
3
3
  import { useSyncExternalStore } from "preact/compat";
4
4
  import { createStore } from "zustand/vanilla";
5
5
  import { immer } from "zustand/middleware/immer";
6
- import { Immer, applyPatches, current, enableMapSet, enablePatches, freeze, isDraft, produceWithPatches } from "immer";
6
+ import { Immer, current, enableMapSet, enablePatches, freeze, isDraft } from "immer";
7
7
  import { shallow } from "zustand/vanilla/shallow";
8
8
  import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
9
9
  import { micromark } from "micromark";
@@ -32,338 +32,6 @@ function create() {
32
32
  return (initializer) => bindPreact(createStore()(initializer));
33
33
  }
34
34
  //#endregion
35
- //#region src/utils/namespace.ts
36
- /** The variable name no namespace can hold. */
37
- var RESERVED_NAME = "__proto__";
38
- /**
39
- * Why `name` (without its sigil) cannot be a variable name, or undefined if
40
- * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
41
- */
42
- function variableNameError(name, label = name) {
43
- return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
44
- }
45
- /** Throw a TypeError if `name` cannot be a variable name. */
46
- function checkVariableName(name, label = name) {
47
- const error = variableNameError(name, label);
48
- if (error) throw new TypeError(`spindle: ${error}`);
49
- }
50
- /** Whether `obj` holds `key` as an own property. */
51
- var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
52
- /** `ns[key]` if `ns` holds it as an own property, else undefined. */
53
- var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
54
- /**
55
- * Set an own enumerable property. A "__proto__" key is defined as an own
56
- * property (as JSON.parse does) instead of replacing the prototype.
57
- */
58
- function setOwn(target, key, value) {
59
- if (key === "__proto__") Object.defineProperty(target, key, {
60
- value,
61
- enumerable: true,
62
- writable: true,
63
- configurable: true
64
- });
65
- else target[key] = value;
66
- }
67
- /**
68
- * A record with no prototype holding the own entries of `sources`, or only
69
- * those whose value passes `keep`.
70
- */
71
- function ownEntries(sources, keep = () => true) {
72
- const record = Object.create(null);
73
- for (const source of sources) {
74
- if (!source) continue;
75
- for (const key of Object.keys(source)) {
76
- const value = source[key];
77
- if (keep(value)) record[key] = value;
78
- }
79
- }
80
- return record;
81
- }
82
- /** A namespace with no prototype holding the own entries of `sources`. */
83
- var createNamespace = (...sources) => ownEntries(sources);
84
- /** Whether `value` is an object with no prototype. */
85
- var isNamespace = (value) => Object.getPrototypeOf(value) === null;
86
- /** `ns` itself if it has no prototype, else a namespace copy of it. */
87
- var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
88
- /** A namespace copy of `ns` with `key` set to `value`. */
89
- function withEntry(ns, key, value) {
90
- const next = createNamespace(ns);
91
- next[key] = value;
92
- return next;
93
- }
94
- /** An empty namespace that cannot change. */
95
- var EMPTY_NAMESPACE = Object.freeze(createNamespace());
96
- /** The names whose own entries differ between `a` and `b`, `a`'s first. */
97
- function changedNames(a, b) {
98
- return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
99
- }
100
- var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
101
- /** The count of `name` in `counts`: its own entry, or 0. */
102
- function countOf(counts, name) {
103
- const value = ownValue(counts, name);
104
- return isCount(value) ? value : 0;
105
- }
106
- /**
107
- * Counters with no prototype holding the own entries of `source` that are
108
- * counts, with `name` (if given) counted once more. Immer copies the
109
- * counters on every write anyway, so counting into a fresh copy costs no
110
- * more than counting in place.
111
- */
112
- function createCounts(source, name) {
113
- const counts = ownEntries([source], isCount);
114
- if (name !== void 0) counts[name] = countOf(source, name) + 1;
115
- return counts;
116
- }
117
- //#endregion
118
- //#region src/class-registry.ts
119
- var registry$1 = /* @__PURE__ */ new Map();
120
- var ctorToName = /* @__PURE__ */ new Map();
121
- function registerClass(name, ctor) {
122
- registry$1.set(name, ctor);
123
- ctorToName.set(ctor, name);
124
- }
125
- /**
126
- * The registered name of the class `value` is an instance of, or undefined.
127
- * Looks the constructor up on the prototype, so an own property named
128
- * "constructor" (plain data) cannot hide or fake the class.
129
- */
130
- function registeredClassName(value) {
131
- const proto = Object.getPrototypeOf(value);
132
- return proto === null ? void 0 : ctorToName.get(proto.constructor);
133
- }
134
- var CLASS_TAG = "__spindle_class__";
135
- var DATA_TAG = "__spindle_data__";
136
- /** A tagged value, as serialize() writes and deserialize() reads it. */
137
- var tagged = (name, data) => ({
138
- [CLASS_TAG]: name,
139
- [DATA_TAG]: data
140
- });
141
- /** Numbers JSON text cannot hold (it writes null for them, and 0 for -0). */
142
- var SPECIAL_NUMBERS = {
143
- NaN: NaN,
144
- Infinity: Infinity,
145
- "-Infinity": -Infinity,
146
- "-0": -0
147
- };
148
- function specialNumberName(value) {
149
- if (Number.isNaN(value)) return "NaN";
150
- if (value === Infinity) return "Infinity";
151
- if (value === -Infinity) return "-Infinity";
152
- if (Object.is(value, -0)) return "-0";
153
- }
154
- /**
155
- * Turn a story value into data that survives JSON text (saves, exports,
156
- * the session) and that deserialize() restores. Date, RegExp, Map, Set and
157
- * registered class instances become tagged objects, and so do the values
158
- * JSON text would drop or change: undefined (also array holes), NaN,
159
- * ±Infinity, -0, bigint, invalid dates, and plain objects with a key named
160
- * like the class tag. Throws on circular references and on a property
161
- * named "__proto__", which story state cannot hold.
162
- */
163
- function serialize(value) {
164
- const seen = /* @__PURE__ */ new Set();
165
- function ser(val) {
166
- if (val === void 0) return tagged("__Undefined__", {});
167
- if (typeof val === "number") {
168
- const name = specialNumberName(val);
169
- return name === void 0 ? val : tagged("__Number__", { value: name });
170
- }
171
- if (typeof val === "bigint") return tagged("__BigInt__", { value: val.toString() });
172
- if (val === null || typeof val !== "object") return val;
173
- if (seen.has(val)) throw new Error("spindle: Cannot serialize circular references");
174
- seen.add(val);
175
- try {
176
- return serObject(val);
177
- } finally {
178
- seen.delete(val);
179
- }
180
- }
181
- function serKeys(obj) {
182
- const data = {};
183
- for (const key of Object.keys(obj)) {
184
- if (key === "__proto__") throw new Error("spindle: Cannot save a property named \"__proto__\"");
185
- data[key] = ser(obj[key]);
186
- }
187
- return data;
188
- }
189
- function serObject(val) {
190
- if (val instanceof Date) return tagged("__Date__", { iso: !Number.isNaN(val.getTime()) ? val.toISOString() : null });
191
- if (val instanceof RegExp) return tagged("__RegExp__", {
192
- source: val.source,
193
- flags: val.flags
194
- });
195
- if (Array.isArray(val)) {
196
- const result = [];
197
- for (let i = 0; i < val.length; i++) result.push(ser(val[i]));
198
- return result;
199
- }
200
- if (val instanceof Map) return tagged("__Map__", { entries: [...val].map(([k, v]) => [ser(k), ser(v)]) });
201
- if (val instanceof Set) return tagged("__Set__", { entries: [...val].map((v) => ser(v)) });
202
- const name = registeredClassName(val);
203
- if (name !== void 0) return tagged(name, serKeys(val));
204
- const data = serKeys(val);
205
- return hasOwn(val, CLASS_TAG) ? tagged("__Object__", data) : data;
206
- }
207
- return ser(value);
208
- }
209
- function deserialize(value) {
210
- function deserKeys(target, data) {
211
- for (const key of Object.keys(data)) setOwn(target, key, deser(data[key]));
212
- return target;
213
- }
214
- function deser(val) {
215
- if (val === null || typeof val !== "object") return val;
216
- if (Array.isArray(val)) return val.map((item) => deser(item));
217
- const obj = val;
218
- if (CLASS_TAG in obj && DATA_TAG in obj) {
219
- const name = obj[CLASS_TAG];
220
- const data = obj[DATA_TAG];
221
- switch (name) {
222
- case "__Date__": return new Date(data.iso === null ? NaN : data.iso);
223
- case "__RegExp__": return new RegExp(data.source, data.flags);
224
- case "__Map__": {
225
- const entries = data.entries;
226
- return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
227
- }
228
- case "__Set__": return new Set(data.entries.map((v) => deser(v)));
229
- case "__Undefined__": return;
230
- case "__Number__": return SPECIAL_NUMBERS[data.value];
231
- case "__BigInt__": return BigInt(data.value);
232
- case "__Object__": return deserKeys({}, data);
233
- }
234
- const ctor = registry$1.get(name);
235
- if (!ctor) {
236
- console.warn(`spindle: Class "${name}" not registered. Falling back to plain object.`);
237
- return deserKeys({}, data);
238
- }
239
- return deserKeys(Object.create(ctor.prototype), data);
240
- }
241
- if (val instanceof Date || val instanceof RegExp || val instanceof Map || val instanceof Set) return val;
242
- if (registeredClassName(obj) !== void 0) return val;
243
- return deserKeys({}, obj);
244
- }
245
- return deser(value);
246
- }
247
- function isDataRecord(value) {
248
- return typeof value === "object" && value !== null && !Array.isArray(value);
249
- }
250
- /**
251
- * Whether `value` is serialized data that deserialize() can restore: every
252
- * tagged value has the shape serialize() writes for its tag (a valid ISO
253
- * date or null, a compilable RegExp, Map entries as `[key, value]` pairs,
254
- * Set entries as an array, a special number's name, a bigint in decimal,
255
- * class data as an object) and no object has a "__proto__" key, at any
256
- * depth. Use it on data from outside the running story, such as an
257
- * imported save, before storing it. Tags of unregistered classes pass;
258
- * they load as plain objects.
259
- */
260
- function isDeserializable(value) {
261
- const path = /* @__PURE__ */ new Set();
262
- function check(val) {
263
- if (val === null || typeof val !== "object") return true;
264
- if (path.has(val)) return false;
265
- path.add(val);
266
- const ok = checkObject(val);
267
- path.delete(val);
268
- return ok;
269
- }
270
- const checkKeys = (obj) => Object.keys(obj).every((key) => key !== "__proto__" && check(obj[key]));
271
- function checkObject(val) {
272
- if (Array.isArray(val)) return Array.from(val).every(check);
273
- const obj = val;
274
- if (!(CLASS_TAG in obj && DATA_TAG in obj)) return checkKeys(obj);
275
- const name = obj[CLASS_TAG];
276
- const data = obj[DATA_TAG];
277
- if (typeof name !== "string" || !isDataRecord(data)) return false;
278
- switch (name) {
279
- case "__Date__": return data.iso === null || typeof data.iso === "string" && !Number.isNaN(new Date(data.iso).getTime());
280
- case "__RegExp__":
281
- if (typeof data.source !== "string" || typeof data.flags !== "string") return false;
282
- try {
283
- new RegExp(data.source, data.flags);
284
- return true;
285
- } catch {
286
- return false;
287
- }
288
- case "__Map__": return Array.isArray(data.entries) && Array.from(data.entries).every((entry) => Array.isArray(entry) && entry.length === 2 && check(entry[0]) && check(entry[1]));
289
- case "__Set__": return Array.isArray(data.entries) && Array.from(data.entries).every(check);
290
- case "__Undefined__": return true;
291
- case "__Number__": return typeof data.value === "string" && hasOwn(SPECIAL_NUMBERS, data.value);
292
- case "__BigInt__": return typeof data.value === "string" && /^-?\d+$/.test(data.value);
293
- default: return checkKeys(data);
294
- }
295
- }
296
- return check(value);
297
- }
298
- //#endregion
299
- //#region src/saves/types.ts
300
- function isSaveExport(value) {
301
- if (typeof value !== "object" || value === null) return false;
302
- const obj = value;
303
- if (obj.version !== 1 || typeof obj.ifid !== "string") return false;
304
- if (typeof obj.save !== "object" || obj.save === null) return false;
305
- const save = obj.save;
306
- if (typeof save.meta !== "object" || save.meta === null) return false;
307
- if (typeof save.payload !== "object" || save.payload === null) return false;
308
- const meta = save.meta;
309
- if (typeof meta.id !== "string" || typeof meta.passage !== "string") return false;
310
- if (typeof meta.ifid !== "string") return false;
311
- if (typeof meta.playthroughId !== "string") return false;
312
- if (typeof meta.createdAt !== "string") return false;
313
- if (typeof meta.updatedAt !== "string") return false;
314
- if (typeof meta.title !== "string") return false;
315
- return isImportablePayload(save.payload);
316
- }
317
- function isRecord(value) {
318
- return typeof value === "object" && value !== null && !Array.isArray(value);
319
- }
320
- /** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
321
- function isOptionalPRNGSnapshot(value) {
322
- if (value == null) return true;
323
- return isRecord(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
324
- }
325
- /** A variable container whose values deserialize() can restore. */
326
- function isSerializedVariables(value) {
327
- return isRecord(value) && isDeserializable(value);
328
- }
329
- function isSaveHistoryMoment(value) {
330
- return isRecord(value) && typeof value.passage === "string" && isSerializedVariables(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
331
- }
332
- /**
333
- * Full check of a payload from outside the running story (an imported save):
334
- * every history moment is well formed, `historyIndex` is an integer that
335
- * points at a moment of the payload's passage, and the encoded values (Map,
336
- * Set, Date, RegExp, class instances) in the live variables and every
337
- * moment's variables have the shape deserialize() expects. Anything that
338
- * passes can be stored and later loaded.
339
- */
340
- function isImportablePayload(value) {
341
- if (!isRecord(value)) return false;
342
- if (typeof value.passage !== "string") return false;
343
- if (!isSerializedVariables(value.variables)) return false;
344
- const { history, historyIndex } = value;
345
- if (!Array.isArray(history) || history.length === 0) return false;
346
- if (!Array.from(history).every(isSaveHistoryMoment)) return false;
347
- if (typeof historyIndex !== "number" || !Number.isInteger(historyIndex) || historyIndex < 0 || historyIndex >= history.length) return false;
348
- if (history[historyIndex].passage !== value.passage) return false;
349
- if (value.visitCounts !== void 0 && !isRecord(value.visitCounts)) return false;
350
- if (value.renderCounts !== void 0 && !isRecord(value.renderCounts)) return false;
351
- return isOptionalPRNGSnapshot(value.prng);
352
- }
353
- function isSavePayload(value) {
354
- if (typeof value !== "object" || value === null) return false;
355
- const obj = value;
356
- if (typeof obj.passage !== "string") return false;
357
- if (typeof obj.variables !== "object" || obj.variables === null) return false;
358
- if (!Array.isArray(obj.history) || obj.history.length === 0) return false;
359
- if (typeof obj.historyIndex !== "number") return false;
360
- return true;
361
- }
362
- /** Estimated byte size of a serialized save payload. */
363
- function estimatePayloadBytes(payload) {
364
- return JSON.stringify(payload).length;
365
- }
366
- //#endregion
367
35
  //#region src/js-lexer.ts
368
36
  /** Transient name after `%`: an identifier, so `%3` is never a reference. */
369
37
  var TRANS_NAME_RE = /[A-Za-z_]\w*/y;
@@ -2660,6 +2328,89 @@ function getPRNGPull() {
2660
2328
  return currentPull;
2661
2329
  }
2662
2330
  //#endregion
2331
+ //#region src/utils/namespace.ts
2332
+ /** The variable name no namespace can hold. */
2333
+ var RESERVED_NAME = "__proto__";
2334
+ /**
2335
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
2336
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
2337
+ */
2338
+ function variableNameError(name, label = name) {
2339
+ return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
2340
+ }
2341
+ /** Throw a TypeError if `name` cannot be a variable name. */
2342
+ function checkVariableName(name, label = name) {
2343
+ const error = variableNameError(name, label);
2344
+ if (error) throw new TypeError(`spindle: ${error}`);
2345
+ }
2346
+ /** Whether `obj` holds `key` as an own property. */
2347
+ var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
2348
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2349
+ var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
2350
+ /**
2351
+ * Set an own enumerable property. A "__proto__" key is defined as an own
2352
+ * property (as JSON.parse does) instead of replacing the prototype.
2353
+ */
2354
+ function setOwn(target, key, value) {
2355
+ if (key === "__proto__") Object.defineProperty(target, key, {
2356
+ value,
2357
+ enumerable: true,
2358
+ writable: true,
2359
+ configurable: true
2360
+ });
2361
+ else target[key] = value;
2362
+ }
2363
+ /**
2364
+ * A record with no prototype holding the own entries of `sources`, or only
2365
+ * those whose value passes `keep`.
2366
+ */
2367
+ function ownEntries(sources, keep = () => true) {
2368
+ const record = Object.create(null);
2369
+ for (const source of sources) {
2370
+ if (!source) continue;
2371
+ for (const key of Object.keys(source)) {
2372
+ const value = source[key];
2373
+ if (keep(value)) record[key] = value;
2374
+ }
2375
+ }
2376
+ return record;
2377
+ }
2378
+ /** A namespace with no prototype holding the own entries of `sources`. */
2379
+ var createNamespace = (...sources) => ownEntries(sources);
2380
+ /** Whether `value` is an object with no prototype. */
2381
+ var isNamespace = (value) => Object.getPrototypeOf(value) === null;
2382
+ /** `ns` itself if it has no prototype, else a namespace copy of it. */
2383
+ var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
2384
+ /** A namespace copy of `ns` with `key` set to `value`. */
2385
+ function withEntry(ns, key, value) {
2386
+ const next = createNamespace(ns);
2387
+ next[key] = value;
2388
+ return next;
2389
+ }
2390
+ /** An empty namespace that cannot change. */
2391
+ var EMPTY_NAMESPACE = Object.freeze(createNamespace());
2392
+ /** The names whose own entries differ between `a` and `b`, `a`'s first. */
2393
+ function changedNames(a, b) {
2394
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
2395
+ }
2396
+ var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
2397
+ /** The count of `name` in `counts`: its own entry, or 0. */
2398
+ function countOf(counts, name) {
2399
+ const value = ownValue(counts, name);
2400
+ return isCount(value) ? value : 0;
2401
+ }
2402
+ /**
2403
+ * Counters with no prototype holding the own entries of `source` that are
2404
+ * counts, with `name` (if given) counted once more. Immer copies the
2405
+ * counters on every write anyway, so counting into a fresh copy costs no
2406
+ * more than counting in place.
2407
+ */
2408
+ function createCounts(source, name) {
2409
+ const counts = ownEntries([source], isCount);
2410
+ if (name !== void 0) counts[name] = countOf(source, name) + 1;
2411
+ return counts;
2412
+ }
2413
+ //#endregion
2663
2414
  //#region src/expression.ts
2664
2415
  var FN_CACHE_MAX = 500;
2665
2416
  var fnCache = /* @__PURE__ */ new Map();
@@ -2793,7 +2544,7 @@ function namespaceArg(ns) {
2793
2544
  return ns;
2794
2545
  }
2795
2546
  /** Run compiled code in `namespaces`. */
2796
- function run(fn, ...[variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE]) {
2547
+ function run$1(fn, ...[variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE]) {
2797
2548
  return fn(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2798
2549
  }
2799
2550
  /**
@@ -2802,7 +2553,7 @@ function run(fn, ...[variables, temporary, locals = EMPTY_NAMESPACE, transient =
2802
2553
  */
2803
2554
  function evaluate(expr, ...namespaces) {
2804
2555
  const body = `return (${transform(expr)}\n);`;
2805
- return run(getOrCompile(body, body), ...namespaces);
2556
+ return run$1(getOrCompile(body, body), ...namespaces);
2806
2557
  }
2807
2558
  /**
2808
2559
  * Execute statements (no return value).
@@ -2810,16 +2561,16 @@ function evaluate(expr, ...namespaces) {
2810
2561
  */
2811
2562
  function execute(code, ...namespaces) {
2812
2563
  const transformed = transform(code, "statements");
2813
- run(getOrCompile("exec:" + transformed, transformed), ...namespaces);
2564
+ run$1(getOrCompile("exec:" + transformed, transformed), ...namespaces);
2814
2565
  }
2815
2566
  //#endregion
2816
2567
  //#region src/registry.ts
2817
- var registry = new NameMap();
2568
+ var registry$1 = new NameMap();
2818
2569
  function registerMacro(name, component) {
2819
- registry.set(name, component);
2570
+ registry$1.set(name, component);
2820
2571
  }
2821
2572
  function getMacro(name) {
2822
- return registry.get(name);
2573
+ return registry$1.get(name);
2823
2574
  }
2824
2575
  var textRegistry = new NameMap();
2825
2576
  /** Register (or, with undefined, remove) the text form of a macro. */
@@ -3712,9 +3463,14 @@ function MacroError({ macro, error }) {
3712
3463
  children: `{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`
3713
3464
  });
3714
3465
  }
3715
- /** Log an error in the macro `{label}` (its name, or name and arguments). */
3716
- function logMacroError(label, error) {
3717
- console.error(`spindle: Error in {${label}}${currentSourceLocation()}:`, error);
3466
+ /**
3467
+ * Log an error in the macro `{label}` (its name, or name and arguments) at
3468
+ * `location`, the source location of the macro's passage. Take it before
3469
+ * running the code that failed (see currentSourceLocation): code that
3470
+ * navigates changes the current passage.
3471
+ */
3472
+ function logMacroError(label, error, location = currentSourceLocation()) {
3473
+ console.error(`spindle: Error in {${label}}${location}:`, error);
3718
3474
  }
3719
3475
  /**
3720
3476
  * Run `effect` once, during the macro's first render. If it throws, the
@@ -3725,11 +3481,12 @@ function useRunOnce(macro, rawArgs, effect) {
3725
3481
  const failure = useRef();
3726
3482
  if (failure.current === void 0) {
3727
3483
  failure.current = null;
3484
+ const location = currentSourceLocation();
3728
3485
  try {
3729
3486
  effect();
3730
3487
  } catch (error) {
3731
3488
  failure.current = { error };
3732
- logMacroError(`${macro} ${rawArgs}`, error);
3489
+ logMacroError(`${macro} ${rawArgs}`, error, location);
3733
3490
  }
3734
3491
  }
3735
3492
  if (!failure.current) return null;
@@ -3767,6 +3524,7 @@ var RawTextContext = createContext(false);
3767
3524
  * no live form properties, so they are set as written (see splitAttributes).
3768
3525
  */
3769
3526
  var SvgContext = createContext(false);
3527
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
3770
3528
  /** Elements whose content is literal text, not markdown. */
3771
3529
  var PREFORMATTED_ELEMENTS = new Set(["pre", "textarea"]);
3772
3530
  var WidgetChildrenContext = createContext(null);
@@ -3811,20 +3569,42 @@ function expandPlaceholderText(text, ph) {
3811
3569
  return splitPlaceholderText(text, ph).map((part) => typeof part === "number" ? ph.components[part] : part);
3812
3570
  }
3813
3571
  /**
3814
- * An element whose attributes contain placeholders (a variable in image alt
3815
- * text or a link title). Each such attribute is evaluated as text against
3816
- * the store and locals, so it follows its variables as the component would.
3572
+ * An element of markdown output whose attributes contain placeholders (a
3573
+ * variable in image alt text or a link title). Each such attribute is
3574
+ * evaluated as text against the store and locals, so it follows its
3575
+ * variables as the component would.
3817
3576
  */
3818
- function PlaceholderAttributes({ tag, props, attributes, children }) {
3577
+ function PlaceholderAttributes({ tag, attributes, placeholders, svg, children }) {
3819
3578
  const scope = useTextScope();
3820
- const resolved = { ...props };
3821
3579
  const errors = [];
3822
- for (const [name, nodes] of Object.entries(attributes)) {
3580
+ const { props, direct } = splitAttributes(attributes.map(([name, value, written]) => {
3581
+ const nodes = placeholders[name];
3582
+ if (!nodes) return [
3583
+ name,
3584
+ value,
3585
+ written
3586
+ ];
3823
3587
  const result = renderText(nodes, scope);
3824
- resolved[name] = result.text;
3825
3588
  for (const error of result.errors) errors.push([name, error]);
3826
- }
3827
- return withAttributeErrors(errors, h(tag, resolved, ...children));
3589
+ return [
3590
+ name,
3591
+ result.text,
3592
+ written
3593
+ ];
3594
+ }), svg);
3595
+ const ref = useDirectAttributes(direct);
3596
+ return withAttributeErrors(errors, h(tag, ref ? {
3597
+ ...props,
3598
+ ref
3599
+ } : props, ...children));
3600
+ }
3601
+ /** An element of markdown output with attributes to set directly. */
3602
+ function DirectAttributes({ tag, props, direct, children }) {
3603
+ const ref = useDirectAttributes(direct);
3604
+ return h(tag, {
3605
+ ...props,
3606
+ ref
3607
+ }, ...children);
3828
3608
  }
3829
3609
  /**
3830
3610
  * Show the errors met in an element's attributes in front of it, the way a
@@ -3846,16 +3626,29 @@ function placeholderIndex(node, ph) {
3846
3626
  return parseInt(value.slice(prefix.length), 10);
3847
3627
  }
3848
3628
  /**
3849
- * Convert an HTML string (from micromark) to Preact VNodes,
3850
- * replacing placeholder elements with pre-rendered components.
3851
- * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
3629
+ * Parse HTML into nodes without side effects. The content of a `<template>`
3630
+ * belongs to a document with no browsing context, so parsing it fetches no
3631
+ * images and media, runs no inline event handlers (`<img onerror>`) and
3632
+ * constructs no custom elements. A detached `<div>` belongs to the page's
3633
+ * document, which does all of these while parsing.
3634
+ */
3635
+ function parseHtmlInert(html) {
3636
+ const template = document.createElement("template");
3637
+ template.innerHTML = html;
3638
+ return template.content;
3639
+ }
3640
+ /**
3641
+ * Convert an HTML string (from micromark) to Preact VNodes,
3642
+ * replacing placeholder elements with pre-rendered components.
3643
+ * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
3852
3644
  * replaced by their children.
3853
3645
  */
3854
3646
  function htmlToPreact(html, ph, unwrapParagraphs = false) {
3855
- const temp = document.createElement("div");
3856
- temp.innerHTML = html.trim();
3857
- if (unwrapParagraphs) for (const p of Array.from(temp.querySelectorAll(":scope > p"))) p.replaceWith(...Array.from(p.childNodes));
3858
- return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(temp.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
3647
+ const content = parseHtmlInert(html.trim());
3648
+ if (unwrapParagraphs) {
3649
+ for (const child of Array.from(content.childNodes)) if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === "P") child.replaceWith(...Array.from(child.childNodes));
3650
+ }
3651
+ return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(content.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
3859
3652
  }
3860
3653
  function convertDomNode(node, key, ph) {
3861
3654
  if (node.nodeType === Node.TEXT_NODE) {
@@ -3869,25 +3662,36 @@ function convertDomNode(node, key, ph) {
3869
3662
  const tag = el.localName;
3870
3663
  const idx = placeholderIndex(el, ph);
3871
3664
  if (idx !== -1) return ph.components[idx];
3872
- const props = {};
3873
- let withPlaceholders;
3874
- for (const attr of Array.from(el.attributes)) {
3875
- const parts = splitPlaceholderText(attr.value, ph);
3876
- if (parts.every((part) => typeof part === "string")) {
3877
- props[attr.name] = attr.value;
3878
- continue;
3879
- }
3880
- withPlaceholders ??= {};
3881
- withPlaceholders[attr.name] = parts.map((part) => typeof part === "string" ? {
3665
+ const svg = el.namespaceURI === SVG_NAMESPACE;
3666
+ const attributes = [];
3667
+ let placeholders;
3668
+ for (const { name, value } of Array.from(el.attributes)) {
3669
+ attributes.push([
3670
+ name,
3671
+ value,
3672
+ value
3673
+ ]);
3674
+ const parts = splitPlaceholderText(value, ph);
3675
+ if (parts.every((part) => typeof part === "string")) continue;
3676
+ placeholders ??= {};
3677
+ placeholders[name] = parts.map((part) => typeof part === "string" ? {
3882
3678
  type: "text",
3883
3679
  value: part
3884
3680
  } : ph.nodes[part]);
3885
3681
  }
3886
3682
  const children = Array.from(el.childNodes).map((child, i) => convertDomNode(child, i, ph));
3887
- if (withPlaceholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3683
+ if (placeholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3684
+ tag,
3685
+ attributes,
3686
+ placeholders,
3687
+ svg,
3688
+ children
3689
+ }, key);
3690
+ const { props, direct } = splitAttributes(attributes, svg);
3691
+ if (direct.length > 0) return /* @__PURE__ */ jsx(DirectAttributes, {
3888
3692
  tag,
3889
3693
  props,
3890
- attributes: withPlaceholders,
3694
+ direct,
3891
3695
  children
3892
3696
  }, key);
3893
3697
  return h(tag, {
@@ -4002,9 +3806,7 @@ function setRawAttribute(el, name, value) {
4002
3806
  el.setAttribute(name, value);
4003
3807
  return;
4004
3808
  }
4005
- const template = document.createElement("template");
4006
- template.innerHTML = `<i ${name}=""></i>`;
4007
- const parsed = template.content.firstChild.attributes[0];
3809
+ const parsed = parseHtmlInert(`<i ${name}=""></i>`).firstChild.attributes[0];
4008
3810
  if (!parsed) return;
4009
3811
  const attr = parsed.cloneNode();
4010
3812
  attr.value = value;
@@ -4042,6 +3844,23 @@ function splitAttributes(attributes, svg) {
4042
3844
  direct
4043
3845
  };
4044
3846
  }
3847
+ /**
3848
+ * Set the attributes `splitAttributes` leaves to set directly on the element
3849
+ * given the returned ref (none if there are none), and remove them again
3850
+ * when they change.
3851
+ */
3852
+ function useDirectAttributes(direct) {
3853
+ const elementRef = useRef(null);
3854
+ useLayoutEffect(() => {
3855
+ const el = elementRef.current;
3856
+ if (!el || direct.length === 0) return;
3857
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
3858
+ return () => {
3859
+ for (const [name] of direct) el.removeAttribute(name);
3860
+ };
3861
+ }, [JSON.stringify(direct)]);
3862
+ return direct.length > 0 ? elementRef : void 0;
3863
+ }
4045
3864
  var decodedAttributeText = /* @__PURE__ */ new Map();
4046
3865
  /**
4047
3866
  * Decode character references (`&amp;`, `&#123;`) in attribute text the way
@@ -4051,9 +3870,7 @@ function decodeAttributeText(text) {
4051
3870
  if (!text.includes("&")) return text;
4052
3871
  let decoded = decodedAttributeText.get(text);
4053
3872
  if (decoded === void 0) {
4054
- const template = document.createElement("template");
4055
- template.innerHTML = `<i title="${text.replace(/"/g, "&quot;")}"></i>`;
4056
- decoded = template.content.firstChild.getAttribute("title") ?? text;
3873
+ decoded = parseHtmlInert(`<i title="${text.replace(/"/g, "&quot;")}"></i>`).firstChild.getAttribute("title") ?? text;
4057
3874
  decodedAttributeText.set(text, decoded);
4058
3875
  }
4059
3876
  return decoded;
@@ -4118,16 +3935,8 @@ function HtmlNodeRenderer({ node }) {
4118
3935
  resolveAttributeValue(k, v, scope, errors),
4119
3936
  v
4120
3937
  ]), inSvg || isSvgRoot);
4121
- const elementRef = useRef(null);
4122
- useLayoutEffect(() => {
4123
- const el = elementRef.current;
4124
- if (!el || direct.length === 0) return;
4125
- for (const [name, value] of direct) setRawAttribute(el, name, value);
4126
- return () => {
4127
- for (const [name] of direct) el.removeAttribute(name);
4128
- };
4129
- }, [JSON.stringify(direct)]);
4130
- if (direct.length > 0) props.ref = elementRef;
3938
+ const ref = useDirectAttributes(direct);
3939
+ if (ref) props.ref = ref;
4131
3940
  const isInline = INLINE_ELEMENTS.has(tag);
4132
3941
  let children = void 0;
4133
3942
  if (node.children.length > 0) if (inRaw || isRawRoot) children = renderInlineNodes(node.children);
@@ -4240,88 +4049,1183 @@ var PLACEHOLDER_STRIP_RE = /<span data-tw=[0-9a-z]+:\d+><\/span>/g;
4240
4049
  /** Whitespace that markdown strips at the start and end of a paragraph. */
4241
4050
  var EDGE_WS = " \r\n";
4242
4051
  /**
4243
- * Index of the first character from `from` on (`step` 1) or before `from`
4244
- * (`step` -1) that is not in `chars`, or where the run of them ends. A loop,
4245
- * not a regex: `/[ \t]*$/` and the like try each position of a whitespace
4246
- * run, taking quadratic time on a long run in the middle of a passage.
4052
+ * Index of the first character from `from` on (`step` 1) or before `from`
4053
+ * (`step` -1) that is not in `chars`, or where the run of them ends. A loop,
4054
+ * not a regex: `/[ \t]*$/` and the like try each position of a whitespace
4055
+ * run, taking quadratic time on a long run in the middle of a passage.
4056
+ */
4057
+ function skipChars(s, from, step, chars) {
4058
+ let i = from;
4059
+ if (step === 1) while (i < s.length && chars.includes(s[i])) i++;
4060
+ else while (i > 0 && chars.includes(s[i - 1])) i--;
4061
+ return i;
4062
+ }
4063
+ /**
4064
+ * Drop the spaces and tabs around line endings, as markdown does within a
4065
+ * paragraph.
4066
+ */
4067
+ function trimLineEdges(text) {
4068
+ if (!text.includes("\n")) return text;
4069
+ return text.split("\n").map((line, k, lines) => {
4070
+ const start = k === 0 ? 0 : skipChars(line, 0, 1, " ");
4071
+ const end = k === lines.length - 1 ? line.length : skipChars(line, line.length, -1, " ");
4072
+ return line.slice(start, Math.max(start, end));
4073
+ }).join("\n");
4074
+ }
4075
+ /**
4076
+ * Build Preact vnodes from a combined string that contains only plain text
4077
+ * and placeholders. No micromark, no innerHTML. The text is the content of
4078
+ * one paragraph, so like micromark it drops spaces and tabs around line
4079
+ * endings; its edges are already split off by the caller.
4080
+ */
4081
+ function buildPlainTextVnodes(core, ph, unwrapParagraphs) {
4082
+ const children = expandPlaceholderText(trimLineEdges(core), ph).filter((part) => part !== "");
4083
+ return unwrapParagraphs ? /* @__PURE__ */ jsx(Fragment$1, { children }) : h("p", null, ...children);
4084
+ }
4085
+ /**
4086
+ * Render AST nodes with full CommonMark markdown support.
4087
+ *
4088
+ * Combines all nodes into a single markdown document, using placeholder
4089
+ * elements (see Placeholders) for non-text nodes (variables, macros, links,
4090
+ * HTML). This allows
4091
+ * markdown syntax to span across Twine tokens — e.g., markdown tables can
4092
+ * contain {$variables} and {macros} in their cells.
4093
+ *
4094
+ * After micromark processes the combined string, the HTML is parsed back into
4095
+ * Preact VNodes with placeholders replaced by the real rendered components.
4096
+ * Placeholders that micromark escaped as code text are swapped back too (see
4097
+ * PLACEHOLDER_TEXT_RE), so variables in code stay live components.
4098
+ */
4099
+ function renderNodes(nodes, options) {
4100
+ if (nodes.length === 0) return null;
4101
+ if (options?.raw) return renderInlineNodes(nodes);
4102
+ if (!nodes.some((n) => n.type === "text" && (n.value.trim() !== "" || BLANK_LINE_RE.test(n.value)))) return nodes.map((node) => renderSingleNode(node));
4103
+ const components = [];
4104
+ const ph = {
4105
+ nonce: Math.random().toString(36).slice(2, 10) || "0",
4106
+ components,
4107
+ nodes: []
4108
+ };
4109
+ let combined = "";
4110
+ for (let i = 0; i < nodes.length; i++) {
4111
+ const node = nodes[i];
4112
+ if (node.type === "text") {
4113
+ combined += node.value;
4114
+ continue;
4115
+ }
4116
+ const phIdx = components.length;
4117
+ components.push(renderSingleNode(node));
4118
+ ph.nodes.push(node);
4119
+ if (combined.endsWith("\\")) combined += ESCAPE_GUARD;
4120
+ combined += placeholderHtml(ph.nonce, phIdx);
4121
+ }
4122
+ const unwrapParagraphs = !!(options?.nobr || options?.inline);
4123
+ const lead = combined.slice(0, skipChars(combined, 0, 1, EDGE_WS));
4124
+ const trail = lead.length === combined.length ? "" : combined.slice(skipChars(combined, combined.length, -1, EDGE_WS));
4125
+ const core = combined.slice(lead.length, combined.length - trail.length);
4126
+ const edges = (content) => unwrapParagraphs && (lead || trail) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
4127
+ lead,
4128
+ content,
4129
+ trail
4130
+ ] }) : content;
4131
+ const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, "");
4132
+ if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) return edges(buildPlainTextVnodes(core, ph, unwrapParagraphs));
4133
+ return edges(htmlToPreact(markdownToHtml(combined, { inline: options?.inline }), ph, unwrapParagraphs));
4134
+ }
4135
+ //#endregion
4136
+ //#region node_modules/devalue/src/constants.js
4137
+ var MAX_ARRAY_LEN = 2 ** 32 - 1;
4138
+ var MAX_ARRAY_INDEX = MAX_ARRAY_LEN - 1;
4139
+ //#endregion
4140
+ //#region node_modules/devalue/src/utils.js
4141
+ var DevalueError = class extends Error {
4142
+ /**
4143
+ * @param {string} message
4144
+ * @param {string[]} keys
4145
+ * @param {any} [value] - The value that failed to be serialized
4146
+ * @param {any} [root] - The root value being serialized
4147
+ */
4148
+ constructor(message, keys, value, root) {
4149
+ super(message);
4150
+ this.name = "DevalueError";
4151
+ this.path = keys.join("");
4152
+ this.value = value;
4153
+ this.root = root;
4154
+ }
4155
+ };
4156
+ /**
4157
+ * Marks a Map key on the path being serialized. The key itself is recorded
4158
+ * right after the marker, so nothing is formatted or allocated per entry.
4159
+ */
4160
+ var MAP_KEY = Symbol();
4161
+ /**
4162
+ * Formats the path recorded on the way to the value being serialized — a
4163
+ * property key, an array index, or `MAP_KEY` followed by a Map key — into
4164
+ * the segments a `DevalueError` joins. Only runs when an error is raised.
4165
+ * @param {any[]} keys
4166
+ * @param {(key: any) => string} format_map_key
4167
+ * @returns {string[]}
4168
+ */
4169
+ function format_path(keys, format_map_key) {
4170
+ /** @type {string[]} */
4171
+ const path = [];
4172
+ for (let i = 0; i < keys.length; i += 1) {
4173
+ const segment = keys[i];
4174
+ if (segment === MAP_KEY) path.push(`.get(${format_map_key(keys[++i])})`);
4175
+ else if (typeof segment === "number") path.push(`[${segment}]`);
4176
+ else path.push(stringify_key(segment));
4177
+ }
4178
+ return path;
4179
+ }
4180
+ var object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype).sort().join("\0");
4181
+ /** @param {any} thing */
4182
+ function is_plain_object(thing) {
4183
+ const proto = Object.getPrototypeOf(thing);
4184
+ return proto === Object.prototype || proto === null || Object.getPrototypeOf(proto) === null || Object.getOwnPropertyNames(proto).sort().join("\0") === object_proto_names;
4185
+ }
4186
+ /** @param {any} thing */
4187
+ function get_type(thing) {
4188
+ return Object.prototype.toString.call(thing).slice(8, -1);
4189
+ }
4190
+ /** @param {any} thing */
4191
+ function is_buffer(thing) {
4192
+ return typeof Buffer !== "undefined" && Buffer.isBuffer(thing);
4193
+ }
4194
+ /** @param {string} char */
4195
+ function get_escaped_char(char) {
4196
+ switch (char) {
4197
+ case "\"": return "\\\"";
4198
+ case "<": return "\\u003C";
4199
+ case "\\": return "\\\\";
4200
+ case "\n": return "\\n";
4201
+ case "\r": return "\\r";
4202
+ case " ": return "\\t";
4203
+ case "\b": return "\\b";
4204
+ case "\f": return "\\f";
4205
+ case "\u2028": return "\\u2028";
4206
+ case "\u2029": return "\\u2029";
4207
+ default: return char < " " ? `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}` : "";
4208
+ }
4209
+ }
4210
+ var needs_escape = /["<\\\u0000-\u001f\u2028\u2029]/;
4211
+ /** @param {string} str */
4212
+ function stringify_string(str) {
4213
+ if (!needs_escape.test(str) && str.isWellFormed()) return `"${str}"`;
4214
+ let result = "";
4215
+ let last_pos = 0;
4216
+ const len = str.length;
4217
+ for (let i = 0; i < len; i += 1) {
4218
+ const code = str.charCodeAt(i);
4219
+ if (code >= 55296 && code <= 57343) {
4220
+ if (code <= 56319 && i + 1 < len && (str.charCodeAt(i + 1) & 64512) === 56320) {
4221
+ i += 1;
4222
+ continue;
4223
+ }
4224
+ result += str.slice(last_pos, i) + `\\u${code.toString(16)}`;
4225
+ last_pos = i + 1;
4226
+ continue;
4227
+ }
4228
+ const replacement = get_escaped_char(str[i]);
4229
+ if (replacement) {
4230
+ result += str.slice(last_pos, i) + replacement;
4231
+ last_pos = i + 1;
4232
+ }
4233
+ }
4234
+ return `"${last_pos === 0 ? str : result + str.slice(last_pos)}"`;
4235
+ }
4236
+ var MAX_CACHED_KEY_LENGTH = 64;
4237
+ /** @type {Map<string, string>} */
4238
+ var quoted_keys = /* @__PURE__ */ new Map();
4239
+ /** @param {string} key */
4240
+ function quote_key(key) {
4241
+ if (key.length > MAX_CACHED_KEY_LENGTH) return stringify_string(key);
4242
+ let quoted = quoted_keys.get(key);
4243
+ if (quoted === void 0) {
4244
+ if (quoted_keys.size >= 1024) quoted_keys.clear();
4245
+ quoted = stringify_string(key);
4246
+ quoted_keys.set(key, quoted);
4247
+ }
4248
+ return quoted;
4249
+ }
4250
+ /** @param {Record<string | symbol, any>} object */
4251
+ function enumerable_symbols(object) {
4252
+ const symbols = Object.getOwnPropertySymbols(object);
4253
+ return symbols.length === 0 ? symbols : symbols.filter((symbol) => Object.getOwnPropertyDescriptor(object, symbol)?.enumerable);
4254
+ }
4255
+ var is_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;
4256
+ /** @param {string} key */
4257
+ function stringify_key(key) {
4258
+ return is_identifier.test(key) ? "." + key : "[" + JSON.stringify(key) + "]";
4259
+ }
4260
+ /** @param {number} n */
4261
+ function is_valid_array_index(n) {
4262
+ if (!Number.isInteger(n)) return false;
4263
+ if (n < 0) return false;
4264
+ if (n > MAX_ARRAY_INDEX) return false;
4265
+ return true;
4266
+ }
4267
+ /** @param {number} n */
4268
+ function is_valid_array_len(n) {
4269
+ if (!Number.isInteger(n)) return false;
4270
+ if (n < 0) return false;
4271
+ if (n > MAX_ARRAY_LEN) return false;
4272
+ return true;
4273
+ }
4274
+ /** @param {string} s */
4275
+ function is_valid_array_index_string(s) {
4276
+ if (s.length === 0) return false;
4277
+ if (s.length > 1 && s.charCodeAt(0) === 48) return false;
4278
+ for (let i = 0; i < s.length; i++) {
4279
+ const c = s.charCodeAt(i);
4280
+ if (c < 48 || c > 57) return false;
4281
+ }
4282
+ return is_valid_array_index(+s);
4283
+ }
4284
+ /**
4285
+ * Returns the length of the leading run of valid array indices in `keys`.
4286
+ * @param {readonly string[]} keys
4287
+ */
4288
+ function array_index_cut(keys) {
4289
+ for (var i = keys.length - 1; i >= 0; i--) if (is_valid_array_index_string(keys[i])) break;
4290
+ return i + 1;
4291
+ }
4292
+ /**
4293
+ * Finds the populated indices of an array.
4294
+ * @param {unknown[]} array
4295
+ */
4296
+ function valid_array_indices(array) {
4297
+ const keys = Object.keys(array);
4298
+ keys.length = array_index_cut(keys);
4299
+ return keys;
4300
+ }
4301
+ //#endregion
4302
+ //#region node_modules/devalue/src/base64.js
4303
+ /** @type {(array_buffer: ArrayBuffer) => string} */
4304
+ function encode_native(array_buffer) {
4305
+ return new Uint8Array(array_buffer).toBase64();
4306
+ }
4307
+ /** @type {(base64: string) => ArrayBuffer} */
4308
+ function decode_native(base64) {
4309
+ return Uint8Array.fromBase64(base64).buffer;
4310
+ }
4311
+ /** @type {(array_buffer: ArrayBuffer) => string} */
4312
+ function encode_buffer(array_buffer) {
4313
+ return Buffer.from(array_buffer).toString("base64");
4314
+ }
4315
+ /** @type {(base64: string) => ArrayBuffer} */
4316
+ function decode_buffer(base64) {
4317
+ return Uint8Array.from(Buffer.from(base64, "base64")).buffer;
4318
+ }
4319
+ /** @type {(array_buffer: ArrayBuffer) => string} */
4320
+ function encode_legacy(array_buffer) {
4321
+ const array = new Uint8Array(array_buffer);
4322
+ let binary = "";
4323
+ const chunk_size = 32768;
4324
+ for (let i = 0; i < array.length; i += chunk_size) {
4325
+ const chunk = array.subarray(i, i + chunk_size);
4326
+ binary += String.fromCharCode.apply(null, chunk);
4327
+ }
4328
+ return btoa(binary);
4329
+ }
4330
+ /** @type {(base64: string) => ArrayBuffer} */
4331
+ function decode_legacy(base64) {
4332
+ const binary_string = atob(base64);
4333
+ const len = binary_string.length;
4334
+ const array = new Uint8Array(len);
4335
+ for (let i = 0; i < len; i++) array[i] = binary_string.charCodeAt(i);
4336
+ return array.buffer;
4337
+ }
4338
+ var native = typeof Uint8Array.fromBase64 === "function";
4339
+ var buffer = typeof process === "object" && process.versions?.node !== void 0;
4340
+ var encode64 = native ? encode_native : buffer ? encode_buffer : encode_legacy;
4341
+ var decode64 = native ? decode_native : buffer ? decode_buffer : decode_legacy;
4342
+ //#endregion
4343
+ //#region node_modules/devalue/src/operations.js
4344
+ /**
4345
+ * Merges caller-provided operation overrides over the defaults. Iterating the
4346
+ * default keys (rather than the override's own keys) means nullish members
4347
+ * fall back to the default, and inherited members — e.g. from a class
4348
+ * instance — are picked up.
4349
+ *
4350
+ * @template {Record<string, any>} T
4351
+ * @param {T} defaults
4352
+ * @param {Partial<T> | undefined} overrides
4353
+ * @returns {T}
4354
+ */
4355
+ function merge_operations(defaults, overrides) {
4356
+ if (!overrides) return defaults;
4357
+ const merged = {};
4358
+ for (const key of Object.keys(defaults)) merged[key] = overrides[key] ?? defaults[key];
4359
+ return merged;
4360
+ }
4361
+ /** @type {{ kind: 'not-plain' }} */
4362
+ var NOT_PLAIN = /* @__PURE__ */ Object.freeze({ kind: "not-plain" });
4363
+ /** @type {{ kind: 'symbol-keys' }} */
4364
+ var SYMBOL_KEYS = /* @__PURE__ */ Object.freeze({ kind: "symbol-keys" });
4365
+ var default_stringify_operations = /* @__PURE__ */ Object.freeze({
4366
+ identify: (value) => value,
4367
+ typeOf: (value) => value === null ? "null" : typeof value,
4368
+ toPrimitive: (value) => value,
4369
+ tagOf: (value) => get_type(value),
4370
+ isThenable: (value) => typeof value.then === "function",
4371
+ toPromise: (thenable) => Promise.resolve(thenable),
4372
+ unbox: (boxed) => boxed.valueOf(),
4373
+ toISOString: (date) => isNaN(date.getDate()) ? "" : date.toISOString(),
4374
+ toStringValue: (value) => value.toString(),
4375
+ regExpInfo: (regexp) => ({
4376
+ source: regexp.source,
4377
+ flags: regexp.flags
4378
+ }),
4379
+ valuesOf: (set) => set,
4380
+ entriesOf: (map) => map,
4381
+ viewInfo: (view) => {
4382
+ if (is_buffer(view)) view = new Uint8Array(view);
4383
+ return {
4384
+ buffer: view.buffer,
4385
+ byteOffset: view.byteOffset,
4386
+ byteLength: view.byteLength,
4387
+ length: view.length,
4388
+ bufferByteLength: view.buffer.byteLength
4389
+ };
4390
+ },
4391
+ toArrayBuffer: (buffer) => buffer,
4392
+ lengthOf: (array) => array.length,
4393
+ hasOwn: (value, key) => Object.hasOwn(value, key),
4394
+ indicesOf: (array) => valid_array_indices(array),
4395
+ shapeOf: (value) => {
4396
+ if (!is_plain_object(value)) return NOT_PLAIN;
4397
+ if (enumerable_symbols(value).length > 0) return SYMBOL_KEYS;
4398
+ return {
4399
+ kind: Object.getPrototypeOf(value) === null ? "null-proto" : "plain",
4400
+ keys: Object.keys(value)
4401
+ };
4402
+ },
4403
+ get: (value, key) => value[key]
4404
+ });
4405
+ var array_buffer_byte_length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")?.get;
4406
+ var shared_array_buffer_byte_length = typeof SharedArrayBuffer === "undefined" ? void 0 : Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, "byteLength")?.get;
4407
+ var default_parse_operations = /* @__PURE__ */ Object.freeze({
4408
+ fromPrimitive: (primitive) => primitive,
4409
+ fromISOString: (iso) => new Date(iso),
4410
+ fromStringValue: (tag, text) => {
4411
+ if (tag === "URL") return new URL(text);
4412
+ if (tag === "URLSearchParams") return new URLSearchParams(text);
4413
+ return Temporal[tag.slice(9)].from(text);
4414
+ },
4415
+ fromArrayBuffer: (buffer) => buffer,
4416
+ fromRegExpInfo: (source, flags) => new RegExp(source, flags),
4417
+ fromViewInfo: (tag, buffer, byteOffset, length) => {
4418
+ try {
4419
+ array_buffer_byte_length.call(buffer);
4420
+ } catch (error) {
4421
+ if (!shared_array_buffer_byte_length) throw error;
4422
+ shared_array_buffer_byte_length.call(buffer);
4423
+ }
4424
+ const Constructor = globalThis[tag];
4425
+ return byteOffset !== void 0 ? new Constructor(buffer, byteOffset, length) : new Constructor(buffer);
4426
+ },
4427
+ box: (value) => Object(value),
4428
+ createArray: (length) => new Array(length),
4429
+ createSparseArray: (length) => {
4430
+ /** @type {any[]} */
4431
+ const array = [];
4432
+ array[MAX_ARRAY_INDEX] = void 0;
4433
+ delete array[MAX_ARRAY_INDEX];
4434
+ array.length = length;
4435
+ return array;
4436
+ },
4437
+ createObject: () => ({}),
4438
+ createNullPrototypeObject: () => Object.create(null),
4439
+ createSet: () => /* @__PURE__ */ new Set(),
4440
+ createMap: () => /* @__PURE__ */ new Map(),
4441
+ set: (target, key, value) => {
4442
+ target[key] = value;
4443
+ },
4444
+ addValue: (set, value) => {
4445
+ set.add(value);
4446
+ },
4447
+ addEntry: (map, key, value) => {
4448
+ map.set(key, value);
4449
+ }
4450
+ });
4451
+ //#endregion
4452
+ //#region node_modules/devalue/src/parse.js
4453
+ function invalid$1() {
4454
+ throw new Error("Invalid input");
4455
+ }
4456
+ /**
4457
+ * Revive a value serialized with `devalue.stringify`
4458
+ * @param {string} serialized
4459
+ * @param {Record<string, (value: any) => any>} [revivers]
4460
+ * @param {import('./types.js').ParseOptions} [options]
4461
+ */
4462
+ function parse(serialized, revivers, options) {
4463
+ return unflatten(JSON.parse(serialized), revivers, options);
4464
+ }
4465
+ /**
4466
+ * Revive a value flattened with `devalue.stringify`
4467
+ * @param {number | any[]} parsed
4468
+ * @param {Record<string, (value: any) => any>} [revivers]
4469
+ * @param {import('./types.js').ParseOptions} [options]
4470
+ */
4471
+ function unflatten(parsed, revivers, options) {
4472
+ /** @type {import('./types.js').ParseOperations} */
4473
+ const ops = merge_operations(default_parse_operations, options?.operations);
4474
+ if (typeof parsed === "number") return hydrate(parsed, true);
4475
+ if (!Array.isArray(parsed) || parsed.length === 0) invalid$1();
4476
+ const values = parsed;
4477
+ const hydrated = Array(values.length);
4478
+ /**
4479
+ * A set of values currently being hydrated with custom revivers,
4480
+ * used to detect invalid cyclical dependencies
4481
+ * @type {Set<number> | null}
4482
+ */
4483
+ let hydrating = null;
4484
+ /**
4485
+ * @param {number} index
4486
+ * @returns {any}
4487
+ */
4488
+ function get_raw(index) {
4489
+ if (!is_valid_array_index(index) || index >= values.length) invalid$1();
4490
+ return values[index];
4491
+ }
4492
+ /**
4493
+ * @param {number} index
4494
+ * @returns {any}
4495
+ */
4496
+ function hydrate(index, standalone = false) {
4497
+ if (index === -1) return ops.fromPrimitive(void 0);
4498
+ if (index === -3) return ops.fromPrimitive(NaN);
4499
+ if (index === -4) return ops.fromPrimitive(Infinity);
4500
+ if (index === -5) return ops.fromPrimitive(-Infinity);
4501
+ if (index === -6) return ops.fromPrimitive(-0);
4502
+ if (standalone || typeof index !== "number") invalid$1();
4503
+ if (index in hydrated) return hydrated[index];
4504
+ if (index >= values.length) invalid$1();
4505
+ const value = values[index];
4506
+ if (!value || typeof value !== "object") hydrated[index] = ops.fromPrimitive(value);
4507
+ else if (Array.isArray(value)) if (typeof value[0] === "string") {
4508
+ const type = value[0];
4509
+ const reviver = revivers && Object.hasOwn(revivers, type) ? revivers[type] : void 0;
4510
+ if (reviver) {
4511
+ let i = value[1];
4512
+ if (typeof i !== "number") i = values.push(value[1]) - 1;
4513
+ if (Object.hasOwn(hydrated, i)) return hydrated[index] = reviver(hydrated[i]);
4514
+ hydrating ??= /* @__PURE__ */ new Set();
4515
+ if (hydrating.has(i)) throw new Error("Invalid circular reference");
4516
+ hydrating.add(i);
4517
+ hydrated[index] = reviver(hydrate(i));
4518
+ hydrating.delete(i);
4519
+ return hydrated[index];
4520
+ }
4521
+ switch (type) {
4522
+ case "Date":
4523
+ hydrated[index] = ops.fromISOString(value[1]);
4524
+ break;
4525
+ case "Set":
4526
+ const set = ops.createSet();
4527
+ hydrated[index] = set;
4528
+ for (let i = 1; i < value.length; i += 1) ops.addValue(set, hydrate(value[i]));
4529
+ break;
4530
+ case "Map":
4531
+ const map = ops.createMap();
4532
+ hydrated[index] = map;
4533
+ for (let i = 1; i < value.length; i += 2) ops.addEntry(map, hydrate(value[i]), hydrate(value[i + 1]));
4534
+ break;
4535
+ case "RegExp":
4536
+ hydrated[index] = ops.fromRegExpInfo(value[1], value[2]);
4537
+ break;
4538
+ case "Object": {
4539
+ const wrapped_index = value[1];
4540
+ if (!(wrapped_index === -3 || wrapped_index === -4 || wrapped_index === -5 || wrapped_index === -6)) {
4541
+ const wrapped = get_raw(wrapped_index);
4542
+ const is_bigint = Array.isArray(wrapped) && wrapped[0] === "BigInt";
4543
+ if ((wrapped === void 0 || typeof wrapped === "object") && !is_bigint) invalid$1();
4544
+ }
4545
+ hydrated[index] = ops.box(hydrate(wrapped_index));
4546
+ break;
4547
+ }
4548
+ case "BigInt":
4549
+ hydrated[index] = ops.fromPrimitive(BigInt(value[1]));
4550
+ break;
4551
+ case "null":
4552
+ const obj = ops.createNullPrototypeObject();
4553
+ hydrated[index] = obj;
4554
+ for (let i = 1; i < value.length; i += 2) {
4555
+ const key = value[i];
4556
+ if (typeof key !== "string") throw new Error("Cannot parse an object with a non-string key");
4557
+ if (key === "__proto__") throw new Error("Cannot parse an object with a `__proto__` property");
4558
+ ops.set(obj, key, hydrate(value[i + 1]));
4559
+ }
4560
+ break;
4561
+ case "Int8Array":
4562
+ case "Uint8Array":
4563
+ case "Uint8ClampedArray":
4564
+ case "Int16Array":
4565
+ case "Uint16Array":
4566
+ case "Float16Array":
4567
+ case "Int32Array":
4568
+ case "Uint32Array":
4569
+ case "Float32Array":
4570
+ case "Float64Array":
4571
+ case "BigInt64Array":
4572
+ case "BigUint64Array":
4573
+ case "DataView": {
4574
+ const buffer_index = value[1];
4575
+ const raw = get_raw(buffer_index);
4576
+ if (!Array.isArray(raw) || raw[0] !== "ArrayBuffer") invalid$1();
4577
+ const buffer = hydrate(buffer_index);
4578
+ hydrated[index] = ops.fromViewInfo(type, buffer, value[2], value[3]);
4579
+ break;
4580
+ }
4581
+ case "ArrayBuffer": {
4582
+ const base64 = value[1];
4583
+ if (typeof base64 !== "string") throw new Error("Invalid ArrayBuffer encoding");
4584
+ hydrated[index] = ops.fromArrayBuffer(decode64(base64));
4585
+ break;
4586
+ }
4587
+ case "URL":
4588
+ case "URLSearchParams":
4589
+ case "Temporal.Duration":
4590
+ case "Temporal.Instant":
4591
+ case "Temporal.PlainDate":
4592
+ case "Temporal.PlainTime":
4593
+ case "Temporal.PlainDateTime":
4594
+ case "Temporal.PlainMonthDay":
4595
+ case "Temporal.PlainYearMonth":
4596
+ case "Temporal.ZonedDateTime":
4597
+ hydrated[index] = ops.fromStringValue(type, value[1]);
4598
+ break;
4599
+ default: throw new Error(`Unknown type ${type}`);
4600
+ }
4601
+ } else if (value[0] === -7) {
4602
+ const len = value[1];
4603
+ if (!is_valid_array_len(len)) invalid$1();
4604
+ const array = ops.createSparseArray(len);
4605
+ hydrated[index] = array;
4606
+ for (let i = 2; i < value.length; i += 2) {
4607
+ const idx = value[i];
4608
+ if (!is_valid_array_index(idx) || idx >= len) invalid$1();
4609
+ ops.set(array, idx, hydrate(value[i + 1]));
4610
+ }
4611
+ } else {
4612
+ const array = ops.createArray(value.length);
4613
+ hydrated[index] = array;
4614
+ for (let i = 0; i < value.length; i += 1) {
4615
+ const n = value[i];
4616
+ if (n === -2) continue;
4617
+ ops.set(array, i, hydrate(n));
4618
+ }
4619
+ }
4620
+ else {
4621
+ const object = ops.createObject();
4622
+ hydrated[index] = object;
4623
+ for (const key of Object.keys(value)) {
4624
+ if (key === "__proto__") throw new Error("Cannot parse an object with a `__proto__` property");
4625
+ ops.set(object, key, hydrate(value[key]));
4626
+ }
4627
+ }
4628
+ return hydrated[index];
4629
+ }
4630
+ return hydrate(0);
4631
+ }
4632
+ //#endregion
4633
+ //#region node_modules/devalue/src/stringify.js
4634
+ /**
4635
+ * Turn a value into a JSON string that can be parsed with `devalue.parse`
4636
+ * @param {any} value
4637
+ * @param {Record<string, (value: any) => any>} [reducers]
4638
+ * @param {import('./types.js').StringifyOptions} [options]
4639
+ */
4640
+ function stringify(value, reducers, options) {
4641
+ const stringified = run(false, value, reducers, options);
4642
+ return typeof stringified === "string" ? stringified : `[${stringified.join(",")}]`;
4643
+ }
4644
+ /**
4645
+ * @param {boolean} async
4646
+ * @param {any} value
4647
+ * @param {Record<string, (value: any) => any>} [reducers]
4648
+ * @param {import('./types.js').StringifyOptions} [options]
4649
+ */
4650
+ function run(async, value, reducers, options) {
4651
+ const ops = merge_operations(default_stringify_operations, options?.operations);
4652
+ /** @type {any[]} */
4653
+ const stringified = [];
4654
+ /** @type {Map<any, number>} */
4655
+ const indexes = /* @__PURE__ */ new Map();
4656
+ /** @type {Array<{ key: string, fn: (value: any) => any }>} */
4657
+ const custom = [];
4658
+ if (reducers) for (const key of Object.getOwnPropertyNames(reducers)) custom.push({
4659
+ key,
4660
+ fn: reducers[key]
4661
+ });
4662
+ /** @type {any[]} */
4663
+ let keys = [];
4664
+ let p = 0;
4665
+ /**
4666
+ * @param {string} message
4667
+ * @param {any} thing
4668
+ * @returns {never}
4669
+ */
4670
+ function error(message, thing) {
4671
+ throw new DevalueError(message, format_path(keys, (key) => {
4672
+ const key_type = ops.typeOf(key);
4673
+ return key_type !== "object" && key_type !== "function" && key_type !== "symbol" ? stringify_primitive(ops.toPrimitive(key)) : "...";
4674
+ }), thing, value);
4675
+ }
4676
+ /**
4677
+ * @param {any} thing
4678
+ * @param {number} [index]
4679
+ */
4680
+ function flatten(thing, index) {
4681
+ const type = ops.typeOf(thing);
4682
+ if (type === "undefined") return -1;
4683
+ /** @type {number | undefined} */
4684
+ let number;
4685
+ if (type === "number") {
4686
+ number = ops.toPrimitive(thing);
4687
+ if (Number.isNaN(number)) return -3;
4688
+ if (number === Infinity) return -4;
4689
+ if (number === -Infinity) return -5;
4690
+ if (number === 0 && 1 / number < 0) return -6;
4691
+ }
4692
+ const id = ops.identify(thing);
4693
+ if (indexes.has(id)) return indexes.get(id);
4694
+ index ??= p++;
4695
+ indexes.set(id, index);
4696
+ for (const { key, fn } of custom) {
4697
+ const value = fn(thing);
4698
+ if (value) {
4699
+ stringified[index] = `["${key}",${flatten(value)}]`;
4700
+ return index;
4701
+ }
4702
+ }
4703
+ if (type === "function") error(`Cannot stringify a function`, thing);
4704
+ else if (type === "symbol") error(`Cannot stringify a Symbol primitive`, thing);
4705
+ /** @type {string | Promise<any>} */
4706
+ let str = "";
4707
+ if (type !== "object") str = stringify_primitive(type === "number" ? number : ops.toPrimitive(thing));
4708
+ else if (ops.isThenable(thing)) {
4709
+ if (!async) error(`Cannot stringify a Promise or thenable — use stringifyAsync instead`, thing);
4710
+ const prev_keys = keys.slice();
4711
+ str = ops.toPromise(thing).then((value) => {
4712
+ keys = prev_keys;
4713
+ const i = flatten(value, index);
4714
+ if (i < 0) stringified[index] = i;
4715
+ });
4716
+ str.catch(() => {});
4717
+ } else {
4718
+ const tag = ops.tagOf(thing);
4719
+ switch (tag) {
4720
+ case "Number":
4721
+ case "String":
4722
+ case "Boolean":
4723
+ case "BigInt":
4724
+ str = `["Object",${flatten(ops.unbox(thing))}]`;
4725
+ break;
4726
+ case "Date":
4727
+ str = `["Date","${ops.toISOString(thing)}"]`;
4728
+ break;
4729
+ case "URL":
4730
+ str = `["URL",${stringify_string(ops.toStringValue(thing))}]`;
4731
+ break;
4732
+ case "URLSearchParams":
4733
+ str = `["URLSearchParams",${stringify_string(ops.toStringValue(thing))}]`;
4734
+ break;
4735
+ case "RegExp":
4736
+ const { source, flags } = ops.regExpInfo(thing);
4737
+ str = flags ? `["RegExp",${stringify_string(source)},"${flags}"]` : `["RegExp",${stringify_string(source)}]`;
4738
+ break;
4739
+ case "Array": {
4740
+ let mostly_dense = false;
4741
+ const length = ops.lengthOf(thing);
4742
+ str = "[";
4743
+ for (let i = 0; i < length; i += 1) {
4744
+ if (i > 0) str += ",";
4745
+ if (ops.hasOwn(thing, i)) {
4746
+ keys.push(i);
4747
+ str += flatten(ops.get(thing, i));
4748
+ keys.pop();
4749
+ } else if (mostly_dense) str += -2;
4750
+ else {
4751
+ const populated_keys = ops.indicesOf(thing);
4752
+ const population = populated_keys.length;
4753
+ const d = String(length).length;
4754
+ if ((length - population) * 3 > 4 + d + population * (d + 1)) {
4755
+ str = "[-7," + length;
4756
+ for (let j = 0; j < populated_keys.length; j++) {
4757
+ const key = populated_keys[j];
4758
+ keys.push(+key);
4759
+ str += "," + key + "," + flatten(ops.get(thing, key));
4760
+ keys.pop();
4761
+ }
4762
+ break;
4763
+ } else {
4764
+ mostly_dense = true;
4765
+ str += -2;
4766
+ }
4767
+ }
4768
+ }
4769
+ str += "]";
4770
+ break;
4771
+ }
4772
+ case "Set":
4773
+ str = "[\"Set\"";
4774
+ for (const value of ops.valuesOf(thing)) str += `,${flatten(value)}`;
4775
+ str += "]";
4776
+ break;
4777
+ case "Map":
4778
+ str = "[\"Map\"";
4779
+ for (const [key, value] of ops.entriesOf(thing)) {
4780
+ keys.push(MAP_KEY, key);
4781
+ str += `,${flatten(key)},${flatten(value)}`;
4782
+ keys.pop();
4783
+ keys.pop();
4784
+ }
4785
+ str += "]";
4786
+ break;
4787
+ case "Int8Array":
4788
+ case "Uint8Array":
4789
+ case "Uint8ClampedArray":
4790
+ case "Int16Array":
4791
+ case "Uint16Array":
4792
+ case "Float16Array":
4793
+ case "Int32Array":
4794
+ case "Uint32Array":
4795
+ case "Float32Array":
4796
+ case "Float64Array":
4797
+ case "BigInt64Array":
4798
+ case "BigUint64Array": {
4799
+ const info = ops.viewInfo(thing);
4800
+ str = "[\"" + tag + "\"," + flatten(info.buffer);
4801
+ if (info.byteLength !== info.bufferByteLength) str += `,${info.byteOffset},${info.length}`;
4802
+ str += "]";
4803
+ break;
4804
+ }
4805
+ case "DataView": {
4806
+ const info = ops.viewInfo(thing);
4807
+ str = "[\"" + tag + "\"," + flatten(info.buffer);
4808
+ if (info.byteLength !== info.bufferByteLength) str += `,${info.byteOffset},${info.byteLength}`;
4809
+ str += "]";
4810
+ break;
4811
+ }
4812
+ case "ArrayBuffer":
4813
+ str = `["ArrayBuffer","${encode64(ops.toArrayBuffer(thing))}"]`;
4814
+ break;
4815
+ case "Temporal.Duration":
4816
+ case "Temporal.Instant":
4817
+ case "Temporal.PlainDate":
4818
+ case "Temporal.PlainTime":
4819
+ case "Temporal.PlainDateTime":
4820
+ case "Temporal.PlainMonthDay":
4821
+ case "Temporal.PlainYearMonth":
4822
+ case "Temporal.ZonedDateTime":
4823
+ str = `["${tag}",${stringify_string(ops.toStringValue(thing))}]`;
4824
+ break;
4825
+ default: {
4826
+ const shape = ops.shapeOf(thing);
4827
+ if (shape.kind === "not-plain") error(`Cannot stringify arbitrary non-POJOs`, thing);
4828
+ if (shape.kind === "symbol-keys") error(`Cannot stringify POJOs with symbolic keys`, thing);
4829
+ if (shape.kind === "null-proto") {
4830
+ str = "[\"null\"";
4831
+ for (const key of shape.keys) {
4832
+ if (key === "__proto__") error(`Cannot stringify objects with __proto__ keys`, thing);
4833
+ keys.push(key);
4834
+ str += `,${quote_key(key)},${flatten(ops.get(thing, key))}`;
4835
+ keys.pop();
4836
+ }
4837
+ str += "]";
4838
+ } else {
4839
+ str = "{";
4840
+ let started = false;
4841
+ for (const key of shape.keys) {
4842
+ if (key === "__proto__") error(`Cannot stringify objects with __proto__ keys`, thing);
4843
+ if (started) str += ",";
4844
+ started = true;
4845
+ keys.push(key);
4846
+ str += `${quote_key(key)}:${flatten(ops.get(thing, key))}`;
4847
+ keys.pop();
4848
+ }
4849
+ str += "}";
4850
+ }
4851
+ }
4852
+ }
4853
+ }
4854
+ stringified[index] = str;
4855
+ return index;
4856
+ }
4857
+ const index = flatten(value);
4858
+ if (index < 0) return `${index}`;
4859
+ return stringified;
4860
+ }
4861
+ /**
4862
+ * @param {any} thing
4863
+ * @returns {string}
4864
+ */
4865
+ function stringify_primitive(thing) {
4866
+ const type = typeof thing;
4867
+ if (type === "string") return stringify_string(thing);
4868
+ if (thing === void 0) return (-1).toString();
4869
+ if (thing === 0 && 1 / thing < 0) return (-6).toString();
4870
+ if (type === "bigint") return `["BigInt","${thing}"]`;
4871
+ return String(thing);
4872
+ }
4873
+ //#endregion
4874
+ //#region src/class-registry.ts
4875
+ var registry = /* @__PURE__ */ new Map();
4876
+ var ctorToName = /* @__PURE__ */ new Map();
4877
+ function registerClass(name, ctor) {
4878
+ registry.set(name, ctor);
4879
+ ctorToName.set(ctor, name);
4880
+ }
4881
+ /**
4882
+ * The registered name of the class `value` is an instance of, or undefined.
4883
+ * Looks the constructor up on the prototype, so an own property named
4884
+ * "constructor" (plain data) cannot hide or fake the class.
4885
+ */
4886
+ function registeredClassName(value) {
4887
+ const proto = Object.getPrototypeOf(value);
4888
+ return proto === null ? void 0 : ctorToName.get(proto.constructor);
4889
+ }
4890
+ var CLASS_PREFIX = "c:";
4891
+ var ERROR_PREFIX = "E:";
4892
+ var SYMBOL_TAG = "S";
4893
+ var KEYS_TAG = "K";
4894
+ var BUILTIN_ERRORS = new Map([
4895
+ Error,
4896
+ EvalError,
4897
+ RangeError,
4898
+ ReferenceError,
4899
+ SyntaxError,
4900
+ TypeError,
4901
+ URIError,
4902
+ globalThis.AggregateError
4903
+ ].map((ctor) => [ctor.prototype, ctor.name]));
4904
+ var ERROR_CTORS = new Map([...BUILTIN_ERRORS].map(([proto, name]) => [name, proto.constructor]));
4905
+ /** Error keys that are own but not enumerable on a live error. */
4906
+ var ERROR_HIDDEN_KEYS$1 = [
4907
+ "message",
4908
+ "cause",
4909
+ "errors"
4910
+ ];
4911
+ var isObject = (v) => typeof v === "object" && v !== null;
4912
+ var defineData = (target, key, value) => {
4913
+ Object.defineProperty(target, key, {
4914
+ value,
4915
+ enumerable: true,
4916
+ writable: true,
4917
+ configurable: true
4918
+ });
4919
+ };
4920
+ /**
4921
+ * Own enumerable keys of `value`, as a plain object; for an error also its
4922
+ * message, cause and (AggregateError) errors, which are not enumerable.
4923
+ */
4924
+ function ownData(value) {
4925
+ const data = {};
4926
+ if (value instanceof Error) {
4927
+ for (const key of ERROR_HIDDEN_KEYS$1) if (hasOwn(value, key)) defineData(data, key, value[key]);
4928
+ }
4929
+ for (const key of Object.keys(value)) defineData(data, key, value[key]);
4930
+ return data;
4931
+ }
4932
+ /** Make an error's message, cause and errors non-enumerable again. */
4933
+ function hideErrorKeys(value) {
4934
+ for (const key of ERROR_HIDDEN_KEYS$1) if (hasOwn(value, key)) Object.defineProperty(value, key, { enumerable: false });
4935
+ }
4936
+ /** Whether JSON.stringify writes `key` as a one-character escape sequence. */
4937
+ var isEscapedOneCharKey = (key) => key.length === 1 && JSON.stringify(key).length > 3;
4938
+ function reducers() {
4939
+ const out = {};
4940
+ for (const [name, ctor] of registry) out[CLASS_PREFIX + name] = (v) => isObject(v) && Object.getPrototypeOf(v) === ctor.prototype && ownData(v);
4941
+ for (const [proto, name] of BUILTIN_ERRORS) out[ERROR_PREFIX + name] = (v) => isObject(v) && Object.getPrototypeOf(v) === proto && ownData(v);
4942
+ out[SYMBOL_TAG] = (v) => {
4943
+ if (typeof v !== "symbol") return false;
4944
+ const key = Symbol.keyFor(v);
4945
+ return key !== void 0 && [key];
4946
+ };
4947
+ out[KEYS_TAG] = (v) => {
4948
+ if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) return false;
4949
+ const keys = Object.keys(v);
4950
+ if (!keys.some(isEscapedOneCharKey)) return false;
4951
+ if (keys.includes("__proto__")) throw new TypeError("spindle: Cannot save a property named \"__proto__\"");
4952
+ return keys.flatMap((k) => [k, v[k]]);
4953
+ };
4954
+ return out;
4955
+ }
4956
+ /** Restore a registered class instance in place, so cycles through it hold. */
4957
+ function reviveClass(name) {
4958
+ return (data) => {
4959
+ if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
4960
+ if (isObject(data) && registeredClassName(data) === name) return data;
4961
+ throw new TypeError(`spindle: Malformed data for class "${name}"`);
4962
+ }
4963
+ const ctor = registry.get(name);
4964
+ if (!ctor) throw new TypeError(`spindle: The save holds an instance of class "${name}", which is not registered`);
4965
+ Object.setPrototypeOf(data, ctor.prototype);
4966
+ if (data instanceof Error) hideErrorKeys(data);
4967
+ return data;
4968
+ };
4969
+ }
4970
+ /** Restore a built-in error in place, like a class instance. */
4971
+ function reviveError(name) {
4972
+ const ctor = ERROR_CTORS.get(name);
4973
+ if (!ctor) return void 0;
4974
+ return (data) => {
4975
+ if (Object.getPrototypeOf(data) === ctor.prototype) return data;
4976
+ if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) throw new TypeError(`spindle: Malformed data for "${name}"`);
4977
+ Object.setPrototypeOf(data, ctor.prototype);
4978
+ hideErrorKeys(data);
4979
+ return data;
4980
+ };
4981
+ }
4982
+ /**
4983
+ * The object of a `K` entry list. A cycle through the object revives it
4984
+ * before its list is complete (devalue holds unread entries as holes), so
4985
+ * the object is kept per list and filled with what the list holds so far.
4986
+ */
4987
+ var keyedObjects = /* @__PURE__ */ new WeakMap();
4988
+ function reviveKeyed(entries) {
4989
+ if (!Array.isArray(entries) || entries.length % 2 !== 0) throw new TypeError("spindle: Malformed object entries");
4990
+ let obj = keyedObjects.get(entries);
4991
+ if (!obj) keyedObjects.set(entries, obj = {});
4992
+ for (let i = 0; i < entries.length; i += 2) {
4993
+ if (!hasOwn(entries, i)) break;
4994
+ const key = entries[i];
4995
+ if (typeof key !== "string" || key === "__proto__") throw new TypeError("spindle: Malformed object entries");
4996
+ defineData(obj, key, hasOwn(entries, i + 1) ? entries[i + 1] : void 0);
4997
+ }
4998
+ return obj;
4999
+ }
5000
+ /** Revivers for every tag a save may hold. */
5001
+ var revivers = new Proxy({}, {
5002
+ getOwnPropertyDescriptor(_, key) {
5003
+ const fn = reviverFor(key);
5004
+ return fn && {
5005
+ value: fn,
5006
+ enumerable: true,
5007
+ configurable: true
5008
+ };
5009
+ },
5010
+ get: (_, key) => reviverFor(key)
5011
+ });
5012
+ function reviverFor(key) {
5013
+ if (typeof key !== "string") return void 0;
5014
+ if (key === SYMBOL_TAG) return (data) => {
5015
+ if (!Array.isArray(data) || typeof data[0] !== "string") throw new TypeError("spindle: Malformed symbol");
5016
+ return Symbol.for(data[0]);
5017
+ };
5018
+ if (key === KEYS_TAG) return reviveKeyed;
5019
+ if (key.startsWith(CLASS_PREFIX)) return reviveClass(key.slice(2));
5020
+ if (key.startsWith(ERROR_PREFIX)) return reviveError(key.slice(2));
5021
+ }
5022
+ /** A clearer message for devalue's refusals, with the path to the value. */
5023
+ function saveError(err, value) {
5024
+ if (!(err instanceof Error) || err.name !== "DevalueError") return err instanceof Error ? err : new Error(String(err));
5025
+ const { path, value: what } = err;
5026
+ const at = path ? ` (at ${path})` : "";
5027
+ let reason = err.message;
5028
+ if (typeof what === "function") reason = "Cannot save a function";
5029
+ else if (isObject(what) && /non-POJO/.test(err.message)) reason = `Cannot save an instance of class "${Object.getPrototypeOf(what)?.constructor?.name ?? "?"}", which is not registered (see Story.registerClass)`;
5030
+ else if (typeof what === "symbol") reason = "Cannot save a unique symbol (only Symbol.for() symbols)";
5031
+ else if (/symbolic keys/.test(err.message)) reason = "Cannot save an object with symbol keys";
5032
+ else if (/__proto__/.test(err.message)) reason = "Cannot save a property named \"__proto__\"";
5033
+ return /* @__PURE__ */ new TypeError(`spindle: ${reason}${at}`);
5034
+ }
5035
+ /**
5036
+ * Turn a story value into JSON text that deserialize() restores (saves,
5037
+ * exports, the session). Throws, naming the path to the value, on what a
5038
+ * save cannot hold: functions, unique symbols, symbol keys, instances of
5039
+ * unregistered classes, and a property named "__proto__".
5040
+ */
5041
+ function serialize(value) {
5042
+ try {
5043
+ return stringify(value, reducers());
5044
+ } catch (err) {
5045
+ throw saveError(err, value);
5046
+ }
5047
+ }
5048
+ /**
5049
+ * Restore a value serialize() wrote. Throws on malformed text, and on an
5050
+ * instance of a class that is not registered.
4247
5051
  */
4248
- function skipChars(s, from, step, chars) {
4249
- let i = from;
4250
- if (step === 1) while (i < s.length && chars.includes(s[i])) i++;
4251
- else while (i > 0 && chars.includes(s[i - 1])) i--;
4252
- return i;
5052
+ function deserialize(text) {
5053
+ return parse(text, revivers);
4253
5054
  }
5055
+ /** A save, export or session from a version of Spindle this one cannot read. */
5056
+ var IncompatibleSaveError = class extends Error {
5057
+ constructor() {
5058
+ super("This save was made by an incompatible version of Spindle");
5059
+ this.name = "IncompatibleSaveError";
5060
+ }
5061
+ };
4254
5062
  /**
4255
- * Drop the spaces and tabs around line endings, as markdown does within a
4256
- * paragraph.
5063
+ * Migrations of the stored data: MIGRATIONS[v] turns the `data` of format
5064
+ * version v into that of version v + 1. Version 1 is the first versioned
5065
+ * format; data from before it has no version and is refused.
4257
5066
  */
4258
- function trimLineEdges(text) {
4259
- if (!text.includes("\n")) return text;
4260
- return text.split("\n").map((line, k, lines) => {
4261
- const start = k === 0 ? 0 : skipChars(line, 0, 1, " ");
4262
- const end = k === lines.length - 1 ? line.length : skipChars(line, line.length, -1, " ");
4263
- return line.slice(start, Math.max(start, end));
4264
- }).join("\n");
5067
+ var MIGRATIONS = {};
5068
+ /** Throw an IncompatibleSaveError unless this build reads format `version`. */
5069
+ function checkFormatVersion(version) {
5070
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1 || version > 1) throw new IncompatibleSaveError();
5071
+ }
5072
+ /** Bring `data` of format `version` up to SAVE_FORMAT_VERSION. */
5073
+ function migrate(version, data) {
5074
+ checkFormatVersion(version);
5075
+ let current = data;
5076
+ for (let v = version; v < 1; v++) {
5077
+ const step = MIGRATIONS[v];
5078
+ if (!step) throw new IncompatibleSaveError();
5079
+ current = step(current);
5080
+ }
5081
+ return current;
4265
5082
  }
4266
5083
  /**
4267
- * Build Preact vnodes from a combined string that contains only plain text
4268
- * and placeholders. No micromark, no innerHTML. The text is the content of
4269
- * one paragraph, so like micromark it drops spaces and tabs around line
4270
- * endings; its edges are already split off by the caller.
5084
+ * The passage counters as a Map: a passage may be named "__proto__", which
5085
+ * serialize() refuses as an object key (as every story-state key).
4271
5086
  */
4272
- function buildPlainTextVnodes(core, ph, unwrapParagraphs) {
4273
- const children = expandPlaceholderText(trimLineEdges(core), ph).filter((part) => part !== "");
4274
- return unwrapParagraphs ? /* @__PURE__ */ jsx(Fragment$1, { children }) : h("p", null, ...children);
5087
+ var countsToMap = (counts) => counts && new Map(Object.entries(counts));
5088
+ /**
5089
+ * The error of serialize() with its path told as a story variable:
5090
+ * `(at .variables.inv[0])` becomes `(at $inv[0])`, and a value only an
5091
+ * earlier history moment holds `(at $inv[0] in history moment 3)`.
5092
+ */
5093
+ function variablePathError(err) {
5094
+ if (!(err instanceof TypeError)) return err;
5095
+ const message = err.message.replace(/\(at \.variables(?=[.[]|\))/, "(at $").replace(/\(at \.history\[(\d+)\]\.variables(.*)\)$/, (_, i, path) => `(at $${path} in history moment ${i})`).replace(/\(at \$\.?/, "(at $");
5096
+ return message === err.message ? err : new TypeError(message);
4275
5097
  }
4276
5098
  /**
4277
- * Render AST nodes with full CommonMark markdown support.
4278
- *
4279
- * Combines all nodes into a single markdown document, using placeholder
4280
- * elements (see Placeholders) for non-text nodes (variables, macros, links,
4281
- * HTML). This allows
4282
- * markdown syntax to span across Twine tokens — e.g., markdown tables can
4283
- * contain {$variables} and {macros} in their cells.
4284
- *
4285
- * After micromark processes the combined string, the HTML is parsed back into
4286
- * Preact VNodes with placeholders replaced by the real rendered components.
4287
- * Placeholders that micromark escaped as code text are swapped back too (see
4288
- * PLACEHOLDER_TEXT_RE), so variables in code stay live components.
5099
+ * The stored form of `payload`. Throws, naming the variable, when the
5100
+ * payload holds a value a save cannot (see serialize()).
4289
5101
  */
4290
- function renderNodes(nodes, options) {
4291
- if (nodes.length === 0) return null;
4292
- if (options?.raw) return renderInlineNodes(nodes);
4293
- if (!nodes.some((n) => n.type === "text" && (n.value.trim() !== "" || BLANK_LINE_RE.test(n.value)))) return nodes.map((node) => renderSingleNode(node));
4294
- const components = [];
4295
- const ph = {
4296
- nonce: Math.random().toString(36).slice(2, 10) || "0",
4297
- components,
4298
- nodes: []
5102
+ function encodePayload(payload) {
5103
+ try {
5104
+ return {
5105
+ formatVersion: 1,
5106
+ data: serialize({
5107
+ ...payload,
5108
+ visitCounts: countsToMap(payload.visitCounts),
5109
+ renderCounts: countsToMap(payload.renderCounts)
5110
+ })
5111
+ };
5112
+ } catch (err) {
5113
+ throw variablePathError(err);
5114
+ }
5115
+ }
5116
+ /** Thrown for stored data that is not a payload this format can hold. */
5117
+ var invalid = () => /* @__PURE__ */ new Error("Invalid save data");
5118
+ /** A plain object or one without a prototype (a variable namespace). */
5119
+ function isRecord$1(value) {
5120
+ if (typeof value !== "object" || value === null) return false;
5121
+ const proto = Object.getPrototypeOf(value);
5122
+ return proto === Object.prototype || proto === null;
5123
+ }
5124
+ /** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
5125
+ function isOptionalPRNGSnapshot(value) {
5126
+ if (value == null) return true;
5127
+ return isRecord$1(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
5128
+ }
5129
+ var isMoment = (value) => isRecord$1(value) && typeof value.passage === "string" && isRecord$1(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
5130
+ var isCountsMap = (value) => value === void 0 || value instanceof Map && [...value.keys()].every((k) => typeof k === "string");
5131
+ /**
5132
+ * Whether a deserialized payload is well formed: every history moment is,
5133
+ * and `historyIndex` is an integer that points at a moment of the payload's
5134
+ * passage.
5135
+ */
5136
+ function isPayload(value) {
5137
+ if (!isRecord$1(value)) return false;
5138
+ if (typeof value.passage !== "string" || !isRecord$1(value.variables)) return false;
5139
+ const { history, historyIndex } = value;
5140
+ if (!Array.isArray(history) || history.length === 0) return false;
5141
+ if (!Array.from(history).every(isMoment)) return false;
5142
+ if (typeof historyIndex !== "number" || !Number.isInteger(historyIndex) || historyIndex < 0 || historyIndex >= history.length || history[historyIndex].passage !== value.passage) return false;
5143
+ return isCountsMap(value.visitCounts) && isCountsMap(value.renderCounts) && isOptionalPRNGSnapshot(value.prng);
5144
+ }
5145
+ var mapToCounts = (counts) => counts instanceof Map ? createCounts(Object.fromEntries(counts)) : void 0;
5146
+ /**
5147
+ * The live payload of a stored one: class instances and built-ins
5148
+ * restored. Throws an IncompatibleSaveError when `encoded` has no format
5149
+ * version or one this build cannot read (saves from before versioning, or
5150
+ * from a newer Spindle), and an error when its data is malformed.
5151
+ */
5152
+ function decodePayload(encoded) {
5153
+ if (typeof encoded !== "object" || encoded === null) throw invalid();
5154
+ const { formatVersion, data } = encoded;
5155
+ checkFormatVersion(formatVersion);
5156
+ if (typeof data !== "string") throw invalid();
5157
+ const body = deserialize(migrate(formatVersion, data));
5158
+ if (!isPayload(body)) throw invalid();
5159
+ const payload = {
5160
+ ...body,
5161
+ visitCounts: mapToCounts(body.visitCounts),
5162
+ renderCounts: mapToCounts(body.renderCounts)
4299
5163
  };
4300
- let combined = "";
4301
- for (let i = 0; i < nodes.length; i++) {
4302
- const node = nodes[i];
4303
- if (node.type === "text") {
4304
- combined += node.value;
4305
- continue;
4306
- }
4307
- const phIdx = components.length;
4308
- components.push(renderSingleNode(node));
4309
- ph.nodes.push(node);
4310
- if (combined.endsWith("\\")) combined += ESCAPE_GUARD;
4311
- combined += placeholderHtml(ph.nonce, phIdx);
5164
+ if (payload.visitCounts === void 0) delete payload.visitCounts;
5165
+ if (payload.renderCounts === void 0) delete payload.renderCounts;
5166
+ return payload;
5167
+ }
5168
+ //#endregion
5169
+ //#region src/saves/types.ts
5170
+ var INVALID_FILE = "Invalid save file format";
5171
+ function isRecord(value) {
5172
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5173
+ }
5174
+ var META_STRINGS = [
5175
+ "id",
5176
+ "passage",
5177
+ "ifid",
5178
+ "playthroughId",
5179
+ "createdAt",
5180
+ "updatedAt",
5181
+ "title"
5182
+ ];
5183
+ /** decodePayload(), without the warnings for unregistered classes. */
5184
+ function decodeQuietly(encoded) {
5185
+ const warn = console.warn;
5186
+ console.warn = () => {};
5187
+ try {
5188
+ decodePayload(encoded);
5189
+ } finally {
5190
+ console.warn = warn;
4312
5191
  }
4313
- const unwrapParagraphs = !!(options?.nobr || options?.inline);
4314
- const lead = combined.slice(0, skipChars(combined, 0, 1, EDGE_WS));
4315
- const trail = lead.length === combined.length ? "" : combined.slice(skipChars(combined, combined.length, -1, EDGE_WS));
4316
- const core = combined.slice(lead.length, combined.length - trail.length);
4317
- const edges = (content) => unwrapParagraphs && (lead || trail) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
4318
- lead,
4319
- content,
4320
- trail
4321
- ] }) : content;
4322
- const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, "");
4323
- if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) return edges(buildPlainTextVnodes(core, ph, unwrapParagraphs));
4324
- return edges(htmlToPreact(markdownToHtml(combined, { inline: options?.inline }), ph, unwrapParagraphs));
5192
+ }
5193
+ /**
5194
+ * Check data from outside the running story (an imported save) in full:
5195
+ * the export's and the payload's format versions, the record's metadata,
5196
+ * and that the payload decodes (see decodePayload). Throws an
5197
+ * IncompatibleSaveError for a missing or unknown format version, and an
5198
+ * "Invalid save file format" error for anything else that is wrong.
5199
+ * Anything that passes can be stored and later loaded.
5200
+ */
5201
+ function checkSaveExport(value) {
5202
+ if (!isRecord(value)) throw new Error(INVALID_FILE);
5203
+ if (!hasOwn(value, "formatVersion")) throw value.version === 1 ? new IncompatibleSaveError() : new Error(INVALID_FILE);
5204
+ checkFormatVersion(value.formatVersion);
5205
+ if (typeof value.ifid !== "string") throw new Error(INVALID_FILE);
5206
+ const save = value.save;
5207
+ if (!isRecord(save) || !isRecord(save.meta)) throw new Error(INVALID_FILE);
5208
+ const meta = save.meta;
5209
+ if (META_STRINGS.some((key) => typeof meta[key] !== "string")) throw new Error(INVALID_FILE);
5210
+ try {
5211
+ decodeQuietly(save.payload);
5212
+ } catch (err) {
5213
+ if (err instanceof IncompatibleSaveError) throw err;
5214
+ throw new Error(INVALID_FILE);
5215
+ }
5216
+ }
5217
+ /** Whether checkSaveExport() accepts `value`. */
5218
+ function isSaveExport(value) {
5219
+ try {
5220
+ checkSaveExport(value);
5221
+ return true;
5222
+ } catch {
5223
+ return false;
5224
+ }
5225
+ }
5226
+ /** Estimated byte size of a stored payload. */
5227
+ function estimatePayloadBytes(payload) {
5228
+ return JSON.stringify(payload).length;
4325
5229
  }
4326
5230
  //#endregion
4327
5231
  //#region src/saves/storage.ts
@@ -4622,6 +5526,28 @@ function getBackendType() {
4622
5526
  return _backend?.type ?? "memory";
4623
5527
  }
4624
5528
  //#endregion
5529
+ //#region src/utils/value-kinds.ts
5530
+ var toStringTag = (value) => Object.prototype.toString.call(value).slice(8, -1);
5531
+ /** Temporal values are immutable: copies may share them. */
5532
+ var isTemporal = (value) => toStringTag(value).startsWith("Temporal.");
5533
+ /** new Number(), new String(), new Boolean(), Object(1n). */
5534
+ var isBoxed = (value) => value instanceof Number || value instanceof String || value instanceof Boolean || toStringTag(value) === "BigInt";
5535
+ /**
5536
+ * Values compared and copied as a whole, by their content: they have no
5537
+ * own keys to merge by, or their keys are not the data (a property written
5538
+ * into one would be dropped by clones and saves, and by Immer for Map and
5539
+ * Set).
5540
+ */
5541
+ function isAtomic(value) {
5542
+ return value instanceof Date || value instanceof RegExp || value instanceof Map || value instanceof Set || ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof URL || value instanceof URLSearchParams || value instanceof Error || isBoxed(value) || isTemporal(value);
5543
+ }
5544
+ /** "a Map", "an Error", ... for an atomic value (see isAtomic), else undefined. */
5545
+ function atomicName(value) {
5546
+ if (!isAtomic(value)) return void 0;
5547
+ const tag = value instanceof Error ? "Error" : toStringTag(value);
5548
+ return `${/^[AEIOU]/.test(tag) ? "an" : "a"} ${tag}`;
5549
+ }
5550
+ //#endregion
4625
5551
  //#region src/utils/object-path.ts
4626
5552
  /**
4627
5553
  * Traverse dot-path segments on an object and return the nested value.
@@ -4645,7 +5571,7 @@ var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
4645
5571
  * Built-ins whose content is not their properties: a property written into
4646
5572
  * one would be dropped by clones and saves (and by Immer, for Map and Set).
4647
5573
  */
4648
- var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
5574
+ var builtinName = atomicName;
4649
5575
  /**
4650
5576
  * Shallow copy that keeps the prototype, so a registered class instance
4651
5577
  * stays an instance of its class (with the same own keys deepClone copies).
@@ -4697,6 +5623,13 @@ function deleteByPath(root, segments) {
4697
5623
  }
4698
5624
  if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
4699
5625
  const parent = walkToParent(root, segments, false);
5626
+ if (Array.isArray(parent) && isDraft(parent) && segments.length > 1) {
5627
+ const copy = current(parent).slice();
5628
+ delete copy[Number(last)];
5629
+ const holder = walkToParent(root, segments.slice(0, -1), false);
5630
+ holder[segments[segments.length - 2]] = copy;
5631
+ return;
5632
+ }
4700
5633
  delete parent[last];
4701
5634
  }
4702
5635
  /** Refuse a segment that would reach a prototype instead of story state. */
@@ -4742,66 +5675,199 @@ function isPlainObject(value) {
4742
5675
  const proto = Object.getPrototypeOf(value);
4743
5676
  return proto === Object.prototype || proto === null;
4744
5677
  }
5678
+ /** Own keys of an error that are not enumerable but are data. */
5679
+ var ERROR_HIDDEN_KEYS = [
5680
+ "message",
5681
+ "cause",
5682
+ "errors"
5683
+ ];
4745
5684
  function deepClone(value, options = {}) {
4746
5685
  const seen = /* @__PURE__ */ new Map();
5686
+ /** Record `copy` as the copy of `obj` before its contents are copied. */
5687
+ const keep = (obj, copy) => {
5688
+ seen.set(obj, copy);
5689
+ return copy;
5690
+ };
5691
+ /** Copy the own (enumerable) keys of `obj` into `copy`. */
5692
+ function copyKeys(obj, copy) {
5693
+ for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
5694
+ return copy;
5695
+ }
5696
+ function cloneBuiltin(val) {
5697
+ if (val instanceof Date) return keep(val, new Date(val.getTime()));
5698
+ if (val instanceof RegExp) return keep(val, new RegExp(val.source, val.flags));
5699
+ if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
5700
+ if (ArrayBuffer.isView(val)) {
5701
+ const buffer = clone(val.buffer);
5702
+ if (val instanceof DataView) return keep(val, new DataView(buffer, val.byteOffset, val.byteLength));
5703
+ const ctor = val.constructor;
5704
+ const length = val.length;
5705
+ return keep(val, new ctor(buffer, val.byteOffset, length));
5706
+ }
5707
+ if (val instanceof URL) return keep(val, new URL(val.href));
5708
+ if (val instanceof URLSearchParams) return keep(val, new URLSearchParams(val));
5709
+ if (isBoxed(val)) return keep(val, Object(val.valueOf()));
5710
+ if (isTemporal(val)) return val;
5711
+ if (val instanceof Error) {
5712
+ const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
5713
+ for (const key of [...ERROR_HIDDEN_KEYS, "stack"]) if (hasOwn(val, key)) Object.defineProperty(copy, key, {
5714
+ value: clone(val[key]),
5715
+ writable: true,
5716
+ configurable: true
5717
+ });
5718
+ return copyKeys(val, copy);
5719
+ }
5720
+ }
4747
5721
  function clone(val) {
4748
5722
  if (val === null || typeof val !== "object") return val;
4749
5723
  const obj = val;
4750
5724
  if (seen.has(obj)) return seen.get(obj);
4751
- if (val instanceof Date) return new Date(val.getTime());
4752
- if (val instanceof RegExp) return new RegExp(val.source, val.flags);
4753
5725
  if (Array.isArray(val)) {
4754
- const arr = [];
4755
- seen.set(obj, arr);
4756
- for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
5726
+ const arr = keep(obj, new Array(val.length));
5727
+ for (let i = 0; i < val.length; i++) if (i in val) arr[i] = clone(val[i]);
4757
5728
  return arr;
4758
5729
  }
4759
5730
  if (val instanceof Map) {
4760
- const copy = /* @__PURE__ */ new Map();
4761
- seen.set(obj, copy);
5731
+ const copy = keep(obj, /* @__PURE__ */ new Map());
4762
5732
  for (const [k, v] of val) copy.set(clone(k), clone(v));
4763
5733
  return copy;
4764
5734
  }
4765
5735
  if (val instanceof Set) {
4766
- const copy = /* @__PURE__ */ new Set();
4767
- seen.set(obj, copy);
5736
+ const copy = keep(obj, /* @__PURE__ */ new Set());
4768
5737
  for (const v of val) copy.add(clone(v));
4769
5738
  return copy;
4770
5739
  }
5740
+ const builtin = cloneBuiltin(obj);
5741
+ if (builtin !== void 0) return builtin;
4771
5742
  const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
4772
5743
  if (!keepsProto && options.keepUnregistered) return val;
4773
- const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
4774
- seen.set(obj, copy);
4775
- for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
4776
- return copy;
5744
+ return copyKeys(obj, keep(obj, keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {}));
4777
5745
  }
4778
5746
  return clone(value);
4779
5747
  }
4780
5748
  /**
4781
- * Structural equality over the value types deepClone() supports: primitives,
4782
- * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
4783
- * at any depth). Map and Set entries are compared in insertion order. Arrays
4784
- * are compared by length and index, so a hole equals an undefined element
4785
- * (deepClone() and save/load turn holes into undefined elements).
5749
+ * Structural equality over the value types deepClone() supports, nested at
5750
+ * any depth and with cycles. Objects must have the same prototype. Map and
5751
+ * Set entries are compared in insertion order, so reordering one is a
5752
+ * change. Arrays are compared by length and index; a hole differs from an
5753
+ * undefined element. Errors are compared by class, message, cause, errors
5754
+ * and own keys (not their stack).
4786
5755
  */
4787
5756
  function deepEqual(a, b) {
4788
- return equal(a, b, /* @__PURE__ */ new Map());
5757
+ return equal(a, b, loosePairs());
4789
5758
  }
4790
5759
  /**
4791
- * `assumed` holds the pairs already being compared: meeting one again (a
4792
- * cycle) assumes it equal. Every object may pair with several others, since
4793
- * cycles of different lengths can still unfold to the same value.
5760
+ * Pairs for deepEqual(). Every object may pair with several others, since
5761
+ * cycles of different lengths can still unfold to the same value, and
5762
+ * shared references may equal distinct copies.
4794
5763
  */
5764
+ function loosePairs() {
5765
+ const assumed = /* @__PURE__ */ new Map();
5766
+ return (a, b) => {
5767
+ const pairs = assumed.get(a);
5768
+ if (pairs?.has(b)) return true;
5769
+ if (pairs) pairs.add(b);
5770
+ else assumed.set(a, new Set([b]));
5771
+ };
5772
+ }
5773
+ /**
5774
+ * Pairs that also require the same reference structure: each object of one
5775
+ * value pairs with exactly one of the other, so shared references and
5776
+ * cycles are shared at the same places.
5777
+ */
5778
+ function strictPairs() {
5779
+ const ab = /* @__PURE__ */ new Map();
5780
+ const ba = /* @__PURE__ */ new Map();
5781
+ const pairs = (a, b) => {
5782
+ const known = ab.get(a);
5783
+ if (known !== void 0) return known === b;
5784
+ if (ba.has(b)) return false;
5785
+ ab.set(a, b);
5786
+ ba.set(b, a);
5787
+ };
5788
+ pairs.strict = true;
5789
+ return pairs;
5790
+ }
5791
+ /**
5792
+ * Whether `a` and `b` are equal as deepEqual() has it and also share
5793
+ * references at the same places (a save of one loads as the other).
5794
+ */
5795
+ function deepEqualStrict(a, b) {
5796
+ return equal(a, b, strictPairs());
5797
+ }
5798
+ /**
5799
+ * `curr` with each part that equals (see deepEqualStrict) the part of
5800
+ * `prev` at the same place replaced by `prev`'s: the history moments of a
5801
+ * save then share what did not change between them, which a save stores
5802
+ * once. Containers on the way to a change are copied; neither value is
5803
+ * changed. Shared references and cycles within `curr` stay so.
5804
+ */
5805
+ function shareEqual(prev, curr) {
5806
+ const done = /* @__PURE__ */ new Map();
5807
+ function share(p, c) {
5808
+ if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
5809
+ if (done.has(c)) return done.get(c);
5810
+ if (deepEqualStrict(p, c)) {
5811
+ done.set(c, p);
5812
+ return p;
5813
+ }
5814
+ if (c instanceof Map && p instanceof Map) {
5815
+ const out = /* @__PURE__ */ new Map();
5816
+ done.set(c, out);
5817
+ for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
5818
+ return out;
5819
+ }
5820
+ if (Array.isArray(c) && Array.isArray(p)) {
5821
+ const out = new Array(c.length);
5822
+ done.set(c, out);
5823
+ for (let i = 0; i < c.length; i++) if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
5824
+ return out;
5825
+ }
5826
+ if (mergesWith(p, c, false)) {
5827
+ const out = Object.create(Object.getPrototypeOf(c));
5828
+ done.set(c, out);
5829
+ const cr = c;
5830
+ for (const key of Object.keys(cr)) setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
5831
+ return out;
5832
+ }
5833
+ done.set(c, c);
5834
+ return c;
5835
+ }
5836
+ return share(prev, curr);
5837
+ }
5838
+ var isObjectValue = (v) => typeof v === "object" && v !== null;
5839
+ function equalBytes(a, b) {
5840
+ const x = ArrayBuffer.isView(a) ? new Uint8Array(a.buffer, a.byteOffset, a.byteLength) : new Uint8Array(a);
5841
+ const y = ArrayBuffer.isView(b) ? new Uint8Array(b.buffer, b.byteOffset, b.byteLength) : new Uint8Array(b);
5842
+ if (x.length !== y.length) return false;
5843
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
5844
+ return true;
5845
+ }
5846
+ /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
5847
+ function equalBuiltin(a, b, assumed) {
5848
+ if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
5849
+ if (a instanceof RegExp) return String(a) === String(b);
5850
+ if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) return equalBytes(a, b);
5851
+ if (a instanceof URL || a instanceof URLSearchParams) return String(a) === String(b);
5852
+ if (isBoxed(a)) return Object.is(a.valueOf(), b.valueOf());
5853
+ if (isTemporal(a)) return String(a) === String(b);
5854
+ if (a instanceof Error) return ERROR_HIDDEN_KEYS.every((key) => hasOwn(a, key) === hasOwn(b, key) && equal(a[key], b[key], assumed)) && equalKeys(a, b, assumed);
5855
+ }
5856
+ function equalKeys(a, b, assumed) {
5857
+ const ao = a;
5858
+ const bo = b;
5859
+ const keys = Object.keys(ao);
5860
+ if (keys.length !== Object.keys(bo).length) return false;
5861
+ for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
5862
+ return true;
5863
+ }
5864
+ /** `assumed` tracks the pairs of objects met (see Pairs). */
4795
5865
  function equal(a, b, assumed) {
4796
- if (Object.is(a, b)) return true;
5866
+ if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
4797
5867
  if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4798
5868
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
4799
- const pairs = assumed.get(a);
4800
- if (pairs?.has(b)) return true;
4801
- if (pairs) pairs.add(b);
4802
- else assumed.set(a, new Set([b]));
4803
- if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
4804
- if (a instanceof RegExp) return String(a) === String(b);
5869
+ const met = assumed(a, b);
5870
+ if (met !== void 0) return met;
4805
5871
  if (a instanceof Map || a instanceof Set) {
4806
5872
  const bc = b;
4807
5873
  if (a.size !== bc.size) return false;
@@ -4810,27 +5876,25 @@ function equal(a, b, assumed) {
4810
5876
  for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!equal(x.value, y.value, assumed)) return false;
4811
5877
  return true;
4812
5878
  }
5879
+ const builtin = equalBuiltin(a, b, assumed);
5880
+ if (builtin !== void 0) return builtin;
4813
5881
  if (Array.isArray(a)) {
4814
5882
  const bc = b;
4815
5883
  if (a.length !== bc.length) return false;
4816
- for (let i = 0; i < a.length; i++) if (!equal(a[i], bc[i], assumed)) return false;
5884
+ for (let i = 0; i < a.length; i++) if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
4817
5885
  return true;
4818
5886
  }
4819
- const ao = a;
4820
- const bo = b;
4821
- const keys = Object.keys(ao);
4822
- if (keys.length !== Object.keys(bo).length) return false;
4823
- for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
4824
- return true;
5887
+ return equalKeys(a, b, assumed);
4825
5888
  }
4826
5889
  /**
4827
5890
  * Objects merged property by property: plain objects and (registered)
4828
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
4829
- * replaced as a whole, since their elements have no stable identity to
4830
- * merge by (a shift moves every index).
5891
+ * class instances. Arrays and atomic built-ins (Map, Set, Date, typed
5892
+ * arrays, errors..., see isAtomic) are values that are replaced as a whole,
5893
+ * since their elements have no stable identity to merge by (a shift moves
5894
+ * every index) or their content is not in their keys.
4831
5895
  */
4832
5896
  function isMergeable(value) {
4833
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
5897
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !isAtomic(value);
4834
5898
  }
4835
5899
  /**
4836
5900
  * Whether changes between `a` and `b` merge key by key: both are objects of
@@ -4969,13 +6033,13 @@ var VALID_EVENTS = new Set([
4969
6033
  "passagerender",
4970
6034
  "dialogrender"
4971
6035
  ]);
4972
- var listeners = /* @__PURE__ */ new Map();
6036
+ var listeners$1 = /* @__PURE__ */ new Map();
4973
6037
  function on(event, cb) {
4974
6038
  if (!VALID_EVENTS.has(event)) throw new Error(`spindle: Unknown event "${event}".`);
4975
- let set = listeners.get(event);
6039
+ let set = listeners$1.get(event);
4976
6040
  if (!set) {
4977
6041
  set = /* @__PURE__ */ new Set();
4978
- listeners.set(event, set);
6042
+ listeners$1.set(event, set);
4979
6043
  }
4980
6044
  set.add(cb);
4981
6045
  return () => {
@@ -4984,7 +6048,7 @@ function on(event, cb) {
4984
6048
  }
4985
6049
  /** Call `call` with each listener of `event`. */
4986
6050
  function forEachListener(event, call) {
4987
- const set = listeners.get(event);
6051
+ const set = listeners$1.get(event);
4988
6052
  if (!set) return;
4989
6053
  for (const cb of [...set]) call(cb);
4990
6054
  }
@@ -5173,29 +6237,15 @@ function saveWithHooks(slot, custom, beginCapture, write) {
5173
6237
  return Promise.reject(err);
5174
6238
  }
5175
6239
  }
5176
- /**
5177
- * `payload` with `convert` applied to its live variables and to those of
5178
- * every history moment.
5179
- */
5180
- function mapVariables(payload, convert) {
5181
- return {
5182
- ...payload,
5183
- variables: convert(payload.variables),
5184
- history: payload.history.map((m) => ({
5185
- ...m,
5186
- variables: convert(m.variables)
5187
- }))
5188
- };
5189
- }
5190
- /** The record to store for a save: `payload` serialized, and its size. */
6240
+ /** The record to store for a save: `payload` encoded, and its size. */
5191
6241
  function storedRecord(meta, payload) {
5192
- const serialized = mapVariables(deepClone(payload), serialize);
6242
+ const encoded = encodePayload(payload);
5193
6243
  return {
5194
6244
  meta: {
5195
6245
  ...meta,
5196
- estimatedBytes: estimatePayloadBytes(serialized)
6246
+ estimatedBytes: estimatePayloadBytes(encoded)
5197
6247
  },
5198
- payload: serialized
6248
+ payload: encoded
5199
6249
  };
5200
6250
  }
5201
6251
  var createSave = queued(createSaveNow);
@@ -5249,15 +6299,13 @@ async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
5249
6299
  return updated;
5250
6300
  }
5251
6301
  /**
5252
- * Turn a stored (serialized) payload into a live one, restoring class
5253
- * instances and built-ins. Returns a new payload; the stored one is left
5254
- * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
6302
+ * The live payload of a stored save, if there is one (see decodePayload:
6303
+ * throws for a save of an incompatible format). Payloads handed to the
6304
+ * store's `loadFromPayload()` must be live.
5255
6305
  */
5256
- function deserializePayload(payload) {
5257
- return mapVariables(payload, deserialize);
5258
- }
5259
- /** The live payload of a stored save, if there is one. */
5260
- var livePayload = (record) => record && deserializePayload(record.payload);
6306
+ var livePayload = (record) => record && decodePayload(record.payload);
6307
+ /** The live payload of a stored record's payload (see decodePayload). */
6308
+ var decodeSavePayload = (payload) => decodePayload(payload);
5261
6309
  readingSave(livePayload);
5262
6310
  /**
5263
6311
  * Delete a save record. If the default slot or a named slot holds it, that
@@ -5426,7 +6474,7 @@ function loadSlotSave(ifid, slot) {
5426
6474
  return inOrder(async () => {
5427
6475
  const record = await slotRecord(ifid, slot);
5428
6476
  if (!record) return void 0;
5429
- const payload = deserializePayload(record.payload);
6477
+ const payload = decodePayload(record.payload);
5430
6478
  const { playthroughId } = record.meta;
5431
6479
  if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
5432
6480
  return {
@@ -5476,22 +6524,32 @@ var deleteSlotSave = queued(async (ifid, slot) => {
5476
6524
  });
5477
6525
  var SESSION_KEY_PREFIX = "spindle.session.";
5478
6526
  /**
5479
- * Write a pre-serialized session payload to sessionStorage.
5480
- * Callers are responsible for serializing variables (see persistSession in store.ts).
6527
+ * Write the session payload to sessionStorage, encoded like a save (see
6528
+ * encodePayload). Throws, like a save, when the state holds a value a save
6529
+ * cannot (a function, an instance of an unregistered class, a unique
6530
+ * symbol); the session then keeps its previous copy.
5481
6531
  */
5482
- function saveSession(ifid, data) {
6532
+ function saveSession(ifid, payload) {
6533
+ const text = JSON.stringify(encodePayload(payload));
5483
6534
  try {
5484
- sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, JSON.stringify(data));
6535
+ sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
5485
6536
  } catch {}
5486
6537
  }
6538
+ /**
6539
+ * The live payload of the stored session, if there is one that loads. A
6540
+ * session written by an incompatible version of Spindle is dropped with a
6541
+ * warning, and the story starts fresh.
6542
+ */
5487
6543
  function loadSession(ifid) {
5488
6544
  try {
5489
6545
  const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${ifid}`);
5490
6546
  if (!raw) return void 0;
5491
- const parsed = JSON.parse(raw);
5492
- if (!isSavePayload(parsed)) return void 0;
5493
- return deserializePayload(parsed);
5494
- } catch {
6547
+ return decodePayload(JSON.parse(raw));
6548
+ } catch (err) {
6549
+ if (err instanceof IncompatibleSaveError) {
6550
+ console.warn(`spindle: ${err.message}; the session was not restored.`);
6551
+ clearSession(ifid);
6552
+ }
5495
6553
  return;
5496
6554
  }
5497
6555
  }
@@ -5504,7 +6562,7 @@ function clearSession(ifid) {
5504
6562
  function toExport(record) {
5505
6563
  if (!record) return void 0;
5506
6564
  return {
5507
- version: 1,
6565
+ formatVersion: 1,
5508
6566
  ifid: record.meta.ifid,
5509
6567
  exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5510
6568
  save: record
@@ -5517,9 +6575,8 @@ var exportSave = readingSave(toExport);
5517
6575
  * The returned record is not stored yet.
5518
6576
  */
5519
6577
  async function prepareImport(data, ifid) {
5520
- if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
6578
+ checkSaveExport(data);
5521
6579
  if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
5522
- if (!isSaveExport(data)) throw new Error("Invalid save file format");
5523
6580
  const record = deepClone(data.save);
5524
6581
  record.meta.id = crypto.randomUUID();
5525
6582
  record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -6168,6 +7225,48 @@ function isDialogShowing() {
6168
7225
  return dialogHostCallbacks?.isOpen() ?? false;
6169
7226
  }
6170
7227
  //#endregion
7228
+ //#region src/runtime-errors.ts
7229
+ var errors = [];
7230
+ var nextId = 1;
7231
+ var listeners = /* @__PURE__ */ new Set();
7232
+ function update(next) {
7233
+ errors = next;
7234
+ for (const listener of listeners) listener();
7235
+ }
7236
+ /**
7237
+ * Show `error` on the page, after `context` (what failed). The same error
7238
+ * again, while it is shown, counts up instead of adding an entry. Logging
7239
+ * it to the console is up to the caller.
7240
+ */
7241
+ function showRuntimeError(context, error) {
7242
+ const message = errorMessage(error).replace(/^spindle: /, "");
7243
+ const same = errors.find((e) => e.context === context && e.message === message);
7244
+ update(same ? errors.map((e) => e === same ? {
7245
+ ...e,
7246
+ count: e.count + 1
7247
+ } : e) : [...errors, {
7248
+ id: nextId++,
7249
+ context,
7250
+ message,
7251
+ count: 1
7252
+ }]);
7253
+ }
7254
+ /** Remove the shown error `id`. */
7255
+ function dismissRuntimeError(id) {
7256
+ update(errors.filter((e) => e.id !== id));
7257
+ }
7258
+ /** The errors shown now, oldest first. */
7259
+ function getRuntimeErrors() {
7260
+ return errors;
7261
+ }
7262
+ /** Call `listener` whenever the shown errors change; returns unsubscribe. */
7263
+ function subscribeRuntimeErrors(listener) {
7264
+ listeners.add(listener);
7265
+ return () => {
7266
+ listeners.delete(listener);
7267
+ };
7268
+ }
7269
+ //#endregion
6171
7270
  //#region src/store.ts
6172
7271
  enablePatches();
6173
7272
  enableMapSet();
@@ -6193,24 +7292,39 @@ var variableBase = createNamespace();
6193
7292
  var patchEntries = [];
6194
7293
  /** Immer-produced reference to variables right after the last navigation. */
6195
7294
  var lastNavigationVars = createNamespace();
6196
- /** Deep-clone patch values so they are independent of future mutations. */
6197
- function clonePatches(patches) {
6198
- return patches.map((p) => ({
6199
- ...p,
6200
- value: p.value !== void 0 ? deepClone(p.value) : void 0
6201
- }));
6202
- }
6203
- /** Compute forward + inverse patches that transform `prev` into `curr`. */
7295
+ /**
7296
+ * The variables that differ between `prev` and `curr`: added, replaced (by
7297
+ * reference: store values are immutable, so an unchanged variable keeps its
7298
+ * object) or deleted. A recorded value is a deep copy, so that changing a
7299
+ * live value in place (a class instance is not frozen) cannot change
7300
+ * history; instances of unregistered classes are kept, as the store keeps
7301
+ * them (a save then refuses them).
7302
+ */
6204
7303
  function computeVarPatches(prev, curr) {
6205
- const [, forward, inverse] = produceWithPatches(prev, (draft) => {
6206
- const d = draft;
6207
- for (const key of Object.keys(d)) if (!hasOwn(curr, key)) delete d[key];
6208
- for (const [key, val] of Object.entries(curr)) d[key] = val;
7304
+ const forward = [];
7305
+ for (const key of Object.keys(prev)) if (!hasOwn(curr, key)) forward.push({
7306
+ key,
7307
+ deleted: true
6209
7308
  });
6210
- return {
6211
- forward: clonePatches(forward),
6212
- inverse: clonePatches(inverse)
6213
- };
7309
+ for (const key of Object.keys(curr)) if (!hasOwn(prev, key) || !Object.is(prev[key], curr[key])) forward.push({
7310
+ key,
7311
+ deleted: false,
7312
+ value: deepClone(curr[key], { keepUnregistered: true })
7313
+ });
7314
+ return { forward };
7315
+ }
7316
+ /**
7317
+ * The variables of the moment after `vars`, by `changes`. The recorded
7318
+ * values are used as they are, not copied, so values the moments share
7319
+ * stay shared, and cycles and shared references in them hold (Immer's
7320
+ * applyPatches copies each value, and recurses forever on a cycle).
7321
+ */
7322
+ function applyVarPatches(vars, changes) {
7323
+ const next = Object.create(Object.getPrototypeOf(vars));
7324
+ for (const key of Object.keys(vars)) setOwn(next, key, vars[key]);
7325
+ for (const change of changes) if (change.deleted) delete next[change.key];
7326
+ else setOwn(next, change.key, change.value);
7327
+ return next;
6214
7328
  }
6215
7329
  /**
6216
7330
  * Replace the variable snapshot recorded for the newest history moment
@@ -6228,7 +7342,7 @@ function rerecordNewestMoment(vars) {
6228
7342
  function forEachRecorded(count, fn) {
6229
7343
  let vars = variableBase;
6230
7344
  for (let i = 0; i < count; i++) {
6231
- if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
7345
+ if (i > 0) vars = applyVarPatches(vars, patchEntries[i - 1].forward);
6232
7346
  fn(vars, i);
6233
7347
  }
6234
7348
  }
@@ -6244,30 +7358,65 @@ function savedMoment(moment, variables) {
6244
7358
  /** Reconstruct variables at a given history moment by replaying patches. */
6245
7359
  function reconstructVarsAt(index) {
6246
7360
  let vars = variableBase;
6247
- for (let i = 0; i < index; i++) vars = applyPatches(vars, patchEntries[i].forward);
7361
+ for (let i = 0; i < index; i++) vars = applyVarPatches(vars, patchEntries[i].forward);
6248
7362
  return vars;
6249
7363
  }
6250
- var serializedHistory = [];
7364
+ /**
7365
+ * The history moments the session holds, with the variables recorded for
7366
+ * each. Store state is immutable, so holding the recorded objects keeps
7367
+ * them as they were; moments share the values that did not change between
7368
+ * them, and the session stores those once (see encodePayload).
7369
+ */
7370
+ var sessionMoments = [];
7371
+ /**
7372
+ * Write the game to the session: the whole payload, encoded in one piece
7373
+ * on every navigation.
7374
+ */
6251
7375
  function persistSession(get) {
6252
7376
  const { storyData, currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
6253
7377
  if (!storyData) return;
6254
- if (serializedHistory.length > history.length) serializedHistory.length = history.length;
6255
- if (serializedHistory.length < history.length) if (history.length - serializedHistory.length === 1) {
7378
+ if (sessionMoments.length > history.length) sessionMoments.length = history.length;
7379
+ if (sessionMoments.length < history.length) if (history.length - sessionMoments.length === 1) {
6256
7380
  const i = history.length - 1;
6257
- serializedHistory[i] = savedMoment(history[i], serialize(variables));
7381
+ sessionMoments[i] = savedMoment(history[i], variables);
6258
7382
  } else forEachRecorded(history.length, (vars, i) => {
6259
- if (i >= serializedHistory.length) serializedHistory[i] = savedMoment(history[i], serialize(vars));
7383
+ if (i >= sessionMoments.length) sessionMoments[i] = savedMoment(history[i], vars);
6260
7384
  });
6261
7385
  saveSession(storyData.ifid, {
6262
7386
  passage: currentPassage,
6263
- variables: serialize(variables),
6264
- history: serializedHistory,
7387
+ variables,
7388
+ history: sessionMoments,
6265
7389
  historyIndex,
6266
7390
  visitCounts,
6267
7391
  renderCounts,
6268
7392
  prng: snapshotPRNG()
6269
7393
  });
6270
7394
  }
7395
+ /** What the page shows before a session write error (see RuntimeErrors). */
7396
+ var SESSION_ERROR_CONTEXT = "The game could not be saved for a page reload; a reload goes back to the last passage it could save:";
7397
+ /**
7398
+ * Write the session, then run `after` (the rest of the operation: its
7399
+ * events and queued navigations), then throw the session's error if writing
7400
+ * it failed: a value a save cannot hold (a function, an instance of an
7401
+ * unregistered class, a unique symbol) fails the operation that put it in
7402
+ * the state's history, as it fails a save, but only after the operation is
7403
+ * complete, so the story is not left half-way through it. The error is also
7404
+ * shown on the page, and the session keeps its last good copy.
7405
+ */
7406
+ function persistSessionThen(get, after) {
7407
+ let failure;
7408
+ try {
7409
+ persistSession(get);
7410
+ } catch (error) {
7411
+ failure = { error };
7412
+ showRuntimeError(SESSION_ERROR_CONTEXT, error);
7413
+ }
7414
+ try {
7415
+ after?.();
7416
+ } finally {
7417
+ if (failure) throw failure.error;
7418
+ }
7419
+ }
6271
7420
  /**
6272
7421
  * Trim history to `state.maxHistory` moments: keep the newest moments that
6273
7422
  * include the current one. After a navigation (the current moment is the
@@ -6281,10 +7430,10 @@ function trimHistory(state) {
6281
7430
  if (excess <= 0) return false;
6282
7431
  const start = Math.min(state.historyIndex, excess);
6283
7432
  const end = start + state.maxHistory;
6284
- for (let i = 0; i < start; i++) variableBase = applyPatches(variableBase, patchEntries[i].forward);
7433
+ for (let i = 0; i < start; i++) variableBase = applyVarPatches(variableBase, patchEntries[i].forward);
6285
7434
  state.history = state.history.slice(start, end);
6286
7435
  patchEntries = patchEntries.slice(start, end - 1);
6287
- serializedHistory = serializedHistory.slice(start, end);
7436
+ sessionMoments = sessionMoments.slice(start, end);
6288
7437
  state.historyIndex -= start;
6289
7438
  return true;
6290
7439
  }
@@ -6317,16 +7466,19 @@ function finishEnteredMoment(get, set) {
6317
7466
  });
6318
7467
  }
6319
7468
  /**
6320
- * A deep copy of a variable namespace as save data: a plain object, as a
6321
- * loaded save holds it (the store turns it back into a namespace on load).
7469
+ * A variable namespace as save data: a plain object, as a loaded save holds
7470
+ * it (the store turns it back into a namespace on load). The values are the
7471
+ * store's own, immutable ones, not copies: the moments of a payload share
7472
+ * the values that did not change between them, and a save stores those
7473
+ * once (see encodePayload).
6322
7474
  */
6323
- var plainCopy = (ns) => ({ ...deepClone(ns) });
7475
+ var plainCopy = (ns) => ({ ...ns });
6324
7476
  /** Reset all module-level state (called on init, restart, loadFromPayload). */
6325
7477
  function resetModuleState(base) {
6326
7478
  variableBase = base;
6327
7479
  patchEntries = [];
6328
7480
  lastNavigationVars = base;
6329
- serializedHistory = [];
7481
+ sessionMoments = [];
6330
7482
  }
6331
7483
  /**
6332
7484
  * Fresh variables from `defaults` for a game that starts over (init,
@@ -6377,8 +7529,7 @@ function moveInHistory(get, set, step) {
6377
7529
  reinitTriggerState();
6378
7530
  lastNavigationVars = get().variables;
6379
7531
  restorePRNGFromMoment(get().history[get().historyIndex]);
6380
- persistSession(get);
6381
- emit("afternavigate", targetPassage, previousPassage);
7532
+ persistSessionThen(get, () => emit("afternavigate", targetPassage, previousPassage));
6382
7533
  }
6383
7534
  /**
6384
7535
  * Record the state StoryInit left behind as the start moment: its variable
@@ -6551,7 +7702,8 @@ function loadedEntryMoment(payload) {
6551
7702
  * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
6552
7703
  * load restores that snapshot, the state on entering the passage, and runs
6553
7704
  * the passage again, so data a hook adds to a save would otherwise be lost
6554
- * on load (#227). Only the property paths the hooks changed are written: a
7705
+ * on load (#227). Only the property paths the hooks changed are written (into
7706
+ * copies of the objects on those paths, the rest staying shared): a
6555
7707
  * whole variable would bring along what the passage did to the rest of it,
6556
7708
  * which the passage then does again on load (#232). Only the payload's copy
6557
7709
  * changes: the live history keeps the recorded snapshot (#159).
@@ -6573,7 +7725,9 @@ function mergeHookWrites(target, before, after, ancestors) {
6573
7725
  ancestors.add(after);
6574
7726
  mergeKeys(target, before, after, (key, b, a) => {
6575
7727
  if (!hasOwn(target, key) || !mergesWith(a, target[key], true) || ancestors.has(a)) return false;
6576
- mergeHookWrites(target[key], b, a, ancestors);
7728
+ const copy = shallowCopy(target[key]);
7729
+ target[key] = copy;
7730
+ mergeHookWrites(copy, b, a, ancestors);
6577
7731
  return true;
6578
7732
  });
6579
7733
  ancestors.delete(after);
@@ -6738,7 +7892,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
6738
7892
  state.maxHistory = Math.max(1, Math.round(limit));
6739
7893
  trimmed = trimHistory(state);
6740
7894
  });
6741
- if (trimmed) persistSession(get);
7895
+ if (trimmed) persistSessionThen(get);
6742
7896
  },
6743
7897
  setQuickSaveKey: (key) => {
6744
7898
  set((state) => {
@@ -6799,7 +7953,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
6799
7953
  state.navigationId++;
6800
7954
  state.history = state.history.slice(0, state.historyIndex + 1);
6801
7955
  patchEntries.length = state.historyIndex;
6802
- if (serializedHistory.length > state.historyIndex + 1) serializedHistory.length = state.historyIndex + 1;
7956
+ if (sessionMoments.length > state.historyIndex + 1) sessionMoments.length = state.historyIndex + 1;
6803
7957
  patchEntries.push(patchEntry);
6804
7958
  state.history.push({
6805
7959
  passage: passageName,
@@ -6824,9 +7978,10 @@ var useStoryStore = create()(storyStore((set, get) => ({
6824
7978
  const deferred = deferredNavigations;
6825
7979
  deferredNavigations = [];
6826
7980
  finishEnteredMoment(get, set);
6827
- persistSession(get);
6828
- emit("afternavigate", passageName, previousPassage);
6829
- for (const next of deferred) get().navigate(next);
7981
+ persistSessionThen(get, () => {
7982
+ emit("afternavigate", passageName, previousPassage);
7983
+ for (const next of deferred) get().navigate(next);
7984
+ });
6830
7985
  },
6831
7986
  goBack: () => moveInHistory(get, set, -1),
6832
7987
  goForward: () => moveInHistory(get, set, 1),
@@ -6967,7 +8122,6 @@ var useStoryStore = create()(storyStore((set, get) => ({
6967
8122
  importSave: async (data, slot) => {
6968
8123
  const { storyData } = get();
6969
8124
  if (!storyData) throw new Error("spindle: Story is not initialized.");
6970
- if (!isSaveExport(data)) throw new Error("Invalid save file format");
6971
8125
  const info = await importSlotSave(data, storyData.ifid, slot);
6972
8126
  recordKnownSave(set, slot, true);
6973
8127
  return info;
@@ -7002,11 +8156,12 @@ var useStoryStore = create()(storyStore((set, get) => ({
7002
8156
  const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
7003
8157
  const saveHistory = [];
7004
8158
  forEachRecorded(history.length, (vars, i) => {
7005
- saveHistory.push(savedMoment(history[i], plainCopy(vars)));
8159
+ const recorded = plainCopy(vars);
8160
+ saveHistory.push(savedMoment(history[i], i === 0 ? recorded : shareEqual(saveHistory[i - 1].variables, recorded)));
7006
8161
  });
7007
8162
  return {
7008
8163
  passage: currentPassage,
7009
- variables: plainCopy(variables),
8164
+ variables: shareEqual(saveHistory[historyIndex]?.variables, plainCopy(variables)),
7010
8165
  history: saveHistory,
7011
8166
  historyIndex,
7012
8167
  visitCounts: { ...visitCounts },
@@ -7034,17 +8189,10 @@ var useStoryStore = create()(storyStore((set, get) => ({
7034
8189
  const ifid = get().storyData?.ifid;
7035
8190
  if (playthroughId && ifid) switchToLoadedPlaythrough(ifid, playthroughId);
7036
8191
  const entry = loadedEntryMoment(payload);
7037
- const base = createNamespace(deepClone(payload.history[0]?.variables ?? {}));
7038
- const newPatchEntries = [];
7039
- let prevVars = base;
7040
- for (let i = 1; i < payload.history.length; i++) {
7041
- const currVars = createNamespace(deepClone(payload.history[i].variables));
7042
- newPatchEntries.push(computeVarPatches(prevVars, currVars));
7043
- prevVars = currVars;
7044
- }
7045
- variableBase = deepClone(base);
7046
- patchEntries = newPatchEntries;
7047
- serializedHistory = payload.history.map((m) => savedMoment(m, serialize(m.variables)));
8192
+ const snapshots = deepClone(payload.history.map((m) => m.variables)).map((vars) => createNamespace(vars));
8193
+ variableBase = deepClone(snapshots[0]);
8194
+ patchEntries = snapshots.slice(1).map((curr, i) => computeVarPatches(snapshots[i], curr));
8195
+ sessionMoments = payload.history.map((m, i) => savedMoment(m, snapshots[i]));
7048
8196
  set((state) => {
7049
8197
  state.currentPassage = payload.passage;
7050
8198
  state.navigationId++;
@@ -7064,8 +8212,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7064
8212
  reinitTriggerState();
7065
8213
  lastNavigationVars = reconstructVarsAt(get().historyIndex);
7066
8214
  restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
7067
- persistSession(get);
7068
- withoutDraws(() => emit("afterload", slot));
8215
+ persistSessionThen(get, () => withoutDraws(() => emit("afterload", slot)));
7069
8216
  },
7070
8217
  getHistoryVariables: (index) => {
7071
8218
  return deepClone(reconstructVarsAt(index));
@@ -7306,6 +8453,50 @@ function TriggerDialogHost() {
7306
8453
  }, `${dialog.passageName}-${i}`)) });
7307
8454
  }
7308
8455
  //#endregion
8456
+ //#region src/components/RuntimeErrors.tsx
8457
+ /**
8458
+ * The runtime errors shown on the page (see showRuntimeError): one banner
8459
+ * each, announced to screen readers (role="alert"), until the player
8460
+ * dismisses it. Style them with the `spindle-error-banner` classes.
8461
+ */
8462
+ function RuntimeErrors() {
8463
+ const [errors, setErrors] = useState(getRuntimeErrors);
8464
+ useEffect(() => {
8465
+ setErrors(getRuntimeErrors());
8466
+ return subscribeRuntimeErrors(() => setErrors(getRuntimeErrors()));
8467
+ }, []);
8468
+ if (errors.length === 0) return null;
8469
+ return /* @__PURE__ */ jsx("div", {
8470
+ class: "spindle-error-banners",
8471
+ children: errors.map((e) => /* @__PURE__ */ jsxs("div", {
8472
+ class: "spindle-error-banner",
8473
+ role: "alert",
8474
+ children: [
8475
+ /* @__PURE__ */ jsx("span", {
8476
+ class: "spindle-error-banner-context",
8477
+ children: e.context
8478
+ }),
8479
+ " ",
8480
+ /* @__PURE__ */ jsx("span", {
8481
+ class: "spindle-error-banner-message",
8482
+ children: e.message
8483
+ }),
8484
+ e.count > 1 && /* @__PURE__ */ jsx("span", {
8485
+ class: "spindle-error-banner-count",
8486
+ children: ` (×${e.count})`
8487
+ }),
8488
+ /* @__PURE__ */ jsx("button", {
8489
+ type: "button",
8490
+ class: "spindle-error-banner-dismiss",
8491
+ "aria-label": "Dismiss",
8492
+ onClick: () => dismissRuntimeError(e.id),
8493
+ children: "✕"
8494
+ })
8495
+ ]
8496
+ }, e.id))
8497
+ });
8498
+ }
8499
+ //#endregion
7309
8500
  //#region src/components/App.tsx
7310
8501
  function App() {
7311
8502
  const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
@@ -7327,7 +8518,11 @@ function App() {
7327
8518
  class: "loading",
7328
8519
  children: "Loading..."
7329
8520
  });
7330
- const content = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(StoryInterface, {}), /* @__PURE__ */ jsx(TriggerDialogHost, {})] });
8521
+ const content = /* @__PURE__ */ jsxs(Fragment$1, { children: [
8522
+ /* @__PURE__ */ jsx(StoryInterface, {}),
8523
+ /* @__PURE__ */ jsx(TriggerDialogHost, {}),
8524
+ /* @__PURE__ */ jsx(RuntimeErrors, {})
8525
+ ] });
7331
8526
  return nobr ? /* @__PURE__ */ jsx(NobrContext.Provider, {
7332
8527
  value: true,
7333
8528
  children: content
@@ -7335,19 +8530,92 @@ function App() {
7335
8530
  }
7336
8531
  //#endregion
7337
8532
  //#region src/parser.ts
8533
+ var HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
8534
+ /** HTML elements written without content or end tag, as innerHTML does. */
8535
+ var VOID_ELEMENTS = new Set([
8536
+ "area",
8537
+ "base",
8538
+ "basefont",
8539
+ "bgsound",
8540
+ "br",
8541
+ "col",
8542
+ "embed",
8543
+ "frame",
8544
+ "hr",
8545
+ "img",
8546
+ "input",
8547
+ "keygen",
8548
+ "link",
8549
+ "meta",
8550
+ "param",
8551
+ "source",
8552
+ "track",
8553
+ "wbr"
8554
+ ]);
8555
+ /**
8556
+ * The passage markup in the nodes of passage data.
8557
+ *
8558
+ * A compiler that HTML-escapes passage text (Twine, Tweego) leaves a text
8559
+ * node whose text is the markup; a no-break space in it is U+00A0, which
8560
+ * innerHTML writes as `&nbsp;`. One that doesn't escape it leaves the
8561
+ * browser's parse of it, whose elements are written back as markup here.
8562
+ * innerHTML did that too, but its escaping can't be undone: a `"` in an
8563
+ * attribute value is written `&quot;`, and decoding that ends the value
8564
+ * early, while text it leaves unescaped (in `<style>`, comments) would be
8565
+ * decoded wrongly.
8566
+ */
8567
+ function passageMarkup(parent) {
8568
+ let markup = "";
8569
+ for (const node of Array.from(parent.childNodes)) if (node.nodeType === Node.TEXT_NODE) markup += node.data;
8570
+ else if (node.nodeType === Node.COMMENT_NODE) markup += `<!--${node.data}-->`;
8571
+ else if (node.nodeType === Node.ELEMENT_NODE) markup += elementMarkup(node);
8572
+ return markup;
8573
+ }
8574
+ /** An element of passage data as markup (see passageMarkup). */
8575
+ function elementMarkup(el) {
8576
+ const name = el.localName;
8577
+ let markup = `<${name}`;
8578
+ for (const attr of Array.from(el.attributes)) markup += ` ${attr.name}=${quoteAttribute(attr.name, attr.value)}`;
8579
+ markup += ">";
8580
+ if (el.namespaceURI === HTML_NAMESPACE && VOID_ELEMENTS.has(name)) return markup;
8581
+ const content = name === "template" ? el.content : el;
8582
+ return `${markup}${passageMarkup(content)}</${name}>`;
8583
+ }
8584
+ /**
8585
+ * An attribute value quoted so that the passage tokenizer reads it back:
8586
+ * in double quotes, else single ones, else with the double quotes in its
8587
+ * text written as `&quot;`, which rendering decodes. Quotes inside markup in
8588
+ * the value (`{print "a"}`) are code, and don't end it.
8589
+ */
8590
+ function quoteAttribute(name, value) {
8591
+ const code = isCodeAttribute(name);
8592
+ const probe = code ? "on" : "a";
8593
+ const readsBack = (quoted) => {
8594
+ const tokens = tokenize(`<i ${probe}=${quoted}>`);
8595
+ const tag = tokens[0];
8596
+ return tokens.length === 1 && tag?.type === "html" && tag.attributes[probe] === quoted.slice(1, -1);
8597
+ };
8598
+ return [`"${value}"`, `'${value}'`].find(readsBack) ?? `"${escapeTextQuotes(value, code)}"`;
8599
+ }
7338
8600
  /**
7339
- * Decode the HTML entities that browsers use when serializing innerHTML.
7340
- * &amp; is decoded last to avoid double-decoding (e.g. &amp;lt; → &lt;, not <).
8601
+ * `value` with the double quotes in its text written as `&quot;`, but not
8602
+ * those in markup (`{print "a"}`) or, in a code attribute, in sigil
8603
+ * references (see splitSigilTemplate), which are code.
7341
8604
  */
7342
- function decodeHtmlEntities(html) {
7343
- return html.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/g, "&");
8605
+ function escapeTextQuotes(value, code) {
8606
+ const escape = (text) => text.replace(/"/g, "&quot;");
8607
+ if (code) return splitSigilTemplate(value).map((part) => "text" in part ? escape(part.text) : "expr" in part ? `{${part.expr}}` : part.verbatim).join("");
8608
+ return tokenize(value, { text: true }).map((token) => {
8609
+ const source = value.slice(token.start, token.end);
8610
+ return token.type === "text" ? escape(source) : source;
8611
+ }).join("");
7344
8612
  }
7345
8613
  /**
7346
8614
  * Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
7347
8615
  *
7348
- * Uses innerHTML + entity decoding instead of textContent so that HTML
7349
- * tags inside passage markup (e.g. <div> inside {button}) are preserved
7350
- * regardless of whether the compiler HTML-encoded them.
8616
+ * Passage content is read with passageMarkup instead of textContent, so
8617
+ * that HTML tags inside passage markup (e.g. <div> inside {button}) are
8618
+ * preserved whether or not the compiler HTML-encoded them.
7351
8619
  */
7352
8620
  function parseStoryData() {
7353
8621
  const storyEl = document.querySelector("tw-storydata");
@@ -7365,7 +8633,7 @@ function parseStoryData() {
7365
8633
  const pid = parseInt(el.getAttribute("pid") || "0", 10);
7366
8634
  const passageName = el.getAttribute("name") || "";
7367
8635
  const tags = (el.getAttribute("tags") || "").split(/\s+/).filter((t) => t.length > 0);
7368
- const content = decodeHtmlEntities(el.innerHTML);
8636
+ const content = passageMarkup(el);
7369
8637
  const metadata = {};
7370
8638
  const skipAttrs = new Set([
7371
8639
  "pid",
@@ -8675,12 +9943,13 @@ function assignmentArgs(rawArgs, args) {
8675
9943
  * and later recomputations read the same source.
8676
9944
  */
8677
9945
  function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, localsUpdate) {
9946
+ const location = currentSourceLocation();
8678
9947
  let newValue;
8679
9948
  try {
8680
9949
  const { variables, temporary, transient } = readState();
8681
9950
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
8682
9951
  } catch (err) {
8683
- logMacroError(`computed ${rawArgs}`, err);
9952
+ logMacroError(`computed ${rawArgs}`, err, location);
8684
9953
  return;
8685
9954
  }
8686
9955
  if (!deepEqual(prevRef.current, newValue)) {
@@ -8688,7 +9957,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8688
9957
  if (isLocal) try {
8689
9958
  localsUpdate(name, newValue);
8690
9959
  } catch (err) {
8691
- logMacroError(`computed ${rawArgs}`, err);
9960
+ logMacroError(`computed ${rawArgs}`, err, location);
8692
9961
  }
8693
9962
  else {
8694
9963
  const state = useStoryStore.getState();
@@ -9012,10 +10281,11 @@ defineMacro({
9012
10281
  render({ children = [] }, ctx) {
9013
10282
  const code = ctx.collectText(children);
9014
10283
  ctx.hooks.useLayoutEffect(() => {
10284
+ const location = ctx.sourceLocation();
9015
10285
  try {
9016
10286
  ctx.mutate(code);
9017
10287
  } catch (err) {
9018
- logMacroError("do", err);
10288
+ logMacroError("do", err, location);
9019
10289
  }
9020
10290
  }, []);
9021
10291
  return null;
@@ -9623,7 +10893,7 @@ function SaveManagerContent() {
9623
10893
  };
9624
10894
  const handleLoad = async (save) => {
9625
10895
  try {
9626
- loadFromPayload(deserializePayload(save.payload), void 0, save.meta.playthroughId);
10896
+ loadFromPayload(decodeSavePayload(save.payload), void 0, save.meta.playthroughId);
9627
10897
  showStatus("Game loaded");
9628
10898
  if (closeDialog) closeTimer.current = window.setTimeout(closeDialog, 500);
9629
10899
  await refresh();
@@ -9963,7 +11233,12 @@ defineMacro({
9963
11233
  }],
9964
11234
  render(_props, ctx) {
9965
11235
  ctx.hooks.useLayoutEffect(() => {
9966
- useStoryStore.getState().navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate));
11236
+ const location = ctx.sourceLocation();
11237
+ try {
11238
+ useStoryStore.getState().navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate));
11239
+ } catch (err) {
11240
+ logMacroError("goto", err, location);
11241
+ }
9967
11242
  }, []);
9968
11243
  return null;
9969
11244
  }
@@ -10577,7 +11852,7 @@ defineMacro({
10577
11852
  });
10578
11853
  //#endregion
10579
11854
  //#region src/styles.css?inline
10580
- var styles_default = "*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 16px;\n -webkit-text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n padding: 0;\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.75;\n color: #e0e0e0;\n background-color: #1a1a2e;\n min-height: 100vh;\n}\n\ntw-storydata {\n display: none !important;\n}\n\n#story {\n max-width: 42em;\n margin: 0 auto;\n padding: 2em 1.5em;\n}\n\n/* Passage transitions */\n\n.passage-container {\n position: relative;\n}\n\n.passage-container--crossfading {\n display: grid;\n}\n\n.passage-container--crossfading > * {\n grid-area: 1 / 1;\n}\n\n.passage {\n animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;\n}\n\n.passage[data-transition='none'] {\n animation: none;\n}\n\n.passage-snapshot {\n animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;\n pointer-events: none;\n user-select: none;\n}\n\n@keyframes passage-fade-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes passage-fade-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(-8px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .passage,\n .passage-snapshot {\n animation-duration: 0.01s !important;\n }\n}\n\n.macro-button {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n font-size: 0.95em;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-button:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n.macro-button:active {\n background: rgba(100, 181, 246, 0.35);\n}\n\n.error {\n color: #ef5350;\n font-family: monospace;\n padding: 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.loading {\n text-align: center;\n padding: 2em;\n opacity: 0.6;\n}\n\n/* Menu bar */\n\n.story-menubar {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.5em 1.5em;\n background: #16213e;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-family: system-ui, sans-serif;\n font-size: 0.9em;\n}\n\n.story-title {\n flex: 1;\n font-weight: 600;\n color: #e0e0e0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.menubar-button {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n padding: 0.35em 0.7em;\n border-radius: 4px;\n cursor: pointer;\n font: inherit;\n transition:\n color 0.2s,\n background 0.2s;\n}\n\n.menubar-button:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.menubar-button:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.menubar-button:disabled:hover {\n color: #9e9e9e;\n background: transparent;\n}\n\n/* Generic dialog */\n\n.dialog-overlay {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n.dialog-panel {\n background: #16213e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 8px;\n width: 90%;\n max-width: 500px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n color: #e0e0e0;\n font-family: system-ui, sans-serif;\n position: relative;\n}\n\n/* Focused programmatically when the dialog has no focusable content */\n.dialog-panel:focus {\n outline: none;\n}\n\n.dialog-saves {\n max-width: 550px;\n}\n\n.dialog-settings {\n max-width: 400px;\n}\n\n.dialog-close {\n position: absolute;\n top: 0.75em;\n right: 0.75em;\n z-index: 1;\n background: transparent;\n border: none;\n color: #9e9e9e;\n font-size: 1.1em;\n cursor: pointer;\n padding: 0.25em;\n line-height: 1;\n}\n\n.dialog-close:hover {\n color: #e0e0e0;\n}\n\n.dialog-body {\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n/* Settings controls */\n\n.settings-controls {\n padding: 1em 1.25em;\n display: flex;\n flex-direction: column;\n gap: 1em;\n}\n\n.settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1em;\n}\n\n.settings-row select,\n.settings-row input[type='range'] {\n accent-color: #64b5f6;\n}\n\n.settings-row select {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.settings-row input[type='checkbox'] {\n accent-color: #64b5f6;\n width: 1.1em;\n height: 1.1em;\n}\n\n/* Save manager */\n\n.save-manager {\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n}\n\n.saves-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1em 1.25em;\n padding-right: 3em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-weight: 600;\n flex-shrink: 0;\n}\n\n.saves-header-left {\n display: flex;\n align-items: center;\n gap: 0.75em;\n}\n\n.saves-mode-toggle {\n display: flex;\n gap: 0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n overflow: hidden;\n}\n\n.saves-mode-toggle button {\n background: transparent;\n color: #9e9e9e;\n border: none;\n padding: 0.3em 0.8em;\n font: inherit;\n font-size: 0.85em;\n font-weight: 600;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-mode-toggle button.active {\n background: rgba(100, 181, 246, 0.2);\n color: #64b5f6;\n}\n\n.saves-mode-toggle button:hover:not(.active) {\n color: #e0e0e0;\n}\n\n.saves-body {\n padding: 0.75em 1.25em;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.saves-empty {\n text-align: center;\n padding: 2em 1em;\n opacity: 0.5;\n font-size: 0.9em;\n}\n\n.saves-toolbar {\n display: flex;\n gap: 0.5em;\n padding: 0.5em 1.25em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n}\n\n.saves-toolbar-button {\n background: rgba(255, 255, 255, 0.05);\n color: #9e9e9e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 4px;\n padding: 0.3em 0.7em;\n font: inherit;\n font-size: 0.8em;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-toolbar-button:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #e0e0e0;\n}\n\n.playthrough-group {\n margin-bottom: 0.75em;\n}\n\n.playthrough-header {\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.4em 0;\n cursor: pointer;\n user-select: none;\n font-size: 0.85em;\n color: #9e9e9e;\n transition: color 0.2s;\n}\n\n.playthrough-header:hover {\n color: #e0e0e0;\n}\n\n.playthrough-chevron {\n display: inline-block;\n font-size: 0.7em;\n transition: transform 0.2s;\n}\n\n.playthrough-chevron.open {\n transform: rotate(90deg);\n}\n\n.playthrough-label {\n font-weight: 600;\n}\n\n.playthrough-date {\n opacity: 0.6;\n font-size: 0.9em;\n}\n\n.playthrough-saves {\n display: flex;\n flex-direction: column;\n gap: 0.4em;\n padding-left: 0.25em;\n}\n\n.save-slot {\n display: flex;\n align-items: center;\n gap: 0.75em;\n padding: 0.5em 0.6em;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 6px;\n transition: background 0.15s;\n}\n\n.save-slot:hover {\n background: rgba(255, 255, 255, 0.06);\n}\n\n.save-slot-info {\n flex: 1;\n min-width: 0;\n}\n\n.save-slot-title {\n font-weight: 500;\n font-size: 0.9em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.save-slot-meta {\n font-size: 0.75em;\n color: #9e9e9e;\n display: flex;\n gap: 0.75em;\n}\n\n.save-slot-actions {\n display: flex;\n gap: 0.3em;\n flex-shrink: 0;\n}\n\n.save-slot-action {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n border-radius: 3px;\n padding: 0.2em 0.5em;\n font: inherit;\n font-size: 0.75em;\n cursor: pointer;\n transition:\n color 0.2s,\n background 0.2s;\n white-space: nowrap;\n}\n\n.save-slot-action:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.save-slot-action.primary {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n}\n\n.save-slot-action.primary:hover {\n background: rgba(100, 181, 246, 0.15);\n}\n\n.save-slot-action.danger:hover {\n color: #ef5350;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.save-slot-new {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0.5em;\n margin-top: 0.25em;\n border: 1px dashed rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n color: #9e9e9e;\n font: inherit;\n font-size: 0.8em;\n background: transparent;\n cursor: pointer;\n width: 100%;\n transition:\n color 0.2s,\n border-color 0.2s,\n background 0.2s;\n}\n\n.save-slot-new:hover {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n background: rgba(100, 181, 246, 0.05);\n}\n\n.saves-status {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #81c784;\n border-top: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n text-align: center;\n}\n\n.saves-status.error {\n color: #ef5350;\n border: none;\n border-radius: 0;\n padding: 0.5em 1.25em;\n font-family: inherit;\n background: transparent;\n}\n\n.save-rename-input {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 3px;\n padding: 0.15em 0.4em;\n font: inherit;\n font-size: 0.9em;\n width: 100%;\n}\n\n.save-rename-input:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n/* Macro: link */\n\n.macro-link {\n color: #64b5f6;\n text-decoration: none;\n border-bottom: 1px solid rgba(100, 181, 246, 0.3);\n transition:\n color 0.2s,\n border-color 0.2s;\n cursor: pointer;\n}\n\n.macro-link:hover {\n color: #90caf9;\n border-bottom-color: #90caf9;\n}\n\n.macro-link:active {\n color: #42a5f5;\n}\n\n/* Macro: form inputs */\n\n.macro-textbox,\n.macro-numberbox,\n.macro-textarea {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.macro-textbox:focus,\n.macro-numberbox:focus,\n.macro-textarea:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n.macro-textarea {\n min-height: 3em;\n resize: vertical;\n}\n\n.macro-checkbox,\n.macro-radiobutton {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n}\n\n.macro-checkbox input,\n.macro-radiobutton input {\n accent-color: #64b5f6;\n}\n\n/* Macro: listbox */\n\n.macro-listbox {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n/* Macro: cycle */\n\n.macro-cycle {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-cycle:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n/* Macro: meter */\n\n.macro-meter {\n position: relative;\n width: 100%;\n height: 1.4em;\n background: #2a2a3e;\n border-radius: 0.3em;\n overflow: hidden;\n border: 1px solid #444;\n}\n\n.macro-meter-fill {\n height: 100%;\n background: #64b5f6;\n transition: width 0.3s ease;\n}\n\n.macro-meter-label {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.85em;\n color: #e0e0e0;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n pointer-events: none;\n}\n\n/* Markdown wrapper — invisible to layout */\n\n.md {\n display: contents;\n}\n\n/* Markdown block elements inside passages */\n\n.passage p {\n margin: 0.6em 0;\n}\n\n.passage p:first-child {\n margin-top: 0;\n}\n\n.passage p:last-child {\n margin-bottom: 0;\n}\n\n.passage h1,\n.passage h2,\n.passage h3,\n.passage h4,\n.passage h5,\n.passage h6 {\n margin: 1em 0 0.4em;\n line-height: 1.3;\n color: #f0f0f0;\n}\n\n.passage h1 {\n font-size: 1.8em;\n}\n.passage h2 {\n font-size: 1.5em;\n}\n.passage h3 {\n font-size: 1.25em;\n}\n.passage h4 {\n font-size: 1.1em;\n}\n.passage h5 {\n font-size: 1em;\n}\n.passage h6 {\n font-size: 0.9em;\n opacity: 0.8;\n}\n\n.passage code {\n font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;\n font-size: 0.9em;\n background: rgba(255, 255, 255, 0.08);\n padding: 0.15em 0.35em;\n border-radius: 3px;\n}\n\n.passage pre {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 1em;\n overflow-x: auto;\n margin: 0.8em 0;\n}\n\n.passage pre code {\n background: none;\n padding: 0;\n border-radius: 0;\n font-size: 0.85em;\n}\n\n.passage blockquote {\n border-left: 3px solid rgba(100, 181, 246, 0.5);\n margin: 0.8em 0;\n padding: 0.4em 1em;\n color: #b0b0b0;\n}\n\n.passage blockquote p {\n margin: 0.3em 0;\n}\n\n.passage ul,\n.passage ol {\n margin: 0.6em 0;\n padding-left: 1.8em;\n}\n\n.passage li {\n margin: 0.2em 0;\n}\n\n.passage hr {\n border: none;\n border-top: 1px solid rgba(255, 255, 255, 0.12);\n margin: 1.2em 0;\n}\n\n.passage table {\n border-collapse: collapse;\n width: 100%;\n margin: 0.8em 0;\n}\n\n.passage th,\n.passage td {\n border: 1px solid rgba(255, 255, 255, 0.12);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.passage th {\n background: rgba(255, 255, 255, 0.06);\n font-weight: 600;\n}\n\n.passage tr:nth-child(even) {\n background: rgba(255, 255, 255, 0.02);\n}\n\n.passage del {\n opacity: 0.5;\n}\n\n.passage strong {\n color: #f0f0f0;\n}\n\n/* Macro: type (typewriter) */\n\n.macro-type-cursor::after {\n content: '\\2588';\n animation: type-blink 0.7s step-end infinite;\n color: #64b5f6;\n}\n\n.macro-type-done .macro-type-cursor::after {\n display: none;\n}\n\n@keyframes type-blink {\n 50% {\n opacity: 0;\n }\n}\n";
11855
+ var styles_default = "*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 16px;\n -webkit-text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n padding: 0;\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.75;\n color: #e0e0e0;\n background-color: #1a1a2e;\n min-height: 100vh;\n}\n\ntw-storydata {\n display: none !important;\n}\n\n#story {\n max-width: 42em;\n margin: 0 auto;\n padding: 2em 1.5em;\n}\n\n/* Passage transitions */\n\n.passage-container {\n position: relative;\n}\n\n.passage-container--crossfading {\n display: grid;\n}\n\n.passage-container--crossfading > * {\n grid-area: 1 / 1;\n}\n\n.passage {\n animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;\n}\n\n.passage[data-transition='none'] {\n animation: none;\n}\n\n.passage-snapshot {\n animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;\n pointer-events: none;\n user-select: none;\n}\n\n@keyframes passage-fade-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes passage-fade-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(-8px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .passage,\n .passage-snapshot {\n animation-duration: 0.01s !important;\n }\n}\n\n.macro-button {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n font-size: 0.95em;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-button:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n.macro-button:active {\n background: rgba(100, 181, 246, 0.35);\n}\n\n.error {\n color: #ef5350;\n font-family: monospace;\n padding: 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: rgba(239, 83, 80, 0.1);\n}\n\n/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */\n.spindle-error-banners {\n position: fixed;\n top: 0.75em;\n left: 50%;\n z-index: 300;\n display: flex;\n flex-direction: column;\n gap: 0.5em;\n width: min(42em, calc(100% - 1.5em));\n transform: translateX(-50%);\n}\n\n.spindle-error-banner {\n position: relative;\n padding: 0.75em 2.75em 0.75em 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: #2b1a1e;\n color: #ffcdd2;\n font-size: 0.9em;\n line-height: 1.5;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);\n}\n\n.spindle-error-banner-message {\n font-family: monospace;\n color: #ef9a9a;\n}\n\n.spindle-error-banner-dismiss {\n position: absolute;\n top: 0.5em;\n right: 0.5em;\n background: transparent;\n border: none;\n color: inherit;\n font-size: 1em;\n cursor: pointer;\n}\n\n.spindle-error-banner-dismiss:hover {\n color: #fff;\n}\n\n.loading {\n text-align: center;\n padding: 2em;\n opacity: 0.6;\n}\n\n/* Menu bar */\n\n.story-menubar {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.5em 1.5em;\n background: #16213e;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-family: system-ui, sans-serif;\n font-size: 0.9em;\n}\n\n.story-title {\n flex: 1;\n font-weight: 600;\n color: #e0e0e0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.menubar-button {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n padding: 0.35em 0.7em;\n border-radius: 4px;\n cursor: pointer;\n font: inherit;\n transition:\n color 0.2s,\n background 0.2s;\n}\n\n.menubar-button:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.menubar-button:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.menubar-button:disabled:hover {\n color: #9e9e9e;\n background: transparent;\n}\n\n/* Generic dialog */\n\n.dialog-overlay {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n.dialog-panel {\n background: #16213e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 8px;\n width: 90%;\n max-width: 500px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n color: #e0e0e0;\n font-family: system-ui, sans-serif;\n position: relative;\n}\n\n/* Focused programmatically when the dialog has no focusable content */\n.dialog-panel:focus {\n outline: none;\n}\n\n.dialog-saves {\n max-width: 550px;\n}\n\n.dialog-settings {\n max-width: 400px;\n}\n\n.dialog-close {\n position: absolute;\n top: 0.75em;\n right: 0.75em;\n z-index: 1;\n background: transparent;\n border: none;\n color: #9e9e9e;\n font-size: 1.1em;\n cursor: pointer;\n padding: 0.25em;\n line-height: 1;\n}\n\n.dialog-close:hover {\n color: #e0e0e0;\n}\n\n.dialog-body {\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n/* Settings controls */\n\n.settings-controls {\n padding: 1em 1.25em;\n display: flex;\n flex-direction: column;\n gap: 1em;\n}\n\n.settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1em;\n}\n\n.settings-row select,\n.settings-row input[type='range'] {\n accent-color: #64b5f6;\n}\n\n.settings-row select {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.settings-row input[type='checkbox'] {\n accent-color: #64b5f6;\n width: 1.1em;\n height: 1.1em;\n}\n\n/* Save manager */\n\n.save-manager {\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n}\n\n.saves-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1em 1.25em;\n padding-right: 3em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-weight: 600;\n flex-shrink: 0;\n}\n\n.saves-header-left {\n display: flex;\n align-items: center;\n gap: 0.75em;\n}\n\n.saves-mode-toggle {\n display: flex;\n gap: 0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n overflow: hidden;\n}\n\n.saves-mode-toggle button {\n background: transparent;\n color: #9e9e9e;\n border: none;\n padding: 0.3em 0.8em;\n font: inherit;\n font-size: 0.85em;\n font-weight: 600;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-mode-toggle button.active {\n background: rgba(100, 181, 246, 0.2);\n color: #64b5f6;\n}\n\n.saves-mode-toggle button:hover:not(.active) {\n color: #e0e0e0;\n}\n\n.saves-body {\n padding: 0.75em 1.25em;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.saves-empty {\n text-align: center;\n padding: 2em 1em;\n opacity: 0.5;\n font-size: 0.9em;\n}\n\n.saves-toolbar {\n display: flex;\n gap: 0.5em;\n padding: 0.5em 1.25em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n}\n\n.saves-toolbar-button {\n background: rgba(255, 255, 255, 0.05);\n color: #9e9e9e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 4px;\n padding: 0.3em 0.7em;\n font: inherit;\n font-size: 0.8em;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-toolbar-button:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #e0e0e0;\n}\n\n.playthrough-group {\n margin-bottom: 0.75em;\n}\n\n.playthrough-header {\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.4em 0;\n cursor: pointer;\n user-select: none;\n font-size: 0.85em;\n color: #9e9e9e;\n transition: color 0.2s;\n}\n\n.playthrough-header:hover {\n color: #e0e0e0;\n}\n\n.playthrough-chevron {\n display: inline-block;\n font-size: 0.7em;\n transition: transform 0.2s;\n}\n\n.playthrough-chevron.open {\n transform: rotate(90deg);\n}\n\n.playthrough-label {\n font-weight: 600;\n}\n\n.playthrough-date {\n opacity: 0.6;\n font-size: 0.9em;\n}\n\n.playthrough-saves {\n display: flex;\n flex-direction: column;\n gap: 0.4em;\n padding-left: 0.25em;\n}\n\n.save-slot {\n display: flex;\n align-items: center;\n gap: 0.75em;\n padding: 0.5em 0.6em;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 6px;\n transition: background 0.15s;\n}\n\n.save-slot:hover {\n background: rgba(255, 255, 255, 0.06);\n}\n\n.save-slot-info {\n flex: 1;\n min-width: 0;\n}\n\n.save-slot-title {\n font-weight: 500;\n font-size: 0.9em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.save-slot-meta {\n font-size: 0.75em;\n color: #9e9e9e;\n display: flex;\n gap: 0.75em;\n}\n\n.save-slot-actions {\n display: flex;\n gap: 0.3em;\n flex-shrink: 0;\n}\n\n.save-slot-action {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n border-radius: 3px;\n padding: 0.2em 0.5em;\n font: inherit;\n font-size: 0.75em;\n cursor: pointer;\n transition:\n color 0.2s,\n background 0.2s;\n white-space: nowrap;\n}\n\n.save-slot-action:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.save-slot-action.primary {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n}\n\n.save-slot-action.primary:hover {\n background: rgba(100, 181, 246, 0.15);\n}\n\n.save-slot-action.danger:hover {\n color: #ef5350;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.save-slot-new {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0.5em;\n margin-top: 0.25em;\n border: 1px dashed rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n color: #9e9e9e;\n font: inherit;\n font-size: 0.8em;\n background: transparent;\n cursor: pointer;\n width: 100%;\n transition:\n color 0.2s,\n border-color 0.2s,\n background 0.2s;\n}\n\n.save-slot-new:hover {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n background: rgba(100, 181, 246, 0.05);\n}\n\n.saves-status {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #81c784;\n border-top: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n text-align: center;\n}\n\n.saves-status.error {\n color: #ef5350;\n border: none;\n border-radius: 0;\n padding: 0.5em 1.25em;\n font-family: inherit;\n background: transparent;\n}\n\n.save-rename-input {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 3px;\n padding: 0.15em 0.4em;\n font: inherit;\n font-size: 0.9em;\n width: 100%;\n}\n\n.save-rename-input:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n/* Macro: link */\n\n.macro-link {\n color: #64b5f6;\n text-decoration: none;\n border-bottom: 1px solid rgba(100, 181, 246, 0.3);\n transition:\n color 0.2s,\n border-color 0.2s;\n cursor: pointer;\n}\n\n.macro-link:hover {\n color: #90caf9;\n border-bottom-color: #90caf9;\n}\n\n.macro-link:active {\n color: #42a5f5;\n}\n\n/* Macro: form inputs */\n\n.macro-textbox,\n.macro-numberbox,\n.macro-textarea {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.macro-textbox:focus,\n.macro-numberbox:focus,\n.macro-textarea:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n.macro-textarea {\n min-height: 3em;\n resize: vertical;\n}\n\n.macro-checkbox,\n.macro-radiobutton {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n}\n\n.macro-checkbox input,\n.macro-radiobutton input {\n accent-color: #64b5f6;\n}\n\n/* Macro: listbox */\n\n.macro-listbox {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n/* Macro: cycle */\n\n.macro-cycle {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-cycle:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n/* Macro: meter */\n\n.macro-meter {\n position: relative;\n width: 100%;\n height: 1.4em;\n background: #2a2a3e;\n border-radius: 0.3em;\n overflow: hidden;\n border: 1px solid #444;\n}\n\n.macro-meter-fill {\n height: 100%;\n background: #64b5f6;\n transition: width 0.3s ease;\n}\n\n.macro-meter-label {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.85em;\n color: #e0e0e0;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n pointer-events: none;\n}\n\n/* Markdown wrapper — invisible to layout */\n\n.md {\n display: contents;\n}\n\n/* Markdown block elements inside passages */\n\n.passage p {\n margin: 0.6em 0;\n}\n\n.passage p:first-child {\n margin-top: 0;\n}\n\n.passage p:last-child {\n margin-bottom: 0;\n}\n\n.passage h1,\n.passage h2,\n.passage h3,\n.passage h4,\n.passage h5,\n.passage h6 {\n margin: 1em 0 0.4em;\n line-height: 1.3;\n color: #f0f0f0;\n}\n\n.passage h1 {\n font-size: 1.8em;\n}\n.passage h2 {\n font-size: 1.5em;\n}\n.passage h3 {\n font-size: 1.25em;\n}\n.passage h4 {\n font-size: 1.1em;\n}\n.passage h5 {\n font-size: 1em;\n}\n.passage h6 {\n font-size: 0.9em;\n opacity: 0.8;\n}\n\n.passage code {\n font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;\n font-size: 0.9em;\n background: rgba(255, 255, 255, 0.08);\n padding: 0.15em 0.35em;\n border-radius: 3px;\n}\n\n.passage pre {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 1em;\n overflow-x: auto;\n margin: 0.8em 0;\n}\n\n.passage pre code {\n background: none;\n padding: 0;\n border-radius: 0;\n font-size: 0.85em;\n}\n\n.passage blockquote {\n border-left: 3px solid rgba(100, 181, 246, 0.5);\n margin: 0.8em 0;\n padding: 0.4em 1em;\n color: #b0b0b0;\n}\n\n.passage blockquote p {\n margin: 0.3em 0;\n}\n\n.passage ul,\n.passage ol {\n margin: 0.6em 0;\n padding-left: 1.8em;\n}\n\n.passage li {\n margin: 0.2em 0;\n}\n\n.passage hr {\n border: none;\n border-top: 1px solid rgba(255, 255, 255, 0.12);\n margin: 1.2em 0;\n}\n\n.passage table {\n border-collapse: collapse;\n width: 100%;\n margin: 0.8em 0;\n}\n\n.passage th,\n.passage td {\n border: 1px solid rgba(255, 255, 255, 0.12);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.passage th {\n background: rgba(255, 255, 255, 0.06);\n font-weight: 600;\n}\n\n.passage tr:nth-child(even) {\n background: rgba(255, 255, 255, 0.02);\n}\n\n.passage del {\n opacity: 0.5;\n}\n\n.passage strong {\n color: #f0f0f0;\n}\n\n/* Macro: type (typewriter) */\n\n.macro-type-cursor::after {\n content: '\\2588';\n animation: type-blink 0.7s step-end infinite;\n color: #64b5f6;\n}\n\n.macro-type-done .macro-type-cursor::after {\n display: none;\n}\n\n@keyframes type-blink {\n 50% {\n opacity: 0;\n }\n}\n";
10581
11856
  //#endregion
10582
11857
  //#region src/index.tsx
10583
11858
  function renderErrors(root, errors) {