@rohal12/spindle 0.52.5 → 0.52.7

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.
Files changed (43) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +705 -576
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/dist/pkg/story-variables.js +54 -32
  5. package/package.json +1 -1
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/hooks/use-render-options.ts +26 -0
  39. package/src/js-lexer.ts +80 -45
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/types/index.d.ts +69 -6
  43. package/types/tooling.d.ts +33 -1
@@ -946,6 +946,13 @@ var UNCLOSED = -1;
946
946
  var MALFORMED = -2;
947
947
  /** A frame result: the scan stops at index `s` inside it (`STOPPED - s`). */
948
948
  var STOPPED = -3;
949
+ /**
950
+ * The result of a frame that closes at `end`. `bodyStarted`: the code inside
951
+ * started a function or class body. That body replaced the one to come, and
952
+ * is gone once the frame is closed, so after the frame no body is to come —
953
+ * a scan that skips the frame must know, as must the frames around it.
954
+ */
955
+ var frameEnd = (end, bodyStarted) => end * 2 + +bodyStarted;
949
956
  /** The closers whose search for their frame a frame result may depend on. */
950
957
  var CLOSERS = [
951
958
  ")",
@@ -1178,14 +1185,30 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1178
1185
  ]] : void 0;
1179
1186
  /**
1180
1187
  * How many function or class bodies to come the scan has started, and with
1181
- * `parens`, that count when each open frame opened. Code in a `(…)` or
1182
- * `[…]` that starts one replaces the one to come, and the new one can't
1183
- * follow once the frame is closed: none is to come then.
1188
+ * `braces` or `parens`, that count when each open frame opened. Code in a
1189
+ * frame that starts one replaces the one to come, and the new one can't
1190
+ * follow once the frame is closed: none is to come then. A scan skipping
1191
+ * the frame learns this from its result (`frameEnd`, `resume`), and counts
1192
+ * the body as started too, for the frames around it to record.
1184
1193
  */
1185
1194
  let bodyStarts = 0;
1186
- const startsAt = parens ? [0] : void 0;
1195
+ const startsAt = braces || parens ? [0] : void 0;
1187
1196
  /** A `(…)` or `[…]` frame result found in `parens`, to skip to. */
1188
1197
  let parenTo;
1198
+ /** The result of the frame at index `k`, closing at `i`. */
1199
+ const closedAt = (k) => frameEnd(i, startsAt !== void 0 && bodyStarts !== startsAt[k]);
1200
+ /**
1201
+ * Go on after a frame an earlier scan lexed, whose result (`frameEnd`) is
1202
+ * `result`: returns the index it closes at. A body started in it is gone
1203
+ * after it, with the one it replaced.
1204
+ */
1205
+ function resume(result) {
1206
+ if (result % 2 === 1) {
1207
+ pendingBody = void 0;
1208
+ bodyStarts++;
1209
+ }
1210
+ return Math.floor(result / 2);
1211
+ }
1189
1212
  /** The closer `c` looked for its frame and found index `k`. */
1190
1213
  function lookedFor(c, k) {
1191
1214
  if (!lowest) return;
@@ -1333,8 +1356,8 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1333
1356
  if (lowest) {
1334
1357
  for (let j = frames.length - 1; j >= k; j--) foldLowest(j);
1335
1358
  lowest.length = k;
1336
- startsAt.length = k;
1337
1359
  }
1360
+ if (startsAt) startsAt.length = k;
1338
1361
  frames.length = k;
1339
1362
  if (stackIds) stackIds.length = k;
1340
1363
  for (const list of Object.values(closers)) while (list.length && list[list.length - 1] >= k) list.pop();
@@ -1411,6 +1434,18 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1411
1434
  stmtNext = f.kind === "block";
1412
1435
  keyNext = f.kind === "object" || f.kind === "class";
1413
1436
  }
1437
+ /**
1438
+ * Go past frame `f` if an earlier scan lexed it: to just after it, or to
1439
+ * the end of the source when it never closes. Whether it did, or
1440
+ * MALFORMED when that scan found the code malformed.
1441
+ */
1442
+ function skipLexed(f) {
1443
+ const end = known(f);
1444
+ if (end === void 0) return false;
1445
+ if (end === MALFORMED) return MALFORMED;
1446
+ i = end === UNCLOSED ? src.length : resume(end) + 1;
1447
+ return true;
1448
+ }
1414
1449
  function openBrace() {
1415
1450
  let f;
1416
1451
  if (pendingBody?.depth === frames.length) {
@@ -1454,10 +1489,10 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1454
1489
  return;
1455
1490
  }
1456
1491
  const closed = frames[k];
1492
+ const result = closedAt(k);
1457
1493
  if (lowest && c !== "}") {
1458
1494
  for (let j = frames.length - 1; j > k; j--) foldLowest(j);
1459
- const bodyStarted = bodyStarts !== startsAt[k];
1460
- recordParen(k, i * 2 + +bodyStarted);
1495
+ recordParen(k, result);
1461
1496
  }
1462
1497
  truncate(k);
1463
1498
  if (c === "]" && !ctx.lookahead) ctx.brackets.set(closed.open, i);
@@ -1466,7 +1501,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1466
1501
  stmtNext = closed.header;
1467
1502
  } else if (c === "]") endPunct(c, false);
1468
1503
  else {
1469
- record(closed, i);
1504
+ record(closed, result);
1470
1505
  afterBrace(closed);
1471
1506
  }
1472
1507
  }
@@ -1568,23 +1603,16 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1568
1603
  } else if (ch === "`") {
1569
1604
  literal(ch, i);
1570
1605
  if (frames.length === 1) return i + 1;
1571
- record(top(), i);
1606
+ record(top(), closedAt(frames.length - 1));
1572
1607
  i++;
1573
1608
  truncate(frames.length - 1);
1574
1609
  endOperand();
1575
1610
  } else if (ch === "$" && src.charAt(i + 1) === "{") {
1576
1611
  const f = frame("expr", "}", i);
1577
1612
  f.interpolation = true;
1578
- const end = known(f);
1579
- if (end === MALFORMED) return malformed();
1580
- if (end === UNCLOSED) {
1581
- i = src.length;
1582
- break;
1583
- }
1584
- if (end !== void 0) {
1585
- i = end + 1;
1586
- continue;
1587
- }
1613
+ const skipped = skipLexed(f);
1614
+ if (skipped === MALFORMED) return malformed();
1615
+ if (skipped) continue;
1588
1616
  literal("${", i);
1589
1617
  i += 2;
1590
1618
  nesting++;
@@ -1611,14 +1639,9 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1611
1639
  if (ch === "`") {
1612
1640
  endWord();
1613
1641
  const f = frame("template", "`", i);
1614
- const end = known(f);
1615
- if (end === MALFORMED) return malformed();
1616
- if (end === UNCLOSED) {
1617
- i = src.length;
1618
- break;
1619
- }
1620
- if (end !== void 0) {
1621
- i = end + 1;
1642
+ const skipped = skipLexed(f);
1643
+ if (skipped === MALFORMED) return malformed();
1644
+ if (skipped) {
1622
1645
  endOperand();
1623
1646
  continue;
1624
1647
  }
@@ -1637,7 +1660,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1637
1660
  lookedFor("}", k);
1638
1661
  if (frames[k].interpolation) {
1639
1662
  endWord();
1640
- record(frames[k], i);
1663
+ record(frames[k], closedAt(k));
1641
1664
  truncate(k);
1642
1665
  nesting--;
1643
1666
  literal(ch, i);
@@ -1719,7 +1742,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1719
1742
  continue;
1720
1743
  }
1721
1744
  }
1722
- if (ch === outer.closer && frames.length === 1) break;
1745
+ if (ch === outer.closer && (frames.length === 1 || ch === "]" && innermost("]") === 0 && innermost("}") === 0)) break;
1723
1746
  trackCode(ch);
1724
1747
  if (skipTo) {
1725
1748
  const { frame: skipped, end } = skipTo;
@@ -1729,7 +1752,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1729
1752
  i = src.length;
1730
1753
  break;
1731
1754
  }
1732
- i = end;
1755
+ i = resume(end);
1733
1756
  afterBrace(skipped);
1734
1757
  i++;
1735
1758
  continue;
@@ -1747,8 +1770,7 @@ function scan(src, handlers, start, nesting, outer, ctx) {
1747
1770
  strict.stopped = true;
1748
1771
  return STOPPED - result;
1749
1772
  }
1750
- i = Math.floor(result / 2);
1751
- if (result % 2 === 1) pendingBody = void 0;
1773
+ i = resume(result);
1752
1774
  if (skipped.closer === ")") {
1753
1775
  endPunct(")", skipped.header);
1754
1776
  stmtNext = skipped.header;
@@ -3679,11 +3701,29 @@ function ExprDisplay({ expression, className, id }) {
3679
3701
  return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3680
3702
  }
3681
3703
  //#endregion
3682
- //#region src/components/macros/WidgetInvocation.tsx
3683
- function WidgetBody({ body, parentValues, ownKeys }) {
3684
- const nobr = useContext(NobrContext);
3685
- const inline = useContext(InlineContext);
3686
- const raw = useContext(RawTextContext);
3704
+ //#region src/hooks/use-render-options.ts
3705
+ /**
3706
+ * The renderNodes() options of the enclosing content: nobr, inline and raw
3707
+ * rendering and the locals in scope. A macro or widget body rendered with
3708
+ * them renders as the content around it does.
3709
+ */
3710
+ function useRenderOptions() {
3711
+ return {
3712
+ nobr: useContext(NobrContext),
3713
+ inline: useContext(InlineContext),
3714
+ raw: useContext(RawTextContext),
3715
+ locals: useContext(LocalsValuesContext)
3716
+ };
3717
+ }
3718
+ //#endregion
3719
+ //#region src/components/macros/locals-scope.tsx
3720
+ /**
3721
+ * Render `body` in a new locals scope: the locals of `parentValues`, with
3722
+ * `ownKeys` (an iteration's loop variables, a widget's parameters) on top.
3723
+ * Locals the body assigns are kept in this scope.
3724
+ */
3725
+ function LocalsScope({ body, parentValues, ownKeys }) {
3726
+ const options = useRenderOptions();
3687
3727
  const [localMutations, setLocalMutations] = useState({});
3688
3728
  const localState = useMemo(() => createNamespace(parentValues, ownKeys, localMutations), [
3689
3729
  parentValues,
@@ -3710,29 +3750,22 @@ function WidgetBody({ body, parentValues, ownKeys }) {
3710
3750
  children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
3711
3751
  value: localState,
3712
3752
  children: renderNodes(body, {
3713
- nobr,
3714
- inline,
3715
- raw,
3753
+ ...options,
3716
3754
  locals: localState
3717
3755
  })
3718
3756
  })
3719
3757
  });
3720
3758
  }
3759
+ //#endregion
3760
+ //#region src/components/macros/WidgetInvocation.tsx
3721
3761
  function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
3722
- const parentValues = useContext(LocalsValuesContext);
3723
- const nobr = useContext(NobrContext);
3724
- const inline = useContext(InlineContext);
3725
- const raw = useContext(RawTextContext);
3762
+ const renderOptions = useRenderOptions();
3763
+ const parentValues = renderOptions.locals;
3726
3764
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
3727
3765
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
3728
3766
  if (params.length === 0) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
3729
3767
  value: childrenValue,
3730
- children: renderNodes(body, {
3731
- nobr,
3732
- inline,
3733
- raw,
3734
- locals: parentValues
3735
- })
3768
+ children: renderNodes(body, renderOptions)
3736
3769
  });
3737
3770
  const argExprs = rawArgs ? splitArgs(rawArgs) : [];
3738
3771
  const values = [];
@@ -3753,7 +3786,7 @@ function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
3753
3786
  }, values);
3754
3787
  return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
3755
3788
  value: childrenValue,
3756
- children: /* @__PURE__ */ jsx(WidgetBody, {
3789
+ children: /* @__PURE__ */ jsx(LocalsScope, {
3757
3790
  body,
3758
3791
  parentValues,
3759
3792
  ownKeys
@@ -3958,6 +3991,32 @@ function MacroError({ macro, error }) {
3958
3991
  children: `{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`
3959
3992
  });
3960
3993
  }
3994
+ /** Log an error in the macro `{label}` (its name, or name and arguments). */
3995
+ function logMacroError(label, error) {
3996
+ console.error(`spindle: Error in {${label}}${currentSourceLocation()}:`, error);
3997
+ }
3998
+ /**
3999
+ * Run `effect` once, during the macro's first render. If it throws, the
4000
+ * error is logged under `{macro rawArgs}` and shown in place of the macro
4001
+ * from then on; otherwise the macro renders nothing.
4002
+ */
4003
+ function useRunOnce(macro, rawArgs, effect) {
4004
+ const failure = useRef();
4005
+ if (failure.current === void 0) {
4006
+ failure.current = null;
4007
+ try {
4008
+ effect();
4009
+ } catch (error) {
4010
+ failure.current = { error };
4011
+ logMacroError(`${macro} ${rawArgs}`, error);
4012
+ }
4013
+ }
4014
+ if (!failure.current) return null;
4015
+ return /* @__PURE__ */ jsx(MacroError, {
4016
+ macro,
4017
+ error: failure.current.error
4018
+ });
4019
+ }
3961
4020
  //#endregion
3962
4021
  //#region src/markup/render.tsx
3963
4022
  var defaultUpdater = {
@@ -4327,11 +4386,8 @@ function resolveAttributeValue(name, value, scope, errors) {
4327
4386
  }
4328
4387
  function HtmlNodeRenderer({ node }) {
4329
4388
  const scope = useTextScope();
4330
- const nobr = useContext(NobrContext);
4331
- const locals = useContext(LocalsValuesContext);
4332
- const inRaw = useContext(RawTextContext);
4389
+ const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
4333
4390
  const inSvg = useContext(SvgContext);
4334
- const parentInline = useContext(InlineContext);
4335
4391
  const tag = node.tag.toLowerCase();
4336
4392
  const isSvgRoot = tag === "svg";
4337
4393
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
@@ -7494,18 +7550,236 @@ function collectText(nodes) {
7494
7550
  return nodes.map((n) => n.type === "text" ? n.value : "").join("");
7495
7551
  }
7496
7552
  //#endregion
7553
+ //#region src/utils/parse-delay.ts
7554
+ /**
7555
+ * Parse a delay string into milliseconds.
7556
+ * Supports: "2s" → 2000, "500ms" → 500, bare number → ms
7557
+ */
7558
+ function parseDelay(str) {
7559
+ const trimmed = str.trim();
7560
+ if (trimmed.endsWith("ms")) return parseFloat(trimmed.slice(0, -2));
7561
+ if (trimmed.endsWith("s")) return parseFloat(trimmed.slice(0, -1)) * 1e3;
7562
+ return parseFloat(trimmed);
7563
+ }
7564
+ //#endregion
7565
+ //#region src/components/macros/macro-args.ts
7566
+ /**
7567
+ * The macro argument layer: reads a macro's raw argument text into the
7568
+ * parameters its definition declares (see ParameterDef), so every macro
7569
+ * splits and unquotes its arguments the same way. defineMacro() passes the
7570
+ * result to the macro as `ctx.args`.
7571
+ *
7572
+ * Arguments are read in groups, split at `separator` parameters. A group's
7573
+ * `flag` parameters are taken off its start or end first. Its last
7574
+ * `expression` or `text` parameter (without one, its last parameter) reads
7575
+ * the text the others leave; each parameter before it reads one term from
7576
+ * the start, and each after it one term from the end. Terms are separated by
7577
+ * whitespace outside literals and brackets (see arg-utils.ts), and a term
7578
+ * that follows an operator belongs to the expression before it. A parameter
7579
+ * with nothing to read is unset (a flag or separator is false, options are
7580
+ * empty).
7581
+ */
7582
+ /** The terms of `src`: the runs of text between its depth-0 whitespace. */
7583
+ function terms(src) {
7584
+ const spans = [];
7585
+ let start = 0;
7586
+ for (const i of [...topLevelIndices(src, isWhitespace), src.length]) {
7587
+ if (i > start) spans.push([start, i]);
7588
+ start = i + 1;
7589
+ }
7590
+ return spans;
7591
+ }
7592
+ var slice = (src, [start, end]) => src.slice(start, end);
7593
+ /**
7594
+ * The value of a `text` argument: one quoted string, or the text with any
7595
+ * loose quotes stripped (`"Red` → `Red`).
7596
+ */
7597
+ function readText(src) {
7598
+ return readWholeQuoted(src) ?? stripLooseQuotes(src);
7599
+ }
7600
+ function readValue(param, src) {
7601
+ switch (param.type) {
7602
+ case "string": return readWholeQuoted(src) ?? void 0;
7603
+ case "text": return readText(src);
7604
+ case "names": return src.split(",").map((name) => name.trim());
7605
+ case "delay": return parseDelay(src);
7606
+ case "number": return Number(src);
7607
+ case "options": return readOptions(param.parameters ?? [], src);
7608
+ default: return src;
7609
+ }
7610
+ }
7611
+ function unset(param) {
7612
+ if (param.type === "flag" || param.type === "separator") return false;
7613
+ return param.type === "options" ? {} : void 0;
7614
+ }
7615
+ var WORD_RE = /\w+/y;
7616
+ var DIGITS_RE = /\d+/y;
7617
+ /** Match the sticky regex `re` at `pos`, returning the matched text. */
7618
+ function matchAt(re, src, pos) {
7619
+ re.lastIndex = pos;
7620
+ return re.exec(src)?.[0] ?? null;
7621
+ }
7622
+ /**
7623
+ * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
7624
+ * quoted string or digit run after it as its value; keywords that aren't
7625
+ * declared, and their values, are skipped.
7626
+ */
7627
+ function readOptions(params, src) {
7628
+ const options = {};
7629
+ let i = 0;
7630
+ while (i < src.length) {
7631
+ const key = matchAt(WORD_RE, src, i);
7632
+ if (!key) {
7633
+ i = readQuoted(src, i)?.end ?? i + 1;
7634
+ continue;
7635
+ }
7636
+ i += key.length;
7637
+ let val;
7638
+ let j = i;
7639
+ while (j < src.length && isWhitespace(src[j])) j++;
7640
+ if (j > i) {
7641
+ const quoted = readQuoted(src, j);
7642
+ const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
7643
+ if (quoted) {
7644
+ val = quoted.value;
7645
+ i = quoted.end;
7646
+ } else if (digits) {
7647
+ val = digits;
7648
+ i = j + digits.length;
7649
+ }
7650
+ }
7651
+ const param = params.find((p) => p.name === key);
7652
+ if (param?.type === "flag") options[key] = true;
7653
+ else if (param?.type === "number") options[key] = Number(val ?? 0);
7654
+ else if (param) options[key] = val;
7655
+ }
7656
+ return options;
7657
+ }
7658
+ /** `inline %name` reads a transient variable; `inline % 2` is modulo. */
7659
+ var LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
7660
+ /**
7661
+ * The rest of `src` without the flag `word` as its last or first term, or
7662
+ * `null` if it has no such flag. Next to a binary operator the word is an
7663
+ * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
7664
+ * is not one.
7665
+ */
7666
+ function takeFlag(src, word) {
7667
+ const spans = terms(src);
7668
+ if (spans.length < 2) return null;
7669
+ if (slice(src, spans[spans.length - 1]) === word) {
7670
+ const rest = src.slice(0, spans[spans.length - 2][1]);
7671
+ if (!endsWithOperator(rest)) return rest;
7672
+ }
7673
+ if (slice(src, spans[0]) === word) {
7674
+ const rest = src.slice(spans[1][0]);
7675
+ if (!LEADING_OPERATOR_RE.test(rest)) return rest;
7676
+ }
7677
+ return null;
7678
+ }
7679
+ /**
7680
+ * Where the separator `word` splits `src`, as the end of the text before it
7681
+ * and the start of the text after it, or `null` if it doesn't. A word is a
7682
+ * term with terms on both sides; `=` is an assignment at depth 0, not part
7683
+ * of `==` or `!=`.
7684
+ */
7685
+ function findSeparator(src, word) {
7686
+ if (word === "=") {
7687
+ const indices = topLevelIndices(src, (ch) => ch === "=");
7688
+ for (let k = 0; k < indices.length; k++) {
7689
+ const i = indices[k];
7690
+ if (src[i + 1] === "=") {
7691
+ k++;
7692
+ continue;
7693
+ }
7694
+ if (src[i - 1] !== "!") return [i, i + 1];
7695
+ }
7696
+ return null;
7697
+ }
7698
+ const spans = terms(src);
7699
+ for (let k = 1; k < spans.length - 1; k++) if (slice(src, spans[k]) === word) return [spans[k - 1][1], spans[k + 1][0]];
7700
+ return null;
7701
+ }
7702
+ var takesRest = (param) => param.type === void 0 || param.type === "expression" || param.type === "text";
7703
+ /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
7704
+ function readGroup(params, src, args) {
7705
+ let text = src?.trim() ?? "";
7706
+ for (const flag of params.filter((p) => p.type === "flag")) {
7707
+ const rest = src === null ? null : takeFlag(text, flag.name);
7708
+ args[flag.name] = rest !== null;
7709
+ text = rest ?? text;
7710
+ }
7711
+ const positional = params.filter((p) => p.type !== "flag");
7712
+ if (positional.length === 0) return;
7713
+ const lastText = positional.findIndex((p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest));
7714
+ const restIndex = lastText < 0 ? positional.length - 1 : lastText;
7715
+ const read = (param, value) => value === "" ? unset(param) : readValue(param, value);
7716
+ if (positional.length === 1) {
7717
+ args[positional[0].name] = read(positional[0], text);
7718
+ return;
7719
+ }
7720
+ const spans = terms(text);
7721
+ let from = 0;
7722
+ let to = spans.length;
7723
+ for (const param of positional.slice(0, restIndex)) args[param.name] = from < to ? read(param, slice(text, spans[from++])) : unset(param);
7724
+ for (const param of positional.slice(restIndex + 1).reverse()) {
7725
+ const value = from < to && !endsWithOperator(text.slice(0, spans[to - 1][0])) ? read(param, slice(text, spans[to - 1])) : void 0;
7726
+ args[param.name] = value ?? unset(param);
7727
+ if (value !== void 0) to--;
7728
+ }
7729
+ const rest = positional[restIndex];
7730
+ args[rest.name] = from < to ? read(rest, text.slice(spans[from][0], spans[to - 1][1])) : unset(rest);
7731
+ }
7732
+ /** Read `rawArgs` into the declared `parameters` (see above). */
7733
+ function parseMacroArgs(rawArgs, parameters) {
7734
+ const args = {};
7735
+ let rest = rawArgs.trim();
7736
+ let groupStart = 0;
7737
+ parameters.forEach((separator, i) => {
7738
+ if (separator.type !== "separator") return;
7739
+ const at = rest === null ? null : findSeparator(rest, separator.name);
7740
+ args[separator.name] = at !== null;
7741
+ const before = at ? rest.slice(0, at[0]) : rest;
7742
+ readGroup(parameters.slice(groupStart, i), before, args);
7743
+ rest = at ? rest.slice(at[1]) : null;
7744
+ groupStart = i + 1;
7745
+ });
7746
+ readGroup(parameters.slice(groupStart), rest, args);
7747
+ return args;
7748
+ }
7749
+ /**
7750
+ * The passage a passage-name argument names: the value of its expression
7751
+ * or, when that can't be evaluated (`{goto Bob's room}`), its text.
7752
+ */
7753
+ function evaluatePassageName(expr, evaluate) {
7754
+ try {
7755
+ return String(evaluate(expr ?? ""));
7756
+ } catch {
7757
+ return readText(expr ?? "");
7758
+ }
7759
+ }
7760
+ /**
7761
+ * The variable a `storeVar` macro binds: its first term, as written. Custom
7762
+ * macros may bind one without declaring parameters.
7763
+ */
7764
+ function readBoundVariable(rawArgs) {
7765
+ const src = rawArgs.trim();
7766
+ const first = terms(src)[0];
7767
+ return first ? slice(src, first) : "";
7768
+ }
7769
+ //#endregion
7497
7770
  //#region src/components/macros/option-utils.ts
7771
+ /** `$var "placeholder"`, the arguments of the input macros. */
7498
7772
  function parseVarArgs(rawArgs) {
7499
- const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
7500
- if (!match) return {
7501
- varName: rawArgs.trim(),
7502
- placeholder: ""
7503
- };
7504
- const varName = match[1].replace(/["']/g, "");
7505
- const quoted = match[2];
7773
+ const { variable = "", placeholder = "" } = parseMacroArgs(rawArgs, [{
7774
+ name: "variable",
7775
+ type: "variable"
7776
+ }, {
7777
+ name: "placeholder",
7778
+ type: "string"
7779
+ }]);
7506
7780
  return {
7507
- varName,
7508
- placeholder: quoted ? readWholeQuoted(quoted) ?? quoted.slice(1, -1) : ""
7781
+ varName: variable.replace(/["']/g, ""),
7782
+ placeholder
7509
7783
  };
7510
7784
  }
7511
7785
  /**
@@ -7536,6 +7810,7 @@ var sharedHooks = {
7536
7810
  useContext
7537
7811
  };
7538
7812
  function defineMacro(config, source = "builtin") {
7813
+ const parameters = config.parameters ?? [];
7539
7814
  function Wrapper(props) {
7540
7815
  let className = props.className;
7541
7816
  let id = props.id;
@@ -7546,18 +7821,13 @@ function defineMacro(config, source = "builtin") {
7546
7821
  id = resolve(id);
7547
7822
  }
7548
7823
  const { update, getValues } = useContext(LocalsUpdateContext);
7549
- const nobr = useContext(NobrContext);
7550
- const inline = useContext(InlineContext);
7551
- const raw = useContext(RawTextContext);
7552
- const localsValues = useContext(LocalsValuesContext);
7824
+ const renderOptions = useRenderOptions();
7553
7825
  const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
7554
- nobr,
7555
- inline,
7556
- raw,
7557
- locals: localsValues,
7826
+ ...renderOptions,
7558
7827
  ...options
7559
7828
  });
7560
7829
  const ctx = {
7830
+ args: parseMacroArgs(props.rawArgs, parameters),
7561
7831
  collectText,
7562
7832
  sourceLocation: currentSourceLocation,
7563
7833
  parseVarArgs,
@@ -7588,7 +7858,7 @@ function defineMacro(config, source = "builtin") {
7588
7858
  ctx.evaluate = (expr) => evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
7589
7859
  }
7590
7860
  if (config.storeVar) {
7591
- const firstToken = props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, "") ?? "";
7861
+ const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, "");
7592
7862
  if (firstToken.startsWith("%")) return h("span", { class: "error" }, `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`);
7593
7863
  const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
7594
7864
  const segments = varExpr.split(".");
@@ -7605,7 +7875,11 @@ function defineMacro(config, source = "builtin") {
7605
7875
  return config.render(props, ctx);
7606
7876
  }
7607
7877
  registerMacro(config.name, Wrapper);
7608
- registerMacroText(config.name, config.text);
7878
+ const text = config.text;
7879
+ registerMacroText(config.name, text && ((props, ctx) => text(props, {
7880
+ ...ctx,
7881
+ args: parseMacroArgs(props.rawArgs, parameters)
7882
+ })));
7609
7883
  const isBlock = config.block === true || config.block !== false && (config.subMacros?.length ?? 0) > 0;
7610
7884
  registerMacroMetadata(config.name, {
7611
7885
  name: config.name,
@@ -7615,7 +7889,7 @@ function defineMacro(config, source = "builtin") {
7615
7889
  interpolate: config.interpolate,
7616
7890
  merged: config.merged,
7617
7891
  description: config.description,
7618
- parameters: config.parameters,
7892
+ parameters: config.parameters && [...config.parameters],
7619
7893
  source
7620
7894
  });
7621
7895
  if (config.subMacros) for (const sub of config.subMacros) registerSubMacro(sub);
@@ -8288,29 +8562,24 @@ defineMacro({
8288
8562
  //#region src/components/macros/Set.tsx
8289
8563
  defineMacro({
8290
8564
  name: "set",
8565
+ parameters: [{
8566
+ name: "code",
8567
+ type: "expression",
8568
+ required: true
8569
+ }],
8291
8570
  render({ rawArgs }, ctx) {
8292
- const ran = ctx.hooks.useRef(false);
8293
- const failure = ctx.hooks.useRef(null);
8294
- if (!ran.current) {
8295
- ran.current = true;
8296
- try {
8297
- ctx.mutate(rawArgs);
8298
- } catch (err) {
8299
- failure.current = { error: err };
8300
- console.error(`spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`, err);
8301
- }
8302
- }
8303
- if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
8304
- macro: "set",
8305
- error: failure.current.error
8306
- });
8307
- return null;
8571
+ return useRunOnce("set", rawArgs, () => ctx.mutate(rawArgs));
8308
8572
  }
8309
8573
  });
8310
8574
  //#endregion
8311
8575
  //#region src/components/macros/Print.tsx
8312
8576
  defineMacro({
8313
8577
  name: "print",
8578
+ parameters: [{
8579
+ name: "expression",
8580
+ type: "expression",
8581
+ required: true
8582
+ }],
8314
8583
  interpolate: true,
8315
8584
  merged: true,
8316
8585
  render({ rawArgs }, ctx) {
@@ -8338,30 +8607,16 @@ defineMacro({
8338
8607
  * applied, even `undefined`; a first evaluation that throws leaves it unset.
8339
8608
  */
8340
8609
  var UNSET = Symbol("unset");
8341
- function parseComputedArgs(rawArgs) {
8342
- const trimmed = rawArgs.trim();
8343
- let depth = 0;
8344
- for (let i = 0; i < trimmed.length; i++) {
8345
- const ch = trimmed[i];
8346
- if (ch === "(" || ch === "[" || ch === "{") depth++;
8347
- else if (ch === ")" || ch === "]" || ch === "}") depth--;
8348
- else if (ch === "=" && depth === 0) {
8349
- if (trimmed[i + 1] === "=") {
8350
- i++;
8351
- continue;
8352
- }
8353
- if (i > 0 && trimmed[i - 1] === "!") continue;
8354
- const target = trimmed.slice(0, i).trim();
8355
- const expr = trimmed.slice(i + 1).trim();
8356
- if (!target.match(/^[$_@]\w+$/)) throw new Error(`{computed}: target must be $name, _name, or @name, got "${target}"`);
8357
- checkVariableName(target.slice(1), target);
8358
- return {
8359
- target,
8360
- expr
8361
- };
8362
- }
8363
- }
8364
- throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
8610
+ /** Check the arguments of "target = expression". */
8611
+ function assignmentArgs(rawArgs, args) {
8612
+ if (!args["="]) throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
8613
+ const { target = "", expression: expr = "" } = args;
8614
+ if (!target.match(/^[$_@]\w+$/)) throw new Error(`{computed}: target must be $name, _name, or @name, got "${target}"`);
8615
+ checkVariableName(target.slice(1), target);
8616
+ return {
8617
+ target,
8618
+ expr
8619
+ };
8365
8620
  }
8366
8621
  /**
8367
8622
  * Evaluate `expr` against the current values — the store's state and the
@@ -8378,7 +8633,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8378
8633
  const { variables, temporary, transient } = readState();
8379
8634
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
8380
8635
  } catch (err) {
8381
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
8636
+ logMacroError(`computed ${rawArgs}`, err);
8382
8637
  return;
8383
8638
  }
8384
8639
  if (!deepEqual(prevRef.current, newValue)) {
@@ -8386,7 +8641,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8386
8641
  if (isLocal) try {
8387
8642
  localsUpdate(name, newValue);
8388
8643
  } catch (err) {
8389
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
8644
+ logMacroError(`computed ${rawArgs}`, err);
8390
8645
  }
8391
8646
  else {
8392
8647
  const state = useStoryStore.getState();
@@ -8398,12 +8653,28 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8398
8653
  defineMacro({
8399
8654
  name: "computed",
8400
8655
  merged: true,
8656
+ parameters: [
8657
+ {
8658
+ name: "target",
8659
+ type: "variable",
8660
+ required: true
8661
+ },
8662
+ {
8663
+ name: "=",
8664
+ type: "separator"
8665
+ },
8666
+ {
8667
+ name: "expression",
8668
+ type: "expression",
8669
+ required: true
8670
+ }
8671
+ ],
8401
8672
  render({ rawArgs }, ctx) {
8402
8673
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged;
8403
8674
  let target;
8404
8675
  let expr;
8405
8676
  try {
8406
- ({target, expr} = parseComputedArgs(rawArgs));
8677
+ ({target, expr} = assignmentArgs(rawArgs, ctx.args));
8407
8678
  } catch (err) {
8408
8679
  return /* @__PURE__ */ jsx(MacroError, {
8409
8680
  macro: "computed",
@@ -8415,14 +8686,13 @@ defineMacro({
8415
8686
  const name = target.slice(1);
8416
8687
  const localsUpdate = isLocal ? ctx.update : null;
8417
8688
  const prevOutput = ctx.hooks.useRef(UNSET);
8689
+ const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8418
8690
  const ran = ctx.hooks.useRef(false);
8419
8691
  if (!ran.current) {
8420
8692
  ran.current = true;
8421
- computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8693
+ compute();
8422
8694
  }
8423
- ctx.hooks.useLayoutEffect(() => {
8424
- computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8425
- }, [
8695
+ ctx.hooks.useLayoutEffect(compute, [
8426
8696
  mergedVars,
8427
8697
  mergedTemps,
8428
8698
  mergedLocals,
@@ -8433,26 +8703,13 @@ defineMacro({
8433
8703
  });
8434
8704
  //#endregion
8435
8705
  //#region src/components/macros/Meter.tsx
8436
- /**
8437
- * Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
8438
- * whitespace outside string and template literals and bracket pairs; a
8439
- * trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
8440
- * `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
8441
- * operand of the max expression, not the label.
8442
- */
8443
- function parseMeterArgs(rawArgs) {
8444
- const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
8445
- let labelMode = "";
8446
- const label = tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(" ")) ? readWholeQuoted(tokens[tokens.length - 1]) : null;
8447
- if (label !== null) {
8448
- labelMode = label;
8449
- tokens.pop();
8450
- }
8451
- if (tokens.length < 2) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
8706
+ /** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
8707
+ function meterArgs(args) {
8708
+ if (args.current === void 0 || args.max === void 0) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
8452
8709
  return {
8453
- currentExpr: tokens[0],
8454
- maxExpr: tokens.slice(1).join(" "),
8455
- labelMode
8710
+ currentExpr: args.current,
8711
+ maxExpr: args.max,
8712
+ labelMode: args.label ?? ""
8456
8713
  };
8457
8714
  }
8458
8715
  function formatLabel(current, max, labelMode) {
@@ -8465,9 +8722,25 @@ defineMacro({
8465
8722
  name: "meter",
8466
8723
  interpolate: true,
8467
8724
  merged: true,
8468
- render({ rawArgs }, ctx) {
8725
+ parameters: [
8726
+ {
8727
+ name: "current",
8728
+ type: "expression",
8729
+ required: true
8730
+ },
8731
+ {
8732
+ name: "max",
8733
+ type: "expression",
8734
+ required: true
8735
+ },
8736
+ {
8737
+ name: "label",
8738
+ type: "string"
8739
+ }
8740
+ ],
8741
+ render(_props, ctx) {
8469
8742
  try {
8470
- const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
8743
+ const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
8471
8744
  const current = Number(ctx.evaluate(currentExpr));
8472
8745
  const max = Number(ctx.evaluate(maxExpr));
8473
8746
  const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
@@ -8583,16 +8856,12 @@ function keyOf(val, ancestors) {
8583
8856
  //#endregion
8584
8857
  //#region src/components/macros/For.tsx
8585
8858
  /**
8586
- * Parse for-loop args: "@item, @i of $list" or "@item of $list"
8859
+ * Check the arguments of "@item, @i of $list" or "@item of $list".
8587
8860
  */
8588
- function parseForArgs(rawArgs) {
8589
- const ofIdx = rawArgs.indexOf(" of ");
8590
- if (ofIdx === -1) throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
8591
- const varsPart = rawArgs.slice(0, ofIdx).trim();
8592
- const listExpr = rawArgs.slice(ofIdx + 4).trim();
8593
- const vars = varsPart.split(",").map((v) => v.trim());
8594
- const itemVar = vars[0];
8595
- const indexVar = vars.length > 1 ? vars[1] : null;
8861
+ function loopArgs(rawArgs, args) {
8862
+ if (!args.of) throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
8863
+ const [itemVar = "", indexVar = null] = args.variables ?? [];
8864
+ const listExpr = args.list ?? "";
8596
8865
  if (!itemVar.startsWith("@")) throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
8597
8866
  if (indexVar && !indexVar.startsWith("@")) throw new Error(`{for} index variable must use @ prefix: got "${indexVar}"`);
8598
8867
  checkVariableName(itemVar.slice(1), itemVar);
@@ -8604,50 +8873,18 @@ function parseForArgs(rawArgs) {
8604
8873
  };
8605
8874
  }
8606
8875
  function ForIteration({ parentValues, itemVar, itemValue, indexVar, indexValue, children }) {
8607
- const [localMutations, setLocalMutations] = useState(() => ({}));
8608
- const ownKeys = useMemo(() => ({
8609
- [itemVar]: itemValue,
8610
- ...indexVar ? { [indexVar]: indexValue } : void 0
8611
- }), [
8612
- itemVar,
8613
- itemValue,
8614
- indexVar,
8615
- indexValue
8616
- ]);
8617
- const localState = useMemo(() => createNamespace(parentValues, ownKeys, localMutations), [
8876
+ return /* @__PURE__ */ jsx(LocalsScope, {
8877
+ body: children,
8618
8878
  parentValues,
8619
- ownKeys,
8620
- localMutations
8621
- ]);
8622
- const valuesRef = useRef(localState);
8623
- valuesRef.current = localState;
8624
- const getValues = useCallback(() => valuesRef.current, []);
8625
- const update = useCallback((key, value) => {
8626
- checkVariableName(key, `@${key}`);
8627
- valuesRef.current = withEntry(valuesRef.current, key, value);
8628
- setLocalMutations((prev) => ({
8629
- ...prev,
8630
- [key]: value
8631
- }));
8632
- }, []);
8633
- const updater = useMemo(() => ({
8634
- update,
8635
- getValues
8636
- }), [update, getValues]);
8637
- const nobr = useContext(NobrContext);
8638
- const inline = useContext(InlineContext);
8639
- const raw = useContext(RawTextContext);
8640
- return /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
8641
- value: updater,
8642
- children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
8643
- value: localState,
8644
- children: renderNodes(children, {
8645
- nobr,
8646
- inline,
8647
- raw,
8648
- locals: localState
8649
- })
8650
- })
8879
+ ownKeys: useMemo(() => ({
8880
+ [itemVar]: itemValue,
8881
+ ...indexVar ? { [indexVar]: indexValue } : void 0
8882
+ }), [
8883
+ itemVar,
8884
+ itemValue,
8885
+ indexVar,
8886
+ indexValue
8887
+ ])
8651
8888
  });
8652
8889
  }
8653
8890
  defineMacro({
@@ -8655,21 +8892,29 @@ defineMacro({
8655
8892
  block: true,
8656
8893
  interpolate: true,
8657
8894
  merged: true,
8895
+ parameters: [
8896
+ {
8897
+ name: "variables",
8898
+ type: "names",
8899
+ required: true
8900
+ },
8901
+ {
8902
+ name: "of",
8903
+ type: "separator"
8904
+ },
8905
+ {
8906
+ name: "list",
8907
+ type: "expression",
8908
+ required: true
8909
+ }
8910
+ ],
8658
8911
  render({ rawArgs, children = [] }, ctx) {
8659
8912
  const parentValues = useContext(LocalsValuesContext);
8660
- let parsed;
8661
- try {
8662
- parsed = parseForArgs(rawArgs);
8663
- } catch (err) {
8664
- return /* @__PURE__ */ jsx(MacroError, {
8665
- macro: "for",
8666
- error: err
8667
- });
8668
- }
8669
- const { itemVar, indexVar, listExpr } = parsed;
8913
+ let loop;
8670
8914
  let list;
8671
8915
  try {
8672
- const result = ctx.evaluate(listExpr);
8916
+ loop = loopArgs(rawArgs, ctx.args);
8917
+ const result = ctx.evaluate(loop.listExpr);
8673
8918
  if (!Array.isArray(result)) return /* @__PURE__ */ jsx("span", {
8674
8919
  class: "error",
8675
8920
  children: `{for error: expression did not evaluate to an array}`
@@ -8681,6 +8926,7 @@ defineMacro({
8681
8926
  error: err
8682
8927
  });
8683
8928
  }
8929
+ const { itemVar, indexVar } = loop;
8684
8930
  const content = list.map((item, i) => /* @__PURE__ */ jsx(ForIteration, {
8685
8931
  parentValues,
8686
8932
  itemVar,
@@ -8692,7 +8938,7 @@ defineMacro({
8692
8938
  return ctx.wrap(content);
8693
8939
  },
8694
8940
  text({ rawArgs, children = [] }, ctx) {
8695
- const { itemVar, indexVar, listExpr } = parseForArgs(rawArgs);
8941
+ const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
8696
8942
  const list = ctx.evaluate(listExpr);
8697
8943
  if (!Array.isArray(list)) throw new Error("expression did not evaluate to an array");
8698
8944
  return list.map((item, i) => ctx.renderText(children, {
@@ -8712,7 +8958,7 @@ defineMacro({
8712
8958
  try {
8713
8959
  ctx.mutate(code);
8714
8960
  } catch (err) {
8715
- console.error(`spindle: Error in {do}${ctx.sourceLocation()}:`, err);
8961
+ logMacroError("do", err);
8716
8962
  }
8717
8963
  }, []);
8718
8964
  return null;
@@ -8743,27 +8989,20 @@ function liveLocalsView(getValues) {
8743
8989
  });
8744
8990
  }
8745
8991
  //#endregion
8746
- //#region src/utils/parse-delay.ts
8747
- /**
8748
- * Parse a delay string into milliseconds.
8749
- * Supports: "2s" → 2000, "500ms" → 500, bare number → ms
8750
- */
8751
- function parseDelay(str) {
8752
- const trimmed = str.trim();
8753
- if (trimmed.endsWith("ms")) return parseFloat(trimmed.slice(0, -2));
8754
- if (trimmed.endsWith("s")) return parseFloat(trimmed.slice(0, -1)) * 1e3;
8755
- return parseFloat(trimmed);
8756
- }
8757
- //#endregion
8758
8992
  //#region src/components/macros/Repeat.tsx
8759
8993
  var RepeatContext = createContext({ stop: () => {} });
8760
8994
  defineMacro({
8761
8995
  name: "repeat",
8762
8996
  block: true,
8763
8997
  interpolate: true,
8764
- render({ rawArgs, children = [] }, ctx) {
8998
+ parameters: [{
8999
+ name: "delay",
9000
+ type: "delay",
9001
+ required: true
9002
+ }],
9003
+ render({ children = [] }, ctx) {
8765
9004
  const { useState, useEffect, useCallback } = ctx.hooks;
8766
- const delay = parseDelay(rawArgs);
9005
+ const delay = ctx.args.delay ?? 0;
8767
9006
  const [count, setCount] = useState(0);
8768
9007
  const [stopped, setStopped] = useState(false);
8769
9008
  const stop = useCallback(() => setStopped(true), []);
@@ -8810,8 +9049,7 @@ defineMacro({
8810
9049
  */
8811
9050
  function useDetachedBody() {
8812
9051
  const updater = useContext(LocalsUpdateContext);
8813
- const nobr = useContext(NobrContext);
8814
- const inline = useContext(InlineContext);
9052
+ const { nobr, inline } = useRenderOptions();
8815
9053
  const widgetChildren = useContext(WidgetChildrenContext);
8816
9054
  const repeat = useContext(RepeatContext);
8817
9055
  const closeDialog = useContext(DialogCloseContext);
@@ -8854,9 +9092,13 @@ defineMacro({
8854
9092
  name: "button",
8855
9093
  block: true,
8856
9094
  interpolate: true,
9095
+ parameters: [{
9096
+ name: "label",
9097
+ type: "text",
9098
+ required: true
9099
+ }],
8857
9100
  render({ rawArgs, children = [] }, ctx) {
8858
- const text = readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, "");
8859
- const label = ctx.resolve?.(text) ?? rawArgs;
9101
+ const label = ctx.resolve?.(ctx.args.label ?? "") ?? rawArgs;
8860
9102
  const runBody = useDetachedBody();
8861
9103
  const handleClick = () => runBody(children);
8862
9104
  ctx.useAction({
@@ -8876,30 +9118,22 @@ defineMacro({
8876
9118
  });
8877
9119
  //#endregion
8878
9120
  //#region src/components/macros/MacroLink.tsx
8879
- function parseLinkArgs(rawArgs) {
8880
- const parts = [];
8881
- const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
8882
- let m;
8883
- while ((m = re.exec(rawArgs)) !== null) parts.push(unescapeQuoted(m[2]));
8884
- if (parts.length >= 2) return {
8885
- display: parts[0],
8886
- passage: parts[1]
8887
- };
8888
- if (parts.length === 1) return {
8889
- display: parts[0],
8890
- passage: null
8891
- };
8892
- return {
8893
- display: rawArgs.trim(),
8894
- passage: null
8895
- };
8896
- }
8897
9121
  defineMacro({
8898
9122
  name: "link",
8899
9123
  block: true,
8900
9124
  interpolate: true,
9125
+ parameters: [{
9126
+ name: "text",
9127
+ type: "string",
9128
+ required: true
9129
+ }, {
9130
+ name: "passage",
9131
+ type: "string"
9132
+ }],
8901
9133
  render({ rawArgs, children = [] }, ctx) {
8902
- const { display, passage } = parseLinkArgs(rawArgs);
9134
+ const { text } = ctx.args;
9135
+ const display = text ?? rawArgs.trim();
9136
+ const passage = text === void 0 ? null : ctx.args.passage ?? null;
8903
9137
  const runBody = useDetachedBody();
8904
9138
  const perform = () => {
8905
9139
  runBody(children);
@@ -8928,9 +9162,27 @@ defineMacro({
8928
9162
  });
8929
9163
  //#endregion
8930
9164
  //#region src/components/macros/Switch.tsx
9165
+ /**
9166
+ * The first {case} branch whose value equals `value`, else the {default}
9167
+ * branch, else null. Throws what a case expression throws.
9168
+ */
9169
+ function selectBranch(value, branches, evaluate) {
9170
+ let defaultBranch = null;
9171
+ for (let i = 1; i < branches.length; i++) {
9172
+ const branch = branches[i];
9173
+ if (branch.rawArgs === "") defaultBranch = branch;
9174
+ else if (value === evaluate(branch.rawArgs)) return branch;
9175
+ }
9176
+ return defaultBranch;
9177
+ }
8931
9178
  defineMacro({
8932
9179
  name: "switch",
8933
9180
  subMacros: ["case", "default"],
9181
+ parameters: [{
9182
+ name: "expression",
9183
+ type: "expression",
9184
+ required: true
9185
+ }],
8934
9186
  merged: true,
8935
9187
  render({ rawArgs, branches = [] }, ctx) {
8936
9188
  let switchValue;
@@ -8942,35 +9194,20 @@ defineMacro({
8942
9194
  error: err
8943
9195
  });
8944
9196
  }
8945
- let defaultBranch = null;
8946
- for (let i = 1; i < branches.length; i++) {
8947
- const branch = branches[i];
8948
- if (branch.rawArgs === "") {
8949
- defaultBranch = branch;
8950
- continue;
8951
- }
8952
- try {
8953
- const caseValue = ctx.evaluate(branch.rawArgs);
8954
- if (switchValue === caseValue) return /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
8955
- } catch (err) {
8956
- return /* @__PURE__ */ jsx(MacroError, {
8957
- macro: "case",
8958
- error: err
8959
- });
8960
- }
9197
+ let branch;
9198
+ try {
9199
+ branch = selectBranch(switchValue, branches, ctx.evaluate);
9200
+ } catch (err) {
9201
+ return /* @__PURE__ */ jsx(MacroError, {
9202
+ macro: "case",
9203
+ error: err
9204
+ });
8961
9205
  }
8962
- if (defaultBranch) return /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(defaultBranch.children) });
8963
- return null;
9206
+ return branch && /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
8964
9207
  },
8965
9208
  text({ rawArgs, branches = [] }, ctx) {
8966
- const switchValue = ctx.evaluate(rawArgs);
8967
- let defaultBranch = null;
8968
- for (let i = 1; i < branches.length; i++) {
8969
- const branch = branches[i];
8970
- if (branch.rawArgs === "") defaultBranch = branch;
8971
- else if (switchValue === ctx.evaluate(branch.rawArgs)) return ctx.renderText(branch.children);
8972
- }
8973
- return defaultBranch ? ctx.renderText(defaultBranch.children) : "";
9209
+ const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9210
+ return branch ? ctx.renderText(branch.children) : "";
8974
9211
  }
8975
9212
  });
8976
9213
  //#endregion
@@ -9030,9 +9267,14 @@ defineMacro({
9030
9267
  name: "type",
9031
9268
  block: true,
9032
9269
  interpolate: true,
9033
- render({ rawArgs, children = [] }, ctx) {
9270
+ parameters: [{
9271
+ name: "delay",
9272
+ type: "delay",
9273
+ required: true
9274
+ }],
9275
+ render({ children = [] }, ctx) {
9034
9276
  const { useState, useEffect, useRef } = ctx.hooks;
9035
- const speed = parseDelay(rawArgs);
9277
+ const speed = ctx.args.delay ?? 0;
9036
9278
  const containerRef = useRef(null);
9037
9279
  const [totalChars, setTotalChars] = useState(0);
9038
9280
  const [visibleChars, setVisibleChars] = useState(0);
@@ -9213,11 +9455,18 @@ defineMacro({
9213
9455
  name: "dialog",
9214
9456
  block: true,
9215
9457
  interpolate: true,
9216
- render({ rawArgs, children = [] }, ctx) {
9458
+ parameters: [{
9459
+ name: "label",
9460
+ type: "text",
9461
+ required: true
9462
+ }, {
9463
+ name: "noclose",
9464
+ type: "flag"
9465
+ }],
9466
+ render({ children = [] }, ctx) {
9217
9467
  const [open, setOpen] = ctx.hooks.useState(false);
9218
- const noclose = /\bnoclose\s*$/.test(rawArgs);
9219
- const labelRaw = rawArgs.replace(/\bnoclose\s*$/, "").trim();
9220
- const label = ctx.resolve?.(readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, "")) ?? labelRaw;
9468
+ const { noclose } = ctx.args;
9469
+ const label = ctx.resolve(ctx.args.label ?? "");
9221
9470
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
9222
9471
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
9223
9472
  id: ctx.id,
@@ -9624,59 +9873,22 @@ defineMacro({
9624
9873
  });
9625
9874
  //#endregion
9626
9875
  //#region src/components/macros/Include.tsx
9627
- var FLAG = "inline";
9628
- /**
9629
- * Split a standalone `inline` flag off the start or end of the include
9630
- * arguments. The flag only counts outside quotes, brackets, parentheses and
9631
- * braces, separated by whitespace from the target expression, so passage
9632
- * names and expressions containing the word stay intact (#201).
9633
- */
9634
- function parseIncludeArgs(rawArgs) {
9635
- const trimmed = rawArgs.trim();
9636
- const spaces = topLevelIndices(trimmed, isWhitespace);
9637
- let first = null;
9638
- let last = null;
9639
- if (spaces.length > 0) {
9640
- let s = 0;
9641
- while (s + 1 < spaces.length && spaces[s + 1] === spaces[s] + 1) s++;
9642
- first = [spaces[0], spaces[s] + 1];
9643
- let e = spaces.length - 1;
9644
- while (e > 0 && spaces[e - 1] === spaces[e] - 1) e--;
9645
- last = [spaces[e], spaces[spaces.length - 1] + 1];
9646
- }
9647
- if (last && last[1] === trimmed.length - 6 && trimmed.endsWith(FLAG)) {
9648
- const rest = trimmed.slice(0, last[0]);
9649
- if (!endsWithOperator(rest)) return {
9650
- nameExpr: rest,
9651
- inline: true
9652
- };
9653
- }
9654
- if (first && first[0] === 6 && trimmed.startsWith(FLAG)) {
9655
- const rest = trimmed.slice(first[1]);
9656
- if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest)) return {
9657
- nameExpr: rest,
9658
- inline: true
9659
- };
9660
- }
9661
- return {
9662
- nameExpr: trimmed,
9663
- inline: false
9664
- };
9665
- }
9666
9876
  defineMacro({
9667
9877
  name: "include",
9668
9878
  interpolate: true,
9669
9879
  merged: true,
9670
- render({ rawArgs }, ctx) {
9880
+ parameters: [{
9881
+ name: "inline",
9882
+ type: "flag"
9883
+ }, {
9884
+ name: "passage",
9885
+ type: "expression",
9886
+ required: true
9887
+ }],
9888
+ render(_props, ctx) {
9671
9889
  const storyData = useStoryStore((s) => s.storyData);
9672
- const { nameExpr, inline } = parseIncludeArgs(rawArgs);
9673
- let passageName;
9674
- try {
9675
- const result = ctx.evaluate(nameExpr);
9676
- passageName = String(result);
9677
- } catch {
9678
- passageName = nameExpr.replace(/^["']|["']$/g, "");
9679
- }
9890
+ const { inline } = ctx.args;
9891
+ const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate);
9680
9892
  const passage = storyData?.passages.get(passageName);
9681
9893
  const ast = ctx.hooks.useMemo(() => passage ? buildAST(tokenize(passage.content)) : null, [passage]);
9682
9894
  if (!storyData) return null;
@@ -9706,16 +9918,14 @@ defineMacro({
9706
9918
  defineMacro({
9707
9919
  name: "goto",
9708
9920
  merged: true,
9709
- render({ rawArgs }, ctx) {
9921
+ parameters: [{
9922
+ name: "passage",
9923
+ type: "expression",
9924
+ required: true
9925
+ }],
9926
+ render(_props, ctx) {
9710
9927
  ctx.hooks.useLayoutEffect(() => {
9711
- let passageName;
9712
- try {
9713
- const result = ctx.evaluate(rawArgs);
9714
- passageName = String(result);
9715
- } catch {
9716
- passageName = rawArgs.replace(/^["']|["']$/g, "");
9717
- }
9718
- useStoryStore.getState().navigate(passageName);
9928
+ useStoryStore.getState().navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate));
9719
9929
  }, []);
9720
9930
  return null;
9721
9931
  }
@@ -9724,124 +9934,90 @@ defineMacro({
9724
9934
  //#region src/components/macros/Unset.tsx
9725
9935
  defineMacro({
9726
9936
  name: "unset",
9937
+ parameters: [{
9938
+ name: "variable",
9939
+ type: "variable",
9940
+ required: true
9941
+ }],
9727
9942
  render({ rawArgs }, ctx) {
9728
- const ran = ctx.hooks.useRef(false);
9729
- const failure = ctx.hooks.useRef(null);
9730
- if (!ran.current) {
9731
- ran.current = true;
9943
+ return useRunOnce("unset", rawArgs, () => {
9732
9944
  const state = useStoryStore.getState();
9733
- const name = rawArgs.trim();
9734
- try {
9735
- if (name.startsWith("$")) state.deleteVariable(name.slice(1));
9736
- else if (name.startsWith("_")) state.deleteTemporary(name.slice(1));
9737
- else if (name.startsWith("%")) state.deleteTransient(name.slice(1));
9738
- else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
9739
- else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
9740
- } catch (err) {
9741
- failure.current = { error: err };
9742
- console.error(`spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`, err);
9743
- }
9744
- }
9745
- if (failure.current) return /* @__PURE__ */ jsx(MacroError, {
9746
- macro: "unset",
9747
- error: failure.current.error
9945
+ const name = ctx.args.variable ?? "";
9946
+ if (name.startsWith("$")) state.deleteVariable(name.slice(1));
9947
+ else if (name.startsWith("_")) state.deleteTemporary(name.slice(1));
9948
+ else if (name.startsWith("%")) state.deleteTransient(name.slice(1));
9949
+ else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
9950
+ else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
9748
9951
  });
9749
- return null;
9750
9952
  }
9751
9953
  });
9752
9954
  //#endregion
9955
+ //#region src/components/macros/input-macro.ts
9956
+ /**
9957
+ * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
9958
+ * `type` (or, without one, a `<textarea>`) bound to the story variable.
9959
+ * `toValue` turns what the reader types, or an automation action's value
9960
+ * (`undefined` when it has none), into the variable's value: by default,
9961
+ * the text.
9962
+ */
9963
+ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? String(input) : "") {
9964
+ defineMacro({
9965
+ name,
9966
+ storeVar: true,
9967
+ parameters: [{
9968
+ name: "variable",
9969
+ type: "variable",
9970
+ required: true
9971
+ }, {
9972
+ name: "placeholder",
9973
+ type: "string"
9974
+ }],
9975
+ render(_props, ctx) {
9976
+ const placeholder = ctx.args.placeholder ?? "";
9977
+ ctx.useAction({
9978
+ type: name,
9979
+ key: `$${ctx.varName}`,
9980
+ authorId: ctx.id,
9981
+ label: placeholder || ctx.varName,
9982
+ variable: ctx.varName,
9983
+ value: ctx.value,
9984
+ perform: (v) => ctx.setValue(toValue(v))
9985
+ });
9986
+ return h(type ? "input" : "textarea", {
9987
+ type,
9988
+ id: ctx.id,
9989
+ class: ctx.cls,
9990
+ value: ctx.value == null ? "" : String(ctx.value),
9991
+ placeholder,
9992
+ onInput: (e) => ctx.setValue(toValue(e.target.value))
9993
+ });
9994
+ }
9995
+ });
9996
+ }
9997
+ //#endregion
9753
9998
  //#region src/components/macros/Textbox.tsx
9754
- defineMacro({
9755
- name: "textbox",
9756
- storeVar: true,
9757
- render({ rawArgs }, ctx) {
9758
- const { placeholder } = ctx.parseVarArgs(rawArgs);
9759
- ctx.useAction({
9760
- type: "textbox",
9761
- key: `$${ctx.varName}`,
9762
- authorId: ctx.id,
9763
- label: placeholder || ctx.varName,
9764
- variable: ctx.varName,
9765
- value: ctx.value,
9766
- perform: (v) => ctx.setValue(v !== void 0 ? String(v) : "")
9767
- });
9768
- return /* @__PURE__ */ jsx("input", {
9769
- type: "text",
9770
- id: ctx.id,
9771
- class: ctx.cls,
9772
- value: ctx.value == null ? "" : String(ctx.value),
9773
- placeholder,
9774
- onInput: (e) => ctx.setValue(e.target.value)
9775
- });
9776
- }
9777
- });
9999
+ defineInputMacro("textbox", "text");
9778
10000
  //#endregion
9779
10001
  //#region src/components/macros/Numberbox.tsx
9780
- defineMacro({
9781
- name: "numberbox",
9782
- storeVar: true,
9783
- render({ rawArgs }, ctx) {
9784
- const { placeholder } = ctx.parseVarArgs(rawArgs);
9785
- ctx.useAction({
9786
- type: "numberbox",
9787
- key: `$${ctx.varName}`,
9788
- authorId: ctx.id,
9789
- label: placeholder || ctx.varName,
9790
- variable: ctx.varName,
9791
- value: ctx.value,
9792
- perform: (v) => ctx.setValue(v !== void 0 ? Number(v) : 0)
9793
- });
9794
- return /* @__PURE__ */ jsx("input", {
9795
- type: "number",
9796
- id: ctx.id,
9797
- class: ctx.cls,
9798
- value: ctx.value == null ? "" : String(ctx.value),
9799
- placeholder,
9800
- onInput: (e) => {
9801
- const val = e.target.value;
9802
- ctx.setValue(val === "" ? 0 : Number(val));
9803
- }
9804
- });
9805
- }
9806
- });
10002
+ defineInputMacro("numberbox", "number", (v) => v !== void 0 ? Number(v) : 0);
9807
10003
  //#endregion
9808
10004
  //#region src/components/macros/Textarea.tsx
9809
- defineMacro({
9810
- name: "textarea",
9811
- storeVar: true,
9812
- render({ rawArgs }, ctx) {
9813
- const { placeholder } = ctx.parseVarArgs(rawArgs);
9814
- ctx.useAction({
9815
- type: "textarea",
9816
- key: `$${ctx.varName}`,
9817
- authorId: ctx.id,
9818
- label: placeholder || ctx.varName,
9819
- variable: ctx.varName,
9820
- value: ctx.value,
9821
- perform: (v) => ctx.setValue(v !== void 0 ? String(v) : "")
9822
- });
9823
- return /* @__PURE__ */ jsx("textarea", {
9824
- id: ctx.id,
9825
- class: ctx.cls,
9826
- value: ctx.value == null ? "" : String(ctx.value),
9827
- placeholder,
9828
- onInput: (e) => ctx.setValue(e.target.value)
9829
- });
9830
- }
9831
- });
10005
+ defineInputMacro("textarea");
9832
10006
  //#endregion
9833
10007
  //#region src/components/macros/Checkbox.tsx
9834
- function parseCheckboxLabel(rawArgs) {
9835
- const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+)$/s);
9836
- if (!match) return "";
9837
- const rest = match[1].trimEnd() || match[1];
9838
- return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
9839
- }
9840
10008
  defineMacro({
9841
10009
  name: "checkbox",
9842
10010
  storeVar: true,
9843
- render({ rawArgs }, ctx) {
9844
- const label = parseCheckboxLabel(rawArgs);
10011
+ parameters: [{
10012
+ name: "variable",
10013
+ type: "variable",
10014
+ required: true
10015
+ }, {
10016
+ name: "label",
10017
+ type: "text"
10018
+ }],
10019
+ render(_props, ctx) {
10020
+ const label = ctx.args.label ?? "";
9845
10021
  ctx.useAction({
9846
10022
  type: "checkbox",
9847
10023
  key: `$${ctx.varName}`,
@@ -9864,38 +10040,28 @@ defineMacro({
9864
10040
  });
9865
10041
  //#endregion
9866
10042
  //#region src/components/macros/Radiobutton.tsx
9867
- function parseRadioArgs(rawArgs) {
9868
- const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
9869
- if (head) {
9870
- const rest = rawArgs.slice(head[0].length).trim();
9871
- const value = readQuoted(rest, 0);
9872
- if (value && (value.end === rest.length || isWhitespace(rest[value.end]))) {
9873
- const labelRaw = rest.slice(value.end).trim();
9874
- const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
9875
- return {
9876
- value: value.value,
9877
- label
9878
- };
9879
- }
9880
- }
9881
- const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s);
9882
- if (!match) {
9883
- const parts = rawArgs.trim().split(/\s+/).slice(1);
9884
- return {
9885
- value: parts[0] ?? "",
9886
- label: parts.slice(1).join(" ")
9887
- };
9888
- }
9889
- return {
9890
- value: match[1],
9891
- label: match[2]
9892
- };
9893
- }
9894
10043
  defineMacro({
9895
10044
  name: "radiobutton",
9896
10045
  storeVar: true,
9897
- render({ rawArgs }, ctx) {
9898
- const { value: radioValue, label } = parseRadioArgs(rawArgs);
10046
+ parameters: [
10047
+ {
10048
+ name: "variable",
10049
+ type: "variable",
10050
+ required: true
10051
+ },
10052
+ {
10053
+ name: "value",
10054
+ type: "text",
10055
+ required: true
10056
+ },
10057
+ {
10058
+ name: "label",
10059
+ type: "text"
10060
+ }
10061
+ ],
10062
+ render(_props, ctx) {
10063
+ const radioValue = ctx.args.value ?? "";
10064
+ const label = ctx.args.label ?? "";
9899
10065
  ctx.useAction({
9900
10066
  type: "radiobutton",
9901
10067
  key: `$${ctx.varName}:${radioValue}`,
@@ -9923,6 +10089,11 @@ defineMacro({
9923
10089
  name: "listbox",
9924
10090
  subMacros: ["option"],
9925
10091
  storeVar: true,
10092
+ parameters: [{
10093
+ name: "variable",
10094
+ type: "variable",
10095
+ required: true
10096
+ }],
9926
10097
  render({ children = [] }, ctx) {
9927
10098
  const options = ctx.extractOptions(children);
9928
10099
  ctx.useAction({
@@ -9955,6 +10126,11 @@ defineMacro({
9955
10126
  name: "cycle",
9956
10127
  block: true,
9957
10128
  storeVar: true,
10129
+ parameters: [{
10130
+ name: "variable",
10131
+ type: "variable",
10132
+ required: true
10133
+ }],
9958
10134
  render({ children = [] }, ctx) {
9959
10135
  const options = ctx.extractOptions(children);
9960
10136
  const handleClick = () => {
@@ -10191,6 +10367,16 @@ function setCSSProperties(el, config) {
10191
10367
  el.style.setProperty("--passage-pause", `${config.pause / 1e3}s`);
10192
10368
  }
10193
10369
  /**
10370
+ * Set up `containerEl` for an outgoing transition and snapshot the passage
10371
+ * it shows, or return null if there is no container or passage.
10372
+ */
10373
+ function snapshotPassage(containerEl, config) {
10374
+ if (!containerEl) return null;
10375
+ setCSSProperties(containerEl, config);
10376
+ const passageEl = containerEl.querySelector(".passage");
10377
+ return passageEl ? createSnapshot(passageEl, config.type) : null;
10378
+ }
10379
+ /**
10194
10380
  * Remove all snapshot elements and crossfading class from a container.
10195
10381
  */
10196
10382
  function cleanupSnapshots(containerEl) {
@@ -10258,18 +10444,12 @@ defineMacro({
10258
10444
  setDisplayedPassage(currentPassage);
10259
10445
  return;
10260
10446
  }
10447
+ const snapshot = config.type === "fade-through" || config.type === "crossfade" ? snapshotPassage(containerEl, config) : null;
10448
+ if (!containerEl || !snapshot) {
10449
+ setDisplayedPassage(currentPassage);
10450
+ return;
10451
+ }
10261
10452
  if (config.type === "fade-through") {
10262
- if (!containerEl) {
10263
- setDisplayedPassage(currentPassage);
10264
- return;
10265
- }
10266
- setCSSProperties(containerEl, config);
10267
- const passageEl = containerEl.querySelector(".passage");
10268
- if (!passageEl) {
10269
- setDisplayedPassage(currentPassage);
10270
- return;
10271
- }
10272
- const snapshot = createSnapshot(passageEl, config.type);
10273
10453
  containerEl.appendChild(snapshot);
10274
10454
  setDisplayedPassage("");
10275
10455
  const incomingDelay = config.duration + config.pause;
@@ -10282,27 +10462,12 @@ defineMacro({
10282
10462
  }, cleanupDelay)];
10283
10463
  return;
10284
10464
  }
10285
- if (config.type === "crossfade") {
10286
- if (!containerEl) {
10287
- setDisplayedPassage(currentPassage);
10288
- return;
10289
- }
10290
- setCSSProperties(containerEl, config);
10291
- const passageEl = containerEl.querySelector(".passage");
10292
- if (!passageEl) {
10293
- setDisplayedPassage(currentPassage);
10294
- return;
10295
- }
10296
- const snapshot = createSnapshot(passageEl, config.type);
10297
- containerEl.classList.add("passage-container--crossfading");
10298
- containerEl.appendChild(snapshot);
10299
- setDisplayedPassage(currentPassage);
10300
- timeoutsRef.current = [setTimeout(() => {
10301
- cleanupSnapshots(containerEl);
10302
- }, config.duration)];
10303
- return;
10304
- }
10465
+ containerEl.classList.add("passage-container--crossfading");
10466
+ containerEl.appendChild(snapshot);
10305
10467
  setDisplayedPassage(currentPassage);
10468
+ timeoutsRef.current = [setTimeout(() => {
10469
+ cleanupSnapshots(containerEl);
10470
+ }, config.duration)];
10306
10471
  }, [navigationId]);
10307
10472
  const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
10308
10473
  if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
@@ -10334,87 +10499,46 @@ defineMacro({
10334
10499
  });
10335
10500
  //#endregion
10336
10501
  //#region src/components/macros/Watch.tsx
10337
- var WORD_RE = /\w+/y;
10338
- var DIGITS_RE = /\d+/y;
10339
- /** Match the sticky regex `re` at `pos`, returning the matched text. */
10340
- function matchAt(re, src, pos) {
10341
- re.lastIndex = pos;
10342
- return re.exec(src)?.[0] ?? null;
10343
- }
10344
- /**
10345
- * Parse `{watch "condition" keyword value …}`: a quoted condition followed
10346
- * by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
10347
- * `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
10348
- * Returns `null` when the arguments do not start with a quoted condition.
10349
- */
10350
- function parseWatchArgs(rawArgs) {
10351
- const raw = rawArgs.trim();
10352
- const cond = readQuoted(raw, 0);
10353
- if (!cond) return null;
10354
- const options = {};
10355
- let i = cond.end;
10356
- while (i < raw.length) {
10357
- const key = matchAt(WORD_RE, raw, i);
10358
- if (!key) {
10359
- i = readQuoted(raw, i)?.end ?? i + 1;
10360
- continue;
10361
- }
10362
- i += key.length;
10363
- let val;
10364
- let j = i;
10365
- while (j < raw.length && isWhitespace(raw[j])) j++;
10366
- if (j > i) {
10367
- const quoted = readQuoted(raw, j);
10368
- const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
10369
- if (quoted) {
10370
- val = quoted.value;
10371
- i = quoted.end;
10372
- } else if (digits) {
10373
- val = digits;
10374
- i = j + digits.length;
10375
- }
10376
- }
10377
- switch (key) {
10378
- case "goto":
10379
- options.goto = val;
10380
- break;
10381
- case "dialog":
10382
- options.dialog = val;
10383
- break;
10384
- case "run":
10385
- options.run = val;
10386
- break;
10387
- case "name":
10388
- options.name = val;
10389
- break;
10390
- case "priority":
10391
- options.priority = val ? Number(val) : 0;
10392
- break;
10393
- case "once":
10394
- options.once = true;
10395
- break;
10396
- }
10397
- }
10398
- return {
10399
- condition: cond.value,
10400
- options
10401
- };
10402
- }
10403
- /**
10404
- * Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
10405
- * same escapes as `{watch}`, or a bare name.
10406
- */
10407
- function parseUnwatchName(rawArgs) {
10408
- const raw = rawArgs.trim();
10409
- return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, "");
10410
- }
10411
10502
  defineMacro({
10412
10503
  name: "watch",
10413
- render(props, ctx) {
10504
+ parameters: [{
10505
+ name: "condition",
10506
+ type: "string",
10507
+ required: true
10508
+ }, {
10509
+ name: "options",
10510
+ type: "options",
10511
+ parameters: [
10512
+ {
10513
+ name: "goto",
10514
+ type: "string"
10515
+ },
10516
+ {
10517
+ name: "dialog",
10518
+ type: "string"
10519
+ },
10520
+ {
10521
+ name: "run",
10522
+ type: "string"
10523
+ },
10524
+ {
10525
+ name: "name",
10526
+ type: "string"
10527
+ },
10528
+ {
10529
+ name: "priority",
10530
+ type: "number"
10531
+ },
10532
+ {
10533
+ name: "once",
10534
+ type: "flag"
10535
+ }
10536
+ ]
10537
+ }],
10538
+ render(_props, ctx) {
10414
10539
  const { hooks } = ctx;
10415
- const parsed = parseWatchArgs(props.rawArgs);
10416
- if (!parsed) return null;
10417
- const { condition, options } = parsed;
10540
+ const { condition, options } = ctx.args;
10541
+ if (condition === void 0) return null;
10418
10542
  const registered = hooks.useRef(false);
10419
10543
  if (!registered.current) {
10420
10544
  registered.current = true;
@@ -10425,8 +10549,13 @@ defineMacro({
10425
10549
  });
10426
10550
  defineMacro({
10427
10551
  name: "unwatch",
10428
- render(props, ctx) {
10429
- const name = parseUnwatchName(props.rawArgs);
10552
+ parameters: [{
10553
+ name: "name",
10554
+ type: "text",
10555
+ required: true
10556
+ }],
10557
+ render(_props, ctx) {
10558
+ const name = ctx.args.name ?? "";
10430
10559
  const ran = ctx.hooks.useRef(false);
10431
10560
  if (!ran.current) {
10432
10561
  ran.current = true;