@rohal12/spindle 0.51.3 → 0.51.4

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