@rohal12/spindle 0.50.1 → 0.51.1
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 +418 -129
- package/dist/pkg/story-variables.js +13 -0
- package/package.json +1 -1
- package/src/class-registry.ts +63 -1
- package/src/components/macros/Computed.tsx +2 -18
- package/src/components/macros/Include.tsx +57 -2
- package/src/components/macros/MacroLink.tsx +4 -3
- package/src/components/macros/SaveManager.tsx +7 -0
- package/src/components/macros/VarDisplay.tsx +3 -2
- package/src/components/macros/Widget.tsx +7 -1
- package/src/components/macros/WidgetInvocation.tsx +4 -2
- package/src/execute-mutation.ts +8 -54
- package/src/expression.ts +99 -4
- package/src/index.tsx +12 -20
- package/src/interpolation.ts +2 -1
- package/src/markup/ast.ts +9 -1
- package/src/markup/render.tsx +2 -1
- package/src/preact-store.ts +50 -0
- package/src/saves/save-manager.ts +109 -55
- package/src/saves/storage.ts +18 -4
- package/src/saves/types.ts +56 -7
- package/src/store.ts +86 -55
- package/src/story-api.ts +9 -9
- package/src/story-init.ts +23 -0
- package/src/story-variables.ts +18 -0
- package/types/index.d.ts +16 -6
package/dist/pkg/headless.js
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import { Fragment, createContext, h, render } from "preact";
|
|
2
2
|
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
3
|
-
import {
|
|
3
|
+
import { useSyncExternalStore } from "preact/compat";
|
|
4
|
+
import { createStore } from "zustand/vanilla";
|
|
4
5
|
import { immer } from "zustand/middleware/immer";
|
|
5
6
|
import { applyPatches, enablePatches, produceWithPatches } from "immer";
|
|
6
7
|
import { Fragment as Fragment$1, jsx, jsxs } from "preact/jsx-runtime";
|
|
7
8
|
import { micromark } from "micromark";
|
|
8
9
|
import { gfmTable, gfmTableHtml } from "micromark-extension-gfm-table";
|
|
9
10
|
import { gfmStrikethrough, gfmStrikethroughHtml } from "micromark-extension-gfm-strikethrough";
|
|
11
|
+
//#region src/preact-store.ts
|
|
12
|
+
/**
|
|
13
|
+
* Zustand's `create()` for Preact.
|
|
14
|
+
*
|
|
15
|
+
* Zustand's default entry (`zustand` / `zustand/react`) imports `react`, which
|
|
16
|
+
* Spindle doesn't depend on. Bundlers that alias `react` to `preact/compat`
|
|
17
|
+
* hide that, but the published headless entry keeps Zustand external and runs
|
|
18
|
+
* under plain Node resolution, where `react` doesn't exist. Building on the
|
|
19
|
+
* vanilla store and Preact's own hooks keeps `react` out of the module graph.
|
|
20
|
+
*/
|
|
21
|
+
var identity = (value) => value;
|
|
22
|
+
function bindPreact(api) {
|
|
23
|
+
function useBoundStore(selector = identity) {
|
|
24
|
+
const getSnapshot = useCallback(() => selector(api.getState()), [selector]);
|
|
25
|
+
return useSyncExternalStore(api.subscribe, getSnapshot);
|
|
26
|
+
}
|
|
27
|
+
return Object.assign(useBoundStore, api);
|
|
28
|
+
}
|
|
29
|
+
/** Curried `create<State>()(initializer)`, as in Zustand. */
|
|
30
|
+
function create() {
|
|
31
|
+
return (initializer) => bindPreact(createStore()(initializer));
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
10
34
|
//#region src/saves/types.ts
|
|
11
35
|
function isSaveExport(value) {
|
|
12
36
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -23,12 +47,37 @@ function isSaveExport(value) {
|
|
|
23
47
|
if (typeof meta.createdAt !== "string") return false;
|
|
24
48
|
if (typeof meta.updatedAt !== "string") return false;
|
|
25
49
|
if (typeof meta.title !== "string") return false;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
return isImportablePayload(save.payload);
|
|
51
|
+
}
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
/** Absent, null, or a `{ seed, pull }` PRNG snapshot. */
|
|
56
|
+
function isOptionalPRNGSnapshot(value) {
|
|
57
|
+
if (value == null) return true;
|
|
58
|
+
return isRecord(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
|
|
59
|
+
}
|
|
60
|
+
function isSaveHistoryMoment(value) {
|
|
61
|
+
return isRecord(value) && typeof value.passage === "string" && isRecord(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Full check of a payload from outside the running story (an imported save):
|
|
65
|
+
* every history moment is well formed, and `historyIndex` is an integer that
|
|
66
|
+
* points at a moment of the payload's passage. Anything that passes can be
|
|
67
|
+
* stored and later loaded.
|
|
68
|
+
*/
|
|
69
|
+
function isImportablePayload(value) {
|
|
70
|
+
if (!isRecord(value)) return false;
|
|
71
|
+
if (typeof value.passage !== "string") return false;
|
|
72
|
+
if (!isRecord(value.variables)) return false;
|
|
73
|
+
const { history, historyIndex } = value;
|
|
74
|
+
if (!Array.isArray(history) || history.length === 0) return false;
|
|
75
|
+
if (!history.every(isSaveHistoryMoment)) return false;
|
|
76
|
+
if (typeof historyIndex !== "number" || !Number.isInteger(historyIndex) || historyIndex < 0 || historyIndex >= history.length) return false;
|
|
77
|
+
if (history[historyIndex].passage !== value.passage) return false;
|
|
78
|
+
if (value.visitCounts !== void 0 && !isRecord(value.visitCounts)) return false;
|
|
79
|
+
if (value.renderCounts !== void 0 && !isRecord(value.renderCounts)) return false;
|
|
80
|
+
return isOptionalPRNGSnapshot(value.prng);
|
|
32
81
|
}
|
|
33
82
|
function isSavePayload(value) {
|
|
34
83
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -831,6 +880,13 @@ var BRANCHING_BLOCK_MACROS = new Set([
|
|
|
831
880
|
"timed"
|
|
832
881
|
]);
|
|
833
882
|
/**
|
|
883
|
+
* Quote a bracket-link value as a {link} argument, escaping backslashes and
|
|
884
|
+
* double quotes so MacroLink reads the value back unchanged (#200).
|
|
885
|
+
*/
|
|
886
|
+
function quoteArg(value) {
|
|
887
|
+
return `"${value.replace(/[\\"]/g, "\\$&")}"`;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
834
890
|
* Build an AST from a token array. Block macros are nested into trees
|
|
835
891
|
* using a stack. Throws on unclosed or mismatched macros.
|
|
836
892
|
*/
|
|
@@ -854,7 +910,7 @@ function buildAST(tokens) {
|
|
|
854
910
|
const macroNode = {
|
|
855
911
|
type: "macro",
|
|
856
912
|
name: "link",
|
|
857
|
-
rawArgs:
|
|
913
|
+
rawArgs: `${quoteArg(token.display)} ${quoteArg(token.target)}`,
|
|
858
914
|
children: []
|
|
859
915
|
};
|
|
860
916
|
if (token.className) macroNode.className = token.className;
|
|
@@ -1115,33 +1171,105 @@ var FN_CACHE_MAX = 500;
|
|
|
1115
1171
|
var fnCache = /* @__PURE__ */ new Map();
|
|
1116
1172
|
/**
|
|
1117
1173
|
* Transform expression: $var → variables["var"], _var → temporary["var"],
|
|
1118
|
-
* @var → locals["var"]
|
|
1174
|
+
* @var → locals["var"]
|
|
1119
1175
|
* Only transforms when sigils appear as a word boundary (not inside strings naively,
|
|
1120
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.
|
|
1121
1179
|
*/
|
|
1122
1180
|
var VAR_RE = /\$(\w+)/g;
|
|
1123
1181
|
var TEMP_RE = /(?<![.\w])_(\w+)/g;
|
|
1124
1182
|
var LOCAL_RE = /@(\w+)/g;
|
|
1125
|
-
|
|
1183
|
+
/** Transient name after `%`: an identifier, so `%3` is never a reference. */
|
|
1184
|
+
var TRANS_NAME_RE = /[A-Za-z_]\w*/y;
|
|
1185
|
+
/** Characters of identifiers, numbers and sigil variable references. */
|
|
1186
|
+
var WORD_CHAR_RE = /[\w$@]/;
|
|
1187
|
+
var SPACE_RE = /\s/;
|
|
1188
|
+
/** Keywords followed by an operand rather than an operator. */
|
|
1189
|
+
var OPERAND_KEYWORDS = new Set([
|
|
1190
|
+
"await",
|
|
1191
|
+
"case",
|
|
1192
|
+
"delete",
|
|
1193
|
+
"do",
|
|
1194
|
+
"else",
|
|
1195
|
+
"in",
|
|
1196
|
+
"instanceof",
|
|
1197
|
+
"new",
|
|
1198
|
+
"of",
|
|
1199
|
+
"return",
|
|
1200
|
+
"throw",
|
|
1201
|
+
"typeof",
|
|
1202
|
+
"void",
|
|
1203
|
+
"yield"
|
|
1204
|
+
]);
|
|
1205
|
+
/** Keywords whose parenthesised header is followed by a statement. */
|
|
1206
|
+
var HEADER_KEYWORDS = new Set([
|
|
1207
|
+
"for",
|
|
1208
|
+
"if",
|
|
1209
|
+
"while",
|
|
1210
|
+
"with"
|
|
1211
|
+
]);
|
|
1126
1212
|
function transformSegment(segment) {
|
|
1127
|
-
return segment.replace(VAR_RE, "variables[\"$1\"]").replace(TEMP_RE, "temporary[\"$1\"]").replace(LOCAL_RE, "locals[\"$1\"]")
|
|
1213
|
+
return segment.replace(VAR_RE, "variables[\"$1\"]").replace(TEMP_RE, "temporary[\"$1\"]").replace(LOCAL_RE, "locals[\"$1\"]");
|
|
1128
1214
|
}
|
|
1129
1215
|
/**
|
|
1130
1216
|
* String-aware expression transformer. Walks the expression character by
|
|
1131
1217
|
* character so that variable sigils ($, _, @, %) inside string literals are
|
|
1132
1218
|
* left untouched while code — including expressions inside template-literal
|
|
1133
1219
|
* `${…}` interpolations — is transformed.
|
|
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.
|
|
1134
1224
|
*/
|
|
1135
1225
|
function transform(expr) {
|
|
1136
1226
|
let result = "";
|
|
1137
1227
|
let code = "";
|
|
1138
1228
|
let i = 0;
|
|
1229
|
+
let operandNext = true;
|
|
1230
|
+
let word = "";
|
|
1231
|
+
let afterDot = false;
|
|
1232
|
+
let afterHeaderKeyword = false;
|
|
1233
|
+
let lastPunct = "";
|
|
1234
|
+
const parenIsHeader = [];
|
|
1139
1235
|
function flushCode() {
|
|
1140
1236
|
if (code) {
|
|
1141
1237
|
result += transformSegment(code);
|
|
1142
1238
|
code = "";
|
|
1143
1239
|
}
|
|
1144
1240
|
}
|
|
1241
|
+
function endWord() {
|
|
1242
|
+
if (!word) return;
|
|
1243
|
+
operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
|
|
1244
|
+
afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
|
|
1245
|
+
afterDot = false;
|
|
1246
|
+
lastPunct = "";
|
|
1247
|
+
word = "";
|
|
1248
|
+
}
|
|
1249
|
+
/** A string or template literal, or a transient reference, just ended. */
|
|
1250
|
+
function endOperand() {
|
|
1251
|
+
endWord();
|
|
1252
|
+
operandNext = false;
|
|
1253
|
+
afterHeaderKeyword = false;
|
|
1254
|
+
afterDot = false;
|
|
1255
|
+
lastPunct = "";
|
|
1256
|
+
}
|
|
1257
|
+
function trackCode(c) {
|
|
1258
|
+
if (WORD_CHAR_RE.test(c)) {
|
|
1259
|
+
word += c;
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
endWord();
|
|
1263
|
+
if (c === "\n") operandNext = true;
|
|
1264
|
+
if (SPACE_RE.test(c)) return;
|
|
1265
|
+
if (c === "(") parenIsHeader.push(afterHeaderKeyword);
|
|
1266
|
+
if (c === ")") operandNext = parenIsHeader.pop() ?? false;
|
|
1267
|
+
else if (c === ".") operandNext = lastPunct === ".";
|
|
1268
|
+
else operandNext = c !== "]";
|
|
1269
|
+
afterDot = c === ".";
|
|
1270
|
+
afterHeaderKeyword = false;
|
|
1271
|
+
lastPunct = c;
|
|
1272
|
+
}
|
|
1145
1273
|
while (i < expr.length) {
|
|
1146
1274
|
const ch = expr.charAt(i);
|
|
1147
1275
|
if (ch === "\"" || ch === "'") {
|
|
@@ -1164,6 +1292,7 @@ function transform(expr) {
|
|
|
1164
1292
|
}
|
|
1165
1293
|
}
|
|
1166
1294
|
result += str;
|
|
1295
|
+
endOperand();
|
|
1167
1296
|
continue;
|
|
1168
1297
|
}
|
|
1169
1298
|
if (ch === "`") {
|
|
@@ -1257,8 +1386,22 @@ function transform(expr) {
|
|
|
1257
1386
|
i++;
|
|
1258
1387
|
}
|
|
1259
1388
|
}
|
|
1389
|
+
endOperand();
|
|
1260
1390
|
continue;
|
|
1261
1391
|
}
|
|
1392
|
+
if (ch === "%") {
|
|
1393
|
+
endWord();
|
|
1394
|
+
TRANS_NAME_RE.lastIndex = i + 1;
|
|
1395
|
+
const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : void 0;
|
|
1396
|
+
if (name) {
|
|
1397
|
+
flushCode();
|
|
1398
|
+
result += `transient["${name}"]`;
|
|
1399
|
+
i += 1 + name.length;
|
|
1400
|
+
endOperand();
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
trackCode(ch);
|
|
1262
1405
|
code += ch;
|
|
1263
1406
|
i++;
|
|
1264
1407
|
}
|
|
@@ -1349,7 +1492,7 @@ function hasInterpolation(s) {
|
|
|
1349
1492
|
function resolveDotPath(root, parts) {
|
|
1350
1493
|
let value = root;
|
|
1351
1494
|
for (let i = 1; i < parts.length; i++) {
|
|
1352
|
-
if (value == null
|
|
1495
|
+
if (value == null) return void 0;
|
|
1353
1496
|
value = value[parts[i]];
|
|
1354
1497
|
}
|
|
1355
1498
|
return value;
|
|
@@ -1434,7 +1577,7 @@ function VarDisplay({ name, scope, className, id }) {
|
|
|
1434
1577
|
if (scope === "local") value = root in localsValues ? localsValues[root] : void 0;
|
|
1435
1578
|
else value = storeValue;
|
|
1436
1579
|
for (let i = 1; i < parts.length; i++) {
|
|
1437
|
-
if (value == null
|
|
1580
|
+
if (value == null) {
|
|
1438
1581
|
value = void 0;
|
|
1439
1582
|
break;
|
|
1440
1583
|
}
|
|
@@ -1630,14 +1773,14 @@ function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
|
|
|
1630
1773
|
const nobr = useContext(NobrContext);
|
|
1631
1774
|
const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
|
|
1632
1775
|
const childrenValue = invocationChildren?.length ? invocationChildren : null;
|
|
1633
|
-
if (params.length === 0
|
|
1776
|
+
if (params.length === 0) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
|
|
1634
1777
|
value: childrenValue,
|
|
1635
1778
|
children: renderNodes(body, {
|
|
1636
1779
|
nobr,
|
|
1637
1780
|
locals: parentValues
|
|
1638
1781
|
})
|
|
1639
1782
|
});
|
|
1640
|
-
const argExprs = splitArgs(rawArgs);
|
|
1783
|
+
const argExprs = rawArgs ? splitArgs(rawArgs) : [];
|
|
1641
1784
|
const values = [];
|
|
1642
1785
|
for (let i = 0; i < params.length; i++) {
|
|
1643
1786
|
const expr = argExprs[i];
|
|
@@ -1948,7 +2091,7 @@ function getVariableTextValue(node, locals) {
|
|
|
1948
2091
|
else if (node.scope === "transient") value = state.transient[root];
|
|
1949
2092
|
else value = locals[root];
|
|
1950
2093
|
for (let i = 1; i < parts.length; i++) {
|
|
1951
|
-
if (value == null
|
|
2094
|
+
if (value == null) return "";
|
|
1952
2095
|
value = value[parts[i]];
|
|
1953
2096
|
}
|
|
1954
2097
|
return value == null ? "" : String(value);
|
|
@@ -2354,13 +2497,25 @@ async function detectBackend() {
|
|
|
2354
2497
|
} catch {}
|
|
2355
2498
|
return createMemoryBackend();
|
|
2356
2499
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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;
|
|
2361
2515
|
}
|
|
2362
2516
|
function resetBackend() {
|
|
2363
2517
|
_backend = null;
|
|
2518
|
+
_backendPromise = null;
|
|
2364
2519
|
}
|
|
2365
2520
|
/**
|
|
2366
2521
|
* Synchronous accessor for the current backend type.
|
|
@@ -2382,7 +2537,7 @@ function isPlainObject(value) {
|
|
|
2382
2537
|
const proto = Object.getPrototypeOf(value);
|
|
2383
2538
|
return proto === Object.prototype || proto === null;
|
|
2384
2539
|
}
|
|
2385
|
-
function deepClone(value) {
|
|
2540
|
+
function deepClone(value, options = {}) {
|
|
2386
2541
|
const seen = /* @__PURE__ */ new Map();
|
|
2387
2542
|
function clone(val) {
|
|
2388
2543
|
if (val === null || typeof val !== "object") return val;
|
|
@@ -2415,6 +2570,7 @@ function deepClone(value) {
|
|
|
2415
2570
|
for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
|
|
2416
2571
|
return copy;
|
|
2417
2572
|
}
|
|
2573
|
+
if (!isPlainObject(val) && options.keepUnregistered) return val;
|
|
2418
2574
|
if (isPlainObject(val) || typeof val === "object") {
|
|
2419
2575
|
const copy = {};
|
|
2420
2576
|
seen.set(obj, copy);
|
|
@@ -2425,6 +2581,34 @@ function deepClone(value) {
|
|
|
2425
2581
|
}
|
|
2426
2582
|
return clone(value);
|
|
2427
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
|
+
}
|
|
2428
2612
|
var CLASS_TAG = "__spindle_class__";
|
|
2429
2613
|
var DATA_TAG = "__spindle_data__";
|
|
2430
2614
|
function serialize(value) {
|
|
@@ -2628,13 +2812,21 @@ async function getCurrentPlaythroughId(ifid) {
|
|
|
2628
2812
|
* before `getPayload()` captures the state, so data a hook sets is part of the
|
|
2629
2813
|
* save; `aftersave` fires once `write` has stored it. Every user-facing save
|
|
2630
2814
|
* path goes through here.
|
|
2815
|
+
*
|
|
2816
|
+
* The hook and capture run synchronously, but an error they throw (a failing
|
|
2817
|
+
* hook, an unserializable payload) rejects the returned promise like a failed
|
|
2818
|
+
* write rather than escaping to the caller.
|
|
2631
2819
|
*/
|
|
2632
2820
|
function saveWithHooks(slot, custom, getPayload, write) {
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2821
|
+
try {
|
|
2822
|
+
emit("beforesave", slot, custom);
|
|
2823
|
+
return write(getPayload()).then((result) => {
|
|
2824
|
+
emit("aftersave", slot);
|
|
2825
|
+
return result;
|
|
2826
|
+
});
|
|
2827
|
+
} catch (err) {
|
|
2828
|
+
return Promise.reject(err);
|
|
2829
|
+
}
|
|
2638
2830
|
}
|
|
2639
2831
|
async function createSave(ifid, playthroughId, payload, custom = {}) {
|
|
2640
2832
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2708,8 +2900,25 @@ async function loadSave(saveId) {
|
|
|
2708
2900
|
if (!record) return void 0;
|
|
2709
2901
|
return deserializePayload(record.payload);
|
|
2710
2902
|
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Delete a save record. If the default slot or a named slot holds it, that
|
|
2905
|
+
* slot is cleared as well (pointer and slot index), as `deleteSlotSave` would.
|
|
2906
|
+
*/
|
|
2711
2907
|
async function deleteSaveById(saveId) {
|
|
2712
|
-
|
|
2908
|
+
const backend = await getBackend();
|
|
2909
|
+
const record = await backend.getSave(saveId);
|
|
2910
|
+
await backend.deleteSave(saveId);
|
|
2911
|
+
if (!record) return;
|
|
2912
|
+
const ifid = record.meta.ifid;
|
|
2913
|
+
const slots = [void 0, ...await getIndexedSlots(ifid)];
|
|
2914
|
+
for (const slot of slots) {
|
|
2915
|
+
const metaKey = slotMetaKey(ifid, slot);
|
|
2916
|
+
await withSlot(metaKey, async () => {
|
|
2917
|
+
if (await backend.getMeta(metaKey) !== saveId) return;
|
|
2918
|
+
await backend.deleteMeta(metaKey);
|
|
2919
|
+
await removeFromSlotIndex(ifid, slot);
|
|
2920
|
+
});
|
|
2921
|
+
}
|
|
2713
2922
|
}
|
|
2714
2923
|
async function renameSave(saveId, newTitle) {
|
|
2715
2924
|
const backend = await getBackend();
|
|
@@ -2771,6 +2980,23 @@ function isNamedSlot(slot) {
|
|
|
2771
2980
|
function slotMetaKey(ifid, slot) {
|
|
2772
2981
|
return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
|
|
2773
2982
|
}
|
|
2983
|
+
/** Tail of each slot's operation queue, keyed by the slot's meta key. */
|
|
2984
|
+
var slotQueues = /* @__PURE__ */ new Map();
|
|
2985
|
+
/**
|
|
2986
|
+
* Run a complete operation on one slot (save, import, delete) after the ones
|
|
2987
|
+
* already queued for it. Each operation reads the slot pointer and writes the
|
|
2988
|
+
* record and pointer as a unit, so concurrent operations on the same slot
|
|
2989
|
+
* apply in call order instead of each creating its own record.
|
|
2990
|
+
*/
|
|
2991
|
+
function withSlot(metaKey, op) {
|
|
2992
|
+
const run = (slotQueues.get(metaKey) ?? Promise.resolve()).then(op);
|
|
2993
|
+
const tail = run.catch(() => {});
|
|
2994
|
+
slotQueues.set(metaKey, tail);
|
|
2995
|
+
tail.then(() => {
|
|
2996
|
+
if (slotQueues.get(metaKey) === tail) slotQueues.delete(metaKey);
|
|
2997
|
+
});
|
|
2998
|
+
return run;
|
|
2999
|
+
}
|
|
2774
3000
|
/** Tail of the queue that serializes slot index updates. */
|
|
2775
3001
|
var slotIndexQueue = Promise.resolve();
|
|
2776
3002
|
/**
|
|
@@ -2813,22 +3039,24 @@ function toSaveInfo(record, slot) {
|
|
|
2813
3039
|
custom: record.meta.custom ?? {}
|
|
2814
3040
|
};
|
|
2815
3041
|
}
|
|
2816
|
-
|
|
2817
|
-
const backend = await getBackend();
|
|
3042
|
+
function quickSave(ifid, playthroughId, payload, slot, custom) {
|
|
2818
3043
|
const metaKey = slotMetaKey(ifid, slot);
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
const
|
|
2822
|
-
if (
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
3044
|
+
return withSlot(metaKey, async () => {
|
|
3045
|
+
const backend = await getBackend();
|
|
3046
|
+
const existingId = await backend.getMeta(metaKey);
|
|
3047
|
+
if (existingId) {
|
|
3048
|
+
const updated = await overwriteSave(existingId, payload, custom);
|
|
3049
|
+
if (updated) return updated;
|
|
3050
|
+
}
|
|
3051
|
+
const record = await createSave(ifid, playthroughId, payload, {
|
|
3052
|
+
isAutosave: !isNamedSlot(slot),
|
|
3053
|
+
...isNamedSlot(slot) ? { slot } : {},
|
|
3054
|
+
...custom
|
|
3055
|
+
});
|
|
3056
|
+
await backend.setMeta(metaKey, record.meta.id);
|
|
3057
|
+
await addToSlotIndex(ifid, slot);
|
|
3058
|
+
return record;
|
|
2828
3059
|
});
|
|
2829
|
-
await backend.setMeta(metaKey, record.meta.id);
|
|
2830
|
-
await addToSlotIndex(ifid, slot);
|
|
2831
|
-
return record;
|
|
2832
3060
|
}
|
|
2833
3061
|
async function hasQuickSave(ifid, slot) {
|
|
2834
3062
|
const backend = await getBackend();
|
|
@@ -2848,7 +3076,7 @@ async function loadQuickSave(ifid, slot) {
|
|
|
2848
3076
|
* The default (autosave) slot uses empty string as key.
|
|
2849
3077
|
*/
|
|
2850
3078
|
async function populateKnownSaves(ifid) {
|
|
2851
|
-
const result =
|
|
3079
|
+
const result = Object.create(null);
|
|
2852
3080
|
if (await hasQuickSave(ifid)) result[""] = true;
|
|
2853
3081
|
for (const slot of await getIndexedSlots(ifid)) if (await hasQuickSave(ifid, slot)) result[slot] = true;
|
|
2854
3082
|
return result;
|
|
@@ -2882,14 +3110,16 @@ async function listSlotSaves(ifid) {
|
|
|
2882
3110
|
/**
|
|
2883
3111
|
* Delete a save by slot name. Removes from slot index if named.
|
|
2884
3112
|
*/
|
|
2885
|
-
|
|
2886
|
-
const backend = await getBackend();
|
|
3113
|
+
function deleteSlotSave(ifid, slot) {
|
|
2887
3114
|
const metaKey = slotMetaKey(ifid, slot);
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
3115
|
+
return withSlot(metaKey, async () => {
|
|
3116
|
+
const backend = await getBackend();
|
|
3117
|
+
const existingId = await backend.getMeta(metaKey);
|
|
3118
|
+
if (!existingId) return;
|
|
3119
|
+
await backend.deleteSave(existingId);
|
|
3120
|
+
await backend.deleteMeta(metaKey);
|
|
3121
|
+
await removeFromSlotIndex(ifid, slot);
|
|
3122
|
+
});
|
|
2893
3123
|
}
|
|
2894
3124
|
var SESSION_KEY_PREFIX = "spindle.session.";
|
|
2895
3125
|
/**
|
|
@@ -2935,6 +3165,7 @@ async function exportSave(saveId) {
|
|
|
2935
3165
|
async function prepareImport(data, ifid) {
|
|
2936
3166
|
if (data.version !== 1) throw new Error(`Unsupported save version: ${data.version}`);
|
|
2937
3167
|
if (data.ifid !== ifid) throw new Error(`Save is from a different story (expected IFID ${ifid}, got ${data.ifid})`);
|
|
3168
|
+
if (!isSaveExport(data)) throw new Error("Invalid save file format");
|
|
2938
3169
|
const backend = await getBackend();
|
|
2939
3170
|
const record = deepClone(data.save);
|
|
2940
3171
|
record.meta.id = crypto.randomUUID();
|
|
@@ -2970,22 +3201,24 @@ async function exportSlotSave(ifid, slot) {
|
|
|
2970
3201
|
* validation as `importSave` (version, IFID) and keeps the playthrough record
|
|
2971
3202
|
* and slot index consistent.
|
|
2972
3203
|
*/
|
|
2973
|
-
|
|
2974
|
-
const record = await prepareImport(data, ifid);
|
|
2975
|
-
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
2976
|
-
record.meta.custom = {
|
|
2977
|
-
...custom,
|
|
2978
|
-
isAutosave: !isNamedSlot(slot),
|
|
2979
|
-
...isNamedSlot(slot) ? { slot } : {}
|
|
2980
|
-
};
|
|
2981
|
-
const backend = await getBackend();
|
|
3204
|
+
function importSlotSave(data, ifid, slot) {
|
|
2982
3205
|
const metaKey = slotMetaKey(ifid, slot);
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3206
|
+
return withSlot(metaKey, async () => {
|
|
3207
|
+
const record = await prepareImport(data, ifid);
|
|
3208
|
+
const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
|
|
3209
|
+
record.meta.custom = {
|
|
3210
|
+
...custom,
|
|
3211
|
+
isAutosave: !isNamedSlot(slot),
|
|
3212
|
+
...isNamedSlot(slot) ? { slot } : {}
|
|
3213
|
+
};
|
|
3214
|
+
const backend = await getBackend();
|
|
3215
|
+
const previousId = await backend.getMeta(metaKey);
|
|
3216
|
+
await backend.putSave(record);
|
|
3217
|
+
await backend.setMeta(metaKey, record.meta.id);
|
|
3218
|
+
await addToSlotIndex(ifid, slot);
|
|
3219
|
+
if (previousId) await backend.deleteSave(previousId);
|
|
3220
|
+
return toSaveInfo(record, slot);
|
|
3221
|
+
});
|
|
2989
3222
|
}
|
|
2990
3223
|
async function getStorageInfo(ifid) {
|
|
2991
3224
|
const backend = await getBackend();
|
|
@@ -3075,37 +3308,21 @@ function executeStoryInit() {
|
|
|
3075
3308
|
const saveTitlePassage = state.storyData.passages.get("SaveTitle");
|
|
3076
3309
|
if (saveTitlePassage) setSaveTitlePassage(saveTitlePassage.content);
|
|
3077
3310
|
}
|
|
3078
|
-
//#endregion
|
|
3079
|
-
//#region src/execute-mutation.ts
|
|
3080
3311
|
/**
|
|
3081
|
-
*
|
|
3082
|
-
*
|
|
3083
|
-
*
|
|
3084
|
-
*
|
|
3312
|
+
* Initialize a freshly booted story: run StoryInit, restore `session` (the
|
|
3313
|
+
* state a page refresh left behind) if there is one, then fire `storyinit`
|
|
3314
|
+
* once all state is settled. Without a session, what the `storyinit`
|
|
3315
|
+
* handlers set synchronously belongs to the start moment, like StoryInit's
|
|
3316
|
+
* changes; a restored session keeps the history it recorded.
|
|
3085
3317
|
*/
|
|
3086
|
-
function
|
|
3087
|
-
|
|
3088
|
-
if (
|
|
3089
|
-
|
|
3090
|
-
if (
|
|
3091
|
-
seen.set(a, b);
|
|
3092
|
-
if (a instanceof Date) return a.getTime() === b.getTime();
|
|
3093
|
-
if (a instanceof RegExp) return String(a) === String(b);
|
|
3094
|
-
if (a instanceof Map || a instanceof Set) {
|
|
3095
|
-
const bc = b;
|
|
3096
|
-
if (a.size !== bc.size) return false;
|
|
3097
|
-
const ai = a.entries();
|
|
3098
|
-
const bi = bc.entries();
|
|
3099
|
-
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;
|
|
3100
|
-
return true;
|
|
3101
|
-
}
|
|
3102
|
-
const ao = a;
|
|
3103
|
-
const bo = b;
|
|
3104
|
-
const keys = Object.keys(ao);
|
|
3105
|
-
if (keys.length !== Object.keys(bo).length) return false;
|
|
3106
|
-
for (const key of keys) if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
|
|
3107
|
-
return true;
|
|
3318
|
+
function initializeStory(session) {
|
|
3319
|
+
executeStoryInit();
|
|
3320
|
+
if (session) useStoryStore.getState().loadFromPayload(session);
|
|
3321
|
+
emit("storyinit");
|
|
3322
|
+
if (!session) recordStoryInitState();
|
|
3108
3323
|
}
|
|
3324
|
+
//#endregion
|
|
3325
|
+
//#region src/execute-mutation.ts
|
|
3109
3326
|
/**
|
|
3110
3327
|
* Write the keys `code` changed (`clone` vs the pre-execution `before`) into
|
|
3111
3328
|
* an Immer draft of the same namespace, and delete the keys it removed.
|
|
@@ -3120,14 +3337,14 @@ function executeMutation(code, mergedLocals, scopeUpdate) {
|
|
|
3120
3337
|
const vars = deepClone(state.variables);
|
|
3121
3338
|
const temps = deepClone(state.temporary);
|
|
3122
3339
|
const trans = deepClone(state.transient);
|
|
3123
|
-
const localsClone = {
|
|
3340
|
+
const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
|
|
3124
3341
|
execute(code, vars, temps, localsClone, trans);
|
|
3125
3342
|
state.updateVariables((draft) => {
|
|
3126
3343
|
commitNamespace(draft.variables, vars, state.variables);
|
|
3127
3344
|
commitNamespace(draft.temporary, temps, state.temporary);
|
|
3128
3345
|
commitNamespace(draft.transient, trans, state.transient);
|
|
3129
3346
|
});
|
|
3130
|
-
for (const key of Object.keys(localsClone)) if (localsClone[key]
|
|
3347
|
+
for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
|
|
3131
3348
|
for (const key of Object.keys(mergedLocals)) if (!(key in localsClone)) scopeUpdate(key, void 0);
|
|
3132
3349
|
}
|
|
3133
3350
|
//#endregion
|
|
@@ -3432,8 +3649,10 @@ function resetModuleState(base) {
|
|
|
3432
3649
|
/**
|
|
3433
3650
|
* Record the state StoryInit left behind as the start moment: its variable
|
|
3434
3651
|
* snapshot (the history base) and PRNG state. Called by executeStoryInit()
|
|
3435
|
-
* after the StoryInit passage has rendered
|
|
3436
|
-
*
|
|
3652
|
+
* after the StoryInit passage has rendered, and again after the `storyinit`
|
|
3653
|
+
* handlers have run at boot (unless a session was restored) and on restart;
|
|
3654
|
+
* init()/restart() record the start moment before StoryInit runs. A no-op
|
|
3655
|
+
* once the story has moved on.
|
|
3437
3656
|
*/
|
|
3438
3657
|
function recordStoryInitState() {
|
|
3439
3658
|
const { history, historyIndex, variables } = useStoryStore.getState();
|
|
@@ -3489,6 +3708,16 @@ function restorePRNGFromMoment(moment) {
|
|
|
3489
3708
|
if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
|
|
3490
3709
|
else if (moment) resetPRNG();
|
|
3491
3710
|
}
|
|
3711
|
+
/**
|
|
3712
|
+
* Return `p` marked as handled: a caller that ignores the result of a
|
|
3713
|
+
* fire-and-forget operation (whose failure is already logged) gets no
|
|
3714
|
+
* unhandled-rejection report, while a caller that awaits it still sees the
|
|
3715
|
+
* error.
|
|
3716
|
+
*/
|
|
3717
|
+
function handled(p) {
|
|
3718
|
+
p.catch(() => {});
|
|
3719
|
+
return p;
|
|
3720
|
+
}
|
|
3492
3721
|
var useStoryStore = create()(immer((set, get) => ({
|
|
3493
3722
|
storyData: null,
|
|
3494
3723
|
currentPassage: "",
|
|
@@ -3746,6 +3975,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3746
3975
|
executeStoryInit();
|
|
3747
3976
|
clearSession(storyData.ifid);
|
|
3748
3977
|
emit("storyinit");
|
|
3978
|
+
recordStoryInitState();
|
|
3749
3979
|
startNewPlaythrough(storyData.ifid).then((newId) => {
|
|
3750
3980
|
set((state) => {
|
|
3751
3981
|
state.playthroughId = newId;
|
|
@@ -3754,8 +3984,8 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3754
3984
|
},
|
|
3755
3985
|
save: (slot, custom) => {
|
|
3756
3986
|
const { storyData, playthroughId } = get();
|
|
3757
|
-
if (!storyData) return;
|
|
3758
|
-
saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
|
|
3987
|
+
if (!storyData) return Promise.resolve();
|
|
3988
|
+
return handled(saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
|
|
3759
3989
|
set((state) => {
|
|
3760
3990
|
state.saveError = null;
|
|
3761
3991
|
});
|
|
@@ -3771,15 +4001,16 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3771
4001
|
set((state) => {
|
|
3772
4002
|
state.saveError = err instanceof Error ? err.message : "Failed to save";
|
|
3773
4003
|
});
|
|
3774
|
-
|
|
4004
|
+
throw err;
|
|
4005
|
+
}));
|
|
3775
4006
|
},
|
|
3776
4007
|
load: (slot) => {
|
|
3777
4008
|
const { storyData } = get();
|
|
3778
|
-
if (!storyData) return;
|
|
4009
|
+
if (!storyData) return Promise.resolve();
|
|
3779
4010
|
set((state) => {
|
|
3780
4011
|
state.loadError = null;
|
|
3781
4012
|
});
|
|
3782
|
-
loadQuickSave(storyData.ifid, slot).then((payload) => {
|
|
4013
|
+
return handled(loadQuickSave(storyData.ifid, slot).then((payload) => {
|
|
3783
4014
|
if (!payload) return;
|
|
3784
4015
|
get().loadFromPayload(payload, slot);
|
|
3785
4016
|
}).catch((err) => {
|
|
@@ -3787,12 +4018,13 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3787
4018
|
set((state) => {
|
|
3788
4019
|
state.loadError = err instanceof Error ? err.message : "Failed to load";
|
|
3789
4020
|
});
|
|
3790
|
-
|
|
4021
|
+
throw err;
|
|
4022
|
+
}));
|
|
3791
4023
|
},
|
|
3792
4024
|
hasSave: (slot) => {
|
|
3793
4025
|
const { storyData, knownSaves } = get();
|
|
3794
4026
|
if (!storyData) return false;
|
|
3795
|
-
return (slot ?? "")
|
|
4027
|
+
return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
|
|
3796
4028
|
},
|
|
3797
4029
|
getSaveInfo: async (slot) => {
|
|
3798
4030
|
const { storyData } = get();
|
|
@@ -3806,8 +4038,8 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3806
4038
|
},
|
|
3807
4039
|
deleteSave: (slot) => {
|
|
3808
4040
|
const { storyData } = get();
|
|
3809
|
-
if (!storyData) return;
|
|
3810
|
-
deleteSlotSave(storyData.ifid, slot).then(() => {
|
|
4041
|
+
if (!storyData) return Promise.resolve();
|
|
4042
|
+
return handled(deleteSlotSave(storyData.ifid, slot).then(() => {
|
|
3811
4043
|
set((state) => {
|
|
3812
4044
|
const key = slot ?? "";
|
|
3813
4045
|
const { [key]: _, ...rest } = state.knownSaves;
|
|
@@ -3815,7 +4047,8 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3815
4047
|
});
|
|
3816
4048
|
}).catch((err) => {
|
|
3817
4049
|
console.error("spindle: failed to delete save", err);
|
|
3818
|
-
|
|
4050
|
+
throw err;
|
|
4051
|
+
}));
|
|
3819
4052
|
},
|
|
3820
4053
|
exportSave: async (slot) => {
|
|
3821
4054
|
const { storyData } = get();
|
|
@@ -4739,10 +4972,10 @@ function createStoryAPI() {
|
|
|
4739
4972
|
useStoryStore.getState().restart();
|
|
4740
4973
|
},
|
|
4741
4974
|
save(slot, custom) {
|
|
4742
|
-
useStoryStore.getState().save(slot, custom);
|
|
4975
|
+
return useStoryStore.getState().save(slot, custom);
|
|
4743
4976
|
},
|
|
4744
4977
|
load(slot) {
|
|
4745
|
-
useStoryStore.getState().load(slot);
|
|
4978
|
+
return useStoryStore.getState().load(slot);
|
|
4746
4979
|
},
|
|
4747
4980
|
hasSave(slot) {
|
|
4748
4981
|
return useStoryStore.getState().hasSave(slot);
|
|
@@ -4754,7 +4987,7 @@ function createStoryAPI() {
|
|
|
4754
4987
|
return useStoryStore.getState().listSaves();
|
|
4755
4988
|
},
|
|
4756
4989
|
deleteSave(slot) {
|
|
4757
|
-
useStoryStore.getState().deleteSave(slot);
|
|
4990
|
+
return useStoryStore.getState().deleteSave(slot);
|
|
4758
4991
|
},
|
|
4759
4992
|
exportSave(slot) {
|
|
4760
4993
|
return useStoryStore.getState().exportSave(slot);
|
|
@@ -5023,6 +5256,12 @@ var VALID_VAR_TYPES = new Set([
|
|
|
5023
5256
|
"string",
|
|
5024
5257
|
"boolean"
|
|
5025
5258
|
]);
|
|
5259
|
+
/** Boxed sample values whose members a primitive of each type can access. */
|
|
5260
|
+
var PRIMITIVE_SAMPLES = {
|
|
5261
|
+
string: Object(""),
|
|
5262
|
+
number: Object(0),
|
|
5263
|
+
boolean: Object(false)
|
|
5264
|
+
};
|
|
5026
5265
|
function inferSchema(value) {
|
|
5027
5266
|
if (Array.isArray(value)) return { type: "array" };
|
|
5028
5267
|
if (value !== null && typeof value === "object") {
|
|
@@ -5100,6 +5339,13 @@ function validateRef(ref, schema, forLocals) {
|
|
|
5100
5339
|
for (let i = 1; i < parts.length; i++) {
|
|
5101
5340
|
const part = parts[i];
|
|
5102
5341
|
if (current.type === "array") return null;
|
|
5342
|
+
const sample = PRIMITIVE_SAMPLES[current.type];
|
|
5343
|
+
if (sample && part in sample) {
|
|
5344
|
+
const memberType = typeof sample[part];
|
|
5345
|
+
if (!VALID_VAR_TYPES.has(memberType)) return null;
|
|
5346
|
+
current = { type: memberType };
|
|
5347
|
+
continue;
|
|
5348
|
+
}
|
|
5103
5349
|
if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
|
|
5104
5350
|
const fieldSchema = current.fields.get(part);
|
|
5105
5351
|
if (!fieldSchema) return null;
|
|
@@ -5364,15 +5610,6 @@ function parseComputedArgs(rawArgs) {
|
|
|
5364
5610
|
}
|
|
5365
5611
|
throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
|
|
5366
5612
|
}
|
|
5367
|
-
function valuesEqual(a, b) {
|
|
5368
|
-
if (Object.is(a, b)) return true;
|
|
5369
|
-
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) try {
|
|
5370
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
5371
|
-
} catch {
|
|
5372
|
-
return false;
|
|
5373
|
-
}
|
|
5374
|
-
return false;
|
|
5375
|
-
}
|
|
5376
5613
|
function computeAndApply(expr, name, isTemp, isLocal, variables, temporary, locals, transient, rawArgs, prevRef, localsUpdate) {
|
|
5377
5614
|
let newValue;
|
|
5378
5615
|
try {
|
|
@@ -5381,7 +5618,7 @@ function computeAndApply(expr, name, isTemp, isLocal, variables, temporary, loca
|
|
|
5381
5618
|
console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
|
|
5382
5619
|
return;
|
|
5383
5620
|
}
|
|
5384
|
-
if (!
|
|
5621
|
+
if (!deepEqual(prevRef.current, newValue)) {
|
|
5385
5622
|
prevRef.current = newValue;
|
|
5386
5623
|
if (isLocal) try {
|
|
5387
5624
|
localsUpdate(name, newValue);
|
|
@@ -5730,9 +5967,9 @@ defineMacro({
|
|
|
5730
5967
|
//#region src/components/macros/MacroLink.tsx
|
|
5731
5968
|
function parseArgs(rawArgs) {
|
|
5732
5969
|
const parts = [];
|
|
5733
|
-
const re = /(["'])(
|
|
5970
|
+
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
5734
5971
|
let m;
|
|
5735
|
-
while ((m = re.exec(rawArgs)) !== null) parts.push(m[2]);
|
|
5972
|
+
while ((m = re.exec(rawArgs)) !== null) parts.push(m[2].replace(/\\(["'\\])/g, "$1"));
|
|
5736
5973
|
if (parts.length >= 2) return {
|
|
5737
5974
|
display: parts[0],
|
|
5738
5975
|
passage: parts[1]
|
|
@@ -5990,7 +6227,9 @@ defineMacro({
|
|
|
5990
6227
|
const childrenKey = JSON.stringify(children);
|
|
5991
6228
|
const paramsKey = params.join(",");
|
|
5992
6229
|
ctx.hooks.useLayoutEffect(() => {
|
|
5993
|
-
|
|
6230
|
+
const isBlock = astContainsChildren(children);
|
|
6231
|
+
registerWidget(name, children, params, isBlock);
|
|
6232
|
+
if (isBlock) registerBlockMacro(name);
|
|
5994
6233
|
}, [
|
|
5995
6234
|
name,
|
|
5996
6235
|
childrenKey,
|
|
@@ -6256,6 +6495,10 @@ function SaveManagerContent() {
|
|
|
6256
6495
|
if (!confirm("Delete this save?")) return;
|
|
6257
6496
|
try {
|
|
6258
6497
|
await deleteSaveById(saveId);
|
|
6498
|
+
const known = await populateKnownSaves(ifid);
|
|
6499
|
+
useStoryStore.setState((state) => {
|
|
6500
|
+
state.knownSaves = known;
|
|
6501
|
+
});
|
|
6259
6502
|
showStatus("Save deleted");
|
|
6260
6503
|
await refresh();
|
|
6261
6504
|
} catch {
|
|
@@ -6528,14 +6771,63 @@ defineMacro({
|
|
|
6528
6771
|
});
|
|
6529
6772
|
//#endregion
|
|
6530
6773
|
//#region src/components/macros/Include.tsx
|
|
6774
|
+
var FLAG = "inline";
|
|
6775
|
+
/**
|
|
6776
|
+
* Split a standalone `inline` flag off the start or end of the include
|
|
6777
|
+
* arguments. The flag only counts outside quotes, brackets, parentheses and
|
|
6778
|
+
* braces, separated by whitespace from the target expression, so passage
|
|
6779
|
+
* names and expressions containing the word stay intact (#201).
|
|
6780
|
+
*/
|
|
6781
|
+
function parseIncludeArgs(rawArgs) {
|
|
6782
|
+
const trimmed = rawArgs.trim();
|
|
6783
|
+
let first = null;
|
|
6784
|
+
let last = null;
|
|
6785
|
+
let depth = 0;
|
|
6786
|
+
let inString = null;
|
|
6787
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
6788
|
+
const ch = trimmed[i];
|
|
6789
|
+
if (inString) {
|
|
6790
|
+
if (ch === "\\") i++;
|
|
6791
|
+
else if (ch === inString) inString = null;
|
|
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
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
if (last && last[1] === trimmed.length - 6 && trimmed.endsWith(FLAG)) {
|
|
6806
|
+
const rest = trimmed.slice(0, last[0]);
|
|
6807
|
+
if (!/[-+*/%&|^!=<>?:,.]$/.test(rest)) return {
|
|
6808
|
+
nameExpr: rest,
|
|
6809
|
+
inline: true
|
|
6810
|
+
};
|
|
6811
|
+
}
|
|
6812
|
+
if (first && first[0] === 6 && trimmed.startsWith(FLAG)) {
|
|
6813
|
+
const rest = trimmed.slice(first[1]);
|
|
6814
|
+
if (!/^[-+*/%&|^=<>?:,.]/.test(rest)) return {
|
|
6815
|
+
nameExpr: rest,
|
|
6816
|
+
inline: true
|
|
6817
|
+
};
|
|
6818
|
+
}
|
|
6819
|
+
return {
|
|
6820
|
+
nameExpr: trimmed,
|
|
6821
|
+
inline: false
|
|
6822
|
+
};
|
|
6823
|
+
}
|
|
6531
6824
|
defineMacro({
|
|
6532
6825
|
name: "include",
|
|
6533
6826
|
interpolate: true,
|
|
6534
6827
|
merged: true,
|
|
6535
6828
|
render({ rawArgs }, ctx) {
|
|
6536
6829
|
const storyData = useStoryStore((s) => s.storyData);
|
|
6537
|
-
const inline =
|
|
6538
|
-
const nameExpr = rawArgs.replace(/\binline\b/, "").trim();
|
|
6830
|
+
const { nameExpr, inline } = parseIncludeArgs(rawArgs);
|
|
6539
6831
|
let passageName;
|
|
6540
6832
|
try {
|
|
6541
6833
|
const result = ctx.evaluate(nameExpr);
|
|
@@ -7306,12 +7598,8 @@ function boot() {
|
|
|
7306
7598
|
setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
|
|
7307
7599
|
useStoryStore.getState().init(storyData, defaults, transientDefaults);
|
|
7308
7600
|
enterRuntimePhase();
|
|
7309
|
-
executeStoryInit();
|
|
7310
|
-
const sessionPayload = loadSession(storyData.ifid);
|
|
7311
|
-
if (sessionPayload) useStoryStore.getState().loadFromPayload(sessionPayload);
|
|
7312
|
-
emit("storyinit");
|
|
7313
7601
|
const blockWidgetPattern = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
|
|
7314
|
-
for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
|
|
7602
|
+
for (const [passageName, passage] of storyData.passages) if (passageName === "StoryInit" || passage.tags.includes("widget")) {
|
|
7315
7603
|
let match;
|
|
7316
7604
|
while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
|
|
7317
7605
|
const name = match[1];
|
|
@@ -7320,6 +7608,7 @@ function boot() {
|
|
|
7320
7608
|
}
|
|
7321
7609
|
blockWidgetPattern.lastIndex = 0;
|
|
7322
7610
|
}
|
|
7611
|
+
initializeStory(loadSession(storyData.ifid));
|
|
7323
7612
|
for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
|
|
7324
7613
|
const widgetAST = buildAST(tokenize(passage.content));
|
|
7325
7614
|
for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
|