@rohal12/spindle 0.51.0 → 0.51.2

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.
@@ -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 { create } from "zustand";
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
- const payload = save.payload;
27
- if (typeof payload.passage !== "string") return false;
28
- if (!Array.isArray(payload.history) || payload.history.length === 0) return false;
29
- if (typeof payload.historyIndex !== "number") return false;
30
- if (typeof payload.variables !== "object" || payload.variables === null) return false;
31
- return true;
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: `"${token.display}" "${token.target}"`,
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"], %var → transient["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
- var TRANS_RE = /(?<!\w)%(\w+)/g;
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\"]").replace(TRANS_RE, "transient[\"$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 || typeof value !== "object") return void 0;
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 || typeof value !== "object") {
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 || !rawArgs) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
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 || typeof value !== "object") return "";
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
- async function getBackend() {
2358
- if (_backend) return _backend;
2359
- _backend = await detectBackend();
2360
- return _backend;
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
- emit("beforesave", slot, custom);
2634
- return write(getPayload()).then((result) => {
2635
- emit("aftersave", slot);
2636
- return result;
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
- await (await getBackend()).deleteSave(saveId);
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
- async function quickSave(ifid, playthroughId, payload, slot, custom) {
2817
- const backend = await getBackend();
3042
+ function quickSave(ifid, playthroughId, payload, slot, custom) {
2818
3043
  const metaKey = slotMetaKey(ifid, slot);
2819
- const existingId = await backend.getMeta(metaKey);
2820
- if (existingId) {
2821
- const updated = await overwriteSave(existingId, payload, custom);
2822
- if (updated) return updated;
2823
- }
2824
- const record = await createSave(ifid, playthroughId, payload, {
2825
- isAutosave: !isNamedSlot(slot),
2826
- ...isNamedSlot(slot) ? { slot } : {},
2827
- ...custom
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
- async function deleteSlotSave(ifid, slot) {
2886
- const backend = await getBackend();
3113
+ function deleteSlotSave(ifid, slot) {
2887
3114
  const metaKey = slotMetaKey(ifid, slot);
2888
- const existingId = await backend.getMeta(metaKey);
2889
- if (!existingId) return;
2890
- await backend.deleteSave(existingId);
2891
- await backend.deleteMeta(metaKey);
2892
- await removeFromSlotIndex(ifid, slot);
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
- async function importSlotSave(data, ifid, slot) {
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
- const previousId = await backend.getMeta(metaKey);
2984
- await backend.putSave(record);
2985
- await backend.setMeta(metaKey, record.meta.id);
2986
- await addToSlotIndex(ifid, slot);
2987
- if (previousId) await backend.deleteSave(previousId);
2988
- return toSaveInfo(record, slot);
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
- * Structural equality for a deepClone() copy against its source. Lets
3082
- * executeMutation write back only the variables the code actually changed,
3083
- * so untouched clones don't overwrite updates made during execution
3084
- * (e.g. Story.set called from inside {do}).
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 deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
3087
- if (Object.is(a, b)) return true;
3088
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
3089
- if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
3090
- if (seen.get(a) === b) return true;
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 = { ...mergedLocals };
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] !== mergedLocals[key]) scopeUpdate(key, 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; init()/restart() record the
3436
- * start moment before StoryInit runs. A no-op once the story has moved on.
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 ?? "") in knownSaves;
4027
+ return Object.prototype.hasOwnProperty.call(knownSaves, slot ?? "");
3808
4028
  },
3809
4029
  getSaveInfo: async (slot) => {
3810
4030
  const { storyData } = get();
@@ -4605,16 +4825,17 @@ function defineMacro(config, source = "builtin") {
4605
4825
  //#endregion
4606
4826
  //#region src/passage-render-state.ts
4607
4827
  /**
4608
- * Name of the passage most recently mounted by <Passage> (null until the
4609
- * first render, e.g. when the story interface has no {passage}).
4610
- * Lets Story.waitForActions() tell whether the current passage is on screen.
4828
+ * Navigation id (the store's `navigationId`) of the passage most recently
4829
+ * mounted by <Passage> (null until the first render, e.g. when the story
4830
+ * interface has no {passage}). Lets Story.waitForActions() tell whether the
4831
+ * current navigation is on screen, also when it revisits the passage shown.
4611
4832
  */
4612
- var renderedPassage = null;
4613
- function markPassageRendered(name) {
4614
- renderedPassage = name;
4833
+ var renderedNavigationId = null;
4834
+ function markPassageRendered(navigationId) {
4835
+ renderedNavigationId = navigationId;
4615
4836
  }
4616
- function getRenderedPassage() {
4617
- return renderedPassage;
4837
+ function getRenderedNavigationId() {
4838
+ return renderedNavigationId;
4618
4839
  }
4619
4840
  //#endregion
4620
4841
  //#region src/story-api.ts
@@ -4908,11 +5129,10 @@ function createStoryAPI() {
4908
5129
  await new Promise((resolve) => {
4909
5130
  requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
4910
5131
  });
4911
- const { currentPassage, renderDeferred } = useStoryStore.getState();
4912
- const rendered = getRenderedPassage();
4913
- if (rendered !== null && rendered !== currentPassage && !renderDeferred) await new Promise((resolve) => {
4914
- const off = on("passagerender", (name) => {
4915
- if (name === useStoryStore.getState().currentPassage) {
5132
+ const isRendered = () => getRenderedNavigationId() === useStoryStore.getState().navigationId;
5133
+ if (getRenderedNavigationId() !== null && !isRendered() && !useStoryStore.getState().renderDeferred) await new Promise((resolve) => {
5134
+ const off = on("passagerender", () => {
5135
+ if (isRendered()) {
4916
5136
  off();
4917
5137
  resolve();
4918
5138
  }
@@ -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 (!valuesEqual(prevRef.current, newValue)) {
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 = /(["'])(.*?)\1/g;
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
- registerWidget(name, children, params);
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 = /\binline\b/.test(rawArgs);
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);
@@ -6873,7 +7152,7 @@ var CODE_PASSAGES = new Set([
6873
7152
  "PassageFooter",
6874
7153
  "PassageDone"
6875
7154
  ]);
6876
- function Passage({ passage, dataTransition }) {
7155
+ function Passage({ passage, dataTransition, navigationId }) {
6877
7156
  const storyData = useStoryStore((s) => s.storyData);
6878
7157
  const isCodePassage = CODE_PASSAGES.has(passage.name);
6879
7158
  const [doneReady, setDoneReady] = useState(false);
@@ -6931,9 +7210,9 @@ function Passage({ passage, dataTransition }) {
6931
7210
  }, [doneReady, donePassage?.content]);
6932
7211
  const elRef = useRef(null);
6933
7212
  useLayoutEffect(() => {
6934
- markPassageRendered(passage.name);
7213
+ if (navigationId !== void 0) markPassageRendered(navigationId);
6935
7214
  if (elRef.current) emitFromRender("passagerender", passage.name, elRef.current);
6936
- }, [passage.name]);
7215
+ }, [passage.name, navigationId]);
6937
7216
  const nobr = passage.tags.includes("nobr");
6938
7217
  const inner = /* @__PURE__ */ jsxs("div", {
6939
7218
  ref: elRef,
@@ -7179,7 +7458,8 @@ defineMacro({
7179
7458
  ref: containerRef,
7180
7459
  children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
7181
7460
  passage: effectivePassage,
7182
- dataTransition: renderDeferred ? "none" : resolvedTypeRef.current
7461
+ dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
7462
+ navigationId: renderDeferred ? void 0 : displayed.id
7183
7463
  }, renderDeferred ? "loading" : `nav-${displayed.id}`)
7184
7464
  })]
7185
7465
  });
@@ -7319,12 +7599,8 @@ function boot() {
7319
7599
  setDeclaredVariables(Object.keys(defaults), Object.keys(transientDefaults));
7320
7600
  useStoryStore.getState().init(storyData, defaults, transientDefaults);
7321
7601
  enterRuntimePhase();
7322
- executeStoryInit();
7323
- const sessionPayload = loadSession(storyData.ifid);
7324
- if (sessionPayload) useStoryStore.getState().loadFromPayload(sessionPayload);
7325
- emit("storyinit");
7326
7602
  const blockWidgetPattern = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
7327
- for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
7603
+ for (const [passageName, passage] of storyData.passages) if (passageName === "StoryInit" || passage.tags.includes("widget")) {
7328
7604
  let match;
7329
7605
  while ((match = blockWidgetPattern.exec(passage.content)) !== null) {
7330
7606
  const name = match[1];
@@ -7333,6 +7609,7 @@ function boot() {
7333
7609
  }
7334
7610
  blockWidgetPattern.lastIndex = 0;
7335
7611
  }
7612
+ initializeStory(loadSession(storyData.ifid));
7336
7613
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
7337
7614
  const widgetAST = buildAST(tokenize(passage.content));
7338
7615
  for (const node of widgetAST) if (node.type === "macro" && node.name === "widget" && node.rawArgs) {