@rohal12/spindle 0.52.3 → 0.52.5

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.
@@ -32,28 +32,192 @@ function create() {
32
32
  return (initializer) => bindPreact(createStore()(initializer));
33
33
  }
34
34
  //#endregion
35
- //#region src/class-registry.ts
36
- var registry$1 = /* @__PURE__ */ new Map();
37
- var ctorToName = /* @__PURE__ */ new Map();
38
- function registerClass(name, ctor) {
39
- registry$1.set(name, ctor);
40
- ctorToName.set(ctor, name);
35
+ //#region src/utils/namespace.ts
36
+ /** The variable name no namespace can hold. */
37
+ var RESERVED_NAME = "__proto__";
38
+ /**
39
+ * Why `name` (without its sigil) cannot be a variable name, or undefined if
40
+ * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
41
+ */
42
+ function variableNameError(name, label = name) {
43
+ return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
44
+ }
45
+ /** Throw a TypeError if `name` cannot be a variable name. */
46
+ function checkVariableName(name, label = name) {
47
+ const error = variableNameError(name, label);
48
+ if (error) throw new TypeError(`spindle: ${error}`);
41
49
  }
50
+ /** Whether `obj` holds `key` as an own property. */
51
+ var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
52
+ /** `ns[key]` if `ns` holds it as an own property, else undefined. */
53
+ var ownValue = (ns, key) => ns && hasOwn(ns, key) ? ns[key] : void 0;
42
54
  /**
43
- * The registered name of the class `value` is an instance of, or undefined.
44
- * Looks the constructor up on the prototype, so an own property named
45
- * "constructor" (plain data) cannot hide or fake the class.
55
+ * A record with no prototype holding the own entries of `sources`, or only
56
+ * those whose value passes `keep`.
46
57
  */
47
- function registeredClassName(value) {
48
- const proto = Object.getPrototypeOf(value);
49
- return proto === null ? void 0 : ctorToName.get(proto.constructor);
58
+ function ownEntries(sources, keep = () => true) {
59
+ const record = Object.create(null);
60
+ for (const source of sources) {
61
+ if (!source) continue;
62
+ for (const key of Object.keys(source)) {
63
+ const value = source[key];
64
+ if (keep(value)) record[key] = value;
65
+ }
66
+ }
67
+ return record;
50
68
  }
51
- function isPlainObject(value) {
52
- if (typeof value !== "object" || value === null) return false;
53
- const proto = Object.getPrototypeOf(value);
54
- return proto === Object.prototype || proto === null;
69
+ /** A namespace with no prototype holding the own entries of `sources`. */
70
+ var createNamespace = (...sources) => ownEntries(sources);
71
+ /** Whether `value` is an object with no prototype. */
72
+ var isNamespace = (value) => Object.getPrototypeOf(value) === null;
73
+ /** `ns` itself if it has no prototype, else a namespace copy of it. */
74
+ var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
75
+ /** A namespace copy of `ns` with `key` set to `value`. */
76
+ function withEntry(ns, key, value) {
77
+ const next = createNamespace(ns);
78
+ next[key] = value;
79
+ return next;
80
+ }
81
+ /** An empty namespace that cannot change. */
82
+ var EMPTY_NAMESPACE = Object.freeze(createNamespace());
83
+ /** The names whose own entries differ between `a` and `b`, `a`'s first. */
84
+ function changedNames(a, b) {
85
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
86
+ }
87
+ var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
88
+ /** The count of `name` in `counts`: its own entry, or 0. */
89
+ function countOf(counts, name) {
90
+ const value = ownValue(counts, name);
91
+ return isCount(value) ? value : 0;
92
+ }
93
+ /**
94
+ * Counters with no prototype holding the own entries of `source` that are
95
+ * counts, with `name` (if given) counted once more. Immer copies the
96
+ * counters on every write anyway, so counting into a fresh copy costs no
97
+ * more than counting in place.
98
+ */
99
+ function createCounts(source, name) {
100
+ const counts = ownEntries([source], isCount);
101
+ if (name !== void 0) counts[name] = countOf(source, name) + 1;
102
+ return counts;
103
+ }
104
+ //#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(".")}")`);
55
190
  }
56
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
+ /**
57
221
  * Set an own enumerable property. A "__proto__" key is defined as an own
58
222
  * property (as JSON.parse does) instead of replacing the prototype.
59
223
  */
@@ -66,6 +230,11 @@ function setOwn(target, key, value) {
66
230
  });
67
231
  else target[key] = value;
68
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
+ }
69
238
  function deepClone(value, options = {}) {
70
239
  const seen = /* @__PURE__ */ new Map();
71
240
  function clone(val) {
@@ -92,15 +261,9 @@ function deepClone(value, options = {}) {
92
261
  for (const v of val) copy.add(clone(v));
93
262
  return copy;
94
263
  }
95
- if (registeredClassName(obj) !== void 0) {
96
- const copy = Object.create(Object.getPrototypeOf(obj));
97
- seen.set(obj, copy);
98
- for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
99
- return copy;
100
- }
101
- const plain = isPlainObject(val);
102
- if (!plain && options.keepUnregistered) return val;
103
- const copy = plain ? Object.create(Object.getPrototypeOf(obj)) : {};
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)) : {};
104
267
  seen.set(obj, copy);
105
268
  for (const key of Object.keys(obj)) setOwn(copy, key, clone(obj[key]));
106
269
  return copy;
@@ -150,10 +313,156 @@ function equal(a, b, assumed) {
150
313
  const bo = b;
151
314
  const keys = Object.keys(ao);
152
315
  if (keys.length !== Object.keys(bo).length) return false;
153
- for (const key of keys) if (!hasOwn$3(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
316
+ for (const key of keys) if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
154
317
  return true;
155
318
  }
156
- var hasOwn$3 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
319
+ /**
320
+ * Objects merged property by property: plain objects and (registered)
321
+ * class instances. Arrays, Map, Set, Date and RegExp are values that are
322
+ * replaced as a whole, since their elements have no stable identity to
323
+ * merge by (a shift moves every index).
324
+ */
325
+ function isMergeable(value) {
326
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
327
+ }
328
+ /**
329
+ * Whether changes between `a` and `b` merge key by key: both are objects of
330
+ * one class (see isMergeable), or, with `arrays`, both are arrays.
331
+ */
332
+ function mergesWith(a, b, arrays) {
333
+ if (arrays && (Array.isArray(a) || Array.isArray(b))) return Array.isArray(a) && Array.isArray(b);
334
+ return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
335
+ }
336
+ /**
337
+ * The keys at which `after` differs from `before`, two values that merge
338
+ * (see mergesWith): keys added, deleted, or holding another value. Store
339
+ * updates are immutable, so a value that kept its identity is no change,
340
+ * and one rebuilt with equal content (mutation code commits whole values)
341
+ * is none either. Values that merge are reported as 'nested', to be
342
+ * compared key by key; they may still be equal. With `arrays`, arrays
343
+ * merge too: elements at the indices both hold are compared, and the
344
+ * elements one has beyond the other's length are added or deleted.
345
+ */
346
+ function keyChanges(before, after, arrays) {
347
+ const changes = [];
348
+ const compare = (key, b, a) => {
349
+ if (Object.is(b, a)) return;
350
+ if (mergesWith(b, a, arrays)) changes.push({
351
+ key,
352
+ kind: "nested",
353
+ before: b,
354
+ after: a
355
+ });
356
+ else if (!deepEqual(b, a)) changes.push({
357
+ key,
358
+ kind: "changed",
359
+ after: a
360
+ });
361
+ };
362
+ if (Array.isArray(after)) {
363
+ const b = before;
364
+ const a = after;
365
+ for (let i = 0; i < Math.min(b.length, a.length); i++) compare(String(i), b[i], a[i]);
366
+ for (let i = b.length; i < a.length; i++) changes.push({
367
+ key: String(i),
368
+ kind: "added",
369
+ after: a[i]
370
+ });
371
+ for (let i = a.length; i < b.length; i++) changes.push({
372
+ key: String(i),
373
+ kind: "deleted"
374
+ });
375
+ return changes;
376
+ }
377
+ for (const key of Object.keys(after)) if (hasOwn(before, key)) compare(key, before[key], after[key]);
378
+ else changes.push({
379
+ key,
380
+ kind: "added",
381
+ after: after[key]
382
+ });
383
+ for (const key of Object.keys(before)) if (!hasOwn(after, key)) changes.push({
384
+ key,
385
+ kind: "deleted"
386
+ });
387
+ return changes;
388
+ }
389
+ /**
390
+ * The property paths where `after` differs from `before`, two objects of
391
+ * one class: objects that merge (see isMergeable) are compared property by
392
+ * property, any other value as a whole.
393
+ */
394
+ function diffPaths(before, after) {
395
+ const changes = [];
396
+ const ancestors = /* @__PURE__ */ new Set();
397
+ (function walk(b, a, path) {
398
+ if (ancestors.has(a)) return;
399
+ ancestors.add(a);
400
+ for (const change of keyChanges(b, a, false)) {
401
+ const at = [...path, change.key];
402
+ if (change.kind === "nested") walk(change.before, change.after, at);
403
+ else changes.push(change.kind === "deleted" ? {
404
+ path: at,
405
+ deleted: true
406
+ } : {
407
+ path: at,
408
+ deleted: false,
409
+ value: change.after
410
+ });
411
+ }
412
+ ancestors.delete(a);
413
+ })(before, after, []);
414
+ return changes;
415
+ }
416
+ /** Whether `target` already holds what `change` would write. */
417
+ function isApplied(target, change) {
418
+ const parent = getByPath(target, change.path.slice(0, -1));
419
+ if (parent === null || typeof parent !== "object") return change.deleted;
420
+ const key = change.path[change.path.length - 1];
421
+ if (!hasOwn(parent, key)) return change.deleted;
422
+ return !change.deleted && deepEqual(parent[key], change.value);
423
+ }
424
+ function applyChange(target, change) {
425
+ if (change.deleted) deleteByPath(target, change.path);
426
+ else setByPath(target, change.path, change.value);
427
+ }
428
+ /**
429
+ * Write how `after` differs from `before` (see keyChanges; arrays merge by
430
+ * index) into `target`, another version of the same object or array.
431
+ * Values that changed are written whole, as deep copies; for values that
432
+ * merge, `mergeNested` may merge them into `target` instead, returning
433
+ * whether it did. Into an array, elements removed from the end are removed
434
+ * at the same indices and elements added are appended, and changes at
435
+ * indices `target` lacks are dropped: where `target` was resized, its
436
+ * indices do not line up with `before`'s.
437
+ */
438
+ function mergeKeys(target, before, after, mergeNested) {
439
+ const list = Array.isArray(target) ? target : void 0;
440
+ for (const change of keyChanges(before, after, true)) {
441
+ const { key } = change;
442
+ if (change.kind === "deleted") if (list) list.length = Math.min(list.length, Number(key));
443
+ else delete target[key];
444
+ else if (list && change.kind === "added") list.push(deepClone(change.after));
445
+ else if (list && Number(key) >= list.length) continue;
446
+ else if (change.kind !== "nested" || !(mergeNested?.(key, change.before, change.after) || deepEqual(change.before, change.after))) target[key] = deepClone(change.after);
447
+ }
448
+ }
449
+ //#endregion
450
+ //#region src/class-registry.ts
451
+ var registry$1 = /* @__PURE__ */ new Map();
452
+ var ctorToName = /* @__PURE__ */ new Map();
453
+ function registerClass(name, ctor) {
454
+ registry$1.set(name, ctor);
455
+ ctorToName.set(ctor, name);
456
+ }
457
+ /**
458
+ * The registered name of the class `value` is an instance of, or undefined.
459
+ * Looks the constructor up on the prototype, so an own property named
460
+ * "constructor" (plain data) cannot hide or fake the class.
461
+ */
462
+ function registeredClassName(value) {
463
+ const proto = Object.getPrototypeOf(value);
464
+ return proto === null ? void 0 : ctorToName.get(proto.constructor);
465
+ }
157
466
  var CLASS_TAG = "__spindle_class__";
158
467
  var DATA_TAG = "__spindle_data__";
159
468
  /** A tagged value, as serialize() writes and deserialize() reads it. */
@@ -225,7 +534,7 @@ function serialize(value) {
225
534
  const name = registeredClassName(val);
226
535
  if (name !== void 0) return tagged(name, serKeys(val));
227
536
  const data = serKeys(val);
228
- return hasOwn$3(val, CLASS_TAG) ? tagged("__Object__", data) : data;
537
+ return hasOwn(val, CLASS_TAG) ? tagged("__Object__", data) : data;
229
538
  }
230
539
  return ser(value);
231
540
  }
@@ -311,7 +620,7 @@ function isDeserializable(value) {
311
620
  case "__Map__": return Array.isArray(data.entries) && Array.from(data.entries).every((entry) => Array.isArray(entry) && entry.length === 2 && check(entry[0]) && check(entry[1]));
312
621
  case "__Set__": return Array.isArray(data.entries) && Array.from(data.entries).every(check);
313
622
  case "__Undefined__": return true;
314
- case "__Number__": return typeof data.value === "string" && hasOwn$3(SPECIAL_NUMBERS, data.value);
623
+ case "__Number__": return typeof data.value === "string" && hasOwn(SPECIAL_NUMBERS, data.value);
315
624
  case "__BigInt__": return typeof data.value === "string" && /^-?\d+$/.test(data.value);
316
625
  default: return checkKeys(data);
317
626
  }
@@ -382,6 +691,10 @@ function isSavePayload(value) {
382
691
  if (typeof obj.historyIndex !== "number") return false;
383
692
  return true;
384
693
  }
694
+ /** Estimated byte size of a serialized save payload. */
695
+ function estimatePayloadBytes(payload) {
696
+ return JSON.stringify(payload).length;
697
+ }
385
698
  //#endregion
386
699
  //#region src/js-lexer.ts
387
700
  /** Transient name after `%`: an identifier, so `%3` is never a reference. */
@@ -486,13 +799,13 @@ function scanRegex(src, start, cache) {
486
799
  };
487
800
  let i = start + 1;
488
801
  while (i < src.length) {
802
+ const lineEnd = regexLineEnd(src, i);
803
+ if (lineEnd !== -1) return done(lineEnd, false);
489
804
  const c = src.charAt(i);
490
805
  if (c === "\\") {
491
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return done(i + 1, false);
492
806
  i += 2;
493
807
  continue;
494
808
  }
495
- if (LINE_TERMINATOR_RE.test(c)) return done(i, false);
496
809
  if (c === "[") {
497
810
  i = scanRegexClass(src, i, cache);
498
811
  if (src.charAt(i) !== "]") return done(i, false);
@@ -523,16 +836,17 @@ function scanRegexClass(src, open, cache) {
523
836
  const opens = [open];
524
837
  let i = open + 1;
525
838
  while (i < src.length) {
839
+ const lineEnd = regexLineEnd(src, i);
840
+ if (lineEnd !== -1) {
841
+ i = lineEnd;
842
+ break;
843
+ }
526
844
  const c = src.charAt(i);
527
845
  if (c === "\\") {
528
- if (LINE_TERMINATOR_RE.test(src.charAt(i + 1))) {
529
- i++;
530
- break;
531
- }
532
846
  i += 2;
533
847
  continue;
534
848
  }
535
- if (c === "]" || LINE_TERMINATOR_RE.test(c)) break;
849
+ if (c === "]") break;
536
850
  if (c === "[" && cache) opens.push(i);
537
851
  i++;
538
852
  }
@@ -541,6 +855,16 @@ function scanRegexClass(src, open, cache) {
541
855
  return i;
542
856
  }
543
857
  /**
858
+ * Index of the line break at `i`, escaped (`\` then a line break) or not,
859
+ * where a regex literal or class ends unterminated; -1 if there is none.
860
+ */
861
+ function regexLineEnd(src, i) {
862
+ const c = src.charAt(i);
863
+ if (LINE_TERMINATOR_RE.test(c)) return i;
864
+ if (c === "\\" && LINE_TERMINATOR_RE.test(src.charAt(i + 1))) return i + 1;
865
+ return -1;
866
+ }
867
+ /**
544
868
  * The next index from `from` on where `find` matches (-1 for none), for
545
869
  * searches from increasing positions: a search from within the stretch the
546
870
  * last one covered has the same answer.
@@ -1490,8 +1814,7 @@ function splitSigilTemplate(template) {
1490
1814
  }
1491
1815
  text += template.slice(i, brace);
1492
1816
  i = brace + 1;
1493
- const sigil = template[i];
1494
- const end = sigil === "$" || sigil === "_" || sigil === "@" || sigil === "%" ? scanBalancedBrace(template, i, memo) : -1;
1817
+ const end = isSigil(template[i]) ? scanBalancedBrace(template, i, memo) : -1;
1495
1818
  if (end === -1) {
1496
1819
  text += "{";
1497
1820
  continue;
@@ -1507,6 +1830,26 @@ function splitSigilTemplate(template) {
1507
1830
  }
1508
1831
  //#endregion
1509
1832
  //#region src/markup/tokenizer.ts
1833
+ /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1834
+ var SIGIL_SCOPES = {
1835
+ $: "variable",
1836
+ _: "temporary",
1837
+ "@": "local",
1838
+ "%": "transient"
1839
+ };
1840
+ /** The sigil of each variable scope. */
1841
+ var SCOPE_SIGILS = Object.fromEntries(Object.entries(SIGIL_SCOPES).map(([sigil, scope]) => [scope, sigil]));
1842
+ var SIGIL_CHARS = new Set(Object.keys(SIGIL_SCOPES));
1843
+ /** Whether `c` is a variable sigil. */
1844
+ function isSigil(c) {
1845
+ return c !== void 0 && SIGIL_CHARS.has(c);
1846
+ }
1847
+ /** Copy the selectors set in `from` onto `target`, and return it. */
1848
+ function withSelectors(target, from) {
1849
+ if (from.className) target.className = from.className;
1850
+ if (from.id) target.id = from.id;
1851
+ return target;
1852
+ }
1510
1853
  /** Tag name must start with a letter (covers standard and custom elements). */
1511
1854
  var VALID_TAG_START = /[a-zA-Z]/;
1512
1855
  /** HTML void elements: never have children or a closing tag. */
@@ -1526,13 +1869,6 @@ var HTML_VOID_TAGS = new Set([
1526
1869
  "track",
1527
1870
  "wbr"
1528
1871
  ]);
1529
- /** Variable sigils: story ($), temporary (_), local (@), transient (%). */
1530
- var SIGIL_CHARS = new Set([
1531
- "$",
1532
- "_",
1533
- "@",
1534
- "%"
1535
- ]);
1536
1872
  /**
1537
1873
  * Characters other than a sigil that open an expression after `{`:
1538
1874
  * `{(Math.max($a, 0))}`, `{!$done}`. Other characters that can start an
@@ -1543,25 +1879,32 @@ var EXPRESSION_START = new Set(["(", "!"]);
1543
1879
  /** Macros whose body is JavaScript source, kept verbatim instead of tokenized. */
1544
1880
  var RAW_BODY_MACROS = new Set(["do"]);
1545
1881
  /**
1882
+ * Link separators, in the order they are tried, and whether the target
1883
+ * comes first: display|target, display->target, target<-display.
1884
+ */
1885
+ var LINK_SEPARATORS = [
1886
+ ["|", false],
1887
+ ["->", false],
1888
+ ["<-", true]
1889
+ ];
1890
+ /**
1546
1891
  * Parse a Twine link interior into display and target.
1547
1892
  * Supports: display|target, display->target, target<-display, plain
1548
1893
  */
1549
1894
  function parseLink(inner) {
1550
- const pipeIdx = inner.indexOf("|");
1551
- if (pipeIdx !== -1) return {
1552
- display: inner.slice(0, pipeIdx).trim(),
1553
- target: inner.slice(pipeIdx + 1).trim()
1554
- };
1555
- const arrowIdx = inner.indexOf("->");
1556
- if (arrowIdx !== -1) return {
1557
- display: inner.slice(0, arrowIdx).trim(),
1558
- target: inner.slice(arrowIdx + 2).trim()
1559
- };
1560
- const revIdx = inner.indexOf("<-");
1561
- if (revIdx !== -1) return {
1562
- target: inner.slice(0, revIdx).trim(),
1563
- display: inner.slice(revIdx + 2).trim()
1564
- };
1895
+ for (const [separator, targetFirst] of LINK_SEPARATORS) {
1896
+ const idx = inner.indexOf(separator);
1897
+ if (idx === -1) continue;
1898
+ const before = inner.slice(0, idx).trim();
1899
+ const after = inner.slice(idx + separator.length).trim();
1900
+ return targetFirst ? {
1901
+ display: after,
1902
+ target: before
1903
+ } : {
1904
+ display: before,
1905
+ target: after
1906
+ };
1907
+ }
1565
1908
  const trimmed = inner.trim();
1566
1909
  return {
1567
1910
  display: trimmed,
@@ -1592,8 +1935,10 @@ function parseMacroContent(content) {
1592
1935
  }
1593
1936
  /**
1594
1937
  * Parse CSS selectors: .foo.bar#baz → { className: "foo bar", id: "baz" }
1595
- * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order.
1596
- * Returns space-joined class string, last id wins, and position after last segment.
1938
+ * Scans .[a-zA-Z0-9_-]+ and #[a-zA-Z0-9_-]+ segments in any order, and one
1939
+ * space after them. Returns space-joined class string, last id wins (each
1940
+ * left out if empty), and the position after them: `startIdx` if there are
1941
+ * none.
1597
1942
  */
1598
1943
  function parseSelectors(input, startIdx) {
1599
1944
  const classes = [];
@@ -1606,7 +1951,7 @@ function parseSelectors(input, startIdx) {
1606
1951
  while (i < input.length) if (/[a-zA-Z0-9_-]/.test(input[i])) {
1607
1952
  name += input[i];
1608
1953
  i++;
1609
- } else if (input[i] === "{" && SIGIL_CHARS.has(input[i + 1])) {
1954
+ } else if (input[i] === "{" && isSigil(input[i + 1])) {
1610
1955
  const braceStart = i;
1611
1956
  i += 2;
1612
1957
  while (i < input.length && /[\w.]/.test(input[i])) i++;
@@ -1621,10 +1966,13 @@ function parseSelectors(input, startIdx) {
1621
1966
  if (name) if (prefix === ".") classes.push(name);
1622
1967
  else id = name;
1623
1968
  }
1969
+ if (i > startIdx && input[i] === " ") i++;
1624
1970
  return {
1625
- className: classes.join(" "),
1626
- id,
1627
- endIdx: i
1971
+ selectors: withSelectors({}, {
1972
+ className: classes.join(" "),
1973
+ id
1974
+ }),
1975
+ end: i
1628
1976
  };
1629
1977
  }
1630
1978
  /**
@@ -1739,7 +2087,7 @@ function scanQuotedValue(input, j, quote, code, memo) {
1739
2087
  continue;
1740
2088
  }
1741
2089
  if (input[j] === "{") {
1742
- const closeIdx = !code ? scanBlockClose(input, j, memo) : SIGIL_CHARS.has(input[j + 1]) ? scanBalancedBrace(input, j + 1, memo) : -1;
2090
+ const closeIdx = !code ? scanBlockClose(input, j, memo) : isSigil(input[j + 1]) ? scanBalancedBrace(input, j + 1, memo) : -1;
1743
2091
  if (closeIdx !== -1) {
1744
2092
  j = closeIdx + 1;
1745
2093
  checkpoint = true;
@@ -1815,12 +2163,7 @@ function scanMacroClose(input, contentStart, memo) {
1815
2163
  * code from just past the {. Returns -1 if it is unclosed.
1816
2164
  */
1817
2165
  function scanBlockClose(input, open, memo) {
1818
- let at = open + 1;
1819
- const c = input[at];
1820
- if (c === "." || c === "#") {
1821
- at = parseSelectors(input, at).endIdx;
1822
- if (input[at] === " ") at++;
1823
- }
2166
+ const at = parseSelectors(input, open + 1).end;
1824
2167
  const first = input[at];
1825
2168
  if (first !== void 0 && (first === "/" || /[a-zA-Z]/.test(first))) return scanMacroClose(input, at, memo);
1826
2169
  return scanBalancedBrace(input, at, memo);
@@ -2012,13 +2355,22 @@ function tokenize(input, options = {}) {
2012
2355
  const memo = createScanMemo();
2013
2356
  let i = 0;
2014
2357
  let textStart = 0;
2358
+ /** Push a text token for the text not yet tokenized before `end`. */
2015
2359
  function flushText(end) {
2016
- if (end > textStart) tokens.push({
2017
- type: "text",
2018
- value: input.slice(textStart, end),
2019
- start: textStart,
2020
- end
2021
- });
2360
+ if (end > textStart) {
2361
+ tokens.push({
2362
+ type: "text",
2363
+ value: input.slice(textStart, end),
2364
+ start: textStart,
2365
+ end
2366
+ });
2367
+ textStart = end;
2368
+ }
2369
+ }
2370
+ /** Push `token` and go on after it. */
2371
+ function emit(token) {
2372
+ tokens.push(token);
2373
+ i = textStart = token.end;
2022
2374
  }
2023
2375
  /**
2024
2376
  * After an opening raw-body macro ({do}), emit everything up to its
@@ -2060,34 +2412,66 @@ function tokenize(input, options = {}) {
2060
2412
  atRe.lastIndex = closeStart;
2061
2413
  const closeEnd = closeStart + atRe.exec(input)[0].length;
2062
2414
  flushText(closeStart);
2063
- tokens.push({
2415
+ emit({
2064
2416
  type: "macro",
2065
2417
  ...parseMacroContent(input.slice(closeStart + 1, closeEnd - 1)),
2066
2418
  start: closeStart,
2067
2419
  end: closeEnd
2068
2420
  });
2069
- i = closeEnd;
2070
- textStart = closeEnd;
2071
2421
  }
2072
2422
  /**
2073
- * Push an expression token for the `{…}` block opened at `start` whose
2074
- * expression starts at `exprStart`, flushing the text before it first
2075
- * when `flush`. Returns false, consuming nothing, if the block is unclosed.
2423
+ * Push an expression token for the `{…}` block opened at `start` whose
2424
+ * expression starts at `exprStart`, flushing the text before it first.
2425
+ * Returns false, consuming nothing, if the block is unclosed.
2426
+ */
2427
+ function pushExpression(exprStart, start, selectors) {
2428
+ const closeIdx = scanBalancedBrace(input, exprStart, memo);
2429
+ if (closeIdx === -1) return false;
2430
+ flushText(start);
2431
+ emit(withSelectors({
2432
+ type: "expression",
2433
+ expression: input.slice(exprStart, closeIdx),
2434
+ start,
2435
+ end: closeIdx + 1
2436
+ }, selectors));
2437
+ return true;
2438
+ }
2439
+ /**
2440
+ * Push a variable token for the `{…}` block opened at `start` whose sigil
2441
+ * is at `sigilAt`: `{$name}`, `{_name.field.subfield}`. Anything else
2442
+ * after the name makes the block an expression from the sigil on
2443
+ * (`{$expr[...]}`). Returns false, consuming nothing, if it is unclosed.
2444
+ */
2445
+ function pushVariable(sigilAt, start, selectors) {
2446
+ let nameEnd = sigilAt + 1;
2447
+ while (nameEnd < input.length && /[\w.]/.test(input[nameEnd])) nameEnd++;
2448
+ if (input[nameEnd] !== "}") return pushExpression(sigilAt, start, selectors);
2449
+ emit(withSelectors({
2450
+ type: "variable",
2451
+ name: input.slice(sigilAt + 1, nameEnd),
2452
+ scope: SIGIL_SCOPES[input[sigilAt]],
2453
+ start,
2454
+ end: nameEnd + 1
2455
+ }, selectors));
2456
+ return true;
2457
+ }
2458
+ /**
2459
+ * Push a macro token for the `{…}` block opened at `start` whose content
2460
+ * (name, then arguments) starts at `contentStart`, then consume the body
2461
+ * of a raw-body macro. Returns false, consuming nothing, if the block is
2462
+ * unclosed.
2076
2463
  */
2077
- function pushExpression(exprStart, start, flush, className, id) {
2078
- const closeIdx = scanBalancedBrace(input, exprStart, memo);
2464
+ function pushMacro(contentStart, start, selectors) {
2465
+ const closeIdx = scanMacroClose(input, contentStart, memo);
2079
2466
  if (closeIdx === -1) return false;
2080
- if (flush) flushText(start);
2081
- const token = {
2082
- type: "expression",
2083
- expression: input.slice(exprStart, closeIdx),
2467
+ const token = withSelectors({
2468
+ type: "macro",
2469
+ ...parseMacroContent(input.slice(contentStart, closeIdx)),
2084
2470
  start,
2085
2471
  end: closeIdx + 1
2086
- };
2087
- if (className) token.className = className;
2088
- if (id) token.id = id;
2089
- tokens.push(token);
2090
- i = textStart = closeIdx + 1;
2472
+ }, selectors);
2473
+ emit(token);
2474
+ consumeRawBody(token.name, token.isClose);
2091
2475
  return true;
2092
2476
  }
2093
2477
  while (i < input.length) {
@@ -2111,14 +2495,12 @@ function tokenize(input, options = {}) {
2111
2495
  }
2112
2496
  if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
2113
2497
  flushText(k - 1);
2114
- tokens.push({
2498
+ emit({
2115
2499
  type: "text",
2116
2500
  value: next,
2117
2501
  start: k - 1,
2118
2502
  end: k + 1
2119
2503
  });
2120
- i = k + 1;
2121
- textStart = i;
2122
2504
  continue;
2123
2505
  }
2124
2506
  i = k;
@@ -2127,413 +2509,36 @@ function tokenize(input, options = {}) {
2127
2509
  if (!textMode && input[i] === "[" && input[i + 1] === "[") {
2128
2510
  flushText(i);
2129
2511
  const start = i;
2130
- i += 2;
2131
- let className;
2132
- let id;
2133
- if (input[i] === "." || input[i] === "#") {
2134
- const parsed = parseSelectors(input, i);
2135
- className = parsed.className || void 0;
2136
- id = parsed.id || void 0;
2137
- i = parsed.endIdx;
2138
- if (input[i] === " ") i++;
2139
- }
2140
- const innerStart = i;
2512
+ const { selectors, end: innerStart } = parseSelectors(input, i + 2);
2141
2513
  const closeIdx = scanLinkClose(input, innerStart, memo);
2142
2514
  if (closeIdx === -1) {
2143
2515
  i = start + 2;
2144
- textStart = start;
2145
2516
  continue;
2146
2517
  }
2147
- const inner = input.slice(innerStart, closeIdx);
2148
- i = closeIdx + 2;
2149
- const { display, target } = parseLink(inner);
2150
- const linkToken = {
2518
+ emit(withSelectors({
2151
2519
  type: "link",
2152
- display,
2153
- target,
2520
+ ...parseLink(input.slice(innerStart, closeIdx)),
2154
2521
  start,
2155
- end: i
2156
- };
2157
- if (className) linkToken.className = className;
2158
- if (id) linkToken.id = id;
2159
- tokens.push(linkToken);
2160
- textStart = i;
2522
+ end: closeIdx + 2
2523
+ }, selectors));
2161
2524
  continue;
2162
2525
  }
2163
2526
  if (input[i] === "{") {
2164
2527
  const start = i;
2165
- let nextChar = input[i + 1];
2166
- let className;
2167
- let id;
2168
- if (nextChar === "." || nextChar === "#") {
2169
- flushText(i);
2170
- const parsed = parseSelectors(input, i + 1);
2171
- className = parsed.className || void 0;
2172
- id = parsed.id || void 0;
2173
- let afterSelectors = parsed.endIdx;
2174
- if (input[afterSelectors] === " ") afterSelectors++;
2175
- const charAfter = input[afterSelectors];
2176
- if (charAfter === "$") {
2177
- i = afterSelectors + 1;
2178
- const nameStart = i;
2179
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2180
- const name = input.slice(nameStart, i);
2181
- if (input[i] === "}") {
2182
- i++;
2183
- const token = {
2184
- type: "variable",
2185
- name,
2186
- scope: "variable",
2187
- start,
2188
- end: i
2189
- };
2190
- if (className) token.className = className;
2191
- if (id) token.id = id;
2192
- tokens.push(token);
2193
- textStart = i;
2194
- continue;
2195
- }
2196
- const closeIdx$ = scanBalancedBrace(input, nameStart - 1, memo);
2197
- if (closeIdx$ !== -1) {
2198
- const expression = input.slice(afterSelectors, closeIdx$);
2199
- i = closeIdx$ + 1;
2200
- const token = {
2201
- type: "expression",
2202
- expression,
2203
- start,
2204
- end: i
2205
- };
2206
- if (className) token.className = className;
2207
- if (id) token.id = id;
2208
- tokens.push(token);
2209
- textStart = i;
2210
- continue;
2211
- }
2212
- i = start + 1;
2213
- textStart = start;
2214
- continue;
2215
- }
2216
- if (charAfter === "_") {
2217
- i = afterSelectors + 1;
2218
- const nameStart = i;
2219
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2220
- const name = input.slice(nameStart, i);
2221
- if (input[i] === "}") {
2222
- i++;
2223
- const token = {
2224
- type: "variable",
2225
- name,
2226
- scope: "temporary",
2227
- start,
2228
- end: i
2229
- };
2230
- if (className) token.className = className;
2231
- if (id) token.id = id;
2232
- tokens.push(token);
2233
- textStart = i;
2234
- continue;
2235
- }
2236
- const closeIdx_ = scanBalancedBrace(input, nameStart - 1, memo);
2237
- if (closeIdx_ !== -1) {
2238
- const expression = input.slice(afterSelectors, closeIdx_);
2239
- i = closeIdx_ + 1;
2240
- const token = {
2241
- type: "expression",
2242
- expression,
2243
- start,
2244
- end: i
2245
- };
2246
- if (className) token.className = className;
2247
- if (id) token.id = id;
2248
- tokens.push(token);
2249
- textStart = i;
2250
- continue;
2251
- }
2252
- i = start + 1;
2253
- textStart = start;
2254
- continue;
2255
- }
2256
- if (charAfter === "@") {
2257
- i = afterSelectors + 1;
2258
- const nameStart = i;
2259
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2260
- const name = input.slice(nameStart, i);
2261
- if (input[i] === "}") {
2262
- i++;
2263
- const token = {
2264
- type: "variable",
2265
- name,
2266
- scope: "local",
2267
- start,
2268
- end: i
2269
- };
2270
- if (className) token.className = className;
2271
- if (id) token.id = id;
2272
- tokens.push(token);
2273
- textStart = i;
2274
- continue;
2275
- }
2276
- const closeIdx_at = scanBalancedBrace(input, nameStart - 1, memo);
2277
- if (closeIdx_at !== -1) {
2278
- const expression = input.slice(afterSelectors, closeIdx_at);
2279
- i = closeIdx_at + 1;
2280
- const token = {
2281
- type: "expression",
2282
- expression,
2283
- start,
2284
- end: i
2285
- };
2286
- if (className) token.className = className;
2287
- if (id) token.id = id;
2288
- tokens.push(token);
2289
- textStart = i;
2290
- continue;
2291
- }
2292
- i = start + 1;
2293
- textStart = start;
2294
- continue;
2295
- }
2296
- if (charAfter === "%") {
2297
- i = afterSelectors + 1;
2298
- const nameStart = i;
2299
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2300
- const name = input.slice(nameStart, i);
2301
- if (input[i] === "}") {
2302
- i++;
2303
- const token = {
2304
- type: "variable",
2305
- name,
2306
- scope: "transient",
2307
- start,
2308
- end: i
2309
- };
2310
- if (className) token.className = className;
2311
- if (id) token.id = id;
2312
- tokens.push(token);
2313
- textStart = i;
2314
- continue;
2315
- }
2316
- const closeIdx_pct = scanBalancedBrace(input, nameStart - 1, memo);
2317
- if (closeIdx_pct !== -1) {
2318
- const expression = input.slice(afterSelectors, closeIdx_pct);
2319
- i = closeIdx_pct + 1;
2320
- const token = {
2321
- type: "expression",
2322
- expression,
2323
- start,
2324
- end: i
2325
- };
2326
- if (className) token.className = className;
2327
- if (id) token.id = id;
2328
- tokens.push(token);
2329
- textStart = i;
2330
- continue;
2331
- }
2332
- i = start + 1;
2333
- textStart = start;
2334
- continue;
2335
- }
2336
- if (EXPRESSION_START.has(charAfter) && pushExpression(afterSelectors, start, false, className, id)) continue;
2337
- if (charAfter !== void 0 && /[a-zA-Z]/.test(charAfter)) {
2338
- const contentStart = afterSelectors;
2339
- const closeIdx = scanMacroClose(input, contentStart, memo);
2340
- if (closeIdx === -1) {
2341
- i = start + 1;
2342
- textStart = start;
2343
- continue;
2344
- }
2345
- const content = input.slice(contentStart, closeIdx);
2346
- i = closeIdx + 1;
2347
- const { name, rawArgs, isClose } = parseMacroContent(content);
2348
- const token = {
2349
- type: "macro",
2350
- name,
2351
- rawArgs,
2352
- isClose,
2353
- start,
2354
- end: i
2355
- };
2356
- if (className) token.className = className;
2357
- if (id) token.id = id;
2358
- tokens.push(token);
2359
- textStart = i;
2360
- consumeRawBody(name, isClose);
2361
- continue;
2362
- }
2363
- i = start + 1;
2364
- textStart = start;
2365
- continue;
2366
- }
2367
- if (nextChar === "$") {
2368
- flushText(i);
2369
- i += 2;
2370
- const nameStart = i;
2371
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2372
- const name = input.slice(nameStart, i);
2373
- if (input[i] === "}") {
2374
- i++;
2375
- tokens.push({
2376
- type: "variable",
2377
- name,
2378
- scope: "variable",
2379
- start,
2380
- end: i
2381
- });
2382
- textStart = i;
2383
- continue;
2384
- }
2385
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2386
- if (closeIdx !== -1) {
2387
- const expression = input.slice(start + 1, closeIdx);
2388
- i = closeIdx + 1;
2389
- tokens.push({
2390
- type: "expression",
2391
- expression,
2392
- start,
2393
- end: i
2394
- });
2395
- textStart = i;
2396
- continue;
2397
- }
2398
- i = start + 1;
2399
- textStart = start;
2400
- continue;
2401
- }
2402
- if (nextChar === "_") {
2403
- flushText(i);
2404
- i += 2;
2405
- const nameStart = i;
2406
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2407
- const name = input.slice(nameStart, i);
2408
- if (input[i] === "}") {
2409
- i++;
2410
- tokens.push({
2411
- type: "variable",
2412
- name,
2413
- scope: "temporary",
2414
- start,
2415
- end: i
2416
- });
2417
- textStart = i;
2418
- continue;
2419
- }
2420
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2421
- if (closeIdx !== -1) {
2422
- const expression = input.slice(start + 1, closeIdx);
2423
- i = closeIdx + 1;
2424
- tokens.push({
2425
- type: "expression",
2426
- expression,
2427
- start,
2428
- end: i
2429
- });
2430
- textStart = i;
2431
- continue;
2432
- }
2433
- i = start + 1;
2434
- textStart = start;
2435
- continue;
2436
- }
2437
- if (nextChar === "@") {
2438
- flushText(i);
2439
- i += 2;
2440
- const nameStart = i;
2441
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2442
- const name = input.slice(nameStart, i);
2443
- if (input[i] === "}") {
2444
- i++;
2445
- tokens.push({
2446
- type: "variable",
2447
- name,
2448
- scope: "local",
2449
- start,
2450
- end: i
2451
- });
2452
- textStart = i;
2453
- continue;
2454
- }
2455
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2456
- if (closeIdx !== -1) {
2457
- const expression = input.slice(start + 1, closeIdx);
2458
- i = closeIdx + 1;
2459
- tokens.push({
2460
- type: "expression",
2461
- expression,
2462
- start,
2463
- end: i
2464
- });
2465
- textStart = i;
2466
- continue;
2467
- }
2468
- i = start + 1;
2469
- textStart = start;
2470
- continue;
2471
- }
2472
- if (nextChar === "%") {
2473
- flushText(i);
2474
- i += 2;
2475
- const nameStart = i;
2476
- while (i < input.length && /[\w.]/.test(input[i])) i++;
2477
- const name = input.slice(nameStart, i);
2478
- if (input[i] === "}") {
2479
- i++;
2480
- tokens.push({
2481
- type: "variable",
2482
- name,
2483
- scope: "transient",
2484
- start,
2485
- end: i
2486
- });
2487
- textStart = i;
2488
- continue;
2489
- }
2490
- const closeIdx = scanBalancedBrace(input, nameStart - 1, memo);
2491
- if (closeIdx !== -1) {
2492
- const expression = input.slice(start + 1, closeIdx);
2493
- i = closeIdx + 1;
2494
- tokens.push({
2495
- type: "expression",
2496
- expression,
2497
- start,
2498
- end: i
2499
- });
2500
- textStart = i;
2501
- continue;
2502
- }
2503
- i = start + 1;
2504
- textStart = start;
2505
- continue;
2506
- }
2507
- if (EXPRESSION_START.has(nextChar)) {
2508
- if (pushExpression(i + 1, start, true)) continue;
2509
- i++;
2510
- continue;
2511
- }
2512
- if (nextChar !== void 0 && (nextChar === "/" || /[a-zA-Z]/.test(nextChar))) {
2513
- flushText(i);
2514
- const contentStart = i + 1;
2515
- const closeIdx = scanMacroClose(input, contentStart, memo);
2516
- if (closeIdx === -1) {
2517
- i = start + 1;
2518
- textStart = start;
2519
- continue;
2520
- }
2521
- const content = input.slice(contentStart, closeIdx);
2522
- i = closeIdx + 1;
2523
- const { name, rawArgs, isClose } = parseMacroContent(content);
2524
- tokens.push({
2525
- type: "macro",
2526
- name,
2527
- rawArgs,
2528
- isClose,
2529
- start,
2530
- end: i
2531
- });
2532
- textStart = i;
2533
- consumeRawBody(name, isClose);
2534
- continue;
2528
+ const { selectors, end: at } = parseSelectors(input, i + 1);
2529
+ const prefixed = at > i + 1;
2530
+ if (prefixed) flushText(start);
2531
+ const c = input[at];
2532
+ if (isSigil(c)) {
2533
+ flushText(start);
2534
+ if (pushVariable(at, start, selectors)) continue;
2535
+ } else if (EXPRESSION_START.has(c)) {
2536
+ if (pushExpression(at, start, selectors)) continue;
2537
+ } else if (c !== void 0 && (/[a-zA-Z]/.test(c) || c === "/" && !prefixed)) {
2538
+ flushText(start);
2539
+ if (pushMacro(at, start, selectors)) continue;
2535
2540
  }
2536
- i++;
2541
+ i = start + 1;
2537
2542
  continue;
2538
2543
  }
2539
2544
  if (!textMode && input[i] === "<") {
@@ -2551,11 +2556,10 @@ function tokenize(input, options = {}) {
2551
2556
  j++;
2552
2557
  flushText(start);
2553
2558
  if (HTML_VOID_TAGS.has(tagLower)) {
2554
- textStart = j;
2555
- i = j;
2559
+ i = textStart = j;
2556
2560
  continue;
2557
2561
  }
2558
- tokens.push({
2562
+ emit({
2559
2563
  type: "html",
2560
2564
  tag,
2561
2565
  attributes: {},
@@ -2564,8 +2568,6 @@ function tokenize(input, options = {}) {
2564
2568
  start,
2565
2569
  end: j
2566
2570
  });
2567
- textStart = j;
2568
- i = j;
2569
2571
  continue;
2570
2572
  }
2571
2573
  } else {
@@ -2579,7 +2581,7 @@ function tokenize(input, options = {}) {
2579
2581
  if (input[j] === ">") {
2580
2582
  j++;
2581
2583
  flushText(start);
2582
- tokens.push({
2584
+ emit({
2583
2585
  type: "html",
2584
2586
  tag,
2585
2587
  attributes: parseHtmlAttributes(input, attrsStart, memo).attributes,
@@ -2588,8 +2590,6 @@ function tokenize(input, options = {}) {
2588
2590
  start,
2589
2591
  end: j
2590
2592
  });
2591
- textStart = j;
2592
- i = j;
2593
2593
  continue;
2594
2594
  }
2595
2595
  }
@@ -2645,6 +2645,21 @@ var BRANCHING_BLOCK_MACROS = new Set([
2645
2645
  function quoteArg(value) {
2646
2646
  return `"${value.replace(/[\\"]/g, "\\$&")}"`;
2647
2647
  }
2648
+ /** A new branch for the macro token opening it, with its selectors. */
2649
+ function newBranch(token) {
2650
+ return withSelectors({
2651
+ rawArgs: token.rawArgs,
2652
+ children: []
2653
+ }, token);
2654
+ }
2655
+ /** The name of a macro or HTML element. */
2656
+ function tagName(tagged) {
2657
+ return tagged.type === "html" ? tagged.tag : tagged.name;
2658
+ }
2659
+ /** The tag closing a macro or HTML element, as written in markup. */
2660
+ function closerOf(tagged) {
2661
+ return tagged.type === "html" ? `</${tagged.tag}>` : `{/${tagged.name}}`;
2662
+ }
2648
2663
  /**
2649
2664
  * Build an AST from a token array. Block macros are nested into trees
2650
2665
  * using a stack. Throws on unclosed or mismatched macros.
@@ -2658,6 +2673,18 @@ function buildAST(tokens) {
2658
2673
  if (top.type === "macro" && top.branches && top.branches.length > 0) return top.branches[top.branches.length - 1].children;
2659
2674
  return top.children;
2660
2675
  }
2676
+ /**
2677
+ * Pop the node on top of the stack, which the closing `token` (`</b>`,
2678
+ * `{/if}`) must close. Names match ignoring case.
2679
+ */
2680
+ function close(token) {
2681
+ const found = closerOf(token);
2682
+ if (stack.length === 0) throw new Error(`Unexpected closing ${found} (at character ${token.start})`);
2683
+ const top = stack[stack.length - 1];
2684
+ if (top.node.type !== token.type || tagName(top.node).toLowerCase() !== tagName(token).toLowerCase()) throw new Error(`Expected ${closerOf(top.node)} but found ${found} (at character ${token.start})`);
2685
+ stack.pop();
2686
+ current().push(top.node);
2687
+ }
2661
2688
  for (const token of tokens) switch (token.type) {
2662
2689
  case "text":
2663
2690
  current().push({
@@ -2666,133 +2693,72 @@ function buildAST(tokens) {
2666
2693
  });
2667
2694
  break;
2668
2695
  case "link": {
2669
- const macroNode = {
2696
+ const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
2697
+ current().push(withSelectors({
2670
2698
  type: "macro",
2671
2699
  name: "link",
2672
- rawArgs: `${quoteArg(token.display)} ${quoteArg(token.target)}`,
2700
+ rawArgs,
2673
2701
  children: []
2674
- };
2675
- if (token.className) macroNode.className = token.className;
2676
- if (token.id) macroNode.id = token.id;
2677
- current().push(macroNode);
2702
+ }, token));
2678
2703
  break;
2679
2704
  }
2680
- case "variable": {
2681
- const varNode = {
2705
+ case "variable":
2706
+ current().push(withSelectors({
2682
2707
  type: "variable",
2683
2708
  name: token.name,
2684
2709
  scope: token.scope
2685
- };
2686
- if (token.className) varNode.className = token.className;
2687
- if (token.id) varNode.id = token.id;
2688
- current().push(varNode);
2710
+ }, token));
2689
2711
  break;
2690
- }
2691
- case "expression": {
2692
- const exprNode = {
2712
+ case "expression":
2713
+ current().push(withSelectors({
2693
2714
  type: "expression",
2694
2715
  expression: token.expression
2695
- };
2696
- if (token.className) exprNode.className = token.className;
2697
- if (token.id) exprNode.id = token.id;
2698
- current().push(exprNode);
2716
+ }, token));
2699
2717
  break;
2700
- }
2701
2718
  case "html": {
2702
- if (token.isSelfClose) {
2703
- current().push({
2704
- type: "html",
2705
- tag: token.tag,
2706
- attributes: token.attributes,
2707
- children: []
2708
- });
2709
- break;
2710
- }
2711
- if (token.isClose) {
2712
- if (stack.length === 0) throw new Error(`Unexpected closing </${token.tag}> (at character ${token.start})`);
2713
- const top = stack[stack.length - 1];
2714
- if (top.node.type !== "html" || top.node.tag.toLowerCase() !== token.tag.toLowerCase()) {
2715
- const expected = top.node.type === "html" ? `</${top.node.tag}>` : `{/${top.node.name}}`;
2716
- throw new Error(`Expected ${expected} but found </${token.tag}> (at character ${token.start})`);
2717
- }
2718
- stack.pop();
2719
- current().push(top.node);
2720
- break;
2721
- }
2722
2719
  const htmlNode = {
2723
2720
  type: "html",
2724
2721
  tag: token.tag,
2725
2722
  attributes: token.attributes,
2726
2723
  children: []
2727
2724
  };
2728
- stack.push({
2725
+ if (token.isSelfClose) current().push(htmlNode);
2726
+ else if (token.isClose) close(token);
2727
+ else stack.push({
2729
2728
  node: htmlNode,
2730
2729
  start: token.start
2731
2730
  });
2732
2731
  break;
2733
2732
  }
2734
2733
  case "macro": {
2735
- const name = token.name.toLowerCase();
2736
2734
  if (token.isClose) {
2737
- if (stack.length === 0) throw new Error(`Unexpected closing {/${token.name}} (at character ${token.start})`);
2738
- const top = stack[stack.length - 1];
2739
- if (top.node.type !== "macro" || top.node.name !== name) {
2740
- const expected = top.node.type === "macro" ? `{/${top.node.name}}` : `</${top.node.tag}>`;
2741
- throw new Error(`Expected ${expected} but found {/${token.name}} (at character ${token.start})`);
2742
- }
2743
- stack.pop();
2744
- current().push(top.node);
2735
+ close(token);
2745
2736
  break;
2746
2737
  }
2738
+ const name = token.name.toLowerCase();
2747
2739
  const expectedParent = Object.prototype.hasOwnProperty.call(BRANCH_PARENT, name) ? BRANCH_PARENT[name] : void 0;
2748
2740
  if (expectedParent) {
2749
2741
  const topNode = stack.length > 0 ? stack[stack.length - 1].node : null;
2750
2742
  if (!topNode || topNode.type !== "macro" || topNode.name !== expectedParent) throw new Error(`{${token.name}} without matching {${expectedParent}} (at character ${token.start})`);
2751
- const branch = {
2752
- rawArgs: token.rawArgs,
2753
- children: []
2754
- };
2755
- if (token.className) branch.className = token.className;
2756
- if (token.id) branch.id = token.id;
2757
- topNode.branches.push(branch);
2743
+ topNode.branches.push(newBranch(token));
2758
2744
  break;
2759
2745
  }
2760
- if (BLOCK_MACROS.has(name)) {
2761
- const node = {
2762
- type: "macro",
2763
- name,
2764
- rawArgs: token.rawArgs,
2765
- children: []
2766
- };
2767
- if (BRANCHING_BLOCK_MACROS.has(name)) {
2768
- const firstBranch = {
2769
- rawArgs: token.rawArgs,
2770
- children: []
2771
- };
2772
- if (token.className) firstBranch.className = token.className;
2773
- if (token.id) firstBranch.id = token.id;
2774
- node.branches = [firstBranch];
2775
- } else {
2776
- if (token.className) node.className = token.className;
2777
- if (token.id) node.id = token.id;
2778
- }
2779
- stack.push({
2780
- node,
2781
- start: token.start
2782
- });
2746
+ const node = {
2747
+ type: "macro",
2748
+ name,
2749
+ rawArgs: token.rawArgs,
2750
+ children: []
2751
+ };
2752
+ if (!BLOCK_MACROS.has(name)) {
2753
+ current().push(withSelectors(node, token));
2783
2754
  break;
2784
2755
  }
2785
- {
2786
- const macroNode = {
2787
- type: "macro",
2788
- name,
2789
- rawArgs: token.rawArgs,
2790
- children: []
2791
- };
2792
- if (token.className) macroNode.className = token.className;
2793
- if (token.id) macroNode.id = token.id;
2794
- current().push(macroNode);
2795
- }
2756
+ if (BRANCHING_BLOCK_MACROS.has(name)) node.branches = [newBranch(token)];
2757
+ else withSelectors(node, token);
2758
+ stack.push({
2759
+ node,
2760
+ start: token.start
2761
+ });
2796
2762
  break;
2797
2763
  }
2798
2764
  default: throw new Error(`Unknown token type: ${token.type}`);
@@ -2967,76 +2933,6 @@ function getPRNGPull() {
2967
2933
  return currentPull;
2968
2934
  }
2969
2935
  //#endregion
2970
- //#region src/utils/namespace.ts
2971
- /** The variable name no namespace can hold. */
2972
- var RESERVED_NAME = "__proto__";
2973
- /**
2974
- * Why `name` (without its sigil) cannot be a variable name, or undefined if
2975
- * it can. `label` is the name as the author wrote it (e.g. `$__proto__`).
2976
- */
2977
- function variableNameError(name, label = name) {
2978
- return name === "__proto__" ? `"${label}" cannot be used as a variable name (${RESERVED_NAME} is reserved)` : void 0;
2979
- }
2980
- /** Throw a TypeError if `name` cannot be a variable name. */
2981
- function checkVariableName(name, label = name) {
2982
- const error = variableNameError(name, label);
2983
- if (error) throw new TypeError(`spindle: ${error}`);
2984
- }
2985
- /** Whether `obj` holds `key` as an own property. */
2986
- var hasOwn$2 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
2987
- /** `ns[key]` if `ns` holds it as an own property, else undefined. */
2988
- var ownValue = (ns, key) => ns && hasOwn$2(ns, key) ? ns[key] : void 0;
2989
- /**
2990
- * A record with no prototype holding the own entries of `sources`, or only
2991
- * those whose value passes `keep`.
2992
- */
2993
- function ownEntries(sources, keep = () => true) {
2994
- const record = Object.create(null);
2995
- for (const source of sources) {
2996
- if (!source) continue;
2997
- for (const key of Object.keys(source)) {
2998
- const value = source[key];
2999
- if (keep(value)) record[key] = value;
3000
- }
3001
- }
3002
- return record;
3003
- }
3004
- /** A namespace with no prototype holding the own entries of `sources`. */
3005
- var createNamespace = (...sources) => ownEntries(sources);
3006
- /** Whether `value` is an object with no prototype. */
3007
- var isNamespace = (value) => Object.getPrototypeOf(value) === null;
3008
- /** `ns` itself if it has no prototype, else a namespace copy of it. */
3009
- var asNamespace = (ns) => isNamespace(ns) ? ns : createNamespace(ns);
3010
- /** A namespace copy of `ns` with `key` set to `value`. */
3011
- function withEntry(ns, key, value) {
3012
- const next = createNamespace(ns);
3013
- next[key] = value;
3014
- return next;
3015
- }
3016
- /** An empty namespace that cannot change. */
3017
- var EMPTY_NAMESPACE = Object.freeze(createNamespace());
3018
- /** The names whose own entries differ between `a` and `b`, `a`'s first. */
3019
- function changedNames(a, b) {
3020
- return [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((name) => ownValue(a, name) !== ownValue(b, name));
3021
- }
3022
- var isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
3023
- /** The count of `name` in `counts`: its own entry, or 0. */
3024
- function countOf(counts, name) {
3025
- const value = ownValue(counts, name);
3026
- return isCount(value) ? value : 0;
3027
- }
3028
- /**
3029
- * Counters with no prototype holding the own entries of `source` that are
3030
- * counts, with `name` (if given) counted once more. Immer copies the
3031
- * counters on every write anyway, so counting into a fresh copy costs no
3032
- * more than counting in place.
3033
- */
3034
- function createCounts(source, name) {
3035
- const counts = ownEntries([source], isCount);
3036
- if (name !== void 0) counts[name] = countOf(source, name) + 1;
3037
- return counts;
3038
- }
3039
- //#endregion
3040
2936
  //#region src/expression.ts
3041
2937
  var FN_CACHE_MAX = 500;
3042
2938
  var fnCache = /* @__PURE__ */ new Map();
@@ -3463,12 +3359,6 @@ var MAX_DEPTH = 100;
3463
3359
  function display(value) {
3464
3360
  return value == null ? "" : String(value);
3465
3361
  }
3466
- var KIND_SIGILS = {
3467
- variable: "$",
3468
- temporary: "_",
3469
- local: "@",
3470
- transient: "%"
3471
- };
3472
3362
  /**
3473
3363
  * The value of a variable reference (`name` without its sigil, with an
3474
3364
  * optional dot path), as `{$name}` shows it in passage text (see
@@ -3478,7 +3368,7 @@ var KIND_SIGILS = {
3478
3368
  function resolveVariable(scope, kind, name) {
3479
3369
  const parts = name.split(".");
3480
3370
  const root = parts[0];
3481
- checkVariableName(root, KIND_SIGILS[kind] + root);
3371
+ checkVariableName(root, SCOPE_SIGILS[kind] + root);
3482
3372
  let value = ownValue(kind === "variable" ? scope.variables : kind === "temporary" ? scope.temporary : kind === "transient" ? scope.transient : scope.locals, root);
3483
3373
  for (let i = 1; i < parts.length; i++) {
3484
3374
  if (value == null) return void 0;
@@ -3528,7 +3418,7 @@ var TextEvaluator = class {
3528
3418
  return display(resolveVariable(scope, kind, name));
3529
3419
  } catch (error) {
3530
3420
  this.errors.push({
3531
- macro: KIND_SIGILS[kind] + name,
3421
+ macro: SCOPE_SIGILS[kind] + name,
3532
3422
  error
3533
3423
  });
3534
3424
  return "";
@@ -3636,12 +3526,6 @@ function interpolateText(template, scope) {
3636
3526
  };
3637
3527
  return renderText(parsed.nodes, scope);
3638
3528
  }
3639
- var SIGIL_KINDS = {
3640
- $: "variable",
3641
- _: "temporary",
3642
- "@": "local",
3643
- "%": "transient"
3644
- };
3645
3529
  /**
3646
3530
  * Resolve the sigil references in the value of a code attribute (see
3647
3531
  * isCodeAttribute): `{$x}`, `{_x.y}`, `{$n + 1}`. Everything else, other
@@ -3654,7 +3538,7 @@ function interpolateCode(template, scope, decode) {
3654
3538
  for (const part of splitSigilTemplate(template)) if ("text" in part) text += decode ? decode(part.text) : part.text;
3655
3539
  else if ("verbatim" in part) text += part.verbatim;
3656
3540
  else if (/^[$_@%][\w.]+$/.test(part.expr)) {
3657
- const kind = SIGIL_KINDS[part.expr[0]];
3541
+ const kind = SIGIL_SCOPES[part.expr[0]];
3658
3542
  try {
3659
3543
  text += display(resolveVariable(scope, kind, part.expr.slice(1)));
3660
3544
  } catch (error) {
@@ -4681,94 +4565,92 @@ function renderNodes(nodes, options) {
4681
4565
  }
4682
4566
  //#endregion
4683
4567
  //#region src/saves/storage.ts
4684
- /**
4685
- * In-memory storage. Like the other backends it stores copies: records and
4686
- * values go in and come out cloned, so changing a record after storing it,
4687
- * or one that was read, changes nothing stored.
4688
- */
4689
- function createMemoryBackend() {
4690
- const copy = (value) => structuredClone(value);
4691
- const saves = /* @__PURE__ */ new Map();
4692
- const playthroughs = /* @__PURE__ */ new Map();
4693
- const meta = /* @__PURE__ */ new Map();
4568
+ /** A backend's tables, made by its `table` function. */
4569
+ function makeTables(table) {
4694
4570
  return {
4695
- type: "memory",
4696
- async putSave(record) {
4697
- saves.set(record.meta.id, copy(record));
4698
- },
4699
- async getSave(id) {
4700
- const record = saves.get(id);
4701
- return record && copy(record);
4702
- },
4703
- async deleteSave(id) {
4704
- saves.delete(id);
4705
- },
4706
- async getSavesByIfid(ifid) {
4707
- return [...saves.values()].filter((s) => s.meta.ifid === ifid).map(copy);
4708
- },
4709
- async deleteSavesByIfid(ifid) {
4710
- for (const id of [...saves.keys()]) {
4711
- const s = saves.get(id);
4712
- if (s && s.meta.ifid === ifid) saves.delete(id);
4713
- }
4714
- },
4715
- async deleteSavesByPlaythrough(playthroughId) {
4716
- const deleted = [];
4717
- for (const id of [...saves.keys()]) {
4718
- const s = saves.get(id);
4719
- if (s && s.meta.playthroughId === playthroughId) {
4720
- saves.delete(id);
4721
- deleted.push(id);
4722
- }
4723
- }
4724
- return deleted;
4725
- },
4726
- async putPlaythrough(record) {
4727
- playthroughs.set(record.id, copy(record));
4728
- },
4729
- async getPlaythroughsByIfid(ifid) {
4730
- return [...playthroughs.values()].filter((p) => p.ifid === ifid).map(copy);
4731
- },
4732
- async deletePlaythroughsByIfid(ifid) {
4733
- for (const id of [...playthroughs.keys()]) {
4734
- const p = playthroughs.get(id);
4735
- if (p && p.ifid === ifid) playthroughs.delete(id);
4736
- }
4737
- },
4738
- async deletePlaythroughById(id) {
4739
- playthroughs.delete(id);
4740
- },
4741
- async getMeta(key) {
4742
- return copy(meta.get(key));
4743
- },
4744
- async setMeta(key, value) {
4745
- meta.set(key, copy(value));
4746
- },
4747
- async deleteMeta(key) {
4748
- meta.delete(key);
4749
- },
4750
- async deleteMetaByPrefix(prefix) {
4751
- for (const key of [...meta.keys()]) if (key.startsWith(prefix)) meta.delete(key);
4752
- },
4753
- async deleteMetaByIfid(ifid) {
4754
- for (const key of [...meta.keys()]) if (key.includes(ifid)) meta.delete(key);
4571
+ saves: table("saves", {
4572
+ ifid: (r) => r.meta.ifid,
4573
+ playthroughId: (r) => r.meta.playthroughId
4574
+ }),
4575
+ playthroughs: table("playthroughs", { ifid: (r) => r.ifid }),
4576
+ meta: table("meta", {})
4577
+ };
4578
+ }
4579
+ function createBackend(type, { saves, playthroughs, meta }, destroy) {
4580
+ /** Delete the records `field` finds, resolving to their keys. */
4581
+ async function deleteFound(table, keyOf, field, value) {
4582
+ const keys = (await table.find(field, value)).map(keyOf);
4583
+ for (const key of keys) await table.delete(key);
4584
+ return keys;
4585
+ }
4586
+ async function deleteMetaWhere(test) {
4587
+ for (const key of await meta.keys()) if (test(key)) await meta.delete(key);
4588
+ }
4589
+ const saveId = (r) => r.meta.id;
4590
+ const playthroughId = (r) => r.id;
4591
+ return {
4592
+ type,
4593
+ putSave: (record) => saves.put(record.meta.id, record),
4594
+ getSave: (id) => saves.get(id),
4595
+ deleteSave: (id) => saves.delete(id),
4596
+ getSavesByIfid: (ifid) => saves.find("ifid", ifid),
4597
+ deleteSavesByIfid: async (ifid) => {
4598
+ await deleteFound(saves, saveId, "ifid", ifid);
4755
4599
  },
4756
- async getAllMetaKeys() {
4757
- return [...meta.keys()];
4600
+ deleteSavesByPlaythrough: (id) => deleteFound(saves, saveId, "playthroughId", id),
4601
+ putPlaythrough: (record) => playthroughs.put(record.id, record),
4602
+ getPlaythroughsByIfid: (ifid) => playthroughs.find("ifid", ifid),
4603
+ deletePlaythroughsByIfid: async (ifid) => {
4604
+ await deleteFound(playthroughs, playthroughId, "ifid", ifid);
4758
4605
  },
4759
- async destroy() {
4760
- saves.clear();
4761
- playthroughs.clear();
4762
- meta.clear();
4763
- }
4606
+ deletePlaythroughById: (id) => playthroughs.delete(id),
4607
+ getMeta: (key) => meta.get(key),
4608
+ setMeta: (key, value) => meta.put(key, value),
4609
+ deleteMeta: (key) => meta.delete(key),
4610
+ deleteMetaByPrefix: (prefix) => deleteMetaWhere((key) => key.startsWith(prefix)),
4611
+ deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
4612
+ getAllMetaKeys: () => meta.keys(),
4613
+ destroy
4764
4614
  };
4765
4615
  }
4766
- var LS_SAVE_PREFIX = "spindle.save.";
4767
- var LS_PT_PREFIX = "spindle.pt.";
4768
- var LS_META_PREFIX = "spindle.meta.";
4769
- var LS_IDX_SAVES_PREFIX = "spindle.idx.saves.";
4770
- var LS_IDX_PT_PREFIX = "spindle.idx.pt.";
4771
- var LS_IDX_SAVES_PT_PREFIX = "spindle.idx.saves-pt.";
4616
+ /**
4617
+ * In-memory storage. Like the other backends it stores copies: records and
4618
+ * values go in and come out cloned, so changing a record after storing it,
4619
+ * or one that was read, changes nothing stored.
4620
+ */
4621
+ function createMemoryBackend() {
4622
+ const copy = (value) => structuredClone(value);
4623
+ const maps = [];
4624
+ function table(_name, indexes) {
4625
+ const rows = /* @__PURE__ */ new Map();
4626
+ maps.push(rows);
4627
+ return {
4628
+ get: async (key) => copy(rows.get(key)),
4629
+ put: async (key, value) => {
4630
+ rows.set(key, copy(value));
4631
+ },
4632
+ delete: async (key) => {
4633
+ rows.delete(key);
4634
+ },
4635
+ find: async (field, value) => [...rows.values()].filter((r) => indexes[field](r) === value).map(copy),
4636
+ keys: async () => [...rows.keys()]
4637
+ };
4638
+ }
4639
+ return createBackend("memory", makeTables(table), async () => {
4640
+ for (const rows of maps) rows.clear();
4641
+ });
4642
+ }
4643
+ /** Key prefixes: of the records, and of each index's ID lists. */
4644
+ var LS_PREFIXES = {
4645
+ saves: "spindle.save.",
4646
+ playthroughs: "spindle.pt.",
4647
+ meta: "spindle.meta."
4648
+ };
4649
+ var LS_INDEX_PREFIXES = {
4650
+ "saves.ifid": "spindle.idx.saves.",
4651
+ "saves.playthroughId": "spindle.idx.saves-pt.",
4652
+ "playthroughs.ifid": "spindle.idx.pt."
4653
+ };
4772
4654
  function lsGet(key) {
4773
4655
  const raw = localStorage.getItem(key);
4774
4656
  if (raw === null) return void 0;
@@ -4794,107 +4676,55 @@ function lsIndexAdd(key, entry) {
4794
4676
  lsSet(key, arr);
4795
4677
  }
4796
4678
  }
4679
+ /** Remove an ID from an index; an index left empty is removed. */
4797
4680
  function lsIndexRemove(key, entry) {
4798
- lsSet(key, lsIndex(key).filter((e) => e !== entry));
4681
+ const arr = lsIndex(key).filter((e) => e !== entry);
4682
+ if (arr.length > 0) lsSet(key, arr);
4683
+ else lsDel(key);
4799
4684
  }
4685
+ /** The keys of every stored item that start with `prefix`. */
4686
+ var lsKeys = (prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix));
4800
4687
  function createLocalStorageBackend() {
4801
- return {
4802
- type: "localstorage",
4803
- async putSave(record) {
4804
- const id = record.meta.id;
4805
- const previous = lsGet(`${LS_SAVE_PREFIX}${id}`);
4806
- if (previous && previous.meta.playthroughId !== record.meta.playthroughId) lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`, id);
4807
- lsSet(`${LS_SAVE_PREFIX}${id}`, record);
4808
- lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
4809
- lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4810
- },
4811
- async getSave(id) {
4812
- return lsGet(`${LS_SAVE_PREFIX}${id}`);
4813
- },
4814
- async deleteSave(id) {
4815
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4816
- if (record) {
4817
- lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
4818
- lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4819
- }
4820
- lsDel(`${LS_SAVE_PREFIX}${id}`);
4821
- },
4822
- async getSavesByIfid(ifid) {
4823
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4824
- const results = [];
4825
- for (const id of ids) {
4826
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4827
- if (record) results.push(record);
4828
- }
4829
- return results;
4830
- },
4831
- async deleteSavesByIfid(ifid) {
4832
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4833
- for (const id of ids) {
4834
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4835
- if (record) lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
4836
- lsDel(`${LS_SAVE_PREFIX}${id}`);
4837
- }
4838
- lsDel(`${LS_IDX_SAVES_PREFIX}${ifid}`);
4839
- },
4840
- async deleteSavesByPlaythrough(playthroughId) {
4841
- const ids = lsIndex(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
4842
- for (const id of ids) {
4843
- const record = lsGet(`${LS_SAVE_PREFIX}${id}`);
4844
- if (record) lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
4845
- lsDel(`${LS_SAVE_PREFIX}${id}`);
4846
- }
4847
- lsDel(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
4848
- return ids;
4849
- },
4850
- async putPlaythrough(record) {
4851
- lsSet(`${LS_PT_PREFIX}${record.id}`, record);
4852
- lsIndexAdd(`${LS_IDX_PT_PREFIX}${record.ifid}`, record.id);
4853
- },
4854
- async getPlaythroughsByIfid(ifid) {
4855
- const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
4856
- const results = [];
4857
- for (const id of ids) {
4858
- const record = lsGet(`${LS_PT_PREFIX}${id}`);
4859
- if (record) results.push(record);
4860
- }
4861
- return results;
4862
- },
4863
- async deletePlaythroughsByIfid(ifid) {
4864
- const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
4865
- for (const id of ids) lsDel(`${LS_PT_PREFIX}${id}`);
4866
- lsDel(`${LS_IDX_PT_PREFIX}${ifid}`);
4867
- },
4868
- async deletePlaythroughById(id) {
4869
- const record = lsGet(`${LS_PT_PREFIX}${id}`);
4870
- if (record) lsIndexRemove(`${LS_IDX_PT_PREFIX}${record.ifid}`, id);
4871
- lsDel(`${LS_PT_PREFIX}${id}`);
4872
- },
4873
- async getMeta(key) {
4874
- return lsGet(`${LS_META_PREFIX}${key}`);
4875
- },
4876
- async setMeta(key, value) {
4877
- lsSet(`${LS_META_PREFIX}${key}`, value);
4878
- },
4879
- async deleteMeta(key) {
4880
- lsDel(`${LS_META_PREFIX}${key}`);
4881
- },
4882
- async deleteMetaByPrefix(prefix) {
4883
- const fullPrefix = `${LS_META_PREFIX}${prefix}`;
4884
- for (const key of Object.keys(localStorage)) if (key.startsWith(fullPrefix)) localStorage.removeItem(key);
4885
- },
4886
- async deleteMetaByIfid(ifid) {
4887
- for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX) && key.includes(ifid)) localStorage.removeItem(key);
4888
- },
4889
- async getAllMetaKeys() {
4890
- const keys = [];
4891
- for (const key of Object.keys(localStorage)) if (key.startsWith(LS_META_PREFIX)) keys.push(key.slice(13));
4892
- return keys;
4893
- },
4894
- async destroy() {
4895
- for (const key of Object.keys(localStorage)) if (key.startsWith("spindle.")) localStorage.removeItem(key);
4896
- }
4897
- };
4688
+ function table(name, fields) {
4689
+ const prefix = LS_PREFIXES[name];
4690
+ const indexes = Object.entries(fields).map(([field, read]) => ({
4691
+ field,
4692
+ read,
4693
+ prefix: LS_INDEX_PREFIXES[`${name}.${field}`]
4694
+ }));
4695
+ /** The index lists `record` is in. */
4696
+ const listsOf = (record) => indexes.map((index) => `${index.prefix}${index.read(record)}`);
4697
+ return {
4698
+ get: async (key) => lsGet(`${prefix}${key}`),
4699
+ async put(key, value) {
4700
+ const previous = lsGet(`${prefix}${key}`);
4701
+ const lists = listsOf(value);
4702
+ if (previous !== void 0) {
4703
+ for (const list of listsOf(previous)) if (!lists.includes(list)) lsIndexRemove(list, key);
4704
+ }
4705
+ lsSet(`${prefix}${key}`, value);
4706
+ for (const list of lists) lsIndexAdd(list, key);
4707
+ },
4708
+ async delete(key) {
4709
+ const record = lsGet(`${prefix}${key}`);
4710
+ if (record !== void 0) for (const list of listsOf(record)) lsIndexRemove(list, key);
4711
+ lsDel(`${prefix}${key}`);
4712
+ },
4713
+ async find(field, value) {
4714
+ const index = indexes.find((i) => i.field === field);
4715
+ const results = [];
4716
+ for (const id of lsIndex(`${index.prefix}${value}`)) {
4717
+ const record = lsGet(`${prefix}${id}`);
4718
+ if (record !== void 0) results.push(record);
4719
+ }
4720
+ return results;
4721
+ },
4722
+ keys: async () => lsKeys(prefix).map((key) => key.slice(prefix.length))
4723
+ };
4724
+ }
4725
+ return createBackend("localstorage", makeTables(table), async () => {
4726
+ for (const key of lsKeys("spindle.")) lsDel(key);
4727
+ });
4898
4728
  }
4899
4729
  var IDB_DB_NAME = "spindle";
4900
4730
  var IDB_DB_VERSION = 1;
@@ -4919,95 +4749,51 @@ function createIDBBackend() {
4919
4749
  });
4920
4750
  return dbPromise;
4921
4751
  }
4922
- function tx(storeName, mode) {
4923
- return openDB().then((db) => db.transaction(storeName, mode).objectStore(storeName));
4924
- }
4925
4752
  function idbReq(req) {
4926
4753
  return new Promise((resolve, reject) => {
4927
4754
  req.onsuccess = () => resolve(req.result);
4928
4755
  req.onerror = () => reject(req.error);
4929
4756
  });
4930
4757
  }
4931
- async function getAllFromIndex(storeName, indexName, value) {
4932
- return await idbReq((await tx(storeName, "readonly")).index(indexName).getAll(value)) ?? [];
4758
+ /**
4759
+ * An object store. Records hold their own key (keyPath); meta values are
4760
+ * stored as `{ key, value }` rows.
4761
+ */
4762
+ function table(name) {
4763
+ const rows = name === "meta";
4764
+ /** Run one request in a transaction of its own. */
4765
+ const request = async (mode, make) => {
4766
+ return idbReq(make((await openDB()).transaction(name, mode).objectStore(name)));
4767
+ };
4768
+ return {
4769
+ async get(key) {
4770
+ const result = await request("readonly", (s) => s.get(key));
4771
+ return rows ? result?.value : result;
4772
+ },
4773
+ async put(key, value) {
4774
+ await request("readwrite", (s) => s.put(rows ? {
4775
+ key,
4776
+ value
4777
+ } : value));
4778
+ },
4779
+ async delete(key) {
4780
+ await request("readwrite", (s) => s.delete(key));
4781
+ },
4782
+ async find(field, value) {
4783
+ return await request("readonly", (s) => s.index(field).getAll(value)) ?? [];
4784
+ },
4785
+ keys: async () => await request("readonly", (s) => s.getAllKeys())
4786
+ };
4933
4787
  }
4934
- return {
4935
- type: "indexeddb",
4936
- async putSave(record) {
4937
- await idbReq((await tx("saves", "readwrite")).put(record));
4938
- },
4939
- async getSave(id) {
4940
- return await idbReq((await tx("saves", "readonly")).get(id)) ?? void 0;
4941
- },
4942
- async deleteSave(id) {
4943
- await idbReq((await tx("saves", "readwrite")).delete(id));
4944
- },
4945
- async getSavesByIfid(ifid) {
4946
- return getAllFromIndex("saves", "ifid", ifid);
4947
- },
4948
- async deleteSavesByIfid(ifid) {
4949
- const records = await getAllFromIndex("saves", "ifid", ifid);
4950
- const store = await tx("saves", "readwrite");
4951
- for (const r of records) await idbReq(store.delete(r.meta.id));
4952
- },
4953
- async deleteSavesByPlaythrough(playthroughId) {
4954
- const records = await getAllFromIndex("saves", "playthroughId", playthroughId);
4955
- const store = await tx("saves", "readwrite");
4956
- const ids = [];
4957
- for (const r of records) {
4958
- await idbReq(store.delete(r.meta.id));
4959
- ids.push(r.meta.id);
4960
- }
4961
- return ids;
4962
- },
4963
- async putPlaythrough(record) {
4964
- await idbReq((await tx("playthroughs", "readwrite")).put(record));
4965
- },
4966
- async getPlaythroughsByIfid(ifid) {
4967
- return getAllFromIndex("playthroughs", "ifid", ifid);
4968
- },
4969
- async deletePlaythroughsByIfid(ifid) {
4970
- const records = await getAllFromIndex("playthroughs", "ifid", ifid);
4971
- const store = await tx("playthroughs", "readwrite");
4972
- for (const r of records) await idbReq(store.delete(r.id));
4973
- },
4974
- async deletePlaythroughById(id) {
4975
- await idbReq((await tx("playthroughs", "readwrite")).delete(id));
4976
- },
4977
- async getMeta(key) {
4978
- const row = await idbReq((await tx("meta", "readonly")).get(key));
4979
- return row ? row.value : void 0;
4980
- },
4981
- async setMeta(key, value) {
4982
- await idbReq((await tx("meta", "readwrite")).put({
4983
- key,
4984
- value
4985
- }));
4986
- },
4987
- async deleteMeta(key) {
4988
- await idbReq((await tx("meta", "readwrite")).delete(key));
4989
- },
4990
- async deleteMetaByPrefix(prefix) {
4991
- const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
4992
- for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
4993
- },
4994
- async deleteMetaByIfid(ifid) {
4995
- const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
4996
- for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
4997
- },
4998
- async getAllMetaKeys() {
4999
- return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
5000
- },
5001
- async destroy() {
5002
- (await openDB()).close();
5003
- dbPromise = null;
5004
- await new Promise((resolve, reject) => {
5005
- const req = indexedDB.deleteDatabase(IDB_DB_NAME);
5006
- req.onsuccess = () => resolve();
5007
- req.onerror = () => reject(req.error);
5008
- });
5009
- }
5010
- };
4788
+ return createBackend("indexeddb", makeTables(table), async () => {
4789
+ (await openDB()).close();
4790
+ dbPromise = null;
4791
+ await new Promise((resolve, reject) => {
4792
+ const req = indexedDB.deleteDatabase(IDB_DB_NAME);
4793
+ req.onsuccess = () => resolve();
4794
+ req.onerror = () => reject(req.error);
4795
+ });
4796
+ });
5011
4797
  }
5012
4798
  var _backend = null;
5013
4799
  async function detectBackend() {
@@ -5156,15 +4942,17 @@ function inOrder(op) {
5156
4942
  operationQueue = run.catch(() => {});
5157
4943
  return run;
5158
4944
  }
4945
+ /** `op` (a `...Now` helper) as an operation that queues itself. */
4946
+ function queued(op) {
4947
+ return (...args) => inOrder(() => op(...args));
4948
+ }
5159
4949
  /**
5160
4950
  * Store a new playthrough and make it the story's current one. Pass `id` when
5161
4951
  * the caller has already switched to the playthrough (restart() does, so
5162
4952
  * saves issued before this resolves are tagged with it).
5163
4953
  */
5164
- function startNewPlaythrough(ifid, id = crypto.randomUUID()) {
5165
- return inOrder(() => startNewPlaythroughNow(ifid, id));
5166
- }
5167
- async function startNewPlaythroughNow(ifid, id) {
4954
+ var startNewPlaythrough = queued(startNewPlaythroughNow);
4955
+ async function startNewPlaythroughNow(ifid, id = crypto.randomUUID()) {
5168
4956
  const backend = await getBackend();
5169
4957
  const num = await nextPlaythroughNumber(ifid);
5170
4958
  const record = {
@@ -5209,19 +4997,26 @@ function currentPlaythroughKey(ifid) {
5209
4997
  * recorded again as "Imported", as importing the save would: it was not
5210
4998
  * started here, so it takes no number.
5211
4999
  */
5212
- function adoptPlaythrough(ifid, id) {
5213
- return inOrder(() => adoptPlaythroughNow(ifid, id));
5214
- }
5000
+ var adoptPlaythrough = queued(adoptPlaythroughNow);
5215
5001
  async function adoptPlaythroughNow(ifid, id) {
5002
+ const backend = await getBackend();
5003
+ await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
5004
+ const key = currentPlaythroughKey(ifid);
5005
+ if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
5006
+ }
5007
+ /**
5008
+ * Record the playthrough `id` of the story as "Imported", created at
5009
+ * `createdAt`, unless it has a record: a playthrough not started here takes
5010
+ * no number.
5011
+ */
5012
+ async function ensurePlaythrough(ifid, id, createdAt) {
5216
5013
  const backend = await getBackend();
5217
5014
  if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === id)) await backend.putPlaythrough({
5218
5015
  id,
5219
5016
  ifid,
5220
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5017
+ createdAt,
5221
5018
  label: "Imported"
5222
5019
  });
5223
- const key = currentPlaythroughKey(ifid);
5224
- if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
5225
5020
  }
5226
5021
  /**
5227
5022
  * Set up the save system for a story that boots: the story's current
@@ -5231,7 +5026,7 @@ async function adoptPlaythroughNow(ifid, id) {
5231
5026
  function establishPlaythrough(ifid) {
5232
5027
  return inOrder(async () => {
5233
5028
  return {
5234
- id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid, crypto.randomUUID()),
5029
+ id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
5235
5030
  knownSaves: await populateKnownSavesNow(ifid)
5236
5031
  };
5237
5032
  });
@@ -5266,12 +5061,35 @@ function saveWithHooks(slot, custom, beginCapture, write) {
5266
5061
  return Promise.reject(err);
5267
5062
  }
5268
5063
  }
5269
- function createSave(ifid, playthroughId, payload, custom = {}) {
5270
- return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
5064
+ /**
5065
+ * `payload` with `convert` applied to its live variables and to those of
5066
+ * every history moment.
5067
+ */
5068
+ function mapVariables(payload, convert) {
5069
+ return {
5070
+ ...payload,
5071
+ variables: convert(payload.variables),
5072
+ history: payload.history.map((m) => ({
5073
+ ...m,
5074
+ variables: convert(m.variables)
5075
+ }))
5076
+ };
5077
+ }
5078
+ /** The record to store for a save: `payload` serialized, and its size. */
5079
+ function storedRecord(meta, payload) {
5080
+ const serialized = mapVariables(deepClone(payload), serialize);
5081
+ return {
5082
+ meta: {
5083
+ ...meta,
5084
+ estimatedBytes: estimatePayloadBytes(serialized)
5085
+ },
5086
+ payload: serialized
5087
+ };
5271
5088
  }
5272
- async function createSaveNow(ifid, playthroughId, payload, custom) {
5089
+ var createSave = queued(createSaveNow);
5090
+ async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
5273
5091
  const now = (/* @__PURE__ */ new Date()).toISOString();
5274
- const meta = {
5092
+ const record = storedRecord({
5275
5093
  id: crypto.randomUUID(),
5276
5094
  ifid,
5277
5095
  playthroughId,
@@ -5280,18 +5098,7 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
5280
5098
  title: generateTitle(payload),
5281
5099
  passage: payload.passage,
5282
5100
  custom
5283
- };
5284
- const serializedPayload = deepClone(payload);
5285
- serializedPayload.variables = serialize(serializedPayload.variables);
5286
- serializedPayload.history = serializedPayload.history.map((m) => ({
5287
- ...m,
5288
- variables: serialize(m.variables)
5289
- }));
5290
- const record = {
5291
- meta,
5292
- payload: serializedPayload
5293
- };
5294
- record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
5101
+ }, payload);
5295
5102
  await (await getBackend()).putSave(record);
5296
5103
  return record;
5297
5104
  }
@@ -5310,34 +5117,22 @@ async function createSaveNow(ifid, playthroughId, payload, custom) {
5310
5117
  * save itself rather than describing its content. `custom` is merged, as
5311
5118
  * before, so slot keys and metadata not passed again are kept.
5312
5119
  */
5313
- function overwriteSave(saveId, payload, custom, playthroughId) {
5314
- return inOrder(() => overwriteSaveNow(saveId, payload, custom, playthroughId));
5315
- }
5120
+ var overwriteSave = queued(overwriteSaveNow);
5316
5121
  async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
5317
5122
  const backend = await getBackend();
5318
5123
  const existing = await backend.getSave(saveId);
5319
5124
  if (!existing) return void 0;
5320
- const serializedPayload = deepClone(payload);
5321
- serializedPayload.variables = serialize(serializedPayload.variables);
5322
- serializedPayload.history = serializedPayload.history.map((m) => ({
5323
- ...m,
5324
- variables: serialize(m.variables)
5325
- }));
5326
- const updated = {
5327
- meta: {
5328
- ...existing.meta,
5329
- ...playthroughId ? { playthroughId } : {},
5330
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5331
- title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
5332
- passage: payload.passage,
5333
- ...custom != null ? { custom: {
5334
- ...existing.meta.custom,
5335
- ...custom
5336
- } } : {}
5337
- },
5338
- payload: serializedPayload
5339
- };
5340
- updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
5125
+ const updated = storedRecord({
5126
+ ...existing.meta,
5127
+ ...playthroughId ? { playthroughId } : {},
5128
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5129
+ title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
5130
+ passage: payload.passage,
5131
+ ...custom != null ? { custom: {
5132
+ ...existing.meta.custom,
5133
+ ...custom
5134
+ } } : {}
5135
+ }, payload);
5341
5136
  await backend.putSave(updated);
5342
5137
  return updated;
5343
5138
  }
@@ -5347,15 +5142,11 @@ async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
5347
5142
  * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
5348
5143
  */
5349
5144
  function deserializePayload(payload) {
5350
- return {
5351
- ...payload,
5352
- variables: deserialize(payload.variables),
5353
- history: payload.history.map((m) => ({
5354
- ...m,
5355
- variables: deserialize(m.variables)
5356
- }))
5357
- };
5145
+ return mapVariables(payload, deserialize);
5358
5146
  }
5147
+ /** The live payload of a stored save, if there is one. */
5148
+ var livePayload = (record) => record && deserializePayload(record.payload);
5149
+ queued(async (saveId) => livePayload(await (await getBackend()).getSave(saveId)));
5359
5150
  /**
5360
5151
  * Delete a save record. If the default slot or a named slot holds it, that
5361
5152
  * slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
@@ -5366,14 +5157,7 @@ function deleteSaveById(saveId) {
5366
5157
  const record = await backend.getSave(saveId);
5367
5158
  await backend.deleteSave(saveId);
5368
5159
  if (!record) return;
5369
- const ifid = record.meta.ifid;
5370
- const slots = [void 0, ...await getIndexedSlots(ifid)];
5371
- for (const slot of slots) {
5372
- const metaKey = slotMetaKey(ifid, slot);
5373
- if (await backend.getMeta(metaKey) !== saveId) continue;
5374
- await backend.deleteMeta(metaKey);
5375
- await removeFromSlotIndex(ifid, slot);
5376
- }
5160
+ await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
5377
5161
  });
5378
5162
  }
5379
5163
  function renameSave(saveId, newTitle) {
@@ -5393,10 +5177,7 @@ function renameSave(saveId, newTitle) {
5393
5177
  await backend.putSave(updated);
5394
5178
  });
5395
5179
  }
5396
- function getSavesGrouped(ifid) {
5397
- return inOrder(() => getSavesGroupedNow(ifid));
5398
- }
5399
- async function getSavesGroupedNow(ifid) {
5180
+ var getSavesGrouped = queued(async function getSavesGroupedNow(ifid) {
5400
5181
  const backend = await getBackend();
5401
5182
  const [allSaves, allPlaythroughs] = await Promise.all([backend.getSavesByIfid(ifid), backend.getPlaythroughsByIfid(ifid)]);
5402
5183
  const ptMap = /* @__PURE__ */ new Map();
@@ -5428,7 +5209,7 @@ async function getSavesGroupedNow(ifid) {
5428
5209
  saves
5429
5210
  });
5430
5211
  return result;
5431
- }
5212
+ });
5432
5213
  var AUTOSAVE_KEY_PREFIX = "autosave.";
5433
5214
  var SLOT_KEY_PREFIX = "slot.";
5434
5215
  var SLOT_INDEX_KEY_PREFIX = "slotIndex.";
@@ -5442,29 +5223,49 @@ function isNamedSlot(slot) {
5442
5223
  function slotMetaKey(ifid, slot) {
5443
5224
  return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
5444
5225
  }
5226
+ var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5445
5227
  /** Read-modify-write the per-story slot index. */
5446
5228
  async function updateSlotIndex(ifid, update) {
5447
5229
  const backend = await getBackend();
5448
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5449
- const existing = await backend.getMeta(indexKey) ?? [];
5230
+ const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
5450
5231
  const updated = update(existing);
5451
- if (updated !== existing) await backend.setMeta(indexKey, updated);
5452
- }
5453
- /** Record a named slot in the per-story slot index (no-op for the default slot). */
5454
- function addToSlotIndex(ifid, slot) {
5455
- if (!isNamedSlot(slot)) return Promise.resolve();
5456
- return updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
5457
- }
5458
- /** Remove a named slot from the per-story slot index (no-op for the default slot). */
5459
- function removeFromSlotIndex(ifid, slot) {
5460
- if (!isNamedSlot(slot)) return Promise.resolve();
5461
- return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
5462
- }
5463
- /** Named slots in the per-story slot index. */
5464
- async function getIndexedSlots(ifid) {
5465
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
5466
- return (await (await getBackend()).getMeta(indexKey) ?? []).filter(isNamedSlot);
5467
- }
5232
+ if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
5233
+ }
5234
+ /** Every slot: the default one (undefined), then those in the slot index. */
5235
+ async function allSlots(ifid) {
5236
+ return [void 0, ...(await (await getBackend()).getMeta(slotIndexKey(ifid)) ?? []).filter(isNamedSlot)];
5237
+ }
5238
+ /** The ID of the save a slot holds, if any. */
5239
+ async function slotSaveId(ifid, slot) {
5240
+ return (await getBackend()).getMeta(slotMetaKey(ifid, slot));
5241
+ }
5242
+ /** The record of the save a slot holds, if there is one. */
5243
+ async function slotRecord(ifid, slot) {
5244
+ const id = await slotSaveId(ifid, slot);
5245
+ return id ? (await getBackend()).getSave(id) : void 0;
5246
+ }
5247
+ /** Make a slot hold the save `saveId`: its pointer, and the slot index. */
5248
+ async function fillSlot(ifid, slot, saveId) {
5249
+ await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
5250
+ if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.includes(slot) ? slots : [...slots, slot]);
5251
+ }
5252
+ /** Clear a slot: its pointer, and its entry in the slot index. */
5253
+ async function emptySlot(ifid, slot) {
5254
+ await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
5255
+ if (isNamedSlot(slot)) await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
5256
+ }
5257
+ /** Clear every slot holding a save whose ID passes `test`. */
5258
+ async function emptySlotsHolding(ifid, test) {
5259
+ for (const slot of await allSlots(ifid)) {
5260
+ const id = await slotSaveId(ifid, slot);
5261
+ if (id && test(id)) await emptySlot(ifid, slot);
5262
+ }
5263
+ }
5264
+ /** The `custom` keys that say which slot a save lives in. */
5265
+ var slotCustom = (slot) => ({
5266
+ isAutosave: !isNamedSlot(slot),
5267
+ ...isNamedSlot(slot) ? { slot } : {}
5268
+ });
5468
5269
  function toSaveInfo(record, slot) {
5469
5270
  return {
5470
5271
  slot: isNamedSlot(slot) ? slot : "",
@@ -5482,32 +5283,26 @@ function toSaveInfo(record, slot) {
5482
5283
  * place in the order of operations when it is called.
5483
5284
  */
5484
5285
  function quickSave(ifid, playthroughId, payload, slot, custom) {
5485
- const metaKey = slotMetaKey(ifid, slot);
5486
5286
  return inOrder(async () => {
5487
5287
  const ptId = await playthroughId;
5488
- const backend = await getBackend();
5489
- const existingId = await backend.getMeta(metaKey);
5288
+ const existingId = await slotSaveId(ifid, slot);
5490
5289
  if (existingId) {
5491
5290
  const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
5492
5291
  if (updated) return updated;
5493
5292
  }
5494
5293
  const record = await createSaveNow(ifid, ptId, payload, {
5495
- isAutosave: !isNamedSlot(slot),
5496
- ...isNamedSlot(slot) ? { slot } : {},
5294
+ ...slotCustom(slot),
5497
5295
  ...custom
5498
5296
  });
5499
- await backend.setMeta(metaKey, record.meta.id);
5500
- await addToSlotIndex(ifid, slot);
5297
+ await fillSlot(ifid, slot, record.meta.id);
5501
5298
  return record;
5502
5299
  });
5503
5300
  }
5301
+ queued(hasQuickSaveNow);
5504
5302
  async function hasQuickSaveNow(ifid, slot) {
5505
- const backend = await getBackend();
5506
- const metaKey = slotMetaKey(ifid, slot);
5507
- const existingId = await backend.getMeta(metaKey);
5508
- if (!existingId) return false;
5509
- return await backend.getSave(existingId) !== void 0;
5303
+ return await slotRecord(ifid, slot) !== void 0;
5510
5304
  }
5305
+ queued(async (ifid, slot) => livePayload(await slotRecord(ifid, slot)));
5511
5306
  /**
5512
5307
  * Read the save held in a slot (the default slot when `slot` is omitted) for
5513
5308
  * the running game to load, and make its playthrough the story's current one
@@ -5516,12 +5311,8 @@ async function hasQuickSaveNow(ifid, slot) {
5516
5311
  * and its playthrough, or undefined if the slot is empty.
5517
5312
  */
5518
5313
  function loadSlotSave(ifid, slot) {
5519
- const metaKey = slotMetaKey(ifid, slot);
5520
5314
  return inOrder(async () => {
5521
- const backend = await getBackend();
5522
- const existingId = await backend.getMeta(metaKey);
5523
- if (!existingId) return void 0;
5524
- const record = await backend.getSave(existingId);
5315
+ const record = await slotRecord(ifid, slot);
5525
5316
  if (!record) return void 0;
5526
5317
  const payload = deserializePayload(record.payload);
5527
5318
  const { playthroughId } = record.meta;
@@ -5536,60 +5327,41 @@ function loadSlotSave(ifid, slot) {
5536
5327
  * Check storage for all known saves and return a map of slot keys to true.
5537
5328
  * The default (autosave) slot uses empty string as key.
5538
5329
  */
5539
- function populateKnownSaves(ifid) {
5540
- return inOrder(() => populateKnownSavesNow(ifid));
5541
- }
5330
+ var populateKnownSaves = queued(populateKnownSavesNow);
5542
5331
  async function populateKnownSavesNow(ifid) {
5543
5332
  const result = Object.create(null);
5544
- if (await hasQuickSaveNow(ifid)) result[""] = true;
5545
- for (const slot of await getIndexedSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot] = true;
5333
+ for (const slot of await allSlots(ifid)) if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ""] = true;
5546
5334
  return result;
5547
5335
  }
5548
5336
  /**
5549
5337
  * Get metadata for a specific save slot.
5550
5338
  * Returns null if no save exists for that slot.
5551
5339
  */
5552
- function getSlotSaveInfo(ifid, slot) {
5553
- return inOrder(() => getSlotSaveInfoNow(ifid, slot));
5554
- }
5340
+ var getSlotSaveInfo = queued(getSlotSaveInfoNow);
5555
5341
  async function getSlotSaveInfoNow(ifid, slot) {
5556
- const backend = await getBackend();
5557
- const metaKey = slotMetaKey(ifid, slot);
5558
- const existingId = await backend.getMeta(metaKey);
5559
- if (!existingId) return null;
5560
- const record = await backend.getSave(existingId);
5561
- if (!record) return null;
5562
- return toSaveInfo(record, slot);
5342
+ const record = await slotRecord(ifid, slot);
5343
+ return record ? toSaveInfo(record, slot) : null;
5563
5344
  }
5564
5345
  /**
5565
5346
  * List metadata for all known save slots (default + named).
5566
5347
  */
5567
- function listSlotSaves(ifid) {
5568
- return inOrder(async () => {
5569
- const result = [];
5570
- const defaultInfo = await getSlotSaveInfoNow(ifid);
5571
- if (defaultInfo) result.push(defaultInfo);
5572
- for (const slot of await getIndexedSlots(ifid)) {
5573
- const info = await getSlotSaveInfoNow(ifid, slot);
5574
- if (info) result.push(info);
5575
- }
5576
- return result;
5577
- });
5578
- }
5348
+ var listSlotSaves = queued(async (ifid) => {
5349
+ const result = [];
5350
+ for (const slot of await allSlots(ifid)) {
5351
+ const info = await getSlotSaveInfoNow(ifid, slot);
5352
+ if (info) result.push(info);
5353
+ }
5354
+ return result;
5355
+ });
5579
5356
  /**
5580
5357
  * Delete a save by slot name. Removes from slot index if named.
5581
5358
  */
5582
- function deleteSlotSave(ifid, slot) {
5583
- const metaKey = slotMetaKey(ifid, slot);
5584
- return inOrder(async () => {
5585
- const backend = await getBackend();
5586
- const existingId = await backend.getMeta(metaKey);
5587
- if (!existingId) return;
5588
- await backend.deleteSave(existingId);
5589
- await backend.deleteMeta(metaKey);
5590
- await removeFromSlotIndex(ifid, slot);
5591
- });
5592
- }
5359
+ var deleteSlotSave = queued(async (ifid, slot) => {
5360
+ const existingId = await slotSaveId(ifid, slot);
5361
+ if (!existingId) return;
5362
+ await (await getBackend()).deleteSave(existingId);
5363
+ await emptySlot(ifid, slot);
5364
+ });
5593
5365
  var SESSION_KEY_PREFIX = "spindle.session.";
5594
5366
  /**
5595
5367
  * Write a pre-serialized session payload to sessionStorage.
@@ -5616,11 +5388,8 @@ function clearSession(ifid) {
5616
5388
  sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
5617
5389
  } catch {}
5618
5390
  }
5619
- function exportSave(saveId) {
5620
- return inOrder(() => exportSaveNow(saveId));
5621
- }
5622
- async function exportSaveNow(saveId) {
5623
- const record = await (await getBackend()).getSave(saveId);
5391
+ /** The export of a stored save, if there is one. */
5392
+ function toExport(record) {
5624
5393
  if (!record) return void 0;
5625
5394
  return {
5626
5395
  version: 1,
@@ -5629,6 +5398,7 @@ async function exportSaveNow(saveId) {
5629
5398
  save: record
5630
5399
  };
5631
5400
  }
5401
+ var exportSave = queued(async (saveId) => toExport(await (await getBackend()).getSave(saveId)));
5632
5402
  /**
5633
5403
  * Validate an export against the running story and build the record to store:
5634
5404
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
@@ -5638,20 +5408,11 @@ async function prepareImport(data, ifid) {
5638
5408
  if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
5639
5409
  if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
5640
5410
  if (!isSaveExport(data)) throw new Error("Invalid save file format");
5641
- const backend = await getBackend();
5642
5411
  const record = deepClone(data.save);
5643
5412
  record.meta.id = crypto.randomUUID();
5644
5413
  record.meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5645
5414
  record.meta.ifid = ifid;
5646
- if (!(await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === record.meta.playthroughId)) {
5647
- const pt = {
5648
- id: record.meta.playthroughId,
5649
- ifid,
5650
- createdAt: record.meta.createdAt,
5651
- label: "Imported"
5652
- };
5653
- await backend.putPlaythrough(pt);
5654
- }
5415
+ await ensurePlaythrough(ifid, record.meta.playthroughId, record.meta.createdAt);
5655
5416
  return record;
5656
5417
  }
5657
5418
  function importSave(data, ifid) {
@@ -5665,13 +5426,7 @@ function importSave(data, ifid) {
5665
5426
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
5666
5427
  * Returns undefined if the slot is empty.
5667
5428
  */
5668
- function exportSlotSave(ifid, slot) {
5669
- return inOrder(async () => {
5670
- const saveId = await (await getBackend()).getMeta(slotMetaKey(ifid, slot));
5671
- if (!saveId) return void 0;
5672
- return exportSaveNow(saveId);
5673
- });
5674
- }
5429
+ var exportSlotSave = queued(async (ifid, slot) => toExport(await slotRecord(ifid, slot)));
5675
5430
  /**
5676
5431
  * Import an exported save into a slot (default autosave slot when `slot` is
5677
5432
  * omitted), replacing any save already in that slot. Applies the same
@@ -5679,35 +5434,29 @@ function exportSlotSave(ifid, slot) {
5679
5434
  * and slot index consistent.
5680
5435
  */
5681
5436
  function importSlotSave(data, ifid, slot) {
5682
- const metaKey = slotMetaKey(ifid, slot);
5683
5437
  return inOrder(async () => {
5684
5438
  const record = await prepareImport(data, ifid);
5685
5439
  const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
5686
5440
  record.meta.custom = {
5687
5441
  ...custom,
5688
- isAutosave: !isNamedSlot(slot),
5689
- ...isNamedSlot(slot) ? { slot } : {}
5442
+ ...slotCustom(slot)
5690
5443
  };
5691
5444
  const backend = await getBackend();
5692
- const previousId = await backend.getMeta(metaKey);
5445
+ const previousId = await slotSaveId(ifid, slot);
5693
5446
  await backend.putSave(record);
5694
- await backend.setMeta(metaKey, record.meta.id);
5695
- await addToSlotIndex(ifid, slot);
5447
+ await fillSlot(ifid, slot, record.meta.id);
5696
5448
  if (previousId) await backend.deleteSave(previousId);
5697
5449
  return toSaveInfo(record, slot);
5698
5450
  });
5699
5451
  }
5700
- function getStorageInfo(ifid) {
5701
- return inOrder(() => getStorageInfoNow(ifid));
5702
- }
5703
- async function getStorageInfoNow(ifid) {
5452
+ var getStorageInfo = queued(async function getStorageInfoNow(ifid) {
5704
5453
  const backend = await getBackend();
5705
5454
  const saves = await backend.getSavesByIfid(ifid);
5706
5455
  const playthroughs = await backend.getPlaythroughsByIfid(ifid);
5707
5456
  let totalBytes = 0;
5708
5457
  for (const save of saves) if (save.meta.estimatedBytes != null) totalBytes += save.meta.estimatedBytes;
5709
5458
  else {
5710
- const bytes = JSON.stringify(save.payload).length;
5459
+ const bytes = estimatePayloadBytes(save.payload);
5711
5460
  save.meta.estimatedBytes = bytes;
5712
5461
  await backend.putSave(save);
5713
5462
  totalBytes += bytes;
@@ -5718,7 +5467,7 @@ async function getStorageInfoNow(ifid) {
5718
5467
  totalBytes,
5719
5468
  backend: backend.type
5720
5469
  };
5721
- }
5470
+ });
5722
5471
  function clearGameData(ifid) {
5723
5472
  return inOrder(async () => {
5724
5473
  const backend = await getBackend();
@@ -5728,9 +5477,7 @@ function clearGameData(ifid) {
5728
5477
  clearSession(ifid);
5729
5478
  });
5730
5479
  }
5731
- function clearAllData() {
5732
- return inOrder(clearAllDataNow);
5733
- }
5480
+ var clearAllData = queued(clearAllDataNow);
5734
5481
  async function clearAllDataNow() {
5735
5482
  await (await getBackend()).destroy();
5736
5483
  resetBackend();
@@ -5758,14 +5505,7 @@ function deletePlaythroughData(ifid, playthroughId, replacement) {
5758
5505
  const deletedSaveIds = await backend.deleteSavesByPlaythrough(playthroughId);
5759
5506
  await backend.deletePlaythroughById(playthroughId);
5760
5507
  const deletedSet = new Set(deletedSaveIds);
5761
- for (const slot of [void 0, ...await getIndexedSlots(ifid)]) {
5762
- const metaKey = slotMetaKey(ifid, slot);
5763
- const value = await backend.getMeta(metaKey);
5764
- if (value && deletedSet.has(value)) {
5765
- await backend.deleteMeta(metaKey);
5766
- await removeFromSlotIndex(ifid, slot);
5767
- }
5768
- }
5508
+ await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
5769
5509
  if (replacement && await replacement.current === playthroughId) {
5770
5510
  await startNewPlaythroughNow(ifid, replacement.id);
5771
5511
  return true;
@@ -5809,135 +5549,21 @@ function executeStoryInit() {
5809
5549
  render(h(() => renderNodes(ast), null), container);
5810
5550
  recordStoryInitState();
5811
5551
  }
5812
- const saveTitlePassage = state.storyData.passages.get("SaveTitle");
5813
- if (saveTitlePassage) setSaveTitlePassage(saveTitlePassage.content);
5814
- }
5815
- /**
5816
- * Initialize a freshly booted story: run StoryInit, restore `session` (the
5817
- * state a page refresh left behind) if there is one, then fire `storyinit`
5818
- * once all state is settled. Without a session, what the `storyinit`
5819
- * handlers set synchronously belongs to the start moment, like StoryInit's
5820
- * changes; a restored session keeps the history it recorded.
5821
- */
5822
- function initializeStory(session) {
5823
- executeStoryInit();
5824
- if (session) useStoryStore.getState().loadFromPayload(session);
5825
- emit("storyinit");
5826
- if (!session) recordStoryInitState();
5827
- }
5828
- //#endregion
5829
- //#region src/utils/object-path.ts
5830
- /**
5831
- * Traverse dot-path segments on an object and return the nested value.
5832
- * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
5833
- * are not story state: unless an object holds one as its own property, it
5834
- * reads as missing, as setByPath() treats it. Other inherited properties
5835
- * (class getters, `size` of a Map) are read.
5836
- */
5837
- function getByPath(obj, segments) {
5838
- let current = obj;
5839
- for (const seg of segments) {
5840
- if (current == null || typeof current !== "object") return void 0;
5841
- if (seg in Object.prototype && !hasOwn$2(current, seg)) return void 0;
5842
- current = current[seg];
5843
- }
5844
- return current;
5845
- }
5846
- /** Array indices ("0", "1", …) and "length": the keys an array holds. */
5847
- var isArrayKey = (key) => key === "length" || /^(0|[1-9]\d*)$/.test(key);
5848
- /**
5849
- * Built-ins whose content is not their properties: a property written into
5850
- * one would be dropped by clones and saves (and by Immer, for Map and Set).
5851
- */
5852
- 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;
5853
- /**
5854
- * Shallow copy that keeps the prototype, so a registered class instance
5855
- * stays an instance of its class (with the same own keys deepClone copies).
5856
- */
5857
- function shallowCopy(value) {
5858
- const copy = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
5859
- for (const key of Object.keys(value)) Object.defineProperty(copy, key, {
5860
- value: value[key],
5861
- enumerable: true,
5862
- writable: true,
5863
- configurable: true
5864
- });
5865
- return copy;
5866
- }
5867
- /**
5868
- * Set a value at dot-path `segments` below `root`.
5869
- *
5870
- * When `root` is an Immer draft, objects Immer does not draft (registered
5871
- * class instances and everything below them) are copied on the way down and
5872
- * the copy is assigned to its parent, so the write produces new references
5873
- * up to the root. Writing into such an object directly would mutate the
5874
- * instance shared with the previous state and with history, and subscribers
5875
- * would see no change. Outside a draft (a private working copy) the path is
5876
- * written in place.
5877
- *
5878
- * The path goes through own properties of plain objects, class instances
5879
- * and arrays (by index); anything else throws a TypeError rather than
5880
- * writing where no clone or save would see it: an inherited property such
5881
- * as a method counts as missing, and Map, Set, Date and RegExp values and
5882
- * other keys of arrays are refused, as is a "__proto__" segment.
5883
- */
5884
- function setByPath(root, segments, value, options = {}) {
5885
- const parent = walkToParent(root, segments, options.createMissing ?? false);
5886
- parent[segments[segments.length - 1]] = value;
5887
- }
5888
- /**
5889
- * Delete the property at dot-path `segments` below `root`, copying
5890
- * undrafted objects on the way down like setByPath(). Does nothing when an
5891
- * intermediate is missing or not an object, or the property is absent (or
5892
- * only inherited).
5893
- */
5894
- function deleteByPath(root, segments) {
5895
- const last = segments[segments.length - 1];
5896
- checkSegments(segments);
5897
- let holder = root;
5898
- for (const seg of segments.slice(0, -1)) {
5899
- if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, seg)) return;
5900
- holder = holder[seg];
5901
- }
5902
- if (holder == null || typeof holder !== "object" || !hasOwn$2(holder, last)) return;
5903
- const parent = walkToParent(root, segments, false);
5904
- delete parent[last];
5905
- }
5906
- /** Refuse a segment that would reach a prototype instead of story state. */
5907
- function checkSegments(segments) {
5908
- if (segments.includes("__proto__")) throw new TypeError(`spindle: Cannot use "__proto__" in a variable path ("${segments.join(".")}")`);
5909
- }
5910
- /** Throw unless `holder` can take `key` as story state (see setByPath). */
5911
- function checkHolder(holder, key, segments, depth) {
5912
- const kind = builtinName(holder) ?? (Array.isArray(holder) && !isArrayKey(key) ? "an array" : "");
5913
- if (kind) throw new TypeError(`spindle: Cannot set property "${key}" on ${kind} (at "${segments.slice(0, depth).join(".")}")`);
5552
+ const saveTitlePassage = state.storyData.passages.get("SaveTitle");
5553
+ if (saveTitlePassage) setSaveTitlePassage(saveTitlePassage.content);
5914
5554
  }
5915
5555
  /**
5916
- * Walk to the object holding the last of `segments`, copying undrafted
5917
- * objects when `root` is an Immer draft (see setByPath). A missing or
5918
- * non-object intermediate is replaced by a plain object when
5919
- * `createMissing` is set, and throws a TypeError otherwise.
5556
+ * Initialize a freshly booted story: run StoryInit, restore `session` (the
5557
+ * state a page refresh left behind) if there is one, then fire `storyinit`
5558
+ * once all state is settled. Without a session, what the `storyinit`
5559
+ * handlers set synchronously belongs to the start moment, like StoryInit's
5560
+ * changes; a restored session keeps the history it recorded.
5920
5561
  */
5921
- function walkToParent(root, segments, createMissing) {
5922
- checkSegments(segments);
5923
- const copyOnWrite = isDraft(root);
5924
- let current = root;
5925
- for (let i = 0; i < segments.length - 1; i++) {
5926
- const seg = segments[i];
5927
- checkHolder(current, seg, segments, i);
5928
- let next = hasOwn$2(current, seg) ? current[seg] : void 0;
5929
- if (next == null || typeof next !== "object") {
5930
- 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(".")}")`);
5931
- next = {};
5932
- current[seg] = next;
5933
- } else if (copyOnWrite && !isDraft(next) && !builtinName(next)) {
5934
- next = shallowCopy(next);
5935
- current[seg] = next;
5936
- }
5937
- current = next;
5938
- }
5939
- checkHolder(current, segments[segments.length - 1], segments, segments.length - 1);
5940
- return current;
5562
+ function initializeStory(session) {
5563
+ executeStoryInit();
5564
+ if (session) useStoryStore.getState().loadFromPayload(session);
5565
+ emit("storyinit");
5566
+ if (!session) recordStoryInitState();
5941
5567
  }
5942
5568
  //#endregion
5943
5569
  //#region src/execute-mutation.ts
@@ -5981,54 +5607,6 @@ function frozenCopy(value) {
5981
5607
  return freeze(cloneValue(value), true);
5982
5608
  }
5983
5609
  /**
5984
- * Objects merged property by property: plain objects and (registered)
5985
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
5986
- * replaced as a whole, since their elements have no stable identity to
5987
- * merge by (a shift moves every index).
5988
- */
5989
- function isMergeable(value) {
5990
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
5991
- }
5992
- var hasOwn$1 = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
5993
- /** Collect the property paths where `work` differs from `base`. */
5994
- function diff(base, work, path, changes, ancestors) {
5995
- if (ancestors.has(work)) return;
5996
- ancestors.add(work);
5997
- for (const key of Object.keys(work)) {
5998
- const b = base[key];
5999
- const w = work[key];
6000
- if (!hasOwn$1(base, key)) changes.push({
6001
- path: [...path, key],
6002
- deleted: false,
6003
- value: w
6004
- });
6005
- else if (Object.is(b, w)) continue;
6006
- else if (isMergeable(b) && isMergeable(w) && Object.getPrototypeOf(b) === Object.getPrototypeOf(w)) diff(b, w, [...path, key], changes, ancestors);
6007
- else if (!deepEqual(b, w)) changes.push({
6008
- path: [...path, key],
6009
- deleted: false,
6010
- value: w
6011
- });
6012
- }
6013
- for (const key of Object.keys(base)) if (!hasOwn$1(work, key)) changes.push({
6014
- path: [...path, key],
6015
- deleted: true
6016
- });
6017
- ancestors.delete(work);
6018
- }
6019
- /** Whether `ns` already holds what `change` would write. */
6020
- function isApplied(ns, change) {
6021
- const parent = getByPath(ns, change.path.slice(0, -1));
6022
- if (parent === null || typeof parent !== "object") return change.deleted;
6023
- const key = change.path[change.path.length - 1];
6024
- if (!hasOwn$1(parent, key)) return change.deleted;
6025
- return !change.deleted && deepEqual(parent[key], change.value);
6026
- }
6027
- function applyChange(ns, change) {
6028
- if (change.deleted) deleteByPath(ns, change.path);
6029
- else setByPath(ns, change.path, change.value);
6030
- }
6031
- /**
6032
5610
  * Apply a write that reached the store while mutations execute to their
6033
5611
  * `work` and `base` copies (see MutationScope), so the code sees it and
6034
5612
  * its commit neither drops it nor reverts it. Called from inside the store
@@ -6046,7 +5624,7 @@ function mirror(draft, ns, change, scopes = activeScopes) {
6046
5624
  });
6047
5625
  } catch {
6048
5626
  const stored = draft[ns][root];
6049
- if (hasOwn$1(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
5627
+ if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
6050
5628
  else delete copy[root];
6051
5629
  }
6052
5630
  }
@@ -6070,7 +5648,7 @@ function changedPath(ns, segments) {
6070
5648
  if (!isMergeable(node)) break;
6071
5649
  const key = String(segment);
6072
5650
  path.push(key);
6073
- node = hasOwn$1(node, key) ? node[key] : void 0;
5651
+ node = hasOwn(node, key) ? node[key] : void 0;
6074
5652
  }
6075
5653
  return path;
6076
5654
  }
@@ -6078,7 +5656,7 @@ function changedPath(ns, segments) {
6078
5656
  function changeTo(ns, path) {
6079
5657
  const parent = getByPath(ns, path.slice(0, -1));
6080
5658
  const key = path[path.length - 1];
6081
- return isMergeable(parent) && hasOwn$1(parent, key) ? {
5659
+ return isMergeable(parent) && hasOwn(parent, key) ? {
6082
5660
  path,
6083
5661
  deleted: false,
6084
5662
  value: parent[key]
@@ -6094,7 +5672,7 @@ function changeTo(ns, path) {
6094
5672
  * replaces exactly what it replaced in program order.
6095
5673
  */
6096
5674
  function writtenPaths(before, after, patches) {
6097
- if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn$1(before, root) || before[root] !== after[root]).map((root) => changeTo(after, [root]));
5675
+ if (patches.some((p) => p.path.length === 1)) return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((root) => !hasOwn(before, root) || before[root] !== after[root]).map((root) => changeTo(after, [root]));
6098
5676
  const seen = /* @__PURE__ */ new Set();
6099
5677
  const changes = [];
6100
5678
  for (const patch of patches) {
@@ -6166,7 +5744,7 @@ function routeStoreUpdate(recipe) {
6166
5744
  try {
6167
5745
  applyChange(namespaces[ns], owned(change));
6168
5746
  } catch {
6169
- if (hasOwn$1(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
5747
+ if (hasOwn(inner.work[ns], root)) namespaces[ns][root] = cloneValue(inner.work[ns][root]);
6170
5748
  else delete namespaces[ns][root];
6171
5749
  }
6172
5750
  mirror(namespaces, ns, change);
@@ -6197,11 +5775,7 @@ function commitScopes(commits) {
6197
5775
  const all = commits.map(({ scope, keepRunning }) => ({
6198
5776
  scope,
6199
5777
  keepRunning,
6200
- changes: NAMESPACES.map((ns) => {
6201
- const list = [];
6202
- diff(scope.base[ns], scope.work[ns], [], list, /* @__PURE__ */ new Set());
6203
- return [ns, list];
6204
- })
5778
+ changes: NAMESPACES.map((ns) => [ns, diffPaths(scope.base[ns], scope.work[ns])])
6205
5779
  }));
6206
5780
  const changed = all.filter(({ changes }) => changes.some(([, list]) => list.length > 0));
6207
5781
  if (changed.length === 0) return;
@@ -6249,12 +5823,7 @@ var running = () => activeScopes.map((scope) => ({
6249
5823
  */
6250
5824
  function resync(scope) {
6251
5825
  const state = useStoryStore.getState();
6252
- for (const ns of NAMESPACES) {
6253
- const work = scope.work[ns];
6254
- const stored = state[ns];
6255
- for (const key of Object.keys(work)) if (!hasOwn$1(stored, key)) delete work[key];
6256
- for (const key of Object.keys(stored)) if (!hasOwn$1(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
6257
- }
5826
+ for (const ns of NAMESPACES) mergeKeys(scope.work[ns], scope.work[ns], state[ns]);
6258
5827
  scope.base = cloneNamespaces(state);
6259
5828
  }
6260
5829
  /**
@@ -6293,7 +5862,7 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
6293
5862
  keepRunning: false
6294
5863
  }]);
6295
5864
  for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
6296
- for (const key of Object.keys(mergedLocals)) if (!hasOwn$1(localsClone, key)) scopeUpdate(key, void 0);
5865
+ for (const key of Object.keys(mergedLocals)) if (!hasOwn(localsClone, key)) scopeUpdate(key, void 0);
6297
5866
  }
6298
5867
  //#endregion
6299
5868
  //#region src/triggers.ts
@@ -6523,7 +6092,7 @@ function clonePatches(patches) {
6523
6092
  function computeVarPatches(prev, curr) {
6524
6093
  const [, forward, inverse] = produceWithPatches(prev, (draft) => {
6525
6094
  const d = draft;
6526
- for (const key of Object.keys(d)) if (!Object.prototype.hasOwnProperty.call(curr, key)) delete d[key];
6095
+ for (const key of Object.keys(d)) if (!hasOwn(curr, key)) delete d[key];
6527
6096
  for (const [key, val] of Object.entries(curr)) d[key] = val;
6528
6097
  });
6529
6098
  return {
@@ -6540,6 +6109,26 @@ function rerecordNewestMoment(vars) {
6540
6109
  if (last < 0) variableBase = vars;
6541
6110
  else patchEntries[last] = computeVarPatches(reconstructVarsAt(last), vars);
6542
6111
  }
6112
+ /**
6113
+ * Call `fn` with the variables recorded for each of the first `count`
6114
+ * history moments, replaying the patches once.
6115
+ */
6116
+ function forEachRecorded(count, fn) {
6117
+ let vars = variableBase;
6118
+ for (let i = 0; i < count; i++) {
6119
+ if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
6120
+ fn(vars, i);
6121
+ }
6122
+ }
6123
+ /** A history moment as saves and the session hold it, with its variables. */
6124
+ function savedMoment(moment, variables) {
6125
+ return {
6126
+ passage: moment.passage,
6127
+ variables,
6128
+ timestamp: moment.timestamp,
6129
+ prng: moment.prng
6130
+ };
6131
+ }
6543
6132
  /** Reconstruct variables at a given history moment by replaying patches. */
6544
6133
  function reconstructVarsAt(index) {
6545
6134
  let vars = variableBase;
@@ -6553,24 +6142,10 @@ function persistSession(get) {
6553
6142
  if (serializedHistory.length > history.length) serializedHistory.length = history.length;
6554
6143
  if (serializedHistory.length < history.length) if (history.length - serializedHistory.length === 1) {
6555
6144
  const i = history.length - 1;
6556
- serializedHistory[i] = {
6557
- passage: history[i].passage,
6558
- variables: serialize(variables),
6559
- timestamp: history[i].timestamp,
6560
- prng: history[i].prng
6561
- };
6562
- } else {
6563
- let vars = variableBase;
6564
- for (let i = 0; i < history.length; i++) {
6565
- if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
6566
- if (i >= serializedHistory.length) serializedHistory[i] = {
6567
- passage: history[i].passage,
6568
- variables: serialize(vars),
6569
- timestamp: history[i].timestamp,
6570
- prng: history[i].prng
6571
- };
6572
- }
6573
- }
6145
+ serializedHistory[i] = savedMoment(history[i], serialize(variables));
6146
+ } else forEachRecorded(history.length, (vars, i) => {
6147
+ if (i >= serializedHistory.length) serializedHistory[i] = savedMoment(history[i], serialize(vars));
6148
+ });
6574
6149
  saveSession(storyData.ifid, {
6575
6150
  passage: currentPassage,
6576
6151
  variables: serialize(variables),
@@ -6642,6 +6217,58 @@ function resetModuleState(base) {
6642
6217
  serializedHistory = [];
6643
6218
  }
6644
6219
  /**
6220
+ * Fresh variables from `defaults` for a game that starts over (init,
6221
+ * restart), recorded as the history base.
6222
+ */
6223
+ function startVariables(defaults) {
6224
+ const initialVars = createNamespace(deepClone(defaults));
6225
+ resetModuleState(deepClone(initialVars));
6226
+ return initialVars;
6227
+ }
6228
+ /**
6229
+ * Enter the start passage with `variables`, as the only history moment
6230
+ * (init, restart). Call it inside a store update.
6231
+ */
6232
+ function enterStart(state, passage, variables, transientDefaults) {
6233
+ state.currentPassage = passage;
6234
+ state.navigationId++;
6235
+ state.variables = variables;
6236
+ state.transient = createNamespace(deepClone(transientDefaults));
6237
+ state.temporary = createNamespace();
6238
+ state.history = [{
6239
+ passage,
6240
+ timestamp: Date.now()
6241
+ }];
6242
+ state.historyIndex = 0;
6243
+ }
6244
+ /**
6245
+ * Move through history by `step` moments (back or forward), restoring the
6246
+ * snapshot recorded for the moment moved to: live variables may hold edits
6247
+ * made since the current moment was recorded.
6248
+ */
6249
+ function moveInHistory(get, set, step) {
6250
+ const { historyIndex, history } = get();
6251
+ const target = historyIndex + step;
6252
+ if (target < 0 || target >= history.length) return;
6253
+ finishEnteredMoment(get, set);
6254
+ const previousPassage = get().currentPassage;
6255
+ const targetPassage = history[target].passage;
6256
+ emit("beforenavigate", targetPassage);
6257
+ const restoredVars = deepClone(reconstructVarsAt(target));
6258
+ set((state) => {
6259
+ state.historyIndex += step;
6260
+ state.currentPassage = state.history[state.historyIndex].passage;
6261
+ state.navigationId++;
6262
+ state.variables = restoredVars;
6263
+ state.temporary = createNamespace();
6264
+ });
6265
+ reinitTriggerState();
6266
+ lastNavigationVars = get().variables;
6267
+ restorePRNGFromMoment(get().history[get().historyIndex]);
6268
+ persistSession(get);
6269
+ emit("afternavigate", targetPassage, previousPassage);
6270
+ }
6271
+ /**
6645
6272
  * Record the state StoryInit left behind as the start moment: its variable
6646
6273
  * snapshot (the history base) and PRNG state. Called by executeStoryInit()
6647
6274
  * after the StoryInit passage has rendered, and again after the `storyinit`
@@ -6652,10 +6279,7 @@ function resetModuleState(base) {
6652
6279
  function recordStoryInitState() {
6653
6280
  const { history, historyIndex, variables } = useStoryStore.getState();
6654
6281
  if (history.length !== 1 || historyIndex !== 0) return;
6655
- variableBase = variables;
6656
- patchEntries = [];
6657
- lastNavigationVars = variables;
6658
- serializedHistory = [];
6282
+ resetModuleState(variables);
6659
6283
  const prng = snapshotPRNG();
6660
6284
  useStoryStore.setState((state) => {
6661
6285
  state.history[0].prng = prng;
@@ -6825,56 +6449,31 @@ function keepHookWrites(payload, before, after) {
6825
6449
  if (!moment) return;
6826
6450
  mergeHookWrites(moment.variables, before, after, /* @__PURE__ */ new Set());
6827
6451
  }
6828
- var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
6829
- /**
6830
- * Whether changes between `a` and `b` merge key by key: both are arrays,
6831
- * or both are objects of one class (see isMergeable).
6832
- */
6833
- function mergeable(a, b) {
6834
- if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b);
6835
- return isMergeable(a) && isMergeable(b) && Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
6836
- }
6837
- /**
6838
- * Apply the hooks' change of a value, from `before` to `after`, to the
6839
- * snapshot's copy of it, `target[key]`. Store updates are immutable, so a
6840
- * value no hook touched keeps its identity; one rebuilt with equal content
6841
- * (mutation code commits whole values) is no change either. Where `target`
6842
- * lacks the key or holds another kind of value (the passage created or
6843
- * replaced it), the hooks' whole value is written.
6844
- */
6845
- function mergeHookWrite(target, key, before, after, ancestors) {
6846
- if (Object.is(before, after)) return;
6847
- if (mergeable(before, after) && hasOwn(target, key) && mergeable(after, target[key]) && !ancestors.has(after)) mergeHookWrites(target[key], before, after, ancestors);
6848
- else if (!deepEqual(before, after)) target[key] = deepClone(after);
6849
- }
6850
6452
  /**
6851
- * Apply the changes between `before` and `after`, two objects or two arrays
6852
- * (see mergeable()), to the snapshot's `target`. Array elements merge by
6853
- * index. Elements the hooks removed from an array's end are removed from
6854
- * the snapshot's array at the same indices, and elements they added are
6855
- * appended to it: where the passage resized the array, its indices do not
6856
- * line up with the snapshot's, and the passage resizes it again on load.
6453
+ * Apply the hooks' changes between `before` and `after`, two objects or two
6454
+ * arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
6455
+ * elements merge by index). A changed value is merged key by key where the
6456
+ * snapshot holds the same kind of value. Where it lacks the key or holds
6457
+ * another kind (the passage created or replaced it), the hooks' whole value
6458
+ * is written.
6857
6459
  */
6858
6460
  function mergeHookWrites(target, before, after, ancestors) {
6859
6461
  ancestors.add(after);
6860
- if (Array.isArray(target)) {
6861
- const b = before;
6862
- const a = after;
6863
- const shared = Math.min(b.length, a.length, target.length);
6864
- for (let i = 0; i < shared; i++) mergeHookWrite(target, String(i), b[i], a[i], ancestors);
6865
- if (a.length < b.length) target.length = Math.min(target.length, a.length);
6866
- for (let i = b.length; i < a.length; i++) target.push(deepClone(a[i]));
6867
- } else {
6868
- for (const key of Object.keys(before)) if (!hasOwn(after, key)) delete target[key];
6869
- for (const key of Object.keys(after)) if (hasOwn(before, key)) mergeHookWrite(target, key, before[key], after[key], ancestors);
6870
- else target[key] = deepClone(after[key]);
6871
- }
6462
+ mergeKeys(target, before, after, (key, b, a) => {
6463
+ if (!hasOwn(target, key) || !mergesWith(a, target[key], true) || ancestors.has(a)) return false;
6464
+ mergeHookWrites(target[key], b, a, ancestors);
6465
+ return true;
6466
+ });
6872
6467
  ancestors.delete(after);
6873
6468
  }
6469
+ /** Restore the PRNG from a snapshot, or reset it without one. */
6470
+ function restorePRNGFrom(prng) {
6471
+ if (prng) restorePRNG(prng.seed, prng.pull);
6472
+ else resetPRNG();
6473
+ }
6874
6474
  /** Restore or reset PRNG from a history moment's snapshot. */
6875
6475
  function restorePRNGFromMoment(moment) {
6876
- if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
6877
- else if (moment) resetPRNG();
6476
+ if (moment) restorePRNGFrom(moment.prng);
6878
6477
  }
6879
6478
  var NAMESPACE_KEYS = [
6880
6479
  "variables",
@@ -6957,6 +6556,46 @@ function handled(p) {
6957
6556
  p.catch(() => {});
6958
6557
  return p;
6959
6558
  }
6559
+ /**
6560
+ * `p` with its failure logged under `message` and passed to `onError`, and
6561
+ * still rejecting; marked as handled (see handled()).
6562
+ */
6563
+ function reported(p, message, onError) {
6564
+ return handled(p.catch((err) => {
6565
+ console.error(message, err);
6566
+ onError?.(err);
6567
+ throw err;
6568
+ }));
6569
+ }
6570
+ /** Record in the slot cache whether `slot` holds a save. */
6571
+ function recordKnownSave(set, slot, known) {
6572
+ set((state) => {
6573
+ const key = slot ?? "";
6574
+ if (known) state.knownSaves = {
6575
+ ...state.knownSaves,
6576
+ [key]: true
6577
+ };
6578
+ else {
6579
+ const { [key]: _, ...rest } = state.knownSaves;
6580
+ state.knownSaves = rest;
6581
+ }
6582
+ });
6583
+ }
6584
+ /**
6585
+ * Queue the clearing of saved data, then restart now: the new playthrough
6586
+ * is stored after it, and operations issued from here on belong to the new
6587
+ * game. The slot cache empties once the clearing is done, after operations
6588
+ * issued before it have updated it.
6589
+ */
6590
+ function clearAndRestart(get, set, clearing, message) {
6591
+ const cleared = clearing.then(() => {
6592
+ set((state) => {
6593
+ state.knownSaves = {};
6594
+ });
6595
+ });
6596
+ get().restart();
6597
+ return reported(cleared, message);
6598
+ }
6960
6599
  var useStoryStore = create()(storyStore((set, get) => ({
6961
6600
  storyData: null,
6962
6601
  currentPassage: "",
@@ -7002,23 +6641,13 @@ var useStoryStore = create()(storyStore((set, get) => ({
7002
6641
  init: (storyData, variableDefaults = {}, transientDefaults = {}) => {
7003
6642
  const startPassage = storyData.passagesById.get(storyData.startNode);
7004
6643
  if (!startPassage) throw new Error(`spindle: Start passage (pid=${storyData.startNode}) not found.`);
7005
- const initialVars = createNamespace(deepClone(variableDefaults));
7006
- resetModuleState(deepClone(initialVars));
6644
+ const initialVars = startVariables(variableDefaults);
7007
6645
  set((state) => {
7008
6646
  state.storyData = storyData;
7009
6647
  state.playthroughId = "";
7010
- state.currentPassage = startPassage.name;
7011
- state.navigationId++;
7012
- state.variables = initialVars;
7013
6648
  state.variableDefaults = variableDefaults;
7014
- state.transient = createNamespace(deepClone(transientDefaults));
7015
6649
  state.transientDefaults = transientDefaults;
7016
- state.temporary = createNamespace();
7017
- state.history = [{
7018
- passage: startPassage.name,
7019
- timestamp: Date.now()
7020
- }];
7021
- state.historyIndex = 0;
6650
+ enterStart(state, startPassage.name, initialVars, transientDefaults);
7022
6651
  state.visitCounts = createCounts(null, startPassage.name);
7023
6652
  state.renderCounts = createCounts(null, startPassage.name);
7024
6653
  });
@@ -7087,48 +6716,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
7087
6716
  emit("afternavigate", passageName, previousPassage);
7088
6717
  for (const next of deferred) get().navigate(next);
7089
6718
  },
7090
- goBack: () => {
7091
- const { historyIndex } = get();
7092
- if (historyIndex <= 0) return;
7093
- finishEnteredMoment(get, set);
7094
- const previousPassage = get().currentPassage;
7095
- const targetPassage = get().history[historyIndex - 1].passage;
7096
- emit("beforenavigate", targetPassage);
7097
- const restoredVars = deepClone(reconstructVarsAt(historyIndex - 1));
7098
- set((state) => {
7099
- state.historyIndex--;
7100
- state.currentPassage = state.history[state.historyIndex].passage;
7101
- state.navigationId++;
7102
- state.variables = restoredVars;
7103
- state.temporary = createNamespace();
7104
- });
7105
- reinitTriggerState();
7106
- lastNavigationVars = get().variables;
7107
- restorePRNGFromMoment(get().history[get().historyIndex]);
7108
- persistSession(get);
7109
- emit("afternavigate", targetPassage, previousPassage);
7110
- },
7111
- goForward: () => {
7112
- const { historyIndex, history: hist } = get();
7113
- if (historyIndex >= hist.length - 1) return;
7114
- finishEnteredMoment(get, set);
7115
- const previousPassage = get().currentPassage;
7116
- const targetPassage = hist[historyIndex + 1].passage;
7117
- emit("beforenavigate", targetPassage);
7118
- const restoredVars = deepClone(reconstructVarsAt(historyIndex + 1));
7119
- set((state) => {
7120
- state.historyIndex++;
7121
- state.currentPassage = state.history[state.historyIndex].passage;
7122
- state.navigationId++;
7123
- state.variables = restoredVars;
7124
- state.temporary = createNamespace();
7125
- });
7126
- reinitTriggerState();
7127
- lastNavigationVars = get().variables;
7128
- restorePRNGFromMoment(get().history[get().historyIndex]);
7129
- persistSession(get);
7130
- emit("afternavigate", targetPassage, previousPassage);
7131
- },
6719
+ goBack: () => moveInHistory(get, set, -1),
6720
+ goForward: () => moveInHistory(get, set, 1),
7132
6721
  setVariable: (name, value) => {
7133
6722
  set((state) => {
7134
6723
  checkVariableName(name, `$${name}`);
@@ -7190,19 +6779,9 @@ var useStoryStore = create()(storyStore((set, get) => ({
7190
6779
  cleanupRuntimeHandlers();
7191
6780
  resetPRNG();
7192
6781
  resetTriggers();
7193
- const initialVars = createNamespace(deepClone(variableDefaults));
7194
- resetModuleState(deepClone(initialVars));
6782
+ const initialVars = startVariables(variableDefaults);
7195
6783
  set((state) => {
7196
- state.currentPassage = startPassage.name;
7197
- state.navigationId++;
7198
- state.variables = initialVars;
7199
- state.transient = createNamespace(deepClone(transientDefaults));
7200
- state.temporary = createNamespace();
7201
- state.history = [{
7202
- passage: startPassage.name,
7203
- timestamp: Date.now()
7204
- }];
7205
- state.historyIndex = 0;
6784
+ enterStart(state, startPassage.name, initialVars, transientDefaults);
7206
6785
  state.visitCounts = createCounts(null, startPassage.name);
7207
6786
  state.renderCounts = createCounts(null, startPassage.name);
7208
6787
  if (!keepDeferred) state.renderDeferred = false;
@@ -7218,23 +6797,14 @@ var useStoryStore = create()(storyStore((set, get) => ({
7218
6797
  const { storyData } = get();
7219
6798
  if (!storyData) return Promise.resolve();
7220
6799
  const playthrough = resolvePlaythroughId();
7221
- return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
6800
+ return reported(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
7222
6801
  set((state) => {
7223
6802
  state.saveError = null;
7224
6803
  });
7225
6804
  await quickSave(storyData.ifid, playthrough, payload, slot, custom);
7226
- set((state) => {
7227
- state.knownSaves = {
7228
- ...state.knownSaves,
7229
- [slot ?? ""]: true
7230
- };
7231
- });
7232
- }).catch((err) => {
7233
- console.error("spindle: failed to save", err);
7234
- set((state) => {
7235
- state.saveError = errorMessage(err, "Failed to save");
7236
- });
7237
- throw err;
6805
+ recordKnownSave(set, slot, true);
6806
+ }), "spindle: failed to save", (err) => set((state) => {
6807
+ state.saveError = errorMessage(err, "Failed to save");
7238
6808
  }));
7239
6809
  },
7240
6810
  load: (slot) => {
@@ -7247,24 +6817,20 @@ var useStoryStore = create()(storyStore((set, get) => ({
7247
6817
  const read = loadSlotSave(storyData.ifid, slot);
7248
6818
  const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
7249
6819
  const replacement = ++stateReplacementsIssued;
7250
- return handled(read.then(async (loaded) => {
6820
+ return reported(read.then(async (loaded) => {
7251
6821
  await switched;
7252
6822
  if (!loaded) return;
7253
6823
  if (latestStateApplied > replacement) return;
7254
6824
  slotLoadApplying = replacement;
7255
6825
  get().loadFromPayload(loaded.payload, slot);
7256
- }).catch((err) => {
7257
- console.error("spindle: failed to load save", err);
7258
- set((state) => {
7259
- state.loadError = errorMessage(err, "Failed to load");
7260
- });
7261
- throw err;
6826
+ }), "spindle: failed to load save", (err) => set((state) => {
6827
+ state.loadError = errorMessage(err, "Failed to load");
7262
6828
  }));
7263
6829
  },
7264
6830
  hasSave: (slot) => {
7265
6831
  const { storyData, knownSaves } = get();
7266
6832
  if (!storyData) return false;
7267
- return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
6833
+ return hasOwn(knownSaves, slot ?? "");
7268
6834
  },
7269
6835
  getSaveInfo: async (slot) => {
7270
6836
  const { storyData } = get();
@@ -7279,16 +6845,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7279
6845
  deleteSave: (slot) => {
7280
6846
  const { storyData } = get();
7281
6847
  if (!storyData) return Promise.resolve();
7282
- return handled(deleteSlotSave(storyData.ifid, slot).then(() => {
7283
- set((state) => {
7284
- const key = slot ?? "";
7285
- const { [key]: _, ...rest } = state.knownSaves;
7286
- state.knownSaves = rest;
7287
- });
7288
- }).catch((err) => {
7289
- console.error("spindle: failed to delete save", err);
7290
- throw err;
7291
- }));
6848
+ return reported(deleteSlotSave(storyData.ifid, slot).then(() => recordKnownSave(set, slot, false)), "spindle: failed to delete save");
7292
6849
  },
7293
6850
  exportSave: async (slot) => {
7294
6851
  const { storyData } = get();
@@ -7300,40 +6857,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
7300
6857
  if (!storyData) throw new Error("spindle: Story is not initialized.");
7301
6858
  if (!isSaveExport(data)) throw new Error("Invalid save file format");
7302
6859
  const info = await importSlotSave(data, storyData.ifid, slot);
7303
- set((state) => {
7304
- state.knownSaves = {
7305
- ...state.knownSaves,
7306
- [slot ?? ""]: true
7307
- };
7308
- });
6860
+ recordKnownSave(set, slot, true);
7309
6861
  return info;
7310
6862
  },
7311
6863
  clearGameData: () => {
7312
6864
  const { storyData } = get();
7313
6865
  if (!storyData) return Promise.resolve();
7314
- const cleared = clearGameData(storyData.ifid).then(() => {
7315
- set((state) => {
7316
- state.knownSaves = {};
7317
- });
7318
- });
7319
- get().restart();
7320
- return handled(cleared.catch((err) => {
7321
- console.error("spindle: failed to clear game data", err);
7322
- throw err;
7323
- }));
7324
- },
7325
- clearAllData: () => {
7326
- const cleared = clearAllData().then(() => {
7327
- set((state) => {
7328
- state.knownSaves = {};
7329
- });
7330
- });
7331
- get().restart();
7332
- return handled(cleared.catch((err) => {
7333
- console.error("spindle: failed to clear all data", err);
7334
- throw err;
7335
- }));
6866
+ return clearAndRestart(get, set, clearGameData(storyData.ifid), "spindle: failed to clear game data");
7336
6867
  },
6868
+ clearAllData: () => clearAndRestart(get, set, clearAllData(), "spindle: failed to clear all data"),
7337
6869
  deletePlaythrough: (playthroughId) => {
7338
6870
  const { storyData } = get();
7339
6871
  if (!storyData) return Promise.resolve();
@@ -7347,29 +6879,19 @@ var useStoryStore = create()(storyStore((set, get) => ({
7347
6879
  });
7348
6880
  if (playthroughId !== "" && playthroughId === current) switchToPlaythrough(replacementId, deletion);
7349
6881
  else if (current === "") switchToLookedUpPlaythrough(deletion.then((replaced) => replaced ? replacementId : established, () => established));
7350
- return handled(deletion.then(async () => {
6882
+ return reported(deletion.then(async () => {
7351
6883
  const known = await populateKnownSaves(storyData.ifid);
7352
6884
  set((state) => {
7353
6885
  state.knownSaves = known;
7354
6886
  });
7355
- }).catch((err) => {
7356
- console.error("spindle: failed to delete playthrough", err);
7357
- throw err;
7358
- }));
6887
+ }), "spindle: failed to delete playthrough");
7359
6888
  },
7360
6889
  getSavePayload: () => {
7361
6890
  const { currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
7362
6891
  const saveHistory = [];
7363
- let vars = variableBase;
7364
- for (let i = 0; i < history.length; i++) {
7365
- if (i > 0) vars = applyPatches(vars, patchEntries[i - 1].forward);
7366
- saveHistory.push({
7367
- passage: history[i].passage,
7368
- variables: plainCopy(vars),
7369
- timestamp: history[i].timestamp,
7370
- prng: history[i].prng
7371
- });
7372
- }
6892
+ forEachRecorded(history.length, (vars, i) => {
6893
+ saveHistory.push(savedMoment(history[i], plainCopy(vars)));
6894
+ });
7373
6895
  return {
7374
6896
  passage: currentPassage,
7375
6897
  variables: plainCopy(variables),
@@ -7410,12 +6932,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7410
6932
  }
7411
6933
  variableBase = deepClone(base);
7412
6934
  patchEntries = newPatchEntries;
7413
- serializedHistory = payload.history.map((m) => ({
7414
- passage: m.passage,
7415
- variables: serialize(m.variables),
7416
- timestamp: m.timestamp,
7417
- prng: m.prng
7418
- }));
6935
+ serializedHistory = payload.history.map((m) => savedMoment(m, serialize(m.variables)));
7419
6936
  set((state) => {
7420
6937
  state.currentPassage = payload.passage;
7421
6938
  state.navigationId++;
@@ -7434,9 +6951,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
7434
6951
  });
7435
6952
  reinitTriggerState();
7436
6953
  lastNavigationVars = reconstructVarsAt(get().historyIndex);
7437
- const prng = entry?.prng !== void 0 ? entry.prng : payload.prng;
7438
- if (prng) restorePRNG(prng.seed, prng.pull);
7439
- else resetPRNG();
6954
+ restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
7440
6955
  persistSession(get);
7441
6956
  withoutDraws(() => emit("afterload", slot));
7442
6957
  },
@@ -7802,7 +7317,7 @@ function loadFromStorage() {
7802
7317
  /** Register a setting, starting from its default unless it has a value. */
7803
7318
  function define(name, def) {
7804
7319
  definitions.set(name, def);
7805
- if (!hasOwn$2(values, name)) values[name] = def.config.default;
7320
+ if (!hasOwn(values, name)) values[name] = def.config.default;
7806
7321
  loadFromStorage();
7807
7322
  }
7808
7323
  /** The value of setting `name` if it has the type of `fallback`, else that. */
@@ -8720,6 +8235,56 @@ function astContainsChildren(nodes) {
8720
8235
  return false;
8721
8236
  }
8722
8237
  //#endregion
8238
+ //#region src/components/macros/Widget.tsx
8239
+ /**
8240
+ * Read the arguments of a {widget} definition: its name (quotes dropped)
8241
+ * and its parameters, the words after it that start with one of the
8242
+ * characters of `sigils`.
8243
+ */
8244
+ function parseWidgetDef(rawArgs, sigils = "@") {
8245
+ const [first, ...rest] = rawArgs.trim().split(/\s+/);
8246
+ return {
8247
+ name: first.replace(/["']/g, ""),
8248
+ params: rest.filter((word) => sigils.includes(word[0]))
8249
+ };
8250
+ }
8251
+ defineMacro({
8252
+ name: "widget",
8253
+ block: true,
8254
+ render({ rawArgs, children = [] }, ctx) {
8255
+ let parsed;
8256
+ let error;
8257
+ try {
8258
+ const def = parseWidgetDef(rawArgs);
8259
+ for (const param of def.params) checkVariableName(param.slice(1), param);
8260
+ parsed = def;
8261
+ } catch (err) {
8262
+ error = err;
8263
+ }
8264
+ const { name, params } = parsed ?? {
8265
+ name: "",
8266
+ params: []
8267
+ };
8268
+ const childrenKey = JSON.stringify(children);
8269
+ const paramsKey = params.join(",");
8270
+ ctx.hooks.useLayoutEffect(() => {
8271
+ if (!parsed) return;
8272
+ const isBlock = astContainsChildren(children);
8273
+ registerWidget(name, children, params, isBlock);
8274
+ if (isBlock) registerBlockMacro(name);
8275
+ }, [
8276
+ name,
8277
+ childrenKey,
8278
+ paramsKey
8279
+ ]);
8280
+ if (error) return /* @__PURE__ */ jsx(MacroError, {
8281
+ macro: "widget",
8282
+ error
8283
+ });
8284
+ return null;
8285
+ }
8286
+ });
8287
+ //#endregion
8723
8288
  //#region src/components/macros/Set.tsx
8724
8289
  defineMacro({
8725
8290
  name: "set",
@@ -9166,7 +8731,7 @@ defineMacro({
9166
8731
  function liveLocalsView(getValues) {
9167
8732
  return new Proxy(createNamespace(), {
9168
8733
  get: (_, key) => typeof key === "string" ? ownValue(getValues(), key) : void 0,
9169
- has: (_, key) => typeof key === "string" && hasOwn$2(getValues(), key),
8734
+ has: (_, key) => typeof key === "string" && hasOwn(getValues(), key),
9170
8735
  ownKeys: () => Reflect.ownKeys(getValues()),
9171
8736
  getOwnPropertyDescriptor: (_, key) => {
9172
8737
  const desc = Object.getOwnPropertyDescriptor(getValues(), key);
@@ -9514,52 +9079,6 @@ defineMacro({
9514
9079
  }
9515
9080
  });
9516
9081
  //#endregion
9517
- //#region src/components/macros/Widget.tsx
9518
- function parseWidgetDef(rawArgs) {
9519
- const tokens = rawArgs.trim().split(/\s+/);
9520
- const name = tokens[0].replace(/["']/g, "");
9521
- const params = tokens.slice(1).filter((t) => t.startsWith("@"));
9522
- for (const param of params) checkVariableName(param.slice(1), param);
9523
- return {
9524
- name,
9525
- params
9526
- };
9527
- }
9528
- defineMacro({
9529
- name: "widget",
9530
- block: true,
9531
- render({ rawArgs, children = [] }, ctx) {
9532
- let parsed;
9533
- let error;
9534
- try {
9535
- parsed = parseWidgetDef(rawArgs);
9536
- } catch (err) {
9537
- error = err;
9538
- }
9539
- const { name, params } = parsed ?? {
9540
- name: "",
9541
- params: []
9542
- };
9543
- const childrenKey = JSON.stringify(children);
9544
- const paramsKey = params.join(",");
9545
- ctx.hooks.useLayoutEffect(() => {
9546
- if (!parsed) return;
9547
- const isBlock = astContainsChildren(children);
9548
- registerWidget(name, children, params, isBlock);
9549
- if (isBlock) registerBlockMacro(name);
9550
- }, [
9551
- name,
9552
- childrenKey,
9553
- paramsKey
9554
- ]);
9555
- if (error) return /* @__PURE__ */ jsx(MacroError, {
9556
- macro: "widget",
9557
- error
9558
- });
9559
- return null;
9560
- }
9561
- });
9562
- //#endregion
9563
9082
  //#region src/components/macros/StoryTitle.tsx
9564
9083
  defineMacro({
9565
9084
  name: "story-title",
@@ -11005,9 +10524,7 @@ function boot() {
11005
10524
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
11006
10525
  const widgetAST = buildAST(tokenize(passage.content));
11007
10526
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
11008
- const tokens2 = node.rawArgs.trim().split(/\s+/);
11009
- const widgetName = tokens2[0].replace(/["']/g, "");
11010
- const params = tokens2.slice(1).filter((t) => t.startsWith("$") || t.startsWith("_") || t.startsWith("@"));
10527
+ const { name: widgetName, params } = parseWidgetDef(node.rawArgs, "$_@");
11011
10528
  const children = node.children;
11012
10529
  const isBlock = astContainsChildren(children);
11013
10530
  try {