@rohal12/spindle 0.51.3 → 0.52.0

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 (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
@@ -1,15 +1,21 @@
1
1
  import { defineMacro } from '../../define-macro';
2
+ import { readWholeQuoted, stripLooseQuotes } from './arg-utils';
2
3
 
3
- function parseLabel(rawArgs: string): string {
4
- const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+["']?(.+?)["']?\s*$/);
5
- return match?.[1] ?? '';
4
+ export function parseCheckboxLabel(rawArgs: string): string {
5
+ // `s`: a quoted label may span lines. Its trailing whitespace is trimmed
6
+ // here, not by the regex: `(.+?)\s*$` tries every position of a run of
7
+ // spaces in the label, taking time quadratic in its length.
8
+ const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+)$/s);
9
+ if (!match) return '';
10
+ const rest = match[1]!.trimEnd() || match[1]!;
11
+ return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
6
12
  }
7
13
 
8
14
  defineMacro({
9
15
  name: 'checkbox',
10
16
  storeVar: true,
11
17
  render({ rawArgs }, ctx) {
12
- const label = parseLabel(rawArgs);
18
+ const label = parseCheckboxLabel(rawArgs);
13
19
 
14
20
  ctx.useAction({
15
21
  type: 'checkbox',
@@ -1,9 +1,11 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { evaluate } from '../../expression';
3
+ import { readState } from '../../execute-mutation';
3
4
  import { deepEqual } from '../../class-registry';
4
5
  import { currentSourceLocation } from '../../utils/source-location';
5
6
  import { defineMacro } from '../../define-macro';
6
7
  import { MacroError } from './MacroError';
8
+ import { checkVariableName } from '../../utils/namespace';
7
9
 
8
10
  function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
9
11
  const trimmed = rawArgs.trim();
@@ -29,6 +31,7 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
29
31
  );
30
32
  }
31
33
 
34
+ checkVariableName(target.slice(1), target);
32
35
  return { target, expr };
33
36
  }
34
37
  }
@@ -38,22 +41,29 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
38
41
  );
39
42
  }
40
43
 
44
+ /**
45
+ * Evaluate `expr` against the current values — the store's state and the
46
+ * enclosing locals scope's live values (`getLocals`) — and write the result
47
+ * to the target if it changed. Reading live values rather than the render's
48
+ * snapshot means the first computation sees a local a preceding {set} just
49
+ * assigned (the scope's context value only catches up on its re-render), and
50
+ * the first computation and later recomputations read the same source.
51
+ */
41
52
  function computeAndApply(
42
53
  expr: string,
43
54
  name: string,
44
55
  isTemp: boolean,
45
56
  isLocal: boolean,
46
- variables: Record<string, unknown>,
47
- temporary: Record<string, unknown>,
48
- locals: Record<string, unknown>,
49
- transient: Record<string, unknown>,
57
+ getLocals: () => Record<string, unknown>,
50
58
  rawArgs: string,
51
59
  prevRef: { current: unknown },
52
60
  localsUpdate: ((key: string, value: unknown) => void) | null,
53
61
  ): void {
54
62
  let newValue: unknown;
55
63
  try {
56
- newValue = evaluate(expr, variables, temporary, locals, transient);
64
+ // In program order, also when mutation code sets this off
65
+ const { variables, temporary, transient } = readState();
66
+ newValue = evaluate(expr, variables, temporary, getLocals(), transient);
57
67
  } catch (err) {
58
68
  console.error(
59
69
  `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
@@ -114,26 +124,22 @@ defineMacro({
114
124
  name,
115
125
  isTemp,
116
126
  isLocal,
117
- mergedVars,
118
- mergedTemps,
119
- mergedLocals,
120
- mergedTrans,
127
+ ctx.getValues,
121
128
  rawArgs,
122
129
  prevOutput,
123
130
  localsUpdate,
124
131
  );
125
132
  }
126
133
 
134
+ // The merged values only decide when to recompute; computeAndApply reads
135
+ // the current ones.
127
136
  ctx.hooks.useLayoutEffect(() => {
128
137
  computeAndApply(
129
138
  expr,
130
139
  name,
131
140
  isTemp,
132
141
  isLocal,
133
- mergedVars,
134
- mergedTemps,
135
- mergedLocals,
136
- mergedTrans,
142
+ ctx.getValues,
137
143
  rawArgs,
138
144
  prevOutput,
139
145
  localsUpdate,
@@ -1,5 +1,6 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { PassageDialog } from '../PassageDialog';
3
+ import { readWholeQuoted } from './arg-utils';
3
4
 
4
5
  defineMacro({
5
6
  name: 'dialog',
@@ -11,7 +12,9 @@ defineMacro({
11
12
  const noclose = /\bnoclose\s*$/.test(rawArgs);
12
13
  const labelRaw = rawArgs.replace(/\bnoclose\s*$/, '').trim();
13
14
  const label =
14
- ctx.resolve?.(labelRaw.replace(/^["']|["']$/g, '')) ?? labelRaw;
15
+ ctx.resolve?.(
16
+ readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, ''),
17
+ ) ?? labelRaw;
15
18
  const passageName = ctx
16
19
  .collectText(children)
17
20
  .trim()
@@ -9,11 +9,19 @@ import {
9
9
  LocalsValuesContext,
10
10
  LocalsUpdateContext,
11
11
  NobrContext,
12
+ InlineContext,
13
+ RawTextContext,
12
14
  renderNodes,
13
15
  } from '../../markup/render';
14
16
  import { defineMacro } from '../../define-macro';
15
17
  import { MacroError } from './MacroError';
18
+ import { stableKey } from '../../utils/stable-key';
16
19
  import type { ASTNode } from '../../markup/ast';
20
+ import {
21
+ checkVariableName,
22
+ createNamespace,
23
+ withEntry,
24
+ } from '../../utils/namespace';
17
25
 
18
26
  /**
19
27
  * Parse for-loop args: "@item, @i of $list" or "@item of $list"
@@ -44,6 +52,9 @@ function parseForArgs(rawArgs: string): {
44
52
  );
45
53
  }
46
54
 
55
+ checkVariableName(itemVar.slice(1), itemVar);
56
+ if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
57
+
47
58
  return {
48
59
  itemVar: itemVar.slice(1),
49
60
  indexVar: indexVar ? indexVar.slice(1) : null,
@@ -79,7 +90,7 @@ function ForIteration({
79
90
  );
80
91
 
81
92
  const localState = useMemo(
82
- () => ({ ...parentValues, ...ownKeys, ...localMutations }),
93
+ () => createNamespace(parentValues, ownKeys, localMutations),
83
94
  [parentValues, ownKeys, localMutations],
84
95
  );
85
96
 
@@ -91,17 +102,20 @@ function ForIteration({
91
102
  // Apply synchronously so later macros in the same render pass (e.g. a
92
103
  // second {set}) read the new value via getValues(); the state update
93
104
  // then re-renders consumers of LocalsValuesContext.
94
- valuesRef.current = { ...valuesRef.current, [key]: value };
105
+ checkVariableName(key, `@${key}`);
106
+ valuesRef.current = withEntry(valuesRef.current, key, value);
95
107
  setLocalMutations((prev) => ({ ...prev, [key]: value }));
96
108
  }, []);
97
109
  const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
98
110
 
99
111
  const nobr = useContext(NobrContext);
112
+ const inline = useContext(InlineContext);
113
+ const raw = useContext(RawTextContext);
100
114
 
101
115
  return (
102
116
  <LocalsUpdateContext.Provider value={updater}>
103
117
  <LocalsValuesContext.Provider value={localState}>
104
- {renderNodes(children, { nobr, locals: localState })}
118
+ {renderNodes(children, { nobr, inline, raw, locals: localState })}
105
119
  </LocalsValuesContext.Provider>
106
120
  </LocalsUpdateContext.Provider>
107
121
  );
@@ -151,7 +165,7 @@ defineMacro({
151
165
 
152
166
  const content = list.map((item, i) => (
153
167
  <ForIteration
154
- key={`${i}-${JSON.stringify(item)}`}
168
+ key={`${i}-${stableKey(item)}`}
155
169
  parentValues={parentValues}
156
170
  itemVar={itemVar}
157
171
  itemValue={item}
@@ -163,4 +177,19 @@ defineMacro({
163
177
 
164
178
  return ctx.wrap(content);
165
179
  },
180
+ text({ rawArgs, children = [] }, ctx) {
181
+ const { itemVar, indexVar, listExpr } = parseForArgs(rawArgs);
182
+ const list = ctx.evaluate(listExpr);
183
+ if (!Array.isArray(list)) {
184
+ throw new Error('expression did not evaluate to an array');
185
+ }
186
+ return list
187
+ .map((item, i) =>
188
+ ctx.renderText(children, {
189
+ [itemVar]: item,
190
+ ...(indexVar ? { [indexVar]: i } : undefined),
191
+ }),
192
+ )
193
+ .join('');
194
+ },
166
195
  });
@@ -45,4 +45,12 @@ defineMacro({
45
45
 
46
46
  return null;
47
47
  },
48
+ text({ branches = [] }, ctx) {
49
+ for (const branch of branches) {
50
+ if (branch.rawArgs === '' || ctx.evaluate(branch.rawArgs)) {
51
+ return ctx.renderText(branch.children);
52
+ }
53
+ }
54
+ return '';
55
+ },
48
56
  });
@@ -3,6 +3,7 @@ import { tokenize } from '../../markup/tokenizer';
3
3
  import { buildAST } from '../../markup/ast';
4
4
  import { NobrContext } from '../../markup/render';
5
5
  import { defineMacro } from '../../define-macro';
6
+ import { endsWithOperator, isWhitespace, topLevelIndices } from './arg-utils';
6
7
 
7
8
  const FLAG = 'inline';
8
9
 
@@ -12,49 +13,39 @@ const FLAG = 'inline';
12
13
  * braces, separated by whitespace from the target expression, so passage
13
14
  * names and expressions containing the word stay intact (#201).
14
15
  */
15
- function parseIncludeArgs(rawArgs: string): {
16
+ export function parseIncludeArgs(rawArgs: string): {
16
17
  nameExpr: string;
17
18
  inline: boolean;
18
19
  } {
19
20
  const trimmed = rawArgs.trim();
20
21
 
21
22
  // First and last whitespace runs at depth 0, as [start, end) offsets.
23
+ const spaces = topLevelIndices(trimmed, isWhitespace);
22
24
  let first: [number, number] | null = null;
23
25
  let last: [number, number] | null = null;
24
- let depth = 0;
25
- let inString: string | null = null;
26
- for (let i = 0; i < trimmed.length; i++) {
27
- const ch = trimmed[i]!;
28
- if (inString) {
29
- if (ch === '\\') i++;
30
- else if (ch === inString) inString = null;
31
- continue;
32
- }
33
- if (ch === '"' || ch === "'" || ch === '`') inString = ch;
34
- else if (ch === '(' || ch === '[' || ch === '{') depth++;
35
- else if (ch === ')' || ch === ']' || ch === '}') depth--;
36
- else if (depth === 0 && /\s/.test(ch)) {
37
- let end = i + 1;
38
- while (end < trimmed.length && /\s/.test(trimmed[end]!)) end++;
39
- last = [i, end];
40
- first ??= last;
41
- i = end - 1;
42
- }
26
+ if (spaces.length > 0) {
27
+ let s = 0;
28
+ while (s + 1 < spaces.length && spaces[s + 1] === spaces[s]! + 1) s++;
29
+ first = [spaces[0]!, spaces[s]! + 1];
30
+ let e = spaces.length - 1;
31
+ while (e > 0 && spaces[e - 1] === spaces[e]! - 1) e--;
32
+ last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
43
33
  }
44
34
 
45
- // Next to a binary operator the word is an operand (`"a" + inline`).
35
+ // Next to a binary operator the word is an operand (`"a" + inline`);
36
+ // the closing `/` of a regex literal is not one.
46
37
  if (
47
38
  last &&
48
39
  last[1] === trimmed.length - FLAG.length &&
49
40
  trimmed.endsWith(FLAG)
50
41
  ) {
51
42
  const rest = trimmed.slice(0, last[0]);
52
- if (!/[-+*/%&|^!=<>?:,.]$/.test(rest))
53
- return { nameExpr: rest, inline: true };
43
+ if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
54
44
  }
55
45
  if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
56
46
  const rest = trimmed.slice(first[1]);
57
- if (!/^[-+*/%&|^=<>?:,.]/.test(rest))
47
+ // `inline %name` reads a transient variable; `inline % 2` is modulo.
48
+ if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
58
49
  return { nameExpr: rest, inline: true };
59
50
  }
60
51
  return { nameExpr: trimmed, inline: false };
@@ -1,4 +1,5 @@
1
1
  import { currentSourceLocation } from '../../utils/source-location';
2
+ import { errorMessage } from '../../utils/error-message';
2
3
 
3
4
  export function MacroError({
4
5
  macro,
@@ -12,7 +13,7 @@ export function MacroError({
12
13
  class="error"
13
14
  title={String(error)}
14
15
  >
15
- {`{${macro} error${currentSourceLocation()}: ${error instanceof Error ? error.message : String(error)}}`}
16
+ {`{${macro} error${currentSourceLocation()}: ${errorMessage(error)}}`}
16
17
  </span>
17
18
  );
18
19
  }
@@ -1,16 +1,9 @@
1
- import { h, render } from 'preact';
2
- import { useContext } from 'preact/hooks';
3
1
  import { useStoryStore } from '../../store';
4
- import {
5
- renderNodes,
6
- LocalsUpdateContext,
7
- LocalsValuesContext,
8
- NobrContext,
9
- } from '../../markup/render';
10
2
  import { defineMacro } from '../../define-macro';
11
- import { liveLocalsView } from '../../utils/live-locals';
3
+ import { unescapeQuoted } from './arg-utils';
4
+ import { useDetachedBody } from './detached-body';
12
5
 
13
- function parseArgs(rawArgs: string): {
6
+ export function parseLinkArgs(rawArgs: string): {
14
7
  display: string;
15
8
  passage: string | null;
16
9
  } {
@@ -20,7 +13,7 @@ function parseArgs(rawArgs: string): {
20
13
  const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
21
14
  let m;
22
15
  while ((m = re.exec(rawArgs)) !== null) {
23
- parts.push(m[2]!.replace(/\\(["'\\])/g, '$1'));
16
+ parts.push(unescapeQuoted(m[2]!));
24
17
  }
25
18
  if (parts.length >= 2) {
26
19
  return { display: parts[0]!, passage: parts[1]! };
@@ -32,58 +25,33 @@ function parseArgs(rawArgs: string): {
32
25
  return { display: rawArgs.trim(), passage: null };
33
26
  }
34
27
 
35
- function renderChildrenDetached(
36
- children: import('../../markup/ast').ASTNode[],
37
- getValues: () => Record<string, unknown>,
38
- update: (key: string, value: unknown) => void,
39
- nobr: boolean,
40
- ) {
41
- const container = document.createElement('div');
42
- const vnode = h(
43
- NobrContext.Provider,
44
- { value: nobr },
45
- h(
46
- LocalsUpdateContext.Provider,
47
- { value: { update, getValues } },
48
- h(
49
- LocalsValuesContext.Provider,
50
- { value: liveLocalsView(getValues) },
51
- renderNodes(children),
52
- ),
53
- ),
54
- );
55
- render(vnode, container);
56
- render(null, container);
57
- }
58
-
59
28
  defineMacro({
60
29
  name: 'link',
61
30
  block: true,
62
31
  interpolate: true,
63
32
  render({ rawArgs, children = [] }, ctx) {
64
- const { display, passage } = parseArgs(rawArgs);
65
- const nobr = useContext(NobrContext);
33
+ const { display, passage } = parseLinkArgs(rawArgs);
34
+ const runBody = useDetachedBody();
66
35
 
67
- const handleClick = (e: Event) => {
68
- e.preventDefault();
69
- renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
36
+ const perform = () => {
37
+ runBody(children);
70
38
  if (passage) {
71
39
  useStoryStore.getState().navigate(passage);
72
40
  }
73
41
  };
74
42
 
43
+ const handleClick = (e: Event) => {
44
+ e.preventDefault();
45
+ perform();
46
+ };
47
+
75
48
  ctx.useAction({
76
49
  type: 'link',
77
50
  key: passage || display,
78
51
  authorId: ctx.id,
79
52
  label: display,
80
53
  target: passage ?? undefined,
81
- perform: () => {
82
- renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
83
- if (passage) {
84
- useStoryStore.getState().navigate(passage);
85
- }
86
- },
54
+ perform,
87
55
  });
88
56
 
89
57
  return (
@@ -1,63 +1,49 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
+ import {
4
+ endsWithOperator,
5
+ isWhitespace,
6
+ readWholeQuoted,
7
+ splitTopLevel,
8
+ } from './arg-utils';
3
9
 
4
- function parseArgs(rawArgs: string): {
10
+ /**
11
+ * Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
12
+ * whitespace outside string and template literals and bracket pairs; a
13
+ * trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
14
+ * `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
15
+ * operand of the max expression, not the label.
16
+ */
17
+ export function parseMeterArgs(rawArgs: string): {
5
18
  currentExpr: string;
6
19
  maxExpr: string;
7
20
  labelMode: string;
8
21
  } {
9
- const trimmed = rawArgs.trim();
22
+ const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
10
23
 
11
- // Extract quoted label mode from the end if present
12
24
  let labelMode = '';
13
- let rest = trimmed;
14
- const quoteMatch = rest.match(/\s+(?:"([^"]*)"|'([^']*)')$/);
15
- if (quoteMatch) {
16
- labelMode = quoteMatch[1] ?? quoteMatch[2] ?? '';
17
- rest = rest.slice(0, quoteMatch.index ?? rest.length).trim();
25
+ const label =
26
+ tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
27
+ ? readWholeQuoted(tokens[tokens.length - 1]!)
28
+ : null;
29
+ if (label !== null) {
30
+ labelMode = label;
31
+ tokens.pop();
18
32
  }
19
33
 
20
- // Split remaining into two expressions.
21
- const exprs = splitExpressions(rest);
22
- if (exprs.length < 2) {
34
+ if (tokens.length < 2) {
23
35
  throw new Error(
24
36
  'meter requires two arguments: {meter currentExpr maxExpr}',
25
37
  );
26
38
  }
27
39
 
28
40
  return {
29
- currentExpr: exprs[0]!,
30
- maxExpr: exprs.slice(1).join(' '),
41
+ currentExpr: tokens[0]!,
42
+ maxExpr: tokens.slice(1).join(' '),
31
43
  labelMode,
32
44
  };
33
45
  }
34
46
 
35
- function splitExpressions(input: string): string[] {
36
- const result: string[] = [];
37
- let current = '';
38
- let depth = 0;
39
-
40
- for (let i = 0; i < input.length; i++) {
41
- const ch = input[i]!;
42
- if (ch === '(' || ch === '[') {
43
- depth++;
44
- current += ch;
45
- } else if (ch === ')' || ch === ']') {
46
- depth--;
47
- current += ch;
48
- } else if (/\s/.test(ch) && depth === 0 && current.length > 0) {
49
- result.push(current);
50
- current = '';
51
- // Skip additional whitespace
52
- while (i + 1 < input.length && /\s/.test(input[i + 1]!)) i++;
53
- } else {
54
- current += ch;
55
- }
56
- }
57
- if (current.length > 0) result.push(current);
58
- return result;
59
- }
60
-
61
47
  function formatLabel(
62
48
  current: number,
63
49
  max: number,
@@ -76,7 +62,7 @@ defineMacro({
76
62
  merged: true,
77
63
  render({ rawArgs }, ctx) {
78
64
  try {
79
- const { currentExpr, maxExpr, labelMode } = parseArgs(rawArgs);
65
+ const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
80
66
  const current = Number(ctx.evaluate!(currentExpr));
81
67
  const max = Number(ctx.evaluate!(maxExpr));
82
68
  const pct =
@@ -13,4 +13,5 @@ defineMacro({
13
13
  ),
14
14
  );
15
15
  },
16
+ text: ({ children = [] }, ctx) => ctx.renderText(children),
16
17
  });
@@ -105,6 +105,9 @@ defineMacro({
105
105
  cleanupSnapshots(containerRef.current);
106
106
  }, []);
107
107
 
108
+ // A transition still running on unmount must not fire into it.
109
+ useEffect(() => cancelTransition, [cancelTransition]);
110
+
108
111
  // State machine effect — triggers on every navigation
109
112
  useEffect(() => {
110
113
  const store = useStoryStore.getState();
@@ -19,4 +19,8 @@ defineMacro({
19
19
  );
20
20
  }
21
21
  },
22
+ text({ rawArgs }, ctx) {
23
+ const result = ctx.evaluate(rawArgs);
24
+ return result == null ? '' : String(result);
25
+ },
22
26
  });
@@ -1,8 +1,33 @@
1
1
  import { defineMacro } from '../../define-macro';
2
+ import {
3
+ isWhitespace,
4
+ readQuoted,
5
+ readWholeQuoted,
6
+ stripLooseQuotes,
7
+ } from './arg-utils';
8
+
9
+ export function parseRadioArgs(rawArgs: string): {
10
+ value: string;
11
+ label: string;
12
+ } {
13
+ // $var "value" label: a quoted value accepts \" \' and \\ escapes and
14
+ // may contain the other quote kind.
15
+ const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
16
+ if (head) {
17
+ const rest = rawArgs.slice(head[0].length).trim();
18
+ const value = readQuoted(rest, 0);
19
+ if (
20
+ value &&
21
+ (value.end === rest.length || isWhitespace(rest[value.end]!))
22
+ ) {
23
+ const labelRaw = rest.slice(value.end).trim();
24
+ const label = readWholeQuoted(labelRaw) ?? stripLooseQuotes(labelRaw);
25
+ return { value: value.value, label };
26
+ }
27
+ }
2
28
 
3
- function parseRadioArgs(rawArgs: string): { value: string; label: string } {
4
29
  const match = rawArgs.match(
5
- /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
30
+ /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
6
31
  );
7
32
  if (!match) {
8
33
  const parts = rawArgs.trim().split(/\s+/).slice(1);