@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
@@ -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,52 @@
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
+ // Boxed: anything can be thrown, including null and other falsy values
10
+ const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
8
11
 
9
12
  if (!ran.current) {
10
13
  ran.current = true;
11
14
  const state = useStoryStore.getState();
12
15
  const name = rawArgs.trim();
13
16
 
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 {
17
+ try {
18
+ if (name.startsWith('$')) {
19
+ state.deleteVariable(name.slice(1));
20
+ } else if (name.startsWith('_')) {
21
+ state.deleteTemporary(name.slice(1));
22
+ } else if (name.startsWith('%')) {
23
+ state.deleteTransient(name.slice(1));
24
+ } else if (name.startsWith('@')) {
25
+ // Throws outside a locals scope ({for}, widget, {link}, {button})
26
+ ctx.update(name.slice(1), undefined);
27
+ } else {
28
+ throw new Error(
29
+ `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
30
+ );
31
+ }
32
+ } catch (err) {
33
+ failure.current = { error: err };
23
34
  console.error(
24
- `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
35
+ `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
36
+ err,
25
37
  );
26
38
  }
27
39
  }
28
40
 
41
+ if (failure.current) {
42
+ return (
43
+ <MacroError
44
+ macro="unset"
45
+ error={failure.current.error}
46
+ />
47
+ );
48
+ }
49
+
29
50
  return null;
30
51
  },
31
52
  });
@@ -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
  }
@@ -2,11 +2,14 @@ import { registerWidget } from '../../widgets/widget-registry';
2
2
  import { astContainsChildren } from '../../widgets/ast-scanner';
3
3
  import { registerBlockMacro } from '../../markup/ast';
4
4
  import { defineMacro } from '../../define-macro';
5
+ import { checkVariableName } from '../../utils/namespace';
6
+ import { MacroError } from './MacroError';
5
7
 
6
8
  function parseWidgetDef(rawArgs: string): { name: string; params: string[] } {
7
9
  const tokens = rawArgs.trim().split(/\s+/);
8
10
  const name = tokens[0]!.replace(/["']/g, '');
9
11
  const params = tokens.slice(1).filter((t) => t.startsWith('@'));
12
+ for (const param of params) checkVariableName(param.slice(1), param);
10
13
  return { name, params };
11
14
  }
12
15
 
@@ -14,12 +17,20 @@ defineMacro({
14
17
  name: 'widget',
15
18
  block: true,
16
19
  render({ rawArgs, children = [] }, ctx) {
17
- const { name, params } = parseWidgetDef(rawArgs);
20
+ let parsed: ReturnType<typeof parseWidgetDef> | undefined;
21
+ let error: unknown;
22
+ try {
23
+ parsed = parseWidgetDef(rawArgs);
24
+ } catch (err) {
25
+ error = err;
26
+ }
27
+ const { name, params } = parsed ?? { name: '', params: [] };
18
28
 
19
29
  const childrenKey = JSON.stringify(children);
20
30
  const paramsKey = params.join(',');
21
31
 
22
32
  ctx.hooks.useLayoutEffect(() => {
33
+ if (!parsed) return;
23
34
  // Widgets whose body renders {@children} take a closing tag; register
24
35
  // them as block macros so passages parsed later nest their content.
25
36
  const isBlock = astContainsChildren(children);
@@ -27,6 +38,14 @@ defineMacro({
27
38
  if (isBlock) registerBlockMacro(name);
28
39
  }, [name, childrenKey, paramsKey]);
29
40
 
41
+ if (error) {
42
+ return (
43
+ <MacroError
44
+ macro="widget"
45
+ error={error}
46
+ />
47
+ );
48
+ }
30
49
  return null;
31
50
  },
32
51
  });
@@ -11,12 +11,20 @@ import {
11
11
  NobrContext,
12
12
  InlineContext,
13
13
  WidgetChildrenContext,
14
+ RawTextContext,
14
15
  renderNodes,
15
16
  } from '../../markup/render';
16
17
  import { useMergedLocals } from '../../hooks/use-merged-locals';
17
18
  import { evaluate } from '../../expression';
18
19
  import type { ASTNode } from '../../markup/ast';
19
- import { isWhitespace, splitTopLevel } from './arg-utils';
20
+ import { splitArgs } from './arg-utils';
21
+ import {
22
+ checkVariableName,
23
+ createNamespace,
24
+ withEntry,
25
+ } from '../../utils/namespace';
26
+
27
+ export { splitArgs };
20
28
 
21
29
  interface WidgetInvocationProps {
22
30
  body: ASTNode[];
@@ -25,75 +33,6 @@ interface WidgetInvocationProps {
25
33
  invocationChildren?: ASTNode[];
26
34
  }
27
35
 
28
- /**
29
- * Check whether a whitespace-delimited token looks like a standalone value
30
- * (not an operator or partial expression).
31
- */
32
- function isStandaloneValue(token: string): boolean {
33
- const first = token[0]!;
34
- // Quoted string
35
- if (first === '"' || first === "'" || first === '`') return true;
36
- // Variable ($var, _var, @var)
37
- if (first === '$' || first === '_' || first === '@' || first === '%')
38
- return true;
39
- // Number literal
40
- if (/\d/.test(first)) return true;
41
- // Signed number (-1, +2)
42
- if (
43
- (first === '-' || first === '+') &&
44
- token.length > 1 &&
45
- /\d/.test(token[1]!)
46
- )
47
- return true;
48
- // Grouped expression or collection literal
49
- if (first === '(' || first === '[' || first === '{') return true;
50
- // Boolean / null / undefined
51
- if (/^(true|false|null|undefined)$/.test(token)) return true;
52
- // Negation (!$flag, !true)
53
- if (first === '!' && token.length > 1) return true;
54
- return false;
55
- }
56
-
57
- /**
58
- * Try to split a raw string on whitespace at depth 0 (respecting strings,
59
- * template literals, parentheses, brackets, and braces). Each resulting token
60
- * must pass `isStandaloneValue()` or the split is rejected and `null` is
61
- * returned.
62
- */
63
- function trySplitOnWhitespace(raw: string): string[] | null {
64
- const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
65
-
66
- // Need 2+ tokens
67
- if (args.length < 2) return null;
68
-
69
- // Every token must be a standalone value (not an operator)
70
- for (const arg of args) {
71
- if (!isStandaloneValue(arg)) return null;
72
- }
73
-
74
- return args;
75
- }
76
-
77
- /**
78
- * Split rawArgs by commas, respecting parentheses, brackets, braces, and
79
- * strings. When no top-level commas are present, also supports adjacent quoted
80
- * string literals separated by whitespace (e.g. `"Label" "target"`).
81
- */
82
- export function splitArgs(raw: string): string[] {
83
- const args = splitTopLevel(raw, (ch) => ch === ',').map((a) => a.trim());
84
- const hasComma = args.length > 1;
85
- if (args[args.length - 1] === '') args.pop();
86
-
87
- // If no commas were found and we got a single expression, try splitting
88
- // on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
89
- if (!hasComma && args.length === 1) {
90
- const split = trySplitOnWhitespace(args[0]!);
91
- if (split) return split;
92
- }
93
-
94
- return args;
95
- }
96
-
97
36
  function WidgetBody({
98
37
  body,
99
38
  parentValues,
@@ -105,12 +44,13 @@ function WidgetBody({
105
44
  }) {
106
45
  const nobr = useContext(NobrContext);
107
46
  const inline = useContext(InlineContext);
47
+ const raw = useContext(RawTextContext);
108
48
  const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
109
49
  {},
110
50
  );
111
51
 
112
52
  const localState = useMemo(
113
- () => ({ ...parentValues, ...ownKeys, ...localMutations }),
53
+ () => createNamespace(parentValues, ownKeys, localMutations),
114
54
  [parentValues, ownKeys, localMutations],
115
55
  );
116
56
 
@@ -122,7 +62,8 @@ function WidgetBody({
122
62
  // Apply synchronously so later macros in the same render pass (e.g. a
123
63
  // second {set}) read the new value via getValues(); the state update
124
64
  // then re-renders consumers of LocalsValuesContext.
125
- valuesRef.current = { ...valuesRef.current, [key]: value };
65
+ checkVariableName(key, `@${key}`);
66
+ valuesRef.current = withEntry(valuesRef.current, key, value);
126
67
  setLocalMutations((prev) => ({ ...prev, [key]: value }));
127
68
  }, []);
128
69
  const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
@@ -130,7 +71,7 @@ function WidgetBody({
130
71
  return (
131
72
  <LocalsUpdateContext.Provider value={updater}>
132
73
  <LocalsValuesContext.Provider value={localState}>
133
- {renderNodes(body, { nobr, inline, locals: localState })}
74
+ {renderNodes(body, { nobr, inline, raw, locals: localState })}
134
75
  </LocalsValuesContext.Provider>
135
76
  </LocalsUpdateContext.Provider>
136
77
  );
@@ -145,6 +86,7 @@ export function WidgetInvocation({
145
86
  const parentValues = useContext(LocalsValuesContext);
146
87
  const nobr = useContext(NobrContext);
147
88
  const inline = useContext(InlineContext);
89
+ const raw = useContext(RawTextContext);
148
90
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
149
91
  useMergedLocals();
150
92
 
@@ -155,7 +97,7 @@ export function WidgetInvocation({
155
97
  if (params.length === 0) {
156
98
  return (
157
99
  <WidgetChildrenContext.Provider value={childrenValue}>
158
- {renderNodes(body, { nobr, inline, locals: parentValues })}
100
+ {renderNodes(body, { nobr, inline, raw, locals: parentValues })}
159
101
  </WidgetChildrenContext.Provider>
160
102
  );
161
103
  }
@@ -183,7 +125,7 @@ export function WidgetInvocation({
183
125
  }
184
126
 
185
127
  const ownKeys = useMemo(() => {
186
- const keys: Record<string, unknown> = {};
128
+ const keys = createNamespace();
187
129
  for (let i = 0; i < params.length; i++) {
188
130
  keys[params[i]!.startsWith('@') ? params[i]!.slice(1) : params[i]!] =
189
131
  values[i];
@@ -75,7 +75,40 @@ export function readWholeQuoted(src: string): string | null {
75
75
  * lenient fallback for labels that are not one well-formed quoted string.
76
76
  */
77
77
  export function stripLooseQuotes(src: string): string {
78
- return /^["']?(.+?)["']?$/.exec(src)?.[1] ?? src;
78
+ return /^["']?(.+?)["']?$/s.exec(src)?.[1] ?? src;
79
+ }
80
+
81
+ /** An operator character, or an operator keyword, at the end of code. */
82
+ const TRAILING_OPERATOR_RE =
83
+ /(?:[-+*/%&|^!~=<>?:,.]|(?:^|[^\w$@.])(?:typeof|void|new|delete|in|instanceof|await|yield))$/;
84
+
85
+ /**
86
+ * Whether `src` ends with an operator that still needs an operand (`$a +`,
87
+ * `$a+`, `$x ==`, `typeof`), so it cannot be a complete value on its own.
88
+ * Only code counts: the closing `/` of a regex literal is not division, a
89
+ * variable reference (`$a`, `%name`, ...) is an operand, trailing comments are skipped,
90
+ * and a postfix `++` or `--` completes its operand.
91
+ */
92
+ export function endsWithOperator(src: string): boolean {
93
+ // Index just past the last code token, or -1 when a literal or transient
94
+ // reference ends the source.
95
+ let end = -1;
96
+ lexJs(src, {
97
+ code(ch, i, nesting) {
98
+ if (nesting === 0 && !isWhitespace(ch)) end = i + 1;
99
+ },
100
+ literal(text, _i, nesting) {
101
+ const comment = text.startsWith('//') || text.startsWith('/*');
102
+ if (nesting === 0 && !comment) end = -1;
103
+ },
104
+ variable(_sigil, _name, _i, nesting) {
105
+ if (nesting === 0) end = -1;
106
+ },
107
+ });
108
+ if (end < 0) return false;
109
+ const code = src.slice(0, end);
110
+ if (/(?:\+\+|--)$/.test(code)) return false;
111
+ return TRAILING_OPERATOR_RE.test(code);
79
112
  }
80
113
 
81
114
  /**
@@ -118,3 +151,76 @@ export function splitTopLevel(
118
151
  segments.push(src.slice(from));
119
152
  return segments;
120
153
  }
154
+
155
+ /**
156
+ * Check whether a whitespace-delimited token looks like a standalone value
157
+ * (not an operator or partial expression): it starts like a value and does
158
+ * not end with an operator still waiting for its operand.
159
+ */
160
+ function isStandaloneValue(token: string): boolean {
161
+ return startsLikeValue(token) && !endsWithOperator(token);
162
+ }
163
+
164
+ function startsLikeValue(token: string): boolean {
165
+ const first = token[0]!;
166
+ // Quoted string
167
+ if (first === '"' || first === "'" || first === '`') return true;
168
+ // Variable ($var, _var, @var, %var), but not the modulo operator in `$a % 2`
169
+ if (/^[$_@]\w|^%[A-Za-z_]/.test(token)) return true;
170
+ // Number literal
171
+ if (/\d/.test(first)) return true;
172
+ // Signed number (-1, +2)
173
+ if (
174
+ (first === '-' || first === '+') &&
175
+ token.length > 1 &&
176
+ /\d/.test(token[1]!)
177
+ )
178
+ return true;
179
+ // Grouped expression or collection literal
180
+ if (first === '(' || first === '[' || first === '{') return true;
181
+ // Boolean / null / undefined, alone or leading an expression (true||$x)
182
+ if (/^(?:true|false|null|undefined)(?![\w$])/.test(token)) return true;
183
+ // Negation (!$flag, !true), but not the operators != and !==
184
+ if (first === '!' && token.length > 1 && token[1] !== '=') return true;
185
+ return false;
186
+ }
187
+
188
+ /**
189
+ * Try to split a raw string on whitespace at depth 0 (respecting strings,
190
+ * template literals, parentheses, brackets, and braces). Each resulting token
191
+ * must pass `isStandaloneValue()` or the split is rejected and `null` is
192
+ * returned.
193
+ */
194
+ function trySplitOnWhitespace(raw: string): string[] | null {
195
+ const args = splitTopLevel(raw, isWhitespace).filter(Boolean);
196
+
197
+ // Need 2+ tokens
198
+ if (args.length < 2) return null;
199
+
200
+ // Every token must be a standalone value (not an operator)
201
+ for (const arg of args) {
202
+ if (!isStandaloneValue(arg)) return null;
203
+ }
204
+
205
+ return args;
206
+ }
207
+
208
+ /**
209
+ * Split rawArgs by commas, respecting parentheses, brackets, braces, and
210
+ * strings. When no top-level commas are present, also supports adjacent quoted
211
+ * string literals separated by whitespace (e.g. `"Label" "target"`).
212
+ */
213
+ export function splitArgs(raw: string): string[] {
214
+ const args = splitTopLevel(raw, (ch) => ch === ',').map((a) => a.trim());
215
+ const hasComma = args.length > 1;
216
+ if (args[args.length - 1] === '') args.pop();
217
+
218
+ // If no commas were found and we got a single expression, try splitting
219
+ // on whitespace at depth 0 (e.g. "Label" "target", $var "text", $x $y).
220
+ if (!hasComma && args.length === 1) {
221
+ const split = trySplitOnWhitespace(args[0]!);
222
+ if (split) return split;
223
+ }
224
+
225
+ return args;
226
+ }
@@ -0,0 +1,68 @@
1
+ import { render } from 'preact';
2
+ import { useContext } from 'preact/hooks';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ NobrContext,
8
+ InlineContext,
9
+ WidgetChildrenContext,
10
+ } from '../../markup/render';
11
+ import type { ASTNode } from '../../markup/ast';
12
+ import { liveLocalsView } from '../../utils/live-locals';
13
+ import { runWithCommittedMutations } from '../../execute-mutation';
14
+ import { RepeatContext } from './Repeat';
15
+ import { DialogCloseContext } from '../PassageDialog';
16
+
17
+ /**
18
+ * Return a function that runs a macro body once, outside the passage tree:
19
+ * it renders `children` into a detached node, so the body's macros ({set},
20
+ * {if}, {goto}, ...) fire their side effects through the normal Preact
21
+ * pipeline, then unmounts it. Used by {button} and {link} on click.
22
+ *
23
+ * A detached render starts with no contexts, so the hook captures the ones
24
+ * the owning macro sees and provides them again: the locals scope (read
25
+ * through a live view, so a local assigned earlier in the body is visible),
26
+ * nobr/inline rendering, the enclosing block widget's {@children}, the
27
+ * enclosing {repeat}'s {stop} and the enclosing dialog's close callback.
28
+ * The body then behaves as if it rendered in place.
29
+ *
30
+ * The body's macros read story state from the store. Run from mutation code
31
+ * (Story.performAction, a click the code dispatches), it runs in program
32
+ * order: the code's writes so far are committed first, so the body sees
33
+ * them, and the code goes on from the state the body leaves (see
34
+ * runWithCommittedMutations).
35
+ */
36
+ export function useDetachedBody(): (children: ASTNode[]) => void {
37
+ const updater = useContext(LocalsUpdateContext);
38
+ const nobr = useContext(NobrContext);
39
+ const inline = useContext(InlineContext);
40
+ const widgetChildren = useContext(WidgetChildrenContext);
41
+ const repeat = useContext(RepeatContext);
42
+ const closeDialog = useContext(DialogCloseContext);
43
+
44
+ const run = (children: ASTNode[]) => {
45
+ const locals = liveLocalsView(updater.getValues);
46
+ const container = document.createElement('div');
47
+ render(
48
+ <LocalsUpdateContext.Provider value={updater}>
49
+ <LocalsValuesContext.Provider value={locals}>
50
+ <NobrContext.Provider value={nobr}>
51
+ <InlineContext.Provider value={inline}>
52
+ <WidgetChildrenContext.Provider value={widgetChildren}>
53
+ <RepeatContext.Provider value={repeat}>
54
+ <DialogCloseContext.Provider value={closeDialog}>
55
+ {renderNodes(children, { nobr, inline, locals })}
56
+ </DialogCloseContext.Provider>
57
+ </RepeatContext.Provider>
58
+ </WidgetChildrenContext.Provider>
59
+ </InlineContext.Provider>
60
+ </NobrContext.Provider>
61
+ </LocalsValuesContext.Provider>
62
+ </LocalsUpdateContext.Provider>,
63
+ container,
64
+ );
65
+ render(null, container);
66
+ };
67
+ return (children) => runWithCommittedMutations(() => run(children));
68
+ }
@@ -5,7 +5,8 @@ export function parseVarArgs(rawArgs: string): {
5
5
  varName: string;
6
6
  placeholder: string;
7
7
  } {
8
- const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/);
8
+ // `s`: a quoted placeholder may span lines.
9
+ const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
9
10
  if (!match) {
10
11
  return { varName: rawArgs.trim(), placeholder: '' };
11
12
  }
@@ -28,7 +29,7 @@ export function extractOptions(children: ASTNode[]): string[] {
28
29
  const raw = node.rawArgs.trim();
29
30
  // Strip surrounding quotes so {option "Long Sword"} gives "Long Sword"
30
31
  const stripped =
31
- readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/, '$2');
32
+ readWholeQuoted(raw) ?? raw.replace(/^(["'])(.+)\1$/s, '$2');
32
33
  options.push(stripped);
33
34
  }
34
35
  }
@@ -16,14 +16,16 @@ import {
16
16
  LocalsValuesContext,
17
17
  NobrContext,
18
18
  InlineContext,
19
+ RawTextContext,
19
20
  renderNodes as _renderNodes,
20
21
  renderInlineNodes,
21
22
  } from './markup/render';
22
23
  import type { ASTNode } from './markup/ast';
23
- import { executeMutation } from './execute-mutation';
24
+ import { executeMutation, readState } from './execute-mutation';
24
25
  import { evaluate } from './expression';
25
26
  import { useStoryStore } from './store';
26
27
  import { getByPath, setByPath } from './utils/object-path';
28
+ import { RESERVED_NAME } from './utils/namespace';
27
29
  import { useAction } from './hooks/use-action';
28
30
  import type { UseActionOptions } from './hooks/use-action';
29
31
  import { collectText } from './utils/extract-text';
@@ -31,12 +33,15 @@ import { currentSourceLocation } from './utils/source-location';
31
33
  import { parseVarArgs, extractOptions } from './components/macros/option-utils';
32
34
  import {
33
35
  registerMacro,
36
+ registerMacroText,
34
37
  registerSubMacro,
35
38
  registerMacroMetadata,
36
39
  } from './registry';
37
- import type { MacroProps, ParameterDef } from './registry';
40
+ import type { MacroProps, MacroTextContext, ParameterDef } from './registry';
38
41
  import { registerBlockMacro } from './markup/ast';
39
42
 
43
+ export type { MacroTextContext };
44
+
40
45
  export function macroClass(type: string, className?: string): string {
41
46
  const base = `macro-${type}`;
42
47
  return className ? `${base} ${className}` : base;
@@ -91,6 +96,12 @@ export interface MacroDefinition {
91
96
  description?: string;
92
97
  parameters?: ParameterDef[];
93
98
  render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
99
+ /**
100
+ * The macro's text form, used where markup becomes a string: HTML
101
+ * attribute values, image alt text and link titles, macro labels. Without
102
+ * one, the macro can't be used there and is reported as an error.
103
+ */
104
+ text?: (props: MacroProps, ctx: MacroTextContext) => string;
94
105
  }
95
106
 
96
107
  const sharedHooks = {
@@ -122,6 +133,7 @@ export function defineMacro(
122
133
  const { update, getValues } = useContext(LocalsUpdateContext);
123
134
  const nobr = useContext(NobrContext);
124
135
  const inline = useContext(InlineContext);
136
+ const raw = useContext(RawTextContext);
125
137
  const localsValues = useContext(LocalsValuesContext);
126
138
  const renderNodes = (
127
139
  nodes: ASTNode[],
@@ -129,9 +141,16 @@ export function defineMacro(
129
141
  nobr?: boolean;
130
142
  locals?: Record<string, unknown>;
131
143
  inline?: boolean;
144
+ raw?: boolean;
132
145
  },
133
146
  ) =>
134
- _renderNodes(nodes, { nobr, inline, locals: localsValues, ...options });
147
+ _renderNodes(nodes, {
148
+ nobr,
149
+ inline,
150
+ raw,
151
+ locals: localsValues,
152
+ ...options,
153
+ });
135
154
  const ctx: MacroContext = {
136
155
  collectText,
137
156
  sourceLocation: currentSourceLocation,
@@ -177,10 +196,17 @@ export function defineMacro(
177
196
 
178
197
  const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, '');
179
198
  const segments = varExpr.split('.');
199
+ if (segments.includes(RESERVED_NAME)) {
200
+ return h(
201
+ 'span',
202
+ { class: 'error' },
203
+ `{${config.name}}: "$${varExpr}" cannot be bound: ${RESERVED_NAME} is reserved`,
204
+ );
205
+ }
180
206
  ctx.varName = varExpr;
181
207
  ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
182
- ctx.getValue = () =>
183
- getByPath(useStoryStore.getState().variables, segments);
208
+ // In program order, also when mutation code performs the input
209
+ ctx.getValue = () => getByPath(readState().variables, segments);
184
210
  ctx.setValue = (value: unknown) => {
185
211
  useStoryStore.setState((state) => {
186
212
  setByPath(state.variables, segments, value, {
@@ -194,6 +220,7 @@ export function defineMacro(
194
220
  }
195
221
 
196
222
  registerMacro(config.name, Wrapper);
223
+ registerMacroText(config.name, config.text);
197
224
 
198
225
  // Store metadata for tooling API
199
226
  const isBlock =