@rohal12/spindle 0.51.4 → 0.52.1

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 (69) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4512 -1702
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1636 -177
  5. package/package.json +5 -2
  6. package/src/action-registry.ts +70 -18
  7. package/src/automation/runner.ts +2 -1
  8. package/src/class-registry.ts +214 -103
  9. package/src/components/Passage.tsx +2 -2
  10. package/src/components/PassageDialog.tsx +2 -5
  11. package/src/components/StoryInterface.tsx +2 -4
  12. package/src/components/macros/Button.tsx +5 -31
  13. package/src/components/macros/Checkbox.tsx +7 -4
  14. package/src/components/macros/Computed.tsx +28 -14
  15. package/src/components/macros/For.tsx +29 -3
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +7 -6
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +12 -45
  20. package/src/components/macros/Meter.tsx +11 -3
  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 +5 -2
  25. package/src/components/macros/SaveManager.tsx +25 -8
  26. package/src/components/macros/Set.tsx +5 -4
  27. package/src/components/macros/Span.tsx +1 -0
  28. package/src/components/macros/StoryTitle.tsx +1 -0
  29. package/src/components/macros/Switch.tsx +13 -0
  30. package/src/components/macros/Unset.tsx +31 -10
  31. package/src/components/macros/VarDisplay.tsx +21 -4
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +17 -75
  34. package/src/components/macros/arg-utils.ts +107 -1
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +3 -2
  37. package/src/define-macro.ts +32 -5
  38. package/src/execute-mutation.ts +271 -68
  39. package/src/expression.ts +91 -59
  40. package/src/hooks/use-action.ts +24 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +12 -2
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1231 -97
  45. package/src/markup/ast.ts +7 -2
  46. package/src/markup/code-attributes.ts +64 -0
  47. package/src/markup/markdown.ts +188 -9
  48. package/src/markup/render.tsx +430 -49
  49. package/src/markup/tokenizer.ts +578 -110
  50. package/src/prng.ts +41 -8
  51. package/src/registry.ts +35 -0
  52. package/src/saves/save-manager.ts +346 -160
  53. package/src/saves/storage.ts +16 -7
  54. package/src/saves/types.ts +2 -1
  55. package/src/settings.ts +12 -9
  56. package/src/store.ts +640 -186
  57. package/src/story-api.ts +38 -73
  58. package/src/story-init.ts +1 -1
  59. package/src/story-variables.ts +98 -102
  60. package/src/triggers.ts +10 -12
  61. package/src/utils/counts.ts +45 -0
  62. package/src/utils/error-message.ts +12 -0
  63. package/src/utils/live-locals.ts +10 -3
  64. package/src/utils/namespace.ts +71 -0
  65. package/src/utils/object-path.ts +99 -14
  66. package/src/utils/stable-key.ts +14 -9
  67. package/src/widgets/widget-registry.ts +9 -0
  68. package/types/index.d.ts +43 -7
  69. package/types/tooling.d.ts +1 -0
@@ -6,6 +6,7 @@ import { renderNodes, NobrContext } from '../markup/render';
6
6
  import { useStoryStore } from '../store';
7
7
  import { emitFromRender } from '../event-emitter';
8
8
  import { useModalFocus } from '../hooks/use-modal-focus';
9
+ import { errorMessage } from '../utils/error-message';
9
10
 
10
11
  export const DialogCloseContext = createContext<(() => void) | null>(null);
11
12
 
@@ -61,11 +62,7 @@ export function PassageDialog({
61
62
  nodes
62
63
  );
63
64
  } catch (err) {
64
- return (
65
- <div class="error">
66
- Error in dialog: {err instanceof Error ? err.message : String(err)}
67
- </div>
68
- );
65
+ return <div class="error">Error in dialog: {errorMessage(err)}</div>;
69
66
  }
70
67
  }, [markup, nobr]);
71
68
 
@@ -3,6 +3,7 @@ import { useStoryStore } from '../store';
3
3
  import { tokenize } from '../markup/tokenizer';
4
4
  import { buildAST } from '../markup/ast';
5
5
  import { renderInlineNodes, NobrContext } from '../markup/render';
6
+ import { errorMessage } from '../utils/error-message';
6
7
 
7
8
  const DEFAULT_MARKUP =
8
9
  '<header class="story-menubar">{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}</header>\n{passage}';
@@ -22,10 +23,7 @@ export function StoryInterface() {
22
23
  return <>{renderInlineNodes(ast)}</>;
23
24
  } catch (err) {
24
25
  return (
25
- <span class="error">
26
- Error in StoryInterface:{' '}
27
- {err instanceof Error ? err.message : String(err)}
28
- </span>
26
+ <span class="error">Error in StoryInterface: {errorMessage(err)}</span>
29
27
  );
30
28
  }
31
29
  }, [markup]);
@@ -1,14 +1,6 @@
1
- import { h, render } from 'preact';
2
- import { useContext } from 'preact/hooks';
3
1
  import { defineMacro } from '../../define-macro';
4
2
  import { readWholeQuoted } from './arg-utils';
5
- import {
6
- renderNodes,
7
- LocalsUpdateContext,
8
- LocalsValuesContext,
9
- NobrContext,
10
- } from '../../markup/render';
11
- import { liveLocalsView } from '../../utils/live-locals';
3
+ import { useDetachedBody } from './detached-body';
12
4
 
13
5
  defineMacro({
14
6
  name: 'button',
@@ -18,29 +10,11 @@ defineMacro({
18
10
  const text =
19
11
  readWholeQuoted(rawArgs.trim()) ?? rawArgs.replace(/^["']|["']$/g, '');
20
12
  const label = ctx.resolve?.(text) ?? rawArgs;
21
- const nobr = useContext(NobrContext);
13
+ const runBody = useDetachedBody();
22
14
 
23
- const handleClick = () => {
24
- // Render children into a detached DOM node — all macro side effects
25
- // ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
26
- // Wrap with locals context so @local variables from for-loops are available.
27
- const container = document.createElement('div');
28
- const vnode = h(
29
- NobrContext.Provider,
30
- { value: nobr },
31
- h(
32
- LocalsUpdateContext.Provider,
33
- { value: { update: ctx.update, getValues: ctx.getValues } },
34
- h(
35
- LocalsValuesContext.Provider,
36
- { value: liveLocalsView(ctx.getValues) },
37
- renderNodes(children),
38
- ),
39
- ),
40
- );
41
- render(vnode, container);
42
- render(null, container);
43
- };
15
+ // Run the body outside the passage tree: all macro side effects ({set},
16
+ // {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
17
+ const handleClick = () => runBody(children);
44
18
 
45
19
  ctx.useAction({
46
20
  type: 'button',
@@ -1,10 +1,13 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { readWholeQuoted, stripLooseQuotes } from './arg-utils';
3
3
 
4
- function parseLabel(rawArgs: string): string {
5
- const match = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+(.+?)\s*$/);
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);
6
9
  if (!match) return '';
7
- const rest = match[1]!;
10
+ const rest = match[1]!.trimEnd() || match[1]!;
8
11
  return readWholeQuoted(rest) ?? stripLooseQuotes(rest);
9
12
  }
10
13
 
@@ -12,7 +15,7 @@ defineMacro({
12
15
  name: 'checkbox',
13
16
  storeVar: true,
14
17
  render({ rawArgs }, ctx) {
15
- const label = parseLabel(rawArgs);
18
+ const label = parseCheckboxLabel(rawArgs);
16
19
 
17
20
  ctx.useAction({
18
21
  type: 'checkbox',
@@ -1,9 +1,18 @@
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';
9
+
10
+ /**
11
+ * The previous output before the first successful evaluation. Distinct from
12
+ * every value an expression can produce, so the first result is always
13
+ * applied, even `undefined`; a first evaluation that throws leaves it unset.
14
+ */
15
+ const UNSET: unique symbol = Symbol('unset');
7
16
 
8
17
  function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
9
18
  const trimmed = rawArgs.trim();
@@ -29,6 +38,7 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
29
38
  );
30
39
  }
31
40
 
41
+ checkVariableName(target.slice(1), target);
32
42
  return { target, expr };
33
43
  }
34
44
  }
@@ -38,22 +48,30 @@ function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
38
48
  );
39
49
  }
40
50
 
51
+ /**
52
+ * Evaluate `expr` against the current values — the store's state and the
53
+ * enclosing locals scope's live values (`getLocals`) — and write the result
54
+ * to the target if it differs from the previous output (always, the first
55
+ * time). Reading live values rather than the render's snapshot means the
56
+ * first computation sees a local a preceding {set} just assigned (the scope's
57
+ * context value only catches up on its re-render), and the first computation
58
+ * and later recomputations read the same source.
59
+ */
41
60
  function computeAndApply(
42
61
  expr: string,
43
62
  name: string,
44
63
  isTemp: boolean,
45
64
  isLocal: boolean,
46
- variables: Record<string, unknown>,
47
- temporary: Record<string, unknown>,
48
- locals: Record<string, unknown>,
49
- transient: Record<string, unknown>,
65
+ getLocals: () => Record<string, unknown>,
50
66
  rawArgs: string,
51
67
  prevRef: { current: unknown },
52
68
  localsUpdate: ((key: string, value: unknown) => void) | null,
53
69
  ): void {
54
70
  let newValue: unknown;
55
71
  try {
56
- newValue = evaluate(expr, variables, temporary, locals, transient);
72
+ // In program order, also when mutation code sets this off
73
+ const { variables, temporary, transient } = readState();
74
+ newValue = evaluate(expr, variables, temporary, getLocals(), transient);
57
75
  } catch (err) {
58
76
  console.error(
59
77
  `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
@@ -104,7 +122,7 @@ defineMacro({
104
122
  const name = target.slice(1);
105
123
  const localsUpdate = isLocal ? ctx.update : null;
106
124
 
107
- const prevOutput = ctx.hooks.useRef<unknown>(undefined);
125
+ const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
108
126
 
109
127
  const ran = ctx.hooks.useRef(false);
110
128
  if (!ran.current) {
@@ -114,26 +132,22 @@ defineMacro({
114
132
  name,
115
133
  isTemp,
116
134
  isLocal,
117
- mergedVars,
118
- mergedTemps,
119
- mergedLocals,
120
- mergedTrans,
135
+ ctx.getValues,
121
136
  rawArgs,
122
137
  prevOutput,
123
138
  localsUpdate,
124
139
  );
125
140
  }
126
141
 
142
+ // The merged values only decide when to recompute; computeAndApply reads
143
+ // the current ones.
127
144
  ctx.hooks.useLayoutEffect(() => {
128
145
  computeAndApply(
129
146
  expr,
130
147
  name,
131
148
  isTemp,
132
149
  isLocal,
133
- mergedVars,
134
- mergedTemps,
135
- mergedLocals,
136
- mergedTrans,
150
+ ctx.getValues,
137
151
  rawArgs,
138
152
  prevOutput,
139
153
  localsUpdate,
@@ -10,12 +10,18 @@ import {
10
10
  LocalsUpdateContext,
11
11
  NobrContext,
12
12
  InlineContext,
13
+ RawTextContext,
13
14
  renderNodes,
14
15
  } from '../../markup/render';
15
16
  import { defineMacro } from '../../define-macro';
16
17
  import { MacroError } from './MacroError';
17
18
  import { stableKey } from '../../utils/stable-key';
18
19
  import type { ASTNode } from '../../markup/ast';
20
+ import {
21
+ checkVariableName,
22
+ createNamespace,
23
+ withEntry,
24
+ } from '../../utils/namespace';
19
25
 
20
26
  /**
21
27
  * Parse for-loop args: "@item, @i of $list" or "@item of $list"
@@ -46,6 +52,9 @@ function parseForArgs(rawArgs: string): {
46
52
  );
47
53
  }
48
54
 
55
+ checkVariableName(itemVar.slice(1), itemVar);
56
+ if (indexVar) checkVariableName(indexVar.slice(1), indexVar);
57
+
49
58
  return {
50
59
  itemVar: itemVar.slice(1),
51
60
  indexVar: indexVar ? indexVar.slice(1) : null,
@@ -81,7 +90,7 @@ function ForIteration({
81
90
  );
82
91
 
83
92
  const localState = useMemo(
84
- () => ({ ...parentValues, ...ownKeys, ...localMutations }),
93
+ () => createNamespace(parentValues, ownKeys, localMutations),
85
94
  [parentValues, ownKeys, localMutations],
86
95
  );
87
96
 
@@ -93,18 +102,20 @@ function ForIteration({
93
102
  // Apply synchronously so later macros in the same render pass (e.g. a
94
103
  // second {set}) read the new value via getValues(); the state update
95
104
  // then re-renders consumers of LocalsValuesContext.
96
- valuesRef.current = { ...valuesRef.current, [key]: value };
105
+ checkVariableName(key, `@${key}`);
106
+ valuesRef.current = withEntry(valuesRef.current, key, value);
97
107
  setLocalMutations((prev) => ({ ...prev, [key]: value }));
98
108
  }, []);
99
109
  const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
100
110
 
101
111
  const nobr = useContext(NobrContext);
102
112
  const inline = useContext(InlineContext);
113
+ const raw = useContext(RawTextContext);
103
114
 
104
115
  return (
105
116
  <LocalsUpdateContext.Provider value={updater}>
106
117
  <LocalsValuesContext.Provider value={localState}>
107
- {renderNodes(children, { nobr, inline, locals: localState })}
118
+ {renderNodes(children, { nobr, inline, raw, locals: localState })}
108
119
  </LocalsValuesContext.Provider>
109
120
  </LocalsUpdateContext.Provider>
110
121
  );
@@ -166,4 +177,19 @@ defineMacro({
166
177
 
167
178
  return ctx.wrap(content);
168
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
+ },
169
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,7 +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 { isWhitespace, topLevelIndices } from './arg-utils';
6
+ import { endsWithOperator, isWhitespace, topLevelIndices } from './arg-utils';
7
7
 
8
8
  const FLAG = 'inline';
9
9
 
@@ -13,7 +13,7 @@ const FLAG = 'inline';
13
13
  * braces, separated by whitespace from the target expression, so passage
14
14
  * names and expressions containing the word stay intact (#201).
15
15
  */
16
- function parseIncludeArgs(rawArgs: string): {
16
+ export function parseIncludeArgs(rawArgs: string): {
17
17
  nameExpr: string;
18
18
  inline: boolean;
19
19
  } {
@@ -32,19 +32,20 @@ function parseIncludeArgs(rawArgs: string): {
32
32
  last = [spaces[e]!, spaces[spaces.length - 1]! + 1];
33
33
  }
34
34
 
35
- // 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.
36
37
  if (
37
38
  last &&
38
39
  last[1] === trimmed.length - FLAG.length &&
39
40
  trimmed.endsWith(FLAG)
40
41
  ) {
41
42
  const rest = trimmed.slice(0, last[0]);
42
- if (!/[-+*/%&|^!=<>?:,.]$/.test(rest))
43
- return { nameExpr: rest, inline: true };
43
+ if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
44
44
  }
45
45
  if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
46
46
  const rest = trimmed.slice(first[1]);
47
- if (!/^[-+*/%&|^=<>?:,.]/.test(rest))
47
+ // `inline %name` reads a transient variable; `inline % 2` is modulo.
48
+ if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
48
49
  return { nameExpr: rest, inline: true };
49
50
  }
50
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,17 +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';
12
3
  import { unescapeQuoted } from './arg-utils';
4
+ import { useDetachedBody } from './detached-body';
13
5
 
14
- function parseArgs(rawArgs: string): {
6
+ export function parseLinkArgs(rawArgs: string): {
15
7
  display: string;
16
8
  passage: string | null;
17
9
  } {
@@ -33,58 +25,33 @@ function parseArgs(rawArgs: string): {
33
25
  return { display: rawArgs.trim(), passage: null };
34
26
  }
35
27
 
36
- function renderChildrenDetached(
37
- children: import('../../markup/ast').ASTNode[],
38
- getValues: () => Record<string, unknown>,
39
- update: (key: string, value: unknown) => void,
40
- nobr: boolean,
41
- ) {
42
- const container = document.createElement('div');
43
- const vnode = h(
44
- NobrContext.Provider,
45
- { value: nobr },
46
- h(
47
- LocalsUpdateContext.Provider,
48
- { value: { update, getValues } },
49
- h(
50
- LocalsValuesContext.Provider,
51
- { value: liveLocalsView(getValues) },
52
- renderNodes(children),
53
- ),
54
- ),
55
- );
56
- render(vnode, container);
57
- render(null, container);
58
- }
59
-
60
28
  defineMacro({
61
29
  name: 'link',
62
30
  block: true,
63
31
  interpolate: true,
64
32
  render({ rawArgs, children = [] }, ctx) {
65
- const { display, passage } = parseArgs(rawArgs);
66
- const nobr = useContext(NobrContext);
33
+ const { display, passage } = parseLinkArgs(rawArgs);
34
+ const runBody = useDetachedBody();
67
35
 
68
- const handleClick = (e: Event) => {
69
- e.preventDefault();
70
- renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
36
+ const perform = () => {
37
+ runBody(children);
71
38
  if (passage) {
72
39
  useStoryStore.getState().navigate(passage);
73
40
  }
74
41
  };
75
42
 
43
+ const handleClick = (e: Event) => {
44
+ e.preventDefault();
45
+ perform();
46
+ };
47
+
76
48
  ctx.useAction({
77
49
  type: 'link',
78
50
  key: passage || display,
79
51
  authorId: ctx.id,
80
52
  label: display,
81
53
  target: passage ?? undefined,
82
- perform: () => {
83
- renderChildrenDetached(children, ctx.getValues, ctx.update, nobr);
84
- if (passage) {
85
- useStoryStore.getState().navigate(passage);
86
- }
87
- },
54
+ perform,
88
55
  });
89
56
 
90
57
  return (
@@ -1,12 +1,18 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
- import { isWhitespace, readWholeQuoted, splitTopLevel } from './arg-utils';
3
+ import {
4
+ endsWithOperator,
5
+ isWhitespace,
6
+ readWholeQuoted,
7
+ splitTopLevel,
8
+ } from './arg-utils';
4
9
 
5
10
  /**
6
11
  * Parse `{meter currentExpr maxExpr ["label"]}`. Arguments are separated by
7
12
  * whitespace outside string and template literals and bracket pairs; a
8
13
  * trailing standalone `"…"` or `'…'` string is the label mode, with `\"`,
9
- * `\'` and `\\` escapes.
14
+ * `\'` and `\\` escapes. A string after an operator (`$a ?? "5"`) is an
15
+ * operand of the max expression, not the label.
10
16
  */
11
17
  export function parseMeterArgs(rawArgs: string): {
12
18
  currentExpr: string;
@@ -17,7 +23,9 @@ export function parseMeterArgs(rawArgs: string): {
17
23
 
18
24
  let labelMode = '';
19
25
  const label =
20
- tokens.length >= 2 ? readWholeQuoted(tokens[tokens.length - 1]!) : null;
26
+ tokens.length >= 2 && !endsWithOperator(tokens.slice(0, -1).join(' '))
27
+ ? readWholeQuoted(tokens[tokens.length - 1]!)
28
+ : null;
21
29
  if (label !== null) {
22
30
  labelMode = label;
23
31
  tokens.pop();
@@ -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
  });
@@ -6,7 +6,10 @@ import {
6
6
  stripLooseQuotes,
7
7
  } from './arg-utils';
8
8
 
9
- function parseRadioArgs(rawArgs: string): { value: string; label: string } {
9
+ export function parseRadioArgs(rawArgs: string): {
10
+ value: string;
11
+ label: string;
12
+ } {
10
13
  // $var "value" label: a quoted value accepts \" \' and \\ escapes and
11
14
  // may contain the other quote kind.
12
15
  const head = rawArgs.match(/^\s*["']?\$?[\w.]+["']?\s+/);
@@ -24,7 +27,7 @@ function parseRadioArgs(rawArgs: string): { value: string; label: string } {
24
27
  }
25
28
 
26
29
  const match = rawArgs.match(
27
- /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/,
30
+ /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
28
31
  );
29
32
  if (!match) {
30
33
  const parts = rawArgs.trim().split(/\s+/).slice(1);
@@ -22,6 +22,7 @@ import {
22
22
  } from '../../saves/save-manager';
23
23
  import { DialogCloseContext } from '../PassageDialog';
24
24
  import { defineMacro } from '../../define-macro';
25
+ import { errorMessage } from '../../utils/error-message';
25
26
 
26
27
  function relativeTime(iso: string): string {
27
28
  const diff = Date.now() - new Date(iso).getTime();
@@ -84,15 +85,25 @@ export function SaveManagerContent() {
84
85
  }
85
86
  }, [renamingId]);
86
87
 
88
+ // Actions are async and may finish after the manager closed: once
89
+ // unmounted, no status is shown and no timer is left behind.
90
+ const mounted = useRef(true);
87
91
  const statusTimer = useRef<number>();
92
+ const closeTimer = useRef<number>();
88
93
  const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
94
+ if (!mounted.current) return;
89
95
  clearTimeout(statusTimer.current);
90
96
  setStatus({ text, type });
91
97
  statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
92
98
  };
93
99
 
94
100
  useEffect(() => {
95
- return () => clearTimeout(statusTimer.current);
101
+ mounted.current = true;
102
+ return () => {
103
+ mounted.current = false;
104
+ clearTimeout(statusTimer.current);
105
+ clearTimeout(closeTimer.current);
106
+ };
96
107
  }, []);
97
108
 
98
109
  const toggleCollapse = (id: string) => {
@@ -142,10 +153,19 @@ export function SaveManagerContent() {
142
153
 
143
154
  const handleLoad = async (save: SaveRecord) => {
144
155
  try {
145
- // Stored records hold serialized variables; the store expects live ones
146
- loadFromPayload(deserializePayload(save.payload));
156
+ // Stored records hold serialized variables; the store expects live
157
+ // ones. The game moves to the save's playthrough.
158
+ loadFromPayload(
159
+ deserializePayload(save.payload),
160
+ undefined,
161
+ save.meta.playthroughId,
162
+ );
147
163
  showStatus('Game loaded');
148
- if (closeDialog) setTimeout(closeDialog, 500);
164
+ if (closeDialog) {
165
+ closeTimer.current = window.setTimeout(closeDialog, 500);
166
+ }
167
+ // A playthrough deleted since the list was read is recorded again
168
+ await refresh();
149
169
  } catch {
150
170
  showStatus('Failed to load save', 'error');
151
171
  }
@@ -229,10 +249,7 @@ export function SaveManagerContent() {
229
249
  showStatus('Save imported');
230
250
  await refresh();
231
251
  } catch (err) {
232
- showStatus(
233
- err instanceof Error ? err.message : 'Failed to import save',
234
- 'error',
235
- );
252
+ showStatus(errorMessage(err, 'Failed to import save'), 'error');
236
253
  }
237
254
  };
238
255
 
@@ -5,7 +5,8 @@ defineMacro({
5
5
  name: 'set',
6
6
  render({ rawArgs }, ctx) {
7
7
  const ran = ctx.hooks.useRef(false);
8
- const error = ctx.hooks.useRef<unknown>(null);
8
+ // Boxed: anything can be thrown, including null and other falsy values
9
+ const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
9
10
 
10
11
  if (!ran.current) {
11
12
  ran.current = true;
@@ -13,7 +14,7 @@ defineMacro({
13
14
  try {
14
15
  ctx.mutate(rawArgs);
15
16
  } catch (err) {
16
- error.current = err;
17
+ failure.current = { error: err };
17
18
  console.error(
18
19
  `spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
19
20
  err,
@@ -21,11 +22,11 @@ defineMacro({
21
22
  }
22
23
  }
23
24
 
24
- if (error.current) {
25
+ if (failure.current) {
25
26
  return (
26
27
  <MacroError
27
28
  macro="set"
28
- error={error.current}
29
+ error={failure.current.error}
29
30
  />
30
31
  );
31
32
  }
@@ -6,4 +6,5 @@ defineMacro({
6
6
  render({ children = [] }, ctx) {
7
7
  return ctx.wrap(ctx.renderNodes(children));
8
8
  },
9
+ text: ({ children = [] }, ctx) => ctx.renderText(children),
9
10
  });
@@ -16,4 +16,5 @@ defineMacro({
16
16
  </span>
17
17
  );
18
18
  },
19
+ text: () => useStoryStore.getState().storyData?.name || '',
19
20
  });