@rohal12/spindle 0.51.2 → 0.51.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);
@@ -2453,264 +2832,76 @@ function createIDBBackend() {
2453
2832
  await idbReq((await tx("meta", "readwrite")).delete(key));
2454
2833
  },
2455
2834
  async deleteMetaByPrefix(prefix) {
2456
- const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
2457
- for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
2458
- },
2459
- async deleteMetaByIfid(ifid) {
2460
- const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
2461
- for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
2462
- },
2463
- async getAllMetaKeys() {
2464
- return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
2465
- },
2466
- async destroy() {
2467
- (await openDB()).close();
2468
- dbPromise = null;
2469
- await new Promise((resolve, reject) => {
2470
- const req = indexedDB.deleteDatabase(IDB_DB_NAME);
2471
- req.onsuccess = () => resolve();
2472
- req.onerror = () => reject(req.error);
2473
- });
2474
- }
2475
- };
2476
- }
2477
- var _backend = null;
2478
- async function detectBackend() {
2479
- if (typeof indexedDB !== "undefined") try {
2480
- await new Promise((resolve, reject) => {
2481
- const req = indexedDB.open("__spindle_probe__", 1);
2482
- req.onsuccess = () => {
2483
- req.result.close();
2484
- indexedDB.deleteDatabase("__spindle_probe__");
2485
- resolve();
2486
- };
2487
- req.onerror = () => reject(req.error);
2488
- });
2489
- return createIDBBackend();
2490
- } catch {}
2491
- if (typeof localStorage !== "undefined") try {
2492
- const probeKey = "__spindle_probe__";
2493
- localStorage.setItem(probeKey, "1");
2494
- const val = localStorage.getItem(probeKey);
2495
- localStorage.removeItem(probeKey);
2496
- if (val === "1") return createLocalStorageBackend();
2497
- } catch {}
2498
- return createMemoryBackend();
2499
- }
2500
- /**
2501
- * Detection in flight. Concurrent first callers share it so they all get the
2502
- * same backend (the memory fallback makes a new, empty store per detection).
2503
- */
2504
- var _backendPromise = null;
2505
- function getBackend() {
2506
- if (_backend) return Promise.resolve(_backend);
2507
- if (!_backendPromise) {
2508
- const pending = detectBackend().then((backend) => {
2509
- if (_backendPromise === pending) _backend = backend;
2510
- return backend;
2511
- });
2512
- _backendPromise = pending;
2513
- }
2514
- return _backendPromise;
2515
- }
2516
- function resetBackend() {
2517
- _backend = null;
2518
- _backendPromise = null;
2519
- }
2520
- /**
2521
- * Synchronous accessor for the current backend type.
2522
- * Returns 'memory' if the backend hasn't been detected yet.
2523
- */
2524
- function getBackendType() {
2525
- return _backend?.type ?? "memory";
2526
- }
2527
- //#endregion
2528
- //#region src/class-registry.ts
2529
- var registry = /* @__PURE__ */ new Map();
2530
- var ctorToName = /* @__PURE__ */ new Map();
2531
- function registerClass(name, ctor) {
2532
- registry.set(name, ctor);
2533
- ctorToName.set(ctor, name);
2534
- }
2535
- function isPlainObject(value) {
2536
- if (typeof value !== "object" || value === null) return false;
2537
- const proto = Object.getPrototypeOf(value);
2538
- return proto === Object.prototype || proto === null;
2539
- }
2540
- function deepClone(value, options = {}) {
2541
- const seen = /* @__PURE__ */ new Map();
2542
- function clone(val) {
2543
- if (val === null || typeof val !== "object") return val;
2544
- const obj = val;
2545
- if (seen.has(obj)) return seen.get(obj);
2546
- if (val instanceof Date) return new Date(val.getTime());
2547
- if (val instanceof RegExp) return new RegExp(val.source, val.flags);
2548
- if (Array.isArray(val)) {
2549
- const arr = [];
2550
- seen.set(obj, arr);
2551
- for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
2552
- return arr;
2553
- }
2554
- if (val instanceof Map) {
2555
- const copy = /* @__PURE__ */ new Map();
2556
- seen.set(obj, copy);
2557
- for (const [k, v] of val) copy.set(clone(k), clone(v));
2558
- return copy;
2559
- }
2560
- if (val instanceof Set) {
2561
- const copy = /* @__PURE__ */ new Set();
2562
- seen.set(obj, copy);
2563
- for (const v of val) copy.add(clone(v));
2564
- return copy;
2565
- }
2566
- const ctor = obj.constructor;
2567
- if (ctorToName.get(ctor) !== void 0) {
2568
- const copy = Object.create(Object.getPrototypeOf(obj));
2569
- seen.set(obj, copy);
2570
- for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
2571
- return copy;
2572
- }
2573
- if (!isPlainObject(val) && options.keepUnregistered) return val;
2574
- if (isPlainObject(val) || typeof val === "object") {
2575
- const copy = {};
2576
- seen.set(obj, copy);
2577
- for (const key of Object.keys(obj)) copy[key] = clone(obj[key]);
2578
- return copy;
2579
- }
2580
- return val;
2581
- }
2582
- return clone(value);
2583
- }
2584
- /**
2585
- * Structural equality over the value types deepClone() supports: primitives,
2586
- * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
2587
- * at any depth). Map and Set entries are compared in insertion order.
2588
- */
2589
- function deepEqual(a, b, seen = /* @__PURE__ */ new Map()) {
2590
- if (Object.is(a, b)) return true;
2591
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
2592
- if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
2593
- if (seen.get(a) === b) return true;
2594
- seen.set(a, b);
2595
- if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
2596
- if (a instanceof RegExp) return String(a) === String(b);
2597
- if (a instanceof Map || a instanceof Set) {
2598
- const bc = b;
2599
- if (a.size !== bc.size) return false;
2600
- const ai = a.entries();
2601
- const bi = bc.entries();
2602
- for (let x = ai.next(), y = bi.next(); !x.done; x = ai.next(), y = bi.next()) if (!deepEqual(x.value, y.value, seen)) return false;
2603
- return true;
2604
- }
2605
- const ao = a;
2606
- const bo = b;
2607
- const keys = Object.keys(ao);
2608
- if (keys.length !== Object.keys(bo).length) return false;
2609
- for (const key of keys) if (!(key in bo) || !deepEqual(ao[key], bo[key], seen)) return false;
2610
- return true;
2611
- }
2612
- var CLASS_TAG = "__spindle_class__";
2613
- var DATA_TAG = "__spindle_data__";
2614
- function serialize(value) {
2615
- const seen = /* @__PURE__ */ new Set();
2616
- function ser(val) {
2617
- if (val === null || typeof val !== "object") return val;
2618
- const obj = val;
2619
- if (seen.has(obj)) throw new Error("spindle: Cannot serialize circular references");
2620
- seen.add(obj);
2621
- if (val instanceof Date) {
2622
- seen.delete(obj);
2623
- return {
2624
- [CLASS_TAG]: "__Date__",
2625
- [DATA_TAG]: { iso: val.toISOString() }
2626
- };
2627
- }
2628
- if (val instanceof RegExp) {
2629
- seen.delete(obj);
2630
- return {
2631
- [CLASS_TAG]: "__RegExp__",
2632
- [DATA_TAG]: {
2633
- source: val.source,
2634
- flags: val.flags
2635
- }
2636
- };
2637
- }
2638
- if (Array.isArray(val)) {
2639
- const result = val.map((item) => ser(item));
2640
- seen.delete(obj);
2641
- return result;
2642
- }
2643
- if (val instanceof Map) {
2644
- const entries = [...val].map(([k, v]) => [ser(k), ser(v)]);
2645
- seen.delete(obj);
2646
- return {
2647
- [CLASS_TAG]: "__Map__",
2648
- [DATA_TAG]: { entries }
2649
- };
2650
- }
2651
- if (val instanceof Set) {
2652
- const entries = [...val].map((v) => ser(v));
2653
- seen.delete(obj);
2654
- return {
2655
- [CLASS_TAG]: "__Set__",
2656
- [DATA_TAG]: { entries }
2657
- };
2658
- }
2659
- const ctor = obj.constructor;
2660
- const name = ctorToName.get(ctor);
2661
- if (name !== void 0) {
2662
- const data = {};
2663
- for (const key of Object.keys(obj)) data[key] = ser(obj[key]);
2664
- seen.delete(obj);
2665
- return {
2666
- [CLASS_TAG]: name,
2667
- [DATA_TAG]: data
2668
- };
2669
- }
2670
- const result = {};
2671
- for (const key of Object.keys(obj)) result[key] = ser(obj[key]);
2672
- seen.delete(obj);
2673
- return result;
2674
- }
2675
- return ser(value);
2676
- }
2677
- function deserialize(value) {
2678
- function deser(val) {
2679
- if (val === null || typeof val !== "object") return val;
2680
- if (Array.isArray(val)) return val.map((item) => deser(item));
2681
- const obj = val;
2682
- if (CLASS_TAG in obj && DATA_TAG in obj) {
2683
- const name = obj[CLASS_TAG];
2684
- const data = obj[DATA_TAG];
2685
- if (name === "__Date__") return new Date(data.iso);
2686
- if (name === "__RegExp__") return new RegExp(data.source, data.flags);
2687
- if (name === "__Map__") {
2688
- const entries = data.entries;
2689
- return new Map(entries.map(([k, v]) => [deser(k), deser(v)]));
2690
- }
2691
- if (name === "__Set__") {
2692
- const entries = data.entries;
2693
- return new Set(entries.map((v) => deser(v)));
2694
- }
2695
- const ctor = registry.get(name);
2696
- if (!ctor) {
2697
- console.warn(`spindle: Class "${name}" not registered. Falling back to plain object.`);
2698
- const plain = {};
2699
- for (const key of Object.keys(data)) plain[key] = deser(data[key]);
2700
- return plain;
2701
- }
2702
- const instance = Object.create(ctor.prototype);
2703
- for (const key of Object.keys(data)) instance[key] = deser(data[key]);
2704
- return instance;
2835
+ const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
2836
+ for (const row of allRows) if (row.key.startsWith(prefix)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
2837
+ },
2838
+ async deleteMetaByIfid(ifid) {
2839
+ const allRows = await idbReq((await tx("meta", "readwrite")).getAll());
2840
+ for (const row of allRows) if (row.key.includes(ifid)) await idbReq((await tx("meta", "readwrite")).delete(row.key));
2841
+ },
2842
+ async getAllMetaKeys() {
2843
+ return (await idbReq((await tx("meta", "readonly")).getAll())).map((r) => r.key);
2844
+ },
2845
+ async destroy() {
2846
+ (await openDB()).close();
2847
+ dbPromise = null;
2848
+ await new Promise((resolve, reject) => {
2849
+ const req = indexedDB.deleteDatabase(IDB_DB_NAME);
2850
+ req.onsuccess = () => resolve();
2851
+ req.onerror = () => reject(req.error);
2852
+ });
2705
2853
  }
2706
- 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;
2854
+ };
2855
+ }
2856
+ var _backend = null;
2857
+ async function detectBackend() {
2858
+ if (typeof indexedDB !== "undefined") try {
2859
+ await new Promise((resolve, reject) => {
2860
+ const req = indexedDB.open("__spindle_probe__", 1);
2861
+ req.onsuccess = () => {
2862
+ req.result.close();
2863
+ indexedDB.deleteDatabase("__spindle_probe__");
2864
+ resolve();
2865
+ };
2866
+ req.onerror = () => reject(req.error);
2867
+ });
2868
+ return createIDBBackend();
2869
+ } catch {}
2870
+ if (typeof localStorage !== "undefined") try {
2871
+ const probeKey = "__spindle_probe__";
2872
+ localStorage.setItem(probeKey, "1");
2873
+ const val = localStorage.getItem(probeKey);
2874
+ localStorage.removeItem(probeKey);
2875
+ if (val === "1") return createLocalStorageBackend();
2876
+ } catch {}
2877
+ return createMemoryBackend();
2878
+ }
2879
+ /**
2880
+ * Detection in flight. Concurrent first callers share it so they all get the
2881
+ * same backend (the memory fallback makes a new, empty store per detection).
2882
+ */
2883
+ var _backendPromise = null;
2884
+ function getBackend() {
2885
+ if (_backend) return Promise.resolve(_backend);
2886
+ if (!_backendPromise) {
2887
+ const pending = detectBackend().then((backend) => {
2888
+ if (_backendPromise === pending) _backend = backend;
2889
+ return backend;
2890
+ });
2891
+ _backendPromise = pending;
2712
2892
  }
2713
- return 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,
@@ -2809,18 +3004,23 @@ async function getCurrentPlaythroughId(ifid) {
2809
3004
  }
2810
3005
  /**
2811
3006
  * Run a save through the `beforesave` / `aftersave` hooks. `beforesave` fires
2812
- * before `getPayload()` captures the state, so data a hook sets is part of the
2813
- * save; `aftersave` fires once `write` has stored it. Every user-facing save
2814
- * path goes through here.
3007
+ * before the payload is captured, so data a hook sets is part of the save;
3008
+ * `aftersave` fires once `write` has stored it. Every user-facing save path
3009
+ * goes through here.
3010
+ *
3011
+ * `beginCapture` is called just before the hooks and returns the function
3012
+ * that captures the payload after them, so the capture can tell which
3013
+ * variables the hooks changed (the store's `beginSave`).
2815
3014
  *
2816
3015
  * The hook and capture run synchronously, but an error they throw (a failing
2817
3016
  * hook, an unserializable payload) rejects the returned promise like a failed
2818
3017
  * write rather than escaping to the caller.
2819
3018
  */
2820
- function saveWithHooks(slot, custom, getPayload, write) {
3019
+ function saveWithHooks(slot, custom, beginCapture, write) {
2821
3020
  try {
3021
+ const capture = beginCapture();
2822
3022
  emit("beforesave", slot, custom);
2823
- return write(getPayload()).then((result) => {
3023
+ return write(capture()).then((result) => {
2824
3024
  emit("aftersave", slot);
2825
3025
  return result;
2826
3026
  });
@@ -2854,7 +3054,22 @@ async function createSave(ifid, playthroughId, payload, custom = {}) {
2854
3054
  await (await getBackend()).putSave(record);
2855
3055
  return record;
2856
3056
  }
2857
- 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) {
2858
3073
  const backend = await getBackend();
2859
3074
  const existing = await backend.getSave(saveId);
2860
3075
  if (!existing) return void 0;
@@ -2867,7 +3082,9 @@ async function overwriteSave(saveId, payload, custom) {
2867
3082
  const updated = {
2868
3083
  meta: {
2869
3084
  ...existing.meta,
3085
+ ...playthroughId ? { playthroughId } : {},
2870
3086
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3087
+ title: existing.meta.userTitle === true ? existing.meta.title : generateTitle(payload),
2871
3088
  passage: payload.passage,
2872
3089
  ...custom != null ? { custom: {
2873
3090
  ...existing.meta.custom,
@@ -2929,6 +3146,7 @@ async function renameSave(saveId, newTitle) {
2929
3146
  meta: {
2930
3147
  ...record.meta,
2931
3148
  title: newTitle,
3149
+ userTitle: true,
2932
3150
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2933
3151
  }
2934
3152
  };
@@ -3045,7 +3263,7 @@ function quickSave(ifid, playthroughId, payload, slot, custom) {
3045
3263
  const backend = await getBackend();
3046
3264
  const existingId = await backend.getMeta(metaKey);
3047
3265
  if (existingId) {
3048
- const updated = await overwriteSave(existingId, payload, custom);
3266
+ const updated = await overwriteSave(existingId, payload, custom, playthroughId);
3049
3267
  if (updated) return updated;
3050
3268
  }
3051
3269
  const record = await createSave(ifid, playthroughId, payload, {
@@ -3322,28 +3540,279 @@ function initializeStory(session) {
3322
3540
  if (!session) recordStoryInitState();
3323
3541
  }
3324
3542
  //#endregion
3543
+ //#region src/utils/object-path.ts
3544
+ /** Traverse dot-path segments on an object and return the nested value. */
3545
+ function getByPath(obj, segments) {
3546
+ let current = obj;
3547
+ for (const seg of segments) {
3548
+ if (current == null || typeof current !== "object") return void 0;
3549
+ current = current[seg];
3550
+ }
3551
+ return current;
3552
+ }
3553
+ /**
3554
+ * Shallow copy that keeps the prototype, so a registered class instance
3555
+ * stays an instance of its class (with the same own keys deepClone copies).
3556
+ */
3557
+ function shallowCopy(value) {
3558
+ let copy;
3559
+ if (Array.isArray(value)) copy = [];
3560
+ else if (value instanceof Map) copy = new Map(value);
3561
+ else if (value instanceof Set) copy = new Set(value);
3562
+ else if (value instanceof Date) copy = new Date(value.getTime());
3563
+ else copy = Object.create(Object.getPrototypeOf(value));
3564
+ return Object.assign(copy, value);
3565
+ }
3566
+ /**
3567
+ * Set a value at dot-path `segments` below `root`.
3568
+ *
3569
+ * When `root` is an Immer draft, objects Immer does not draft (registered
3570
+ * class instances and everything below them) are copied on the way down and
3571
+ * the copy is assigned to its parent, so the write produces new references
3572
+ * up to the root. Writing into such an object directly would mutate the
3573
+ * instance shared with the previous state and with history, and subscribers
3574
+ * would see no change. Outside a draft (a private working copy) the path is
3575
+ * written in place.
3576
+ */
3577
+ function setByPath(root, segments, value, options = {}) {
3578
+ const parent = walkToParent(root, segments, options.createMissing ?? false);
3579
+ parent[segments[segments.length - 1]] = value;
3580
+ }
3581
+ /**
3582
+ * Delete the property at dot-path `segments` below `root`, copying
3583
+ * undrafted objects on the way down like setByPath(). Does nothing when an
3584
+ * intermediate is missing or not an object, or the property is absent.
3585
+ */
3586
+ function deleteByPath(root, segments) {
3587
+ const last = segments[segments.length - 1];
3588
+ const holder = getByPath(root, segments.slice(0, -1));
3589
+ if (holder == null || typeof holder !== "object" || !(last in holder)) return;
3590
+ const parent = walkToParent(root, segments, false);
3591
+ delete parent[last];
3592
+ }
3593
+ /**
3594
+ * Walk to the object holding the last of `segments`, copying undrafted
3595
+ * objects when `root` is an Immer draft (see setByPath). A missing or
3596
+ * non-object intermediate is replaced by a plain object when
3597
+ * `createMissing` is set, and throws a TypeError otherwise.
3598
+ */
3599
+ function walkToParent(root, segments, createMissing) {
3600
+ const copyOnWrite = isDraft(root);
3601
+ let current = root;
3602
+ for (let i = 0; i < segments.length - 1; i++) {
3603
+ const seg = segments[i];
3604
+ let next = current[seg];
3605
+ if (next == null || typeof next !== "object") {
3606
+ if (!createMissing) throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${next === null ? "null" : typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
3607
+ next = {};
3608
+ current[seg] = next;
3609
+ } else if (copyOnWrite && !isDraft(next)) {
3610
+ next = shallowCopy(next);
3611
+ current[seg] = next;
3612
+ }
3613
+ current = next;
3614
+ }
3615
+ return current;
3616
+ }
3617
+ //#endregion
3325
3618
  //#region src/execute-mutation.ts
3619
+ var NAMESPACES = [
3620
+ "variables",
3621
+ "temporary",
3622
+ "transient"
3623
+ ];
3624
+ /** The mutations now executing, innermost last. */
3625
+ var activeScopes = [];
3326
3626
  /**
3327
- * Write the keys `code` changed (`clone` vs the pre-execution `before`) into
3328
- * an Immer draft of the same namespace, and delete the keys it removed.
3329
- * 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.
3330
3630
  */
3331
- function commitNamespace(draft, clone, before) {
3332
- for (const key of Object.keys(clone)) if (!deepEqual(clone[key], before[key])) draft[key] = clone[key];
3333
- for (const key of Object.keys(before)) if (!(key in clone)) delete draft[key];
3631
+ function getActiveMutationScope() {
3632
+ return activeScopes[activeScopes.length - 1]?.work;
3334
3633
  }
3335
- 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) {
3336
3774
  const state = useStoryStore.getState();
3337
- const vars = deepClone(state.variables);
3338
- const temps = deepClone(state.temporary);
3339
- 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
+ };
3340
3808
  const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
3341
- execute(code, vars, temps, localsClone, trans);
3342
- state.updateVariables((draft) => {
3343
- commitNamespace(draft.variables, vars, state.variables);
3344
- commitNamespace(draft.temporary, temps, state.temporary);
3345
- commitNamespace(draft.transient, trans, state.transient);
3346
- });
3809
+ activeScopes.push(scope);
3810
+ try {
3811
+ execute(code, scope.work.variables, scope.work.temporary, localsClone, scope.work.transient);
3812
+ } finally {
3813
+ activeScopes.pop();
3814
+ }
3815
+ commitScope(scope, activeScopes, false);
3347
3816
  for (const key of Object.keys(localsClone)) if (!deepEqual(localsClone[key], mergedLocals[key])) scopeUpdate(key, localsClone[key]);
3348
3817
  for (const key of Object.keys(mergedLocals)) if (!(key in localsClone)) scopeUpdate(key, void 0);
3349
3818
  }
@@ -3419,7 +3888,10 @@ function fireTrigger(trigger) {
3419
3888
  dialogQueue.push({ passageName: options.dialog });
3420
3889
  dialogNotify?.();
3421
3890
  }
3422
- if (options.goto) useStoryStore.getState().navigate(options.goto);
3891
+ if (options.goto) {
3892
+ const passage = options.goto;
3893
+ runWithCommittedMutations(() => useStoryStore.getState().navigate(passage));
3894
+ }
3423
3895
  }
3424
3896
  var MAX_RECHECK_DEPTH = 10;
3425
3897
  function runCheckLoop() {
@@ -3666,6 +4138,27 @@ function recordStoryInitState() {
3666
4138
  state.history[0].prng = prng;
3667
4139
  });
3668
4140
  }
4141
+ /**
4142
+ * Settles once the latest playthrough setup (init's lookup or creation, or a
4143
+ * restart's creation) is stored, with that setup's playthrough ID ('' if
4144
+ * init could not establish one). Each setup chains on the previous one, so
4145
+ * playthroughs are created and numbered in the order the game started them.
4146
+ */
4147
+ var playthroughSetup = Promise.resolve("");
4148
+ /** Bumped by every init()/restart(); a stale init must not adopt its ID. */
4149
+ var playthroughGeneration = 0;
4150
+ /**
4151
+ * The playthrough a save issued now belongs to, once its record is stored.
4152
+ * Read synchronously at the call: restart() switches the store's
4153
+ * `playthroughId` at once, so a save issued after it (even before the new
4154
+ * playthrough is stored) belongs to the new playthrough, and a later restart
4155
+ * doesn't move it. Before init() has looked up the stored playthrough the
4156
+ * store's ID is '', and the save takes the one init establishes.
4157
+ */
4158
+ function resolvePlaythroughId() {
4159
+ const current = useStoryStore.getState().playthroughId;
4160
+ return playthroughSetup.then((established) => current || established);
4161
+ }
3669
4162
  var runtimeUnsubs = [];
3670
4163
  var inRuntimePhase = false;
3671
4164
  /**
@@ -3703,6 +4196,22 @@ function loadedEntryMoment(payload) {
3703
4196
  if (typeof moment.variables !== "object" || moment.variables === null) return;
3704
4197
  return moment;
3705
4198
  }
4199
+ /**
4200
+ * Copy the variables that changed between `before` and `after` (the live
4201
+ * variables around the `beforesave` hooks) into the payload's snapshot of the
4202
+ * saved moment. A load restores that snapshot, the state on entering the
4203
+ * passage, and runs the passage again, so data a hook adds to a save would
4204
+ * otherwise be lost on load (#227). Only the payload's copy changes: the live
4205
+ * history keeps the recorded snapshot (#159). Store updates are immutable, so
4206
+ * a value a hook did not touch keeps its identity.
4207
+ */
4208
+ function keepHookWrites(payload, before, after) {
4209
+ const moment = payload.history[payload.historyIndex];
4210
+ if (!moment) return;
4211
+ const has = (vars, key) => Object.prototype.hasOwnProperty.call(vars, key);
4212
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) if (!has(after, key)) delete moment.variables[key];
4213
+ else if (!has(before, key) || after[key] !== before[key]) moment.variables[key] = deepClone(after[key]);
4214
+ }
3706
4215
  /** Restore or reset PRNG from a history moment's snapshot. */
3707
4216
  function restorePRNGFromMoment(moment) {
3708
4217
  if (moment?.prng) restorePRNG(moment.prng.seed, moment.prng.pull);
@@ -3764,6 +4273,7 @@ var useStoryStore = create()(immer((set, get) => ({
3764
4273
  resetModuleState(deepClone(initialVars));
3765
4274
  set((state) => {
3766
4275
  state.storyData = storyData;
4276
+ state.playthroughId = "";
3767
4277
  state.currentPassage = startPassage.name;
3768
4278
  state.navigationId++;
3769
4279
  state.variables = initialVars;
@@ -3781,22 +4291,21 @@ var useStoryStore = create()(immer((set, get) => ({
3781
4291
  });
3782
4292
  lastNavigationVars = get().variables;
3783
4293
  const ifid = storyData.ifid;
3784
- initSaveSystem().then(async () => {
3785
- const existingId = await getCurrentPlaythroughId(ifid);
3786
- if (existingId) set((state) => {
3787
- 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;
3788
4299
  });
3789
- else {
3790
- const newId = await startNewPlaythrough(ifid);
3791
- set((state) => {
3792
- state.playthroughId = newId;
3793
- });
3794
- }
3795
4300
  const saves = await populateKnownSaves(ifid);
3796
4301
  if (Object.keys(saves).length > 0) set((state) => {
3797
4302
  state.knownSaves = saves;
3798
4303
  });
3799
- }).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
+ });
3800
4309
  },
3801
4310
  navigate: (passageName) => {
3802
4311
  if (navigationTriggerPhase) {
@@ -3949,6 +4458,16 @@ var useStoryStore = create()(immer((set, get) => ({
3949
4458
  state.renderDeferred = false;
3950
4459
  });
3951
4460
  emit("beforerestart");
4461
+ const newPlaythroughId = crypto.randomUUID();
4462
+ ++playthroughGeneration;
4463
+ set((state) => {
4464
+ state.playthroughId = newPlaythroughId;
4465
+ });
4466
+ const ifid = storyData.ifid;
4467
+ playthroughSetup = playthroughSetup.then(() => startNewPlaythrough(ifid, newPlaythroughId)).then(() => newPlaythroughId, (err) => {
4468
+ console.error("spindle: failed to start new playthrough", err);
4469
+ return newPlaythroughId;
4470
+ });
3952
4471
  const keepDeferred = get().renderDeferred;
3953
4472
  cleanupRuntimeHandlers();
3954
4473
  resetPRNG();
@@ -3976,19 +4495,16 @@ var useStoryStore = create()(immer((set, get) => ({
3976
4495
  clearSession(storyData.ifid);
3977
4496
  emit("storyinit");
3978
4497
  recordStoryInitState();
3979
- startNewPlaythrough(storyData.ifid).then((newId) => {
3980
- set((state) => {
3981
- state.playthroughId = newId;
3982
- });
3983
- }).catch((err) => console.error("spindle: failed to start new playthrough", err));
3984
4498
  },
3985
4499
  save: (slot, custom) => {
3986
- const { storyData, playthroughId } = get();
4500
+ const { storyData } = get();
3987
4501
  if (!storyData) return Promise.resolve();
3988
- return handled(saveWithHooks(slot, custom, () => get().getSavePayload(), async (payload) => {
4502
+ const playthrough = resolvePlaythroughId();
4503
+ return handled(saveWithHooks(slot, custom, get().beginSave, async (payload) => {
3989
4504
  set((state) => {
3990
4505
  state.saveError = null;
3991
4506
  });
4507
+ const playthroughId = await playthrough;
3992
4508
  await quickSave(storyData.ifid, playthroughId, payload, slot, custom);
3993
4509
  set((state) => {
3994
4510
  state.knownSaves = {
@@ -4127,6 +4643,14 @@ var useStoryStore = create()(immer((set, get) => ({
4127
4643
  prng: snapshotPRNG()
4128
4644
  };
4129
4645
  },
4646
+ beginSave: () => {
4647
+ const before = get().variables;
4648
+ return () => {
4649
+ const payload = get().getSavePayload();
4650
+ keepHookWrites(payload, before, get().variables);
4651
+ return payload;
4652
+ };
4653
+ },
4130
4654
  loadFromPayload: (payload, slot) => {
4131
4655
  if (payload.history.length === 0) {
4132
4656
  console.warn("loadFromPayload: rejecting payload with empty history");
@@ -4687,14 +5211,16 @@ function currentSourceLocation() {
4687
5211
  //#endregion
4688
5212
  //#region src/components/macros/option-utils.ts
4689
5213
  function parseVarArgs(rawArgs) {
4690
- const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(?:["'](.*)["'])?\s*$/);
5214
+ const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/);
4691
5215
  if (!match) return {
4692
5216
  varName: rawArgs.trim(),
4693
5217
  placeholder: ""
4694
5218
  };
5219
+ const varName = match[1].replace(/["']/g, "");
5220
+ const quoted = match[2];
4695
5221
  return {
4696
- varName: match[1].replace(/["']/g, ""),
4697
- placeholder: match[2] || ""
5222
+ varName,
5223
+ placeholder: quoted ? readWholeQuoted(quoted) ?? quoted.slice(1, -1) : ""
4698
5224
  };
4699
5225
  }
4700
5226
  /**
@@ -4703,7 +5229,8 @@ function parseVarArgs(rawArgs) {
4703
5229
  function extractOptions(children) {
4704
5230
  const options = [];
4705
5231
  for (const node of children) if (node.type === "macro" && node.name === "option") {
4706
- const stripped = node.rawArgs.trim().replace(/^(["'])(.+)\1$/, "$2");
5232
+ const raw = node.rawArgs.trim();
5233
+ const stripped = readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/, "$2");
4707
5234
  options.push(stripped);
4708
5235
  }
4709
5236
  return options;
@@ -4723,25 +5250,6 @@ var sharedHooks = {
4723
5250
  useMemo,
4724
5251
  useContext
4725
5252
  };
4726
- /** Traverse a dot-path on an object, returning the nested value. */
4727
- function getByPath$1(obj, segments) {
4728
- let current = obj;
4729
- for (const seg of segments) {
4730
- if (current == null || typeof current !== "object") return void 0;
4731
- current = current[seg];
4732
- }
4733
- return current;
4734
- }
4735
- /** Set a nested value by dot-path segments on an (Immer draft) object. */
4736
- function setByPath$1(obj, segments, value) {
4737
- let target = obj;
4738
- for (let i = 0; i < segments.length - 1; i++) {
4739
- const seg = segments[i];
4740
- if (target[seg] == null || typeof target[seg] !== "object") target[seg] = {};
4741
- target = target[seg];
4742
- }
4743
- target[segments[segments.length - 1]] = value;
4744
- }
4745
5253
  function defineMacro(config, source = "builtin") {
4746
5254
  function Wrapper(props) {
4747
5255
  let className = props.className;
@@ -4754,9 +5262,11 @@ function defineMacro(config, source = "builtin") {
4754
5262
  }
4755
5263
  const { update, getValues } = useContext(LocalsUpdateContext);
4756
5264
  const nobr = useContext(NobrContext);
5265
+ const inline = useContext(InlineContext);
4757
5266
  const localsValues = useContext(LocalsValuesContext);
4758
5267
  const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
4759
5268
  nobr,
5269
+ inline,
4760
5270
  locals: localsValues,
4761
5271
  ...options
4762
5272
  });
@@ -4796,11 +5306,11 @@ function defineMacro(config, source = "builtin") {
4796
5306
  const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
4797
5307
  const segments = varExpr.split(".");
4798
5308
  ctx.varName = varExpr;
4799
- ctx.value = useStoryStore((s) => getByPath$1(s.variables, segments));
4800
- 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);
4801
5311
  ctx.setValue = (value) => {
4802
5312
  useStoryStore.setState((state) => {
4803
- setByPath$1(state.variables, segments, value);
5313
+ setByPath(state.variables, segments, value, { createMissing: true });
4804
5314
  });
4805
5315
  };
4806
5316
  }
@@ -4876,27 +5386,6 @@ function ensureVariableChangedSubscription() {
4876
5386
  if (hasChanges) emit("variableChanged", changed);
4877
5387
  });
4878
5388
  }
4879
- /** Traverse a dot-delimited path on an object and return the value. */
4880
- function getByPath(obj, path) {
4881
- const segments = path.split(".");
4882
- let current = obj[segments[0]];
4883
- for (let i = 1; i < segments.length; i++) {
4884
- if (current == null) return void 0;
4885
- current = current[segments[i]];
4886
- }
4887
- return current;
4888
- }
4889
- /** Set a value at a dot-delimited path on an object (must be an Immer draft for mutation). */
4890
- function setByPath(obj, path, value) {
4891
- const segments = path.split(".");
4892
- let current = obj;
4893
- for (let i = 0; i < segments.length - 1; i++) {
4894
- const next = current[segments[i]];
4895
- if (next == null || typeof next !== "object") throw new TypeError(`spindle: Cannot set property "${segments[i + 1]}" on ${typeof next} (at "${segments.slice(0, i + 1).join(".")}")`);
4896
- current = next;
4897
- }
4898
- current[segments[segments.length - 1]] = value;
4899
- }
4900
5389
  var declaredVariables = null;
4901
5390
  var declaredTransients = null;
4902
5391
  var warnedUndeclared = /* @__PURE__ */ new Set();
@@ -4939,41 +5428,47 @@ function warnIfUndeclared(isTransient, key) {
4939
5428
  function setOne(draft, name, value) {
4940
5429
  const { isTransient, key } = parseName(name);
4941
5430
  const namespace = isTransient ? draft.transient : draft.variables;
4942
- if (key.includes(".")) setByPath(namespace, key, value);
5431
+ if (key.includes(".")) setByPath(namespace, key.split("."), value);
4943
5432
  else namespace[key] = value;
4944
5433
  }
4945
5434
  function createStoryAPI() {
4946
5435
  return {
4947
5436
  get(name) {
4948
5437
  const { isTransient, key } = parseName(name);
4949
- const store = isTransient ? useStoryStore.getState().transient : useStoryStore.getState().variables;
4950
- 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;
4951
5443
  },
4952
5444
  set(nameOrVars, value) {
4953
- const names = typeof nameOrVars === "string" ? [nameOrVars] : Object.keys(nameOrVars);
4954
- for (const name of names) {
5445
+ const entries = typeof nameOrVars === "string" ? [[nameOrVars, value]] : Object.entries(nameOrVars);
5446
+ for (const [name] of entries) {
4955
5447
  const { isTransient, key } = parseName(name);
4956
5448
  warnIfUndeclared(isTransient, key);
4957
5449
  }
4958
5450
  useStoryStore.getState().updateVariables((draft) => {
4959
- if (typeof nameOrVars === "string") setOne(draft, nameOrVars, value);
4960
- 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
+ }
4961
5456
  });
4962
5457
  },
4963
5458
  goto(passageName) {
4964
- useStoryStore.getState().navigate(passageName);
5459
+ runWithCommittedMutations(() => useStoryStore.getState().navigate(passageName));
4965
5460
  },
4966
5461
  back() {
4967
- useStoryStore.getState().goBack();
5462
+ runWithCommittedMutations(() => useStoryStore.getState().goBack());
4968
5463
  },
4969
5464
  forward() {
4970
- useStoryStore.getState().goForward();
5465
+ runWithCommittedMutations(() => useStoryStore.getState().goForward());
4971
5466
  },
4972
5467
  restart() {
4973
- useStoryStore.getState().restart();
5468
+ runWithCommittedMutations(() => useStoryStore.getState().restart());
4974
5469
  },
4975
5470
  save(slot, custom) {
4976
- return useStoryStore.getState().save(slot, custom);
5471
+ return runWithCommittedMutations(() => useStoryStore.getState().save(slot, custom));
4977
5472
  },
4978
5473
  load(slot) {
4979
5474
  return useStoryStore.getState().load(slot);
@@ -5670,44 +6165,27 @@ defineMacro({
5670
6165
  });
5671
6166
  //#endregion
5672
6167
  //#region src/components/macros/Meter.tsx
5673
- function parseArgs$1(rawArgs) {
5674
- 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);
5675
6176
  let labelMode = "";
5676
- let rest = trimmed;
5677
- const quoteMatch = rest.match(/\s+(?:"([^"]*)"|'([^']*)')$/);
5678
- if (quoteMatch) {
5679
- labelMode = quoteMatch[1] ?? quoteMatch[2] ?? "";
5680
- rest = rest.slice(0, quoteMatch.index ?? rest.length).trim();
5681
- }
5682
- const exprs = splitExpressions(rest);
5683
- if (exprs.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
6177
+ const label = tokens.length >= 2 ? readWholeQuoted(tokens[tokens.length - 1]) : null;
6178
+ if (label !== null) {
6179
+ labelMode = label;
6180
+ tokens.pop();
6181
+ }
6182
+ if (tokens.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
5684
6183
  return {
5685
- currentExpr: exprs[0],
5686
- maxExpr: exprs.slice(1).join(" "),
6184
+ currentExpr: tokens[0],
6185
+ maxExpr: tokens.slice(1).join(" "),
5687
6186
  labelMode
5688
6187
  };
5689
6188
  }
5690
- function splitExpressions(input) {
5691
- const result = [];
5692
- let current = "";
5693
- let depth = 0;
5694
- for (let i = 0; i < input.length; i++) {
5695
- const ch = input[i];
5696
- if (ch === "(" || ch === "[") {
5697
- depth++;
5698
- current += ch;
5699
- } else if (ch === ")" || ch === "]") {
5700
- depth--;
5701
- current += ch;
5702
- } else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
5703
- result.push(current);
5704
- current = "";
5705
- while (i + 1 < input.length && /\s/.test(input[i + 1])) i++;
5706
- } else current += ch;
5707
- }
5708
- if (current.length > 0) result.push(current);
5709
- return result;
5710
- }
5711
6189
  function formatLabel(current, max, labelMode) {
5712
6190
  if (labelMode === "none") return null;
5713
6191
  if (labelMode === "%") return `${max === 0 ? 0 : Math.round(current / max * 100)}%`;
@@ -5720,7 +6198,7 @@ defineMacro({
5720
6198
  merged: true,
5721
6199
  render({ rawArgs }, ctx) {
5722
6200
  try {
5723
- const { currentExpr, maxExpr, labelMode } = parseArgs$1(rawArgs);
6201
+ const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
5724
6202
  const current = Number(ctx.evaluate(currentExpr));
5725
6203
  const max = Number(ctx.evaluate(maxExpr));
5726
6204
  const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
@@ -5779,6 +6257,56 @@ defineMacro({
5779
6257
  }
5780
6258
  });
5781
6259
  //#endregion
6260
+ //#region src/utils/stable-key.ts
6261
+ /**
6262
+ * Content-derived string key for a value, used to remount components when
6263
+ * the value's contents change (e.g. {for} iterations, #45).
6264
+ *
6265
+ * JSON-compatible values (strings, finite numbers, booleans, null, arrays and
6266
+ * plain objects of those) produce exactly their `JSON.stringify` output. Other
6267
+ * values get distinct, unambiguous markers so their contents are reflected
6268
+ * too: Map and Set entries (nested at any depth), Date, RegExp, BigInt,
6269
+ * undefined, NaN/±Infinity, symbols, functions and registered class
6270
+ * instances. Cyclic references become a back-reference marker instead of
6271
+ * throwing, and the function never throws.
6272
+ */
6273
+ function stableKey(value) {
6274
+ try {
6275
+ return keyOf(value, []);
6276
+ } catch {
6277
+ return "<unkeyable>";
6278
+ }
6279
+ }
6280
+ function keyOf(val, ancestors) {
6281
+ switch (typeof val) {
6282
+ case "string": return JSON.stringify(val);
6283
+ case "number": return Number.isFinite(val) ? JSON.stringify(val) : String(val);
6284
+ case "boolean": return String(val);
6285
+ case "bigint": return `${val}n`;
6286
+ case "undefined": return "undefined";
6287
+ case "symbol": return val.toString();
6288
+ case "function": return `<function ${JSON.stringify(val.name)}>`;
6289
+ }
6290
+ if (val === null) return "null";
6291
+ const obj = val;
6292
+ const depth = ancestors.indexOf(obj);
6293
+ if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
6294
+ if (val instanceof Date) return `Date(${val.getTime()})`;
6295
+ if (val instanceof RegExp) return `RegExp(${String(val)})`;
6296
+ ancestors.push(obj);
6297
+ try {
6298
+ if (Array.isArray(val)) return `[${val.map((v) => keyOf(v, ancestors)).join(",")}]`;
6299
+ if (val instanceof Map) return `Map[${[...val].map(([k, v]) => `[${keyOf(k, ancestors)},${keyOf(v, ancestors)}]`).join(",")}]`;
6300
+ if (val instanceof Set) return `Set[${[...val].map((v) => keyOf(v, ancestors)).join(",")}]`;
6301
+ const record = obj;
6302
+ const body = Object.keys(record).map((k) => `${JSON.stringify(k)}:${keyOf(record[k], ancestors)}`).join(",");
6303
+ const className = getClassName(obj.constructor);
6304
+ return className === void 0 ? `{${body}}` : `Class(${JSON.stringify(className)}){${body}}`;
6305
+ } finally {
6306
+ ancestors.pop();
6307
+ }
6308
+ }
6309
+ //#endregion
5782
6310
  //#region src/components/macros/For.tsx
5783
6311
  /**
5784
6312
  * Parse for-loop args: "@item, @i of $list" or "@item of $list"
@@ -5837,12 +6365,14 @@ function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue,
5837
6365
  getValues
5838
6366
  }), [update, getValues]);
5839
6367
  const nobr = useContext(NobrContext);
6368
+ const inline = useContext(InlineContext);
5840
6369
  return /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
5841
6370
  value: updater,
5842
6371
  children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
5843
6372
  value: localState,
5844
6373
  children: renderNodes(children, {
5845
6374
  nobr,
6375
+ inline,
5846
6376
  locals: localState
5847
6377
  })
5848
6378
  })
@@ -5886,7 +6416,7 @@ defineMacro({
5886
6416
  indexVar,
5887
6417
  indexValue: i,
5888
6418
  children
5889
- }, `${i}-${JSON.stringify(item)}`));
6419
+ }, `${i}-${stableKey(item)}`));
5890
6420
  return ctx.wrap(content);
5891
6421
  }
5892
6422
  });
@@ -5938,7 +6468,8 @@ defineMacro({
5938
6468
  block: true,
5939
6469
  interpolate: true,
5940
6470
  render({ rawArgs, children = [] }, ctx) {
5941
- const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, "")) ?? rawArgs;
6471
+ const text = readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, "");
6472
+ const label = ctx.resolve?.(text) ?? rawArgs;
5942
6473
  const nobr = useContext(NobrContext);
5943
6474
  const handleClick = () => {
5944
6475
  const container = document.createElement("div");
@@ -5969,7 +6500,7 @@ function parseArgs(rawArgs) {
5969
6500
  const parts = [];
5970
6501
  const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
5971
6502
  let m;
5972
- while ((m = re.exec(rawArgs)) !== null) parts.push(m[2].replace(/\\(["'\\])/g, "$1"));
6503
+ while ((m = re.exec(rawArgs)) !== null) parts.push(unescapeQuoted(m[2]));
5973
6504
  if (parts.length >= 2) return {
5974
6505
  display: parts[0],
5975
6506
  passage: parts[1]
@@ -6376,7 +6907,7 @@ defineMacro({
6376
6907
  const [open, setOpen] = ctx.hooks.useState(false);
6377
6908
  const noclose = /\bnoclose\s*$/.test(rawArgs);
6378
6909
  const labelRaw = rawArgs.replace(/\bnoclose\s*$/, "").trim();
6379
- const label = ctx.resolve?.(labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
6910
+ const label = ctx.resolve?.(readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
6380
6911
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
6381
6912
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
6382
6913
  id: ctx.id,
@@ -6424,7 +6955,7 @@ function SaveManagerContent() {
6424
6955
  const closeDialog = useContext(DialogCloseContext);
6425
6956
  const storyData = useStoryStore((s) => s.storyData);
6426
6957
  const playthroughId = useStoryStore((s) => s.playthroughId);
6427
- const getSavePayload = useStoryStore((s) => s.getSavePayload);
6958
+ const beginSave = useStoryStore((s) => s.beginSave);
6428
6959
  const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
6429
6960
  const ifid = storyData?.ifid ?? "";
6430
6961
  const refresh = useCallback(async () => {
@@ -6462,9 +6993,14 @@ function SaveManagerContent() {
6462
6993
  });
6463
6994
  };
6464
6995
  const handleNewSave = async () => {
6465
- if (!ifid || !playthroughId) return;
6996
+ if (!ifid) return;
6997
+ const playthrough = resolvePlaythroughId();
6466
6998
  try {
6467
- await saveWithHooks(void 0, void 0, getSavePayload, (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
+ });
6468
7004
  showStatus("Save created");
6469
7005
  await refresh();
6470
7006
  } catch {
@@ -6472,9 +7008,10 @@ function SaveManagerContent() {
6472
7008
  }
6473
7009
  };
6474
7010
  const handleOverwrite = async (saveId) => {
7011
+ const playthrough = resolvePlaythroughId();
6475
7012
  try {
6476
- await saveWithHooks(void 0, void 0, getSavePayload, async (payload) => {
6477
- if (!await overwriteSave(saveId, payload)) throw new Error("Save not found");
7013
+ await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
7014
+ if (!await overwriteSave(saveId, payload, void 0, await playthrough || void 0)) throw new Error("Save not found");
6478
7015
  });
6479
7016
  showStatus("Save overwritten");
6480
7017
  await refresh();
@@ -6780,27 +7317,16 @@ var FLAG = "inline";
6780
7317
  */
6781
7318
  function parseIncludeArgs(rawArgs) {
6782
7319
  const trimmed = rawArgs.trim();
7320
+ const spaces = topLevelIndices(trimmed, isWhitespace);
6783
7321
  let first = null;
6784
7322
  let last = null;
6785
- let depth = 0;
6786
- let inString = null;
6787
- for (let i = 0; i < trimmed.length; i++) {
6788
- const ch = trimmed[i];
6789
- if (inString) {
6790
- if (ch === "\\") i++;
6791
- else if (ch === inString) inString = null;
6792
- continue;
6793
- }
6794
- if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
6795
- else if (ch === "(" || ch === "[" || ch === "{") depth++;
6796
- else if (ch === ")" || ch === "]" || ch === "}") depth--;
6797
- else if (depth === 0 && /\s/.test(ch)) {
6798
- let end = i + 1;
6799
- while (end < trimmed.length && /\s/.test(trimmed[end])) end++;
6800
- last = [i, end];
6801
- first ??= last;
6802
- i = end - 1;
6803
- }
7323
+ if (spaces.length > 0) {
7324
+ let s = 0;
7325
+ while (s + 1 < spaces.length && spaces[s + 1] === spaces[s] + 1) s++;
7326
+ first = [spaces[0], spaces[s] + 1];
7327
+ let e = spaces.length - 1;
7328
+ while (e > 0 && spaces[e - 1] === spaces[e] - 1) e--;
7329
+ last = [spaces[e], spaces[spaces.length - 1] + 1];
6804
7330
  }
6805
7331
  if (last && last[1] === trimmed.length - 6 && trimmed.endsWith(FLAG)) {
6806
7332
  const rest = trimmed.slice(0, last[0]);
@@ -6979,7 +7505,10 @@ defineMacro({
6979
7505
  //#endregion
6980
7506
  //#region src/components/macros/Checkbox.tsx
6981
7507
  function parseLabel(rawArgs) {
6982
- 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);
6983
7512
  }
6984
7513
  defineMacro({
6985
7514
  name: "checkbox",
@@ -7009,6 +7538,19 @@ defineMacro({
7009
7538
  //#endregion
7010
7539
  //#region src/components/macros/Radiobutton.tsx
7011
7540
  function parseRadioArgs(rawArgs) {
7541
+ const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
7542
+ if (head) {
7543
+ const rest = rawArgs.slice(head[0].length).trim();
7544
+ const value = readQuoted(rest, 0);
7545
+ if (value && (value.end === rest.length || isWhitespace(rest[value.end]))) {
7546
+ const labelRaw = rest.slice(value.end).trim();
7547
+ const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
7548
+ return {
7549
+ value: value.value,
7550
+ label
7551
+ };
7552
+ }
7553
+ }
7012
7554
  const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/);
7013
7555
  if (!match) {
7014
7556
  const parts = rawArgs.trim().split(/\s+/).slice(1);
@@ -7467,43 +8009,87 @@ defineMacro({
7467
8009
  });
7468
8010
  //#endregion
7469
8011
  //#region src/components/macros/Watch.tsx
8012
+ var WORD_RE = /\w+/y;
8013
+ var DIGITS_RE = /\d+/y;
8014
+ /** Match the sticky regex `re` at `pos`, returning the matched text. */
8015
+ function matchAt(re, src, pos) {
8016
+ re.lastIndex = pos;
8017
+ return re.exec(src)?.[0] ?? null;
8018
+ }
8019
+ /**
8020
+ * Parse `{watch "condition" keyword value …}`: a quoted condition followed
8021
+ * by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
8022
+ * `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
8023
+ * Returns `null` when the arguments do not start with a quoted condition.
8024
+ */
8025
+ function parseWatchArgs(rawArgs) {
8026
+ const raw = rawArgs.trim();
8027
+ const cond = readQuoted(raw, 0);
8028
+ if (!cond) return null;
8029
+ const options = {};
8030
+ let i = cond.end;
8031
+ while (i < raw.length) {
8032
+ const key = matchAt(WORD_RE, raw, i);
8033
+ if (!key) {
8034
+ i = readQuoted(raw, i)?.end ?? i + 1;
8035
+ continue;
8036
+ }
8037
+ i += key.length;
8038
+ let val;
8039
+ let j = i;
8040
+ while (j < raw.length && isWhitespace(raw[j])) j++;
8041
+ if (j > i) {
8042
+ const quoted = readQuoted(raw, j);
8043
+ const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
8044
+ if (quoted) {
8045
+ val = quoted.value;
8046
+ i = quoted.end;
8047
+ } else if (digits) {
8048
+ val = digits;
8049
+ i = j + digits.length;
8050
+ }
8051
+ }
8052
+ switch (key) {
8053
+ case "goto":
8054
+ options.goto = val;
8055
+ break;
8056
+ case "dialog":
8057
+ options.dialog = val;
8058
+ break;
8059
+ case "run":
8060
+ options.run = val;
8061
+ break;
8062
+ case "name":
8063
+ options.name = val;
8064
+ break;
8065
+ case "priority":
8066
+ options.priority = val ? Number(val) : 0;
8067
+ break;
8068
+ case "once":
8069
+ options.once = true;
8070
+ break;
8071
+ }
8072
+ }
8073
+ return {
8074
+ condition: cond.value,
8075
+ options
8076
+ };
8077
+ }
8078
+ /**
8079
+ * Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
8080
+ * same escapes as `{watch}`, or a bare name.
8081
+ */
8082
+ function parseUnwatchName(rawArgs) {
8083
+ const raw = rawArgs.trim();
8084
+ return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, "");
8085
+ }
7470
8086
  defineMacro({
7471
8087
  name: "watch",
7472
8088
  render(props, ctx) {
7473
8089
  const { hooks } = ctx;
7474
- const raw = props.rawArgs.trim();
7475
- const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
7476
- if (!condMatch) return null;
7477
- const condition = condMatch[2];
7478
- const rest = raw.slice(condMatch[0].length);
7479
- const options = {};
7480
- const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
7481
- let m;
7482
- while ((m = kwRe.exec(rest)) !== null) {
7483
- const key = m[1] ?? m[5];
7484
- const val = m[2] ?? m[3] ?? m[4];
7485
- if (!key) continue;
7486
- switch (key) {
7487
- case "goto":
7488
- options.goto = val;
7489
- break;
7490
- case "dialog":
7491
- options.dialog = val;
7492
- break;
7493
- case "run":
7494
- options.run = val;
7495
- break;
7496
- case "name":
7497
- options.name = val;
7498
- break;
7499
- case "priority":
7500
- options.priority = val ? Number(val) : 0;
7501
- break;
7502
- case "once":
7503
- options.once = true;
7504
- break;
7505
- }
7506
- }
8090
+ const parsed = parseWatchArgs(props.rawArgs);
8091
+ if (!parsed) return null;
8092
+ const { condition, options } = parsed;
7507
8093
  const registered = hooks.useRef(false);
7508
8094
  if (!registered.current) {
7509
8095
  registered.current = true;
@@ -7515,7 +8101,7 @@ defineMacro({
7515
8101
  defineMacro({
7516
8102
  name: "unwatch",
7517
8103
  render(props, ctx) {
7518
- const name = props.rawArgs.trim().replace(/^['"]|['"]$/g, "");
8104
+ const name = parseUnwatchName(props.rawArgs);
7519
8105
  const ran = ctx.hooks.useRef(false);
7520
8106
  if (!ran.current) {
7521
8107
  ran.current = true;