@rohal12/spindle 0.52.5 → 0.52.6

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 (41) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +651 -544
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/components/macros/Button.tsx +2 -4
  6. package/src/components/macros/Checkbox.tsx +6 -13
  7. package/src/components/macros/Computed.tsx +38 -55
  8. package/src/components/macros/Cycle.tsx +1 -0
  9. package/src/components/macros/Dialog.tsx +7 -8
  10. package/src/components/macros/Do.tsx +2 -1
  11. package/src/components/macros/For.tsx +27 -78
  12. package/src/components/macros/Goto.tsx +6 -9
  13. package/src/components/macros/Include.tsx +10 -57
  14. package/src/components/macros/Listbox.tsx +1 -0
  15. package/src/components/macros/MacroError.tsx +37 -0
  16. package/src/components/macros/MacroLink.tsx +9 -24
  17. package/src/components/macros/Meter.tsx +19 -37
  18. package/src/components/macros/Numberbox.tsx +5 -32
  19. package/src/components/macros/PassageDisplay.tsx +37 -47
  20. package/src/components/macros/Print.tsx +1 -0
  21. package/src/components/macros/Radiobutton.tsx +8 -38
  22. package/src/components/macros/Repeat.tsx +3 -3
  23. package/src/components/macros/Set.tsx +3 -29
  24. package/src/components/macros/Switch.tsx +36 -37
  25. package/src/components/macros/Textarea.tsx +2 -28
  26. package/src/components/macros/Textbox.tsx +2 -29
  27. package/src/components/macros/Type.tsx +3 -3
  28. package/src/components/macros/Unset.tsx +17 -40
  29. package/src/components/macros/Watch.tsx +23 -91
  30. package/src/components/macros/WidgetInvocation.tsx +9 -71
  31. package/src/components/macros/detached-body.tsx +2 -2
  32. package/src/components/macros/input-macro.ts +48 -0
  33. package/src/components/macros/locals-scope.tsx +60 -0
  34. package/src/components/macros/macro-args.ts +280 -0
  35. package/src/components/macros/option-utils.ts +7 -12
  36. package/src/define-macro.ts +47 -30
  37. package/src/hooks/use-render-options.ts +26 -0
  38. package/src/markup/render.tsx +2 -4
  39. package/src/registry.ts +48 -0
  40. package/types/index.d.ts +69 -6
  41. package/types/tooling.d.ts +33 -1
@@ -3679,11 +3679,29 @@ function ExprDisplay({ expression, className, id }) {
3679
3679
  return /* @__PURE__ */ jsx(Fragment$1, { children: display });
3680
3680
  }
3681
3681
  //#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);
3682
+ //#region src/hooks/use-render-options.ts
3683
+ /**
3684
+ * The renderNodes() options of the enclosing content: nobr, inline and raw
3685
+ * rendering and the locals in scope. A macro or widget body rendered with
3686
+ * them renders as the content around it does.
3687
+ */
3688
+ function useRenderOptions() {
3689
+ return {
3690
+ nobr: useContext(NobrContext),
3691
+ inline: useContext(InlineContext),
3692
+ raw: useContext(RawTextContext),
3693
+ locals: useContext(LocalsValuesContext)
3694
+ };
3695
+ }
3696
+ //#endregion
3697
+ //#region src/components/macros/locals-scope.tsx
3698
+ /**
3699
+ * Render `body` in a new locals scope: the locals of `parentValues`, with
3700
+ * `ownKeys` (an iteration's loop variables, a widget's parameters) on top.
3701
+ * Locals the body assigns are kept in this scope.
3702
+ */
3703
+ function LocalsScope({ body, parentValues, ownKeys }) {
3704
+ const options = useRenderOptions();
3687
3705
  const [localMutations, setLocalMutations] = useState({});
3688
3706
  const localState = useMemo(() => createNamespace(parentValues, ownKeys, localMutations), [
3689
3707
  parentValues,
@@ -3710,29 +3728,22 @@ function WidgetBody({ body, parentValues, ownKeys }) {
3710
3728
  children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
3711
3729
  value: localState,
3712
3730
  children: renderNodes(body, {
3713
- nobr,
3714
- inline,
3715
- raw,
3731
+ ...options,
3716
3732
  locals: localState
3717
3733
  })
3718
3734
  })
3719
3735
  });
3720
3736
  }
3737
+ //#endregion
3738
+ //#region src/components/macros/WidgetInvocation.tsx
3721
3739
  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);
3740
+ const renderOptions = useRenderOptions();
3741
+ const parentValues = renderOptions.locals;
3726
3742
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
3727
3743
  const childrenValue = invocationChildren?.length ? invocationChildren : null;
3728
3744
  if (params.length === 0) return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
3729
3745
  value: childrenValue,
3730
- children: renderNodes(body, {
3731
- nobr,
3732
- inline,
3733
- raw,
3734
- locals: parentValues
3735
- })
3746
+ children: renderNodes(body, renderOptions)
3736
3747
  });
3737
3748
  const argExprs = rawArgs ? splitArgs(rawArgs) : [];
3738
3749
  const values = [];
@@ -3753,7 +3764,7 @@ function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
3753
3764
  }, values);
3754
3765
  return /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
3755
3766
  value: childrenValue,
3756
- children: /* @__PURE__ */ jsx(WidgetBody, {
3767
+ children: /* @__PURE__ */ jsx(LocalsScope, {
3757
3768
  body,
3758
3769
  parentValues,
3759
3770
  ownKeys
@@ -3958,6 +3969,32 @@ function MacroError({ macro, error }) {
3958
3969
  children: `{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`
3959
3970
  });
3960
3971
  }
3972
+ /** Log an error in the macro `{label}` (its name, or name and arguments). */
3973
+ function logMacroError(label, error) {
3974
+ console.error(`spindle: Error in {${label}}${currentSourceLocation()}:`, error);
3975
+ }
3976
+ /**
3977
+ * Run `effect` once, during the macro's first render. If it throws, the
3978
+ * error is logged under `{macro rawArgs}` and shown in place of the macro
3979
+ * from then on; otherwise the macro renders nothing.
3980
+ */
3981
+ function useRunOnce(macro, rawArgs, effect) {
3982
+ const failure = useRef();
3983
+ if (failure.current === void 0) {
3984
+ failure.current = null;
3985
+ try {
3986
+ effect();
3987
+ } catch (error) {
3988
+ failure.current = { error };
3989
+ logMacroError(`${macro} ${rawArgs}`, error);
3990
+ }
3991
+ }
3992
+ if (!failure.current) return null;
3993
+ return /* @__PURE__ */ jsx(MacroError, {
3994
+ macro,
3995
+ error: failure.current.error
3996
+ });
3997
+ }
3961
3998
  //#endregion
3962
3999
  //#region src/markup/render.tsx
3963
4000
  var defaultUpdater = {
@@ -4327,11 +4364,8 @@ function resolveAttributeValue(name, value, scope, errors) {
4327
4364
  }
4328
4365
  function HtmlNodeRenderer({ node }) {
4329
4366
  const scope = useTextScope();
4330
- const nobr = useContext(NobrContext);
4331
- const locals = useContext(LocalsValuesContext);
4332
- const inRaw = useContext(RawTextContext);
4367
+ const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
4333
4368
  const inSvg = useContext(SvgContext);
4334
- const parentInline = useContext(InlineContext);
4335
4369
  const tag = node.tag.toLowerCase();
4336
4370
  const isSvgRoot = tag === "svg";
4337
4371
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
@@ -7494,18 +7528,236 @@ function collectText(nodes) {
7494
7528
  return nodes.map((n) => n.type === "text" ? n.value : "").join("");
7495
7529
  }
7496
7530
  //#endregion
7531
+ //#region src/utils/parse-delay.ts
7532
+ /**
7533
+ * Parse a delay string into milliseconds.
7534
+ * Supports: "2s" → 2000, "500ms" → 500, bare number → ms
7535
+ */
7536
+ function parseDelay(str) {
7537
+ const trimmed = str.trim();
7538
+ if (trimmed.endsWith("ms")) return parseFloat(trimmed.slice(0, -2));
7539
+ if (trimmed.endsWith("s")) return parseFloat(trimmed.slice(0, -1)) * 1e3;
7540
+ return parseFloat(trimmed);
7541
+ }
7542
+ //#endregion
7543
+ //#region src/components/macros/macro-args.ts
7544
+ /**
7545
+ * The macro argument layer: reads a macro's raw argument text into the
7546
+ * parameters its definition declares (see ParameterDef), so every macro
7547
+ * splits and unquotes its arguments the same way. defineMacro() passes the
7548
+ * result to the macro as `ctx.args`.
7549
+ *
7550
+ * Arguments are read in groups, split at `separator` parameters. A group's
7551
+ * `flag` parameters are taken off its start or end first. Its last
7552
+ * `expression` or `text` parameter (without one, its last parameter) reads
7553
+ * the text the others leave; each parameter before it reads one term from
7554
+ * the start, and each after it one term from the end. Terms are separated by
7555
+ * whitespace outside literals and brackets (see arg-utils.ts), and a term
7556
+ * that follows an operator belongs to the expression before it. A parameter
7557
+ * with nothing to read is unset (a flag or separator is false, options are
7558
+ * empty).
7559
+ */
7560
+ /** The terms of `src`: the runs of text between its depth-0 whitespace. */
7561
+ function terms(src) {
7562
+ const spans = [];
7563
+ let start = 0;
7564
+ for (const i of [...topLevelIndices(src, isWhitespace), src.length]) {
7565
+ if (i > start) spans.push([start, i]);
7566
+ start = i + 1;
7567
+ }
7568
+ return spans;
7569
+ }
7570
+ var slice = (src, [start, end]) => src.slice(start, end);
7571
+ /**
7572
+ * The value of a `text` argument: one quoted string, or the text with any
7573
+ * loose quotes stripped (`"Red` → `Red`).
7574
+ */
7575
+ function readText(src) {
7576
+ return readWholeQuoted(src) ?? stripLooseQuotes(src);
7577
+ }
7578
+ function readValue(param, src) {
7579
+ switch (param.type) {
7580
+ case "string": return readWholeQuoted(src) ?? void 0;
7581
+ case "text": return readText(src);
7582
+ case "names": return src.split(",").map((name) => name.trim());
7583
+ case "delay": return parseDelay(src);
7584
+ case "number": return Number(src);
7585
+ case "options": return readOptions(param.parameters ?? [], src);
7586
+ default: return src;
7587
+ }
7588
+ }
7589
+ function unset(param) {
7590
+ if (param.type === "flag" || param.type === "separator") return false;
7591
+ return param.type === "options" ? {} : void 0;
7592
+ }
7593
+ var WORD_RE = /\w+/y;
7594
+ var DIGITS_RE = /\d+/y;
7595
+ /** Match the sticky regex `re` at `pos`, returning the matched text. */
7596
+ function matchAt(re, src, pos) {
7597
+ re.lastIndex = pos;
7598
+ return re.exec(src)?.[0] ?? null;
7599
+ }
7600
+ /**
7601
+ * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
7602
+ * quoted string or digit run after it as its value; keywords that aren't
7603
+ * declared, and their values, are skipped.
7604
+ */
7605
+ function readOptions(params, src) {
7606
+ const options = {};
7607
+ let i = 0;
7608
+ while (i < src.length) {
7609
+ const key = matchAt(WORD_RE, src, i);
7610
+ if (!key) {
7611
+ i = readQuoted(src, i)?.end ?? i + 1;
7612
+ continue;
7613
+ }
7614
+ i += key.length;
7615
+ let val;
7616
+ let j = i;
7617
+ while (j < src.length && isWhitespace(src[j])) j++;
7618
+ if (j > i) {
7619
+ const quoted = readQuoted(src, j);
7620
+ const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
7621
+ if (quoted) {
7622
+ val = quoted.value;
7623
+ i = quoted.end;
7624
+ } else if (digits) {
7625
+ val = digits;
7626
+ i = j + digits.length;
7627
+ }
7628
+ }
7629
+ const param = params.find((p) => p.name === key);
7630
+ if (param?.type === "flag") options[key] = true;
7631
+ else if (param?.type === "number") options[key] = Number(val ?? 0);
7632
+ else if (param) options[key] = val;
7633
+ }
7634
+ return options;
7635
+ }
7636
+ /** `inline %name` reads a transient variable; `inline % 2` is modulo. */
7637
+ var LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
7638
+ /**
7639
+ * The rest of `src` without the flag `word` as its last or first term, or
7640
+ * `null` if it has no such flag. Next to a binary operator the word is an
7641
+ * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
7642
+ * is not one.
7643
+ */
7644
+ function takeFlag(src, word) {
7645
+ const spans = terms(src);
7646
+ if (spans.length < 2) return null;
7647
+ if (slice(src, spans[spans.length - 1]) === word) {
7648
+ const rest = src.slice(0, spans[spans.length - 2][1]);
7649
+ if (!endsWithOperator(rest)) return rest;
7650
+ }
7651
+ if (slice(src, spans[0]) === word) {
7652
+ const rest = src.slice(spans[1][0]);
7653
+ if (!LEADING_OPERATOR_RE.test(rest)) return rest;
7654
+ }
7655
+ return null;
7656
+ }
7657
+ /**
7658
+ * Where the separator `word` splits `src`, as the end of the text before it
7659
+ * and the start of the text after it, or `null` if it doesn't. A word is a
7660
+ * term with terms on both sides; `=` is an assignment at depth 0, not part
7661
+ * of `==` or `!=`.
7662
+ */
7663
+ function findSeparator(src, word) {
7664
+ if (word === "=") {
7665
+ const indices = topLevelIndices(src, (ch) => ch === "=");
7666
+ for (let k = 0; k < indices.length; k++) {
7667
+ const i = indices[k];
7668
+ if (src[i + 1] === "=") {
7669
+ k++;
7670
+ continue;
7671
+ }
7672
+ if (src[i - 1] !== "!") return [i, i + 1];
7673
+ }
7674
+ return null;
7675
+ }
7676
+ const spans = terms(src);
7677
+ for (let k = 1; k < spans.length - 1; k++) if (slice(src, spans[k]) === word) return [spans[k - 1][1], spans[k + 1][0]];
7678
+ return null;
7679
+ }
7680
+ var takesRest = (param) => param.type === void 0 || param.type === "expression" || param.type === "text";
7681
+ /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
7682
+ function readGroup(params, src, args) {
7683
+ let text = src?.trim() ?? "";
7684
+ for (const flag of params.filter((p) => p.type === "flag")) {
7685
+ const rest = src === null ? null : takeFlag(text, flag.name);
7686
+ args[flag.name] = rest !== null;
7687
+ text = rest ?? text;
7688
+ }
7689
+ const positional = params.filter((p) => p.type !== "flag");
7690
+ if (positional.length === 0) return;
7691
+ const lastText = positional.findIndex((p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest));
7692
+ const restIndex = lastText < 0 ? positional.length - 1 : lastText;
7693
+ const read = (param, value) => value === "" ? unset(param) : readValue(param, value);
7694
+ if (positional.length === 1) {
7695
+ args[positional[0].name] = read(positional[0], text);
7696
+ return;
7697
+ }
7698
+ const spans = terms(text);
7699
+ let from = 0;
7700
+ let to = spans.length;
7701
+ for (const param of positional.slice(0, restIndex)) args[param.name] = from < to ? read(param, slice(text, spans[from++])) : unset(param);
7702
+ for (const param of positional.slice(restIndex + 1).reverse()) {
7703
+ const value = from < to && !endsWithOperator(text.slice(0, spans[to - 1][0])) ? read(param, slice(text, spans[to - 1])) : void 0;
7704
+ args[param.name] = value ?? unset(param);
7705
+ if (value !== void 0) to--;
7706
+ }
7707
+ const rest = positional[restIndex];
7708
+ args[rest.name] = from < to ? read(rest, text.slice(spans[from][0], spans[to - 1][1])) : unset(rest);
7709
+ }
7710
+ /** Read `rawArgs` into the declared `parameters` (see above). */
7711
+ function parseMacroArgs(rawArgs, parameters) {
7712
+ const args = {};
7713
+ let rest = rawArgs.trim();
7714
+ let groupStart = 0;
7715
+ parameters.forEach((separator, i) => {
7716
+ if (separator.type !== "separator") return;
7717
+ const at = rest === null ? null : findSeparator(rest, separator.name);
7718
+ args[separator.name] = at !== null;
7719
+ const before = at ? rest.slice(0, at[0]) : rest;
7720
+ readGroup(parameters.slice(groupStart, i), before, args);
7721
+ rest = at ? rest.slice(at[1]) : null;
7722
+ groupStart = i + 1;
7723
+ });
7724
+ readGroup(parameters.slice(groupStart), rest, args);
7725
+ return args;
7726
+ }
7727
+ /**
7728
+ * The passage a passage-name argument names: the value of its expression
7729
+ * or, when that can't be evaluated (`{goto Bob's room}`), its text.
7730
+ */
7731
+ function evaluatePassageName(expr, evaluate) {
7732
+ try {
7733
+ return String(evaluate(expr ?? ""));
7734
+ } catch {
7735
+ return readText(expr ?? "");
7736
+ }
7737
+ }
7738
+ /**
7739
+ * The variable a `storeVar` macro binds: its first term, as written. Custom
7740
+ * macros may bind one without declaring parameters.
7741
+ */
7742
+ function readBoundVariable(rawArgs) {
7743
+ const src = rawArgs.trim();
7744
+ const first = terms(src)[0];
7745
+ return first ? slice(src, first) : "";
7746
+ }
7747
+ //#endregion
7497
7748
  //#region src/components/macros/option-utils.ts
7749
+ /** `$var "placeholder"`, the arguments of the input macros. */
7498
7750
  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];
7751
+ const { variable = "", placeholder = "" } = parseMacroArgs(rawArgs, [{
7752
+ name: "variable",
7753
+ type: "variable"
7754
+ }, {
7755
+ name: "placeholder",
7756
+ type: "string"
7757
+ }]);
7506
7758
  return {
7507
- varName,
7508
- placeholder: quoted ? readWholeQuoted(quoted) ?? quoted.slice(1, -1) : ""
7759
+ varName: variable.replace(/["']/g, ""),
7760
+ placeholder
7509
7761
  };
7510
7762
  }
7511
7763
  /**
@@ -7536,6 +7788,7 @@ var sharedHooks = {
7536
7788
  useContext
7537
7789
  };
7538
7790
  function defineMacro(config, source = "builtin") {
7791
+ const parameters = config.parameters ?? [];
7539
7792
  function Wrapper(props) {
7540
7793
  let className = props.className;
7541
7794
  let id = props.id;
@@ -7546,18 +7799,13 @@ function defineMacro(config, source = "builtin") {
7546
7799
  id = resolve(id);
7547
7800
  }
7548
7801
  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);
7802
+ const renderOptions = useRenderOptions();
7553
7803
  const renderNodes$1 = (nodes, options) => renderNodes(nodes, {
7554
- nobr,
7555
- inline,
7556
- raw,
7557
- locals: localsValues,
7804
+ ...renderOptions,
7558
7805
  ...options
7559
7806
  });
7560
7807
  const ctx = {
7808
+ args: parseMacroArgs(props.rawArgs, parameters),
7561
7809
  collectText,
7562
7810
  sourceLocation: currentSourceLocation,
7563
7811
  parseVarArgs,
@@ -7588,7 +7836,7 @@ function defineMacro(config, source = "builtin") {
7588
7836
  ctx.evaluate = (expr) => evaluate(expr, merged[0], merged[1], merged[2], merged[3]);
7589
7837
  }
7590
7838
  if (config.storeVar) {
7591
- const firstToken = props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, "") ?? "";
7839
+ const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, "");
7592
7840
  if (firstToken.startsWith("%")) return h("span", { class: "error" }, `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`);
7593
7841
  const varExpr = firstToken.replace(/["']/g, "").replace(/^\$/, "");
7594
7842
  const segments = varExpr.split(".");
@@ -7605,7 +7853,11 @@ function defineMacro(config, source = "builtin") {
7605
7853
  return config.render(props, ctx);
7606
7854
  }
7607
7855
  registerMacro(config.name, Wrapper);
7608
- registerMacroText(config.name, config.text);
7856
+ const text = config.text;
7857
+ registerMacroText(config.name, text && ((props, ctx) => text(props, {
7858
+ ...ctx,
7859
+ args: parseMacroArgs(props.rawArgs, parameters)
7860
+ })));
7609
7861
  const isBlock = config.block === true || config.block !== false && (config.subMacros?.length ?? 0) > 0;
7610
7862
  registerMacroMetadata(config.name, {
7611
7863
  name: config.name,
@@ -7615,7 +7867,7 @@ function defineMacro(config, source = "builtin") {
7615
7867
  interpolate: config.interpolate,
7616
7868
  merged: config.merged,
7617
7869
  description: config.description,
7618
- parameters: config.parameters,
7870
+ parameters: config.parameters && [...config.parameters],
7619
7871
  source
7620
7872
  });
7621
7873
  if (config.subMacros) for (const sub of config.subMacros) registerSubMacro(sub);
@@ -8288,29 +8540,24 @@ defineMacro({
8288
8540
  //#region src/components/macros/Set.tsx
8289
8541
  defineMacro({
8290
8542
  name: "set",
8543
+ parameters: [{
8544
+ name: "code",
8545
+ type: "expression",
8546
+ required: true
8547
+ }],
8291
8548
  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;
8549
+ return useRunOnce("set", rawArgs, () => ctx.mutate(rawArgs));
8308
8550
  }
8309
8551
  });
8310
8552
  //#endregion
8311
8553
  //#region src/components/macros/Print.tsx
8312
8554
  defineMacro({
8313
8555
  name: "print",
8556
+ parameters: [{
8557
+ name: "expression",
8558
+ type: "expression",
8559
+ required: true
8560
+ }],
8314
8561
  interpolate: true,
8315
8562
  merged: true,
8316
8563
  render({ rawArgs }, ctx) {
@@ -8338,30 +8585,16 @@ defineMacro({
8338
8585
  * applied, even `undefined`; a first evaluation that throws leaves it unset.
8339
8586
  */
8340
8587
  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}"`);
8588
+ /** Check the arguments of "target = expression". */
8589
+ function assignmentArgs(rawArgs, args) {
8590
+ if (!args["="]) throw new Error(`{computed}: expected "target = expression", got "${rawArgs}"`);
8591
+ const { target = "", expression: expr = "" } = args;
8592
+ if (!target.match(/^[$_@]\w+$/)) throw new Error(`{computed}: target must be $name, _name, or @name, got "${target}"`);
8593
+ checkVariableName(target.slice(1), target);
8594
+ return {
8595
+ target,
8596
+ expr
8597
+ };
8365
8598
  }
8366
8599
  /**
8367
8600
  * Evaluate `expr` against the current values — the store's state and the
@@ -8378,7 +8611,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8378
8611
  const { variables, temporary, transient } = readState();
8379
8612
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
8380
8613
  } catch (err) {
8381
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
8614
+ logMacroError(`computed ${rawArgs}`, err);
8382
8615
  return;
8383
8616
  }
8384
8617
  if (!deepEqual(prevRef.current, newValue)) {
@@ -8386,7 +8619,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8386
8619
  if (isLocal) try {
8387
8620
  localsUpdate(name, newValue);
8388
8621
  } catch (err) {
8389
- console.error(`spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, err);
8622
+ logMacroError(`computed ${rawArgs}`, err);
8390
8623
  }
8391
8624
  else {
8392
8625
  const state = useStoryStore.getState();
@@ -8398,12 +8631,28 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
8398
8631
  defineMacro({
8399
8632
  name: "computed",
8400
8633
  merged: true,
8634
+ parameters: [
8635
+ {
8636
+ name: "target",
8637
+ type: "variable",
8638
+ required: true
8639
+ },
8640
+ {
8641
+ name: "=",
8642
+ type: "separator"
8643
+ },
8644
+ {
8645
+ name: "expression",
8646
+ type: "expression",
8647
+ required: true
8648
+ }
8649
+ ],
8401
8650
  render({ rawArgs }, ctx) {
8402
8651
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged;
8403
8652
  let target;
8404
8653
  let expr;
8405
8654
  try {
8406
- ({target, expr} = parseComputedArgs(rawArgs));
8655
+ ({target, expr} = assignmentArgs(rawArgs, ctx.args));
8407
8656
  } catch (err) {
8408
8657
  return /* @__PURE__ */ jsx(MacroError, {
8409
8658
  macro: "computed",
@@ -8415,14 +8664,13 @@ defineMacro({
8415
8664
  const name = target.slice(1);
8416
8665
  const localsUpdate = isLocal ? ctx.update : null;
8417
8666
  const prevOutput = ctx.hooks.useRef(UNSET);
8667
+ const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8418
8668
  const ran = ctx.hooks.useRef(false);
8419
8669
  if (!ran.current) {
8420
8670
  ran.current = true;
8421
- computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8671
+ compute();
8422
8672
  }
8423
- ctx.hooks.useLayoutEffect(() => {
8424
- computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
8425
- }, [
8673
+ ctx.hooks.useLayoutEffect(compute, [
8426
8674
  mergedVars,
8427
8675
  mergedTemps,
8428
8676
  mergedLocals,
@@ -8433,26 +8681,13 @@ defineMacro({
8433
8681
  });
8434
8682
  //#endregion
8435
8683
  //#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}");
8684
+ /** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
8685
+ function meterArgs(args) {
8686
+ if (args.current === void 0 || args.max === void 0) throw new Error("meter requires two arguments: {meter currentExpr maxExpr}");
8452
8687
  return {
8453
- currentExpr: tokens[0],
8454
- maxExpr: tokens.slice(1).join(" "),
8455
- labelMode
8688
+ currentExpr: args.current,
8689
+ maxExpr: args.max,
8690
+ labelMode: args.label ?? ""
8456
8691
  };
8457
8692
  }
8458
8693
  function formatLabel(current, max, labelMode) {
@@ -8465,9 +8700,25 @@ defineMacro({
8465
8700
  name: "meter",
8466
8701
  interpolate: true,
8467
8702
  merged: true,
8468
- render({ rawArgs }, ctx) {
8703
+ parameters: [
8704
+ {
8705
+ name: "current",
8706
+ type: "expression",
8707
+ required: true
8708
+ },
8709
+ {
8710
+ name: "max",
8711
+ type: "expression",
8712
+ required: true
8713
+ },
8714
+ {
8715
+ name: "label",
8716
+ type: "string"
8717
+ }
8718
+ ],
8719
+ render(_props, ctx) {
8469
8720
  try {
8470
- const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
8721
+ const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
8471
8722
  const current = Number(ctx.evaluate(currentExpr));
8472
8723
  const max = Number(ctx.evaluate(maxExpr));
8473
8724
  const pct = max === 0 ? 0 : Math.max(0, Math.min(100, current / max * 100));
@@ -8583,16 +8834,12 @@ function keyOf(val, ancestors) {
8583
8834
  //#endregion
8584
8835
  //#region src/components/macros/For.tsx
8585
8836
  /**
8586
- * Parse for-loop args: "@item, @i of $list" or "@item of $list"
8837
+ * Check the arguments of "@item, @i of $list" or "@item of $list".
8587
8838
  */
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;
8839
+ function loopArgs(rawArgs, args) {
8840
+ if (!args.of) throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
8841
+ const [itemVar = "", indexVar = null] = args.variables ?? [];
8842
+ const listExpr = args.list ?? "";
8596
8843
  if (!itemVar.startsWith("@")) throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
8597
8844
  if (indexVar && !indexVar.startsWith("@")) throw new Error(`{for} index variable must use @ prefix: got "${indexVar}"`);
8598
8845
  checkVariableName(itemVar.slice(1), itemVar);
@@ -8604,50 +8851,18 @@ function parseForArgs(rawArgs) {
8604
8851
  };
8605
8852
  }
8606
8853
  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), [
8854
+ return /* @__PURE__ */ jsx(LocalsScope, {
8855
+ body: children,
8618
8856
  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
- })
8857
+ ownKeys: useMemo(() => ({
8858
+ [itemVar]: itemValue,
8859
+ ...indexVar ? { [indexVar]: indexValue } : void 0
8860
+ }), [
8861
+ itemVar,
8862
+ itemValue,
8863
+ indexVar,
8864
+ indexValue
8865
+ ])
8651
8866
  });
8652
8867
  }
8653
8868
  defineMacro({
@@ -8655,21 +8870,29 @@ defineMacro({
8655
8870
  block: true,
8656
8871
  interpolate: true,
8657
8872
  merged: true,
8873
+ parameters: [
8874
+ {
8875
+ name: "variables",
8876
+ type: "names",
8877
+ required: true
8878
+ },
8879
+ {
8880
+ name: "of",
8881
+ type: "separator"
8882
+ },
8883
+ {
8884
+ name: "list",
8885
+ type: "expression",
8886
+ required: true
8887
+ }
8888
+ ],
8658
8889
  render({ rawArgs, children = [] }, ctx) {
8659
8890
  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;
8891
+ let loop;
8670
8892
  let list;
8671
8893
  try {
8672
- const result = ctx.evaluate(listExpr);
8894
+ loop = loopArgs(rawArgs, ctx.args);
8895
+ const result = ctx.evaluate(loop.listExpr);
8673
8896
  if (!Array.isArray(result)) return /* @__PURE__ */ jsx("span", {
8674
8897
  class: "error",
8675
8898
  children: `{for error: expression did not evaluate to an array}`
@@ -8681,6 +8904,7 @@ defineMacro({
8681
8904
  error: err
8682
8905
  });
8683
8906
  }
8907
+ const { itemVar, indexVar } = loop;
8684
8908
  const content = list.map((item, i) => /* @__PURE__ */ jsx(ForIteration, {
8685
8909
  parentValues,
8686
8910
  itemVar,
@@ -8692,7 +8916,7 @@ defineMacro({
8692
8916
  return ctx.wrap(content);
8693
8917
  },
8694
8918
  text({ rawArgs, children = [] }, ctx) {
8695
- const { itemVar, indexVar, listExpr } = parseForArgs(rawArgs);
8919
+ const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
8696
8920
  const list = ctx.evaluate(listExpr);
8697
8921
  if (!Array.isArray(list)) throw new Error("expression did not evaluate to an array");
8698
8922
  return list.map((item, i) => ctx.renderText(children, {
@@ -8712,7 +8936,7 @@ defineMacro({
8712
8936
  try {
8713
8937
  ctx.mutate(code);
8714
8938
  } catch (err) {
8715
- console.error(`spindle: Error in {do}${ctx.sourceLocation()}:`, err);
8939
+ logMacroError("do", err);
8716
8940
  }
8717
8941
  }, []);
8718
8942
  return null;
@@ -8743,27 +8967,20 @@ function liveLocalsView(getValues) {
8743
8967
  });
8744
8968
  }
8745
8969
  //#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
8970
  //#region src/components/macros/Repeat.tsx
8759
8971
  var RepeatContext = createContext({ stop: () => {} });
8760
8972
  defineMacro({
8761
8973
  name: "repeat",
8762
8974
  block: true,
8763
8975
  interpolate: true,
8764
- render({ rawArgs, children = [] }, ctx) {
8976
+ parameters: [{
8977
+ name: "delay",
8978
+ type: "delay",
8979
+ required: true
8980
+ }],
8981
+ render({ children = [] }, ctx) {
8765
8982
  const { useState, useEffect, useCallback } = ctx.hooks;
8766
- const delay = parseDelay(rawArgs);
8983
+ const delay = ctx.args.delay ?? 0;
8767
8984
  const [count, setCount] = useState(0);
8768
8985
  const [stopped, setStopped] = useState(false);
8769
8986
  const stop = useCallback(() => setStopped(true), []);
@@ -8810,8 +9027,7 @@ defineMacro({
8810
9027
  */
8811
9028
  function useDetachedBody() {
8812
9029
  const updater = useContext(LocalsUpdateContext);
8813
- const nobr = useContext(NobrContext);
8814
- const inline = useContext(InlineContext);
9030
+ const { nobr, inline } = useRenderOptions();
8815
9031
  const widgetChildren = useContext(WidgetChildrenContext);
8816
9032
  const repeat = useContext(RepeatContext);
8817
9033
  const closeDialog = useContext(DialogCloseContext);
@@ -8854,9 +9070,13 @@ defineMacro({
8854
9070
  name: "button",
8855
9071
  block: true,
8856
9072
  interpolate: true,
9073
+ parameters: [{
9074
+ name: "label",
9075
+ type: "text",
9076
+ required: true
9077
+ }],
8857
9078
  render({ rawArgs, children = [] }, ctx) {
8858
- const text = readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, "");
8859
- const label = ctx.resolve?.(text) ?? rawArgs;
9079
+ const label = ctx.resolve?.(ctx.args.label ?? "") ?? rawArgs;
8860
9080
  const runBody = useDetachedBody();
8861
9081
  const handleClick = () => runBody(children);
8862
9082
  ctx.useAction({
@@ -8876,30 +9096,22 @@ defineMacro({
8876
9096
  });
8877
9097
  //#endregion
8878
9098
  //#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
9099
  defineMacro({
8898
9100
  name: "link",
8899
9101
  block: true,
8900
9102
  interpolate: true,
9103
+ parameters: [{
9104
+ name: "text",
9105
+ type: "string",
9106
+ required: true
9107
+ }, {
9108
+ name: "passage",
9109
+ type: "string"
9110
+ }],
8901
9111
  render({ rawArgs, children = [] }, ctx) {
8902
- const { display, passage } = parseLinkArgs(rawArgs);
9112
+ const { text } = ctx.args;
9113
+ const display = text ?? rawArgs.trim();
9114
+ const passage = text === void 0 ? null : ctx.args.passage ?? null;
8903
9115
  const runBody = useDetachedBody();
8904
9116
  const perform = () => {
8905
9117
  runBody(children);
@@ -8928,9 +9140,27 @@ defineMacro({
8928
9140
  });
8929
9141
  //#endregion
8930
9142
  //#region src/components/macros/Switch.tsx
9143
+ /**
9144
+ * The first {case} branch whose value equals `value`, else the {default}
9145
+ * branch, else null. Throws what a case expression throws.
9146
+ */
9147
+ function selectBranch(value, branches, evaluate) {
9148
+ let defaultBranch = null;
9149
+ for (let i = 1; i < branches.length; i++) {
9150
+ const branch = branches[i];
9151
+ if (branch.rawArgs === "") defaultBranch = branch;
9152
+ else if (value === evaluate(branch.rawArgs)) return branch;
9153
+ }
9154
+ return defaultBranch;
9155
+ }
8931
9156
  defineMacro({
8932
9157
  name: "switch",
8933
9158
  subMacros: ["case", "default"],
9159
+ parameters: [{
9160
+ name: "expression",
9161
+ type: "expression",
9162
+ required: true
9163
+ }],
8934
9164
  merged: true,
8935
9165
  render({ rawArgs, branches = [] }, ctx) {
8936
9166
  let switchValue;
@@ -8942,35 +9172,20 @@ defineMacro({
8942
9172
  error: err
8943
9173
  });
8944
9174
  }
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
- }
9175
+ let branch;
9176
+ try {
9177
+ branch = selectBranch(switchValue, branches, ctx.evaluate);
9178
+ } catch (err) {
9179
+ return /* @__PURE__ */ jsx(MacroError, {
9180
+ macro: "case",
9181
+ error: err
9182
+ });
8961
9183
  }
8962
- if (defaultBranch) return /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(defaultBranch.children) });
8963
- return null;
9184
+ return branch && /* @__PURE__ */ jsx(Fragment$1, { children: ctx.renderNodes(branch.children) });
8964
9185
  },
8965
9186
  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) : "";
9187
+ const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
9188
+ return branch ? ctx.renderText(branch.children) : "";
8974
9189
  }
8975
9190
  });
8976
9191
  //#endregion
@@ -9030,9 +9245,14 @@ defineMacro({
9030
9245
  name: "type",
9031
9246
  block: true,
9032
9247
  interpolate: true,
9033
- render({ rawArgs, children = [] }, ctx) {
9248
+ parameters: [{
9249
+ name: "delay",
9250
+ type: "delay",
9251
+ required: true
9252
+ }],
9253
+ render({ children = [] }, ctx) {
9034
9254
  const { useState, useEffect, useRef } = ctx.hooks;
9035
- const speed = parseDelay(rawArgs);
9255
+ const speed = ctx.args.delay ?? 0;
9036
9256
  const containerRef = useRef(null);
9037
9257
  const [totalChars, setTotalChars] = useState(0);
9038
9258
  const [visibleChars, setVisibleChars] = useState(0);
@@ -9213,11 +9433,18 @@ defineMacro({
9213
9433
  name: "dialog",
9214
9434
  block: true,
9215
9435
  interpolate: true,
9216
- render({ rawArgs, children = [] }, ctx) {
9436
+ parameters: [{
9437
+ name: "label",
9438
+ type: "text",
9439
+ required: true
9440
+ }, {
9441
+ name: "noclose",
9442
+ type: "flag"
9443
+ }],
9444
+ render({ children = [] }, ctx) {
9217
9445
  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;
9446
+ const { noclose } = ctx.args;
9447
+ const label = ctx.resolve(ctx.args.label ?? "");
9221
9448
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
9222
9449
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
9223
9450
  id: ctx.id,
@@ -9624,59 +9851,22 @@ defineMacro({
9624
9851
  });
9625
9852
  //#endregion
9626
9853
  //#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
9854
  defineMacro({
9667
9855
  name: "include",
9668
9856
  interpolate: true,
9669
9857
  merged: true,
9670
- render({ rawArgs }, ctx) {
9858
+ parameters: [{
9859
+ name: "inline",
9860
+ type: "flag"
9861
+ }, {
9862
+ name: "passage",
9863
+ type: "expression",
9864
+ required: true
9865
+ }],
9866
+ render(_props, ctx) {
9671
9867
  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
- }
9868
+ const { inline } = ctx.args;
9869
+ const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate);
9680
9870
  const passage = storyData?.passages.get(passageName);
9681
9871
  const ast = ctx.hooks.useMemo(() => passage ? buildAST(tokenize(passage.content)) : null, [passage]);
9682
9872
  if (!storyData) return null;
@@ -9706,16 +9896,14 @@ defineMacro({
9706
9896
  defineMacro({
9707
9897
  name: "goto",
9708
9898
  merged: true,
9709
- render({ rawArgs }, ctx) {
9899
+ parameters: [{
9900
+ name: "passage",
9901
+ type: "expression",
9902
+ required: true
9903
+ }],
9904
+ render(_props, ctx) {
9710
9905
  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);
9906
+ useStoryStore.getState().navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate));
9719
9907
  }, []);
9720
9908
  return null;
9721
9909
  }
@@ -9724,124 +9912,90 @@ defineMacro({
9724
9912
  //#region src/components/macros/Unset.tsx
9725
9913
  defineMacro({
9726
9914
  name: "unset",
9915
+ parameters: [{
9916
+ name: "variable",
9917
+ type: "variable",
9918
+ required: true
9919
+ }],
9727
9920
  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;
9921
+ return useRunOnce("unset", rawArgs, () => {
9732
9922
  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
9923
+ const name = ctx.args.variable ?? "";
9924
+ if (name.startsWith("$")) state.deleteVariable(name.slice(1));
9925
+ else if (name.startsWith("_")) state.deleteTemporary(name.slice(1));
9926
+ else if (name.startsWith("%")) state.deleteTransient(name.slice(1));
9927
+ else if (name.startsWith("@")) ctx.update(name.slice(1), void 0);
9928
+ else throw new Error(`{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`);
9748
9929
  });
9749
- return null;
9750
9930
  }
9751
9931
  });
9752
9932
  //#endregion
9933
+ //#region src/components/macros/input-macro.ts
9934
+ /**
9935
+ * Define a text input macro, `{name $var "placeholder"}`: an `<input>` of
9936
+ * `type` (or, without one, a `<textarea>`) bound to the story variable.
9937
+ * `toValue` turns what the reader types, or an automation action's value
9938
+ * (`undefined` when it has none), into the variable's value: by default,
9939
+ * the text.
9940
+ */
9941
+ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? String(input) : "") {
9942
+ defineMacro({
9943
+ name,
9944
+ storeVar: true,
9945
+ parameters: [{
9946
+ name: "variable",
9947
+ type: "variable",
9948
+ required: true
9949
+ }, {
9950
+ name: "placeholder",
9951
+ type: "string"
9952
+ }],
9953
+ render(_props, ctx) {
9954
+ const placeholder = ctx.args.placeholder ?? "";
9955
+ ctx.useAction({
9956
+ type: name,
9957
+ key: `$${ctx.varName}`,
9958
+ authorId: ctx.id,
9959
+ label: placeholder || ctx.varName,
9960
+ variable: ctx.varName,
9961
+ value: ctx.value,
9962
+ perform: (v) => ctx.setValue(toValue(v))
9963
+ });
9964
+ return h(type ? "input" : "textarea", {
9965
+ type,
9966
+ id: ctx.id,
9967
+ class: ctx.cls,
9968
+ value: ctx.value == null ? "" : String(ctx.value),
9969
+ placeholder,
9970
+ onInput: (e) => ctx.setValue(toValue(e.target.value))
9971
+ });
9972
+ }
9973
+ });
9974
+ }
9975
+ //#endregion
9753
9976
  //#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
- });
9977
+ defineInputMacro("textbox", "text");
9778
9978
  //#endregion
9779
9979
  //#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
- });
9980
+ defineInputMacro("numberbox", "number", (v) => v !== void 0 ? Number(v) : 0);
9807
9981
  //#endregion
9808
9982
  //#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
- });
9983
+ defineInputMacro("textarea");
9832
9984
  //#endregion
9833
9985
  //#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
9986
  defineMacro({
9841
9987
  name: "checkbox",
9842
9988
  storeVar: true,
9843
- render({ rawArgs }, ctx) {
9844
- const label = parseCheckboxLabel(rawArgs);
9989
+ parameters: [{
9990
+ name: "variable",
9991
+ type: "variable",
9992
+ required: true
9993
+ }, {
9994
+ name: "label",
9995
+ type: "text"
9996
+ }],
9997
+ render(_props, ctx) {
9998
+ const label = ctx.args.label ?? "";
9845
9999
  ctx.useAction({
9846
10000
  type: "checkbox",
9847
10001
  key: `$${ctx.varName}`,
@@ -9864,38 +10018,28 @@ defineMacro({
9864
10018
  });
9865
10019
  //#endregion
9866
10020
  //#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
10021
  defineMacro({
9895
10022
  name: "radiobutton",
9896
10023
  storeVar: true,
9897
- render({ rawArgs }, ctx) {
9898
- const { value: radioValue, label } = parseRadioArgs(rawArgs);
10024
+ parameters: [
10025
+ {
10026
+ name: "variable",
10027
+ type: "variable",
10028
+ required: true
10029
+ },
10030
+ {
10031
+ name: "value",
10032
+ type: "text",
10033
+ required: true
10034
+ },
10035
+ {
10036
+ name: "label",
10037
+ type: "text"
10038
+ }
10039
+ ],
10040
+ render(_props, ctx) {
10041
+ const radioValue = ctx.args.value ?? "";
10042
+ const label = ctx.args.label ?? "";
9899
10043
  ctx.useAction({
9900
10044
  type: "radiobutton",
9901
10045
  key: `$${ctx.varName}:${radioValue}`,
@@ -9923,6 +10067,11 @@ defineMacro({
9923
10067
  name: "listbox",
9924
10068
  subMacros: ["option"],
9925
10069
  storeVar: true,
10070
+ parameters: [{
10071
+ name: "variable",
10072
+ type: "variable",
10073
+ required: true
10074
+ }],
9926
10075
  render({ children = [] }, ctx) {
9927
10076
  const options = ctx.extractOptions(children);
9928
10077
  ctx.useAction({
@@ -9955,6 +10104,11 @@ defineMacro({
9955
10104
  name: "cycle",
9956
10105
  block: true,
9957
10106
  storeVar: true,
10107
+ parameters: [{
10108
+ name: "variable",
10109
+ type: "variable",
10110
+ required: true
10111
+ }],
9958
10112
  render({ children = [] }, ctx) {
9959
10113
  const options = ctx.extractOptions(children);
9960
10114
  const handleClick = () => {
@@ -10191,6 +10345,16 @@ function setCSSProperties(el, config) {
10191
10345
  el.style.setProperty("--passage-pause", `${config.pause / 1e3}s`);
10192
10346
  }
10193
10347
  /**
10348
+ * Set up `containerEl` for an outgoing transition and snapshot the passage
10349
+ * it shows, or return null if there is no container or passage.
10350
+ */
10351
+ function snapshotPassage(containerEl, config) {
10352
+ if (!containerEl) return null;
10353
+ setCSSProperties(containerEl, config);
10354
+ const passageEl = containerEl.querySelector(".passage");
10355
+ return passageEl ? createSnapshot(passageEl, config.type) : null;
10356
+ }
10357
+ /**
10194
10358
  * Remove all snapshot elements and crossfading class from a container.
10195
10359
  */
10196
10360
  function cleanupSnapshots(containerEl) {
@@ -10258,18 +10422,12 @@ defineMacro({
10258
10422
  setDisplayedPassage(currentPassage);
10259
10423
  return;
10260
10424
  }
10425
+ const snapshot = config.type === "fade-through" || config.type === "crossfade" ? snapshotPassage(containerEl, config) : null;
10426
+ if (!containerEl || !snapshot) {
10427
+ setDisplayedPassage(currentPassage);
10428
+ return;
10429
+ }
10261
10430
  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
10431
  containerEl.appendChild(snapshot);
10274
10432
  setDisplayedPassage("");
10275
10433
  const incomingDelay = config.duration + config.pause;
@@ -10282,27 +10440,12 @@ defineMacro({
10282
10440
  }, cleanupDelay)];
10283
10441
  return;
10284
10442
  }
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
- }
10443
+ containerEl.classList.add("passage-container--crossfading");
10444
+ containerEl.appendChild(snapshot);
10305
10445
  setDisplayedPassage(currentPassage);
10446
+ timeoutsRef.current = [setTimeout(() => {
10447
+ cleanupSnapshots(containerEl);
10448
+ }, config.duration)];
10306
10449
  }, [navigationId]);
10307
10450
  const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
10308
10451
  if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
@@ -10334,87 +10477,46 @@ defineMacro({
10334
10477
  });
10335
10478
  //#endregion
10336
10479
  //#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
10480
  defineMacro({
10412
10481
  name: "watch",
10413
- render(props, ctx) {
10482
+ parameters: [{
10483
+ name: "condition",
10484
+ type: "string",
10485
+ required: true
10486
+ }, {
10487
+ name: "options",
10488
+ type: "options",
10489
+ parameters: [
10490
+ {
10491
+ name: "goto",
10492
+ type: "string"
10493
+ },
10494
+ {
10495
+ name: "dialog",
10496
+ type: "string"
10497
+ },
10498
+ {
10499
+ name: "run",
10500
+ type: "string"
10501
+ },
10502
+ {
10503
+ name: "name",
10504
+ type: "string"
10505
+ },
10506
+ {
10507
+ name: "priority",
10508
+ type: "number"
10509
+ },
10510
+ {
10511
+ name: "once",
10512
+ type: "flag"
10513
+ }
10514
+ ]
10515
+ }],
10516
+ render(_props, ctx) {
10414
10517
  const { hooks } = ctx;
10415
- const parsed = parseWatchArgs(props.rawArgs);
10416
- if (!parsed) return null;
10417
- const { condition, options } = parsed;
10518
+ const { condition, options } = ctx.args;
10519
+ if (condition === void 0) return null;
10418
10520
  const registered = hooks.useRef(false);
10419
10521
  if (!registered.current) {
10420
10522
  registered.current = true;
@@ -10425,8 +10527,13 @@ defineMacro({
10425
10527
  });
10426
10528
  defineMacro({
10427
10529
  name: "unwatch",
10428
- render(props, ctx) {
10429
- const name = parseUnwatchName(props.rawArgs);
10530
+ parameters: [{
10531
+ name: "name",
10532
+ type: "text",
10533
+ required: true
10534
+ }],
10535
+ render(_props, ctx) {
10536
+ const name = ctx.args.name ?? "";
10430
10537
  const ran = ctx.hooks.useRef(false);
10431
10538
  if (!ran.current) {
10432
10539
  ran.current = true;