@rohal12/spindle 0.51.4 → 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 (64) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4313 -1640
  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/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +214 -103
  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 +5 -31
  12. package/src/components/macros/Checkbox.tsx +7 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/For.tsx +29 -3
  15. package/src/components/macros/If.tsx +8 -0
  16. package/src/components/macros/Include.tsx +7 -6
  17. package/src/components/macros/MacroError.tsx +2 -1
  18. package/src/components/macros/MacroLink.tsx +12 -45
  19. package/src/components/macros/Meter.tsx +11 -3
  20. package/src/components/macros/Nobr.tsx +1 -0
  21. package/src/components/macros/PassageDisplay.tsx +3 -0
  22. package/src/components/macros/Print.tsx +4 -0
  23. package/src/components/macros/Radiobutton.tsx +5 -2
  24. package/src/components/macros/SaveManager.tsx +25 -8
  25. package/src/components/macros/Span.tsx +1 -0
  26. package/src/components/macros/StoryTitle.tsx +1 -0
  27. package/src/components/macros/Switch.tsx +13 -0
  28. package/src/components/macros/Unset.tsx +30 -10
  29. package/src/components/macros/VarDisplay.tsx +21 -4
  30. package/src/components/macros/Widget.tsx +20 -1
  31. package/src/components/macros/WidgetInvocation.tsx +17 -75
  32. package/src/components/macros/arg-utils.ts +107 -1
  33. package/src/components/macros/detached-body.tsx +68 -0
  34. package/src/components/macros/option-utils.ts +3 -2
  35. package/src/define-macro.ts +32 -5
  36. package/src/execute-mutation.ts +270 -67
  37. package/src/expression.ts +86 -55
  38. package/src/hooks/use-action.ts +18 -3
  39. package/src/hooks/use-interpolate.ts +36 -5
  40. package/src/index.tsx +10 -1
  41. package/src/interpolation.ts +394 -96
  42. package/src/js-lexer.ts +1231 -97
  43. package/src/markup/code-attributes.ts +64 -0
  44. package/src/markup/markdown.ts +188 -9
  45. package/src/markup/render.tsx +430 -49
  46. package/src/markup/tokenizer.ts +578 -110
  47. package/src/prng.ts +8 -8
  48. package/src/registry.ts +35 -0
  49. package/src/saves/save-manager.ts +339 -158
  50. package/src/saves/storage.ts +16 -7
  51. package/src/saves/types.ts +2 -1
  52. package/src/store.ts +521 -154
  53. package/src/story-api.ts +31 -67
  54. package/src/story-init.ts +1 -1
  55. package/src/story-variables.ts +98 -102
  56. package/src/triggers.ts +6 -5
  57. package/src/utils/error-message.ts +12 -0
  58. package/src/utils/live-locals.ts +10 -3
  59. package/src/utils/namespace.ts +71 -0
  60. package/src/utils/object-path.ts +99 -14
  61. package/src/utils/stable-key.ts +14 -9
  62. package/src/widgets/widget-registry.ts +9 -0
  63. package/types/index.d.ts +43 -7
  64. package/types/tooling.d.ts +1 -0
@@ -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,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,
@@ -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
 
@@ -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
  });
@@ -47,4 +47,17 @@ defineMacro({
47
47
 
48
48
  return null;
49
49
  },
50
+ text({ rawArgs, branches = [] }, ctx) {
51
+ const switchValue = ctx.evaluate(rawArgs);
52
+ let defaultBranch: (typeof branches)[number] | null = null;
53
+ for (let i = 1; i < branches.length; i++) {
54
+ const branch = branches[i]!;
55
+ if (branch.rawArgs === '') {
56
+ defaultBranch = branch;
57
+ } else if (switchValue === ctx.evaluate(branch.rawArgs)) {
58
+ return ctx.renderText(branch.children);
59
+ }
60
+ }
61
+ return defaultBranch ? ctx.renderText(defaultBranch.children) : '';
62
+ },
50
63
  });
@@ -1,31 +1,51 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
+ import { MacroError } from './MacroError';
3
4
 
4
5
  defineMacro({
5
6
  name: 'unset',
6
7
  render({ rawArgs }, ctx) {
7
8
  const ran = ctx.hooks.useRef(false);
9
+ const error = ctx.hooks.useRef<unknown>(null);
8
10
 
9
11
  if (!ran.current) {
10
12
  ran.current = true;
11
13
  const state = useStoryStore.getState();
12
14
  const name = rawArgs.trim();
13
15
 
14
- if (name.startsWith('$')) {
15
- state.deleteVariable(name.slice(1));
16
- } else if (name.startsWith('_')) {
17
- state.deleteTemporary(name.slice(1));
18
- } else if (name.startsWith('%')) {
19
- state.deleteTransient(name.slice(1));
20
- } else if (name.startsWith('@')) {
21
- ctx.update(name.slice(1), undefined);
22
- } else {
16
+ try {
17
+ if (name.startsWith('$')) {
18
+ state.deleteVariable(name.slice(1));
19
+ } else if (name.startsWith('_')) {
20
+ state.deleteTemporary(name.slice(1));
21
+ } else if (name.startsWith('%')) {
22
+ state.deleteTransient(name.slice(1));
23
+ } else if (name.startsWith('@')) {
24
+ // Throws outside a locals scope ({for}, widget, {link}, {button})
25
+ ctx.update(name.slice(1), undefined);
26
+ } else {
27
+ throw new Error(
28
+ `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
29
+ );
30
+ }
31
+ } catch (err) {
32
+ error.current = err;
23
33
  console.error(
24
- `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
34
+ `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
35
+ err,
25
36
  );
26
37
  }
27
38
  }
28
39
 
40
+ if (error.current) {
41
+ return (
42
+ <MacroError
43
+ macro="unset"
44
+ error={error.current}
45
+ />
46
+ );
47
+ }
48
+
29
49
  return null;
30
50
  },
31
51
  });
@@ -2,6 +2,14 @@ import { useStoryStore } from '../../store';
2
2
  import { useContext } from 'preact/hooks';
3
3
  import { LocalsValuesContext } from '../../markup/render';
4
4
  import { useInterpolate } from '../../hooks/use-interpolate';
5
+ import { RESERVED_NAME, ownValue } from '../../utils/namespace';
6
+
7
+ const SIGILS = {
8
+ variable: '$',
9
+ temporary: '_',
10
+ local: '@',
11
+ transient: '%',
12
+ } as const;
5
13
 
6
14
  interface VarDisplayProps {
7
15
  name: string;
@@ -17,19 +25,28 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) {
17
25
  const localsValues = useContext(LocalsValuesContext);
18
26
  const parts = name.split('.');
19
27
  const root = parts[0]!;
28
+ // Own entries only, as the expression engine reads them
20
29
  const storeValue = useStoryStore((s) =>
21
30
  scope === 'variable'
22
- ? s.variables[root]
31
+ ? ownValue(s.variables, root)
23
32
  : scope === 'temporary'
24
- ? s.temporary[root]
33
+ ? ownValue(s.temporary, root)
25
34
  : scope === 'transient'
26
- ? s.transient[root]
35
+ ? ownValue(s.transient, root)
27
36
  : undefined,
28
37
  );
29
38
 
39
+ if (root === RESERVED_NAME) {
40
+ return (
41
+ <span class="error">
42
+ {`{${SIGILS[scope]}${name} error: "${SIGILS[scope]}${root}" cannot be used as a variable name (${RESERVED_NAME} is reserved)}`}
43
+ </span>
44
+ );
45
+ }
46
+
30
47
  let value: unknown;
31
48
  if (scope === 'local') {
32
- value = root in localsValues ? localsValues[root] : undefined;
49
+ value = ownValue(localsValues, root);
33
50
  } else {
34
51
  value = storeValue;
35
52
  }