@rohal12/spindle 0.52.7 → 0.52.9

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.
Files changed (47) hide show
  1. package/README.md +26 -0
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +986 -854
  4. package/dist/pkg/macro-registry.json +18 -0
  5. package/package.json +1 -1
  6. package/src/action-registry.ts +9 -0
  7. package/src/class-registry.ts +1 -5
  8. package/src/components/Passage.tsx +35 -39
  9. package/src/components/PassageDialog.tsx +2 -2
  10. package/src/components/StoryInterface.tsx +2 -2
  11. package/src/components/macros/Checkbox.tsx +3 -8
  12. package/src/components/macros/Computed.tsx +1 -1
  13. package/src/components/macros/Cycle.tsx +3 -6
  14. package/src/components/macros/ExprDisplay.tsx +12 -29
  15. package/src/components/macros/If.tsx +35 -41
  16. package/src/components/macros/Include.tsx +2 -1
  17. package/src/components/macros/Listbox.tsx +5 -7
  18. package/src/components/macros/Print.tsx +3 -5
  19. package/src/components/macros/Radiobutton.tsx +3 -5
  20. package/src/components/macros/Repeat.tsx +2 -1
  21. package/src/components/macros/Switch.tsx +6 -13
  22. package/src/components/macros/Timed.tsx +14 -18
  23. package/src/components/macros/Type.tsx +2 -1
  24. package/src/components/macros/VarDisplay.tsx +12 -32
  25. package/src/components/macros/Widget.tsx +49 -20
  26. package/src/components/macros/branches.ts +18 -0
  27. package/src/components/macros/display.ts +19 -0
  28. package/src/components/macros/input-macro.ts +33 -11
  29. package/src/components/macros/macro-args.ts +7 -0
  30. package/src/components/macros/option-utils.ts +10 -7
  31. package/src/define-macro.ts +4 -6
  32. package/src/expression.ts +58 -40
  33. package/src/hooks/use-action.ts +7 -15
  34. package/src/index.tsx +5 -15
  35. package/src/interpolation.ts +34 -41
  36. package/src/markup/ast.ts +4 -3
  37. package/src/markup/render.tsx +121 -65
  38. package/src/parser.ts +119 -13
  39. package/src/registry.ts +13 -12
  40. package/src/saves/save-manager.ts +18 -13
  41. package/src/saves/storage.ts +19 -20
  42. package/src/story-api.ts +30 -73
  43. package/src/structural.ts +1 -18
  44. package/src/types-drift-check.ts +37 -50
  45. package/src/utils/macro-names.ts +40 -0
  46. package/src/utils/namespace.ts +17 -0
  47. package/src/widgets/widget-registry.ts +5 -4
@@ -52,6 +52,19 @@ var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
52
52
  /** `ns[key]` if `ns` holds it as an own property, else undefined. */
53
53
  var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
54
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
+ /**
55
68
  * A record with no prototype holding the own entries of `sources`, or only
56
69
  * those whose value passes `keep`.
57
70
  */
@@ -102,441 +115,96 @@ function createCounts(source, name) {
102
115
  return counts;
103
116
  }
104
117
  //#endregion
105
- //#region src/utils/object-path.ts
106
- /**
107
- * Traverse dot-path segments on an object and return the nested value.
108
- * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
109
- * are not story state: unless an object holds one as its own property, it
110
- * reads as missing, as setByPath() treats it. Other inherited properties
111
- * (class getters, `size` of a Map) are read.
112
- */
113
- function getByPath(obj, segments) {
114
- let current = obj;
115
- for (const seg of segments) {
116
- if (current == null || typeof current !== "object") return void 0;
117
- if (seg in Object.prototype && !hasOwn(current, seg)) return void 0;
118
- current = current[seg];
119
- }
120
- return current;
121
- }
122
- /** Array indices ("0", "1", …) and "length": the keys an array holds. */
123
- var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
124
- /**
125
- * Built-ins whose content is not their properties: a property written into
126
- * one would be dropped by clones and saves (and by Immer, for Map and Set).
127
- */
128
- 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;
129
- /**
130
- * Shallow copy that keeps the prototype, so a registered class instance
131
- * stays an instance of its class (with the same own keys deepClone copies).
132
- */
133
- function shallowCopy(value) {
134
- const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
135
- for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
136
- value: value[key],
137
- enumerable: true,
138
- writable: true,
139
- configurable: true
140
- });
141
- return copy;
142
- }
143
- /**
144
- * Set a value at dot-path `segments` below `root`.
145
- *
146
- * When `root` is an Immer draft, objects Immer does not draft (registered
147
- * class instances and everything below them) are copied on the way down and
148
- * the copy is assigned to its parent, so the write produces new references
149
- * up to the root. Writing into such an object directly would mutate the
150
- * instance shared with the previous state and with history, and subscribers
151
- * would see no change. Outside a draft (a private working copy) the path is
152
- * written in place.
153
- *
154
- * The path goes through own properties of plain objects, class instances
155
- * and arrays (by index); anything else throws a TypeError rather than
156
- * writing where no clone or save would see it: an inherited property such
157
- * as a method counts as missing, and Map, Set, Date and RegExp values and
158
- * other keys of arrays are refused, as is a "__proto__" segment.
159
- */
160
- function setByPath(root, segments, value, options = {}) {
161
- const parent = walkToParent(root, segments, options.createMissing ?? false);
162
- parent[segments[segments.length - 1]] = value;
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);
163
124
  }
164
125
  /**
165
- * Delete the property at dot-path `segments` below `root`, copying
166
- * undrafted objects on the way down like setByPath(). Does nothing when an
167
- * intermediate is missing or not an object, or the property is absent (or
168
- * only inherited).
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.
169
129
  */
170
- function deleteByPath(root, segments) {
171
- const last = segments[segments.length - 1];
172
- checkSegments(segments);
173
- let holder = root;
174
- for (const seg of segments.slice(0, -1)) {
175
- if (holder == null || typeof holder !== "object" || !hasOwn(holder, seg)) return;
176
- holder = holder[seg];
177
- }
178
- if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
179
- const parent = walkToParent(root, segments, false);
180
- delete parent[last];
181
- }
182
- /** Refuse a segment that would reach a prototype instead of story state. */
183
- function checkSegments(segments) {
184
- if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
130
+ function registeredClassName(value) {
131
+ const proto = Object.getPrototypeOf(value);
132
+ return proto === null ? void 0 : ctorToName.get(proto.constructor);
185
133
  }
186
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
187
- function checkHolder(holder, key, segments, depth) {
188
- const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
189
- if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
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";
190
153
  }
191
154
  /**
192
- * Walk to the object holding the last of `segments`, copying undrafted
193
- * objects when `root` is an Immer draft (see setByPath). A missing or
194
- * non-object intermediate is replaced by a plain object when
195
- * `createMissing` is set, and throws a TypeError otherwise.
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.
196
162
  */
197
- function walkToParent(root, segments, createMissing) {
198
- checkSegments(segments);
199
- const copyOnWrite = isDraft(root);
200
- let current = root;
201
- for (let i = 0; i < segments.length - 1; i++) {
202
- const seg = segments[i];
203
- checkHolder(current, seg, segments, i);
204
- let next = hasOwn(current, seg) ? current[seg] : void 0;
205
- if (next == null || typeof next !== "object") {
206
- if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
207
- next = {};
208
- current[seg] = next;
209
- } else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
210
- next = shallowCopy(next);
211
- current[seg] = next;
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 });
212
170
  }
213
- current = next;
214
- }
215
- checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
216
- return current;
217
- }
218
- //#endregion
219
- //#region src/structural.ts
220
- /**
221
- * Set an own enumerable property. A "__proto__" key is defined as an own
222
- * property (as JSON.parse does) instead of replacing the prototype.
223
- */
224
- function setOwn(target, key, value) {
225
- if (key === "__proto__") Object.defineProperty(target, key, {
226
- value,
227
- enumerable: true,
228
- writable: true,
229
- configurable: true
230
- });
231
- else target[key] = value;
232
- }
233
- function isPlainObject(value) {
234
- if (typeof value !== "object" || value === null) return false;
235
- const proto = Object.getPrototypeOf(value);
236
- return proto === Object.prototype || proto === null;
237
- }
238
- function deepClone(value, options = {}) {
239
- const seen = /* @__PURE__ */ new Map();
240
- function clone(val) {
171
+ if (typeof val === "bigint") return tagged("__BigInt__", { value: val.toString() });
241
172
  if (val === null || typeof val !== "object") return val;
242
- const obj = val;
243
- if (seen.has(obj)) return seen.get(obj);
244
- if (val instanceof Date) return new Date(val.getTime());
245
- if (val instanceof RegExp) return new RegExp(val.source, val.flags);
246
- if (Array.isArray(val)) {
247
- const arr = [];
248
- seen.set(obj, arr);
249
- for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
250
- return arr;
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);
251
179
  }
252
- if (val instanceof Map) {
253
- const copy = /* @__PURE__ */ new Map();
254
- seen.set(obj, copy);
255
- for (const [k, v] of val) copy.set(clone(k), clone(v));
256
- return copy;
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]);
257
186
  }
258
- if (val instanceof Set) {
259
- const copy = /* @__PURE__ */ new Set();
260
- seen.set(obj, copy);
261
- for (const v of val) copy.add(clone(v));
262
- return copy;
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;
263
199
  }
264
- const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
265
- if (!keepsProto && options.keepUnregistered) return val;
266
- const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
267
- seen.set(obj, copy);
268
- for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
269
- return copy;
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;
270
206
  }
271
- return clone(value);
272
- }
273
- /**
274
- * Structural equality over the value types deepClone() supports: primitives,
275
- * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
276
- * at any depth). Map and Set entries are compared in insertion order. Arrays
277
- * are compared by length and index, so a hole equals an undefined element
278
- * (deepClone() and save/load turn holes into undefined elements).
279
- */
280
- function deepEqual(a, b) {
281
- return equal(a, b, /* @__PURE__ */ new Map());
282
- }
283
- /**
284
- * `assumed` holds the pairs already being compared: meeting one again (a
285
- * cycle) assumes it equal. Every object may pair with several others, since
286
- * cycles of different lengths can still unfold to the same value.
287
- */
288
- function equal(a, b, assumed) {
289
- if (Object.is(a, b)) return true;
290
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
291
- if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
292
- const pairs = assumed.get(a);
293
- if (pairs?.has(b)) return true;
294
- if (pairs) pairs.add(b);
295
- else assumed.set(a, new Set([b]));
296
- if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
297
- if (a instanceof RegExp) return String(a) === String(b);
298
- if (a instanceof Map || a instanceof Set) {
299
- const bc = b;
300
- if (a.size !== bc.size) return false;
301
- const ai = a.entries();
302
- const bi = bc.entries();
303
- 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;
304
- return true;
305
- }
306
- if (Array.isArray(a)) {
307
- const bc = b;
308
- if (a.length !== bc.length) return false;
309
- for (let i = 0; i < a.length; i++) if (!equal(a[i], bc[i], assumed)) return false;
310
- return true;
311
- }
312
- const ao = a;
313
- const bo = b;
314
- const keys = Object.keys(ao);
315
- if (keys.length !== Object.keys(bo).length) return false;
316
- for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
317
- return true;
318
- }
319
- /**
320
- * Objects merged property by property: plain objects and (registered)
321
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
322
- * replaced as a whole, since their elements have no stable identity to
323
- * merge by (a shift moves every index).
324
- */
325
- function isMergeable(value) {
326
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
327
- }
328
- /**
329
- * Whether changes between `a` and `b` merge key by key: both are objects of
330
- * one class (see isMergeable), or, with `arrays`, both are arrays.
331
- */
332
- function mergesWith(a, b, arrays) {
333
- if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
334
- return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
335
- }
336
- /**
337
- * The keys at which `after` differs from `before`, two values that merge
338
- * (see mergesWith): keys added, deleted, or holding another value. Store
339
- * updates are immutable, so a value that kept its identity is no change,
340
- * and one rebuilt with equal content (mutation code commits whole values)
341
- * is none either. Values that merge are reported as 'nested', to be
342
- * compared key by key; they may still be equal. With `arrays`, arrays
343
- * merge too: elements at the indices both hold are compared, and the
344
- * elements one has beyond the other's length are added or deleted.
345
- */
346
- function keyChanges(before, after, arrays) {
347
- const changes = [];
348
- const compare = (key, b, a) => {
349
- if (Object.is(b, a)) return;
350
- if (mergesWith(b, a, arrays)) changes.push({
351
- key,
352
- kind: "nested",
353
- before: b,
354
- after: a
355
- });
356
- else if (!deepEqual(b, a)) changes.push({
357
- key,
358
- kind: "changed",
359
- after: a
360
- });
361
- };
362
- if (Array.isArray(after)) {
363
- const b = before;
364
- const a = after;
365
- for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
366
- for (let i = b.length; i < a.length; i++) changes.push({
367
- key: String(i),
368
- kind: "added",
369
- after: a[i]
370
- });
371
- for (let i = a.length; i < b.length; i++) changes.push({
372
- key: String(i),
373
- kind: "deleted"
374
- });
375
- return changes;
376
- }
377
- for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
378
- else changes.push({
379
- key,
380
- kind: "added",
381
- after: after[key]
382
- });
383
- for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
384
- key,
385
- kind: "deleted"
386
- });
387
- return changes;
388
- }
389
- /**
390
- * The property paths where `after` differs from `before`, two objects of
391
- * one class: objects that merge (see isMergeable) are compared property by
392
- * property, any other value as a whole.
393
- */
394
- function diffPaths(before, after) {
395
- const changes = [];
396
- const ancestors = /* @__PURE__ */ new Set();
397
- (function walk(b, a, path) {
398
- if (ancestors.has(a)) return;
399
- ancestors.add(a);
400
- for (const change of keyChanges(b, a, false)) {
401
- const at = [...path, change.key];
402
- if (change.kind === "nested") walk(change.before, change.after, at);
403
- else changes.push(change.kind === "deleted" ? {
404
- path: at,
405
- deleted: true
406
- } : {
407
- path: at,
408
- deleted: false,
409
- value: change.after
410
- });
411
- }
412
- ancestors.delete(a);
413
- })(before, after, []);
414
- return changes;
415
- }
416
- /** Whether `target` already holds what `change` would write. */
417
- function isApplied(target, change) {
418
- const parent = getByPath(target, change.path.slice(0, -1));
419
- if (parent === null || typeof parent !== "object") return change.deleted;
420
- const key = change.path[change.path.length - 1];
421
- if (!hasOwn(parent, key)) return change.deleted;
422
- return !change.deleted && deepEqual(parent[key], change.value);
423
- }
424
- function applyChange(target, change) {
425
- if (change.deleted) deleteByPath(target, change.path);
426
- else setByPath(target, change.path, change.value);
427
- }
428
- /**
429
- * Write how `after` differs from `before` (see keyChanges; arrays merge by
430
- * index) into `target`, another version of the same object or array.
431
- * Values that changed are written whole, as deep copies; for values that
432
- * merge, `mergeNested` may merge them into `target` instead, returning
433
- * whether it did. Into an array, elements removed from the end are removed
434
- * at the same indices and elements added are appended, and changes at
435
- * indices `target` lacks are dropped: where `target` was resized, its
436
- * indices do not line up with `before`'s.
437
- */
438
- function mergeKeys(target, before, after, mergeNested) {
439
- const list = Array.isArray(target) ? target : void 0;
440
- for (const change of keyChanges(before, after, true)) {
441
- const { key } = change;
442
- if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
443
- else delete target[key];
444
- else if (list && change.kind === "added") list.push(deepClone(change.after));
445
- else if (list && Number(key) >= list.length) continue;
446
- else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
447
- }
448
- }
449
- //#endregion
450
- //#region src/class-registry.ts
451
- var registry$1 = /* @__PURE__ */ new Map();
452
- var ctorToName = /* @__PURE__ */ new Map();
453
- function registerClass(name, ctor) {
454
- registry$1.set(name, ctor);
455
- ctorToName.set(ctor, name);
456
- }
457
- /**
458
- * The registered name of the class `value` is an instance of, or undefined.
459
- * Looks the constructor up on the prototype, so an own property named
460
- * "constructor" (plain data) cannot hide or fake the class.
461
- */
462
- function registeredClassName(value) {
463
- const proto = Object.getPrototypeOf(value);
464
- return proto === null ? void 0 : ctorToName.get(proto.constructor);
465
- }
466
- var CLASS_TAG = "__spindle_class__";
467
- var DATA_TAG = "__spindle_data__";
468
- /** A tagged value, as serialize() writes and deserialize() reads it. */
469
- var tagged = (name, data) => ({
470
- [CLASS_TAG]: name,
471
- [DATA_TAG]: data
472
- });
473
- /** Numbers JSON text cannot hold (it writes null for them, and 0 for -0). */
474
- var SPECIAL_NUMBERS = {
475
- NaN: NaN,
476
- Infinity: Infinity,
477
- "-Infinity": -Infinity,
478
- "-0": -0
479
- };
480
- function specialNumberName(value) {
481
- if (Number.isNaN(value)) return "NaN";
482
- if (value === Infinity) return "Infinity";
483
- if (value === -Infinity) return "-Infinity";
484
- if (Object.is(value, -0)) return "-0";
485
- }
486
- /**
487
- * Turn a story value into data that survives JSON text (saves, exports,
488
- * the session) and that deserialize() restores. Date, RegExp, Map, Set and
489
- * registered class instances become tagged objects, and so do the values
490
- * JSON text would drop or change: undefined (also array holes), NaN,
491
- * ±Infinity, -0, bigint, invalid dates, and plain objects with a key named
492
- * like the class tag. Throws on circular references and on a property
493
- * named "__proto__", which story state cannot hold.
494
- */
495
- function serialize(value) {
496
- const seen = /* @__PURE__ */ new Set();
497
- function ser(val) {
498
- if (val === void 0) return tagged("__Undefined__", {});
499
- if (typeof val === "number") {
500
- const name = specialNumberName(val);
501
- return name === void 0 ? val : tagged("__Number__", { value: name });
502
- }
503
- if (typeof val === "bigint") return tagged("__BigInt__", { value: val.toString() });
504
- if (val === null || typeof val !== "object") return val;
505
- if (seen.has(val)) throw new Error("spindle: Cannot serialize circular references");
506
- seen.add(val);
507
- try {
508
- return serObject(val);
509
- } finally {
510
- seen.delete(val);
511
- }
512
- }
513
- function serKeys(obj) {
514
- const data = {};
515
- for (const key of Object.keys(obj)) {
516
- if (key === "__proto__") throw new Error("spindle: Cannot save a property named \"__proto__\"");
517
- data[key] = ser(obj[key]);
518
- }
519
- return data;
520
- }
521
- function serObject(val) {
522
- if (val instanceof Date) return tagged("__Date__", { iso: !Number.isNaN(val.getTime()) ? val.toISOString() : null });
523
- if (val instanceof RegExp) return tagged("__RegExp__", {
524
- source: val.source,
525
- flags: val.flags
526
- });
527
- if (Array.isArray(val)) {
528
- const result = [];
529
- for (let i = 0; i < val.length; i++) result.push(ser(val[i]));
530
- return result;
531
- }
532
- if (val instanceof Map) return tagged("__Map__", { entries: [...val].map(([k, v]) => [ser(k), ser(v)]) });
533
- if (val instanceof Set) return tagged("__Set__", { entries: [...val].map((v) => ser(v)) });
534
- const name = registeredClassName(val);
535
- if (name !== void 0) return tagged(name, serKeys(val));
536
- const data = serKeys(val);
537
- return hasOwn(val, CLASS_TAG) ? tagged("__Object__", data) : data;
538
- }
539
- return ser(value);
207
+ return ser(value);
540
208
  }
541
209
  function deserialize(value) {
542
210
  function deserKeys(target, data) {
@@ -2624,9 +2292,46 @@ function tokenize(input, options = {}) {
2624
2292
  return tokens;
2625
2293
  }
2626
2294
  //#endregion
2295
+ //#region src/utils/macro-names.ts
2296
+ /**
2297
+ * Macro and widget names are case-insensitive: `{StatusBar}` invokes the
2298
+ * widget defined as `statusbar`. A NameMap keys its entries by the lowercased
2299
+ * name.
2300
+ */
2301
+ var NameMap = class extends Map {
2302
+ get(name) {
2303
+ return super.get(name.toLowerCase());
2304
+ }
2305
+ set(name, value) {
2306
+ return super.set(name.toLowerCase(), value);
2307
+ }
2308
+ has(name) {
2309
+ return super.has(name.toLowerCase());
2310
+ }
2311
+ delete(name) {
2312
+ return super.delete(name.toLowerCase());
2313
+ }
2314
+ };
2315
+ /** A set of macro or widget names (see NameMap). */
2316
+ var NameSet = class {
2317
+ constructor(names = []) {
2318
+ this.names = new NameMap();
2319
+ for (const name of names) this.add(name);
2320
+ }
2321
+ add(name) {
2322
+ this.names.set(name, true);
2323
+ }
2324
+ has(name) {
2325
+ return this.names.has(name);
2326
+ }
2327
+ delete(name) {
2328
+ this.names.delete(name);
2329
+ }
2330
+ };
2331
+ //#endregion
2627
2332
  //#region src/markup/ast.ts
2628
2333
  /** Macros that require a closing tag and can contain children */
2629
- var BLOCK_MACROS = new Set([
2334
+ var BLOCK_MACROS = new NameSet([
2630
2335
  "if",
2631
2336
  "for",
2632
2337
  "do",
@@ -2644,7 +2349,7 @@ var BLOCK_MACROS = new Set([
2644
2349
  ]);
2645
2350
  /** Register a custom macro as a block macro so the AST builder nests children. */
2646
2351
  function registerBlockMacro(name) {
2647
- BLOCK_MACROS.add(name.toLowerCase());
2352
+ BLOCK_MACROS.add(name);
2648
2353
  }
2649
2354
  /** Map from branch macro name → required parent macro name */
2650
2355
  var BRANCH_PARENT = {
@@ -3038,15 +2743,15 @@ function countQueries(counts) {
3038
2743
  all: (...names) => names.every((n) => count(n) > 0)
3039
2744
  };
3040
2745
  }
3041
- var cachedFns = null;
3042
- var cachedVisitCounts = null;
3043
- var cachedRenderCounts = null;
3044
- function buildExpressionFns() {
3045
- const { visitCounts, renderCounts } = useStoryStore.getState();
3046
- if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
3047
- const visits = countQueries(() => visitCounts);
3048
- const renders = countQueries(() => renderCounts);
3049
- cachedFns = {
2746
+ /**
2747
+ * The history functions of passage code and the story API: the current and
2748
+ * previous passage, and visited()/rendered() with their has-forms, counting
2749
+ * in the counters `visitCounts()` and `renderCounts()` return.
2750
+ */
2751
+ function historyQueries(visitCounts, renderCounts) {
2752
+ const visits = countQueries(visitCounts);
2753
+ const renders = countQueries(renderCounts);
2754
+ return {
3050
2755
  currentPassage,
3051
2756
  previousPassage,
3052
2757
  visited: visits.count,
@@ -3056,7 +2761,17 @@ function buildExpressionFns() {
3056
2761
  rendered: renders.count,
3057
2762
  hasRendered: renders.has,
3058
2763
  hasRenderedAny: renders.any,
3059
- hasRenderedAll: renders.all,
2764
+ hasRenderedAll: renders.all
2765
+ };
2766
+ }
2767
+ var cachedFns = null;
2768
+ var cachedVisitCounts = null;
2769
+ var cachedRenderCounts = null;
2770
+ function buildExpressionFns() {
2771
+ const { visitCounts, renderCounts } = useStoryStore.getState();
2772
+ if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
2773
+ cachedFns = {
2774
+ ...historyQueries(() => visitCounts, () => renderCounts),
3060
2775
  random,
3061
2776
  randomInt
3062
2777
  };
@@ -3077,57 +2792,61 @@ function namespaceArg(ns) {
3077
2792
  Object.setPrototypeOf(ns, null);
3078
2793
  return ns;
3079
2794
  }
2795
+ /** Run compiled code in `namespaces`. */
2796
+ function run(fn, ...[variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE]) {
2797
+ return fn(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2798
+ }
3080
2799
  /**
3081
2800
  * Evaluate an expression and return its value.
3082
2801
  * e.g. evaluate("$health + 10", variables, temporary) → number
3083
2802
  */
3084
- function evaluate(expr, variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE) {
2803
+ function evaluate(expr, ...namespaces) {
3085
2804
  const body = `return (${transform(expr)}\n);`;
3086
- return getOrCompile(body, body)(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2805
+ return run(getOrCompile(body, body), ...namespaces);
3087
2806
  }
3088
2807
  /**
3089
2808
  * Execute statements (no return value).
3090
2809
  * e.g. execute("$health = 100; $name = 'Hero'", variables, temporary)
3091
2810
  */
3092
- function execute(code, variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE) {
2811
+ function execute(code, ...namespaces) {
3093
2812
  const transformed = transform(code, "statements");
3094
- getOrCompile("exec:" + transformed, transformed)(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2813
+ run(getOrCompile("exec:" + transformed, transformed), ...namespaces);
3095
2814
  }
3096
2815
  //#endregion
3097
2816
  //#region src/registry.ts
3098
- var registry = /* @__PURE__ */ new Map();
2817
+ var registry = new NameMap();
3099
2818
  function registerMacro(name, component) {
3100
- registry.set(name.toLowerCase(), component);
2819
+ registry.set(name, component);
3101
2820
  }
3102
2821
  function getMacro(name) {
3103
- return registry.get(name.toLowerCase());
2822
+ return registry.get(name);
3104
2823
  }
3105
- var textRegistry = /* @__PURE__ */ new Map();
2824
+ var textRegistry = new NameMap();
3106
2825
  /** Register (or, with undefined, remove) the text form of a macro. */
3107
2826
  function registerMacroText(name, text) {
3108
- if (text) textRegistry.set(name.toLowerCase(), text);
3109
- else textRegistry.delete(name.toLowerCase());
2827
+ if (text) textRegistry.set(name, text);
2828
+ else textRegistry.delete(name);
3110
2829
  }
3111
2830
  function getMacroText(name) {
3112
- return textRegistry.get(name.toLowerCase());
2831
+ return textRegistry.get(name);
3113
2832
  }
3114
- var subMacros = /* @__PURE__ */ new Set();
2833
+ var subMacros = new NameSet();
3115
2834
  function registerSubMacro(name) {
3116
- subMacros.add(name.toLowerCase());
2835
+ subMacros.add(name);
3117
2836
  }
3118
2837
  function isSubMacro(name) {
3119
- return subMacros.has(name.toLowerCase());
2838
+ return subMacros.has(name);
3120
2839
  }
3121
- var metadataRegistry = /* @__PURE__ */ new Map();
2840
+ var metadataRegistry = new NameMap();
3122
2841
  function registerMacroMetadata(name, metadata) {
3123
- metadataRegistry.set(name.toLowerCase(), metadata);
2842
+ metadataRegistry.set(name, metadata);
3124
2843
  }
3125
2844
  function getMacroRegistry() {
3126
2845
  return Array.from(metadataRegistry.values());
3127
2846
  }
3128
2847
  //#endregion
3129
2848
  //#region src/widgets/widget-registry.ts
3130
- var widgets = /* @__PURE__ */ new Map();
2849
+ var widgets = new NameMap();
3131
2850
  /**
3132
2851
  * Register a widget. Its `@` parameters become locals of its body, so one
3133
2852
  * that no namespace can hold (`@__proto__`) throws a TypeError and the
@@ -3136,14 +2855,14 @@ var widgets = /* @__PURE__ */ new Map();
3136
2855
  function registerWidget(name, bodyAST, params, isBlock = false) {
3137
2856
  const filteredParams = params.filter((p) => p !== "@children");
3138
2857
  for (const param of filteredParams) if (param.startsWith("@")) checkVariableName(param.slice(1), param);
3139
- widgets.set(name.toLowerCase(), {
2858
+ widgets.set(name, {
3140
2859
  body: bodyAST,
3141
2860
  params: filteredParams,
3142
2861
  isBlock
3143
2862
  });
3144
2863
  }
3145
2864
  function getWidget(name) {
3146
- return widgets.get(name.toLowerCase());
2865
+ return widgets.get(name);
3147
2866
  }
3148
2867
  //#endregion
3149
2868
  //#region src/components/macros/arg-utils.ts
@@ -3310,6 +3029,23 @@ function splitArgs(raw) {
3310
3029
  return args;
3311
3030
  }
3312
3031
  //#endregion
3032
+ //#region src/components/macros/display.ts
3033
+ /** The text a value is shown as: nothing for null and undefined. */
3034
+ function display(value) {
3035
+ return value == null ? "" : String(value);
3036
+ }
3037
+ /**
3038
+ * `content` in a `<span>` carrying `className` and `id`, or in a fragment if
3039
+ * it has neither.
3040
+ */
3041
+ function wrapContent(className, id, content) {
3042
+ if (className || id) return h("span", {
3043
+ id,
3044
+ class: className
3045
+ }, content);
3046
+ return h(Fragment, null, content);
3047
+ }
3048
+ //#endregion
3313
3049
  //#region src/interpolation.ts
3314
3050
  /**
3315
3051
  * Text-only markup: HTML attribute values, image alt text and link titles,
@@ -3378,9 +3114,6 @@ function mapTextNodes(nodes, fn) {
3378
3114
  }
3379
3115
  /** Deep enough for any real nesting; stops a widget that includes itself. */
3380
3116
  var MAX_DEPTH = 100;
3381
- function display(value) {
3382
- return value == null ? "" : String(value);
3383
- }
3384
3117
  /**
3385
3118
  * The value of a variable reference (`name` without its sigil, with an
3386
3119
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -3398,6 +3131,10 @@ function resolveVariable(scope, kind, name) {
3398
3131
  }
3399
3132
  return value;
3400
3133
  }
3134
+ /** Evaluate an expression in `scope`. */
3135
+ function evaluateIn(expr, scope) {
3136
+ return evaluate(expr, scope.variables, scope.temporary, scope.locals, scope.transient);
3137
+ }
3401
3138
  var TextEvaluator = class {
3402
3139
  constructor() {
3403
3140
  this.errors = [];
@@ -3407,9 +3144,6 @@ var TextEvaluator = class {
3407
3144
  for (const node of nodes) text += this.node(node, scope, depth);
3408
3145
  return text;
3409
3146
  }
3410
- evaluate(expr, scope) {
3411
- return evaluate(expr, scope.variables, scope.temporary, scope.locals, scope.transient);
3412
- }
3413
3147
  node(node, scope, depth) {
3414
3148
  switch (node.type) {
3415
3149
  case "text": return node.value;
@@ -3421,7 +3155,7 @@ var TextEvaluator = class {
3421
3155
  }
3422
3156
  return this.variable(node.scope, node.name, scope);
3423
3157
  case "expression": try {
3424
- return display(this.evaluate(node.expression, scope));
3158
+ return display(evaluateIn(node.expression, scope));
3425
3159
  } catch (error) {
3426
3160
  this.errors.push({
3427
3161
  macro: "expression",
@@ -3471,7 +3205,7 @@ var TextEvaluator = class {
3471
3205
  let value;
3472
3206
  const expr = argExprs[i];
3473
3207
  if (expr !== void 0) try {
3474
- value = this.evaluate(expr, scope);
3208
+ value = evaluateIn(expr, scope);
3475
3209
  } catch {
3476
3210
  value = void 0;
3477
3211
  }
@@ -3493,7 +3227,7 @@ var TextEvaluator = class {
3493
3227
  return "";
3494
3228
  }
3495
3229
  const ctx = {
3496
- evaluate: (expr) => this.evaluate(expr, scope),
3230
+ evaluate: (expr) => evaluateIn(expr, scope),
3497
3231
  renderText: (nodes, locals) => this.nested(node.name, nodes, scope, depth, locals ? { locals: withLocals(scope.locals, locals) } : {})
3498
3232
  };
3499
3233
  try {
@@ -3570,7 +3304,7 @@ function interpolateCode(template, scope, decode) {
3570
3304
  });
3571
3305
  }
3572
3306
  } else try {
3573
- text += display(evaluate(part.expr, scope.variables, scope.temporary, scope.locals, scope.transient));
3307
+ text += display(evaluateIn(part.expr, scope));
3574
3308
  } catch (error) {
3575
3309
  errors.push({
3576
3310
  macro: "expression",
@@ -3636,29 +3370,26 @@ function useInterpolate() {
3636
3370
  }
3637
3371
  //#endregion
3638
3372
  //#region src/components/macros/VarDisplay.tsx
3639
- var SIGILS = {
3640
- variable: "$",
3641
- temporary: "_",
3642
- local: "@",
3643
- transient: "%"
3644
- };
3645
3373
  var STORE_NAMESPACES = {
3646
3374
  variable: "variables",
3647
3375
  temporary: "temporary",
3648
3376
  transient: "transient"
3649
3377
  };
3650
- function VarDisplay({ name, scope, className, id }) {
3378
+ /** A `{$name}` (`{_name}`, `{@name}`, `{%name}`) variable display. */
3379
+ function VarDisplay({ node }) {
3380
+ const { name, scope } = node;
3651
3381
  const resolve = useInterpolate();
3652
- className = resolve(className);
3653
- id = resolve(id);
3382
+ const className = resolve(node.className);
3383
+ const id = resolve(node.id);
3654
3384
  const localsValues = useContext(LocalsValuesContext);
3655
3385
  const parts = name.split(".");
3656
3386
  const root = parts[0];
3657
3387
  const storeValue = useStoryStore((s) => scope === "local" ? void 0 : ownValue(s[STORE_NAMESPACES[scope]], root));
3658
- const nameError = variableNameError(root, SIGILS[scope] + root);
3388
+ const sigil = SCOPE_SIGILS[scope];
3389
+ const nameError = variableNameError(root, sigil + root);
3659
3390
  if (nameError) return /* @__PURE__ */ jsx("span", {
3660
3391
  class: "error",
3661
- children: `{${SIGILS[scope]}${name} error: ${nameError}}`
3392
+ children: `{${sigil}${name} error: ${nameError}}`
3662
3393
  });
3663
3394
  let value;
3664
3395
  if (scope === "local") value = ownValue(localsValues, root);
@@ -3671,34 +3402,24 @@ function VarDisplay({ name, scope, className, id }) {
3671
3402
  const part = parts[i];
3672
3403
  value = value[part];
3673
3404
  }
3674
- const display = value == null ? "" : String(value);
3675
- if (className || id) return /* @__PURE__ */ jsx("span", {
3676
- id,
3677
- class: className,
3678
- children: display
3679
- });
3680
- return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3405
+ return wrapContent(className, id, display(value));
3681
3406
  }
3682
3407
  //#endregion
3683
3408
  //#region src/components/macros/ExprDisplay.tsx
3684
- function ExprDisplay({ expression, className, id }) {
3409
+ /** An expression display: `{$hp + 1}`. */
3410
+ function ExprDisplay({ node }) {
3411
+ const { expression } = node;
3685
3412
  const resolve = useInterpolate();
3686
- className = resolve(className);
3687
- id = resolve(id);
3413
+ const className = resolve(node.className);
3414
+ const id = resolve(node.id);
3688
3415
  const [variables, temporary, localsValues, transient] = useMergedLocals();
3689
- let display;
3416
+ let text;
3690
3417
  try {
3691
- const value = evaluate(expression, variables, temporary, localsValues, transient);
3692
- display = value == null ? "" : String(value);
3418
+ text = display(evaluate(expression, variables, temporary, localsValues, transient));
3693
3419
  } catch {
3694
- display = `{error: ${expression}}`;
3420
+ text = `{error: ${expression}}`;
3695
3421
  }
3696
- if (className || id) return /* @__PURE__ */ jsx("span", {
3697
- id,
3698
- class: className,
3699
- children: display
3700
- });
3701
- return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3422
+ return wrapContent(className, id, text);
3702
3423
  }
3703
3424
  //#endregion
3704
3425
  //#region src/hooks/use-render-options.ts
@@ -4046,6 +3767,7 @@ var RawTextContext = createContext(false);
4046
3767
  * no live form properties, so they are set as written (see splitAttributes).
4047
3768
  */
4048
3769
  var SvgContext = createContext(false);
3770
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
4049
3771
  /** Elements whose content is literal text, not markdown. */
4050
3772
  var PREFORMATTED_ELEMENTS = new Set(["pre", "textarea"]);
4051
3773
  var WidgetChildrenContext = createContext(null);
@@ -4090,20 +3812,42 @@ function expandPlaceholderText(text, ph) {
4090
3812
  return splitPlaceholderText(text, ph).map((part) => typeof part === "number" ? ph.components[part] : part);
4091
3813
  }
4092
3814
  /**
4093
- * An element whose attributes contain placeholders (a variable in image alt
4094
- * text or a link title). Each such attribute is evaluated as text against
4095
- * the store and locals, so it follows its variables as the component would.
3815
+ * An element of markdown output whose attributes contain placeholders (a
3816
+ * variable in image alt text or a link title). Each such attribute is
3817
+ * evaluated as text against the store and locals, so it follows its
3818
+ * variables as the component would.
4096
3819
  */
4097
- function PlaceholderAttributes({ tag, props, attributes, children }) {
3820
+ function PlaceholderAttributes({ tag, attributes, placeholders, svg, children }) {
4098
3821
  const scope = useTextScope();
4099
- const resolved = { ...props };
4100
3822
  const errors = [];
4101
- for (const [name, nodes] of Object.entries(attributes)) {
3823
+ const { props, direct } = splitAttributes(attributes.map(([name, value, written]) => {
3824
+ const nodes = placeholders[name];
3825
+ if (!nodes) return [
3826
+ name,
3827
+ value,
3828
+ written
3829
+ ];
4102
3830
  const result = renderText(nodes, scope);
4103
- resolved[name] = result.text;
4104
3831
  for (const error of result.errors) errors.push([name, error]);
4105
- }
4106
- return withAttributeErrors(errors, h(tag, resolved, ...children));
3832
+ return [
3833
+ name,
3834
+ result.text,
3835
+ written
3836
+ ];
3837
+ }), svg);
3838
+ const ref = useDirectAttributes(direct);
3839
+ return withAttributeErrors(errors, h(tag, ref ? {
3840
+ ...props,
3841
+ ref
3842
+ } : props, ...children));
3843
+ }
3844
+ /** An element of markdown output with attributes to set directly. */
3845
+ function DirectAttributes({ tag, props, direct, children }) {
3846
+ const ref = useDirectAttributes(direct);
3847
+ return h(tag, {
3848
+ ...props,
3849
+ ref
3850
+ }, ...children);
4107
3851
  }
4108
3852
  /**
4109
3853
  * Show the errors met in an element's attributes in front of it, the way a
@@ -4125,16 +3869,29 @@ function placeholderIndex(node, ph) {
4125
3869
  return parseInt(value.slice(prefix.length), 10);
4126
3870
  }
4127
3871
  /**
3872
+ * Parse HTML into nodes without side effects. The content of a `<template>`
3873
+ * belongs to a document with no browsing context, so parsing it fetches no
3874
+ * images and media, runs no inline event handlers (`<img onerror>`) and
3875
+ * constructs no custom elements. A detached `<div>` belongs to the page's
3876
+ * document, which does all of these while parsing.
3877
+ */
3878
+ function parseHtmlInert(html) {
3879
+ const template = document.createElement("template");
3880
+ template.innerHTML = html;
3881
+ return template.content;
3882
+ }
3883
+ /**
4128
3884
  * Convert an HTML string (from micromark) to Preact VNodes,
4129
3885
  * replacing placeholder elements with pre-rendered components.
4130
3886
  * With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
4131
3887
  * replaced by their children.
4132
3888
  */
4133
3889
  function htmlToPreact(html, ph, unwrapParagraphs = false) {
4134
- const temp = document.createElement("div");
4135
- temp.innerHTML = html.trim();
4136
- if (unwrapParagraphs) for (const p of Array.from(temp.querySelectorAll(":scope > p"))) p.replaceWith(...Array.from(p.childNodes));
4137
- return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(temp.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
3890
+ const content = parseHtmlInert(html.trim());
3891
+ if (unwrapParagraphs) {
3892
+ for (const child of Array.from(content.childNodes)) if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === "P") child.replaceWith(...Array.from(child.childNodes));
3893
+ }
3894
+ return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(content.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
4138
3895
  }
4139
3896
  function convertDomNode(node, key, ph) {
4140
3897
  if (node.nodeType === Node.TEXT_NODE) {
@@ -4148,25 +3905,36 @@ function convertDomNode(node, key, ph) {
4148
3905
  const tag = el.localName;
4149
3906
  const idx = placeholderIndex(el, ph);
4150
3907
  if (idx !== -1) return ph.components[idx];
4151
- const props = {};
4152
- let withPlaceholders;
4153
- for (const attr of Array.from(el.attributes)) {
4154
- const parts = splitPlaceholderText(attr.value, ph);
4155
- if (parts.every((part) => typeof part === "string")) {
4156
- props[attr.name] = attr.value;
4157
- continue;
4158
- }
4159
- withPlaceholders ??= {};
4160
- withPlaceholders[attr.name] = parts.map((part) => typeof part === "string" ? {
3908
+ const svg = el.namespaceURI === SVG_NAMESPACE;
3909
+ const attributes = [];
3910
+ let placeholders;
3911
+ for (const { name, value } of Array.from(el.attributes)) {
3912
+ attributes.push([
3913
+ name,
3914
+ value,
3915
+ value
3916
+ ]);
3917
+ const parts = splitPlaceholderText(value, ph);
3918
+ if (parts.every((part) => typeof part === "string")) continue;
3919
+ placeholders ??= {};
3920
+ placeholders[name] = parts.map((part) => typeof part === "string" ? {
4161
3921
  type: "text",
4162
3922
  value: part
4163
3923
  } : ph.nodes[part]);
4164
3924
  }
4165
3925
  const children = Array.from(el.childNodes).map((child, i) => convertDomNode(child, i, ph));
4166
- if (withPlaceholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3926
+ if (placeholders) return /* @__PURE__ */ jsx(PlaceholderAttributes, {
3927
+ tag,
3928
+ attributes,
3929
+ placeholders,
3930
+ svg,
3931
+ children
3932
+ }, key);
3933
+ const { props, direct } = splitAttributes(attributes, svg);
3934
+ if (direct.length > 0) return /* @__PURE__ */ jsx(DirectAttributes, {
4167
3935
  tag,
4168
3936
  props,
4169
- attributes: withPlaceholders,
3937
+ direct,
4170
3938
  children
4171
3939
  }, key);
4172
3940
  return h(tag, {
@@ -4281,9 +4049,7 @@ function setRawAttribute(el, name, value) {
4281
4049
  el.setAttribute(name, value);
4282
4050
  return;
4283
4051
  }
4284
- const template = document.createElement("template");
4285
- template.innerHTML = `<i ${name}=""></i>`;
4286
- const parsed = template.content.firstChild.attributes[0];
4052
+ const parsed = parseHtmlInert(`<i ${name}=""></i>`).firstChild.attributes[0];
4287
4053
  if (!parsed) return;
4288
4054
  const attr = parsed.cloneNode();
4289
4055
  attr.value = value;
@@ -4321,6 +4087,23 @@ function splitAttributes(attributes, svg) {
4321
4087
  direct
4322
4088
  };
4323
4089
  }
4090
+ /**
4091
+ * Set the attributes `splitAttributes` leaves to set directly on the element
4092
+ * given the returned ref (none if there are none), and remove them again
4093
+ * when they change.
4094
+ */
4095
+ function useDirectAttributes(direct) {
4096
+ const elementRef = useRef(null);
4097
+ useLayoutEffect(() => {
4098
+ const el = elementRef.current;
4099
+ if (!el || direct.length === 0) return;
4100
+ for (const [name, value] of direct) setRawAttribute(el, name, value);
4101
+ return () => {
4102
+ for (const [name] of direct) el.removeAttribute(name);
4103
+ };
4104
+ }, [JSON.stringify(direct)]);
4105
+ return direct.length > 0 ? elementRef : void 0;
4106
+ }
4324
4107
  var decodedAttributeText = /* @__PURE__ */ new Map();
4325
4108
  /**
4326
4109
  * Decode character references (`&amp;`, `&#123;`) in attribute text the way
@@ -4330,9 +4113,7 @@ function decodeAttributeText(text) {
4330
4113
  if (!text.includes("&")) return text;
4331
4114
  let decoded = decodedAttributeText.get(text);
4332
4115
  if (decoded === void 0) {
4333
- const template = document.createElement("template");
4334
- template.innerHTML = `<i title="${text.replace(/"/g, "&quot;")}"></i>`;
4335
- decoded = template.content.firstChild.getAttribute("title") ?? text;
4116
+ decoded = parseHtmlInert(`<i title="${text.replace(/"/g, "&quot;")}"></i>`).firstChild.getAttribute("title") ?? text;
4336
4117
  decodedAttributeText.set(text, decoded);
4337
4118
  }
4338
4119
  return decoded;
@@ -4397,16 +4178,8 @@ function HtmlNodeRenderer({ node }) {
4397
4178
  resolveAttributeValue(k, v, scope, errors),
4398
4179
  v
4399
4180
  ]), inSvg || isSvgRoot);
4400
- const elementRef = useRef(null);
4401
- useLayoutEffect(() => {
4402
- const el = elementRef.current;
4403
- if (!el || direct.length === 0) return;
4404
- for (const [name, value] of direct) setRawAttribute(el, name, value);
4405
- return () => {
4406
- for (const [name] of direct) el.removeAttribute(name);
4407
- };
4408
- }, [JSON.stringify(direct)]);
4409
- if (direct.length > 0) props.ref = elementRef;
4181
+ const ref = useDirectAttributes(direct);
4182
+ if (ref) props.ref = ref;
4410
4183
  const isInline = INLINE_ELEMENTS.has(tag);
4411
4184
  let children = void 0;
4412
4185
  if (node.children.length > 0) if (inRaw || isRawRoot) children = renderInlineNodes(node.children);
@@ -4433,17 +4206,9 @@ function HtmlNodeRenderer({ node }) {
4433
4206
  }
4434
4207
  function ChildrenSlot() {
4435
4208
  const childrenAST = useContext(WidgetChildrenContext);
4436
- const nobr = useContext(NobrContext);
4437
- const inline = useContext(InlineContext);
4438
- const raw = useContext(RawTextContext);
4439
- const locals = useContext(LocalsValuesContext);
4209
+ const renderOptions = useRenderOptions();
4440
4210
  if (!childrenAST || childrenAST.length === 0) return null;
4441
- return /* @__PURE__ */ jsx(Fragment$1, { children: renderNodes(childrenAST, {
4442
- nobr,
4443
- locals,
4444
- inline,
4445
- raw
4446
- }) });
4211
+ return /* @__PURE__ */ jsx(Fragment$1, { children: renderNodes(childrenAST, renderOptions) });
4447
4212
  }
4448
4213
  /**
4449
4214
  * Stable per-node keys. Keying rendered children by AST node identity (not
@@ -4493,17 +4258,8 @@ function renderSingleNode(node) {
4493
4258
  switch (node.type) {
4494
4259
  case "variable":
4495
4260
  if (node.scope === "local" && node.name === "children") return /* @__PURE__ */ jsx(ChildrenSlot, {}, key);
4496
- return /* @__PURE__ */ jsx(VarDisplay, {
4497
- name: node.name,
4498
- scope: node.scope,
4499
- className: node.className,
4500
- id: node.id
4501
- }, key);
4502
- case "expression": return /* @__PURE__ */ jsx(ExprDisplay, {
4503
- expression: node.expression,
4504
- className: node.className,
4505
- id: node.id
4506
- }, key);
4261
+ return /* @__PURE__ */ jsx(VarDisplay, { node }, key);
4262
+ case "expression": return /* @__PURE__ */ jsx(ExprDisplay, { node }, key);
4507
4263
  case "macro": return renderMacro(node, key);
4508
4264
  case "html": return /* @__PURE__ */ jsx(HtmlNodeRenderer, { node }, key);
4509
4265
  default: return node;
@@ -4784,6 +4540,24 @@ function createLocalStorageBackend() {
4784
4540
  }
4785
4541
  var IDB_DB_NAME = "spindle";
4786
4542
  var IDB_DB_VERSION = 1;
4543
+ /** Each object store's key path, and the key path of each of its indexes. */
4544
+ var IDB_STORES = {
4545
+ saves: {
4546
+ keyPath: "meta.id",
4547
+ indexes: {
4548
+ ifid: "meta.ifid",
4549
+ playthroughId: "meta.playthroughId"
4550
+ }
4551
+ },
4552
+ playthroughs: {
4553
+ keyPath: "id",
4554
+ indexes: { ifid: "ifid" }
4555
+ },
4556
+ meta: {
4557
+ keyPath: "key",
4558
+ indexes: {}
4559
+ }
4560
+ };
4787
4561
  function createIDBBackend() {
4788
4562
  let dbPromise = null;
4789
4563
  function openDB() {
@@ -4792,13 +4566,11 @@ function createIDBBackend() {
4792
4566
  const req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION);
4793
4567
  req.onupgradeneeded = () => {
4794
4568
  const db = req.result;
4795
- if (!db.objectStoreNames.contains("saves")) {
4796
- const savesStore = db.createObjectStore("saves", { keyPath: "meta.id" });
4797
- savesStore.createIndex("ifid", "meta.ifid", { unique: false });
4798
- savesStore.createIndex("playthroughId", "meta.playthroughId", { unique: false });
4569
+ for (const [name, { keyPath, indexes }] of Object.entries(IDB_STORES)) {
4570
+ if (db.objectStoreNames.contains(name)) continue;
4571
+ const store = db.createObjectStore(name, { keyPath });
4572
+ for (const [index, path] of Object.entries(indexes)) store.createIndex(index, path, { unique: false });
4799
4573
  }
4800
- if (!db.objectStoreNames.contains("playthroughs")) db.createObjectStore("playthroughs", { keyPath: "id" }).createIndex("ifid", "ifid", { unique: false });
4801
- if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "key" });
4802
4574
  };
4803
4575
  req.onsuccess = () => resolve(req.result);
4804
4576
  req.onerror = () => reject(req.error);
@@ -4849,57 +4621,389 @@ function createIDBBackend() {
4849
4621
  req.onsuccess = () => resolve();
4850
4622
  req.onerror = () => reject(req.error);
4851
4623
  });
4852
- });
4853
- }
4854
- var _backend = null;
4855
- async function detectBackend() {
4856
- if (typeof indexedDB !== "undefined") try {
4857
- await new Promise((resolve, reject) => {
4858
- const req = indexedDB.open("__spindle_probe__", 1);
4859
- req.onsuccess = () => {
4860
- req.result.close();
4861
- indexedDB.deleteDatabase("__spindle_probe__");
4862
- resolve();
4863
- };
4864
- req.onerror = () => reject(req.error);
4624
+ });
4625
+ }
4626
+ var _backend = null;
4627
+ async function detectBackend() {
4628
+ if (typeof indexedDB !== "undefined") try {
4629
+ await new Promise((resolve, reject) => {
4630
+ const req = indexedDB.open("__spindle_probe__", 1);
4631
+ req.onsuccess = () => {
4632
+ req.result.close();
4633
+ indexedDB.deleteDatabase("__spindle_probe__");
4634
+ resolve();
4635
+ };
4636
+ req.onerror = () => reject(req.error);
4637
+ });
4638
+ return createIDBBackend();
4639
+ } catch {}
4640
+ if (typeof localStorage !== "undefined") try {
4641
+ const probeKey = "__spindle_probe__";
4642
+ localStorage.setItem(probeKey, "1");
4643
+ const val = localStorage.getItem(probeKey);
4644
+ localStorage.removeItem(probeKey);
4645
+ if (val === "1") return createLocalStorageBackend();
4646
+ } catch {}
4647
+ return createMemoryBackend();
4648
+ }
4649
+ /**
4650
+ * Detection in flight. Concurrent first callers share it so they all get the
4651
+ * same backend (the memory fallback makes a new, empty store per detection).
4652
+ */
4653
+ var _backendPromise = null;
4654
+ function getBackend() {
4655
+ if (_backend) return Promise.resolve(_backend);
4656
+ if (!_backendPromise) {
4657
+ const pending = detectBackend().then((backend) => {
4658
+ if (_backendPromise === pending) _backend = backend;
4659
+ return backend;
4660
+ });
4661
+ _backendPromise = pending;
4662
+ }
4663
+ return _backendPromise;
4664
+ }
4665
+ function resetBackend() {
4666
+ _backend = null;
4667
+ _backendPromise = null;
4668
+ }
4669
+ /**
4670
+ * Synchronous accessor for the current backend type.
4671
+ * Returns 'memory' if the backend hasn't been detected yet.
4672
+ */
4673
+ function getBackendType() {
4674
+ return _backend?.type ?? "memory";
4675
+ }
4676
+ //#endregion
4677
+ //#region src/utils/object-path.ts
4678
+ /**
4679
+ * Traverse dot-path segments on an object and return the nested value.
4680
+ * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
4681
+ * are not story state: unless an object holds one as its own property, it
4682
+ * reads as missing, as setByPath() treats it. Other inherited properties
4683
+ * (class getters, `size` of a Map) are read.
4684
+ */
4685
+ function getByPath(obj, segments) {
4686
+ let current = obj;
4687
+ for (const seg of segments) {
4688
+ if (current == null || typeof current !== "object") return void 0;
4689
+ if (seg in Object.prototype && !hasOwn(current, seg)) return void 0;
4690
+ current = current[seg];
4691
+ }
4692
+ return current;
4693
+ }
4694
+ /** Array indices ("0", "1", …) and "length": the keys an array holds. */
4695
+ var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
4696
+ /**
4697
+ * Built-ins whose content is not their properties: a property written into
4698
+ * one would be dropped by clones and saves (and by Immer, for Map and Set).
4699
+ */
4700
+ 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;
4701
+ /**
4702
+ * Shallow copy that keeps the prototype, so a registered class instance
4703
+ * stays an instance of its class (with the same own keys deepClone copies).
4704
+ */
4705
+ function shallowCopy(value) {
4706
+ const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
4707
+ for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
4708
+ value: value[key],
4709
+ enumerable: true,
4710
+ writable: true,
4711
+ configurable: true
4712
+ });
4713
+ return copy;
4714
+ }
4715
+ /**
4716
+ * Set a value at dot-path `segments` below `root`.
4717
+ *
4718
+ * When `root` is an Immer draft, objects Immer does not draft (registered
4719
+ * class instances and everything below them) are copied on the way down and
4720
+ * the copy is assigned to its parent, so the write produces new references
4721
+ * up to the root. Writing into such an object directly would mutate the
4722
+ * instance shared with the previous state and with history, and subscribers
4723
+ * would see no change. Outside a draft (a private working copy) the path is
4724
+ * written in place.
4725
+ *
4726
+ * The path goes through own properties of plain objects, class instances
4727
+ * and arrays (by index); anything else throws a TypeError rather than
4728
+ * writing where no clone or save would see it: an inherited property such
4729
+ * as a method counts as missing, and Map, Set, Date and RegExp values and
4730
+ * other keys of arrays are refused, as is a "__proto__" segment.
4731
+ */
4732
+ function setByPath(root, segments, value, options = {}) {
4733
+ const parent = walkToParent(root, segments, options.createMissing ?? false);
4734
+ parent[segments[segments.length - 1]] = value;
4735
+ }
4736
+ /**
4737
+ * Delete the property at dot-path `segments` below `root`, copying
4738
+ * undrafted objects on the way down like setByPath(). Does nothing when an
4739
+ * intermediate is missing or not an object, or the property is absent (or
4740
+ * only inherited).
4741
+ */
4742
+ function deleteByPath(root, segments) {
4743
+ const last = segments[segments.length - 1];
4744
+ checkSegments(segments);
4745
+ let holder = root;
4746
+ for (const seg of segments.slice(0, -1)) {
4747
+ if (holder == null || typeof holder !== "object" || !hasOwn(holder, seg)) return;
4748
+ holder = holder[seg];
4749
+ }
4750
+ if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
4751
+ const parent = walkToParent(root, segments, false);
4752
+ delete parent[last];
4753
+ }
4754
+ /** Refuse a segment that would reach a prototype instead of story state. */
4755
+ function checkSegments(segments) {
4756
+ if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
4757
+ }
4758
+ /** Throw unless `holder` can take `key` as story state (see setByPath). */
4759
+ function checkHolder(holder, key, segments, depth) {
4760
+ const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
4761
+ if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
4762
+ }
4763
+ /**
4764
+ * Walk to the object holding the last of `segments`, copying undrafted
4765
+ * objects when `root` is an Immer draft (see setByPath). A missing or
4766
+ * non-object intermediate is replaced by a plain object when
4767
+ * `createMissing` is set, and throws a TypeError otherwise.
4768
+ */
4769
+ function walkToParent(root, segments, createMissing) {
4770
+ checkSegments(segments);
4771
+ const copyOnWrite = isDraft(root);
4772
+ let current = root;
4773
+ for (let i = 0; i < segments.length - 1; i++) {
4774
+ const seg = segments[i];
4775
+ checkHolder(current, seg, segments, i);
4776
+ let next = hasOwn(current, seg) ? current[seg] : void 0;
4777
+ if (next == null || typeof next !== "object") {
4778
+ if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
4779
+ next = {};
4780
+ current[seg] = next;
4781
+ } else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
4782
+ next = shallowCopy(next);
4783
+ current[seg] = next;
4784
+ }
4785
+ current = next;
4786
+ }
4787
+ checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
4788
+ return current;
4789
+ }
4790
+ //#endregion
4791
+ //#region src/structural.ts
4792
+ function isPlainObject(value) {
4793
+ if (typeof value !== "object" || value === null) return false;
4794
+ const proto = Object.getPrototypeOf(value);
4795
+ return proto === Object.prototype || proto === null;
4796
+ }
4797
+ function deepClone(value, options = {}) {
4798
+ const seen = /* @__PURE__ */ new Map();
4799
+ function clone(val) {
4800
+ if (val === null || typeof val !== "object") return val;
4801
+ const obj = val;
4802
+ if (seen.has(obj)) return seen.get(obj);
4803
+ if (val instanceof Date) return new Date(val.getTime());
4804
+ if (val instanceof RegExp) return new RegExp(val.source, val.flags);
4805
+ if (Array.isArray(val)) {
4806
+ const arr = [];
4807
+ seen.set(obj, arr);
4808
+ for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
4809
+ return arr;
4810
+ }
4811
+ if (val instanceof Map) {
4812
+ const copy = /* @__PURE__ */ new Map();
4813
+ seen.set(obj, copy);
4814
+ for (const [k, v] of val) copy.set(clone(k), clone(v));
4815
+ return copy;
4816
+ }
4817
+ if (val instanceof Set) {
4818
+ const copy = /* @__PURE__ */ new Set();
4819
+ seen.set(obj, copy);
4820
+ for (const v of val) copy.add(clone(v));
4821
+ return copy;
4822
+ }
4823
+ const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
4824
+ if (!keepsProto && options.keepUnregistered) return val;
4825
+ const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
4826
+ seen.set(obj, copy);
4827
+ for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
4828
+ return copy;
4829
+ }
4830
+ return clone(value);
4831
+ }
4832
+ /**
4833
+ * Structural equality over the value types deepClone() supports: primitives,
4834
+ * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
4835
+ * at any depth). Map and Set entries are compared in insertion order. Arrays
4836
+ * are compared by length and index, so a hole equals an undefined element
4837
+ * (deepClone() and save/load turn holes into undefined elements).
4838
+ */
4839
+ function deepEqual(a, b) {
4840
+ return equal(a, b, /* @__PURE__ */ new Map());
4841
+ }
4842
+ /**
4843
+ * `assumed` holds the pairs already being compared: meeting one again (a
4844
+ * cycle) assumes it equal. Every object may pair with several others, since
4845
+ * cycles of different lengths can still unfold to the same value.
4846
+ */
4847
+ function equal(a, b, assumed) {
4848
+ if (Object.is(a, b)) return true;
4849
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4850
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
4851
+ const pairs = assumed.get(a);
4852
+ if (pairs?.has(b)) return true;
4853
+ if (pairs) pairs.add(b);
4854
+ else assumed.set(a, new Set([b]));
4855
+ if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
4856
+ if (a instanceof RegExp) return String(a) === String(b);
4857
+ if (a instanceof Map || a instanceof Set) {
4858
+ const bc = b;
4859
+ if (a.size !== bc.size) return false;
4860
+ const ai = a.entries();
4861
+ const bi = bc.entries();
4862
+ 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;
4863
+ return true;
4864
+ }
4865
+ if (Array.isArray(a)) {
4866
+ const bc = b;
4867
+ if (a.length !== bc.length) return false;
4868
+ for (let i = 0; i < a.length; i++) if (!equal(a[i], bc[i], assumed)) return false;
4869
+ return true;
4870
+ }
4871
+ const ao = a;
4872
+ const bo = b;
4873
+ const keys = Object.keys(ao);
4874
+ if (keys.length !== Object.keys(bo).length) return false;
4875
+ for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
4876
+ return true;
4877
+ }
4878
+ /**
4879
+ * Objects merged property by property: plain objects and (registered)
4880
+ * class instances. Arrays, Map, Set, Date and RegExp are values that are
4881
+ * replaced as a whole, since their elements have no stable identity to
4882
+ * merge by (a shift moves every index).
4883
+ */
4884
+ function isMergeable(value) {
4885
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
4886
+ }
4887
+ /**
4888
+ * Whether changes between `a` and `b` merge key by key: both are objects of
4889
+ * one class (see isMergeable), or, with `arrays`, both are arrays.
4890
+ */
4891
+ function mergesWith(a, b, arrays) {
4892
+ if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
4893
+ return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
4894
+ }
4895
+ /**
4896
+ * The keys at which `after` differs from `before`, two values that merge
4897
+ * (see mergesWith): keys added, deleted, or holding another value. Store
4898
+ * updates are immutable, so a value that kept its identity is no change,
4899
+ * and one rebuilt with equal content (mutation code commits whole values)
4900
+ * is none either. Values that merge are reported as 'nested', to be
4901
+ * compared key by key; they may still be equal. With `arrays`, arrays
4902
+ * merge too: elements at the indices both hold are compared, and the
4903
+ * elements one has beyond the other's length are added or deleted.
4904
+ */
4905
+ function keyChanges(before, after, arrays) {
4906
+ const changes = [];
4907
+ const compare = (key, b, a) => {
4908
+ if (Object.is(b, a)) return;
4909
+ if (mergesWith(b, a, arrays)) changes.push({
4910
+ key,
4911
+ kind: "nested",
4912
+ before: b,
4913
+ after: a
4914
+ });
4915
+ else if (!deepEqual(b, a)) changes.push({
4916
+ key,
4917
+ kind: "changed",
4918
+ after: a
4919
+ });
4920
+ };
4921
+ if (Array.isArray(after)) {
4922
+ const b = before;
4923
+ const a = after;
4924
+ for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
4925
+ for (let i = b.length; i < a.length; i++) changes.push({
4926
+ key: String(i),
4927
+ kind: "added",
4928
+ after: a[i]
4929
+ });
4930
+ for (let i = a.length; i < b.length; i++) changes.push({
4931
+ key: String(i),
4932
+ kind: "deleted"
4865
4933
  });
4866
- return createIDBBackend();
4867
- } catch {}
4868
- if (typeof localStorage !== "undefined") try {
4869
- const probeKey = "__spindle_probe__";
4870
- localStorage.setItem(probeKey, "1");
4871
- const val = localStorage.getItem(probeKey);
4872
- localStorage.removeItem(probeKey);
4873
- if (val === "1") return createLocalStorageBackend();
4874
- } catch {}
4875
- return createMemoryBackend();
4934
+ return changes;
4935
+ }
4936
+ for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
4937
+ else changes.push({
4938
+ key,
4939
+ kind: "added",
4940
+ after: after[key]
4941
+ });
4942
+ for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
4943
+ key,
4944
+ kind: "deleted"
4945
+ });
4946
+ return changes;
4876
4947
  }
4877
4948
  /**
4878
- * Detection in flight. Concurrent first callers share it so they all get the
4879
- * same backend (the memory fallback makes a new, empty store per detection).
4949
+ * The property paths where `after` differs from `before`, two objects of
4950
+ * one class: objects that merge (see isMergeable) are compared property by
4951
+ * property, any other value as a whole.
4880
4952
  */
4881
- var _backendPromise = null;
4882
- function getBackend() {
4883
- if (_backend) return Promise.resolve(_backend);
4884
- if (!_backendPromise) {
4885
- const pending = detectBackend().then((backend) => {
4886
- if (_backendPromise === pending) _backend = backend;
4887
- return backend;
4888
- });
4889
- _backendPromise = pending;
4890
- }
4891
- return _backendPromise;
4953
+ function diffPaths(before, after) {
4954
+ const changes = [];
4955
+ const ancestors = /* @__PURE__ */ new Set();
4956
+ (function walk(b, a, path) {
4957
+ if (ancestors.has(a)) return;
4958
+ ancestors.add(a);
4959
+ for (const change of keyChanges(b, a, false)) {
4960
+ const at = [...path, change.key];
4961
+ if (change.kind === "nested") walk(change.before, change.after, at);
4962
+ else changes.push(change.kind === "deleted" ? {
4963
+ path: at,
4964
+ deleted: true
4965
+ } : {
4966
+ path: at,
4967
+ deleted: false,
4968
+ value: change.after
4969
+ });
4970
+ }
4971
+ ancestors.delete(a);
4972
+ })(before, after, []);
4973
+ return changes;
4892
4974
  }
4893
- function resetBackend() {
4894
- _backend = null;
4895
- _backendPromise = null;
4975
+ /** Whether `target` already holds what `change` would write. */
4976
+ function isApplied(target, change) {
4977
+ const parent = getByPath(target, change.path.slice(0, -1));
4978
+ if (parent === null || typeof parent !== "object") return change.deleted;
4979
+ const key = change.path[change.path.length - 1];
4980
+ if (!hasOwn(parent, key)) return change.deleted;
4981
+ return !change.deleted && deepEqual(parent[key], change.value);
4982
+ }
4983
+ function applyChange(target, change) {
4984
+ if (change.deleted) deleteByPath(target, change.path);
4985
+ else setByPath(target, change.path, change.value);
4896
4986
  }
4897
4987
  /**
4898
- * Synchronous accessor for the current backend type.
4899
- * Returns 'memory' if the backend hasn't been detected yet.
4988
+ * Write how `after` differs from `before` (see keyChanges; arrays merge by
4989
+ * index) into `target`, another version of the same object or array.
4990
+ * Values that changed are written whole, as deep copies; for values that
4991
+ * merge, `mergeNested` may merge them into `target` instead, returning
4992
+ * whether it did. Into an array, elements removed from the end are removed
4993
+ * at the same indices and elements added are appended, and changes at
4994
+ * indices `target` lacks are dropped: where `target` was resized, its
4995
+ * indices do not line up with `before`'s.
4900
4996
  */
4901
- function getBackendType() {
4902
- return _backend?.type ?? "memory";
4997
+ function mergeKeys(target, before, after, mergeNested) {
4998
+ const list = Array.isArray(target) ? target : void 0;
4999
+ for (const change of keyChanges(before, after, true)) {
5000
+ const { key } = change;
5001
+ if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
5002
+ else delete target[key];
5003
+ else if (list && change.kind === "added") list.push(deepClone(change.after));
5004
+ else if (list && Number(key) >= list.length) continue;
5005
+ else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
5006
+ }
4903
5007
  }
4904
5008
  //#endregion
4905
5009
  //#region src/event-emitter.ts
@@ -5002,6 +5106,10 @@ function inOrder(op) {
5002
5106
  function queued(op) {
5003
5107
  return (...args) => inOrder(() => op(...args));
5004
5108
  }
5109
+ /** An operation passing the save `saveId`, if it is stored, to `read`. */
5110
+ var readingSave = (read) => queued(async (saveId) => read(await (await getBackend()).getSave(saveId)));
5111
+ /** An operation passing the save a slot holds, if any, to `read`. */
5112
+ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(ifid, slot)));
5005
5113
  /**
5006
5114
  * Store a new playthrough and make it the story's current one. Pass `id` when
5007
5115
  * the caller has already switched to the playthrough (restart() does, so
@@ -5202,7 +5310,7 @@ function deserializePayload(payload) {
5202
5310
  }
5203
5311
  /** The live payload of a stored save, if there is one. */
5204
5312
  var livePayload = (record) => record && deserializePayload(record.payload);
5205
- queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
5313
+ readingSave(livePayload);
5206
5314
  /**
5207
5315
  * Delete a save record. If the default slot or a named slot holds it, that
5208
5316
  * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
@@ -5358,7 +5466,7 @@ queued(hasQuickSaveNow);
5358
5466
  async function hasQuickSaveNow(ifid, slot) {
5359
5467
  return await slotRecord(ifid, slot) !== void 0;
5360
5468
  }
5361
- queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
5469
+ readingSlot(livePayload);
5362
5470
  /**
5363
5471
  * Read the save held in a slot (the default slot when `slot` is omitted) for
5364
5472
  * the running game to load, and make its playthrough the story's current one
@@ -5454,7 +5562,7 @@ function toExport(record) {
5454
5562
  save: record
5455
5563
  };
5456
5564
  }
5457
- var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
5565
+ var exportSave = readingSave(toExport);
5458
5566
  /**
5459
5567
  * Validate an export against the running story and build the record to store:
5460
5568
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
@@ -5482,7 +5590,7 @@ function importSave(data, ifid) {
5482
5590
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
5483
5591
  * Returns undefined if the slot is empty.
5484
5592
  */
5485
- var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
5593
+ var exportSlotSave = readingSlot(toExport);
5486
5594
  /**
5487
5595
  * Import an exported save into a slot (default autosave slot when `slot` is
5488
5596
  * omitted), replacing any save already in that slot. Applies the same
@@ -7046,7 +7154,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
7046
7154
  //#region src/components/StoryInterface.tsx
7047
7155
  var DEFAULT_MARKUP = "<header class=\"story-menubar\">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}";
7048
7156
  function StoryInterface() {
7049
- const overridePassage = useStoryStore((s) => s.storyData)?.passages.get("StoryInterface");
7157
+ const { storyData } = useStoryFields("storyData");
7158
+ const overridePassage = storyData?.passages.get("StoryInterface");
7050
7159
  const markup = overridePassage !== void 0 ? overridePassage.content : DEFAULT_MARKUP;
7051
7160
  const nobr = overridePassage?.tags.includes("nobr") ?? false;
7052
7161
  const rendered = useMemo(() => {
@@ -7146,7 +7255,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
7146
7255
  const onCloseRef = useRef(onClose);
7147
7256
  onCloseRef.current = onClose;
7148
7257
  const stableOnClose = useCallback(() => onCloseRef.current(), []);
7149
- const storyData = useStoryStore((s) => s.storyData);
7258
+ const { storyData } = useStoryFields("storyData");
7150
7259
  const passage = passageName ? storyData?.passages.get(passageName) : void 0;
7151
7260
  const markup = passage?.content ?? fallbackMarkup;
7152
7261
  const nobr = passage?.tags.includes("nobr") ?? false;
@@ -7278,19 +7387,92 @@ function App() {
7278
7387
  }
7279
7388
  //#endregion
7280
7389
  //#region src/parser.ts
7390
+ var HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
7391
+ /** HTML elements written without content or end tag, as innerHTML does. */
7392
+ var VOID_ELEMENTS = new Set([
7393
+ "area",
7394
+ "base",
7395
+ "basefont",
7396
+ "bgsound",
7397
+ "br",
7398
+ "col",
7399
+ "embed",
7400
+ "frame",
7401
+ "hr",
7402
+ "img",
7403
+ "input",
7404
+ "keygen",
7405
+ "link",
7406
+ "meta",
7407
+ "param",
7408
+ "source",
7409
+ "track",
7410
+ "wbr"
7411
+ ]);
7412
+ /**
7413
+ * The passage markup in the nodes of passage data.
7414
+ *
7415
+ * A compiler that HTML-escapes passage text (Twine, Tweego) leaves a text
7416
+ * node whose text is the markup; a no-break space in it is U+00A0, which
7417
+ * innerHTML writes as `&nbsp;`. One that doesn't escape it leaves the
7418
+ * browser's parse of it, whose elements are written back as markup here.
7419
+ * innerHTML did that too, but its escaping can't be undone: a `"` in an
7420
+ * attribute value is written `&quot;`, and decoding that ends the value
7421
+ * early, while text it leaves unescaped (in `<style>`, comments) would be
7422
+ * decoded wrongly.
7423
+ */
7424
+ function passageMarkup(parent) {
7425
+ let markup = "";
7426
+ for (const node of Array.from(parent.childNodes)) if (node.nodeType === Node.TEXT_NODE) markup += node.data;
7427
+ else if (node.nodeType === Node.COMMENT_NODE) markup += `<!--${node.data}-->`;
7428
+ else if (node.nodeType === Node.ELEMENT_NODE) markup += elementMarkup(node);
7429
+ return markup;
7430
+ }
7431
+ /** An element of passage data as markup (see passageMarkup). */
7432
+ function elementMarkup(el) {
7433
+ const name = el.localName;
7434
+ let markup = `<${name}`;
7435
+ for (const attr of Array.from(el.attributes)) markup += ` ${attr.name}=${quoteAttribute(attr.name, attr.value)}`;
7436
+ markup += ">";
7437
+ if (el.namespaceURI === HTML_NAMESPACE && VOID_ELEMENTS.has(name)) return markup;
7438
+ const content = name === "template" ? el.content : el;
7439
+ return `${markup}${passageMarkup(content)}</${name}>`;
7440
+ }
7441
+ /**
7442
+ * An attribute value quoted so that the passage tokenizer reads it back:
7443
+ * in double quotes, else single ones, else with the double quotes in its
7444
+ * text written as `&quot;`, which rendering decodes. Quotes inside markup in
7445
+ * the value (`{print "a"}`) are code, and don't end it.
7446
+ */
7447
+ function quoteAttribute(name, value) {
7448
+ const code = isCodeAttribute(name);
7449
+ const probe = code ? "on" : "a";
7450
+ const readsBack = (quoted) => {
7451
+ const tokens = tokenize(`<i ${probe}=${quoted}>`);
7452
+ const tag = tokens[0];
7453
+ return tokens.length === 1 && tag?.type === "html" && tag.attributes[probe] === quoted.slice(1, -1);
7454
+ };
7455
+ return [`"${value}"`, `'${value}'`].find(readsBack) ?? `"${escapeTextQuotes(value, code)}"`;
7456
+ }
7281
7457
  /**
7282
- * Decode the HTML entities that browsers use when serializing innerHTML.
7283
- * &amp; is decoded last to avoid double-decoding (e.g. &amp;lt; → &lt;, not <).
7458
+ * `value` with the double quotes in its text written as `&quot;`, but not
7459
+ * those in markup (`{print "a"}`) or, in a code attribute, in sigil
7460
+ * references (see splitSigilTemplate), which are code.
7284
7461
  */
7285
- function decodeHtmlEntities(html) {
7286
- return html.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/g, "&");
7462
+ function escapeTextQuotes(value, code) {
7463
+ const escape = (text) => text.replace(/"/g, "&quot;");
7464
+ if (code) return splitSigilTemplate(value).map((part) => "text" in part ? escape(part.text) : "expr" in part ? `{${part.expr}}` : part.verbatim).join("");
7465
+ return tokenize(value, { text: true }).map((token) => {
7466
+ const source = value.slice(token.start, token.end);
7467
+ return token.type === "text" ? escape(source) : source;
7468
+ }).join("");
7287
7469
  }
7288
7470
  /**
7289
7471
  * Parse <tw-storydata> and all <tw-passagedata> elements from the DOM.
7290
7472
  *
7291
- * Uses innerHTML + entity decoding instead of textContent so that HTML
7292
- * tags inside passage markup (e.g. <div> inside {button}) are preserved
7293
- * regardless of whether the compiler HTML-encoded them.
7473
+ * Passage content is read with passageMarkup instead of textContent, so
7474
+ * that HTML tags inside passage markup (e.g. <div> inside {button}) are
7475
+ * preserved whether or not the compiler HTML-encoded them.
7294
7476
  */
7295
7477
  function parseStoryData() {
7296
7478
  const storyEl = document.querySelector("tw-storydata");
@@ -7308,7 +7490,7 @@ function parseStoryData() {
7308
7490
  const pid = parseInt(el.getAttribute("pid") || "0", 10);
7309
7491
  const passageName = el.getAttribute("name") || "";
7310
7492
  const tags = (el.getAttribute("tags") || "").split(/\s+/).filter((t) => t.length > 0);
7311
- const content = decodeHtmlEntities(el.innerHTML);
7493
+ const content = passageMarkup(el);
7312
7494
  const metadata = {};
7313
7495
  const skipAttrs = new Set([
7314
7496
  "pid",
@@ -7428,6 +7610,14 @@ var settings = {
7428
7610
  };
7429
7611
  //#endregion
7430
7612
  //#region src/action-registry.ts
7613
+ /** The fields of a StoryAction that are left out when unset. */
7614
+ var OPTIONAL_ACTION_FIELDS = [
7615
+ "target",
7616
+ "variable",
7617
+ "options",
7618
+ "value",
7619
+ "disabled"
7620
+ ];
7431
7621
  /**
7432
7622
  * The registrations holding each ID, oldest first; the newest is the one
7433
7623
  * listed. Generated IDs don't collide, but author IDs can, and one control's
@@ -7510,11 +7700,7 @@ function useAction(opts) {
7510
7700
  label: opts.label,
7511
7701
  perform: (...args) => performRef.current(...args)
7512
7702
  };
7513
- if (opts.target !== void 0) action.target = opts.target;
7514
- if (opts.variable !== void 0) action.variable = opts.variable;
7515
- if (opts.options !== void 0) action.options = opts.options;
7516
- if (opts.value !== void 0) action.value = opts.value;
7517
- if (opts.disabled !== void 0) action.disabled = opts.disabled;
7703
+ for (const field of OPTIONAL_ACTION_FIELDS) if (opts[field] !== void 0) Object.assign(action, { [field]: opts[field] });
7518
7704
  return action;
7519
7705
  };
7520
7706
  const registration = useRef(null);
@@ -7729,6 +7915,12 @@ function readGroup(params, src, args) {
7729
7915
  const rest = positional[restIndex];
7730
7916
  args[rest.name] = from < to ? read(rest, text.slice(spans[from][0], spans[to - 1][1])) : unset(rest);
7731
7917
  }
7918
+ /** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
7919
+ var DELAY_PARAMETER = {
7920
+ name: "delay",
7921
+ type: "delay",
7922
+ required: true
7923
+ };
7732
7924
  /** Read `rawArgs` into the declared `parameters` (see above). */
7733
7925
  function parseMacroArgs(rawArgs, parameters) {
7734
7926
  const args = {};
@@ -7782,15 +7974,19 @@ function parseVarArgs(rawArgs) {
7782
7974
  placeholder
7783
7975
  };
7784
7976
  }
7977
+ /** The parameters of an `{option}` sub-macro: its value. */
7978
+ var OPTION_PARAMETERS = [{
7979
+ name: "value",
7980
+ type: "string"
7981
+ }];
7785
7982
  /**
7786
- * Walk AST children to find {option} macro nodes, returning their rawArgs as values.
7983
+ * Walk AST children to find {option} macro nodes, returning their values.
7787
7984
  */
7788
7985
  function extractOptions(children) {
7789
7986
  const options = [];
7790
7987
  for (const node of children) if (node.type === "macro" && node.name === "option") {
7791
- const raw = node.rawArgs.trim();
7792
- const stripped = readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/s, "$2");
7793
- options.push(stripped);
7988
+ const { value } = parseMacroArgs(node.rawArgs, OPTION_PARAMETERS);
7989
+ options.push(value ?? node.rawArgs.trim().replace(/^(["'])(.+)\1$/s, "$2"));
7794
7990
  }
7795
7991
  return options;
7796
7992
  }
@@ -7844,13 +8040,7 @@ function defineMacro(config, source = "builtin") {
7844
8040
  mutate: (code) => executeMutation(code, getValues(), update),
7845
8041
  update,
7846
8042
  getValues,
7847
- wrap: (content) => {
7848
- if (className || id) return h("span", {
7849
- id,
7850
- class: className
7851
- }, content);
7852
- return h(Fragment, null, content);
7853
- }
8043
+ wrap: (content) => wrapContent(className, id, content)
7854
8044
  };
7855
8045
  if (config.merged) {
7856
8046
  ctx.merged = useMergedLocals();
@@ -7981,9 +8171,11 @@ function setOne(draft, name, value) {
7981
8171
  if (key.includes(".")) setByPath(namespace, key.split("."), value);
7982
8172
  else namespace[key] = value;
7983
8173
  }
8174
+ /** A method calling the store action `name`, as the store holds it then. */
8175
+ function storeAction(name) {
8176
+ return ((...args) => useStoryStore.getState()[name](...args));
8177
+ }
7984
8178
  function createStoryAPI() {
7985
- const visits = countQueries(() => useStoryStore.getState().visitCounts);
7986
- const renders = countQueries(() => useStoryStore.getState().renderCounts);
7987
8179
  return {
7988
8180
  get(name) {
7989
8181
  const { isTransient, key } = parseName(name);
@@ -8003,52 +8195,19 @@ function createStoryAPI() {
8003
8195
  for (const [k, v] of entries) setOne(draft, k, v);
8004
8196
  });
8005
8197
  },
8006
- goto(passageName) {
8007
- useStoryStore.getState().navigate(passageName);
8008
- },
8009
- back() {
8010
- useStoryStore.getState().goBack();
8011
- },
8012
- forward() {
8013
- useStoryStore.getState().goForward();
8014
- },
8015
- restart() {
8016
- useStoryStore.getState().restart();
8017
- },
8018
- save(slot, custom) {
8019
- return useStoryStore.getState().save(slot, custom);
8020
- },
8021
- load(slot) {
8022
- return useStoryStore.getState().load(slot);
8023
- },
8024
- hasSave(slot) {
8025
- return useStoryStore.getState().hasSave(slot);
8026
- },
8027
- getSaveInfo(slot) {
8028
- return useStoryStore.getState().getSaveInfo(slot);
8029
- },
8030
- listSaves() {
8031
- return useStoryStore.getState().listSaves();
8032
- },
8033
- deleteSave(slot) {
8034
- return useStoryStore.getState().deleteSave(slot);
8035
- },
8036
- exportSave(slot) {
8037
- return useStoryStore.getState().exportSave(slot);
8038
- },
8039
- importSave(data, slot) {
8040
- return useStoryStore.getState().importSave(data, slot);
8041
- },
8042
- visited: visits.count,
8043
- hasVisited: visits.has,
8044
- hasVisitedAny: visits.any,
8045
- hasVisitedAll: visits.all,
8046
- rendered: renders.count,
8047
- hasRendered: renders.has,
8048
- hasRenderedAny: renders.any,
8049
- hasRenderedAll: renders.all,
8050
- currentPassage,
8051
- previousPassage,
8198
+ goto: storeAction("navigate"),
8199
+ back: storeAction("goBack"),
8200
+ forward: storeAction("goForward"),
8201
+ restart: storeAction("restart"),
8202
+ save: storeAction("save"),
8203
+ load: storeAction("load"),
8204
+ hasSave: storeAction("hasSave"),
8205
+ getSaveInfo: storeAction("getSaveInfo"),
8206
+ listSaves: storeAction("listSaves"),
8207
+ deleteSave: storeAction("deleteSave"),
8208
+ exportSave: storeAction("exportSave"),
8209
+ importSave: storeAction("importSave"),
8210
+ ...historyQueries(() => useStoryStore.getState().visitCounts, () => useStoryStore.getState().renderCounts),
8052
8211
  get title() {
8053
8212
  return useStoryStore.getState().storyData?.name || "";
8054
8213
  },
@@ -8094,15 +8253,9 @@ function createStoryAPI() {
8094
8253
  estimateSupported: false
8095
8254
  };
8096
8255
  },
8097
- clearGameData() {
8098
- return useStoryStore.getState().clearGameData();
8099
- },
8100
- clearAllData() {
8101
- return useStoryStore.getState().clearAllData();
8102
- },
8103
- deletePlaythrough(playthroughId) {
8104
- return useStoryStore.getState().deletePlaythrough(playthroughId);
8105
- },
8256
+ clearGameData: storeAction("clearGameData"),
8257
+ clearAllData: storeAction("clearAllData"),
8258
+ deletePlaythrough: storeAction("deletePlaythrough"),
8106
8259
  get backend() {
8107
8260
  return getBackendType();
8108
8261
  }
@@ -8510,26 +8663,51 @@ function astContainsChildren(nodes) {
8510
8663
  }
8511
8664
  //#endregion
8512
8665
  //#region src/components/macros/Widget.tsx
8513
- /**
8514
- * Read the arguments of a {widget} definition: its name (quotes dropped)
8515
- * and its parameters, the words after it that start with one of the
8516
- * characters of `sigils`.
8517
- */
8518
- function parseWidgetDef(rawArgs, sigils = "@") {
8519
- const [first, ...rest] = rawArgs.trim().split(/\s+/);
8666
+ /** A {widget} definition's name, then its `@` parameters. */
8667
+ var WIDGET_PARAMETERS = [{
8668
+ name: "name",
8669
+ type: "text",
8670
+ required: true
8671
+ }, {
8672
+ name: "parameters",
8673
+ type: "expression"
8674
+ }];
8675
+ /**
8676
+ * The widget a {widget} definition's arguments declare: its name and its
8677
+ * parameters, the words after it that start with `@` (docs/widgets.md).
8678
+ * Other words, such as `$name`, are not parameters.
8679
+ */
8680
+ function widgetDef({ name = "", parameters = "" }) {
8520
8681
  return {
8521
- name: first.replace(/["']/g, ""),
8522
- params: rest.filter((word) => sigils.includes(word[0]))
8682
+ name,
8683
+ params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
8523
8684
  };
8524
8685
  }
8686
+ /** Read the arguments of a {widget} definition (see widgetDef). */
8687
+ function parseWidgetDef(rawArgs) {
8688
+ return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
8689
+ }
8690
+ /**
8691
+ * Register the widget a definition declares, with its body. Widgets whose
8692
+ * body renders {@children} take a closing tag, so they are registered as
8693
+ * block macros too: passages parsed later nest their content. A parameter
8694
+ * that no namespace can hold throws (see registerWidget), registering
8695
+ * nothing.
8696
+ */
8697
+ function registerWidgetDef({ name, params }, body) {
8698
+ const isBlock = astContainsChildren(body);
8699
+ registerWidget(name, body, params, isBlock);
8700
+ if (isBlock) registerBlockMacro(name);
8701
+ }
8525
8702
  defineMacro({
8526
8703
  name: "widget",
8527
8704
  block: true,
8528
- render({ rawArgs, children = [] }, ctx) {
8705
+ parameters: WIDGET_PARAMETERS,
8706
+ render({ children = [] }, ctx) {
8529
8707
  let parsed;
8530
8708
  let error;
8531
8709
  try {
8532
- const def = parseWidgetDef(rawArgs);
8710
+ const def = widgetDef(ctx.args);
8533
8711
  for (const param of def.params) checkVariableName(param.slice(1), param);
8534
8712
  parsed = def;
8535
8713
  } catch (err) {
@@ -8542,10 +8720,7 @@ defineMacro({
8542
8720
  const childrenKey = JSON.stringify(children);
8543
8721
  const paramsKey = params.join(",");
8544
8722
  ctx.hooks.useLayoutEffect(() => {
8545
- if (!parsed) return;
8546
- const isBlock = astContainsChildren(children);
8547
- registerWidget(name, children, params, isBlock);
8548
- if (isBlock) registerBlockMacro(name);
8723
+ if (parsed) registerWidgetDef(parsed, children);
8549
8724
  }, [
8550
8725
  name,
8551
8726
  childrenKey,
@@ -8584,9 +8759,7 @@ defineMacro({
8584
8759
  merged: true,
8585
8760
  render({ rawArgs }, ctx) {
8586
8761
  try {
8587
- const result = ctx.evaluate(rawArgs);
8588
- const display = result == null ? "" : String(result);
8589
- return ctx.wrap(display);
8762
+ return ctx.wrap(display(ctx.evaluate(rawArgs)));
8590
8763
  } catch (err) {
8591
8764
  return /* @__PURE__ */ jsx(MacroError, {
8592
8765
  macro: "print",
@@ -8595,8 +8768,7 @@ defineMacro({
8595
8768
  }
8596
8769
  },
8597
8770
  text({ rawArgs }, ctx) {
8598
- const result = ctx.evaluate(rawArgs);
8599
- return result == null ? "" : String(result);
8771
+ return display(ctx.evaluate(rawArgs));
8600
8772
  }
8601
8773
  });
8602
8774
  //#endregion
@@ -8766,40 +8938,50 @@ defineMacro({
8766
8938
  }
8767
8939
  });
8768
8940
  //#endregion
8941
+ //#region src/components/macros/branches.ts
8942
+ /**
8943
+ * The branch a conditional block ({if}, {switch}) shows: the first of
8944
+ * `branches` whose argument `matches`, else the last bare one ({else},
8945
+ * {default}), else null. Throws what `matches` throws.
8946
+ */
8947
+ function selectBranch(branches, matches) {
8948
+ let fallback = null;
8949
+ for (const branch of branches) if (branch.rawArgs === "") fallback = branch;
8950
+ else if (matches(branch.rawArgs)) return branch;
8951
+ return fallback;
8952
+ }
8953
+ //#endregion
8769
8954
  //#region src/components/macros/If.tsx
8955
+ /**
8956
+ * The {if}/{elseif}/{else} branch whose condition holds, else the {else}
8957
+ * branch, else null: the chain ends at its first bare branch. Throws what a
8958
+ * condition throws.
8959
+ */
8960
+ function selectIfBranch(branches, evaluate) {
8961
+ const end = branches.findIndex((branch) => branch.rawArgs === "");
8962
+ return selectBranch(end < 0 ? branches : branches.slice(0, end + 1), (expr) => Boolean(evaluate(expr)));
8963
+ }
8770
8964
  defineMacro({
8771
8965
  name: "if",
8772
8966
  block: true,
8773
8967
  interpolate: true,
8774
8968
  merged: true,
8775
8969
  render({ branches = [] }, ctx) {
8776
- function renderBranch(branch) {
8777
- const children = ctx.renderNodes(branch.children);
8778
- const cls = ctx.resolve(branch.className);
8779
- const branchId = ctx.resolve(branch.id);
8780
- if (cls || branchId) return /* @__PURE__ */ jsx("span", {
8781
- id: branchId,
8782
- class: cls,
8783
- children
8970
+ let branch;
8971
+ try {
8972
+ branch = selectIfBranch(branches, ctx.evaluate);
8973
+ } catch (err) {
8974
+ return /* @__PURE__ */ jsx(MacroError, {
8975
+ macro: "if",
8976
+ error: err
8784
8977
  });
8785
- return /* @__PURE__ */ jsx(Fragment$1, { children });
8786
- }
8787
- for (const branch of branches) {
8788
- if (branch.rawArgs === "") return renderBranch(branch);
8789
- try {
8790
- if (ctx.evaluate(branch.rawArgs)) return renderBranch(branch);
8791
- } catch (err) {
8792
- return /* @__PURE__ */ jsx(MacroError, {
8793
- macro: "if",
8794
- error: err
8795
- });
8796
- }
8797
8978
  }
8798
- return null;
8979
+ if (!branch) return null;
8980
+ return wrapContent(ctx.resolve(branch.className), ctx.resolve(branch.id), ctx.renderNodes(branch.children));
8799
8981
  },
8800
8982
  text({ branches = [] }, ctx) {
8801
- for (const branch of branches) if (branch.rawArgs === "" || ctx.evaluate(branch.rawArgs)) return ctx.renderText(branch.children);
8802
- return "";
8983
+ const branch = selectIfBranch(branches, ctx.evaluate);
8984
+ return branch ? ctx.renderText(branch.children) : "";
8803
8985
  }
8804
8986
  });
8805
8987
  //#endregion
@@ -8995,11 +9177,7 @@ defineMacro({
8995
9177
  name: "repeat",
8996
9178
  block: true,
8997
9179
  interpolate: true,
8998
- parameters: [{
8999
- name: "delay",
9000
- type: "delay",
9001
- required: true
9002
- }],
9180
+ parameters: [DELAY_PARAMETER],
9003
9181
  render({ children = [] }, ctx) {
9004
9182
  const { useState, useEffect, useCallback } = ctx.hooks;
9005
9183
  const delay = ctx.args.delay ?? 0;
@@ -9166,14 +9344,8 @@ defineMacro({
9166
9344
  * The first {case} branch whose value equals `value`, else the {default}
9167
9345
  * branch, else null. Throws what a case expression throws.
9168
9346
  */
9169
- function selectBranch(value, branches, evaluate) {
9170
- let defaultBranch = null;
9171
- for (let i = 1; i < branches.length; i++) {
9172
- const branch = branches[i];
9173
- if (branch.rawArgs === "") defaultBranch = branch;
9174
- else if (value === evaluate(branch.rawArgs)) return branch;
9175
- }
9176
- return defaultBranch;
9347
+ function selectCase(value, branches, evaluate) {
9348
+ return selectBranch(branches.slice(1), (expr) => value === evaluate(expr));
9177
9349
  }
9178
9350
  defineMacro({
9179
9351
  name: "switch",
@@ -9196,7 +9368,7 @@ defineMacro({
9196
9368
  }
9197
9369
  let branch;
9198
9370
  try {
9199
- branch = selectBranch(switchValue, branches, ctx.evaluate);
9371
+ branch = selectCase(switchValue, branches, ctx.evaluate);
9200
9372
  } catch (err) {
9201
9373
  return /* @__PURE__ */ jsx(MacroError, {
9202
9374
  macro: "case",
@@ -9206,21 +9378,24 @@ defineMacro({
9206
9378
  return branch && /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
9207
9379
  },
9208
9380
  text({ rawArgs, branches = [] }, ctx) {
9209
- const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9381
+ const branch = selectCase(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9210
9382
  return branch ? ctx.renderText(branch.children) : "";
9211
9383
  }
9212
9384
  });
9213
9385
  //#endregion
9214
9386
  //#region src/components/macros/Timed.tsx
9387
+ /** The parameters of {timed} and of each {next}: the delay before it shows. */
9388
+ var TIMED_PARAMETERS = [DELAY_PARAMETER];
9215
9389
  defineMacro({
9216
9390
  name: "timed",
9217
9391
  subMacros: ["next"],
9218
9392
  interpolate: true,
9393
+ parameters: TIMED_PARAMETERS,
9219
9394
  render({ branches = [] }, ctx) {
9220
9395
  const { useState, useEffect, useMemo } = ctx.hooks;
9221
9396
  const sections = useMemo(() => {
9222
9397
  return branches.map((branch) => ({
9223
- delay: branch.rawArgs ? parseDelay(branch.rawArgs) : 0,
9398
+ delay: parseMacroArgs(branch.rawArgs, TIMED_PARAMETERS).delay ?? 0,
9224
9399
  nodes: branch.children,
9225
9400
  className: branch.className,
9226
9401
  id: branch.id
@@ -9238,15 +9413,7 @@ defineMacro({
9238
9413
  }, [visibleIndex, sections]);
9239
9414
  if (visibleIndex < 0) return ctx.wrap(null);
9240
9415
  const section = sections[visibleIndex];
9241
- const content = ctx.renderNodes(section.nodes);
9242
- const sectionClass = ctx.resolve(section.className);
9243
- const sectionId = ctx.resolve(section.id);
9244
- const inner = sectionClass || sectionId ? /* @__PURE__ */ jsx("span", {
9245
- id: sectionId,
9246
- class: sectionClass,
9247
- children: content
9248
- }) : content;
9249
- return ctx.wrap(inner);
9416
+ return ctx.wrap(wrapContent(ctx.resolve(section.className), ctx.resolve(section.id), ctx.renderNodes(section.nodes)));
9250
9417
  }
9251
9418
  });
9252
9419
  //#endregion
@@ -9267,11 +9434,7 @@ defineMacro({
9267
9434
  name: "type",
9268
9435
  block: true,
9269
9436
  interpolate: true,
9270
- parameters: [{
9271
- name: "delay",
9272
- type: "delay",
9273
- required: true
9274
- }],
9437
+ parameters: [DELAY_PARAMETER],
9275
9438
  render({ children = [] }, ctx) {
9276
9439
  const { useState, useEffect, useRef } = ctx.hooks;
9277
9440
  const speed = ctx.args.delay ?? 0;
@@ -9886,7 +10049,7 @@ defineMacro({
9886
10049
  required: true
9887
10050
  }],
9888
10051
  render(_props, ctx) {
9889
- const storyData = useStoryStore((s) => s.storyData);
10052
+ const { storyData } = useStoryFields("storyData");
9890
10053
  const { inline } = ctx.args;
9891
10054
  const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate);
9892
10055
  const passage = storyData?.passages.get(passageName);
@@ -9953,6 +10116,26 @@ defineMacro({
9953
10116
  });
9954
10117
  //#endregion
9955
10118
  //#region src/components/macros/input-macro.ts
10119
+ /** The story variable a `storeVar` macro binds: its first parameter. */
10120
+ var VARIABLE_PARAMETER = {
10121
+ name: "variable",
10122
+ type: "variable",
10123
+ required: true
10124
+ };
10125
+ /**
10126
+ * Register the automation action of a macro bound to a story variable: keyed
10127
+ * by the variable (or `key`), with the variable's value unless `action`
10128
+ * gives another.
10129
+ */
10130
+ function useVariableAction(ctx, action) {
10131
+ return ctx.useAction({
10132
+ key: `$${ctx.varName}`,
10133
+ authorId: ctx.id,
10134
+ variable: ctx.varName,
10135
+ value: ctx.value,
10136
+ ...action
10137
+ });
10138
+ }
9956
10139
  /**
9957
10140
  * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
9958
10141
  * `type` (or, without one, a `<textarea>`) bound to the story variable.
@@ -9964,30 +10147,22 @@ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? St
9964
10147
  defineMacro({
9965
10148
  name,
9966
10149
  storeVar: true,
9967
- parameters: [{
9968
- name: "variable",
9969
- type: "variable",
9970
- required: true
9971
- }, {
10150
+ parameters: [VARIABLE_PARAMETER, {
9972
10151
  name: "placeholder",
9973
10152
  type: "string"
9974
10153
  }],
9975
10154
  render(_props, ctx) {
9976
10155
  const placeholder = ctx.args.placeholder ?? "";
9977
- ctx.useAction({
10156
+ useVariableAction(ctx, {
9978
10157
  type: name,
9979
- key: `$${ctx.varName}`,
9980
- authorId: ctx.id,
9981
10158
  label: placeholder || ctx.varName,
9982
- variable: ctx.varName,
9983
- value: ctx.value,
9984
10159
  perform: (v) => ctx.setValue(toValue(v))
9985
10160
  });
9986
10161
  return h(type ? "input" : "textarea", {
9987
10162
  type,
9988
10163
  id: ctx.id,
9989
10164
  class: ctx.cls,
9990
- value: ctx.value == null ? "" : String(ctx.value),
10165
+ value: display(ctx.value),
9991
10166
  placeholder,
9992
10167
  onInput: (e) => ctx.setValue(toValue(e.target.value))
9993
10168
  });
@@ -10008,22 +10183,15 @@ defineInputMacro("textarea");
10008
10183
  defineMacro({
10009
10184
  name: "checkbox",
10010
10185
  storeVar: true,
10011
- parameters: [{
10012
- name: "variable",
10013
- type: "variable",
10014
- required: true
10015
- }, {
10186
+ parameters: [VARIABLE_PARAMETER, {
10016
10187
  name: "label",
10017
10188
  type: "text"
10018
10189
  }],
10019
10190
  render(_props, ctx) {
10020
10191
  const label = ctx.args.label ?? "";
10021
- ctx.useAction({
10192
+ useVariableAction(ctx, {
10022
10193
  type: "checkbox",
10023
- key: `$${ctx.varName}`,
10024
- authorId: ctx.id,
10025
10194
  label: label || ctx.varName || "",
10026
- variable: ctx.varName,
10027
10195
  value: !!ctx.value,
10028
10196
  perform: (v) => ctx.setValue(v !== void 0 ? !!v : !ctx.value)
10029
10197
  });
@@ -10044,11 +10212,7 @@ defineMacro({
10044
10212
  name: "radiobutton",
10045
10213
  storeVar: true,
10046
10214
  parameters: [
10047
- {
10048
- name: "variable",
10049
- type: "variable",
10050
- required: true
10051
- },
10215
+ VARIABLE_PARAMETER,
10052
10216
  {
10053
10217
  name: "value",
10054
10218
  type: "text",
@@ -10062,13 +10226,10 @@ defineMacro({
10062
10226
  render(_props, ctx) {
10063
10227
  const radioValue = ctx.args.value ?? "";
10064
10228
  const label = ctx.args.label ?? "";
10065
- ctx.useAction({
10229
+ useVariableAction(ctx, {
10066
10230
  type: "radiobutton",
10067
10231
  key: `$${ctx.varName}:${radioValue}`,
10068
- authorId: ctx.id,
10069
10232
  label: label || radioValue,
10070
- variable: ctx.varName,
10071
- value: ctx.value,
10072
10233
  perform: () => ctx.setValue(radioValue)
10073
10234
  });
10074
10235
  return /* @__PURE__ */ jsxs("label", {
@@ -10089,21 +10250,13 @@ defineMacro({
10089
10250
  name: "listbox",
10090
10251
  subMacros: ["option"],
10091
10252
  storeVar: true,
10092
- parameters: [{
10093
- name: "variable",
10094
- type: "variable",
10095
- required: true
10096
- }],
10253
+ parameters: [VARIABLE_PARAMETER],
10097
10254
  render({ children = [] }, ctx) {
10098
10255
  const options = ctx.extractOptions(children);
10099
- ctx.useAction({
10256
+ useVariableAction(ctx, {
10100
10257
  type: "listbox",
10101
- key: `$${ctx.varName}`,
10102
- authorId: ctx.id,
10103
10258
  label: ctx.varName,
10104
- variable: ctx.varName,
10105
10259
  options,
10106
- value: ctx.value,
10107
10260
  perform: (v) => {
10108
10261
  if (v !== void 0) ctx.setValue(String(v));
10109
10262
  }
@@ -10111,7 +10264,7 @@ defineMacro({
10111
10264
  return /* @__PURE__ */ jsx("select", {
10112
10265
  id: ctx.id,
10113
10266
  class: ctx.cls,
10114
- value: ctx.value == null ? "" : String(ctx.value),
10267
+ value: display(ctx.value),
10115
10268
  onChange: (e) => ctx.setValue(e.target.value),
10116
10269
  children: options.map((opt) => /* @__PURE__ */ jsx("option", {
10117
10270
  value: opt,
@@ -10126,11 +10279,7 @@ defineMacro({
10126
10279
  name: "cycle",
10127
10280
  block: true,
10128
10281
  storeVar: true,
10129
- parameters: [{
10130
- name: "variable",
10131
- type: "variable",
10132
- required: true
10133
- }],
10282
+ parameters: [VARIABLE_PARAMETER],
10134
10283
  render({ children = [] }, ctx) {
10135
10284
  const options = ctx.extractOptions(children);
10136
10285
  const handleClick = () => {
@@ -10139,14 +10288,10 @@ defineMacro({
10139
10288
  const nextIndex = (options.indexOf(String(current)) + 1) % options.length;
10140
10289
  ctx.setValue(options[nextIndex]);
10141
10290
  };
10142
- ctx.useAction({
10291
+ useVariableAction(ctx, {
10143
10292
  type: "cycle",
10144
- key: `$${ctx.varName}`,
10145
- authorId: ctx.id,
10146
10293
  label: ctx.value == null ? options[0] || "" : String(ctx.value),
10147
- variable: ctx.varName,
10148
10294
  options,
10149
- value: ctx.value,
10150
10295
  perform: (v) => {
10151
10296
  if (v !== void 0) ctx.setValue(v);
10152
10297
  else handleClick();
@@ -10192,6 +10337,20 @@ function parsePassage(passage) {
10192
10337
  function renderPassageContent(passage) {
10193
10338
  return renderNodes(parsePassage(passage), passage.tags.includes("nobr") ? { nobr: true } : void 0);
10194
10339
  }
10340
+ /**
10341
+ * The content of a special passage shown with every passage (PassageHeader,
10342
+ * PassageFooter, PassageDone), or null. One that fails to render is logged
10343
+ * and left out, so it can't take the passage down with it.
10344
+ */
10345
+ function renderSpecialPassage(passage) {
10346
+ if (!passage) return null;
10347
+ try {
10348
+ return renderPassageContent(passage);
10349
+ } catch (err) {
10350
+ console.error(`spindle: Error in ${passage.name}:`, err);
10351
+ return null;
10352
+ }
10353
+ }
10195
10354
  var CODE_PASSAGES = new Set([
10196
10355
  "PassageReady",
10197
10356
  "PassageHeader",
@@ -10199,7 +10358,7 @@ var CODE_PASSAGES = new Set([
10199
10358
  "PassageDone"
10200
10359
  ]);
10201
10360
  function Passage({ passage, dataTransition, navigationId }) {
10202
- const storyData = useStoryStore((s) => s.storyData);
10361
+ const { storyData } = useStoryFields("storyData");
10203
10362
  const isCodePassage = CODE_PASSAGES.has(passage.name);
10204
10363
  const [doneReady, setDoneReady] = useState(false);
10205
10364
  const content = useMemo(() => {
@@ -10219,40 +10378,17 @@ function Passage({ passage, dataTransition, navigationId }) {
10219
10378
  });
10220
10379
  }
10221
10380
  }, [passage.content, passage.name]);
10222
- const headerPassage = isCodePassage ? void 0 : storyData?.passages.get("PassageHeader");
10223
- const footerPassage = isCodePassage ? void 0 : storyData?.passages.get("PassageFooter");
10224
- const donePassage = isCodePassage ? void 0 : storyData?.passages.get("PassageDone");
10225
- const headerContent = useMemo(() => {
10226
- if (!headerPassage) return null;
10227
- try {
10228
- return renderPassageContent(headerPassage);
10229
- } catch (err) {
10230
- console.error("spindle: Error in PassageHeader:", err);
10231
- return null;
10232
- }
10233
- }, [headerPassage?.content]);
10234
- const footerContent = useMemo(() => {
10235
- if (!footerPassage) return null;
10236
- try {
10237
- return renderPassageContent(footerPassage);
10238
- } catch (err) {
10239
- console.error("spindle: Error in PassageFooter:", err);
10240
- return null;
10241
- }
10242
- }, [footerPassage?.content]);
10381
+ const special = (name) => isCodePassage ? void 0 : storyData?.passages.get(name);
10382
+ const headerPassage = special("PassageHeader");
10383
+ const footerPassage = special("PassageFooter");
10384
+ const donePassage = special("PassageDone");
10385
+ const headerContent = useMemo(() => renderSpecialPassage(headerPassage), [headerPassage?.content]);
10386
+ const footerContent = useMemo(() => renderSpecialPassage(footerPassage), [footerPassage?.content]);
10243
10387
  useEffect(() => {
10244
10388
  if (donePassage) setDoneReady(true);
10245
10389
  return () => setDoneReady(false);
10246
10390
  }, [passage.name]);
10247
- const doneContent = useMemo(() => {
10248
- if (!doneReady || !donePassage) return null;
10249
- try {
10250
- return renderPassageContent(donePassage);
10251
- } catch (err) {
10252
- console.error("spindle: Error in PassageDone:", err);
10253
- return null;
10254
- }
10255
- }, [doneReady, donePassage?.content]);
10391
+ const doneContent = useMemo(() => doneReady ? renderSpecialPassage(donePassage) : null, [doneReady, donePassage?.content]);
10256
10392
  const elRef = useRef(null);
10257
10393
  useLayoutEffect(() => {
10258
10394
  if (navigationId !== void 0) markPassageRendered(navigationId);
@@ -10653,16 +10789,12 @@ function boot() {
10653
10789
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
10654
10790
  const widgetAST = buildAST(tokenize(passage.content));
10655
10791
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
10656
- const { name: widgetName, params } = parseWidgetDef(node.rawArgs, "$_@");
10657
- const children = node.children;
10658
- const isBlock = astContainsChildren(children);
10792
+ const def = parseWidgetDef(node.rawArgs);
10659
10793
  try {
10660
- registerWidget(widgetName, children, params, isBlock);
10794
+ registerWidgetDef(def, node.children);
10661
10795
  } catch (err) {
10662
- console.error(`spindle: widget "${widgetName}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`);
10663
- continue;
10796
+ console.error(`spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`);
10664
10797
  }
10665
- if (isBlock) registerBlockMacro(widgetName);
10666
10798
  }
10667
10799
  }
10668
10800
  let prevNavigationId = useStoryStore.getState().navigationId;