@rohal12/spindle 0.51.0 → 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 +393 -117
- 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 +9 -4
- package/src/story-init.ts +23 -0
- package/src/story-variables.ts +18 -0
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();
|
|
@@ -3756,6 +3975,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3756
3975
|
executeStoryInit();
|
|
3757
3976
|
clearSession(storyData.ifid);
|
|
3758
3977
|
emit("storyinit");
|
|
3978
|
+
recordStoryInitState();
|
|
3759
3979
|
startNewPlaythrough(storyData.ifid).then((newId) => {
|
|
3760
3980
|
set((state) => {
|
|
3761
3981
|
state.playthroughId = newId;
|
|
@@ -3804,7 +4024,7 @@ var useStoryStore = create()(immer((set, get) => ({
|
|
|
3804
4024
|
hasSave: (slot) => {
|
|
3805
4025
|
const { storyData, knownSaves } = get();
|
|
3806
4026
|
if (!storyData) return false;
|
|
3807
|
-
return (slot ?? "")
|
|
4027
|
+
return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
|
|
3808
4028
|
},
|
|
3809
4029
|
getSaveInfo: async (slot) => {
|
|
3810
4030
|
const { storyData } = get();
|
|
@@ -5036,6 +5256,12 @@ var VALID_VAR_TYPES = new Set([
|
|
|
5036
5256
|
"string",
|
|
5037
5257
|
"boolean"
|
|
5038
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
|
+
};
|
|
5039
5265
|
function inferSchema(value) {
|
|
5040
5266
|
if (Array.isArray(value)) return { type: "array" };
|
|
5041
5267
|
if (value !== null && typeof value === "object") {
|
|
@@ -5113,6 +5339,13 @@ function validateRef(ref, schema, forLocals) {
|
|
|
5113
5339
|
for (let i = 1; i < parts.length; i++) {
|
|
5114
5340
|
const part = parts[i];
|
|
5115
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
|
+
}
|
|
5116
5349
|
if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
|
|
5117
5350
|
const fieldSchema = current.fields.get(part);
|
|
5118
5351
|
if (!fieldSchema) return null;
|
|
@@ -5377,15 +5610,6 @@ function parseComputedArgs(rawArgs) {
|
|
|
5377
5610
|
}
|
|
5378
5611
|
throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
|
|
5379
5612
|
}
|
|
5380
|
-
function valuesEqual(a, b) {
|
|
5381
|
-
if (Object.is(a, b)) return true;
|
|
5382
|
-
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) try {
|
|
5383
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
5384
|
-
} catch {
|
|
5385
|
-
return false;
|
|
5386
|
-
}
|
|
5387
|
-
return false;
|
|
5388
|
-
}
|
|
5389
5613
|
function computeAndApply(expr, name, isTemp, isLocal, variables, temporary, locals, transient, rawArgs, prevRef, localsUpdate) {
|
|
5390
5614
|
let newValue;
|
|
5391
5615
|
try {
|
|
@@ -5394,7 +5618,7 @@ function computeAndApply(expr, name, isTemp, isLocal, variables, temporary, loca
|
|
|
5394
5618
|
console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
|
|
5395
5619
|
return;
|
|
5396
5620
|
}
|
|
5397
|
-
if (!
|
|
5621
|
+
if (!deepEqual(prevRef.current, newValue)) {
|
|
5398
5622
|
prevRef.current = newValue;
|
|
5399
5623
|
if (isLocal) try {
|
|
5400
5624
|
localsUpdate(name, newValue);
|
|
@@ -5743,9 +5967,9 @@ defineMacro({
|
|
|
5743
5967
|
//#region src/components/macros/MacroLink.tsx
|
|
5744
5968
|
function parseArgs(rawArgs) {
|
|
5745
5969
|
const parts = [];
|
|
5746
|
-
const re = /(["'])(
|
|
5970
|
+
const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
|
|
5747
5971
|
let m;
|
|
5748
|
-
while ((m = re.exec(rawArgs)) !== null) parts.push(m[2]);
|
|
5972
|
+
while ((m = re.exec(rawArgs)) !== null) parts.push(m[2].replace(/\\(["'\\])/g, "$1"));
|
|
5749
5973
|
if (parts.length >= 2) return {
|
|
5750
5974
|
display: parts[0],
|
|
5751
5975
|
passage: parts[1]
|
|
@@ -6003,7 +6227,9 @@ defineMacro({
|
|
|
6003
6227
|
const childrenKey = JSON.stringify(children);
|
|
6004
6228
|
const paramsKey = params.join(",");
|
|
6005
6229
|
ctx.hooks.useLayoutEffect(() => {
|
|
6006
|
-
|
|
6230
|
+
const isBlock = astContainsChildren(children);
|
|
6231
|
+
registerWidget(name, children, params, isBlock);
|
|
6232
|
+
if (isBlock) registerBlockMacro(name);
|
|
6007
6233
|
}, [
|
|
6008
6234
|
name,
|
|
6009
6235
|
childrenKey,
|
|
@@ -6269,6 +6495,10 @@ function SaveManagerContent() {
|
|
|
6269
6495
|
if (!confirm("Delete this save?")) return;
|
|
6270
6496
|
try {
|
|
6271
6497
|
await deleteSaveById(saveId);
|
|
6498
|
+
const known = await populateKnownSaves(ifid);
|
|
6499
|
+
useStoryStore.setState((state) => {
|
|
6500
|
+
state.knownSaves = known;
|
|
6501
|
+
});
|
|
6272
6502
|
showStatus("Save deleted");
|
|
6273
6503
|
await refresh();
|
|
6274
6504
|
} catch {
|
|
@@ -6541,14 +6771,63 @@ defineMacro({
|
|
|
6541
6771
|
});
|
|
6542
6772
|
//#endregion
|
|
6543
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
|
+
}
|
|
6544
6824
|
defineMacro({
|
|
6545
6825
|
name: "include",
|
|
6546
6826
|
interpolate: true,
|
|
6547
6827
|
merged: true,
|
|
6548
6828
|
render({ rawArgs }, ctx) {
|
|
6549
6829
|
const storyData = useStoryStore((s) => s.storyData);
|
|
6550
|
-
const inline =
|
|
6551
|
-
const nameExpr = rawArgs.replace(/\binline\b/, "").trim();
|
|
6830
|
+
const { nameExpr, inline } = parseIncludeArgs(rawArgs);
|
|
6552
6831
|
let passageName;
|
|
6553
6832
|
try {
|
|
6554
6833
|
const result = ctx.evaluate(nameExpr);
|
|
@@ -7319,12 +7598,8 @@ function boot() {
|
|
|
7319
7598
|
setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
|
|
7320
7599
|
useStoryStore.getState().init(storyData, defaults, transientDefaults);
|
|
7321
7600
|
enterRuntimePhase();
|
|
7322
|
-
executeStoryInit();
|
|
7323
|
-
const sessionPayload = loadSession(storyData.ifid);
|
|
7324
|
-
if (sessionPayload) useStoryStore.getState().loadFromPayload(sessionPayload);
|
|
7325
|
-
emit("storyinit");
|
|
7326
7601
|
const blockWidgetPattern = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
|
|
7327
|
-
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")) {
|
|
7328
7603
|
let match;
|
|
7329
7604
|
while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
|
|
7330
7605
|
const name = match[1];
|
|
@@ -7333,6 +7608,7 @@ function boot() {
|
|
|
7333
7608
|
}
|
|
7334
7609
|
blockWidgetPattern.lastIndex = 0;
|
|
7335
7610
|
}
|
|
7611
|
+
initializeStory(loadSession(storyData.ifid));
|
|
7336
7612
|
for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
|
|
7337
7613
|
const widgetAST = buildAST(tokenize(passage.content));
|
|
7338
7614
|
for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {
|