@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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +1105 -1588
- package/dist/pkg/story-variables.js +155 -469
- package/package.json +1 -1
- package/src/class-registry.ts +6 -182
- package/src/components/macros/Widget.tsx +18 -7
- package/src/execute-mutation.ts +17 -97
- package/src/index.tsx +5 -8
- package/src/interpolation.ts +16 -22
- package/src/js-lexer.ts +20 -9
- package/src/markup/ast.ts +108 -147
- package/src/markup/code-attributes.ts +4 -6
- package/src/markup/tokenizer.ts +218 -574
- package/src/saves/save-manager.ts +190 -259
- package/src/saves/storage.ts +229 -402
- package/src/store.ts +244 -310
- package/src/structural.ts +388 -0
package/dist/pkg/headless.js
CHANGED
|
@@ -32,28 +32,192 @@ function create() {
|
|
|
32
32
|
return (initializer) => bindPreact(createStore()(initializer));
|
|
33
33
|
}
|
|
34
34
|
//#endregion
|
|
35
|
-
//#region src/
|
|
36
|
-
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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
|
|
48
|
-
const
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 === "]"
|
|
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
|
|
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
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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
|
|
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] === "{" &&
|
|
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
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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) :
|
|
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
|
-
|
|
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)
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
2078
|
-
const closeIdx =
|
|
2464
|
+
function pushMacro(contentStart, start, selectors) {
|
|
2465
|
+
const closeIdx = scanMacroClose(input, contentStart, memo);
|
|
2079
2466
|
if (closeIdx === -1) return false;
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2148
|
-
i = closeIdx + 2;
|
|
2149
|
-
const { display, target } = parseLink(inner);
|
|
2150
|
-
const linkToken = {
|
|
2518
|
+
emit(withSelectors({
|
|
2151
2519
|
type: "link",
|
|
2152
|
-
|
|
2153
|
-
target,
|
|
2520
|
+
...parseLink(input.slice(innerStart, closeIdx)),
|
|
2154
2521
|
start,
|
|
2155
|
-
end:
|
|
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
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2696
|
+
const rawArgs = `${quoteArg(token.display)} ${quoteArg(token.target)}`;
|
|
2697
|
+
current().push(withSelectors({
|
|
2670
2698
|
type: "macro",
|
|
2671
2699
|
name: "link",
|
|
2672
|
-
rawArgs
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
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
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
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,
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
},
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
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
|
-
|
|
4757
|
-
|
|
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
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
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
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
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
|
-
|
|
4932
|
-
|
|
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
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
}
|
|
4942
|
-
|
|
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
|
-
|
|
5165
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5270
|
-
|
|
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
|
-
|
|
5089
|
+
var createSave = queued(createSaveNow);
|
|
5090
|
+
async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
|
|
5273
5091
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5274
|
-
const
|
|
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
|
-
|
|
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
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
...
|
|
5329
|
-
|
|
5330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
5452
|
-
}
|
|
5453
|
-
/**
|
|
5454
|
-
function
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
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
|
|
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
|
-
|
|
5496
|
-
...isNamedSlot(slot) ? { slot } : {},
|
|
5294
|
+
...slotCustom(slot),
|
|
5497
5295
|
...custom
|
|
5498
5296
|
});
|
|
5499
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5553
|
-
return inOrder(() => getSlotSaveInfoNow(ifid, slot));
|
|
5554
|
-
}
|
|
5340
|
+
var getSlotSaveInfo = queued(getSlotSaveInfoNow);
|
|
5555
5341
|
async function getSlotSaveInfoNow(ifid, slot) {
|
|
5556
|
-
const
|
|
5557
|
-
|
|
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
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
const
|
|
5571
|
-
if (
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
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
|
-
|
|
5583
|
-
const
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
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
|
-
|
|
5620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5689
|
-
...isNamedSlot(slot) ? { slot } : {}
|
|
5442
|
+
...slotCustom(slot)
|
|
5690
5443
|
};
|
|
5691
5444
|
const backend = await getBackend();
|
|
5692
|
-
const previousId = await
|
|
5445
|
+
const previousId = await slotSaveId(ifid, slot);
|
|
5693
5446
|
await backend.putSave(record);
|
|
5694
|
-
await
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5917
|
-
*
|
|
5918
|
-
*
|
|
5919
|
-
*
|
|
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
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
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
|
-
|
|
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
|
|
6852
|
-
*
|
|
6853
|
-
* index.
|
|
6854
|
-
*
|
|
6855
|
-
*
|
|
6856
|
-
*
|
|
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
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
7194
|
-
resetModuleState(deepClone(initialVars));
|
|
6782
|
+
const initialVars = startVariables(variableDefaults);
|
|
7195
6783
|
set((state) => {
|
|
7196
|
-
state
|
|
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
|
|
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
|
|
7227
|
-
|
|
7228
|
-
|
|
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
|
|
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
|
-
})
|
|
7257
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
})
|
|
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
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|