@rohal12/spindle 0.51.2 → 0.51.4
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 +1360 -774
- package/dist/pkg/story-variables.js +22 -12
- package/package.json +1 -1
- package/src/class-registry.ts +76 -0
- package/src/components/macros/Button.tsx +4 -1
- package/src/components/macros/Checkbox.tsx +5 -2
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +5 -2
- package/src/components/macros/Include.tsx +9 -19
- package/src/components/macros/MacroLink.tsx +2 -1
- package/src/components/macros/Meter.tsx +18 -40
- package/src/components/macros/Radiobutton.tsx +22 -0
- package/src/components/macros/SaveManager.tsx +19 -16
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/WidgetInvocation.tsx +14 -94
- package/src/components/macros/arg-utils.ts +120 -0
- package/src/components/macros/option-utils.ts +9 -5
- package/src/define-macro.ts +13 -34
- package/src/execute-mutation.ts +294 -26
- package/src/expression.ts +22 -237
- package/src/js-lexer.ts +326 -0
- package/src/markup/render.tsx +139 -81
- package/src/markup/tokenizer.ts +23 -9
- package/src/saves/save-manager.ts +47 -8
- package/src/saves/storage.ts +8 -0
- package/src/saves/types.ts +18 -5
- package/src/store.ts +124 -48
- package/src/story-api.ts +51 -50
- package/src/triggers.ts +5 -2
- package/src/utils/object-path.ts +109 -0
- package/src/utils/stable-key.ts +77 -0
package/dist/pkg/headless.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, u
|
|
|
3
3
|
import { useSyncExternalStore } from "preact/compat";
|
|
4
4
|
import { createStore } from "zustand/vanilla";
|
|
5
5
|
import { immer } from "zustand/middleware/immer";
|
|
6
|
-
import { applyPatches, enablePatches, produceWithPatches } from "immer";
|
|
6
|
+
import { applyPatches, current, enablePatches, freeze, isDraft, produceWithPatches } from "immer";
|
|
7
7
|
import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
|
|
8
8
|
import { micromark } from "micromark";
|
|
9
9
|
import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
|
|
@@ -31,6 +31,242 @@ function create() {
|
|
|
31
31
|
return (initializer) => bindPreact(createStore()(initializer));
|
|
32
32
|
}
|
|
33
33
|
//#endregion
|
|
34
|
+
//#region src/class-registry.ts
|
|
35
|
+
var registry$1 = /* @__PURE__ */ new Map();
|
|
36
|
+
var ctorToName = /* @__PURE__ */ new Map();
|
|
37
|
+
function registerClass(name, ctor) {
|
|
38
|
+
registry$1.set(name, ctor);
|
|
39
|
+
ctorToName.set(ctor, name);
|
|
40
|
+
}
|
|
41
|
+
function getClassName(ctor) {
|
|
42
|
+
return ctorToName.get(ctor);
|
|
43
|
+
}
|
|
44
|
+
function isPlainObject(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null) return false;
|
|
46
|
+
const proto = Object.getPrototypeOf(value);
|
|
47
|
+
return proto === Object.prototype || proto === null;
|
|
48
|
+
}
|
|
49
|
+
function deepClone(value, options = {}) {
|
|
50
|
+
const seen = /* @__PURE__ */ new Map();
|
|
51
|
+
function clone(val) {
|
|
52
|
+
if (val === null || typeof val !== "object") return val;
|
|
53
|
+
const obj = val;
|
|
54
|
+
if (seen.has(obj)) return seen.get(obj);
|
|
55
|
+
if (val instanceof Date) return new Date(val.getTime());
|
|
56
|
+
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
57
|
+
if (Array.isArray(val)) {
|
|
58
|
+
const arr = [];
|
|
59
|
+
seen.set(obj, arr);
|
|
60
|
+
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
61
|
+
return arr;
|
|
62
|
+
}
|
|
63
|
+
if (val instanceof Map) {
|
|
64
|
+
const copy = /* @__PURE__ */ new Map();
|
|
65
|
+
seen.set(obj, copy);
|
|
66
|
+
for (const [k, v] of val) copy.set(clone(k), clone(v));
|
|
67
|
+
return copy;
|
|
68
|
+
}
|
|
69
|
+
if (val instanceof Set) {
|
|
70
|
+
const copy = /* @__PURE__ */ new Set();
|
|
71
|
+
seen.set(obj, copy);
|
|
72
|
+
for (const v of val) copy.add(clone(v));
|
|
73
|
+
return copy;
|
|
74
|
+
}
|
|
75
|
+
const ctor = obj.constructor;
|
|
76
|
+
if (ctorToName.get(ctor) !== void 0) {
|
|
77
|
+
const copy = Object.create(Object.getPrototypeOf(obj));
|
|
78
|
+
seen.set(obj, copy);
|
|
79
|
+
for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
|
|
80
|
+
return copy;
|
|
81
|
+
}
|
|
82
|
+
if (!isPlainObject(val) && options.keepUnregistered) return val;
|
|
83
|
+
if (isPlainObject(val) || typeof val === "object") {
|
|
84
|
+
const copy = {};
|
|
85
|
+
seen.set(obj, copy);
|
|
86
|
+
for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
|
|
87
|
+
return copy;
|
|
88
|
+
}
|
|
89
|
+
return val;
|
|
90
|
+
}
|
|
91
|
+
return clone(value);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Structural equality over the value types deepClone() supports: primitives,
|
|
95
|
+
* arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
|
|
96
|
+
* at any depth). Map and Set entries are compared in insertion order.
|
|
97
|
+
*/
|
|
98
|
+
function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
|
|
99
|
+
if (Object.is(a, b)) return true;
|
|
100
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
101
|
+
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
102
|
+
if (seen.get(a) === b) return true;
|
|
103
|
+
seen.set(a, b);
|
|
104
|
+
if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
|
|
105
|
+
if (a instanceof RegExp) return String(a) === String(b);
|
|
106
|
+
if (a instanceof Map || a instanceof Set) {
|
|
107
|
+
const bc = b;
|
|
108
|
+
if (a.size !== bc.size) return false;
|
|
109
|
+
const ai = a.entries();
|
|
110
|
+
const bi = bc.entries();
|
|
111
|
+
for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!deepEqual(x.value, y.value, seen)) return false;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const ao = a;
|
|
115
|
+
const bo = b;
|
|
116
|
+
const keys = Object.keys(ao);
|
|
117
|
+
if (keys.length !== Object.keys(bo).length) return false;
|
|
118
|
+
for (const key of keys) if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
var CLASS_TAG = "__spindle_class__";
|
|
122
|
+
var DATA_TAG = "__spindle_data__";
|
|
123
|
+
function serialize(value) {
|
|
124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
125
|
+
function ser(val) {
|
|
126
|
+
if (val === null || typeof val !== "object") return val;
|
|
127
|
+
const obj = val;
|
|
128
|
+
if (seen.has(obj)) throw new Error("spindle: Cannot serialize circular references");
|
|
129
|
+
seen.add(obj);
|
|
130
|
+
if (val instanceof Date) {
|
|
131
|
+
seen.delete(obj);
|
|
132
|
+
return {
|
|
133
|
+
[CLASS_TAG]: "__Date__",
|
|
134
|
+
[DATA_TAG]: { iso: val.toISOString() }
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (val instanceof RegExp) {
|
|
138
|
+
seen.delete(obj);
|
|
139
|
+
return {
|
|
140
|
+
[CLASS_TAG]: "__RegExp__",
|
|
141
|
+
[DATA_TAG]: {
|
|
142
|
+
source: val.source,
|
|
143
|
+
flags: val.flags
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(val)) {
|
|
148
|
+
const result = val.map((item) => ser(item));
|
|
149
|
+
seen.delete(obj);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
if (val instanceof Map) {
|
|
153
|
+
const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
|
|
154
|
+
seen.delete(obj);
|
|
155
|
+
return {
|
|
156
|
+
[CLASS_TAG]: "__Map__",
|
|
157
|
+
[DATA_TAG]: { entries }
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (val instanceof Set) {
|
|
161
|
+
const entries = [...val].map((v) => ser(v));
|
|
162
|
+
seen.delete(obj);
|
|
163
|
+
return {
|
|
164
|
+
[CLASS_TAG]: "__Set__",
|
|
165
|
+
[DATA_TAG]: { entries }
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const ctor = obj.constructor;
|
|
169
|
+
const name = ctorToName.get(ctor);
|
|
170
|
+
if (name !== void 0) {
|
|
171
|
+
const data = {};
|
|
172
|
+
for (const key of Object.keys(obj)) data[key] = ser(obj[key]);
|
|
173
|
+
seen.delete(obj);
|
|
174
|
+
return {
|
|
175
|
+
[CLASS_TAG]: name,
|
|
176
|
+
[DATA_TAG]: data
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const result = {};
|
|
180
|
+
for (const key of Object.keys(obj)) result[key] = ser(obj[key]);
|
|
181
|
+
seen.delete(obj);
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
return ser(value);
|
|
185
|
+
}
|
|
186
|
+
function deserialize(value) {
|
|
187
|
+
function deser(val) {
|
|
188
|
+
if (val === null || typeof val !== "object") return val;
|
|
189
|
+
if (Array.isArray(val)) return val.map((item) => deser(item));
|
|
190
|
+
const obj = val;
|
|
191
|
+
if (CLASS_TAG in obj && DATA_TAG in obj) {
|
|
192
|
+
const name = obj[CLASS_TAG];
|
|
193
|
+
const data = obj[DATA_TAG];
|
|
194
|
+
if (name === "__Date__") return new Date(data.iso);
|
|
195
|
+
if (name === "__RegExp__") return new RegExp(data.source, data.flags);
|
|
196
|
+
if (name === "__Map__") {
|
|
197
|
+
const entries = data.entries;
|
|
198
|
+
return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
|
|
199
|
+
}
|
|
200
|
+
if (name === "__Set__") {
|
|
201
|
+
const entries = data.entries;
|
|
202
|
+
return new Set(entries.map((v) => deser(v)));
|
|
203
|
+
}
|
|
204
|
+
const ctor = registry$1.get(name);
|
|
205
|
+
if (!ctor) {
|
|
206
|
+
console.warn(`spindle: Class "${name}" not registered. Falling back to plain object.`);
|
|
207
|
+
const plain = {};
|
|
208
|
+
for (const key of Object.keys(data)) plain[key] = deser(data[key]);
|
|
209
|
+
return plain;
|
|
210
|
+
}
|
|
211
|
+
const instance = Object.create(ctor.prototype);
|
|
212
|
+
for (const key of Object.keys(data)) instance[key] = deser(data[key]);
|
|
213
|
+
return instance;
|
|
214
|
+
}
|
|
215
|
+
if (val instanceof Date || val instanceof RegExp || val instanceof Map || val instanceof Set) return val;
|
|
216
|
+
const ctor = obj.constructor;
|
|
217
|
+
if (ctorToName.has(ctor)) return val;
|
|
218
|
+
const result = {};
|
|
219
|
+
for (const key of Object.keys(obj)) result[key] = deser(obj[key]);
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
return deser(value);
|
|
223
|
+
}
|
|
224
|
+
function isDataRecord(value) {
|
|
225
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Whether `value` is serialized data that deserialize() can restore: every
|
|
229
|
+
* tagged value has the shape serialize() writes for its tag (a valid ISO
|
|
230
|
+
* date, a compilable RegExp, Map entries as `[key, value]` pairs, Set
|
|
231
|
+
* entries as an array, class data as an object), at any depth. Use it on
|
|
232
|
+
* data from outside the running story, such as an imported save, before
|
|
233
|
+
* storing it. Tags of unregistered classes pass; they load as plain objects.
|
|
234
|
+
*/
|
|
235
|
+
function isDeserializable(value) {
|
|
236
|
+
const path = /* @__PURE__ */ new Set();
|
|
237
|
+
function check(val) {
|
|
238
|
+
if (val === null || typeof val !== "object") return true;
|
|
239
|
+
if (path.has(val)) return false;
|
|
240
|
+
path.add(val);
|
|
241
|
+
const ok = checkObject(val);
|
|
242
|
+
path.delete(val);
|
|
243
|
+
return ok;
|
|
244
|
+
}
|
|
245
|
+
function checkObject(val) {
|
|
246
|
+
if (Array.isArray(val)) return val.every(check);
|
|
247
|
+
const obj = val;
|
|
248
|
+
if (!(CLASS_TAG in obj && DATA_TAG in obj)) return Object.keys(obj).every((key) => check(obj[key]));
|
|
249
|
+
const name = obj[CLASS_TAG];
|
|
250
|
+
const data = obj[DATA_TAG];
|
|
251
|
+
if (typeof name !== "string" || !isDataRecord(data)) return false;
|
|
252
|
+
switch (name) {
|
|
253
|
+
case "__Date__": return typeof data.iso === "string" && !Number.isNaN(new Date(data.iso).getTime());
|
|
254
|
+
case "__RegExp__":
|
|
255
|
+
if (typeof data.source !== "string" || typeof data.flags !== "string") return false;
|
|
256
|
+
try {
|
|
257
|
+
new RegExp(data.source, data.flags);
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
case "__Map__": return Array.isArray(data.entries) && data.entries.every((entry) => Array.isArray(entry) && entry.length === 2 && check(entry[0]) && check(entry[1]));
|
|
263
|
+
case "__Set__": return Array.isArray(data.entries) && data.entries.every(check);
|
|
264
|
+
default: return Object.keys(data).every((key) => check(data[key]));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return check(value);
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
34
270
|
//#region src/saves/types.ts
|
|
35
271
|
function isSaveExport(value) {
|
|
36
272
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -57,19 +293,25 @@ function isOptionalPRNGSnapshot(value) {
|
|
|
57
293
|
if (value == null) return true;
|
|
58
294
|
return isRecord(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
|
|
59
295
|
}
|
|
296
|
+
/** A variable container whose values deserialize() can restore. */
|
|
297
|
+
function isSerializedVariables(value) {
|
|
298
|
+
return isRecord(value) && isDeserializable(value);
|
|
299
|
+
}
|
|
60
300
|
function isSaveHistoryMoment(value) {
|
|
61
|
-
return isRecord(value) && typeof value.passage === "string" &&
|
|
301
|
+
return isRecord(value) && typeof value.passage === "string" && isSerializedVariables(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
|
|
62
302
|
}
|
|
63
303
|
/**
|
|
64
304
|
* Full check of a payload from outside the running story (an imported save):
|
|
65
|
-
* every history moment is well formed,
|
|
66
|
-
* points at a moment of the payload's passage
|
|
67
|
-
*
|
|
305
|
+
* every history moment is well formed, `historyIndex` is an integer that
|
|
306
|
+
* points at a moment of the payload's passage, and the encoded values (Map,
|
|
307
|
+
* Set, Date, RegExp, class instances) in the live variables and every
|
|
308
|
+
* moment's variables have the shape deserialize() expects. Anything that
|
|
309
|
+
* passes can be stored and later loaded.
|
|
68
310
|
*/
|
|
69
311
|
function isImportablePayload(value) {
|
|
70
312
|
if (!isRecord(value)) return false;
|
|
71
313
|
if (typeof value.passage !== "string") return false;
|
|
72
|
-
if (!
|
|
314
|
+
if (!isSerializedVariables(value.variables)) return false;
|
|
73
315
|
const { history, historyIndex } = value;
|
|
74
316
|
if (!Array.isArray(history) || history.length === 0) return false;
|
|
75
317
|
if (!history.every(isSaveHistoryMoment)) return false;
|
|
@@ -209,8 +451,11 @@ function parseHtmlAttributes(input, j) {
|
|
|
209
451
|
while (j < input.length && /[a-zA-Z0-9_\-:@]/.test(input[j])) j++;
|
|
210
452
|
const attrName = input.slice(attrStart, j);
|
|
211
453
|
if (!attrName) break;
|
|
212
|
-
|
|
213
|
-
|
|
454
|
+
let eqIdx = j;
|
|
455
|
+
while (eqIdx < input.length && /\s/.test(input[eqIdx])) eqIdx++;
|
|
456
|
+
if (input[eqIdx] === "=") {
|
|
457
|
+
j = eqIdx + 1;
|
|
458
|
+
while (j < input.length && /\s/.test(input[j])) j++;
|
|
214
459
|
if (input[j] === "\"" || input[j] === "'") {
|
|
215
460
|
const quote = input[j];
|
|
216
461
|
j++;
|
|
@@ -351,16 +596,23 @@ function tokenize(input) {
|
|
|
351
596
|
textStart = closeEnd;
|
|
352
597
|
}
|
|
353
598
|
while (i < input.length) {
|
|
354
|
-
if (input[i] === "\\"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
599
|
+
if (input[i] === "\\") {
|
|
600
|
+
let k = i + 1;
|
|
601
|
+
while (input[k] === "\\") k++;
|
|
602
|
+
const next = input[k];
|
|
603
|
+
if ((next === "{" || next === "}") && (k - i) % 2 === 1) {
|
|
604
|
+
flushText(k - 1);
|
|
605
|
+
tokens.push({
|
|
606
|
+
type: "text",
|
|
607
|
+
value: next,
|
|
608
|
+
start: k - 1,
|
|
609
|
+
end: k + 1
|
|
610
|
+
});
|
|
611
|
+
i = k + 1;
|
|
612
|
+
textStart = i;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
i = k;
|
|
364
616
|
continue;
|
|
365
617
|
}
|
|
366
618
|
if (input[i] === "[" && input[i + 1] === "[") {
|
|
@@ -1166,22 +1418,16 @@ function getPRNGPull() {
|
|
|
1166
1418
|
return currentPull;
|
|
1167
1419
|
}
|
|
1168
1420
|
//#endregion
|
|
1169
|
-
//#region src/
|
|
1170
|
-
var FN_CACHE_MAX = 500;
|
|
1171
|
-
var fnCache = /* @__PURE__ */ new Map();
|
|
1172
|
-
/**
|
|
1173
|
-
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
1174
|
-
* @var → locals["var"]
|
|
1175
|
-
* Only transforms when sigils appear as a word boundary (not inside strings naively,
|
|
1176
|
-
* but authors already have full JS access so this is acceptable).
|
|
1177
|
-
* `%var` → transient["var"] is handled by `transform` itself, because `%` is
|
|
1178
|
-
* also the modulo operator and only an operand position makes it a sigil.
|
|
1179
|
-
*/
|
|
1180
|
-
var VAR_RE = /\$(\w+)/g;
|
|
1181
|
-
var TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
1182
|
-
var LOCAL_RE = /@(\w+)/g;
|
|
1421
|
+
//#region src/js-lexer.ts
|
|
1183
1422
|
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
1184
1423
|
var TRANS_NAME_RE = /[A-Za-z_]\w*/y;
|
|
1424
|
+
/**
|
|
1425
|
+
* The rest of an assignment target and its operator after a transient name:
|
|
1426
|
+
* ` = 1`, `.a.b += 2`, but not `== 1` or `=> 1`.
|
|
1427
|
+
*/
|
|
1428
|
+
var ASSIGNMENT_RE = /(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*(?:\*\*|<<|>>>?|&&|\|\||\?\?|[-+*/%&|^])?=(?![=>])/y;
|
|
1429
|
+
/** Flags after the closing `/` of a regex literal. */
|
|
1430
|
+
var REGEX_FLAGS_RE = /\w*/y;
|
|
1185
1431
|
/** Characters of identifiers, numbers and sigil variable references. */
|
|
1186
1432
|
var WORD_CHAR_RE = /[\w$@]/;
|
|
1187
1433
|
var SPACE_RE = /\s/;
|
|
@@ -1209,50 +1455,138 @@ var HEADER_KEYWORDS = new Set([
|
|
|
1209
1455
|
"while",
|
|
1210
1456
|
"with"
|
|
1211
1457
|
]);
|
|
1212
|
-
|
|
1213
|
-
|
|
1458
|
+
/**
|
|
1459
|
+
* Scan the `"…"` or `'…'` string literal opening at `start`. `end` is the
|
|
1460
|
+
* index just past its closing quote, or `src.length` when it is unterminated
|
|
1461
|
+
* (`closed` false). A backslash escapes the character after it, so a quote
|
|
1462
|
+
* after an even run of backslashes closes the string and one after an odd
|
|
1463
|
+
* run does not.
|
|
1464
|
+
*/
|
|
1465
|
+
function scanStringLiteral(src, start) {
|
|
1466
|
+
const quote = src.charAt(start);
|
|
1467
|
+
let i = start + 1;
|
|
1468
|
+
while (i < src.length) {
|
|
1469
|
+
const c = src.charAt(i);
|
|
1470
|
+
if (c === "\\") i += 2;
|
|
1471
|
+
else if (c === quote) return {
|
|
1472
|
+
end: i + 1,
|
|
1473
|
+
closed: true
|
|
1474
|
+
};
|
|
1475
|
+
else i++;
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
end: src.length,
|
|
1479
|
+
closed: false
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/** Index just past the regex literal (with flags) opening at `start`. */
|
|
1483
|
+
function skipRegex(src, start) {
|
|
1484
|
+
let inClass = false;
|
|
1485
|
+
let i = start + 1;
|
|
1486
|
+
while (i < src.length) {
|
|
1487
|
+
const c = src.charAt(i);
|
|
1488
|
+
if (c === "\\") {
|
|
1489
|
+
i += 2;
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1492
|
+
if (c === "\n") return i;
|
|
1493
|
+
if (inClass) {
|
|
1494
|
+
if (c === "]") inClass = false;
|
|
1495
|
+
} else if (c === "[") inClass = true;
|
|
1496
|
+
else if (c === "/") {
|
|
1497
|
+
REGEX_FLAGS_RE.lastIndex = i + 1;
|
|
1498
|
+
return i + 1 + (REGEX_FLAGS_RE.exec(src)?.[0].length ?? 0);
|
|
1499
|
+
}
|
|
1500
|
+
i++;
|
|
1501
|
+
}
|
|
1502
|
+
return src.length;
|
|
1503
|
+
}
|
|
1504
|
+
/** Index just past the comment opening at `start` (`//` or `/*`). */
|
|
1505
|
+
function skipComment(src, start) {
|
|
1506
|
+
if (src.charAt(start + 1) === "/") {
|
|
1507
|
+
const end = src.indexOf("\n", start);
|
|
1508
|
+
return end < 0 ? src.length : end;
|
|
1509
|
+
}
|
|
1510
|
+
const end = src.indexOf("*/", start + 2);
|
|
1511
|
+
return end < 0 ? src.length : end + 2;
|
|
1214
1512
|
}
|
|
1215
1513
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* It also tracks whether the next token is an operand or an operator, so that
|
|
1222
|
-
* `%name` in operand position is a transient reference while `%` after an
|
|
1223
|
-
* operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
|
|
1514
|
+
* Lex the template literal opening at `start` (a backtick): its backticks,
|
|
1515
|
+
* text and escapes are reported as literal text and the code of its `${…}`
|
|
1516
|
+
* interpolations through `lexJs` one nesting level deeper. Returns the index
|
|
1517
|
+
* just past the closing backtick, or `src.length` if it is unterminated.
|
|
1224
1518
|
*/
|
|
1225
|
-
function
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
let i =
|
|
1519
|
+
function lexTemplate(src, start, handlers = {}, nesting = 0) {
|
|
1520
|
+
const literal = (text, index) => handlers.literal?.(text, index, nesting);
|
|
1521
|
+
literal("`", start);
|
|
1522
|
+
let i = start + 1;
|
|
1523
|
+
while (i < src.length) {
|
|
1524
|
+
const c = src.charAt(i);
|
|
1525
|
+
if (c === "\\") {
|
|
1526
|
+
literal(src.slice(i, i + 2), i);
|
|
1527
|
+
i += 2;
|
|
1528
|
+
} else if (c === "`") {
|
|
1529
|
+
literal(c, i);
|
|
1530
|
+
return i + 1;
|
|
1531
|
+
} else if (c === "$" && src.charAt(i + 1) === "{") {
|
|
1532
|
+
literal("${", i);
|
|
1533
|
+
i = lexJs(src, handlers, i + 2, nesting + 1);
|
|
1534
|
+
if (i < src.length) {
|
|
1535
|
+
literal("}", i);
|
|
1536
|
+
i++;
|
|
1537
|
+
}
|
|
1538
|
+
} else {
|
|
1539
|
+
literal(c, i);
|
|
1540
|
+
i++;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return Math.min(i, src.length);
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Walk `src` from `start`, reporting code characters, literal text and
|
|
1547
|
+
* transient references to `handlers` in source order. Every character of the
|
|
1548
|
+
* scanned range is reported exactly once (a transient reference covers its
|
|
1549
|
+
* `%` and name). Returns the index where scanning stopped: `src.length`, or
|
|
1550
|
+
* — for an interpolation (`nesting > 0`) — the index of the `}` closing it.
|
|
1551
|
+
*/
|
|
1552
|
+
function lexJs(src, handlers, start = 0, nesting = 0) {
|
|
1553
|
+
const interpolation = nesting > 0;
|
|
1554
|
+
let i = start;
|
|
1229
1555
|
let operandNext = true;
|
|
1230
1556
|
let word = "";
|
|
1231
1557
|
let afterDot = false;
|
|
1232
1558
|
let afterHeaderKeyword = false;
|
|
1233
1559
|
let lastPunct = "";
|
|
1560
|
+
let lineBreak = false;
|
|
1561
|
+
let braceDepth = 0;
|
|
1234
1562
|
const parenIsHeader = [];
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
result += transformSegment(code);
|
|
1238
|
-
code = "";
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1563
|
+
const code = (ch, index) => handlers.code?.(ch, index, nesting);
|
|
1564
|
+
const literal = (text, index) => handlers.literal?.(text, index, nesting);
|
|
1241
1565
|
function endWord() {
|
|
1242
1566
|
if (!word) return;
|
|
1243
1567
|
operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
|
|
1244
1568
|
afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
|
|
1245
1569
|
afterDot = false;
|
|
1246
1570
|
lastPunct = "";
|
|
1571
|
+
lineBreak = false;
|
|
1247
1572
|
word = "";
|
|
1248
1573
|
}
|
|
1249
|
-
/** A string or
|
|
1574
|
+
/** A string, template or regex literal, or a transient reference, ended. */
|
|
1250
1575
|
function endOperand() {
|
|
1251
1576
|
endWord();
|
|
1252
1577
|
operandNext = false;
|
|
1253
1578
|
afterHeaderKeyword = false;
|
|
1254
1579
|
afterDot = false;
|
|
1255
1580
|
lastPunct = "";
|
|
1581
|
+
lineBreak = false;
|
|
1582
|
+
}
|
|
1583
|
+
/** Track an operator token: `operandNext` tells what may follow it. */
|
|
1584
|
+
function endPunct(punct, nextIsOperand) {
|
|
1585
|
+
operandNext = nextIsOperand;
|
|
1586
|
+
afterDot = punct === ".";
|
|
1587
|
+
afterHeaderKeyword = false;
|
|
1588
|
+
lastPunct = punct;
|
|
1589
|
+
lineBreak = false;
|
|
1256
1590
|
}
|
|
1257
1591
|
function trackCode(c) {
|
|
1258
1592
|
if (WORD_CHAR_RE.test(c)) {
|
|
@@ -1260,151 +1594,123 @@ function transform(expr) {
|
|
|
1260
1594
|
return;
|
|
1261
1595
|
}
|
|
1262
1596
|
endWord();
|
|
1263
|
-
if (c === "\n")
|
|
1597
|
+
if (c === "\n") lineBreak = true;
|
|
1264
1598
|
if (SPACE_RE.test(c)) return;
|
|
1265
1599
|
if (c === "(") parenIsHeader.push(afterHeaderKeyword);
|
|
1266
|
-
if (c === "
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const ch = expr.charAt(i);
|
|
1600
|
+
if (c === "{") braceDepth++;
|
|
1601
|
+
if (c === "}") braceDepth--;
|
|
1602
|
+
if (c === ")") endPunct(c, parenIsHeader.pop() ?? false);
|
|
1603
|
+
else if (c === ".") endPunct(c, lastPunct === ".");
|
|
1604
|
+
else endPunct(c, c !== "]");
|
|
1605
|
+
}
|
|
1606
|
+
while (i < src.length) {
|
|
1607
|
+
const ch = src.charAt(i);
|
|
1275
1608
|
if (ch === "\"" || ch === "'") {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
i++;
|
|
1280
|
-
while (i < expr.length) {
|
|
1281
|
-
const c = expr.charAt(i);
|
|
1282
|
-
if (c === "\\" && i + 1 < expr.length) {
|
|
1283
|
-
str += c + expr.charAt(i + 1);
|
|
1284
|
-
i += 2;
|
|
1285
|
-
} else if (c === quote) {
|
|
1286
|
-
str += quote;
|
|
1287
|
-
i++;
|
|
1288
|
-
break;
|
|
1289
|
-
} else {
|
|
1290
|
-
str += c;
|
|
1291
|
-
i++;
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
result += str;
|
|
1609
|
+
const { end } = scanStringLiteral(src, i);
|
|
1610
|
+
literal(src.slice(i, end), i);
|
|
1611
|
+
i = end;
|
|
1295
1612
|
endOperand();
|
|
1296
1613
|
continue;
|
|
1297
1614
|
}
|
|
1298
1615
|
if (ch === "`") {
|
|
1299
|
-
|
|
1300
|
-
result += "`";
|
|
1301
|
-
i++;
|
|
1302
|
-
while (i < expr.length) {
|
|
1303
|
-
const c = expr.charAt(i);
|
|
1304
|
-
if (c === "\\" && i + 1 < expr.length) {
|
|
1305
|
-
result += c + expr.charAt(i + 1);
|
|
1306
|
-
i += 2;
|
|
1307
|
-
} else if (c === "$" && expr.charAt(i + 1) === "{") {
|
|
1308
|
-
result += "${";
|
|
1309
|
-
i += 2;
|
|
1310
|
-
let depth = 1;
|
|
1311
|
-
let inner = "";
|
|
1312
|
-
while (i < expr.length && depth > 0) {
|
|
1313
|
-
const ic = expr.charAt(i);
|
|
1314
|
-
if (ic === "{") {
|
|
1315
|
-
depth++;
|
|
1316
|
-
inner += ic;
|
|
1317
|
-
} else if (ic === "}") {
|
|
1318
|
-
depth--;
|
|
1319
|
-
if (depth === 0) break;
|
|
1320
|
-
inner += ic;
|
|
1321
|
-
} else if (ic === "\\" && i + 1 < expr.length) {
|
|
1322
|
-
inner += ic + expr.charAt(i + 1);
|
|
1323
|
-
i++;
|
|
1324
|
-
} else if (ic === "\"" || ic === "'") {
|
|
1325
|
-
inner += ic;
|
|
1326
|
-
i++;
|
|
1327
|
-
while (i < expr.length) {
|
|
1328
|
-
const sc = expr.charAt(i);
|
|
1329
|
-
if (sc === "\\" && i + 1 < expr.length) {
|
|
1330
|
-
inner += sc + expr.charAt(i + 1);
|
|
1331
|
-
i += 2;
|
|
1332
|
-
} else if (sc === ic) {
|
|
1333
|
-
inner += sc;
|
|
1334
|
-
i++;
|
|
1335
|
-
break;
|
|
1336
|
-
} else {
|
|
1337
|
-
inner += sc;
|
|
1338
|
-
i++;
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
continue;
|
|
1342
|
-
} else if (ic === "`") {
|
|
1343
|
-
inner += ic;
|
|
1344
|
-
i++;
|
|
1345
|
-
while (i < expr.length) {
|
|
1346
|
-
const tc = expr.charAt(i);
|
|
1347
|
-
if (tc === "\\" && i + 1 < expr.length) {
|
|
1348
|
-
inner += tc + expr.charAt(i + 1);
|
|
1349
|
-
i += 2;
|
|
1350
|
-
} else if (tc === "$" && expr.charAt(i + 1) === "{") {
|
|
1351
|
-
inner += "${";
|
|
1352
|
-
i += 2;
|
|
1353
|
-
let nestedDepth = 1;
|
|
1354
|
-
while (i < expr.length && nestedDepth > 0) {
|
|
1355
|
-
const nc = expr.charAt(i);
|
|
1356
|
-
if (nc === "{") nestedDepth++;
|
|
1357
|
-
else if (nc === "}") nestedDepth--;
|
|
1358
|
-
if (nestedDepth > 0) inner += nc;
|
|
1359
|
-
i++;
|
|
1360
|
-
}
|
|
1361
|
-
inner += "}";
|
|
1362
|
-
} else if (tc === "`") {
|
|
1363
|
-
inner += tc;
|
|
1364
|
-
i++;
|
|
1365
|
-
break;
|
|
1366
|
-
} else {
|
|
1367
|
-
inner += tc;
|
|
1368
|
-
i++;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
continue;
|
|
1372
|
-
} else inner += ic;
|
|
1373
|
-
i++;
|
|
1374
|
-
}
|
|
1375
|
-
result += transform(inner);
|
|
1376
|
-
if (i < expr.length && expr.charAt(i) === "}") {
|
|
1377
|
-
result += "}";
|
|
1378
|
-
i++;
|
|
1379
|
-
}
|
|
1380
|
-
} else if (c === "`") {
|
|
1381
|
-
result += "`";
|
|
1382
|
-
i++;
|
|
1383
|
-
break;
|
|
1384
|
-
} else {
|
|
1385
|
-
result += c;
|
|
1386
|
-
i++;
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1616
|
+
i = lexTemplate(src, i, handlers, nesting);
|
|
1389
1617
|
endOperand();
|
|
1390
1618
|
continue;
|
|
1391
1619
|
}
|
|
1620
|
+
if (ch === "/") {
|
|
1621
|
+
endWord();
|
|
1622
|
+
const next = src.charAt(i + 1);
|
|
1623
|
+
if (next === "/" || next === "*") {
|
|
1624
|
+
const end = skipComment(src, i);
|
|
1625
|
+
const comment = src.slice(i, end);
|
|
1626
|
+
literal(comment, i);
|
|
1627
|
+
if (comment.includes("\n")) lineBreak = true;
|
|
1628
|
+
i = end;
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
if (operandNext) {
|
|
1632
|
+
const end = skipRegex(src, i);
|
|
1633
|
+
literal(src.slice(i, end), i);
|
|
1634
|
+
i = end;
|
|
1635
|
+
endOperand();
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1392
1639
|
if (ch === "%") {
|
|
1393
1640
|
endWord();
|
|
1394
1641
|
TRANS_NAME_RE.lastIndex = i + 1;
|
|
1395
|
-
const name =
|
|
1642
|
+
const name = TRANS_NAME_RE.exec(src)?.[0];
|
|
1396
1643
|
if (name) {
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1644
|
+
ASSIGNMENT_RE.lastIndex = i + 1 + name.length;
|
|
1645
|
+
if (operandNext || lineBreak && ASSIGNMENT_RE.test(src)) {
|
|
1646
|
+
handlers.transient?.(name, i, nesting);
|
|
1647
|
+
i += 1 + name.length;
|
|
1648
|
+
endOperand();
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1402
1651
|
}
|
|
1403
1652
|
}
|
|
1653
|
+
if ((ch === "+" || ch === "-") && src.charAt(i + 1) === ch) {
|
|
1654
|
+
endWord();
|
|
1655
|
+
const postfix = !operandNext && !lineBreak;
|
|
1656
|
+
code(ch, i);
|
|
1657
|
+
code(ch, i + 1);
|
|
1658
|
+
i += 2;
|
|
1659
|
+
endPunct(ch, !postfix);
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
if (ch === "}" && interpolation && braceDepth === 0) break;
|
|
1404
1663
|
trackCode(ch);
|
|
1405
|
-
code
|
|
1664
|
+
code(ch, i);
|
|
1406
1665
|
i++;
|
|
1407
1666
|
}
|
|
1667
|
+
return i;
|
|
1668
|
+
}
|
|
1669
|
+
//#endregion
|
|
1670
|
+
//#region src/expression.ts
|
|
1671
|
+
var FN_CACHE_MAX = 500;
|
|
1672
|
+
var fnCache = /* @__PURE__ */ new Map();
|
|
1673
|
+
/**
|
|
1674
|
+
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
1675
|
+
* @var → locals["var"], %var → transient["var"].
|
|
1676
|
+
* Only transforms when sigils appear as a word boundary, and only in code:
|
|
1677
|
+
* string, template and regex literal text and comments are left untouched.
|
|
1678
|
+
* `%var` is recognised by the lexer (`lexJs`), because `%` is also the modulo
|
|
1679
|
+
* operator and only an operand position makes it a sigil.
|
|
1680
|
+
*/
|
|
1681
|
+
var VAR_RE = /\$(\w+)/g;
|
|
1682
|
+
var TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
1683
|
+
var LOCAL_RE = /@(\w+)/g;
|
|
1684
|
+
function transformSegment(segment) {
|
|
1685
|
+
return segment.replace(VAR_RE, "variables[\"$1\"]").replace(TEMP_RE, "temporary[\"$1\"]").replace(LOCAL_RE, "locals[\"$1\"]");
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Rewrite sigil references in `expr`. The lexer passes literal text and
|
|
1689
|
+
* comments through untouched; each run of code between them (including the
|
|
1690
|
+
* code of template-literal `${…}` interpolations) is transformed as a whole.
|
|
1691
|
+
*/
|
|
1692
|
+
function transform(expr) {
|
|
1693
|
+
let result = "";
|
|
1694
|
+
let code = "";
|
|
1695
|
+
function flushCode() {
|
|
1696
|
+
if (code) {
|
|
1697
|
+
result += transformSegment(code);
|
|
1698
|
+
code = "";
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
lexJs(expr, {
|
|
1702
|
+
code(ch) {
|
|
1703
|
+
code += ch;
|
|
1704
|
+
},
|
|
1705
|
+
literal(text) {
|
|
1706
|
+
flushCode();
|
|
1707
|
+
result += text;
|
|
1708
|
+
},
|
|
1709
|
+
transient(name) {
|
|
1710
|
+
flushCode();
|
|
1711
|
+
result += `transient["${name}"]`;
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1408
1714
|
flushCode();
|
|
1409
1715
|
return result;
|
|
1410
1716
|
}
|
|
@@ -1471,7 +1777,7 @@ function buildExpressionFns() {
|
|
|
1471
1777
|
* e.g. evaluate("$health + 10", variables, temporary) → number
|
|
1472
1778
|
*/
|
|
1473
1779
|
function evaluate(expr, variables, temporary, locals = {}, transient = {}) {
|
|
1474
|
-
const body = `return (${transform(expr)});`;
|
|
1780
|
+
const body = `return (${transform(expr)}\n);`;
|
|
1475
1781
|
return getOrCompile(body, body)(variables, temporary, locals, buildExpressionFns(), transient);
|
|
1476
1782
|
}
|
|
1477
1783
|
/**
|
|
@@ -1617,6 +1923,96 @@ function ExprDisplay({ expression, className, id }) {
|
|
|
1617
1923
|
return /* @__PURE__ */ jsx(Fragment$1, { children: display });
|
|
1618
1924
|
}
|
|
1619
1925
|
//#endregion
|
|
1926
|
+
//#region src/components/macros/arg-utils.ts
|
|
1927
|
+
/**
|
|
1928
|
+
* Shared helpers for macros that split or read their raw argument text by
|
|
1929
|
+
* hand. Their lexical model is the expression lexer's (`js-lexer.ts`):
|
|
1930
|
+
* string, template and regex literals and comments are opaque (a backslash
|
|
1931
|
+
* inside a literal escapes the character after it, and `${…}` parts of a
|
|
1932
|
+
* template are code), `/` opens a regex only where an operand is expected,
|
|
1933
|
+
* and `(`/`[`/`{` nest.
|
|
1934
|
+
*/
|
|
1935
|
+
/** True for a whitespace character. */
|
|
1936
|
+
function isWhitespace(ch) {
|
|
1937
|
+
return /\s/.test(ch);
|
|
1938
|
+
}
|
|
1939
|
+
function isOpener(ch) {
|
|
1940
|
+
return ch === "(" || ch === "[" || ch === "{";
|
|
1941
|
+
}
|
|
1942
|
+
function isCloser(ch) {
|
|
1943
|
+
return ch === ")" || ch === "]" || ch === "}";
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Undo the escapes allowed in a literal quoted macro argument: `\"`, `\'`
|
|
1947
|
+
* and `\\` become `"`, `'` and `\`. Other backslash sequences (`\n`, `\d`)
|
|
1948
|
+
* are kept as written, so code and regex text inside the string survive.
|
|
1949
|
+
*/
|
|
1950
|
+
function unescapeQuoted(body) {
|
|
1951
|
+
return body.replace(/\\(["'\\])/g, "$1");
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Read the `"…"` or `'…'` string whose opening quote is at `start`.
|
|
1955
|
+
* Returns its unescaped value and the index just past the closing quote, or
|
|
1956
|
+
* `null` if there is no such quote at `start` or the string is unterminated.
|
|
1957
|
+
*/
|
|
1958
|
+
function readQuoted(src, start) {
|
|
1959
|
+
const quote = src[start];
|
|
1960
|
+
if (quote !== "\"" && quote !== "'") return null;
|
|
1961
|
+
const { end, closed } = scanStringLiteral(src, start);
|
|
1962
|
+
if (!closed) return null;
|
|
1963
|
+
return {
|
|
1964
|
+
value: unescapeQuoted(src.slice(start + 1, end - 1)),
|
|
1965
|
+
end
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The unescaped value of `src` when it is exactly one `"…"` or `'…'` string
|
|
1970
|
+
* (as with `readQuoted`), otherwise `null`.
|
|
1971
|
+
*/
|
|
1972
|
+
function readWholeQuoted(src) {
|
|
1973
|
+
const quoted = readQuoted(src, 0);
|
|
1974
|
+
return quoted && quoted.end === src.length ? quoted.value : null;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Strip an optional quote from each end of an unquoted-or-loosely-quoted
|
|
1978
|
+
* argument (`"Red`, `Red'`), leaving at least one character. This is the
|
|
1979
|
+
* lenient fallback for labels that are not one well-formed quoted string.
|
|
1980
|
+
*/
|
|
1981
|
+
function stripLooseQuotes(src) {
|
|
1982
|
+
return /^["']?(.+?)["']?$/.exec(src)?.[1] ?? src;
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Indices of the characters in `src` that satisfy `isSeparator` and sit at
|
|
1986
|
+
* depth 0: in code outside string, template and regex literals and comments,
|
|
1987
|
+
* and outside `()`, `[]` and `{}` pairs.
|
|
1988
|
+
*/
|
|
1989
|
+
function topLevelIndices(src, isSeparator) {
|
|
1990
|
+
const indices = [];
|
|
1991
|
+
let depth = 0;
|
|
1992
|
+
lexJs(src, { code(ch, i, nesting) {
|
|
1993
|
+
if (nesting > 0) return;
|
|
1994
|
+
if (isOpener(ch)) depth++;
|
|
1995
|
+
else if (isCloser(ch)) depth--;
|
|
1996
|
+
else if (depth === 0 && isSeparator(ch)) indices.push(i);
|
|
1997
|
+
} });
|
|
1998
|
+
return indices;
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Split `src` at every depth-0 separator (see `topLevelIndices`). Segments
|
|
2002
|
+
* are returned as written, untrimmed, and empty segments are kept, so
|
|
2003
|
+
* `n` separators always give `n + 1` segments.
|
|
2004
|
+
*/
|
|
2005
|
+
function splitTopLevel(src, isSeparator) {
|
|
2006
|
+
const segments = [];
|
|
2007
|
+
let from = 0;
|
|
2008
|
+
for (const i of topLevelIndices(src, isSeparator)) {
|
|
2009
|
+
segments.push(src.slice(from, i));
|
|
2010
|
+
from = i + 1;
|
|
2011
|
+
}
|
|
2012
|
+
segments.push(src.slice(from));
|
|
2013
|
+
return segments;
|
|
2014
|
+
}
|
|
2015
|
+
//#endregion
|
|
1620
2016
|
//#region src/components/macros/WidgetInvocation.tsx
|
|
1621
2017
|
/**
|
|
1622
2018
|
* Check whether a whitespace-delimited token looks like a standalone value
|
|
@@ -1634,47 +2030,13 @@ function isStandaloneValue(token) {
|
|
|
1634
2030
|
return false;
|
|
1635
2031
|
}
|
|
1636
2032
|
/**
|
|
1637
|
-
* Try to split a raw string on whitespace at depth 0 (respecting
|
|
1638
|
-
* parentheses, brackets, and braces). Each resulting token
|
|
1639
|
-
* `isStandaloneValue()` or the split is rejected and `null` is
|
|
2033
|
+
* Try to split a raw string on whitespace at depth 0 (respecting strings,
|
|
2034
|
+
* template literals, parentheses, brackets, and braces). Each resulting token
|
|
2035
|
+
* must pass `isStandaloneValue()` or the split is rejected and `null` is
|
|
2036
|
+
* returned.
|
|
1640
2037
|
*/
|
|
1641
2038
|
function trySplitOnWhitespace(raw) {
|
|
1642
|
-
const args =
|
|
1643
|
-
let current = "";
|
|
1644
|
-
let depth = 0;
|
|
1645
|
-
let inString = null;
|
|
1646
|
-
for (let i = 0; i < raw.length; i++) {
|
|
1647
|
-
const ch = raw[i];
|
|
1648
|
-
if (inString) {
|
|
1649
|
-
current += ch;
|
|
1650
|
-
if (ch === inString && raw[i - 1] !== "\\") inString = null;
|
|
1651
|
-
continue;
|
|
1652
|
-
}
|
|
1653
|
-
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
1654
|
-
inString = ch;
|
|
1655
|
-
current += ch;
|
|
1656
|
-
continue;
|
|
1657
|
-
}
|
|
1658
|
-
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1659
|
-
depth++;
|
|
1660
|
-
current += ch;
|
|
1661
|
-
continue;
|
|
1662
|
-
}
|
|
1663
|
-
if (ch === ")" || ch === "]" || ch === "}") {
|
|
1664
|
-
depth--;
|
|
1665
|
-
current += ch;
|
|
1666
|
-
continue;
|
|
1667
|
-
}
|
|
1668
|
-
if (/\s/.test(ch) && depth === 0) {
|
|
1669
|
-
if (current) {
|
|
1670
|
-
args.push(current);
|
|
1671
|
-
current = "";
|
|
1672
|
-
}
|
|
1673
|
-
continue;
|
|
1674
|
-
}
|
|
1675
|
-
current += ch;
|
|
1676
|
-
}
|
|
1677
|
-
if (current) args.push(current);
|
|
2039
|
+
const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
|
|
1678
2040
|
if (args.length < 2) return null;
|
|
1679
2041
|
for (const arg of args) if (!isStandaloneValue(arg)) return null;
|
|
1680
2042
|
return args;
|
|
@@ -1685,43 +2047,9 @@ function trySplitOnWhitespace(raw) {
|
|
|
1685
2047
|
* string literals separated by whitespace (e.g. `"Label" "target"`).
|
|
1686
2048
|
*/
|
|
1687
2049
|
function splitArgs(raw) {
|
|
1688
|
-
const args =
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
let inString = null;
|
|
1692
|
-
let hasComma = false;
|
|
1693
|
-
for (let i = 0; i < raw.length; i++) {
|
|
1694
|
-
const ch = raw[i];
|
|
1695
|
-
if (inString) {
|
|
1696
|
-
current += ch;
|
|
1697
|
-
if (ch === inString && raw[i - 1] !== "\\") inString = null;
|
|
1698
|
-
continue;
|
|
1699
|
-
}
|
|
1700
|
-
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
1701
|
-
inString = ch;
|
|
1702
|
-
current += ch;
|
|
1703
|
-
continue;
|
|
1704
|
-
}
|
|
1705
|
-
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1706
|
-
depth++;
|
|
1707
|
-
current += ch;
|
|
1708
|
-
continue;
|
|
1709
|
-
}
|
|
1710
|
-
if (ch === ")" || ch === "]" || ch === "}") {
|
|
1711
|
-
depth--;
|
|
1712
|
-
current += ch;
|
|
1713
|
-
continue;
|
|
1714
|
-
}
|
|
1715
|
-
if (ch === "," && depth === 0) {
|
|
1716
|
-
hasComma = true;
|
|
1717
|
-
args.push(current.trim());
|
|
1718
|
-
current = "";
|
|
1719
|
-
continue;
|
|
1720
|
-
}
|
|
1721
|
-
current += ch;
|
|
1722
|
-
}
|
|
1723
|
-
const last = current.trim();
|
|
1724
|
-
if (last) args.push(last);
|
|
2050
|
+
const args = splitTopLevel(raw, (ch) => ch === ",").map((a) => a.trim());
|
|
2051
|
+
const hasComma = args.length > 1;
|
|
2052
|
+
if (args[args.length - 1] === "") args.pop();
|
|
1725
2053
|
if (!hasComma && args.length === 1) {
|
|
1726
2054
|
const split = trySplitOnWhitespace(args[0]);
|
|
1727
2055
|
if (split) return split;
|
|
@@ -1730,6 +2058,7 @@ function splitArgs(raw) {
|
|
|
1730
2058
|
}
|
|
1731
2059
|
function WidgetBody({ body, parentValues, ownKeys }) {
|
|
1732
2060
|
const nobr = useContext(NobrContext);
|
|
2061
|
+
const inline = useContext(InlineContext);
|
|
1733
2062
|
const [localMutations, setLocalMutations] = useState({});
|
|
1734
2063
|
const localState = useMemo(() => ({
|
|
1735
2064
|
...parentValues,
|
|
@@ -1763,6 +2092,7 @@ function WidgetBody({ body, parentValues, ownKeys }) {
|
|
|
1763
2092
|
value: localState,
|
|
1764
2093
|
children: renderNodes(body, {
|
|
1765
2094
|
nobr,
|
|
2095
|
+
inline,
|
|
1766
2096
|
locals: localState
|
|
1767
2097
|
})
|
|
1768
2098
|
})
|
|
@@ -1771,12 +2101,14 @@ function WidgetBody({ body, parentValues, ownKeys }) {
|
|
|
1771
2101
|
function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
|
|
1772
2102
|
const parentValues = useContext(LocalsValuesContext);
|
|
1773
2103
|
const nobr = useContext(NobrContext);
|
|
2104
|
+
const inline = useContext(InlineContext);
|
|
1774
2105
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
|
|
1775
2106
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
1776
2107
|
if (params.length === 0) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
|
|
1777
2108
|
value: childrenValue,
|
|
1778
2109
|
children: renderNodes(body, {
|
|
1779
2110
|
nobr,
|
|
2111
|
+
inline,
|
|
1780
2112
|
locals: parentValues
|
|
1781
2113
|
})
|
|
1782
2114
|
});
|
|
@@ -1822,12 +2154,12 @@ function getWidget(name) {
|
|
|
1822
2154
|
}
|
|
1823
2155
|
//#endregion
|
|
1824
2156
|
//#region src/registry.ts
|
|
1825
|
-
var registry
|
|
2157
|
+
var registry = /* @__PURE__ */ new Map();
|
|
1826
2158
|
function registerMacro(name, component) {
|
|
1827
|
-
registry
|
|
2159
|
+
registry.set(name.toLowerCase(), component);
|
|
1828
2160
|
}
|
|
1829
2161
|
function getMacro(name) {
|
|
1830
|
-
return registry
|
|
2162
|
+
return registry.get(name.toLowerCase());
|
|
1831
2163
|
}
|
|
1832
2164
|
var subMacros = /* @__PURE__ */ new Set();
|
|
1833
2165
|
function registerSubMacro(name) {
|
|
@@ -1877,28 +2209,82 @@ var defaultUpdater = {
|
|
|
1877
2209
|
var LocalsValuesContext = createContext({});
|
|
1878
2210
|
var LocalsUpdateContext = createContext(defaultUpdater);
|
|
1879
2211
|
var NobrContext = createContext(false);
|
|
2212
|
+
/**
|
|
2213
|
+
* True while rendering inside an inline HTML element (e.g. `<span>`), where
|
|
2214
|
+
* block-level markdown and `<p>` wrappers would produce invalid HTML (#220).
|
|
2215
|
+
* Macro and widget bodies read it so their content stays inline too.
|
|
2216
|
+
*/
|
|
2217
|
+
var InlineContext = createContext(false);
|
|
1880
2218
|
var SvgContext = createContext(false);
|
|
1881
2219
|
var WidgetChildrenContext = createContext(null);
|
|
2220
|
+
function placeholderHtml(nonce, index) {
|
|
2221
|
+
return `<span data-tw="${nonce}:${index}"></span>`;
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* A placeholder as text. micromark escapes placeholders in code (a code span
|
|
2225
|
+
* of any backtick length, or a fenced code block), so they reach the DOM as
|
|
2226
|
+
* literal text and are swapped for the live component there, keeping it
|
|
2227
|
+
* subscribed (#223). Letting micromark decide what is code keeps this exact,
|
|
2228
|
+
* with no CommonMark re-implementation to drift from it. Also consumes an
|
|
2229
|
+
* ESCAPE_GUARD in front of the placeholder.
|
|
2230
|
+
*/
|
|
2231
|
+
var PLACEHOLDER_TEXT_RE = /(?:(?<=\\)\uE000)?<span data-tw="([0-9a-z]+):(\d+)"><\/span>/g;
|
|
2232
|
+
/**
|
|
2233
|
+
* Emitted between author text ending in a backslash and a placeholder, so
|
|
2234
|
+
* the backslash cannot escape the placeholder's `<` and leak it as text. A
|
|
2235
|
+
* backslash before a non-punctuation character stays literal in CommonMark,
|
|
2236
|
+
* matching what the author wrote. Removed again when converting text nodes.
|
|
2237
|
+
*/
|
|
2238
|
+
var ESCAPE_GUARD = "";
|
|
2239
|
+
/** Split text into literal parts and the components its placeholders name. */
|
|
2240
|
+
function expandPlaceholderText(text, ph) {
|
|
2241
|
+
if (!text.includes("<span data-tw=\"")) return [text];
|
|
2242
|
+
const parts = [];
|
|
2243
|
+
let last = 0;
|
|
2244
|
+
for (const m of text.matchAll(PLACEHOLDER_TEXT_RE)) {
|
|
2245
|
+
if (m[1] !== ph.nonce) continue;
|
|
2246
|
+
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
2247
|
+
parts.push(ph.components[parseInt(m[2], 10)]);
|
|
2248
|
+
last = m.index + m[0].length;
|
|
2249
|
+
}
|
|
2250
|
+
if (last < text.length) parts.push(text.slice(last));
|
|
2251
|
+
return parts;
|
|
2252
|
+
}
|
|
2253
|
+
/** The component index of a placeholder element of this call, or -1. */
|
|
2254
|
+
function placeholderIndex(node, ph) {
|
|
2255
|
+
if (node == null || node.nodeType !== Node.ELEMENT_NODE) return -1;
|
|
2256
|
+
const value = node.getAttribute("data-tw");
|
|
2257
|
+
const prefix = `${ph.nonce}:`;
|
|
2258
|
+
if (value == null || !value.startsWith(prefix)) return -1;
|
|
2259
|
+
return parseInt(value.slice(prefix.length), 10);
|
|
2260
|
+
}
|
|
1882
2261
|
/**
|
|
1883
2262
|
* Convert an HTML string (from micromark) to Preact VNodes,
|
|
1884
|
-
* replacing
|
|
2263
|
+
* replacing placeholder elements with pre-rendered components.
|
|
2264
|
+
* With `unwrapParagraphs` (nobr or inline content), top-level <p> wrappers are
|
|
2265
|
+
* replaced by their children.
|
|
1885
2266
|
*/
|
|
1886
|
-
function htmlToPreact(html,
|
|
2267
|
+
function htmlToPreact(html, ph, unwrapParagraphs = false) {
|
|
1887
2268
|
const temp = document.createElement("div");
|
|
1888
2269
|
temp.innerHTML = html.trim();
|
|
1889
|
-
if (
|
|
1890
|
-
return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(temp.childNodes).map((child, i) => convertDomNode(child, i,
|
|
2270
|
+
if (unwrapParagraphs) for (const p of Array.from(temp.querySelectorAll(":scope > p"))) p.replaceWith(...Array.from(p.childNodes));
|
|
2271
|
+
return /* @__PURE__ */ jsx(Fragment$1, { children: Array.from(temp.childNodes).map((child, i) => convertDomNode(child, i, ph)) });
|
|
1891
2272
|
}
|
|
1892
|
-
function convertDomNode(node, key,
|
|
1893
|
-
if (node.nodeType === Node.TEXT_NODE)
|
|
2273
|
+
function convertDomNode(node, key, ph) {
|
|
2274
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
2275
|
+
let text = node.textContent ?? "";
|
|
2276
|
+
if (text.endsWith(`\\${ESCAPE_GUARD}`) && placeholderIndex(node.nextSibling, ph) !== -1) text = text.slice(0, -1);
|
|
2277
|
+
const parts = expandPlaceholderText(text, ph);
|
|
2278
|
+
return parts.length === 1 ? parts[0] : parts;
|
|
2279
|
+
}
|
|
1894
2280
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1895
2281
|
const el = node;
|
|
1896
2282
|
const tag = el.localName;
|
|
1897
|
-
const
|
|
1898
|
-
if (
|
|
2283
|
+
const idx = placeholderIndex(el, ph);
|
|
2284
|
+
if (idx !== -1) return ph.components[idx];
|
|
1899
2285
|
const props = { key };
|
|
1900
2286
|
for (const attr of Array.from(el.attributes)) props[attr.name] = attr.value;
|
|
1901
|
-
return h(tag, props, ...Array.from(el.childNodes).map((child, i) => convertDomNode(child, i,
|
|
2287
|
+
return h(tag, props, ...Array.from(el.childNodes).map((child, i) => convertDomNode(child, i, ph)));
|
|
1902
2288
|
}
|
|
1903
2289
|
return null;
|
|
1904
2290
|
}
|
|
@@ -1978,15 +2364,24 @@ function HtmlNodeRenderer({ node }) {
|
|
|
1978
2364
|
const nobr = useContext(NobrContext);
|
|
1979
2365
|
const locals = useContext(LocalsValuesContext);
|
|
1980
2366
|
const inSvg = useContext(SvgContext);
|
|
2367
|
+
const parentInline = useContext(InlineContext);
|
|
1981
2368
|
const attrs = {};
|
|
1982
2369
|
for (const [k, v] of Object.entries(node.attributes)) attrs[k] = isPresentBooleanAttribute(k, v) ? true : resolve(v) ?? v;
|
|
1983
2370
|
const isSvgRoot = node.tag.toLowerCase() === "svg";
|
|
1984
2371
|
const isInline = INLINE_ELEMENTS.has(node.tag.toLowerCase());
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
2372
|
+
let children = void 0;
|
|
2373
|
+
if (node.children.length > 0) if (inSvg || isSvgRoot) children = renderInlineNodes(node.children);
|
|
2374
|
+
else {
|
|
2375
|
+
children = renderNodes(node.children, {
|
|
2376
|
+
nobr,
|
|
2377
|
+
locals,
|
|
2378
|
+
inline: isInline
|
|
2379
|
+
});
|
|
2380
|
+
if (isInline !== parentInline) children = /* @__PURE__ */ jsx(InlineContext.Provider, {
|
|
2381
|
+
value: isInline,
|
|
2382
|
+
children
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
1990
2385
|
const element = h(node.tag, attrs, children);
|
|
1991
2386
|
return isSvgRoot ? /* @__PURE__ */ jsx(SvgContext.Provider, {
|
|
1992
2387
|
value: true,
|
|
@@ -1996,11 +2391,13 @@ function HtmlNodeRenderer({ node }) {
|
|
|
1996
2391
|
function ChildrenSlot() {
|
|
1997
2392
|
const childrenAST = useContext(WidgetChildrenContext);
|
|
1998
2393
|
const nobr = useContext(NobrContext);
|
|
2394
|
+
const inline = useContext(InlineContext);
|
|
1999
2395
|
const locals = useContext(LocalsValuesContext);
|
|
2000
2396
|
if (!childrenAST || childrenAST.length === 0) return null;
|
|
2001
2397
|
return /* @__PURE__ */ jsx(Fragment$1, { children: renderNodes(childrenAST, {
|
|
2002
2398
|
nobr,
|
|
2003
|
-
locals
|
|
2399
|
+
locals,
|
|
2400
|
+
inline
|
|
2004
2401
|
}) });
|
|
2005
2402
|
}
|
|
2006
2403
|
/**
|
|
@@ -2076,26 +2473,6 @@ function renderInlineNodes(nodes) {
|
|
|
2076
2473
|
if (nodes.length === 0) return null;
|
|
2077
2474
|
return nodes.map((node) => renderSingleNode(node));
|
|
2078
2475
|
}
|
|
2079
|
-
function hasUnclosedBacktick(s) {
|
|
2080
|
-
let count = 0;
|
|
2081
|
-
for (let i = 0; i < s.length; i++) if (s[i] === "`") count++;
|
|
2082
|
-
return count % 2 === 1;
|
|
2083
|
-
}
|
|
2084
|
-
function getVariableTextValue(node, locals) {
|
|
2085
|
-
const state = useStoryStore.getState();
|
|
2086
|
-
const parts = node.name.split(".");
|
|
2087
|
-
const root = parts[0];
|
|
2088
|
-
let value;
|
|
2089
|
-
if (node.scope === "variable") value = state.variables[root];
|
|
2090
|
-
else if (node.scope === "temporary") value = state.temporary[root];
|
|
2091
|
-
else if (node.scope === "transient") value = state.transient[root];
|
|
2092
|
-
else value = locals[root];
|
|
2093
|
-
for (let i = 1; i < parts.length; i++) {
|
|
2094
|
-
if (value == null) return "";
|
|
2095
|
-
value = value[parts[i]];
|
|
2096
|
-
}
|
|
2097
|
-
return value == null ? "" : String(value);
|
|
2098
|
-
}
|
|
2099
2476
|
/**
|
|
2100
2477
|
* Characters/patterns that trigger CommonMark or GFM transformations.
|
|
2101
2478
|
* Any match → fall through to the full micromark pipeline.
|
|
@@ -2105,53 +2482,53 @@ function getVariableTextValue(node, locals) {
|
|
|
2105
2482
|
*/
|
|
2106
2483
|
var MARKDOWN_SYNTAX_RE = /[*_`#|~\[>\\\-+=]|!\[|\d+\.|&#?[a-zA-Z0-9]+;| {2}\n/;
|
|
2107
2484
|
var BLANK_LINE_RE = /\n\s*\n/;
|
|
2108
|
-
var
|
|
2109
|
-
var PLACEHOLDER_IDX_RE = /^<span data-tw="(\d+)"><\/span>$/;
|
|
2110
|
-
var PLACEHOLDER_STRIP_RE = /<span data-tw="\d+"><\/span>/g;
|
|
2485
|
+
var PLACEHOLDER_STRIP_RE = /<span data-tw="[0-9a-z]+:\d+"><\/span>/g;
|
|
2111
2486
|
/**
|
|
2112
2487
|
* Build Preact vnodes from a combined string that contains only plain text
|
|
2113
|
-
* and
|
|
2488
|
+
* and placeholders. No micromark, no innerHTML.
|
|
2114
2489
|
*/
|
|
2115
|
-
function buildPlainTextVnodes(combined,
|
|
2116
|
-
const
|
|
2117
|
-
|
|
2118
|
-
for (const part of parts) {
|
|
2119
|
-
const m = PLACEHOLDER_IDX_RE.exec(part);
|
|
2120
|
-
if (m) children.push(components[parseInt(m[1], 10)]);
|
|
2121
|
-
else if (part) children.push(part);
|
|
2122
|
-
}
|
|
2123
|
-
return nobr ? /* @__PURE__ */ jsx(Fragment$1, { children }) : h("p", null, ...children);
|
|
2490
|
+
function buildPlainTextVnodes(combined, ph, unwrapParagraphs) {
|
|
2491
|
+
const children = expandPlaceholderText(combined, ph).filter((part) => part !== "");
|
|
2492
|
+
return unwrapParagraphs ? /* @__PURE__ */ jsx(Fragment$1, { children }) : h("p", null, ...children);
|
|
2124
2493
|
}
|
|
2125
2494
|
/**
|
|
2126
2495
|
* Render AST nodes with full CommonMark markdown support.
|
|
2127
2496
|
*
|
|
2128
|
-
* Combines all nodes into a single markdown document, using
|
|
2129
|
-
* elements for non-text nodes (variables, macros, links,
|
|
2497
|
+
* Combines all nodes into a single markdown document, using placeholder
|
|
2498
|
+
* elements (see Placeholders) for non-text nodes (variables, macros, links,
|
|
2499
|
+
* HTML). This allows
|
|
2130
2500
|
* markdown syntax to span across Twine tokens — e.g., markdown tables can
|
|
2131
2501
|
* contain {$variables} and {macros} in their cells.
|
|
2132
2502
|
*
|
|
2133
2503
|
* After micromark processes the combined string, the HTML is parsed back into
|
|
2134
2504
|
* Preact VNodes with placeholders replaced by the real rendered components.
|
|
2505
|
+
* Placeholders that micromark escaped as code text are swapped back too (see
|
|
2506
|
+
* PLACEHOLDER_TEXT_RE), so variables in code stay live components.
|
|
2135
2507
|
*/
|
|
2136
2508
|
function renderNodes(nodes, options) {
|
|
2137
2509
|
if (nodes.length === 0) return null;
|
|
2138
2510
|
if (!nodes.some((n) => n.type === "text" && (n.value.trim() !== "" || /\n\s*\n/.test(n.value)))) return nodes.map((node) => renderSingleNode(node));
|
|
2139
2511
|
const components = [];
|
|
2512
|
+
const ph = {
|
|
2513
|
+
nonce: Math.random().toString(36).slice(2, 10) || "0",
|
|
2514
|
+
components
|
|
2515
|
+
};
|
|
2140
2516
|
let combined = "";
|
|
2141
|
-
const locals = options?.locals ?? {};
|
|
2142
2517
|
for (let i = 0; i < nodes.length; i++) {
|
|
2143
2518
|
const node = nodes[i];
|
|
2144
|
-
if (node.type === "text")
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
const phIdx = components.length;
|
|
2148
|
-
components.push(renderSingleNode(node));
|
|
2149
|
-
combined += `<span data-tw="${phIdx}"></span>`;
|
|
2519
|
+
if (node.type === "text") {
|
|
2520
|
+
combined += node.value;
|
|
2521
|
+
continue;
|
|
2150
2522
|
}
|
|
2523
|
+
const phIdx = components.length;
|
|
2524
|
+
components.push(renderSingleNode(node));
|
|
2525
|
+
if (combined.endsWith("\\")) combined += ESCAPE_GUARD;
|
|
2526
|
+
combined += placeholderHtml(ph.nonce, phIdx);
|
|
2151
2527
|
}
|
|
2528
|
+
const unwrapParagraphs = !!(options?.nobr || options?.inline);
|
|
2152
2529
|
const textOnly = combined.replace(PLACEHOLDER_STRIP_RE, "");
|
|
2153
|
-
if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) return buildPlainTextVnodes(combined,
|
|
2154
|
-
return htmlToPreact(markdownToHtml(combined, { inline: options?.inline }),
|
|
2530
|
+
if (!MARKDOWN_SYNTAX_RE.test(textOnly) && !BLANK_LINE_RE.test(textOnly)) return buildPlainTextVnodes(combined, ph, unwrapParagraphs);
|
|
2531
|
+
return htmlToPreact(markdownToHtml(combined, { inline: options?.inline }), ph, unwrapParagraphs);
|
|
2155
2532
|
}
|
|
2156
2533
|
//#endregion
|
|
2157
2534
|
//#region src/saves/storage.ts
|
|
@@ -2269,6 +2646,8 @@ function createLocalStorageBackend() {
|
|
|
2269
2646
|
type: "localstorage",
|
|
2270
2647
|
async putSave(record) {
|
|
2271
2648
|
const id = record.meta.id;
|
|
2649
|
+
const previous = lsGet(`${LS_SAVE_PREFIX}${id}`);
|
|
2650
|
+
if (previous && previous.meta.playthroughId !== record.meta.playthroughId) lsIndexRemove(`${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`, id);
|
|
2272
2651
|
lsSet(`${LS_SAVE_PREFIX}${id}`, record);
|
|
2273
2652
|
lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
|
|
2274
2653
|
lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
|
|
@@ -2453,264 +2832,76 @@ function createIDBBackend() {
|
|
|
2453
2832
|
await idbReq((await tx("meta", "readwrite")).delete(key));
|
|
2454
2833
|
},
|
|
2455
2834
|
async deleteMetaByPrefix(prefix) {
|
|
2456
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
2457
|
-
for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
2458
|
-
},
|
|
2459
|
-
async deleteMetaByIfid(ifid) {
|
|
2460
|
-
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
2461
|
-
for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
2462
|
-
},
|
|
2463
|
-
async getAllMetaKeys() {
|
|
2464
|
-
return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
|
|
2465
|
-
},
|
|
2466
|
-
async destroy() {
|
|
2467
|
-
(await openDB()).close();
|
|
2468
|
-
dbPromise = null;
|
|
2469
|
-
await new Promise((resolve, reject) => {
|
|
2470
|
-
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
2471
|
-
req.onsuccess = () => resolve();
|
|
2472
|
-
req.onerror = () => reject(req.error);
|
|
2473
|
-
});
|
|
2474
|
-
}
|
|
2475
|
-
};
|
|
2476
|
-
}
|
|
2477
|
-
var _backend = null;
|
|
2478
|
-
async function detectBackend() {
|
|
2479
|
-
if (typeof indexedDB !== "undefined") try {
|
|
2480
|
-
await new Promise((resolve, reject) => {
|
|
2481
|
-
const req = indexedDB.open("__spindle_probe__", 1);
|
|
2482
|
-
req.onsuccess = () => {
|
|
2483
|
-
req.result.close();
|
|
2484
|
-
indexedDB.deleteDatabase("__spindle_probe__");
|
|
2485
|
-
resolve();
|
|
2486
|
-
};
|
|
2487
|
-
req.onerror = () => reject(req.error);
|
|
2488
|
-
});
|
|
2489
|
-
return createIDBBackend();
|
|
2490
|
-
} catch {}
|
|
2491
|
-
if (typeof localStorage !== "undefined") try {
|
|
2492
|
-
const probeKey = "__spindle_probe__";
|
|
2493
|
-
localStorage.setItem(probeKey, "1");
|
|
2494
|
-
const val = localStorage.getItem(probeKey);
|
|
2495
|
-
localStorage.removeItem(probeKey);
|
|
2496
|
-
if (val === "1") return createLocalStorageBackend();
|
|
2497
|
-
} catch {}
|
|
2498
|
-
return createMemoryBackend();
|
|
2499
|
-
}
|
|
2500
|
-
/**
|
|
2501
|
-
* Detection in flight. Concurrent first callers share it so they all get the
|
|
2502
|
-
* same backend (the memory fallback makes a new, empty store per detection).
|
|
2503
|
-
*/
|
|
2504
|
-
var _backendPromise = null;
|
|
2505
|
-
function getBackend() {
|
|
2506
|
-
if (_backend) return Promise.resolve(_backend);
|
|
2507
|
-
if (!_backendPromise) {
|
|
2508
|
-
const pending = detectBackend().then((backend) => {
|
|
2509
|
-
if (_backendPromise === pending) _backend = backend;
|
|
2510
|
-
return backend;
|
|
2511
|
-
});
|
|
2512
|
-
_backendPromise = pending;
|
|
2513
|
-
}
|
|
2514
|
-
return _backendPromise;
|
|
2515
|
-
}
|
|
2516
|
-
function resetBackend() {
|
|
2517
|
-
_backend = null;
|
|
2518
|
-
_backendPromise = null;
|
|
2519
|
-
}
|
|
2520
|
-
/**
|
|
2521
|
-
* Synchronous accessor for the current backend type.
|
|
2522
|
-
* Returns 'memory' if the backend hasn't been detected yet.
|
|
2523
|
-
*/
|
|
2524
|
-
function getBackendType() {
|
|
2525
|
-
return _backend?.type ?? "memory";
|
|
2526
|
-
}
|
|
2527
|
-
//#endregion
|
|
2528
|
-
//#region src/class-registry.ts
|
|
2529
|
-
var registry = /* @__PURE__ */ new Map();
|
|
2530
|
-
var ctorToName = /* @__PURE__ */ new Map();
|
|
2531
|
-
function registerClass(name, ctor) {
|
|
2532
|
-
registry.set(name, ctor);
|
|
2533
|
-
ctorToName.set(ctor, name);
|
|
2534
|
-
}
|
|
2535
|
-
function isPlainObject(value) {
|
|
2536
|
-
if (typeof value !== "object" || value === null) return false;
|
|
2537
|
-
const proto = Object.getPrototypeOf(value);
|
|
2538
|
-
return proto === Object.prototype || proto === null;
|
|
2539
|
-
}
|
|
2540
|
-
function deepClone(value, options = {}) {
|
|
2541
|
-
const seen = /* @__PURE__ */ new Map();
|
|
2542
|
-
function clone(val) {
|
|
2543
|
-
if (val === null || typeof val !== "object") return val;
|
|
2544
|
-
const obj = val;
|
|
2545
|
-
if (seen.has(obj)) return seen.get(obj);
|
|
2546
|
-
if (val instanceof Date) return new Date(val.getTime());
|
|
2547
|
-
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
2548
|
-
if (Array.isArray(val)) {
|
|
2549
|
-
const arr = [];
|
|
2550
|
-
seen.set(obj, arr);
|
|
2551
|
-
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
2552
|
-
return arr;
|
|
2553
|
-
}
|
|
2554
|
-
if (val instanceof Map) {
|
|
2555
|
-
const copy = /* @__PURE__ */ new Map();
|
|
2556
|
-
seen.set(obj, copy);
|
|
2557
|
-
for (const [k, v] of val) copy.set(clone(k), clone(v));
|
|
2558
|
-
return copy;
|
|
2559
|
-
}
|
|
2560
|
-
if (val instanceof Set) {
|
|
2561
|
-
const copy = /* @__PURE__ */ new Set();
|
|
2562
|
-
seen.set(obj, copy);
|
|
2563
|
-
for (const v of val) copy.add(clone(v));
|
|
2564
|
-
return copy;
|
|
2565
|
-
}
|
|
2566
|
-
const ctor = obj.constructor;
|
|
2567
|
-
if (ctorToName.get(ctor) !== void 0) {
|
|
2568
|
-
const copy = Object.create(Object.getPrototypeOf(obj));
|
|
2569
|
-
seen.set(obj, copy);
|
|
2570
|
-
for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
|
|
2571
|
-
return copy;
|
|
2572
|
-
}
|
|
2573
|
-
if (!isPlainObject(val) && options.keepUnregistered) return val;
|
|
2574
|
-
if (isPlainObject(val) || typeof val === "object") {
|
|
2575
|
-
const copy = {};
|
|
2576
|
-
seen.set(obj, copy);
|
|
2577
|
-
for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
|
|
2578
|
-
return copy;
|
|
2579
|
-
}
|
|
2580
|
-
return val;
|
|
2581
|
-
}
|
|
2582
|
-
return clone(value);
|
|
2583
|
-
}
|
|
2584
|
-
/**
|
|
2585
|
-
* Structural equality over the value types deepClone() supports: primitives,
|
|
2586
|
-
* arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
|
|
2587
|
-
* at any depth). Map and Set entries are compared in insertion order.
|
|
2588
|
-
*/
|
|
2589
|
-
function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
|
|
2590
|
-
if (Object.is(a, b)) return true;
|
|
2591
|
-
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
2592
|
-
if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
|
|
2593
|
-
if (seen.get(a) === b) return true;
|
|
2594
|
-
seen.set(a, b);
|
|
2595
|
-
if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
|
|
2596
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
2597
|
-
if (a instanceof Map || a instanceof Set) {
|
|
2598
|
-
const bc = b;
|
|
2599
|
-
if (a.size !== bc.size) return false;
|
|
2600
|
-
const ai = a.entries();
|
|
2601
|
-
const bi = bc.entries();
|
|
2602
|
-
for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!deepEqual(x.value, y.value, seen)) return false;
|
|
2603
|
-
return true;
|
|
2604
|
-
}
|
|
2605
|
-
const ao = a;
|
|
2606
|
-
const bo = b;
|
|
2607
|
-
const keys = Object.keys(ao);
|
|
2608
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
2609
|
-
for (const key of keys) if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
2610
|
-
return true;
|
|
2611
|
-
}
|
|
2612
|
-
var CLASS_TAG = "__spindle_class__";
|
|
2613
|
-
var DATA_TAG = "__spindle_data__";
|
|
2614
|
-
function serialize(value) {
|
|
2615
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2616
|
-
function ser(val) {
|
|
2617
|
-
if (val === null || typeof val !== "object") return val;
|
|
2618
|
-
const obj = val;
|
|
2619
|
-
if (seen.has(obj)) throw new Error("spindle: Cannot serialize circular references");
|
|
2620
|
-
seen.add(obj);
|
|
2621
|
-
if (val instanceof Date) {
|
|
2622
|
-
seen.delete(obj);
|
|
2623
|
-
return {
|
|
2624
|
-
[CLASS_TAG]: "__Date__",
|
|
2625
|
-
[DATA_TAG]: { iso: val.toISOString() }
|
|
2626
|
-
};
|
|
2627
|
-
}
|
|
2628
|
-
if (val instanceof RegExp) {
|
|
2629
|
-
seen.delete(obj);
|
|
2630
|
-
return {
|
|
2631
|
-
[CLASS_TAG]: "__RegExp__",
|
|
2632
|
-
[DATA_TAG]: {
|
|
2633
|
-
source: val.source,
|
|
2634
|
-
flags: val.flags
|
|
2635
|
-
}
|
|
2636
|
-
};
|
|
2637
|
-
}
|
|
2638
|
-
if (Array.isArray(val)) {
|
|
2639
|
-
const result = val.map((item) => ser(item));
|
|
2640
|
-
seen.delete(obj);
|
|
2641
|
-
return result;
|
|
2642
|
-
}
|
|
2643
|
-
if (val instanceof Map) {
|
|
2644
|
-
const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
|
|
2645
|
-
seen.delete(obj);
|
|
2646
|
-
return {
|
|
2647
|
-
[CLASS_TAG]: "__Map__",
|
|
2648
|
-
[DATA_TAG]: { entries }
|
|
2649
|
-
};
|
|
2650
|
-
}
|
|
2651
|
-
if (val instanceof Set) {
|
|
2652
|
-
const entries = [...val].map((v) => ser(v));
|
|
2653
|
-
seen.delete(obj);
|
|
2654
|
-
return {
|
|
2655
|
-
[CLASS_TAG]: "__Set__",
|
|
2656
|
-
[DATA_TAG]: { entries }
|
|
2657
|
-
};
|
|
2658
|
-
}
|
|
2659
|
-
const ctor = obj.constructor;
|
|
2660
|
-
const name = ctorToName.get(ctor);
|
|
2661
|
-
if (name !== void 0) {
|
|
2662
|
-
const data = {};
|
|
2663
|
-
for (const key of Object.keys(obj)) data[key] = ser(obj[key]);
|
|
2664
|
-
seen.delete(obj);
|
|
2665
|
-
return {
|
|
2666
|
-
[CLASS_TAG]: name,
|
|
2667
|
-
[DATA_TAG]: data
|
|
2668
|
-
};
|
|
2669
|
-
}
|
|
2670
|
-
const result = {};
|
|
2671
|
-
for (const key of Object.keys(obj)) result[key] = ser(obj[key]);
|
|
2672
|
-
seen.delete(obj);
|
|
2673
|
-
return result;
|
|
2674
|
-
}
|
|
2675
|
-
return ser(value);
|
|
2676
|
-
}
|
|
2677
|
-
function deserialize(value) {
|
|
2678
|
-
function deser(val) {
|
|
2679
|
-
if (val === null || typeof val !== "object") return val;
|
|
2680
|
-
if (Array.isArray(val)) return val.map((item) => deser(item));
|
|
2681
|
-
const obj = val;
|
|
2682
|
-
if (CLASS_TAG in obj && DATA_TAG in obj) {
|
|
2683
|
-
const name = obj[CLASS_TAG];
|
|
2684
|
-
const data = obj[DATA_TAG];
|
|
2685
|
-
if (name === "__Date__") return new Date(data.iso);
|
|
2686
|
-
if (name === "__RegExp__") return new RegExp(data.source, data.flags);
|
|
2687
|
-
if (name === "__Map__") {
|
|
2688
|
-
const entries = data.entries;
|
|
2689
|
-
return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
|
|
2690
|
-
}
|
|
2691
|
-
if (name === "__Set__") {
|
|
2692
|
-
const entries = data.entries;
|
|
2693
|
-
return new Set(entries.map((v) => deser(v)));
|
|
2694
|
-
}
|
|
2695
|
-
const ctor = registry.get(name);
|
|
2696
|
-
if (!ctor) {
|
|
2697
|
-
console.warn(`spindle: Class "${name}" not registered. Falling back to plain object.`);
|
|
2698
|
-
const plain = {};
|
|
2699
|
-
for (const key of Object.keys(data)) plain[key] = deser(data[key]);
|
|
2700
|
-
return plain;
|
|
2701
|
-
}
|
|
2702
|
-
const instance = Object.create(ctor.prototype);
|
|
2703
|
-
for (const key of Object.keys(data)) instance[key] = deser(data[key]);
|
|
2704
|
-
return instance;
|
|
2835
|
+
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
2836
|
+
for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
2837
|
+
},
|
|
2838
|
+
async deleteMetaByIfid(ifid) {
|
|
2839
|
+
const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
|
|
2840
|
+
for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
|
|
2841
|
+
},
|
|
2842
|
+
async getAllMetaKeys() {
|
|
2843
|
+
return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
|
|
2844
|
+
},
|
|
2845
|
+
async destroy() {
|
|
2846
|
+
(await openDB()).close();
|
|
2847
|
+
dbPromise = null;
|
|
2848
|
+
await new Promise((resolve, reject) => {
|
|
2849
|
+
const req = indexedDB.deleteDatabase(IDB_DB_NAME);
|
|
2850
|
+
req.onsuccess = () => resolve();
|
|
2851
|
+
req.onerror = () => reject(req.error);
|
|
2852
|
+
});
|
|
2705
2853
|
}
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
var _backend = null;
|
|
2857
|
+
async function detectBackend() {
|
|
2858
|
+
if (typeof indexedDB !== "undefined") try {
|
|
2859
|
+
await new Promise((resolve, reject) => {
|
|
2860
|
+
const req = indexedDB.open("__spindle_probe__", 1);
|
|
2861
|
+
req.onsuccess = () => {
|
|
2862
|
+
req.result.close();
|
|
2863
|
+
indexedDB.deleteDatabase("__spindle_probe__");
|
|
2864
|
+
resolve();
|
|
2865
|
+
};
|
|
2866
|
+
req.onerror = () => reject(req.error);
|
|
2867
|
+
});
|
|
2868
|
+
return createIDBBackend();
|
|
2869
|
+
} catch {}
|
|
2870
|
+
if (typeof localStorage !== "undefined") try {
|
|
2871
|
+
const probeKey = "__spindle_probe__";
|
|
2872
|
+
localStorage.setItem(probeKey, "1");
|
|
2873
|
+
const val = localStorage.getItem(probeKey);
|
|
2874
|
+
localStorage.removeItem(probeKey);
|
|
2875
|
+
if (val === "1") return createLocalStorageBackend();
|
|
2876
|
+
} catch {}
|
|
2877
|
+
return createMemoryBackend();
|
|
2878
|
+
}
|
|
2879
|
+
/**
|
|
2880
|
+
* Detection in flight. Concurrent first callers share it so they all get the
|
|
2881
|
+
* same backend (the memory fallback makes a new, empty store per detection).
|
|
2882
|
+
*/
|
|
2883
|
+
var _backendPromise = null;
|
|
2884
|
+
function getBackend() {
|
|
2885
|
+
if (_backend) return Promise.resolve(_backend);
|
|
2886
|
+
if (!_backendPromise) {
|
|
2887
|
+
const pending = detectBackend().then((backend) => {
|
|
2888
|
+
if (_backendPromise === pending) _backend = backend;
|
|
2889
|
+
return backend;
|
|
2890
|
+
});
|
|
2891
|
+
_backendPromise = pending;
|
|
2712
2892
|
}
|
|
2713
|
-
return
|
|
2893
|
+
return _backendPromise;
|
|
2894
|
+
}
|
|
2895
|
+
function resetBackend() {
|
|
2896
|
+
_backend = null;
|
|
2897
|
+
_backendPromise = null;
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Synchronous accessor for the current backend type.
|
|
2901
|
+
* Returns 'memory' if the backend hasn't been detected yet.
|
|
2902
|
+
*/
|
|
2903
|
+
function getBackendType() {
|
|
2904
|
+
return _backend?.type ?? "memory";
|
|
2714
2905
|
}
|
|
2715
2906
|
//#endregion
|
|
2716
2907
|
//#region src/event-emitter.ts
|
|
@@ -2790,10 +2981,14 @@ async function initSaveSystem() {
|
|
|
2790
2981
|
if (initialized) return;
|
|
2791
2982
|
initialized = true;
|
|
2792
2983
|
}
|
|
2793
|
-
|
|
2984
|
+
/**
|
|
2985
|
+
* Store a new playthrough and make it the story's current one. Pass `id` when
|
|
2986
|
+
* the caller has already switched to the playthrough (restart() does, so
|
|
2987
|
+
* saves issued before this resolves are tagged with it).
|
|
2988
|
+
*/
|
|
2989
|
+
async function startNewPlaythrough(ifid, id = crypto.randomUUID()) {
|
|
2794
2990
|
const backend = await getBackend();
|
|
2795
2991
|
const num = (await backend.getPlaythroughsByIfid(ifid)).length + 1;
|
|
2796
|
-
const id = crypto.randomUUID();
|
|
2797
2992
|
const record = {
|
|
2798
2993
|
id,
|
|
2799
2994
|
ifid,
|
|
@@ -2809,18 +3004,23 @@ async function getCurrentPlaythroughId(ifid) {
|
|
|
2809
3004
|
}
|
|
2810
3005
|
/**
|
|
2811
3006
|
* Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
|
|
2812
|
-
* before
|
|
2813
|
-
*
|
|
2814
|
-
*
|
|
3007
|
+
* before the payload is captured, so data a hook sets is part of the save;
|
|
3008
|
+
* `aftersave` fires once `write` has stored it. Every user-facing save path
|
|
3009
|
+
* goes through here.
|
|
3010
|
+
*
|
|
3011
|
+
* `beginCapture` is called just before the hooks and returns the function
|
|
3012
|
+
* that captures the payload after them, so the capture can tell which
|
|
3013
|
+
* variables the hooks changed (the store's `beginSave`).
|
|
2815
3014
|
*
|
|
2816
3015
|
* The hook and capture run synchronously, but an error they throw (a failing
|
|
2817
3016
|
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
2818
3017
|
* write rather than escaping to the caller.
|
|
2819
3018
|
*/
|
|
2820
|
-
function saveWithHooks(slot, custom,
|
|
3019
|
+
function saveWithHooks(slot, custom, beginCapture, write) {
|
|
2821
3020
|
try {
|
|
3021
|
+
const capture = beginCapture();
|
|
2822
3022
|
emit("beforesave", slot, custom);
|
|
2823
|
-
return write(
|
|
3023
|
+
return write(capture()).then((result) => {
|
|
2824
3024
|
emit("aftersave", slot);
|
|
2825
3025
|
return result;
|
|
2826
3026
|
});
|
|
@@ -2854,7 +3054,22 @@ async function createSave(ifid, playthroughId, payload, custom = {}) {
|
|
|
2854
3054
|
await (await getBackend()).putSave(record);
|
|
2855
3055
|
return record;
|
|
2856
3056
|
}
|
|
2857
|
-
|
|
3057
|
+
/**
|
|
3058
|
+
* Replace the payload of an existing save, keeping its ID and `createdAt`.
|
|
3059
|
+
* Pass the current `playthroughId` when the new payload comes from the
|
|
3060
|
+
* running game: the save then holds that playthrough's state, so it is
|
|
3061
|
+
* grouped and deleted with it rather than with the playthrough that first
|
|
3062
|
+
* created the save.
|
|
3063
|
+
*
|
|
3064
|
+
* Metadata describing the content follows the new payload: `passage`,
|
|
3065
|
+
* `updatedAt` and the title. A generated title (SaveTitle passage, title
|
|
3066
|
+
* generator or the default "passage - time") is generated again, exactly as
|
|
3067
|
+
* for a fresh save, so it never names a passage or state the save no longer
|
|
3068
|
+
* holds. A title the player gave the save (renameSave) is kept: it names the
|
|
3069
|
+
* save itself rather than describing its content. `custom` is merged, as
|
|
3070
|
+
* before, so slot keys and metadata not passed again are kept.
|
|
3071
|
+
*/
|
|
3072
|
+
async function overwriteSave(saveId, payload, custom, playthroughId) {
|
|
2858
3073
|
const backend = await getBackend();
|
|
2859
3074
|
const existing = await backend.getSave(saveId);
|
|
2860
3075
|
if (!existing) return void 0;
|
|
@@ -2867,7 +3082,9 @@ async function overwriteSave(saveId, payload, custom) {
|
|
|
2867
3082
|
const updated = {
|
|
2868
3083
|
meta: {
|
|
2869
3084
|
...existing.meta,
|
|
3085
|
+
...playthroughId ? { playthroughId } : {},
|
|
2870
3086
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3087
|
+
title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
|
|
2871
3088
|
passage: payload.passage,
|
|
2872
3089
|
...custom != null ? { custom: {
|
|
2873
3090
|
...existing.meta.custom,
|
|
@@ -2929,6 +3146,7 @@ async function renameSave(saveId, newTitle) {
|
|
|
2929
3146
|
meta: {
|
|
2930
3147
|
...record.meta,
|
|
2931
3148
|
title: newTitle,
|
|
3149
|
+
userTitle: true,
|
|
2932
3150
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2933
3151
|
}
|
|
2934
3152
|
};
|
|
@@ -3045,7 +3263,7 @@ function quickSave(ifid, playthroughId, payload, slot, custom) {
|
|
|
3045
3263
|
const backend = await getBackend();
|
|
3046
3264
|
const existingId = await backend.getMeta(metaKey);
|
|
3047
3265
|
if (existingId) {
|
|
3048
|
-
const updated = await overwriteSave(existingId, payload, custom);
|
|
3266
|
+
const updated = await overwriteSave(existingId, payload, custom, playthroughId);
|
|
3049
3267
|
if (updated) return updated;
|
|
3050
3268
|
}
|
|
3051
3269
|
const record = await createSave(ifid, playthroughId, payload, {
|
|
@@ -3322,28 +3540,279 @@ function initializeStory(session) {
|
|
|
3322
3540
|
if (!session) recordStoryInitState();
|
|
3323
3541
|
}
|
|
3324
3542
|
//#endregion
|
|
3543
|
+
//#region src/utils/object-path.ts
|
|
3544
|
+
/** Traverse dot-path segments on an object and return the nested value. */
|
|
3545
|
+
function getByPath(obj, segments) {
|
|
3546
|
+
let current = obj;
|
|
3547
|
+
for (const seg of segments) {
|
|
3548
|
+
if (current == null || typeof current !== "object") return void 0;
|
|
3549
|
+
current = current[seg];
|
|
3550
|
+
}
|
|
3551
|
+
return current;
|
|
3552
|
+
}
|
|
3553
|
+
/**
|
|
3554
|
+
* Shallow copy that keeps the prototype, so a registered class instance
|
|
3555
|
+
* stays an instance of its class (with the same own keys deepClone copies).
|
|
3556
|
+
*/
|
|
3557
|
+
function shallowCopy(value) {
|
|
3558
|
+
let copy;
|
|
3559
|
+
if (Array.isArray(value)) copy = [];
|
|
3560
|
+
else if (value instanceof Map) copy = new Map(value);
|
|
3561
|
+
else if (value instanceof Set) copy = new Set(value);
|
|
3562
|
+
else if (value instanceof Date) copy = new Date(value.getTime());
|
|
3563
|
+
else copy = Object.create(Object.getPrototypeOf(value));
|
|
3564
|
+
return Object.assign(copy, value);
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* Set a value at dot-path `segments` below `root`.
|
|
3568
|
+
*
|
|
3569
|
+
* When `root` is an Immer draft, objects Immer does not draft (registered
|
|
3570
|
+
* class instances and everything below them) are copied on the way down and
|
|
3571
|
+
* the copy is assigned to its parent, so the write produces new references
|
|
3572
|
+
* up to the root. Writing into such an object directly would mutate the
|
|
3573
|
+
* instance shared with the previous state and with history, and subscribers
|
|
3574
|
+
* would see no change. Outside a draft (a private working copy) the path is
|
|
3575
|
+
* written in place.
|
|
3576
|
+
*/
|
|
3577
|
+
function setByPath(root, segments, value, options = {}) {
|
|
3578
|
+
const parent = walkToParent(root, segments, options.createMissing ?? false);
|
|
3579
|
+
parent[segments[segments.length - 1]] = value;
|
|
3580
|
+
}
|
|
3581
|
+
/**
|
|
3582
|
+
* Delete the property at dot-path `segments` below `root`, copying
|
|
3583
|
+
* undrafted objects on the way down like setByPath(). Does nothing when an
|
|
3584
|
+
* intermediate is missing or not an object, or the property is absent.
|
|
3585
|
+
*/
|
|
3586
|
+
function deleteByPath(root, segments) {
|
|
3587
|
+
const last = segments[segments.length - 1];
|
|
3588
|
+
const holder = getByPath(root, segments.slice(0, -1));
|
|
3589
|
+
if (holder == null || typeof holder !== "object" || !(last in holder)) return;
|
|
3590
|
+
const parent = walkToParent(root, segments, false);
|
|
3591
|
+
delete parent[last];
|
|
3592
|
+
}
|
|
3593
|
+
/**
|
|
3594
|
+
* Walk to the object holding the last of `segments`, copying undrafted
|
|
3595
|
+
* objects when `root` is an Immer draft (see setByPath). A missing or
|
|
3596
|
+
* non-object intermediate is replaced by a plain object when
|
|
3597
|
+
* `createMissing` is set, and throws a TypeError otherwise.
|
|
3598
|
+
*/
|
|
3599
|
+
function walkToParent(root, segments, createMissing) {
|
|
3600
|
+
const copyOnWrite = isDraft(root);
|
|
3601
|
+
let current = root;
|
|
3602
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
3603
|
+
const seg = segments[i];
|
|
3604
|
+
let next = current[seg];
|
|
3605
|
+
if (next == null || typeof next !== "object") {
|
|
3606
|
+
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(".")}")`);
|
|
3607
|
+
next = {};
|
|
3608
|
+
current[seg] = next;
|
|
3609
|
+
} else if (copyOnWrite && !isDraft(next)) {
|
|
3610
|
+
next = shallowCopy(next);
|
|
3611
|
+
current[seg] = next;
|
|
3612
|
+
}
|
|
3613
|
+
current = next;
|
|
3614
|
+
}
|
|
3615
|
+
return current;
|
|
3616
|
+
}
|
|
3617
|
+
//#endregion
|
|
3325
3618
|
//#region src/execute-mutation.ts
|
|
3619
|
+
var NAMESPACES = [
|
|
3620
|
+
"variables",
|
|
3621
|
+
"temporary",
|
|
3622
|
+
"transient"
|
|
3623
|
+
];
|
|
3624
|
+
/** The mutations now executing, innermost last. */
|
|
3625
|
+
var activeScopes = [];
|
|
3326
3626
|
/**
|
|
3327
|
-
*
|
|
3328
|
-
*
|
|
3329
|
-
*
|
|
3627
|
+
* The working copies of the store namespaces that the innermost executing
|
|
3628
|
+
* mutation reads and writes, or undefined outside of one. Story.get reads
|
|
3629
|
+
* them so the code sees its own pending writes.
|
|
3330
3630
|
*/
|
|
3331
|
-
function
|
|
3332
|
-
|
|
3333
|
-
for (const key of Object.keys(before)) if (!(key in clone)) delete draft[key];
|
|
3631
|
+
function getActiveMutationScope() {
|
|
3632
|
+
return activeScopes[activeScopes.length - 1]?.work;
|
|
3334
3633
|
}
|
|
3335
|
-
|
|
3634
|
+
var cloneValue = (value) => deepClone(value, { keepUnregistered: true });
|
|
3635
|
+
/**
|
|
3636
|
+
* A frozen copy of a value read from a working copy, matching what reads
|
|
3637
|
+
* from the (frozen) store return: writing to it fails instead of changing
|
|
3638
|
+
* the code's pending state behind its back.
|
|
3639
|
+
*/
|
|
3640
|
+
function frozenCopy(value) {
|
|
3641
|
+
return freeze(cloneValue(value), true);
|
|
3642
|
+
}
|
|
3643
|
+
/**
|
|
3644
|
+
* Objects merged property by property: plain objects and (registered)
|
|
3645
|
+
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
3646
|
+
* replaced as a whole, since their elements have no stable identity to
|
|
3647
|
+
* merge by (a shift moves every index).
|
|
3648
|
+
*/
|
|
3649
|
+
function isMergeable(value) {
|
|
3650
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Map) && !(value instanceof Set) && !(value instanceof Date) && !(value instanceof RegExp);
|
|
3651
|
+
}
|
|
3652
|
+
var hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
3653
|
+
/** Collect the property paths where `work` differs from `base`. */
|
|
3654
|
+
function diff(base, work, path, changes, ancestors) {
|
|
3655
|
+
if (ancestors.has(work)) return;
|
|
3656
|
+
ancestors.add(work);
|
|
3657
|
+
for (const key of Object.keys(work)) {
|
|
3658
|
+
const b = base[key];
|
|
3659
|
+
const w = work[key];
|
|
3660
|
+
if (!hasOwn(base, key)) changes.push({
|
|
3661
|
+
path: [...path, key],
|
|
3662
|
+
deleted: false,
|
|
3663
|
+
value: w
|
|
3664
|
+
});
|
|
3665
|
+
else if (isMergeable(b) && isMergeable(w) && Object.getPrototypeOf(b) === Object.getPrototypeOf(w)) diff(b, w, [...path, key], changes, ancestors);
|
|
3666
|
+
else if (!deepEqual(b, w)) changes.push({
|
|
3667
|
+
path: [...path, key],
|
|
3668
|
+
deleted: false,
|
|
3669
|
+
value: w
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
for (const key of Object.keys(base)) if (!hasOwn(work, key)) changes.push({
|
|
3673
|
+
path: [...path, key],
|
|
3674
|
+
deleted: true
|
|
3675
|
+
});
|
|
3676
|
+
ancestors.delete(work);
|
|
3677
|
+
}
|
|
3678
|
+
/** Whether `ns` already holds what `change` would write. */
|
|
3679
|
+
function isApplied(ns, change) {
|
|
3680
|
+
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
3681
|
+
if (parent === null || typeof parent !== "object") return change.deleted;
|
|
3682
|
+
const key = change.path[change.path.length - 1];
|
|
3683
|
+
if (!hasOwn(parent, key)) return change.deleted;
|
|
3684
|
+
return !change.deleted && deepEqual(parent[key], change.value);
|
|
3685
|
+
}
|
|
3686
|
+
function applyChange(ns, change) {
|
|
3687
|
+
if (change.deleted) deleteByPath(ns, change.path);
|
|
3688
|
+
else setByPath(ns, change.path, change.value);
|
|
3689
|
+
}
|
|
3690
|
+
/**
|
|
3691
|
+
* Apply a write that reached the store while mutations execute to their
|
|
3692
|
+
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
3693
|
+
* its commit neither drops it nor reverts it. Called from inside the store
|
|
3694
|
+
* update that makes the write, before subscribers (watchers) run, so writes
|
|
3695
|
+
* those make land in the copies after this one. When a copy cannot take the
|
|
3696
|
+
* path (the code replaced or deleted an intermediate object), its root is
|
|
3697
|
+
* replaced with the store's root as written.
|
|
3698
|
+
*/
|
|
3699
|
+
function mirror(draft, ns, change, scopes = activeScopes) {
|
|
3700
|
+
const root = change.path[0];
|
|
3701
|
+
for (const scope of scopes) for (const copy of [scope.work[ns], scope.base[ns]]) try {
|
|
3702
|
+
applyChange(copy, change.deleted ? change : {
|
|
3703
|
+
...change,
|
|
3704
|
+
value: cloneValue(change.value)
|
|
3705
|
+
});
|
|
3706
|
+
} catch {
|
|
3707
|
+
const stored = draft[ns][root];
|
|
3708
|
+
if (hasOwn(draft[ns], root)) copy[root] = cloneValue(isDraft(stored) ? current(stored) : stored);
|
|
3709
|
+
else delete copy[root];
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
/**
|
|
3713
|
+
* Mirror a Story.set write into every executing mutation (see mirror()).
|
|
3714
|
+
* Call it inside the store update that writes `value` at `path` of `ns`.
|
|
3715
|
+
*/
|
|
3716
|
+
function mirrorWriteToActiveScopes(draft, ns, path, value) {
|
|
3717
|
+
if (activeScopes.length === 0) return;
|
|
3718
|
+
mirror(draft, ns, {
|
|
3719
|
+
path,
|
|
3720
|
+
deleted: false,
|
|
3721
|
+
value
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
var cloneNamespaces = (from) => ({
|
|
3725
|
+
variables: deepClone(from.variables),
|
|
3726
|
+
temporary: deepClone(from.temporary),
|
|
3727
|
+
transient: deepClone(from.transient)
|
|
3728
|
+
});
|
|
3729
|
+
/**
|
|
3730
|
+
* Commit the property paths `scope`'s code changed (work vs base) on top of
|
|
3731
|
+
* the current store state, so writes made elsewhere during execution to
|
|
3732
|
+
* other paths of the same objects survive, and hand them to the `enclosing`
|
|
3733
|
+
* mutations. With `keepRunning`, the code goes on running after the commit:
|
|
3734
|
+
* its working copy must stay its own (the store freezes what it is given),
|
|
3735
|
+
* and its base is moved up to the working copy so nothing commits twice.
|
|
3736
|
+
*/
|
|
3737
|
+
function commitScope(scope, enclosing, keepRunning) {
|
|
3738
|
+
const changes = NAMESPACES.map((ns) => {
|
|
3739
|
+
const list = [];
|
|
3740
|
+
diff(scope.base[ns], scope.work[ns], [], list, /* @__PURE__ */ new Set());
|
|
3741
|
+
return [ns, list];
|
|
3742
|
+
});
|
|
3743
|
+
if (changes.every(([, list]) => list.length === 0)) return;
|
|
3744
|
+
if (keepRunning) scope.base = cloneNamespaces(scope.work);
|
|
3745
|
+
const own = (value) => keepRunning ? cloneValue(value) : value;
|
|
3746
|
+
const current = useStoryStore.getState();
|
|
3747
|
+
current.updateVariables((draft) => {
|
|
3748
|
+
for (const [ns, list] of changes) {
|
|
3749
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
3750
|
+
for (const change of list) {
|
|
3751
|
+
const root = change.path[0];
|
|
3752
|
+
if (replaced.has(root) || isApplied(current[ns], change)) continue;
|
|
3753
|
+
try {
|
|
3754
|
+
applyChange(draft[ns], change.deleted ? change : {
|
|
3755
|
+
...change,
|
|
3756
|
+
value: own(change.value)
|
|
3757
|
+
});
|
|
3758
|
+
} catch {
|
|
3759
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
3760
|
+
replaced.add(root);
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
3764
|
+
}
|
|
3765
|
+
});
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* Bring a suspended mutation's copies up to the store after an action
|
|
3769
|
+
* replaced or changed state under it (navigation clears temporaries, back
|
|
3770
|
+
* and restart replace variables). Roots that still match keep the objects
|
|
3771
|
+
* the code may hold.
|
|
3772
|
+
*/
|
|
3773
|
+
function resync(scope) {
|
|
3336
3774
|
const state = useStoryStore.getState();
|
|
3337
|
-
const
|
|
3338
|
-
|
|
3339
|
-
|
|
3775
|
+
for (const ns of NAMESPACES) {
|
|
3776
|
+
const work = scope.work[ns];
|
|
3777
|
+
const stored = state[ns];
|
|
3778
|
+
for (const key of Object.keys(work)) if (!hasOwn(stored, key)) delete work[key];
|
|
3779
|
+
for (const key of Object.keys(stored)) if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) work[key] = deepClone(stored[key]);
|
|
3780
|
+
}
|
|
3781
|
+
scope.base = cloneNamespaces(state);
|
|
3782
|
+
}
|
|
3783
|
+
/**
|
|
3784
|
+
* Run an action that snapshots or replaces story state (saving, navigating,
|
|
3785
|
+
* restarting) in program order with mutation code running now: the code's
|
|
3786
|
+
* writes so far are committed first, the action runs against the store with
|
|
3787
|
+
* no mutation active, and the code then continues from the resulting state.
|
|
3788
|
+
* Outside mutation code the action just runs.
|
|
3789
|
+
*/
|
|
3790
|
+
function runWithCommittedMutations(action) {
|
|
3791
|
+
if (activeScopes.length === 0) return action();
|
|
3792
|
+
const scopes = [...activeScopes];
|
|
3793
|
+
for (let i = scopes.length - 1; i >= 0; i--) commitScope(scopes[i], scopes.slice(0, i), true);
|
|
3794
|
+
const suspended = activeScopes.splice(0);
|
|
3795
|
+
try {
|
|
3796
|
+
return action();
|
|
3797
|
+
} finally {
|
|
3798
|
+
activeScopes.push(...suspended);
|
|
3799
|
+
for (const scope of suspended) resync(scope);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
function executeMutation(code, mergedLocals, scopeUpdate) {
|
|
3803
|
+
const start = getActiveMutationScope() ?? useStoryStore.getState();
|
|
3804
|
+
const scope = {
|
|
3805
|
+
work: cloneNamespaces(start),
|
|
3806
|
+
base: cloneNamespaces(start)
|
|
3807
|
+
};
|
|
3340
3808
|
const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
}
|
|
3809
|
+
activeScopes.push(scope);
|
|
3810
|
+
try {
|
|
3811
|
+
execute(code, scope.work.variables, scope.work.temporary, localsClone, scope.work.transient);
|
|
3812
|
+
} finally {
|
|
3813
|
+
activeScopes.pop();
|
|
3814
|
+
}
|
|
3815
|
+
commitScope(scope, activeScopes, false);
|
|
3347
3816
|
for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
|
|
3348
3817
|
for (const key of Object.keys(mergedLocals)) if (!(key in localsClone)) scopeUpdate(key, void 0);
|
|
3349
3818
|
}
|
|
@@ -3419,7 +3888,10 @@ function fireTrigger(trigger) {
|
|
|
3419
3888
|
dialogQueue.push({ passageName: options.dialog });
|
|
3420
3889
|
dialogNotify?.();
|
|
3421
3890
|
}
|
|
3422
|
-
if (options.goto)
|
|
3891
|
+
if (options.goto) {
|
|
3892
|
+
const passage = options.goto;
|
|
3893
|
+
runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
|
|
3894
|
+
}
|
|
3423
3895
|
}
|
|
3424
3896
|
var MAX_RECHECK_DEPTH = 10;
|
|
3425
3897
|
function runCheckLoop() {
|
|
@@ -3666,6 +4138,27 @@ function recordStoryInitState() {
|
|
|
3666
4138
|
state.history[0].prng = prng;
|
|
3667
4139
|
});
|
|
3668
4140
|
}
|
|
4141
|
+
/**
|
|
4142
|
+
* Settles once the latest playthrough setup (init's lookup or creation, or a
|
|
4143
|
+
* restart's creation) is stored, with that setup's playthrough ID ('' if
|
|
4144
|
+
* init could not establish one). Each setup chains on the previous one, so
|
|
4145
|
+
* playthroughs are created and numbered in the order the game started them.
|
|
4146
|
+
*/
|
|
4147
|
+
var playthroughSetup = Promise.resolve("");
|
|
4148
|
+
/** Bumped by every init()/restart(); a stale init must not adopt its ID. */
|
|
4149
|
+
var playthroughGeneration = 0;
|
|
4150
|
+
/**
|
|
4151
|
+
* The playthrough a save issued now belongs to, once its record is stored.
|
|
4152
|
+
* Read synchronously at the call: restart() switches the store's
|
|
4153
|
+
* `playthroughId` at once, so a save issued after it (even before the new
|
|
4154
|
+
* playthrough is stored) belongs to the new playthrough, and a later restart
|
|
4155
|
+
* doesn't move it. Before init() has looked up the stored playthrough the
|
|
4156
|
+
* store's ID is '', and the save takes the one init establishes.
|
|
4157
|
+
*/
|
|
4158
|
+
function resolvePlaythroughId() {
|
|
4159
|
+
const current = useStoryStore.getState().playthroughId;
|
|
4160
|
+
return playthroughSetup.then((established) => current || established);
|
|
4161
|
+
}
|
|
3669
4162
|
var runtimeUnsubs = [];
|
|
3670
4163
|
var inRuntimePhase = false;
|
|
3671
4164
|
/**
|
|
@@ -3703,6 +4196,22 @@ function loadedEntryMoment(payload) {
|
|
|
3703
4196
|
if (typeof moment.variables !== "object" || moment.variables === null) return;
|
|
3704
4197
|
return moment;
|
|
3705
4198
|
}
|
|
4199
|
+
/**
|
|
4200
|
+
* Copy the variables that changed between `before` and `after` (the live
|
|
4201
|
+
* variables around the `beforesave` hooks) into the payload's snapshot of the
|
|
4202
|
+
* saved moment. A load restores that snapshot, the state on entering the
|
|
4203
|
+
* passage, and runs the passage again, so data a hook adds to a save would
|
|
4204
|
+
* otherwise be lost on load (#227). Only the payload's copy changes: the live
|
|
4205
|
+
* history keeps the recorded snapshot (#159). Store updates are immutable, so
|
|
4206
|
+
* a value a hook did not touch keeps its identity.
|
|
4207
|
+
*/
|
|
4208
|
+
function keepHookWrites(payload, before, after) {
|
|
4209
|
+
const moment = payload.history[payload.historyIndex];
|
|
4210
|
+
if (!moment) return;
|
|
4211
|
+
const has = (vars, key) => Object.prototype.hasOwnProperty.call(vars, key);
|
|
4212
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) if (!has(after, key)) delete moment.variables[key];
|
|
4213
|
+
else if (!has(before, key) || after[key] !== before[key]) moment.variables[key] = deepClone(after[key]);
|
|
4214
|
+
}
|
|
3706
4215
|
/** Restore or reset PRNG from a history moment's snapshot. */
|
|
3707
4216
|
function restorePRNGFromMoment(moment) {
|
|
3708
4217
|
if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
|
|
@@ -3764,6 +4273,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3764
4273
|
resetModuleState(deepClone(initialVars));
|
|
3765
4274
|
set((state) => {
|
|
3766
4275
|
state.storyData = storyData;
|
|
4276
|
+
state.playthroughId = "";
|
|
3767
4277
|
state.currentPassage = startPassage.name;
|
|
3768
4278
|
state.navigationId++;
|
|
3769
4279
|
state.variables = initialVars;
|
|
@@ -3781,22 +4291,21 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3781
4291
|
});
|
|
3782
4292
|
lastNavigationVars = get().variables;
|
|
3783
4293
|
const ifid = storyData.ifid;
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
4294
|
+
const generation = ++playthroughGeneration;
|
|
4295
|
+
playthroughSetup = initSaveSystem().then(async () => {
|
|
4296
|
+
const id = await getCurrentPlaythroughId(ifid) ?? await startNewPlaythrough(ifid);
|
|
4297
|
+
if (generation === playthroughGeneration) set((state) => {
|
|
4298
|
+
state.playthroughId = id;
|
|
3788
4299
|
});
|
|
3789
|
-
else {
|
|
3790
|
-
const newId = await startNewPlaythrough(ifid);
|
|
3791
|
-
set((state) => {
|
|
3792
|
-
state.playthroughId = newId;
|
|
3793
|
-
});
|
|
3794
|
-
}
|
|
3795
4300
|
const saves = await populateKnownSaves(ifid);
|
|
3796
4301
|
if (Object.keys(saves).length > 0) set((state) => {
|
|
3797
4302
|
state.knownSaves = saves;
|
|
3798
4303
|
});
|
|
3799
|
-
|
|
4304
|
+
return id;
|
|
4305
|
+
}).catch((err) => {
|
|
4306
|
+
console.error("spindle: failed to init save system", err);
|
|
4307
|
+
return "";
|
|
4308
|
+
});
|
|
3800
4309
|
},
|
|
3801
4310
|
navigate: (passageName) => {
|
|
3802
4311
|
if (navigationTriggerPhase) {
|
|
@@ -3949,6 +4458,16 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3949
4458
|
state.renderDeferred = false;
|
|
3950
4459
|
});
|
|
3951
4460
|
emit("beforerestart");
|
|
4461
|
+
const newPlaythroughId = crypto.randomUUID();
|
|
4462
|
+
++playthroughGeneration;
|
|
4463
|
+
set((state) => {
|
|
4464
|
+
state.playthroughId = newPlaythroughId;
|
|
4465
|
+
});
|
|
4466
|
+
const ifid = storyData.ifid;
|
|
4467
|
+
playthroughSetup = playthroughSetup.then(() => startNewPlaythrough(ifid, newPlaythroughId)).then(() => newPlaythroughId, (err) => {
|
|
4468
|
+
console.error("spindle: failed to start new playthrough", err);
|
|
4469
|
+
return newPlaythroughId;
|
|
4470
|
+
});
|
|
3952
4471
|
const keepDeferred = get().renderDeferred;
|
|
3953
4472
|
cleanupRuntimeHandlers();
|
|
3954
4473
|
resetPRNG();
|
|
@@ -3976,19 +4495,16 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3976
4495
|
clearSession(storyData.ifid);
|
|
3977
4496
|
emit("storyinit");
|
|
3978
4497
|
recordStoryInitState();
|
|
3979
|
-
startNewPlaythrough(storyData.ifid).then((newId) => {
|
|
3980
|
-
set((state) => {
|
|
3981
|
-
state.playthroughId = newId;
|
|
3982
|
-
});
|
|
3983
|
-
}).catch((err) => console.error("spindle: failed to start new playthrough", err));
|
|
3984
4498
|
},
|
|
3985
4499
|
save: (slot, custom) => {
|
|
3986
|
-
const { storyData
|
|
4500
|
+
const { storyData } = get();
|
|
3987
4501
|
if (!storyData) return Promise.resolve();
|
|
3988
|
-
|
|
4502
|
+
const playthrough = resolvePlaythroughId();
|
|
4503
|
+
return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
|
|
3989
4504
|
set((state) => {
|
|
3990
4505
|
state.saveError = null;
|
|
3991
4506
|
});
|
|
4507
|
+
const playthroughId = await playthrough;
|
|
3992
4508
|
await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
|
|
3993
4509
|
set((state) => {
|
|
3994
4510
|
state.knownSaves = {
|
|
@@ -4127,6 +4643,14 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
4127
4643
|
prng: snapshotPRNG()
|
|
4128
4644
|
};
|
|
4129
4645
|
},
|
|
4646
|
+
beginSave: () => {
|
|
4647
|
+
const before = get().variables;
|
|
4648
|
+
return () => {
|
|
4649
|
+
const payload = get().getSavePayload();
|
|
4650
|
+
keepHookWrites(payload, before, get().variables);
|
|
4651
|
+
return payload;
|
|
4652
|
+
};
|
|
4653
|
+
},
|
|
4130
4654
|
loadFromPayload: (payload, slot) => {
|
|
4131
4655
|
if (payload.history.length === 0) {
|
|
4132
4656
|
console.warn("loadFromPayload: rejecting payload with empty history");
|
|
@@ -4687,14 +5211,16 @@ function currentSourceLocation() {
|
|
|
4687
5211
|
//#endregion
|
|
4688
5212
|
//#region src/components/macros/option-utils.ts
|
|
4689
5213
|
function parseVarArgs(rawArgs) {
|
|
4690
|
-
const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(
|
|
5214
|
+
const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/);
|
|
4691
5215
|
if (!match) return {
|
|
4692
5216
|
varName: rawArgs.trim(),
|
|
4693
5217
|
placeholder: ""
|
|
4694
5218
|
};
|
|
5219
|
+
const varName = match[1].replace(/["']/g, "");
|
|
5220
|
+
const quoted = match[2];
|
|
4695
5221
|
return {
|
|
4696
|
-
varName
|
|
4697
|
-
placeholder:
|
|
5222
|
+
varName,
|
|
5223
|
+
placeholder: quoted ? readWholeQuoted(quoted) ?? quoted.slice(1, -1) : ""
|
|
4698
5224
|
};
|
|
4699
5225
|
}
|
|
4700
5226
|
/**
|
|
@@ -4703,7 +5229,8 @@ function parseVarArgs(rawArgs) {
|
|
|
4703
5229
|
function extractOptions(children) {
|
|
4704
5230
|
const options = [];
|
|
4705
5231
|
for (const node of children) if (node.type === "macro" && node.name === "option") {
|
|
4706
|
-
const
|
|
5232
|
+
const raw = node.rawArgs.trim();
|
|
5233
|
+
const stripped = readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/, "$2");
|
|
4707
5234
|
options.push(stripped);
|
|
4708
5235
|
}
|
|
4709
5236
|
return options;
|
|
@@ -4723,25 +5250,6 @@ var sharedHooks = {
|
|
|
4723
5250
|
useMemo,
|
|
4724
5251
|
useContext
|
|
4725
5252
|
};
|
|
4726
|
-
/** Traverse a dot-path on an object, returning the nested value. */
|
|
4727
|
-
function getByPath$1(obj, segments) {
|
|
4728
|
-
let current = obj;
|
|
4729
|
-
for (const seg of segments) {
|
|
4730
|
-
if (current == null || typeof current !== "object") return void 0;
|
|
4731
|
-
current = current[seg];
|
|
4732
|
-
}
|
|
4733
|
-
return current;
|
|
4734
|
-
}
|
|
4735
|
-
/** Set a nested value by dot-path segments on an (Immer draft) object. */
|
|
4736
|
-
function setByPath$1(obj, segments, value) {
|
|
4737
|
-
let target = obj;
|
|
4738
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
4739
|
-
const seg = segments[i];
|
|
4740
|
-
if (target[seg] == null || typeof target[seg] !== "object") target[seg] = {};
|
|
4741
|
-
target = target[seg];
|
|
4742
|
-
}
|
|
4743
|
-
target[segments[segments.length - 1]] = value;
|
|
4744
|
-
}
|
|
4745
5253
|
function defineMacro(config, source = "builtin") {
|
|
4746
5254
|
function Wrapper(props) {
|
|
4747
5255
|
let className = props.className;
|
|
@@ -4754,9 +5262,11 @@ function defineMacro(config, source = "builtin") {
|
|
|
4754
5262
|
}
|
|
4755
5263
|
const { update, getValues } = useContext(LocalsUpdateContext);
|
|
4756
5264
|
const nobr = useContext(NobrContext);
|
|
5265
|
+
const inline = useContext(InlineContext);
|
|
4757
5266
|
const localsValues = useContext(LocalsValuesContext);
|
|
4758
5267
|
const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
|
|
4759
5268
|
nobr,
|
|
5269
|
+
inline,
|
|
4760
5270
|
locals: localsValues,
|
|
4761
5271
|
...options
|
|
4762
5272
|
});
|
|
@@ -4796,11 +5306,11 @@ function defineMacro(config, source = "builtin") {
|
|
|
4796
5306
|
const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
|
|
4797
5307
|
const segments = varExpr.split(".");
|
|
4798
5308
|
ctx.varName = varExpr;
|
|
4799
|
-
ctx.value = useStoryStore((s) => getByPath
|
|
4800
|
-
ctx.getValue = () => getByPath
|
|
5309
|
+
ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
|
|
5310
|
+
ctx.getValue = () => getByPath(useStoryStore.getState().variables, segments);
|
|
4801
5311
|
ctx.setValue = (value) => {
|
|
4802
5312
|
useStoryStore.setState((state) => {
|
|
4803
|
-
setByPath
|
|
5313
|
+
setByPath(state.variables, segments, value, { createMissing: true });
|
|
4804
5314
|
});
|
|
4805
5315
|
};
|
|
4806
5316
|
}
|
|
@@ -4876,27 +5386,6 @@ function ensureVariableChangedSubscription() {
|
|
|
4876
5386
|
if (hasChanges) emit("variableChanged", changed);
|
|
4877
5387
|
});
|
|
4878
5388
|
}
|
|
4879
|
-
/** Traverse a dot-delimited path on an object and return the value. */
|
|
4880
|
-
function getByPath(obj, path) {
|
|
4881
|
-
const segments = path.split(".");
|
|
4882
|
-
let current = obj[segments[0]];
|
|
4883
|
-
for (let i = 1; i < segments.length; i++) {
|
|
4884
|
-
if (current == null) return void 0;
|
|
4885
|
-
current = current[segments[i]];
|
|
4886
|
-
}
|
|
4887
|
-
return current;
|
|
4888
|
-
}
|
|
4889
|
-
/** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
|
|
4890
|
-
function setByPath(obj, path, value) {
|
|
4891
|
-
const segments = path.split(".");
|
|
4892
|
-
let current = obj;
|
|
4893
|
-
for (let i = 0; i < segments.length - 1; i++) {
|
|
4894
|
-
const next = current[segments[i]];
|
|
4895
|
-
if (next == null || typeof next !== "object") throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
|
|
4896
|
-
current = next;
|
|
4897
|
-
}
|
|
4898
|
-
current[segments[segments.length - 1]] = value;
|
|
4899
|
-
}
|
|
4900
5389
|
var declaredVariables = null;
|
|
4901
5390
|
var declaredTransients = null;
|
|
4902
5391
|
var warnedUndeclared = /* @__PURE__ */ new Set();
|
|
@@ -4939,41 +5428,47 @@ function warnIfUndeclared(isTransient, key) {
|
|
|
4939
5428
|
function setOne(draft, name, value) {
|
|
4940
5429
|
const { isTransient, key } = parseName(name);
|
|
4941
5430
|
const namespace = isTransient ? draft.transient : draft.variables;
|
|
4942
|
-
if (key.includes(".")) setByPath(namespace, key, value);
|
|
5431
|
+
if (key.includes(".")) setByPath(namespace, key.split("."), value);
|
|
4943
5432
|
else namespace[key] = value;
|
|
4944
5433
|
}
|
|
4945
5434
|
function createStoryAPI() {
|
|
4946
5435
|
return {
|
|
4947
5436
|
get(name) {
|
|
4948
5437
|
const { isTransient, key } = parseName(name);
|
|
4949
|
-
const
|
|
4950
|
-
|
|
5438
|
+
const scope = getActiveMutationScope();
|
|
5439
|
+
const source = scope ?? useStoryStore.getState();
|
|
5440
|
+
const namespace = isTransient ? source.transient : source.variables;
|
|
5441
|
+
const value = key.includes(".") ? getByPath(namespace, key.split(".")) : namespace[key];
|
|
5442
|
+
return scope ? frozenCopy(value) : value;
|
|
4951
5443
|
},
|
|
4952
5444
|
set(nameOrVars, value) {
|
|
4953
|
-
const
|
|
4954
|
-
for (const name of
|
|
5445
|
+
const entries = typeof nameOrVars === "string" ? [[nameOrVars, value]] : Object.entries(nameOrVars);
|
|
5446
|
+
for (const [name] of entries) {
|
|
4955
5447
|
const { isTransient, key } = parseName(name);
|
|
4956
5448
|
warnIfUndeclared(isTransient, key);
|
|
4957
5449
|
}
|
|
4958
5450
|
useStoryStore.getState().updateVariables((draft) => {
|
|
4959
|
-
|
|
4960
|
-
|
|
5451
|
+
for (const [k, v] of entries) setOne(draft, k, v);
|
|
5452
|
+
for (const [k, v] of entries) {
|
|
5453
|
+
const { isTransient, key } = parseName(k);
|
|
5454
|
+
mirrorWriteToActiveScopes(draft, isTransient ? "transient" : "variables", key.split("."), v);
|
|
5455
|
+
}
|
|
4961
5456
|
});
|
|
4962
5457
|
},
|
|
4963
5458
|
goto(passageName) {
|
|
4964
|
-
useStoryStore.getState().navigate(passageName);
|
|
5459
|
+
runWithCommittedMutations(() => useStoryStore.getState().navigate(passageName));
|
|
4965
5460
|
},
|
|
4966
5461
|
back() {
|
|
4967
|
-
useStoryStore.getState().goBack();
|
|
5462
|
+
runWithCommittedMutations(() => useStoryStore.getState().goBack());
|
|
4968
5463
|
},
|
|
4969
5464
|
forward() {
|
|
4970
|
-
useStoryStore.getState().goForward();
|
|
5465
|
+
runWithCommittedMutations(() => useStoryStore.getState().goForward());
|
|
4971
5466
|
},
|
|
4972
5467
|
restart() {
|
|
4973
|
-
useStoryStore.getState().restart();
|
|
5468
|
+
runWithCommittedMutations(() => useStoryStore.getState().restart());
|
|
4974
5469
|
},
|
|
4975
5470
|
save(slot, custom) {
|
|
4976
|
-
return useStoryStore.getState().save(slot, custom);
|
|
5471
|
+
return runWithCommittedMutations(() => useStoryStore.getState().save(slot, custom));
|
|
4977
5472
|
},
|
|
4978
5473
|
load(slot) {
|
|
4979
5474
|
return useStoryStore.getState().load(slot);
|
|
@@ -5670,44 +6165,27 @@ defineMacro({
|
|
|
5670
6165
|
});
|
|
5671
6166
|
//#endregion
|
|
5672
6167
|
//#region src/components/macros/Meter.tsx
|
|
5673
|
-
|
|
5674
|
-
|
|
6168
|
+
/**
|
|
6169
|
+
* Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
|
|
6170
|
+
* whitespace outside string and template literals and bracket pairs; a
|
|
6171
|
+
* trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
|
|
6172
|
+
* `\'` and `\\` escapes.
|
|
6173
|
+
*/
|
|
6174
|
+
function parseMeterArgs(rawArgs) {
|
|
6175
|
+
const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
|
|
5675
6176
|
let labelMode = "";
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
}
|
|
5682
|
-
const exprs = splitExpressions(rest);
|
|
5683
|
-
if (exprs.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
|
|
6177
|
+
const label = tokens.length >= 2 ? readWholeQuoted(tokens[tokens.length - 1]) : null;
|
|
6178
|
+
if (label !== null) {
|
|
6179
|
+
labelMode = label;
|
|
6180
|
+
tokens.pop();
|
|
6181
|
+
}
|
|
6182
|
+
if (tokens.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
|
|
5684
6183
|
return {
|
|
5685
|
-
currentExpr:
|
|
5686
|
-
maxExpr:
|
|
6184
|
+
currentExpr: tokens[0],
|
|
6185
|
+
maxExpr: tokens.slice(1).join(" "),
|
|
5687
6186
|
labelMode
|
|
5688
6187
|
};
|
|
5689
6188
|
}
|
|
5690
|
-
function splitExpressions(input) {
|
|
5691
|
-
const result = [];
|
|
5692
|
-
let current = "";
|
|
5693
|
-
let depth = 0;
|
|
5694
|
-
for (let i = 0; i < input.length; i++) {
|
|
5695
|
-
const ch = input[i];
|
|
5696
|
-
if (ch === "(" || ch === "[") {
|
|
5697
|
-
depth++;
|
|
5698
|
-
current += ch;
|
|
5699
|
-
} else if (ch === ")" || ch === "]") {
|
|
5700
|
-
depth--;
|
|
5701
|
-
current += ch;
|
|
5702
|
-
} else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
|
|
5703
|
-
result.push(current);
|
|
5704
|
-
current = "";
|
|
5705
|
-
while (i + 1 < input.length && /\s/.test(input[i + 1])) i++;
|
|
5706
|
-
} else current += ch;
|
|
5707
|
-
}
|
|
5708
|
-
if (current.length > 0) result.push(current);
|
|
5709
|
-
return result;
|
|
5710
|
-
}
|
|
5711
6189
|
function formatLabel(current, max, labelMode) {
|
|
5712
6190
|
if (labelMode === "none") return null;
|
|
5713
6191
|
if (labelMode === "%") return `${max === 0 ? 0 : Math.round(current / max * 100)}%`;
|
|
@@ -5720,7 +6198,7 @@ defineMacro({
|
|
|
5720
6198
|
merged: true,
|
|
5721
6199
|
render({ rawArgs }, ctx) {
|
|
5722
6200
|
try {
|
|
5723
|
-
const { currentExpr, maxExpr, labelMode } =
|
|
6201
|
+
const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
|
|
5724
6202
|
const current = Number(ctx.evaluate(currentExpr));
|
|
5725
6203
|
const max = Number(ctx.evaluate(maxExpr));
|
|
5726
6204
|
const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
|
|
@@ -5779,6 +6257,56 @@ defineMacro({
|
|
|
5779
6257
|
}
|
|
5780
6258
|
});
|
|
5781
6259
|
//#endregion
|
|
6260
|
+
//#region src/utils/stable-key.ts
|
|
6261
|
+
/**
|
|
6262
|
+
* Content-derived string key for a value, used to remount components when
|
|
6263
|
+
* the value's contents change (e.g. {for} iterations, #45).
|
|
6264
|
+
*
|
|
6265
|
+
* JSON-compatible values (strings, finite numbers, booleans, null, arrays and
|
|
6266
|
+
* plain objects of those) produce exactly their `JSON.stringify` output. Other
|
|
6267
|
+
* values get distinct, unambiguous markers so their contents are reflected
|
|
6268
|
+
* too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
|
|
6269
|
+
* undefined, NaN/±Infinity, symbols, functions and registered class
|
|
6270
|
+
* instances. Cyclic references become a back-reference marker instead of
|
|
6271
|
+
* throwing, and the function never throws.
|
|
6272
|
+
*/
|
|
6273
|
+
function stableKey(value) {
|
|
6274
|
+
try {
|
|
6275
|
+
return keyOf(value, []);
|
|
6276
|
+
} catch {
|
|
6277
|
+
return "<unkeyable>";
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
function keyOf(val, ancestors) {
|
|
6281
|
+
switch (typeof val) {
|
|
6282
|
+
case "string": return JSON.stringify(val);
|
|
6283
|
+
case "number": return Number.isFinite(val) ? JSON.stringify(val) : String(val);
|
|
6284
|
+
case "boolean": return String(val);
|
|
6285
|
+
case "bigint": return `${val}n`;
|
|
6286
|
+
case "undefined": return "undefined";
|
|
6287
|
+
case "symbol": return val.toString();
|
|
6288
|
+
case "function": return `<function ${JSON.stringify(val.name)}>`;
|
|
6289
|
+
}
|
|
6290
|
+
if (val === null) return "null";
|
|
6291
|
+
const obj = val;
|
|
6292
|
+
const depth = ancestors.indexOf(obj);
|
|
6293
|
+
if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
|
|
6294
|
+
if (val instanceof Date) return `Date(${val.getTime()})`;
|
|
6295
|
+
if (val instanceof RegExp) return `RegExp(${String(val)})`;
|
|
6296
|
+
ancestors.push(obj);
|
|
6297
|
+
try {
|
|
6298
|
+
if (Array.isArray(val)) return `[${val.map((v) => keyOf(v, ancestors)).join(",")}]`;
|
|
6299
|
+
if (val instanceof Map) return `Map[${[...val].map(([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`).join(",")}]`;
|
|
6300
|
+
if (val instanceof Set) return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(",")}]`;
|
|
6301
|
+
const record = obj;
|
|
6302
|
+
const body = Object.keys(record).map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`).join(",");
|
|
6303
|
+
const className = getClassName(obj.constructor);
|
|
6304
|
+
return className === void 0 ? `{${body}}` : `Class(${JSON.stringify(className)}){${body}}`;
|
|
6305
|
+
} finally {
|
|
6306
|
+
ancestors.pop();
|
|
6307
|
+
}
|
|
6308
|
+
}
|
|
6309
|
+
//#endregion
|
|
5782
6310
|
//#region src/components/macros/For.tsx
|
|
5783
6311
|
/**
|
|
5784
6312
|
* Parse for-loop args: "@item, @i of $list" or "@item of $list"
|
|
@@ -5837,12 +6365,14 @@ function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue,
|
|
|
5837
6365
|
getValues
|
|
5838
6366
|
}), [update, getValues]);
|
|
5839
6367
|
const nobr = useContext(NobrContext);
|
|
6368
|
+
const inline = useContext(InlineContext);
|
|
5840
6369
|
return /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
|
|
5841
6370
|
value: updater,
|
|
5842
6371
|
children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
|
|
5843
6372
|
value: localState,
|
|
5844
6373
|
children: renderNodes(children, {
|
|
5845
6374
|
nobr,
|
|
6375
|
+
inline,
|
|
5846
6376
|
locals: localState
|
|
5847
6377
|
})
|
|
5848
6378
|
})
|
|
@@ -5886,7 +6416,7 @@ defineMacro({
|
|
|
5886
6416
|
indexVar,
|
|
5887
6417
|
indexValue: i,
|
|
5888
6418
|
children
|
|
5889
|
-
}, `${i}-${
|
|
6419
|
+
}, `${i}-${stableKey(item)}`));
|
|
5890
6420
|
return ctx.wrap(content);
|
|
5891
6421
|
}
|
|
5892
6422
|
});
|
|
@@ -5938,7 +6468,8 @@ defineMacro({
|
|
|
5938
6468
|
block: true,
|
|
5939
6469
|
interpolate: true,
|
|
5940
6470
|
render({ rawArgs, children = [] }, ctx) {
|
|
5941
|
-
const
|
|
6471
|
+
const text = readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, "");
|
|
6472
|
+
const label = ctx.resolve?.(text) ?? rawArgs;
|
|
5942
6473
|
const nobr = useContext(NobrContext);
|
|
5943
6474
|
const handleClick = () => {
|
|
5944
6475
|
const container = document.createElement("div");
|
|
@@ -5969,7 +6500,7 @@ function parseArgs(rawArgs) {
|
|
|
5969
6500
|
const parts = [];
|
|
5970
6501
|
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
5971
6502
|
let m;
|
|
5972
|
-
while ((m = re.exec(rawArgs)) !== null) parts.push(m[2]
|
|
6503
|
+
while ((m = re.exec(rawArgs)) !== null) parts.push(unescapeQuoted(m[2]));
|
|
5973
6504
|
if (parts.length >= 2) return {
|
|
5974
6505
|
display: parts[0],
|
|
5975
6506
|
passage: parts[1]
|
|
@@ -6376,7 +6907,7 @@ defineMacro({
|
|
|
6376
6907
|
const [open, setOpen] = ctx.hooks.useState(false);
|
|
6377
6908
|
const noclose = /\bnoclose\s*$/.test(rawArgs);
|
|
6378
6909
|
const labelRaw = rawArgs.replace(/\bnoclose\s*$/, "").trim();
|
|
6379
|
-
const label = ctx.resolve?.(labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
|
|
6910
|
+
const label = ctx.resolve?.(readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
|
|
6380
6911
|
const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
|
|
6381
6912
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
|
|
6382
6913
|
id: ctx.id,
|
|
@@ -6424,7 +6955,7 @@ function SaveManagerContent() {
|
|
|
6424
6955
|
const closeDialog = useContext(DialogCloseContext);
|
|
6425
6956
|
const storyData = useStoryStore((s) => s.storyData);
|
|
6426
6957
|
const playthroughId = useStoryStore((s) => s.playthroughId);
|
|
6427
|
-
const
|
|
6958
|
+
const beginSave = useStoryStore((s) => s.beginSave);
|
|
6428
6959
|
const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
|
|
6429
6960
|
const ifid = storyData?.ifid ?? "";
|
|
6430
6961
|
const refresh = useCallback(async () => {
|
|
@@ -6462,9 +6993,14 @@ function SaveManagerContent() {
|
|
|
6462
6993
|
});
|
|
6463
6994
|
};
|
|
6464
6995
|
const handleNewSave = async () => {
|
|
6465
|
-
if (!ifid
|
|
6996
|
+
if (!ifid) return;
|
|
6997
|
+
const playthrough = resolvePlaythroughId();
|
|
6466
6998
|
try {
|
|
6467
|
-
await saveWithHooks(void 0, void 0,
|
|
6999
|
+
await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
|
|
7000
|
+
const ptId = await playthrough;
|
|
7001
|
+
if (!ptId) throw new Error("No playthrough");
|
|
7002
|
+
return createSave(ifid, ptId, payload);
|
|
7003
|
+
});
|
|
6468
7004
|
showStatus("Save created");
|
|
6469
7005
|
await refresh();
|
|
6470
7006
|
} catch {
|
|
@@ -6472,9 +7008,10 @@ function SaveManagerContent() {
|
|
|
6472
7008
|
}
|
|
6473
7009
|
};
|
|
6474
7010
|
const handleOverwrite = async (saveId) => {
|
|
7011
|
+
const playthrough = resolvePlaythroughId();
|
|
6475
7012
|
try {
|
|
6476
|
-
await saveWithHooks(void 0, void 0,
|
|
6477
|
-
if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
|
|
7013
|
+
await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
|
|
7014
|
+
if (!await overwriteSave(saveId, payload, void 0, await playthrough || void 0)) throw new Error("Save not found");
|
|
6478
7015
|
});
|
|
6479
7016
|
showStatus("Save overwritten");
|
|
6480
7017
|
await refresh();
|
|
@@ -6780,27 +7317,16 @@ var FLAG = "inline";
|
|
|
6780
7317
|
*/
|
|
6781
7318
|
function parseIncludeArgs(rawArgs) {
|
|
6782
7319
|
const trimmed = rawArgs.trim();
|
|
7320
|
+
const spaces = topLevelIndices(trimmed, isWhitespace);
|
|
6783
7321
|
let first = null;
|
|
6784
7322
|
let last = null;
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
continue;
|
|
6793
|
-
}
|
|
6794
|
-
if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
|
|
6795
|
-
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
6796
|
-
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
6797
|
-
else if (depth === 0 && /\s/.test(ch)) {
|
|
6798
|
-
let end = i + 1;
|
|
6799
|
-
while (end < trimmed.length && /\s/.test(trimmed[end])) end++;
|
|
6800
|
-
last = [i, end];
|
|
6801
|
-
first ??= last;
|
|
6802
|
-
i = end - 1;
|
|
6803
|
-
}
|
|
7323
|
+
if (spaces.length > 0) {
|
|
7324
|
+
let s = 0;
|
|
7325
|
+
while (s + 1 < spaces.length && spaces[s + 1] === spaces[s] + 1) s++;
|
|
7326
|
+
first = [spaces[0], spaces[s] + 1];
|
|
7327
|
+
let e = spaces.length - 1;
|
|
7328
|
+
while (e > 0 && spaces[e - 1] === spaces[e] - 1) e--;
|
|
7329
|
+
last = [spaces[e], spaces[spaces.length - 1] + 1];
|
|
6804
7330
|
}
|
|
6805
7331
|
if (last && last[1] === trimmed.length - 6 && trimmed.endsWith(FLAG)) {
|
|
6806
7332
|
const rest = trimmed.slice(0, last[0]);
|
|
@@ -6979,7 +7505,10 @@ defineMacro({
|
|
|
6979
7505
|
//#endregion
|
|
6980
7506
|
//#region src/components/macros/Checkbox.tsx
|
|
6981
7507
|
function parseLabel(rawArgs) {
|
|
6982
|
-
|
|
7508
|
+
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+?)\s*$/);
|
|
7509
|
+
if (!match) return "";
|
|
7510
|
+
const rest = match[1];
|
|
7511
|
+
return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
|
|
6983
7512
|
}
|
|
6984
7513
|
defineMacro({
|
|
6985
7514
|
name: "checkbox",
|
|
@@ -7009,6 +7538,19 @@ defineMacro({
|
|
|
7009
7538
|
//#endregion
|
|
7010
7539
|
//#region src/components/macros/Radiobutton.tsx
|
|
7011
7540
|
function parseRadioArgs(rawArgs) {
|
|
7541
|
+
const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
|
|
7542
|
+
if (head) {
|
|
7543
|
+
const rest = rawArgs.slice(head[0].length).trim();
|
|
7544
|
+
const value = readQuoted(rest, 0);
|
|
7545
|
+
if (value && (value.end === rest.length || isWhitespace(rest[value.end]))) {
|
|
7546
|
+
const labelRaw = rest.slice(value.end).trim();
|
|
7547
|
+
const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
|
|
7548
|
+
return {
|
|
7549
|
+
value: value.value,
|
|
7550
|
+
label
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
}
|
|
7012
7554
|
const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/);
|
|
7013
7555
|
if (!match) {
|
|
7014
7556
|
const parts = rawArgs.trim().split(/\s+/).slice(1);
|
|
@@ -7467,43 +8009,87 @@ defineMacro({
|
|
|
7467
8009
|
});
|
|
7468
8010
|
//#endregion
|
|
7469
8011
|
//#region src/components/macros/Watch.tsx
|
|
8012
|
+
var WORD_RE = /\w+/y;
|
|
8013
|
+
var DIGITS_RE = /\d+/y;
|
|
8014
|
+
/** Match the sticky regex `re` at `pos`, returning the matched text. */
|
|
8015
|
+
function matchAt(re, src, pos) {
|
|
8016
|
+
re.lastIndex = pos;
|
|
8017
|
+
return re.exec(src)?.[0] ?? null;
|
|
8018
|
+
}
|
|
8019
|
+
/**
|
|
8020
|
+
* Parse `{watch "condition" keyword value …}`: a quoted condition followed
|
|
8021
|
+
* by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
|
|
8022
|
+
* `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
|
|
8023
|
+
* Returns `null` when the arguments do not start with a quoted condition.
|
|
8024
|
+
*/
|
|
8025
|
+
function parseWatchArgs(rawArgs) {
|
|
8026
|
+
const raw = rawArgs.trim();
|
|
8027
|
+
const cond = readQuoted(raw, 0);
|
|
8028
|
+
if (!cond) return null;
|
|
8029
|
+
const options = {};
|
|
8030
|
+
let i = cond.end;
|
|
8031
|
+
while (i < raw.length) {
|
|
8032
|
+
const key = matchAt(WORD_RE, raw, i);
|
|
8033
|
+
if (!key) {
|
|
8034
|
+
i = readQuoted(raw, i)?.end ?? i + 1;
|
|
8035
|
+
continue;
|
|
8036
|
+
}
|
|
8037
|
+
i += key.length;
|
|
8038
|
+
let val;
|
|
8039
|
+
let j = i;
|
|
8040
|
+
while (j < raw.length && isWhitespace(raw[j])) j++;
|
|
8041
|
+
if (j > i) {
|
|
8042
|
+
const quoted = readQuoted(raw, j);
|
|
8043
|
+
const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
|
|
8044
|
+
if (quoted) {
|
|
8045
|
+
val = quoted.value;
|
|
8046
|
+
i = quoted.end;
|
|
8047
|
+
} else if (digits) {
|
|
8048
|
+
val = digits;
|
|
8049
|
+
i = j + digits.length;
|
|
8050
|
+
}
|
|
8051
|
+
}
|
|
8052
|
+
switch (key) {
|
|
8053
|
+
case "goto":
|
|
8054
|
+
options.goto = val;
|
|
8055
|
+
break;
|
|
8056
|
+
case "dialog":
|
|
8057
|
+
options.dialog = val;
|
|
8058
|
+
break;
|
|
8059
|
+
case "run":
|
|
8060
|
+
options.run = val;
|
|
8061
|
+
break;
|
|
8062
|
+
case "name":
|
|
8063
|
+
options.name = val;
|
|
8064
|
+
break;
|
|
8065
|
+
case "priority":
|
|
8066
|
+
options.priority = val ? Number(val) : 0;
|
|
8067
|
+
break;
|
|
8068
|
+
case "once":
|
|
8069
|
+
options.once = true;
|
|
8070
|
+
break;
|
|
8071
|
+
}
|
|
8072
|
+
}
|
|
8073
|
+
return {
|
|
8074
|
+
condition: cond.value,
|
|
8075
|
+
options
|
|
8076
|
+
};
|
|
8077
|
+
}
|
|
8078
|
+
/**
|
|
8079
|
+
* Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
|
|
8080
|
+
* same escapes as `{watch}`, or a bare name.
|
|
8081
|
+
*/
|
|
8082
|
+
function parseUnwatchName(rawArgs) {
|
|
8083
|
+
const raw = rawArgs.trim();
|
|
8084
|
+
return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, "");
|
|
8085
|
+
}
|
|
7470
8086
|
defineMacro({
|
|
7471
8087
|
name: "watch",
|
|
7472
8088
|
render(props, ctx) {
|
|
7473
8089
|
const { hooks } = ctx;
|
|
7474
|
-
const
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
const condition = condMatch[2];
|
|
7478
|
-
const rest = raw.slice(condMatch[0].length);
|
|
7479
|
-
const options = {};
|
|
7480
|
-
const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
|
|
7481
|
-
let m;
|
|
7482
|
-
while ((m = kwRe.exec(rest)) !== null) {
|
|
7483
|
-
const key = m[1] ?? m[5];
|
|
7484
|
-
const val = m[2] ?? m[3] ?? m[4];
|
|
7485
|
-
if (!key) continue;
|
|
7486
|
-
switch (key) {
|
|
7487
|
-
case "goto":
|
|
7488
|
-
options.goto = val;
|
|
7489
|
-
break;
|
|
7490
|
-
case "dialog":
|
|
7491
|
-
options.dialog = val;
|
|
7492
|
-
break;
|
|
7493
|
-
case "run":
|
|
7494
|
-
options.run = val;
|
|
7495
|
-
break;
|
|
7496
|
-
case "name":
|
|
7497
|
-
options.name = val;
|
|
7498
|
-
break;
|
|
7499
|
-
case "priority":
|
|
7500
|
-
options.priority = val ? Number(val) : 0;
|
|
7501
|
-
break;
|
|
7502
|
-
case "once":
|
|
7503
|
-
options.once = true;
|
|
7504
|
-
break;
|
|
7505
|
-
}
|
|
7506
|
-
}
|
|
8090
|
+
const parsed = parseWatchArgs(props.rawArgs);
|
|
8091
|
+
if (!parsed) return null;
|
|
8092
|
+
const { condition, options } = parsed;
|
|
7507
8093
|
const registered = hooks.useRef(false);
|
|
7508
8094
|
if (!registered.current) {
|
|
7509
8095
|
registered.current = true;
|
|
@@ -7515,7 +8101,7 @@ defineMacro({
|
|
|
7515
8101
|
defineMacro({
|
|
7516
8102
|
name: "unwatch",
|
|
7517
8103
|
render(props, ctx) {
|
|
7518
|
-
const name = props.rawArgs
|
|
8104
|
+
const name = parseUnwatchName(props.rawArgs);
|
|
7519
8105
|
const ran = ctx.hooks.useRef(false);
|
|
7520
8106
|
if (!ran.current) {
|
|
7521
8107
|
ran.current = true;
|