@rohal12/spindle 0.52.6 → 0.52.8

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 (48) hide show
  1. package/README.md +26 -0
  2. package/dist/pkg/format.js +1 -1
  3. package/dist/pkg/headless.js +764 -735
  4. package/dist/pkg/macro-registry.json +18 -0
  5. package/dist/pkg/story-variables.js +54 -32
  6. package/package.json +1 -1
  7. package/src/action-registry.ts +9 -0
  8. package/src/class-registry.ts +1 -5
  9. package/src/components/Passage.tsx +35 -39
  10. package/src/components/PassageDialog.tsx +2 -2
  11. package/src/components/StoryInterface.tsx +2 -2
  12. package/src/components/macros/Checkbox.tsx +3 -8
  13. package/src/components/macros/Computed.tsx +1 -1
  14. package/src/components/macros/Cycle.tsx +3 -6
  15. package/src/components/macros/ExprDisplay.tsx +12 -29
  16. package/src/components/macros/If.tsx +35 -41
  17. package/src/components/macros/Include.tsx +2 -1
  18. package/src/components/macros/Listbox.tsx +5 -7
  19. package/src/components/macros/Print.tsx +3 -5
  20. package/src/components/macros/Radiobutton.tsx +3 -5
  21. package/src/components/macros/Repeat.tsx +2 -1
  22. package/src/components/macros/Switch.tsx +6 -13
  23. package/src/components/macros/Timed.tsx +14 -18
  24. package/src/components/macros/Type.tsx +2 -1
  25. package/src/components/macros/VarDisplay.tsx +12 -32
  26. package/src/components/macros/Widget.tsx +49 -20
  27. package/src/components/macros/branches.ts +18 -0
  28. package/src/components/macros/display.ts +19 -0
  29. package/src/components/macros/input-macro.ts +33 -11
  30. package/src/components/macros/macro-args.ts +7 -0
  31. package/src/components/macros/option-utils.ts +10 -7
  32. package/src/define-macro.ts +4 -6
  33. package/src/expression.ts +58 -40
  34. package/src/hooks/use-action.ts +7 -15
  35. package/src/index.tsx +5 -15
  36. package/src/interpolation.ts +34 -41
  37. package/src/js-lexer.ts +80 -45
  38. package/src/markup/ast.ts +4 -3
  39. package/src/markup/render.tsx +4 -12
  40. package/src/registry.ts +13 -12
  41. package/src/saves/save-manager.ts +18 -13
  42. package/src/saves/storage.ts +19 -20
  43. package/src/story-api.ts +30 -73
  44. package/src/structural.ts +1 -18
  45. package/src/types-drift-check.ts +37 -50
  46. package/src/utils/macro-names.ts +40 -0
  47. package/src/utils/namespace.ts +17 -0
  48. 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,351 +115,6 @@ 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;
163
- }
164
- /**
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).
169
- */
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(".")}")`);
185
- }
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(".")}")`);
190
- }
191
- /**
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.
196
- */
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;
212
- }
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) {
241
- 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;
251
- }
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;
257
- }
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;
263
- }
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;
270
- }
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
118
  //#region src/class-registry.ts
451
119
  var registry$1 = /* @__PURE__ */ new Map();
452
120
  var ctorToName = /* @__PURE__ */ new Map();
@@ -946,6 +614,13 @@ var UNCLOSED = -1;
946
614
  var MALFORMED = -2;
947
615
  /** A frame result: the scan stops at index `s` inside it (`STOPPED - s`). */
948
616
  var STOPPED = -3;
617
+ /**
618
+ * The result of a frame that closes at `end`. `bodyStarted`: the code inside
619
+ * started a function or class body. That body replaced the one to come, and
620
+ * is gone once the frame is closed, so after the frame no body is to come —
621
+ * a scan that skips the frame must know, as must the frames around it.
622
+ */
623
+ var frameEnd = (end, bodyStarted) => end * 2 + +bodyStarted;
949
624
  /** The closers whose search for their frame a frame result may depend on. */
950
625
  var CLOSERS = [
951
626
  ")",
@@ -1178,14 +853,30 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1178
853
  ]] : void 0;
1179
854
  /**
1180
855
  * How many function or class bodies to come the scan has started, and with
1181
- * `parens`, that count when each open frame opened. Code in a `(…)` or
1182
- * `[…]` that starts one replaces the one to come, and the new one can't
1183
- * follow once the frame is closed: none is to come then.
856
+ * `braces` or `parens`, that count when each open frame opened. Code in a
857
+ * frame that starts one replaces the one to come, and the new one can't
858
+ * follow once the frame is closed: none is to come then. A scan skipping
859
+ * the frame learns this from its result (`frameEnd`, `resume`), and counts
860
+ * the body as started too, for the frames around it to record.
1184
861
  */
1185
862
  let bodyStarts = 0;
1186
- const startsAt = parens ? [0] : void 0;
863
+ const startsAt = braces || parens ? [0] : void 0;
1187
864
  /** A `(…)` or `[…]` frame result found in `parens`, to skip to. */
1188
865
  let parenTo;
866
+ /** The result of the frame at index `k`, closing at `i`. */
867
+ const closedAt = (k) => frameEnd(i, startsAt !== void 0 && bodyStarts !== startsAt[k]);
868
+ /**
869
+ * Go on after a frame an earlier scan lexed, whose result (`frameEnd`) is
870
+ * `result`: returns the index it closes at. A body started in it is gone
871
+ * after it, with the one it replaced.
872
+ */
873
+ function resume(result) {
874
+ if (result % 2 === 1) {
875
+ pendingBody = void 0;
876
+ bodyStarts++;
877
+ }
878
+ return Math.floor(result / 2);
879
+ }
1189
880
  /** The closer `c` looked for its frame and found index `k`. */
1190
881
  function lookedFor(c, k) {
1191
882
  if (!lowest) return;
@@ -1333,8 +1024,8 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1333
1024
  if (lowest) {
1334
1025
  for (let j = frames.length - 1; j >= k; j--) foldLowest(j);
1335
1026
  lowest.length = k;
1336
- startsAt.length = k;
1337
1027
  }
1028
+ if (startsAt) startsAt.length = k;
1338
1029
  frames.length = k;
1339
1030
  if (stackIds) stackIds.length = k;
1340
1031
  for (const list of Object.values(closers)) while (list.length && list[list.length - 1] >= k) list.pop();
@@ -1411,6 +1102,18 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1411
1102
  stmtNext = f.kind === "block";
1412
1103
  keyNext = f.kind === "object" || f.kind === "class";
1413
1104
  }
1105
+ /**
1106
+ * Go past frame `f` if an earlier scan lexed it: to just after it, or to
1107
+ * the end of the source when it never closes. Whether it did, or
1108
+ * MALFORMED when that scan found the code malformed.
1109
+ */
1110
+ function skipLexed(f) {
1111
+ const end = known(f);
1112
+ if (end === void 0) return false;
1113
+ if (end === MALFORMED) return MALFORMED;
1114
+ i = end === UNCLOSED ? src.length : resume(end) + 1;
1115
+ return true;
1116
+ }
1414
1117
  function openBrace() {
1415
1118
  let f;
1416
1119
  if (pendingBody?.depth === frames.length) {
@@ -1454,10 +1157,10 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1454
1157
  return;
1455
1158
  }
1456
1159
  const closed = frames[k];
1160
+ const result = closedAt(k);
1457
1161
  if (lowest && c !== "}") {
1458
1162
  for (let j = frames.length - 1; j > k; j--) foldLowest(j);
1459
- const bodyStarted = bodyStarts !== startsAt[k];
1460
- recordParen(k, i * 2 + +bodyStarted);
1163
+ recordParen(k, result);
1461
1164
  }
1462
1165
  truncate(k);
1463
1166
  if (c === "]" && !ctx.lookahead) ctx.brackets.set(closed.open, i);
@@ -1466,7 +1169,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1466
1169
  stmtNext = closed.header;
1467
1170
  } else if (c === "]") endPunct(c, false);
1468
1171
  else {
1469
- record(closed, i);
1172
+ record(closed, result);
1470
1173
  afterBrace(closed);
1471
1174
  }
1472
1175
  }
@@ -1568,23 +1271,16 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1568
1271
  } else if (ch === "`") {
1569
1272
  literal(ch, i);
1570
1273
  if (frames.length === 1) return i + 1;
1571
- record(top(), i);
1274
+ record(top(), closedAt(frames.length - 1));
1572
1275
  i++;
1573
1276
  truncate(frames.length - 1);
1574
1277
  endOperand();
1575
1278
  } else if (ch === "$" && src.charAt(i + 1) === "{") {
1576
1279
  const f = frame("expr", "}", i);
1577
1280
  f.interpolation = true;
1578
- const end = known(f);
1579
- if (end === MALFORMED) return malformed();
1580
- if (end === UNCLOSED) {
1581
- i = src.length;
1582
- break;
1583
- }
1584
- if (end !== void 0) {
1585
- i = end + 1;
1586
- continue;
1587
- }
1281
+ const skipped = skipLexed(f);
1282
+ if (skipped === MALFORMED) return malformed();
1283
+ if (skipped) continue;
1588
1284
  literal("${", i);
1589
1285
  i += 2;
1590
1286
  nesting++;
@@ -1611,14 +1307,9 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1611
1307
  if (ch === "`") {
1612
1308
  endWord();
1613
1309
  const f = frame("template", "`", i);
1614
- const end = known(f);
1615
- if (end === MALFORMED) return malformed();
1616
- if (end === UNCLOSED) {
1617
- i = src.length;
1618
- break;
1619
- }
1620
- if (end !== void 0) {
1621
- i = end + 1;
1310
+ const skipped = skipLexed(f);
1311
+ if (skipped === MALFORMED) return malformed();
1312
+ if (skipped) {
1622
1313
  endOperand();
1623
1314
  continue;
1624
1315
  }
@@ -1637,7 +1328,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1637
1328
  lookedFor("}", k);
1638
1329
  if (frames[k].interpolation) {
1639
1330
  endWord();
1640
- record(frames[k], i);
1331
+ record(frames[k], closedAt(k));
1641
1332
  truncate(k);
1642
1333
  nesting--;
1643
1334
  literal(ch, i);
@@ -1719,7 +1410,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1719
1410
  continue;
1720
1411
  }
1721
1412
  }
1722
- if (ch === outer.closer && frames.length === 1) break;
1413
+ if (ch === outer.closer && (frames.length === 1 || ch === "]" && innermost("]") === 0 && innermost("}") === 0)) break;
1723
1414
  trackCode(ch);
1724
1415
  if (skipTo) {
1725
1416
  const { frame: skipped, end } = skipTo;
@@ -1729,7 +1420,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1729
1420
  i = src.length;
1730
1421
  break;
1731
1422
  }
1732
- i = end;
1423
+ i = resume(end);
1733
1424
  afterBrace(skipped);
1734
1425
  i++;
1735
1426
  continue;
@@ -1747,8 +1438,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1747
1438
  strict.stopped = true;
1748
1439
  return STOPPED - result;
1749
1440
  }
1750
- i = Math.floor(result / 2);
1751
- if (result % 2 === 1) pendingBody = void 0;
1441
+ i = resume(result);
1752
1442
  if (skipped.closer === ")") {
1753
1443
  endPunct(")", skipped.header);
1754
1444
  stmtNext = skipped.header;
@@ -2602,9 +2292,46 @@ function tokenize(input, options = {}) {
2602
2292
  return tokens;
2603
2293
  }
2604
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
2605
2332
  //#region src/markup/ast.ts
2606
2333
  /** Macros that require a closing tag and can contain children */
2607
- var BLOCK_MACROS = new Set([
2334
+ var BLOCK_MACROS = new NameSet([
2608
2335
  "if",
2609
2336
  "for",
2610
2337
  "do",
@@ -2622,7 +2349,7 @@ var BLOCK_MACROS = new Set([
2622
2349
  ]);
2623
2350
  /** Register a custom macro as a block macro so the AST builder nests children. */
2624
2351
  function registerBlockMacro(name) {
2625
- BLOCK_MACROS.add(name.toLowerCase());
2352
+ BLOCK_MACROS.add(name);
2626
2353
  }
2627
2354
  /** Map from branch macro name → required parent macro name */
2628
2355
  var BRANCH_PARENT = {
@@ -3016,15 +2743,15 @@ function countQueries(counts) {
3016
2743
  all: (...names) => names.every((n) => count(n) > 0)
3017
2744
  };
3018
2745
  }
3019
- var cachedFns = null;
3020
- var cachedVisitCounts = null;
3021
- var cachedRenderCounts = null;
3022
- function buildExpressionFns() {
3023
- const { visitCounts, renderCounts } = useStoryStore.getState();
3024
- if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
3025
- const visits = countQueries(() => visitCounts);
3026
- const renders = countQueries(() => renderCounts);
3027
- 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 {
3028
2755
  currentPassage,
3029
2756
  previousPassage,
3030
2757
  visited: visits.count,
@@ -3034,7 +2761,17 @@ function buildExpressionFns() {
3034
2761
  rendered: renders.count,
3035
2762
  hasRendered: renders.has,
3036
2763
  hasRenderedAny: renders.any,
3037
- 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),
3038
2775
  random,
3039
2776
  randomInt
3040
2777
  };
@@ -3055,57 +2792,61 @@ function namespaceArg(ns) {
3055
2792
  Object.setPrototypeOf(ns, null);
3056
2793
  return ns;
3057
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
+ }
3058
2799
  /**
3059
2800
  * Evaluate an expression and return its value.
3060
2801
  * e.g. evaluate("$health + 10", variables, temporary) → number
3061
2802
  */
3062
- function evaluate(expr, variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE) {
2803
+ function evaluate(expr, ...namespaces) {
3063
2804
  const body = `return (${transform(expr)}\n);`;
3064
- return getOrCompile(body, body)(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2805
+ return run(getOrCompile(body, body), ...namespaces);
3065
2806
  }
3066
2807
  /**
3067
2808
  * Execute statements (no return value).
3068
2809
  * e.g. execute("$health = 100; $name = 'Hero'", variables, temporary)
3069
2810
  */
3070
- function execute(code, variables, temporary, locals = EMPTY_NAMESPACE, transient = EMPTY_NAMESPACE) {
2811
+ function execute(code, ...namespaces) {
3071
2812
  const transformed = transform(code, "statements");
3072
- getOrCompile("exec:" + transformed, transformed)(namespaceArg(variables), namespaceArg(temporary), namespaceArg(locals), buildExpressionFns(), namespaceArg(transient));
2813
+ run(getOrCompile("exec:" + transformed, transformed), ...namespaces);
3073
2814
  }
3074
2815
  //#endregion
3075
2816
  //#region src/registry.ts
3076
- var registry = /* @__PURE__ */ new Map();
2817
+ var registry = new NameMap();
3077
2818
  function registerMacro(name, component) {
3078
- registry.set(name.toLowerCase(), component);
2819
+ registry.set(name, component);
3079
2820
  }
3080
2821
  function getMacro(name) {
3081
- return registry.get(name.toLowerCase());
2822
+ return registry.get(name);
3082
2823
  }
3083
- var textRegistry = /* @__PURE__ */ new Map();
2824
+ var textRegistry = new NameMap();
3084
2825
  /** Register (or, with undefined, remove) the text form of a macro. */
3085
2826
  function registerMacroText(name, text) {
3086
- if (text) textRegistry.set(name.toLowerCase(), text);
3087
- else textRegistry.delete(name.toLowerCase());
2827
+ if (text) textRegistry.set(name, text);
2828
+ else textRegistry.delete(name);
3088
2829
  }
3089
2830
  function getMacroText(name) {
3090
- return textRegistry.get(name.toLowerCase());
2831
+ return textRegistry.get(name);
3091
2832
  }
3092
- var subMacros = /* @__PURE__ */ new Set();
2833
+ var subMacros = new NameSet();
3093
2834
  function registerSubMacro(name) {
3094
- subMacros.add(name.toLowerCase());
2835
+ subMacros.add(name);
3095
2836
  }
3096
2837
  function isSubMacro(name) {
3097
- return subMacros.has(name.toLowerCase());
2838
+ return subMacros.has(name);
3098
2839
  }
3099
- var metadataRegistry = /* @__PURE__ */ new Map();
2840
+ var metadataRegistry = new NameMap();
3100
2841
  function registerMacroMetadata(name, metadata) {
3101
- metadataRegistry.set(name.toLowerCase(), metadata);
2842
+ metadataRegistry.set(name, metadata);
3102
2843
  }
3103
2844
  function getMacroRegistry() {
3104
2845
  return Array.from(metadataRegistry.values());
3105
2846
  }
3106
2847
  //#endregion
3107
2848
  //#region src/widgets/widget-registry.ts
3108
- var widgets = /* @__PURE__ */ new Map();
2849
+ var widgets = new NameMap();
3109
2850
  /**
3110
2851
  * Register a widget. Its `@` parameters become locals of its body, so one
3111
2852
  * that no namespace can hold (`@__proto__`) throws a TypeError and the
@@ -3114,14 +2855,14 @@ var widgets = /* @__PURE__ */ new Map();
3114
2855
  function registerWidget(name, bodyAST, params, isBlock = false) {
3115
2856
  const filteredParams = params.filter((p) => p !== "@children");
3116
2857
  for (const param of filteredParams) if (param.startsWith("@")) checkVariableName(param.slice(1), param);
3117
- widgets.set(name.toLowerCase(), {
2858
+ widgets.set(name, {
3118
2859
  body: bodyAST,
3119
2860
  params: filteredParams,
3120
2861
  isBlock
3121
2862
  });
3122
2863
  }
3123
2864
  function getWidget(name) {
3124
- return widgets.get(name.toLowerCase());
2865
+ return widgets.get(name);
3125
2866
  }
3126
2867
  //#endregion
3127
2868
  //#region src/components/macros/arg-utils.ts
@@ -3288,6 +3029,23 @@ function splitArgs(raw) {
3288
3029
  return args;
3289
3030
  }
3290
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
3291
3049
  //#region src/interpolation.ts
3292
3050
  /**
3293
3051
  * Text-only markup: HTML attribute values, image alt text and link titles,
@@ -3356,9 +3114,6 @@ function mapTextNodes(nodes, fn) {
3356
3114
  }
3357
3115
  /** Deep enough for any real nesting; stops a widget that includes itself. */
3358
3116
  var MAX_DEPTH = 100;
3359
- function display(value) {
3360
- return value == null ? "" : String(value);
3361
- }
3362
3117
  /**
3363
3118
  * The value of a variable reference (`name` without its sigil, with an
3364
3119
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -3376,6 +3131,10 @@ function resolveVariable(scope, kind, name) {
3376
3131
  }
3377
3132
  return value;
3378
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
+ }
3379
3138
  var TextEvaluator = class {
3380
3139
  constructor() {
3381
3140
  this.errors = [];
@@ -3385,9 +3144,6 @@ var TextEvaluator = class {
3385
3144
  for (const node of nodes) text += this.node(node, scope, depth);
3386
3145
  return text;
3387
3146
  }
3388
- evaluate(expr, scope) {
3389
- return evaluate(expr, scope.variables, scope.temporary, scope.locals, scope.transient);
3390
- }
3391
3147
  node(node, scope, depth) {
3392
3148
  switch (node.type) {
3393
3149
  case "text": return node.value;
@@ -3399,7 +3155,7 @@ var TextEvaluator = class {
3399
3155
  }
3400
3156
  return this.variable(node.scope, node.name, scope);
3401
3157
  case "expression": try {
3402
- return display(this.evaluate(node.expression, scope));
3158
+ return display(evaluateIn(node.expression, scope));
3403
3159
  } catch (error) {
3404
3160
  this.errors.push({
3405
3161
  macro: "expression",
@@ -3449,7 +3205,7 @@ var TextEvaluator = class {
3449
3205
  let value;
3450
3206
  const expr = argExprs[i];
3451
3207
  if (expr !== void 0) try {
3452
- value = this.evaluate(expr, scope);
3208
+ value = evaluateIn(expr, scope);
3453
3209
  } catch {
3454
3210
  value = void 0;
3455
3211
  }
@@ -3471,7 +3227,7 @@ var TextEvaluator = class {
3471
3227
  return "";
3472
3228
  }
3473
3229
  const ctx = {
3474
- evaluate: (expr) => this.evaluate(expr, scope),
3230
+ evaluate: (expr) => evaluateIn(expr, scope),
3475
3231
  renderText: (nodes, locals) => this.nested(node.name, nodes, scope, depth, locals ? { locals: withLocals(scope.locals, locals) } : {})
3476
3232
  };
3477
3233
  try {
@@ -3548,7 +3304,7 @@ function interpolateCode(template, scope, decode) {
3548
3304
  });
3549
3305
  }
3550
3306
  } else try {
3551
- text += display(evaluate(part.expr, scope.variables, scope.temporary, scope.locals, scope.transient));
3307
+ text += display(evaluateIn(part.expr, scope));
3552
3308
  } catch (error) {
3553
3309
  errors.push({
3554
3310
  macro: "expression",
@@ -3614,29 +3370,26 @@ function useInterpolate() {
3614
3370
  }
3615
3371
  //#endregion
3616
3372
  //#region src/components/macros/VarDisplay.tsx
3617
- var SIGILS = {
3618
- variable: "$",
3619
- temporary: "_",
3620
- local: "@",
3621
- transient: "%"
3622
- };
3623
3373
  var STORE_NAMESPACES = {
3624
3374
  variable: "variables",
3625
3375
  temporary: "temporary",
3626
3376
  transient: "transient"
3627
3377
  };
3628
- function VarDisplay({ name, scope, className, id }) {
3378
+ /** A `{$name}` (`{_name}`, `{@name}`, `{%name}`) variable display. */
3379
+ function VarDisplay({ node }) {
3380
+ const { name, scope } = node;
3629
3381
  const resolve = useInterpolate();
3630
- className = resolve(className);
3631
- id = resolve(id);
3382
+ const className = resolve(node.className);
3383
+ const id = resolve(node.id);
3632
3384
  const localsValues = useContext(LocalsValuesContext);
3633
3385
  const parts = name.split(".");
3634
3386
  const root = parts[0];
3635
3387
  const storeValue = useStoryStore((s) => scope === "local" ? void 0 : ownValue(s[STORE_NAMESPACES[scope]], root));
3636
- const nameError = variableNameError(root, SIGILS[scope] + root);
3388
+ const sigil = SCOPE_SIGILS[scope];
3389
+ const nameError = variableNameError(root, sigil + root);
3637
3390
  if (nameError) return /* @__PURE__ */ jsx("span", {
3638
3391
  class: "error",
3639
- children: `{${SIGILS[scope]}${name} error: ${nameError}}`
3392
+ children: `{${sigil}${name} error: ${nameError}}`
3640
3393
  });
3641
3394
  let value;
3642
3395
  if (scope === "local") value = ownValue(localsValues, root);
@@ -3649,34 +3402,24 @@ function VarDisplay({ name, scope, className, id }) {
3649
3402
  const part = parts[i];
3650
3403
  value = value[part];
3651
3404
  }
3652
- const display = value == null ? "" : String(value);
3653
- if (className || id) return /* @__PURE__ */ jsx("span", {
3654
- id,
3655
- class: className,
3656
- children: display
3657
- });
3658
- return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3405
+ return wrapContent(className, id, display(value));
3659
3406
  }
3660
3407
  //#endregion
3661
3408
  //#region src/components/macros/ExprDisplay.tsx
3662
- function ExprDisplay({ expression, className, id }) {
3409
+ /** An expression display: `{$hp + 1}`. */
3410
+ function ExprDisplay({ node }) {
3411
+ const { expression } = node;
3663
3412
  const resolve = useInterpolate();
3664
- className = resolve(className);
3665
- id = resolve(id);
3413
+ const className = resolve(node.className);
3414
+ const id = resolve(node.id);
3666
3415
  const [variables, temporary, localsValues, transient] = useMergedLocals();
3667
- let display;
3416
+ let text;
3668
3417
  try {
3669
- const value = evaluate(expression, variables, temporary, localsValues, transient);
3670
- display = value == null ? "" : String(value);
3418
+ text = display(evaluate(expression, variables, temporary, localsValues, transient));
3671
3419
  } catch {
3672
- display = `{error: ${expression}}`;
3420
+ text = `{error: ${expression}}`;
3673
3421
  }
3674
- if (className || id) return /* @__PURE__ */ jsx("span", {
3675
- id,
3676
- class: className,
3677
- children: display
3678
- });
3679
- return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3422
+ return wrapContent(className, id, text);
3680
3423
  }
3681
3424
  //#endregion
3682
3425
  //#region src/hooks/use-render-options.ts
@@ -4411,17 +4154,9 @@ function HtmlNodeRenderer({ node }) {
4411
4154
  }
4412
4155
  function ChildrenSlot() {
4413
4156
  const childrenAST = useContext(WidgetChildrenContext);
4414
- const nobr = useContext(NobrContext);
4415
- const inline = useContext(InlineContext);
4416
- const raw = useContext(RawTextContext);
4417
- const locals = useContext(LocalsValuesContext);
4157
+ const renderOptions = useRenderOptions();
4418
4158
  if (!childrenAST || childrenAST.length === 0) return null;
4419
- return /* @__PURE__ */ jsx(Fragment$1, { children: renderNodes(childrenAST, {
4420
- nobr,
4421
- locals,
4422
- inline,
4423
- raw
4424
- }) });
4159
+ return /* @__PURE__ */ jsx(Fragment$1, { children: renderNodes(childrenAST, renderOptions) });
4425
4160
  }
4426
4161
  /**
4427
4162
  * Stable per-node keys. Keying rendered children by AST node identity (not
@@ -4471,17 +4206,8 @@ function renderSingleNode(node) {
4471
4206
  switch (node.type) {
4472
4207
  case "variable":
4473
4208
  if (node.scope === "local" && node.name === "children") return /* @__PURE__ */ jsx(ChildrenSlot, {}, key);
4474
- return /* @__PURE__ */ jsx(VarDisplay, {
4475
- name: node.name,
4476
- scope: node.scope,
4477
- className: node.className,
4478
- id: node.id
4479
- }, key);
4480
- case "expression": return /* @__PURE__ */ jsx(ExprDisplay, {
4481
- expression: node.expression,
4482
- className: node.className,
4483
- id: node.id
4484
- }, key);
4209
+ return /* @__PURE__ */ jsx(VarDisplay, { node }, key);
4210
+ case "expression": return /* @__PURE__ */ jsx(ExprDisplay, { node }, key);
4485
4211
  case "macro": return renderMacro(node, key);
4486
4212
  case "html": return /* @__PURE__ */ jsx(HtmlNodeRenderer, { node }, key);
4487
4213
  default: return node;
@@ -4762,6 +4488,24 @@ function createLocalStorageBackend() {
4762
4488
  }
4763
4489
  var IDB_DB_NAME = "spindle";
4764
4490
  var IDB_DB_VERSION = 1;
4491
+ /** Each object store's key path, and the key path of each of its indexes. */
4492
+ var IDB_STORES = {
4493
+ saves: {
4494
+ keyPath: "meta.id",
4495
+ indexes: {
4496
+ ifid: "meta.ifid",
4497
+ playthroughId: "meta.playthroughId"
4498
+ }
4499
+ },
4500
+ playthroughs: {
4501
+ keyPath: "id",
4502
+ indexes: { ifid: "ifid" }
4503
+ },
4504
+ meta: {
4505
+ keyPath: "key",
4506
+ indexes: {}
4507
+ }
4508
+ };
4765
4509
  function createIDBBackend() {
4766
4510
  let dbPromise = null;
4767
4511
  function openDB() {
@@ -4770,13 +4514,11 @@ function createIDBBackend() {
4770
4514
  const req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION);
4771
4515
  req.onupgradeneeded = () => {
4772
4516
  const db = req.result;
4773
- if (!db.objectStoreNames.contains("saves")) {
4774
- const savesStore = db.createObjectStore("saves", { keyPath: "meta.id" });
4775
- savesStore.createIndex("ifid", "meta.ifid", { unique: false });
4776
- savesStore.createIndex("playthroughId", "meta.playthroughId", { unique: false });
4517
+ for (const [name, { keyPath, indexes }] of Object.entries(IDB_STORES)) {
4518
+ if (db.objectStoreNames.contains(name)) continue;
4519
+ const store = db.createObjectStore(name, { keyPath });
4520
+ for (const [index, path] of Object.entries(indexes)) store.createIndex(index, path, { unique: false });
4777
4521
  }
4778
- if (!db.objectStoreNames.contains("playthroughs")) db.createObjectStore("playthroughs", { keyPath: "id" }).createIndex("ifid", "ifid", { unique: false });
4779
- if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "key" });
4780
4522
  };
4781
4523
  req.onsuccess = () => resolve(req.result);
4782
4524
  req.onerror = () => reject(req.error);
@@ -4853,31 +4595,363 @@ async function detectBackend() {
4853
4595
  return createMemoryBackend();
4854
4596
  }
4855
4597
  /**
4856
- * Detection in flight. Concurrent first callers share it so they all get the
4857
- * same backend (the memory fallback makes a new, empty store per detection).
4598
+ * Detection in flight. Concurrent first callers share it so they all get the
4599
+ * same backend (the memory fallback makes a new, empty store per detection).
4600
+ */
4601
+ var _backendPromise = null;
4602
+ function getBackend() {
4603
+ if (_backend) return Promise.resolve(_backend);
4604
+ if (!_backendPromise) {
4605
+ const pending = detectBackend().then((backend) => {
4606
+ if (_backendPromise === pending) _backend = backend;
4607
+ return backend;
4608
+ });
4609
+ _backendPromise = pending;
4610
+ }
4611
+ return _backendPromise;
4612
+ }
4613
+ function resetBackend() {
4614
+ _backend = null;
4615
+ _backendPromise = null;
4616
+ }
4617
+ /**
4618
+ * Synchronous accessor for the current backend type.
4619
+ * Returns 'memory' if the backend hasn't been detected yet.
4620
+ */
4621
+ function getBackendType() {
4622
+ return _backend?.type ?? "memory";
4623
+ }
4624
+ //#endregion
4625
+ //#region src/utils/object-path.ts
4626
+ /**
4627
+ * Traverse dot-path segments on an object and return the nested value.
4628
+ * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
4629
+ * are not story state: unless an object holds one as its own property, it
4630
+ * reads as missing, as setByPath() treats it. Other inherited properties
4631
+ * (class getters, `size` of a Map) are read.
4632
+ */
4633
+ function getByPath(obj, segments) {
4634
+ let current = obj;
4635
+ for (const seg of segments) {
4636
+ if (current == null || typeof current !== "object") return void 0;
4637
+ if (seg in Object.prototype && !hasOwn(current, seg)) return void 0;
4638
+ current = current[seg];
4639
+ }
4640
+ return current;
4641
+ }
4642
+ /** Array indices ("0", "1", …) and "length": the keys an array holds. */
4643
+ var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
4644
+ /**
4645
+ * Built-ins whose content is not their properties: a property written into
4646
+ * one would be dropped by clones and saves (and by Immer, for Map and Set).
4647
+ */
4648
+ var builtinName = (value) => value instanceof Map ? "a Map" : value instanceof Set ? "a Set" : value instanceof Date ? "a Date" : value instanceof RegExp ? "a RegExp" : void 0;
4649
+ /**
4650
+ * Shallow copy that keeps the prototype, so a registered class instance
4651
+ * stays an instance of its class (with the same own keys deepClone copies).
4652
+ */
4653
+ function shallowCopy(value) {
4654
+ const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
4655
+ for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
4656
+ value: value[key],
4657
+ enumerable: true,
4658
+ writable: true,
4659
+ configurable: true
4660
+ });
4661
+ return copy;
4662
+ }
4663
+ /**
4664
+ * Set a value at dot-path `segments` below `root`.
4665
+ *
4666
+ * When `root` is an Immer draft, objects Immer does not draft (registered
4667
+ * class instances and everything below them) are copied on the way down and
4668
+ * the copy is assigned to its parent, so the write produces new references
4669
+ * up to the root. Writing into such an object directly would mutate the
4670
+ * instance shared with the previous state and with history, and subscribers
4671
+ * would see no change. Outside a draft (a private working copy) the path is
4672
+ * written in place.
4673
+ *
4674
+ * The path goes through own properties of plain objects, class instances
4675
+ * and arrays (by index); anything else throws a TypeError rather than
4676
+ * writing where no clone or save would see it: an inherited property such
4677
+ * as a method counts as missing, and Map, Set, Date and RegExp values and
4678
+ * other keys of arrays are refused, as is a "__proto__" segment.
4679
+ */
4680
+ function setByPath(root, segments, value, options = {}) {
4681
+ const parent = walkToParent(root, segments, options.createMissing ?? false);
4682
+ parent[segments[segments.length - 1]] = value;
4683
+ }
4684
+ /**
4685
+ * Delete the property at dot-path `segments` below `root`, copying
4686
+ * undrafted objects on the way down like setByPath(). Does nothing when an
4687
+ * intermediate is missing or not an object, or the property is absent (or
4688
+ * only inherited).
4689
+ */
4690
+ function deleteByPath(root, segments) {
4691
+ const last = segments[segments.length - 1];
4692
+ checkSegments(segments);
4693
+ let holder = root;
4694
+ for (const seg of segments.slice(0, -1)) {
4695
+ if (holder == null || typeof holder !== "object" || !hasOwn(holder, seg)) return;
4696
+ holder = holder[seg];
4697
+ }
4698
+ if (holder == null || typeof holder !== "object" || !hasOwn(holder, last)) return;
4699
+ const parent = walkToParent(root, segments, false);
4700
+ delete parent[last];
4701
+ }
4702
+ /** Refuse a segment that would reach a prototype instead of story state. */
4703
+ function checkSegments(segments) {
4704
+ if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
4705
+ }
4706
+ /** Throw unless `holder` can take `key` as story state (see setByPath). */
4707
+ function checkHolder(holder, key, segments, depth) {
4708
+ const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
4709
+ if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
4710
+ }
4711
+ /**
4712
+ * Walk to the object holding the last of `segments`, copying undrafted
4713
+ * objects when `root` is an Immer draft (see setByPath). A missing or
4714
+ * non-object intermediate is replaced by a plain object when
4715
+ * `createMissing` is set, and throws a TypeError otherwise.
4716
+ */
4717
+ function walkToParent(root, segments, createMissing) {
4718
+ checkSegments(segments);
4719
+ const copyOnWrite = isDraft(root);
4720
+ let current = root;
4721
+ for (let i = 0; i < segments.length - 1; i++) {
4722
+ const seg = segments[i];
4723
+ checkHolder(current, seg, segments, i);
4724
+ let next = hasOwn(current, seg) ? current[seg] : void 0;
4725
+ if (next == null || typeof next !== "object") {
4726
+ 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(".")}")`);
4727
+ next = {};
4728
+ current[seg] = next;
4729
+ } else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
4730
+ next = shallowCopy(next);
4731
+ current[seg] = next;
4732
+ }
4733
+ current = next;
4734
+ }
4735
+ checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
4736
+ return current;
4737
+ }
4738
+ //#endregion
4739
+ //#region src/structural.ts
4740
+ function isPlainObject(value) {
4741
+ if (typeof value !== "object" || value === null) return false;
4742
+ const proto = Object.getPrototypeOf(value);
4743
+ return proto === Object.prototype || proto === null;
4744
+ }
4745
+ function deepClone(value, options = {}) {
4746
+ const seen = /* @__PURE__ */ new Map();
4747
+ function clone(val) {
4748
+ if (val === null || typeof val !== "object") return val;
4749
+ const obj = val;
4750
+ if (seen.has(obj)) return seen.get(obj);
4751
+ if (val instanceof Date) return new Date(val.getTime());
4752
+ if (val instanceof RegExp) return new RegExp(val.source, val.flags);
4753
+ if (Array.isArray(val)) {
4754
+ const arr = [];
4755
+ seen.set(obj, arr);
4756
+ for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
4757
+ return arr;
4758
+ }
4759
+ if (val instanceof Map) {
4760
+ const copy = /* @__PURE__ */ new Map();
4761
+ seen.set(obj, copy);
4762
+ for (const [k, v] of val) copy.set(clone(k), clone(v));
4763
+ return copy;
4764
+ }
4765
+ if (val instanceof Set) {
4766
+ const copy = /* @__PURE__ */ new Set();
4767
+ seen.set(obj, copy);
4768
+ for (const v of val) copy.add(clone(v));
4769
+ return copy;
4770
+ }
4771
+ const keepsProto = registeredClassName(obj) !== void 0 || isPlainObject(val);
4772
+ if (!keepsProto && options.keepUnregistered) return val;
4773
+ const copy = keepsProto ? Object.create(Object.getPrototypeOf(obj)) : {};
4774
+ seen.set(obj, copy);
4775
+ for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
4776
+ return copy;
4777
+ }
4778
+ return clone(value);
4779
+ }
4780
+ /**
4781
+ * Structural equality over the value types deepClone() supports: primitives,
4782
+ * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
4783
+ * at any depth). Map and Set entries are compared in insertion order. Arrays
4784
+ * are compared by length and index, so a hole equals an undefined element
4785
+ * (deepClone() and save/load turn holes into undefined elements).
4786
+ */
4787
+ function deepEqual(a, b) {
4788
+ return equal(a, b, /* @__PURE__ */ new Map());
4789
+ }
4790
+ /**
4791
+ * `assumed` holds the pairs already being compared: meeting one again (a
4792
+ * cycle) assumes it equal. Every object may pair with several others, since
4793
+ * cycles of different lengths can still unfold to the same value.
4794
+ */
4795
+ function equal(a, b, assumed) {
4796
+ if (Object.is(a, b)) return true;
4797
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4798
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
4799
+ const pairs = assumed.get(a);
4800
+ if (pairs?.has(b)) return true;
4801
+ if (pairs) pairs.add(b);
4802
+ else assumed.set(a, new Set([b]));
4803
+ if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
4804
+ if (a instanceof RegExp) return String(a) === String(b);
4805
+ if (a instanceof Map || a instanceof Set) {
4806
+ const bc = b;
4807
+ if (a.size !== bc.size) return false;
4808
+ const ai = a.entries();
4809
+ const bi = bc.entries();
4810
+ for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!equal(x.value, y.value, assumed)) return false;
4811
+ return true;
4812
+ }
4813
+ if (Array.isArray(a)) {
4814
+ const bc = b;
4815
+ if (a.length !== bc.length) return false;
4816
+ for (let i = 0; i < a.length; i++) if (!equal(a[i], bc[i], assumed)) return false;
4817
+ return true;
4818
+ }
4819
+ const ao = a;
4820
+ const bo = b;
4821
+ const keys = Object.keys(ao);
4822
+ if (keys.length !== Object.keys(bo).length) return false;
4823
+ for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
4824
+ return true;
4825
+ }
4826
+ /**
4827
+ * Objects merged property by property: plain objects and (registered)
4828
+ * class instances. Arrays, Map, Set, Date and RegExp are values that are
4829
+ * replaced as a whole, since their elements have no stable identity to
4830
+ * merge by (a shift moves every index).
4831
+ */
4832
+ function isMergeable(value) {
4833
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
4834
+ }
4835
+ /**
4836
+ * Whether changes between `a` and `b` merge key by key: both are objects of
4837
+ * one class (see isMergeable), or, with `arrays`, both are arrays.
4858
4838
  */
4859
- var _backendPromise = null;
4860
- function getBackend() {
4861
- if (_backend) return Promise.resolve(_backend);
4862
- if (!_backendPromise) {
4863
- const pending = detectBackend().then((backend) => {
4864
- if (_backendPromise === pending) _backend = backend;
4865
- return backend;
4839
+ function mergesWith(a, b, arrays) {
4840
+ if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
4841
+ return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
4842
+ }
4843
+ /**
4844
+ * The keys at which `after` differs from `before`, two values that merge
4845
+ * (see mergesWith): keys added, deleted, or holding another value. Store
4846
+ * updates are immutable, so a value that kept its identity is no change,
4847
+ * and one rebuilt with equal content (mutation code commits whole values)
4848
+ * is none either. Values that merge are reported as 'nested', to be
4849
+ * compared key by key; they may still be equal. With `arrays`, arrays
4850
+ * merge too: elements at the indices both hold are compared, and the
4851
+ * elements one has beyond the other's length are added or deleted.
4852
+ */
4853
+ function keyChanges(before, after, arrays) {
4854
+ const changes = [];
4855
+ const compare = (key, b, a) => {
4856
+ if (Object.is(b, a)) return;
4857
+ if (mergesWith(b, a, arrays)) changes.push({
4858
+ key,
4859
+ kind: "nested",
4860
+ before: b,
4861
+ after: a
4866
4862
  });
4867
- _backendPromise = pending;
4863
+ else if (!deepEqual(b, a)) changes.push({
4864
+ key,
4865
+ kind: "changed",
4866
+ after: a
4867
+ });
4868
+ };
4869
+ if (Array.isArray(after)) {
4870
+ const b = before;
4871
+ const a = after;
4872
+ for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
4873
+ for (let i = b.length; i < a.length; i++) changes.push({
4874
+ key: String(i),
4875
+ kind: "added",
4876
+ after: a[i]
4877
+ });
4878
+ for (let i = a.length; i < b.length; i++) changes.push({
4879
+ key: String(i),
4880
+ kind: "deleted"
4881
+ });
4882
+ return changes;
4868
4883
  }
4869
- return _backendPromise;
4884
+ for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
4885
+ else changes.push({
4886
+ key,
4887
+ kind: "added",
4888
+ after: after[key]
4889
+ });
4890
+ for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
4891
+ key,
4892
+ kind: "deleted"
4893
+ });
4894
+ return changes;
4870
4895
  }
4871
- function resetBackend() {
4872
- _backend = null;
4873
- _backendPromise = null;
4896
+ /**
4897
+ * The property paths where `after` differs from `before`, two objects of
4898
+ * one class: objects that merge (see isMergeable) are compared property by
4899
+ * property, any other value as a whole.
4900
+ */
4901
+ function diffPaths(before, after) {
4902
+ const changes = [];
4903
+ const ancestors = /* @__PURE__ */ new Set();
4904
+ (function walk(b, a, path) {
4905
+ if (ancestors.has(a)) return;
4906
+ ancestors.add(a);
4907
+ for (const change of keyChanges(b, a, false)) {
4908
+ const at = [...path, change.key];
4909
+ if (change.kind === "nested") walk(change.before, change.after, at);
4910
+ else changes.push(change.kind === "deleted" ? {
4911
+ path: at,
4912
+ deleted: true
4913
+ } : {
4914
+ path: at,
4915
+ deleted: false,
4916
+ value: change.after
4917
+ });
4918
+ }
4919
+ ancestors.delete(a);
4920
+ })(before, after, []);
4921
+ return changes;
4922
+ }
4923
+ /** Whether `target` already holds what `change` would write. */
4924
+ function isApplied(target, change) {
4925
+ const parent = getByPath(target, change.path.slice(0, -1));
4926
+ if (parent === null || typeof parent !== "object") return change.deleted;
4927
+ const key = change.path[change.path.length - 1];
4928
+ if (!hasOwn(parent, key)) return change.deleted;
4929
+ return !change.deleted && deepEqual(parent[key], change.value);
4930
+ }
4931
+ function applyChange(target, change) {
4932
+ if (change.deleted) deleteByPath(target, change.path);
4933
+ else setByPath(target, change.path, change.value);
4874
4934
  }
4875
4935
  /**
4876
- * Synchronous accessor for the current backend type.
4877
- * Returns 'memory' if the backend hasn't been detected yet.
4936
+ * Write how `after` differs from `before` (see keyChanges; arrays merge by
4937
+ * index) into `target`, another version of the same object or array.
4938
+ * Values that changed are written whole, as deep copies; for values that
4939
+ * merge, `mergeNested` may merge them into `target` instead, returning
4940
+ * whether it did. Into an array, elements removed from the end are removed
4941
+ * at the same indices and elements added are appended, and changes at
4942
+ * indices `target` lacks are dropped: where `target` was resized, its
4943
+ * indices do not line up with `before`'s.
4878
4944
  */
4879
- function getBackendType() {
4880
- return _backend?.type ?? "memory";
4945
+ function mergeKeys(target, before, after, mergeNested) {
4946
+ const list = Array.isArray(target) ? target : void 0;
4947
+ for (const change of keyChanges(before, after, true)) {
4948
+ const { key } = change;
4949
+ if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
4950
+ else delete target[key];
4951
+ else if (list && change.kind === "added") list.push(deepClone(change.after));
4952
+ else if (list && Number(key) >= list.length) continue;
4953
+ else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
4954
+ }
4881
4955
  }
4882
4956
  //#endregion
4883
4957
  //#region src/event-emitter.ts
@@ -4980,6 +5054,10 @@ function inOrder(op) {
4980
5054
  function queued(op) {
4981
5055
  return (...args) => inOrder(() => op(...args));
4982
5056
  }
5057
+ /** An operation passing the save `saveId`, if it is stored, to `read`. */
5058
+ var readingSave = (read) => queued(async (saveId) => read(await (await getBackend()).getSave(saveId)));
5059
+ /** An operation passing the save a slot holds, if any, to `read`. */
5060
+ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(ifid, slot)));
4983
5061
  /**
4984
5062
  * Store a new playthrough and make it the story's current one. Pass `id` when
4985
5063
  * the caller has already switched to the playthrough (restart() does, so
@@ -5180,7 +5258,7 @@ function deserializePayload(payload) {
5180
5258
  }
5181
5259
  /** The live payload of a stored save, if there is one. */
5182
5260
  var livePayload = (record) => record && deserializePayload(record.payload);
5183
- queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
5261
+ readingSave(livePayload);
5184
5262
  /**
5185
5263
  * Delete a save record. If the default slot or a named slot holds it, that
5186
5264
  * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
@@ -5336,7 +5414,7 @@ queued(hasQuickSaveNow);
5336
5414
  async function hasQuickSaveNow(ifid, slot) {
5337
5415
  return await slotRecord(ifid, slot) !== void 0;
5338
5416
  }
5339
- queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
5417
+ readingSlot(livePayload);
5340
5418
  /**
5341
5419
  * Read the save held in a slot (the default slot when `slot` is omitted) for
5342
5420
  * the running game to load, and make its playthrough the story's current one
@@ -5432,7 +5510,7 @@ function toExport(record) {
5432
5510
  save: record
5433
5511
  };
5434
5512
  }
5435
- var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
5513
+ var exportSave = readingSave(toExport);
5436
5514
  /**
5437
5515
  * Validate an export against the running story and build the record to store:
5438
5516
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
@@ -5460,7 +5538,7 @@ function importSave(data, ifid) {
5460
5538
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
5461
5539
  * Returns undefined if the slot is empty.
5462
5540
  */
5463
- var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
5541
+ var exportSlotSave = readingSlot(toExport);
5464
5542
  /**
5465
5543
  * Import an exported save into a slot (default autosave slot when `slot` is
5466
5544
  * omitted), replacing any save already in that slot. Applies the same
@@ -7024,7 +7102,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
7024
7102
  //#region src/components/StoryInterface.tsx
7025
7103
  var DEFAULT_MARKUP = "<header class=\"story-menubar\">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}";
7026
7104
  function StoryInterface() {
7027
- const overridePassage = useStoryStore((s) => s.storyData)?.passages.get("StoryInterface");
7105
+ const { storyData } = useStoryFields("storyData");
7106
+ const overridePassage = storyData?.passages.get("StoryInterface");
7028
7107
  const markup = overridePassage !== void 0 ? overridePassage.content : DEFAULT_MARKUP;
7029
7108
  const nobr = overridePassage?.tags.includes("nobr") ?? false;
7030
7109
  const rendered = useMemo(() => {
@@ -7124,7 +7203,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
7124
7203
  const onCloseRef = useRef(onClose);
7125
7204
  onCloseRef.current = onClose;
7126
7205
  const stableOnClose = useCallback(() => onCloseRef.current(), []);
7127
- const storyData = useStoryStore((s) => s.storyData);
7206
+ const { storyData } = useStoryFields("storyData");
7128
7207
  const passage = passageName ? storyData?.passages.get(passageName) : void 0;
7129
7208
  const markup = passage?.content ?? fallbackMarkup;
7130
7209
  const nobr = passage?.tags.includes("nobr") ?? false;
@@ -7406,6 +7485,14 @@ var settings = {
7406
7485
  };
7407
7486
  //#endregion
7408
7487
  //#region src/action-registry.ts
7488
+ /** The fields of a StoryAction that are left out when unset. */
7489
+ var OPTIONAL_ACTION_FIELDS = [
7490
+ "target",
7491
+ "variable",
7492
+ "options",
7493
+ "value",
7494
+ "disabled"
7495
+ ];
7409
7496
  /**
7410
7497
  * The registrations holding each ID, oldest first; the newest is the one
7411
7498
  * listed. Generated IDs don't collide, but author IDs can, and one control's
@@ -7488,11 +7575,7 @@ function useAction(opts) {
7488
7575
  label: opts.label,
7489
7576
  perform: (...args) => performRef.current(...args)
7490
7577
  };
7491
- if (opts.target !== void 0) action.target = opts.target;
7492
- if (opts.variable !== void 0) action.variable = opts.variable;
7493
- if (opts.options !== void 0) action.options = opts.options;
7494
- if (opts.value !== void 0) action.value = opts.value;
7495
- if (opts.disabled !== void 0) action.disabled = opts.disabled;
7578
+ for (const field of OPTIONAL_ACTION_FIELDS) if (opts[field] !== void 0) Object.assign(action, { [field]: opts[field] });
7496
7579
  return action;
7497
7580
  };
7498
7581
  const registration = useRef(null);
@@ -7707,6 +7790,12 @@ function readGroup(params, src, args) {
7707
7790
  const rest = positional[restIndex];
7708
7791
  args[rest.name] = from < to ? read(rest, text.slice(spans[from][0], spans[to - 1][1])) : unset(rest);
7709
7792
  }
7793
+ /** The duration a timing macro ({repeat}, {type}, {timed}/{next}) waits. */
7794
+ var DELAY_PARAMETER = {
7795
+ name: "delay",
7796
+ type: "delay",
7797
+ required: true
7798
+ };
7710
7799
  /** Read `rawArgs` into the declared `parameters` (see above). */
7711
7800
  function parseMacroArgs(rawArgs, parameters) {
7712
7801
  const args = {};
@@ -7760,15 +7849,19 @@ function parseVarArgs(rawArgs) {
7760
7849
  placeholder
7761
7850
  };
7762
7851
  }
7852
+ /** The parameters of an `{option}` sub-macro: its value. */
7853
+ var OPTION_PARAMETERS = [{
7854
+ name: "value",
7855
+ type: "string"
7856
+ }];
7763
7857
  /**
7764
- * Walk AST children to find {option} macro nodes, returning their rawArgs as values.
7858
+ * Walk AST children to find {option} macro nodes, returning their values.
7765
7859
  */
7766
7860
  function extractOptions(children) {
7767
7861
  const options = [];
7768
7862
  for (const node of children) if (node.type === "macro" && node.name === "option") {
7769
- const raw = node.rawArgs.trim();
7770
- const stripped = readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/s, "$2");
7771
- options.push(stripped);
7863
+ const { value } = parseMacroArgs(node.rawArgs, OPTION_PARAMETERS);
7864
+ options.push(value ?? node.rawArgs.trim().replace(/^(["'])(.+)\1$/s, "$2"));
7772
7865
  }
7773
7866
  return options;
7774
7867
  }
@@ -7822,13 +7915,7 @@ function defineMacro(config, source = "builtin") {
7822
7915
  mutate: (code) => executeMutation(code, getValues(), update),
7823
7916
  update,
7824
7917
  getValues,
7825
- wrap: (content) => {
7826
- if (className || id) return h("span", {
7827
- id,
7828
- class: className
7829
- }, content);
7830
- return h(Fragment, null, content);
7831
- }
7918
+ wrap: (content) => wrapContent(className, id, content)
7832
7919
  };
7833
7920
  if (config.merged) {
7834
7921
  ctx.merged = useMergedLocals();
@@ -7959,9 +8046,11 @@ function setOne(draft, name, value) {
7959
8046
  if (key.includes(".")) setByPath(namespace, key.split("."), value);
7960
8047
  else namespace[key] = value;
7961
8048
  }
8049
+ /** A method calling the store action `name`, as the store holds it then. */
8050
+ function storeAction(name) {
8051
+ return ((...args) => useStoryStore.getState()[name](...args));
8052
+ }
7962
8053
  function createStoryAPI() {
7963
- const visits = countQueries(() => useStoryStore.getState().visitCounts);
7964
- const renders = countQueries(() => useStoryStore.getState().renderCounts);
7965
8054
  return {
7966
8055
  get(name) {
7967
8056
  const { isTransient, key } = parseName(name);
@@ -7981,52 +8070,19 @@ function createStoryAPI() {
7981
8070
  for (const [k, v] of entries) setOne(draft, k, v);
7982
8071
  });
7983
8072
  },
7984
- goto(passageName) {
7985
- useStoryStore.getState().navigate(passageName);
7986
- },
7987
- back() {
7988
- useStoryStore.getState().goBack();
7989
- },
7990
- forward() {
7991
- useStoryStore.getState().goForward();
7992
- },
7993
- restart() {
7994
- useStoryStore.getState().restart();
7995
- },
7996
- save(slot, custom) {
7997
- return useStoryStore.getState().save(slot, custom);
7998
- },
7999
- load(slot) {
8000
- return useStoryStore.getState().load(slot);
8001
- },
8002
- hasSave(slot) {
8003
- return useStoryStore.getState().hasSave(slot);
8004
- },
8005
- getSaveInfo(slot) {
8006
- return useStoryStore.getState().getSaveInfo(slot);
8007
- },
8008
- listSaves() {
8009
- return useStoryStore.getState().listSaves();
8010
- },
8011
- deleteSave(slot) {
8012
- return useStoryStore.getState().deleteSave(slot);
8013
- },
8014
- exportSave(slot) {
8015
- return useStoryStore.getState().exportSave(slot);
8016
- },
8017
- importSave(data, slot) {
8018
- return useStoryStore.getState().importSave(data, slot);
8019
- },
8020
- visited: visits.count,
8021
- hasVisited: visits.has,
8022
- hasVisitedAny: visits.any,
8023
- hasVisitedAll: visits.all,
8024
- rendered: renders.count,
8025
- hasRendered: renders.has,
8026
- hasRenderedAny: renders.any,
8027
- hasRenderedAll: renders.all,
8028
- currentPassage,
8029
- previousPassage,
8073
+ goto: storeAction("navigate"),
8074
+ back: storeAction("goBack"),
8075
+ forward: storeAction("goForward"),
8076
+ restart: storeAction("restart"),
8077
+ save: storeAction("save"),
8078
+ load: storeAction("load"),
8079
+ hasSave: storeAction("hasSave"),
8080
+ getSaveInfo: storeAction("getSaveInfo"),
8081
+ listSaves: storeAction("listSaves"),
8082
+ deleteSave: storeAction("deleteSave"),
8083
+ exportSave: storeAction("exportSave"),
8084
+ importSave: storeAction("importSave"),
8085
+ ...historyQueries(() => useStoryStore.getState().visitCounts, () => useStoryStore.getState().renderCounts),
8030
8086
  get title() {
8031
8087
  return useStoryStore.getState().storyData?.name || "";
8032
8088
  },
@@ -8072,15 +8128,9 @@ function createStoryAPI() {
8072
8128
  estimateSupported: false
8073
8129
  };
8074
8130
  },
8075
- clearGameData() {
8076
- return useStoryStore.getState().clearGameData();
8077
- },
8078
- clearAllData() {
8079
- return useStoryStore.getState().clearAllData();
8080
- },
8081
- deletePlaythrough(playthroughId) {
8082
- return useStoryStore.getState().deletePlaythrough(playthroughId);
8083
- },
8131
+ clearGameData: storeAction("clearGameData"),
8132
+ clearAllData: storeAction("clearAllData"),
8133
+ deletePlaythrough: storeAction("deletePlaythrough"),
8084
8134
  get backend() {
8085
8135
  return getBackendType();
8086
8136
  }
@@ -8488,26 +8538,51 @@ function astContainsChildren(nodes) {
8488
8538
  }
8489
8539
  //#endregion
8490
8540
  //#region src/components/macros/Widget.tsx
8491
- /**
8492
- * Read the arguments of a {widget} definition: its name (quotes dropped)
8493
- * and its parameters, the words after it that start with one of the
8494
- * characters of `sigils`.
8495
- */
8496
- function parseWidgetDef(rawArgs, sigils = "@") {
8497
- const [first, ...rest] = rawArgs.trim().split(/\s+/);
8541
+ /** A {widget} definition's name, then its `@` parameters. */
8542
+ var WIDGET_PARAMETERS = [{
8543
+ name: "name",
8544
+ type: "text",
8545
+ required: true
8546
+ }, {
8547
+ name: "parameters",
8548
+ type: "expression"
8549
+ }];
8550
+ /**
8551
+ * The widget a {widget} definition's arguments declare: its name and its
8552
+ * parameters, the words after it that start with `@` (docs/widgets.md).
8553
+ * Other words, such as `$name`, are not parameters.
8554
+ */
8555
+ function widgetDef({ name = "", parameters = "" }) {
8498
8556
  return {
8499
- name: first.replace(/["']/g, ""),
8500
- params: rest.filter((word) => sigils.includes(word[0]))
8557
+ name,
8558
+ params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
8501
8559
  };
8502
8560
  }
8561
+ /** Read the arguments of a {widget} definition (see widgetDef). */
8562
+ function parseWidgetDef(rawArgs) {
8563
+ return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
8564
+ }
8565
+ /**
8566
+ * Register the widget a definition declares, with its body. Widgets whose
8567
+ * body renders {@children} take a closing tag, so they are registered as
8568
+ * block macros too: passages parsed later nest their content. A parameter
8569
+ * that no namespace can hold throws (see registerWidget), registering
8570
+ * nothing.
8571
+ */
8572
+ function registerWidgetDef({ name, params }, body) {
8573
+ const isBlock = astContainsChildren(body);
8574
+ registerWidget(name, body, params, isBlock);
8575
+ if (isBlock) registerBlockMacro(name);
8576
+ }
8503
8577
  defineMacro({
8504
8578
  name: "widget",
8505
8579
  block: true,
8506
- render({ rawArgs, children = [] }, ctx) {
8580
+ parameters: WIDGET_PARAMETERS,
8581
+ render({ children = [] }, ctx) {
8507
8582
  let parsed;
8508
8583
  let error;
8509
8584
  try {
8510
- const def = parseWidgetDef(rawArgs);
8585
+ const def = widgetDef(ctx.args);
8511
8586
  for (const param of def.params) checkVariableName(param.slice(1), param);
8512
8587
  parsed = def;
8513
8588
  } catch (err) {
@@ -8520,10 +8595,7 @@ defineMacro({
8520
8595
  const childrenKey = JSON.stringify(children);
8521
8596
  const paramsKey = params.join(",");
8522
8597
  ctx.hooks.useLayoutEffect(() => {
8523
- if (!parsed) return;
8524
- const isBlock = astContainsChildren(children);
8525
- registerWidget(name, children, params, isBlock);
8526
- if (isBlock) registerBlockMacro(name);
8598
+ if (parsed) registerWidgetDef(parsed, children);
8527
8599
  }, [
8528
8600
  name,
8529
8601
  childrenKey,
@@ -8562,9 +8634,7 @@ defineMacro({
8562
8634
  merged: true,
8563
8635
  render({ rawArgs }, ctx) {
8564
8636
  try {
8565
- const result = ctx.evaluate(rawArgs);
8566
- const display = result == null ? "" : String(result);
8567
- return ctx.wrap(display);
8637
+ return ctx.wrap(display(ctx.evaluate(rawArgs)));
8568
8638
  } catch (err) {
8569
8639
  return /* @__PURE__ */ jsx(MacroError, {
8570
8640
  macro: "print",
@@ -8573,8 +8643,7 @@ defineMacro({
8573
8643
  }
8574
8644
  },
8575
8645
  text({ rawArgs }, ctx) {
8576
- const result = ctx.evaluate(rawArgs);
8577
- return result == null ? "" : String(result);
8646
+ return display(ctx.evaluate(rawArgs));
8578
8647
  }
8579
8648
  });
8580
8649
  //#endregion
@@ -8744,40 +8813,50 @@ defineMacro({
8744
8813
  }
8745
8814
  });
8746
8815
  //#endregion
8816
+ //#region src/components/macros/branches.ts
8817
+ /**
8818
+ * The branch a conditional block ({if}, {switch}) shows: the first of
8819
+ * `branches` whose argument `matches`, else the last bare one ({else},
8820
+ * {default}), else null. Throws what `matches` throws.
8821
+ */
8822
+ function selectBranch(branches, matches) {
8823
+ let fallback = null;
8824
+ for (const branch of branches) if (branch.rawArgs === "") fallback = branch;
8825
+ else if (matches(branch.rawArgs)) return branch;
8826
+ return fallback;
8827
+ }
8828
+ //#endregion
8747
8829
  //#region src/components/macros/If.tsx
8830
+ /**
8831
+ * The {if}/{elseif}/{else} branch whose condition holds, else the {else}
8832
+ * branch, else null: the chain ends at its first bare branch. Throws what a
8833
+ * condition throws.
8834
+ */
8835
+ function selectIfBranch(branches, evaluate) {
8836
+ const end = branches.findIndex((branch) => branch.rawArgs === "");
8837
+ return selectBranch(end < 0 ? branches : branches.slice(0, end + 1), (expr) => Boolean(evaluate(expr)));
8838
+ }
8748
8839
  defineMacro({
8749
8840
  name: "if",
8750
8841
  block: true,
8751
8842
  interpolate: true,
8752
8843
  merged: true,
8753
8844
  render({ branches = [] }, ctx) {
8754
- function renderBranch(branch) {
8755
- const children = ctx.renderNodes(branch.children);
8756
- const cls = ctx.resolve(branch.className);
8757
- const branchId = ctx.resolve(branch.id);
8758
- if (cls || branchId) return /* @__PURE__ */ jsx("span", {
8759
- id: branchId,
8760
- class: cls,
8761
- children
8845
+ let branch;
8846
+ try {
8847
+ branch = selectIfBranch(branches, ctx.evaluate);
8848
+ } catch (err) {
8849
+ return /* @__PURE__ */ jsx(MacroError, {
8850
+ macro: "if",
8851
+ error: err
8762
8852
  });
8763
- return /* @__PURE__ */ jsx(Fragment$1, { children });
8764
- }
8765
- for (const branch of branches) {
8766
- if (branch.rawArgs === "") return renderBranch(branch);
8767
- try {
8768
- if (ctx.evaluate(branch.rawArgs)) return renderBranch(branch);
8769
- } catch (err) {
8770
- return /* @__PURE__ */ jsx(MacroError, {
8771
- macro: "if",
8772
- error: err
8773
- });
8774
- }
8775
8853
  }
8776
- return null;
8854
+ if (!branch) return null;
8855
+ return wrapContent(ctx.resolve(branch.className), ctx.resolve(branch.id), ctx.renderNodes(branch.children));
8777
8856
  },
8778
8857
  text({ branches = [] }, ctx) {
8779
- for (const branch of branches) if (branch.rawArgs === "" || ctx.evaluate(branch.rawArgs)) return ctx.renderText(branch.children);
8780
- return "";
8858
+ const branch = selectIfBranch(branches, ctx.evaluate);
8859
+ return branch ? ctx.renderText(branch.children) : "";
8781
8860
  }
8782
8861
  });
8783
8862
  //#endregion
@@ -8973,11 +9052,7 @@ defineMacro({
8973
9052
  name: "repeat",
8974
9053
  block: true,
8975
9054
  interpolate: true,
8976
- parameters: [{
8977
- name: "delay",
8978
- type: "delay",
8979
- required: true
8980
- }],
9055
+ parameters: [DELAY_PARAMETER],
8981
9056
  render({ children = [] }, ctx) {
8982
9057
  const { useState, useEffect, useCallback } = ctx.hooks;
8983
9058
  const delay = ctx.args.delay ?? 0;
@@ -9144,14 +9219,8 @@ defineMacro({
9144
9219
  * The first {case} branch whose value equals `value`, else the {default}
9145
9220
  * branch, else null. Throws what a case expression throws.
9146
9221
  */
9147
- function selectBranch(value, branches, evaluate) {
9148
- let defaultBranch = null;
9149
- for (let i = 1; i < branches.length; i++) {
9150
- const branch = branches[i];
9151
- if (branch.rawArgs === "") defaultBranch = branch;
9152
- else if (value === evaluate(branch.rawArgs)) return branch;
9153
- }
9154
- return defaultBranch;
9222
+ function selectCase(value, branches, evaluate) {
9223
+ return selectBranch(branches.slice(1), (expr) => value === evaluate(expr));
9155
9224
  }
9156
9225
  defineMacro({
9157
9226
  name: "switch",
@@ -9174,7 +9243,7 @@ defineMacro({
9174
9243
  }
9175
9244
  let branch;
9176
9245
  try {
9177
- branch = selectBranch(switchValue, branches, ctx.evaluate);
9246
+ branch = selectCase(switchValue, branches, ctx.evaluate);
9178
9247
  } catch (err) {
9179
9248
  return /* @__PURE__ */ jsx(MacroError, {
9180
9249
  macro: "case",
@@ -9184,21 +9253,24 @@ defineMacro({
9184
9253
  return branch && /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
9185
9254
  },
9186
9255
  text({ rawArgs, branches = [] }, ctx) {
9187
- const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9256
+ const branch = selectCase(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9188
9257
  return branch ? ctx.renderText(branch.children) : "";
9189
9258
  }
9190
9259
  });
9191
9260
  //#endregion
9192
9261
  //#region src/components/macros/Timed.tsx
9262
+ /** The parameters of {timed} and of each {next}: the delay before it shows. */
9263
+ var TIMED_PARAMETERS = [DELAY_PARAMETER];
9193
9264
  defineMacro({
9194
9265
  name: "timed",
9195
9266
  subMacros: ["next"],
9196
9267
  interpolate: true,
9268
+ parameters: TIMED_PARAMETERS,
9197
9269
  render({ branches = [] }, ctx) {
9198
9270
  const { useState, useEffect, useMemo } = ctx.hooks;
9199
9271
  const sections = useMemo(() => {
9200
9272
  return branches.map((branch) => ({
9201
- delay: branch.rawArgs ? parseDelay(branch.rawArgs) : 0,
9273
+ delay: parseMacroArgs(branch.rawArgs, TIMED_PARAMETERS).delay ?? 0,
9202
9274
  nodes: branch.children,
9203
9275
  className: branch.className,
9204
9276
  id: branch.id
@@ -9216,15 +9288,7 @@ defineMacro({
9216
9288
  }, [visibleIndex, sections]);
9217
9289
  if (visibleIndex < 0) return ctx.wrap(null);
9218
9290
  const section = sections[visibleIndex];
9219
- const content = ctx.renderNodes(section.nodes);
9220
- const sectionClass = ctx.resolve(section.className);
9221
- const sectionId = ctx.resolve(section.id);
9222
- const inner = sectionClass || sectionId ? /* @__PURE__ */ jsx("span", {
9223
- id: sectionId,
9224
- class: sectionClass,
9225
- children: content
9226
- }) : content;
9227
- return ctx.wrap(inner);
9291
+ return ctx.wrap(wrapContent(ctx.resolve(section.className), ctx.resolve(section.id), ctx.renderNodes(section.nodes)));
9228
9292
  }
9229
9293
  });
9230
9294
  //#endregion
@@ -9245,11 +9309,7 @@ defineMacro({
9245
9309
  name: "type",
9246
9310
  block: true,
9247
9311
  interpolate: true,
9248
- parameters: [{
9249
- name: "delay",
9250
- type: "delay",
9251
- required: true
9252
- }],
9312
+ parameters: [DELAY_PARAMETER],
9253
9313
  render({ children = [] }, ctx) {
9254
9314
  const { useState, useEffect, useRef } = ctx.hooks;
9255
9315
  const speed = ctx.args.delay ?? 0;
@@ -9864,7 +9924,7 @@ defineMacro({
9864
9924
  required: true
9865
9925
  }],
9866
9926
  render(_props, ctx) {
9867
- const storyData = useStoryStore((s) => s.storyData);
9927
+ const { storyData } = useStoryFields("storyData");
9868
9928
  const { inline } = ctx.args;
9869
9929
  const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate);
9870
9930
  const passage = storyData?.passages.get(passageName);
@@ -9931,6 +9991,26 @@ defineMacro({
9931
9991
  });
9932
9992
  //#endregion
9933
9993
  //#region src/components/macros/input-macro.ts
9994
+ /** The story variable a `storeVar` macro binds: its first parameter. */
9995
+ var VARIABLE_PARAMETER = {
9996
+ name: "variable",
9997
+ type: "variable",
9998
+ required: true
9999
+ };
10000
+ /**
10001
+ * Register the automation action of a macro bound to a story variable: keyed
10002
+ * by the variable (or `key`), with the variable's value unless `action`
10003
+ * gives another.
10004
+ */
10005
+ function useVariableAction(ctx, action) {
10006
+ return ctx.useAction({
10007
+ key: `$${ctx.varName}`,
10008
+ authorId: ctx.id,
10009
+ variable: ctx.varName,
10010
+ value: ctx.value,
10011
+ ...action
10012
+ });
10013
+ }
9934
10014
  /**
9935
10015
  * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
9936
10016
  * `type` (or, without one, a `<textarea>`) bound to the story variable.
@@ -9942,30 +10022,22 @@ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? St
9942
10022
  defineMacro({
9943
10023
  name,
9944
10024
  storeVar: true,
9945
- parameters: [{
9946
- name: "variable",
9947
- type: "variable",
9948
- required: true
9949
- }, {
10025
+ parameters: [VARIABLE_PARAMETER, {
9950
10026
  name: "placeholder",
9951
10027
  type: "string"
9952
10028
  }],
9953
10029
  render(_props, ctx) {
9954
10030
  const placeholder = ctx.args.placeholder ?? "";
9955
- ctx.useAction({
10031
+ useVariableAction(ctx, {
9956
10032
  type: name,
9957
- key: `$${ctx.varName}`,
9958
- authorId: ctx.id,
9959
10033
  label: placeholder || ctx.varName,
9960
- variable: ctx.varName,
9961
- value: ctx.value,
9962
10034
  perform: (v) => ctx.setValue(toValue(v))
9963
10035
  });
9964
10036
  return h(type ? "input" : "textarea", {
9965
10037
  type,
9966
10038
  id: ctx.id,
9967
10039
  class: ctx.cls,
9968
- value: ctx.value == null ? "" : String(ctx.value),
10040
+ value: display(ctx.value),
9969
10041
  placeholder,
9970
10042
  onInput: (e) => ctx.setValue(toValue(e.target.value))
9971
10043
  });
@@ -9986,22 +10058,15 @@ defineInputMacro("textarea");
9986
10058
  defineMacro({
9987
10059
  name: "checkbox",
9988
10060
  storeVar: true,
9989
- parameters: [{
9990
- name: "variable",
9991
- type: "variable",
9992
- required: true
9993
- }, {
10061
+ parameters: [VARIABLE_PARAMETER, {
9994
10062
  name: "label",
9995
10063
  type: "text"
9996
10064
  }],
9997
10065
  render(_props, ctx) {
9998
10066
  const label = ctx.args.label ?? "";
9999
- ctx.useAction({
10067
+ useVariableAction(ctx, {
10000
10068
  type: "checkbox",
10001
- key: `$${ctx.varName}`,
10002
- authorId: ctx.id,
10003
10069
  label: label || ctx.varName || "",
10004
- variable: ctx.varName,
10005
10070
  value: !!ctx.value,
10006
10071
  perform: (v) => ctx.setValue(v !== void 0 ? !!v : !ctx.value)
10007
10072
  });
@@ -10022,11 +10087,7 @@ defineMacro({
10022
10087
  name: "radiobutton",
10023
10088
  storeVar: true,
10024
10089
  parameters: [
10025
- {
10026
- name: "variable",
10027
- type: "variable",
10028
- required: true
10029
- },
10090
+ VARIABLE_PARAMETER,
10030
10091
  {
10031
10092
  name: "value",
10032
10093
  type: "text",
@@ -10040,13 +10101,10 @@ defineMacro({
10040
10101
  render(_props, ctx) {
10041
10102
  const radioValue = ctx.args.value ?? "";
10042
10103
  const label = ctx.args.label ?? "";
10043
- ctx.useAction({
10104
+ useVariableAction(ctx, {
10044
10105
  type: "radiobutton",
10045
10106
  key: `$${ctx.varName}:${radioValue}`,
10046
- authorId: ctx.id,
10047
10107
  label: label || radioValue,
10048
- variable: ctx.varName,
10049
- value: ctx.value,
10050
10108
  perform: () => ctx.setValue(radioValue)
10051
10109
  });
10052
10110
  return /* @__PURE__ */ jsxs("label", {
@@ -10067,21 +10125,13 @@ defineMacro({
10067
10125
  name: "listbox",
10068
10126
  subMacros: ["option"],
10069
10127
  storeVar: true,
10070
- parameters: [{
10071
- name: "variable",
10072
- type: "variable",
10073
- required: true
10074
- }],
10128
+ parameters: [VARIABLE_PARAMETER],
10075
10129
  render({ children = [] }, ctx) {
10076
10130
  const options = ctx.extractOptions(children);
10077
- ctx.useAction({
10131
+ useVariableAction(ctx, {
10078
10132
  type: "listbox",
10079
- key: `$${ctx.varName}`,
10080
- authorId: ctx.id,
10081
10133
  label: ctx.varName,
10082
- variable: ctx.varName,
10083
10134
  options,
10084
- value: ctx.value,
10085
10135
  perform: (v) => {
10086
10136
  if (v !== void 0) ctx.setValue(String(v));
10087
10137
  }
@@ -10089,7 +10139,7 @@ defineMacro({
10089
10139
  return /* @__PURE__ */ jsx("select", {
10090
10140
  id: ctx.id,
10091
10141
  class: ctx.cls,
10092
- value: ctx.value == null ? "" : String(ctx.value),
10142
+ value: display(ctx.value),
10093
10143
  onChange: (e) => ctx.setValue(e.target.value),
10094
10144
  children: options.map((opt) => /* @__PURE__ */ jsx("option", {
10095
10145
  value: opt,
@@ -10104,11 +10154,7 @@ defineMacro({
10104
10154
  name: "cycle",
10105
10155
  block: true,
10106
10156
  storeVar: true,
10107
- parameters: [{
10108
- name: "variable",
10109
- type: "variable",
10110
- required: true
10111
- }],
10157
+ parameters: [VARIABLE_PARAMETER],
10112
10158
  render({ children = [] }, ctx) {
10113
10159
  const options = ctx.extractOptions(children);
10114
10160
  const handleClick = () => {
@@ -10117,14 +10163,10 @@ defineMacro({
10117
10163
  const nextIndex = (options.indexOf(String(current)) + 1) % options.length;
10118
10164
  ctx.setValue(options[nextIndex]);
10119
10165
  };
10120
- ctx.useAction({
10166
+ useVariableAction(ctx, {
10121
10167
  type: "cycle",
10122
- key: `$${ctx.varName}`,
10123
- authorId: ctx.id,
10124
10168
  label: ctx.value == null ? options[0] || "" : String(ctx.value),
10125
- variable: ctx.varName,
10126
10169
  options,
10127
- value: ctx.value,
10128
10170
  perform: (v) => {
10129
10171
  if (v !== void 0) ctx.setValue(v);
10130
10172
  else handleClick();
@@ -10170,6 +10212,20 @@ function parsePassage(passage) {
10170
10212
  function renderPassageContent(passage) {
10171
10213
  return renderNodes(parsePassage(passage), passage.tags.includes("nobr") ? { nobr: true } : void 0);
10172
10214
  }
10215
+ /**
10216
+ * The content of a special passage shown with every passage (PassageHeader,
10217
+ * PassageFooter, PassageDone), or null. One that fails to render is logged
10218
+ * and left out, so it can't take the passage down with it.
10219
+ */
10220
+ function renderSpecialPassage(passage) {
10221
+ if (!passage) return null;
10222
+ try {
10223
+ return renderPassageContent(passage);
10224
+ } catch (err) {
10225
+ console.error(`spindle: Error in ${passage.name}:`, err);
10226
+ return null;
10227
+ }
10228
+ }
10173
10229
  var CODE_PASSAGES = new Set([
10174
10230
  "PassageReady",
10175
10231
  "PassageHeader",
@@ -10177,7 +10233,7 @@ var CODE_PASSAGES = new Set([
10177
10233
  "PassageDone"
10178
10234
  ]);
10179
10235
  function Passage({ passage, dataTransition, navigationId }) {
10180
- const storyData = useStoryStore((s) => s.storyData);
10236
+ const { storyData } = useStoryFields("storyData");
10181
10237
  const isCodePassage = CODE_PASSAGES.has(passage.name);
10182
10238
  const [doneReady, setDoneReady] = useState(false);
10183
10239
  const content = useMemo(() => {
@@ -10197,40 +10253,17 @@ function Passage({ passage, dataTransition, navigationId }) {
10197
10253
  });
10198
10254
  }
10199
10255
  }, [passage.content, passage.name]);
10200
- const headerPassage = isCodePassage ? void 0 : storyData?.passages.get("PassageHeader");
10201
- const footerPassage = isCodePassage ? void 0 : storyData?.passages.get("PassageFooter");
10202
- const donePassage = isCodePassage ? void 0 : storyData?.passages.get("PassageDone");
10203
- const headerContent = useMemo(() => {
10204
- if (!headerPassage) return null;
10205
- try {
10206
- return renderPassageContent(headerPassage);
10207
- } catch (err) {
10208
- console.error("spindle: Error in PassageHeader:", err);
10209
- return null;
10210
- }
10211
- }, [headerPassage?.content]);
10212
- const footerContent = useMemo(() => {
10213
- if (!footerPassage) return null;
10214
- try {
10215
- return renderPassageContent(footerPassage);
10216
- } catch (err) {
10217
- console.error("spindle: Error in PassageFooter:", err);
10218
- return null;
10219
- }
10220
- }, [footerPassage?.content]);
10256
+ const special = (name) => isCodePassage ? void 0 : storyData?.passages.get(name);
10257
+ const headerPassage = special("PassageHeader");
10258
+ const footerPassage = special("PassageFooter");
10259
+ const donePassage = special("PassageDone");
10260
+ const headerContent = useMemo(() => renderSpecialPassage(headerPassage), [headerPassage?.content]);
10261
+ const footerContent = useMemo(() => renderSpecialPassage(footerPassage), [footerPassage?.content]);
10221
10262
  useEffect(() => {
10222
10263
  if (donePassage) setDoneReady(true);
10223
10264
  return () => setDoneReady(false);
10224
10265
  }, [passage.name]);
10225
- const doneContent = useMemo(() => {
10226
- if (!doneReady || !donePassage) return null;
10227
- try {
10228
- return renderPassageContent(donePassage);
10229
- } catch (err) {
10230
- console.error("spindle: Error in PassageDone:", err);
10231
- return null;
10232
- }
10233
- }, [doneReady, donePassage?.content]);
10266
+ const doneContent = useMemo(() => doneReady ? renderSpecialPassage(donePassage) : null, [doneReady, donePassage?.content]);
10234
10267
  const elRef = useRef(null);
10235
10268
  useLayoutEffect(() => {
10236
10269
  if (navigationId !== void 0) markPassageRendered(navigationId);
@@ -10631,16 +10664,12 @@ function boot() {
10631
10664
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
10632
10665
  const widgetAST = buildAST(tokenize(passage.content));
10633
10666
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
10634
- const { name: widgetName, params } = parseWidgetDef(node.rawArgs, "$_@");
10635
- const children = node.children;
10636
- const isBlock = astContainsChildren(children);
10667
+ const def = parseWidgetDef(node.rawArgs);
10637
10668
  try {
10638
- registerWidget(widgetName, children, params, isBlock);
10669
+ registerWidgetDef(def, node.children);
10639
10670
  } catch (err) {
10640
- console.error(`spindle: widget "${widgetName}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`);
10641
- continue;
10671
+ console.error(`spindle: widget "${def.name}" in passage "${passage.name}" was not registered: ${errorMessage(err)}`);
10642
10672
  }
10643
- if (isBlock) registerBlockMacro(widgetName);
10644
10673
  }
10645
10674
  }
10646
10675
  let prevNavigationId = useStoryStore.getState().navigationId;