@rohal12/spindle 0.52.5 → 0.52.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +651 -544
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/components/macros/Button.tsx +2 -4
  6. package/src/components/macros/Checkbox.tsx +6 -13
  7. package/src/components/macros/Computed.tsx +38 -55
  8. package/src/components/macros/Cycle.tsx +1 -0
  9. package/src/components/macros/Dialog.tsx +7 -8
  10. package/src/components/macros/Do.tsx +2 -1
  11. package/src/components/macros/For.tsx +27 -78
  12. package/src/components/macros/Goto.tsx +6 -9
  13. package/src/components/macros/Include.tsx +10 -57
  14. package/src/components/macros/Listbox.tsx +1 -0
  15. package/src/components/macros/MacroError.tsx +37 -0
  16. package/src/components/macros/MacroLink.tsx +9 -24
  17. package/src/components/macros/Meter.tsx +19 -37
  18. package/src/components/macros/Numberbox.tsx +5 -32
  19. package/src/components/macros/PassageDisplay.tsx +37 -47
  20. package/src/components/macros/Print.tsx +1 -0
  21. package/src/components/macros/Radiobutton.tsx +8 -38
  22. package/src/components/macros/Repeat.tsx +3 -3
  23. package/src/components/macros/Set.tsx +3 -29
  24. package/src/components/macros/Switch.tsx +36 -37
  25. package/src/components/macros/Textarea.tsx +2 -28
  26. package/src/components/macros/Textbox.tsx +2 -29
  27. package/src/components/macros/Type.tsx +3 -3
  28. package/src/components/macros/Unset.tsx +17 -40
  29. package/src/components/macros/Watch.tsx +23 -91
  30. package/src/components/macros/WidgetInvocation.tsx +9 -71
  31. package/src/components/macros/detached-body.tsx +2 -2
  32. package/src/components/macros/input-macro.ts +48 -0
  33. package/src/components/macros/locals-scope.tsx +60 -0
  34. package/src/components/macros/macro-args.ts +280 -0
  35. package/src/components/macros/option-utils.ts +7 -12
  36. package/src/define-macro.ts +47 -30
  37. package/src/hooks/use-render-options.ts +26 -0
  38. package/src/markup/render.tsx +2 -4
  39. package/src/registry.ts +48 -0
  40. package/types/index.d.ts +69 -6
  41. package/types/tooling.d.ts +33 -1
@@ -1,47 +1,29 @@
1
- import {
2
- useContext,
3
- useState,
4
- useCallback,
5
- useRef,
6
- useMemo,
7
- } from 'preact/hooks';
8
- import {
9
- LocalsValuesContext,
10
- LocalsUpdateContext,
11
- NobrContext,
12
- InlineContext,
13
- RawTextContext,
14
- renderNodes,
15
- } from '../../markup/render';
1
+ import { useContext, useMemo } from 'preact/hooks';
2
+ import { LocalsValuesContext } from '../../markup/render';
16
3
  import { defineMacro } from '../../define-macro';
17
4
  import { MacroError } from './MacroError';
18
5
  import { stableKey } from '../../utils/stable-key';
19
6
  import type { ASTNode } from '../../markup/ast';
20
- import {
21
- checkVariableName,
22
- createNamespace,
23
- withEntry,
24
- } from '../../utils/namespace';
7
+ import { checkVariableName } from '../../utils/namespace';
8
+ import { LocalsScope } from './locals-scope';
25
9
 
26
10
  /**
27
- * Parse for-loop args: "@item, @i of $list" or "@item of $list"
11
+ * Check the arguments of "@item, @i of $list" or "@item of $list".
28
12
  */
29
- function parseForArgs(rawArgs: string): {
13
+ function loopArgs(
14
+ rawArgs: string,
15
+ args: { variables?: string[]; of: boolean; list?: string },
16
+ ): {
30
17
  itemVar: string;
31
18
  indexVar: string | null;
32
19
  listExpr: string;
33
20
  } {
34
- const ofIdx = rawArgs.indexOf(' of ');
35
- if (ofIdx === -1) {
21
+ if (!args.of) {
36
22
  throw new Error(`{for} requires "of" keyword: {for ${rawArgs}}`);
37
23
  }
38
24
 
39
- const varsPart = rawArgs.slice(0, ofIdx).trim();
40
- const listExpr = rawArgs.slice(ofIdx + 4).trim();
41
-
42
- const vars = varsPart.split(',').map((v) => v.trim());
43
- const itemVar = vars[0]!;
44
- const indexVar = vars.length > 1 ? vars[1]! : null;
25
+ const [itemVar = '', indexVar = null] = args.variables ?? [];
26
+ const listExpr = args.list ?? '';
45
27
 
46
28
  if (!itemVar.startsWith('@')) {
47
29
  throw new Error(`{for} loop variable must use @ prefix: got "${itemVar}"`);
@@ -77,10 +59,6 @@ function ForIteration({
77
59
  indexValue: number;
78
60
  children: ASTNode[];
79
61
  }) {
80
- const [localMutations, setLocalMutations] = useState<Record<string, unknown>>(
81
- () => ({}),
82
- );
83
-
84
62
  const ownKeys = useMemo(
85
63
  () => ({
86
64
  [itemVar]: itemValue,
@@ -89,35 +67,12 @@ function ForIteration({
89
67
  [itemVar, itemValue, indexVar, indexValue],
90
68
  );
91
69
 
92
- const localState = useMemo(
93
- () => createNamespace(parentValues, ownKeys, localMutations),
94
- [parentValues, ownKeys, localMutations],
95
- );
96
-
97
- const valuesRef = useRef(localState);
98
- valuesRef.current = localState;
99
-
100
- const getValues = useCallback(() => valuesRef.current, []);
101
- const update = useCallback((key: string, value: unknown) => {
102
- // Apply synchronously so later macros in the same render pass (e.g. a
103
- // second {set}) read the new value via getValues(); the state update
104
- // then re-renders consumers of LocalsValuesContext.
105
- checkVariableName(key, `@${key}`);
106
- valuesRef.current = withEntry(valuesRef.current, key, value);
107
- setLocalMutations((prev) => ({ ...prev, [key]: value }));
108
- }, []);
109
- const updater = useMemo(() => ({ update, getValues }), [update, getValues]);
110
-
111
- const nobr = useContext(NobrContext);
112
- const inline = useContext(InlineContext);
113
- const raw = useContext(RawTextContext);
114
-
115
70
  return (
116
- <LocalsUpdateContext.Provider value={updater}>
117
- <LocalsValuesContext.Provider value={localState}>
118
- {renderNodes(children, { nobr, inline, raw, locals: localState })}
119
- </LocalsValuesContext.Provider>
120
- </LocalsUpdateContext.Provider>
71
+ <LocalsScope
72
+ body={children}
73
+ parentValues={parentValues}
74
+ ownKeys={ownKeys}
75
+ />
121
76
  );
122
77
  }
123
78
 
@@ -126,26 +81,19 @@ defineMacro({
126
81
  block: true,
127
82
  interpolate: true,
128
83
  merged: true,
84
+ parameters: [
85
+ { name: 'variables', type: 'names', required: true },
86
+ { name: 'of', type: 'separator' },
87
+ { name: 'list', type: 'expression', required: true },
88
+ ],
129
89
  render({ rawArgs, children = [] }, ctx) {
130
90
  const parentValues = useContext(LocalsValuesContext);
131
91
 
132
- let parsed: ReturnType<typeof parseForArgs>;
133
- try {
134
- parsed = parseForArgs(rawArgs);
135
- } catch (err) {
136
- return (
137
- <MacroError
138
- macro="for"
139
- error={err}
140
- />
141
- );
142
- }
143
-
144
- const { itemVar, indexVar, listExpr } = parsed;
145
-
92
+ let loop: ReturnType<typeof loopArgs>;
146
93
  let list: unknown[];
147
94
  try {
148
- const result = ctx.evaluate!(listExpr);
95
+ loop = loopArgs(rawArgs, ctx.args);
96
+ const result = ctx.evaluate!(loop.listExpr);
149
97
  if (!Array.isArray(result)) {
150
98
  return (
151
99
  <span class="error">
@@ -163,6 +111,7 @@ defineMacro({
163
111
  );
164
112
  }
165
113
 
114
+ const { itemVar, indexVar } = loop;
166
115
  const content = list.map((item, i) => (
167
116
  <ForIteration
168
117
  key={`${i}-${stableKey(item)}`}
@@ -178,7 +127,7 @@ defineMacro({
178
127
  return ctx.wrap(content);
179
128
  },
180
129
  text({ rawArgs, children = [] }, ctx) {
181
- const { itemVar, indexVar, listExpr } = parseForArgs(rawArgs);
130
+ const { itemVar, indexVar, listExpr } = loopArgs(rawArgs, ctx.args);
182
131
  const list = ctx.evaluate(listExpr);
183
132
  if (!Array.isArray(list)) {
184
133
  throw new Error('expression did not evaluate to an array');
@@ -1,19 +1,16 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
+ import { evaluatePassageName } from './macro-args';
3
4
 
4
5
  defineMacro({
5
6
  name: 'goto',
6
7
  merged: true,
7
- render({ rawArgs }, ctx) {
8
+ parameters: [{ name: 'passage', type: 'expression', required: true }],
9
+ render(_props, ctx) {
8
10
  ctx.hooks.useLayoutEffect(() => {
9
- let passageName: string;
10
- try {
11
- const result = ctx.evaluate!(rawArgs);
12
- passageName = String(result);
13
- } catch {
14
- passageName = rawArgs.replace(/^["']|["']$/g, '');
15
- }
16
- useStoryStore.getState().navigate(passageName);
11
+ useStoryStore
12
+ .getState()
13
+ .navigate(evaluatePassageName(ctx.args.passage, ctx.evaluate!));
17
14
  }, []);
18
15
 
19
16
  return null;
@@ -3,70 +3,23 @@ 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';
7
-
8
- const FLAG = 'inline';
9
-
10
- /**
11
- * Split a standalone `inline` flag off the start or end of the include
12
- * arguments. The flag only counts outside quotes, brackets, parentheses and
13
- * braces, separated by whitespace from the target expression, so passage
14
- * names and expressions containing the word stay intact (#201).
15
- */
16
- export function parseIncludeArgs(rawArgs: string): {
17
- nameExpr: string;
18
- inline: boolean;
19
- } {
20
- const trimmed = rawArgs.trim();
21
-
22
- // First and last whitespace runs at depth 0, as [start, end) offsets.
23
- const spaces = topLevelIndices(trimmed, isWhitespace);
24
- let first: [number, number] | null = null;
25
- let last: [number, number] | null = null;
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];
33
- }
34
-
35
- // Next to a binary operator the word is an operand (`"a" + inline`);
36
- // the closing `/` of a regex literal is not one.
37
- if (
38
- last &&
39
- last[1] === trimmed.length - FLAG.length &&
40
- trimmed.endsWith(FLAG)
41
- ) {
42
- const rest = trimmed.slice(0, last[0]);
43
- if (!endsWithOperator(rest)) return { nameExpr: rest, inline: true };
44
- }
45
- if (first && first[0] === FLAG.length && trimmed.startsWith(FLAG)) {
46
- const rest = trimmed.slice(first[1]);
47
- // `inline %name` reads a transient variable; `inline % 2` is modulo.
48
- if (!/^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/.test(rest))
49
- return { nameExpr: rest, inline: true };
50
- }
51
- return { nameExpr: trimmed, inline: false };
52
- }
6
+ import { evaluatePassageName } from './macro-args';
53
7
 
54
8
  defineMacro({
55
9
  name: 'include',
56
10
  interpolate: true,
57
11
  merged: true,
58
- render({ rawArgs }, ctx) {
12
+ // A standalone `inline` flag only counts outside quotes and brackets, so
13
+ // passage names and expressions containing the word stay intact (#201).
14
+ parameters: [
15
+ { name: 'inline', type: 'flag' },
16
+ { name: 'passage', type: 'expression', required: true },
17
+ ],
18
+ render(_props, ctx) {
59
19
  const storyData = useStoryStore((s) => s.storyData);
60
20
 
61
- const { nameExpr, inline } = parseIncludeArgs(rawArgs);
62
-
63
- let passageName: string;
64
- try {
65
- const result = ctx.evaluate!(nameExpr);
66
- passageName = String(result);
67
- } catch {
68
- passageName = nameExpr.replace(/^["']|["']$/g, '');
69
- }
21
+ const { inline } = ctx.args;
22
+ const passageName = evaluatePassageName(ctx.args.passage, ctx.evaluate!);
70
23
 
71
24
  const passage = storyData?.passages.get(passageName);
72
25
  // Parse once per included passage: the renderer keys children by AST
@@ -4,6 +4,7 @@ defineMacro({
4
4
  name: 'listbox',
5
5
  subMacros: ['option'],
6
6
  storeVar: true,
7
+ parameters: [{ name: 'variable', type: 'variable', required: true }],
7
8
  render({ children = [] }, ctx) {
8
9
  const options = ctx.extractOptions(children);
9
10
 
@@ -1,3 +1,4 @@
1
+ import { useRef } from 'preact/hooks';
1
2
  import { currentSourceLocation } from '../../utils/source-location';
2
3
  import { errorMessage } from '../../utils/error-message';
3
4
 
@@ -17,3 +18,39 @@ export function MacroError({
17
18
  </span>
18
19
  );
19
20
  }
21
+
22
+ /** Log an error in the macro `{label}` (its name, or name and arguments). */
23
+ export function logMacroError(label: string, error: unknown): void {
24
+ console.error(
25
+ `spindle: Error in {${label}}${currentSourceLocation()}:`,
26
+ error,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Run `effect` once, during the macro's first render. If it throws, the
32
+ * error is logged under `{macro rawArgs}` and shown in place of the macro
33
+ * from then on; otherwise the macro renders nothing.
34
+ */
35
+ export function useRunOnce(macro: string, rawArgs: string, effect: () => void) {
36
+ // Unset until the effect has run. Boxed: anything can be thrown,
37
+ // including null and other falsy values.
38
+ const failure = useRef<{ error: unknown } | null>();
39
+ if (failure.current === undefined) {
40
+ failure.current = null;
41
+ try {
42
+ effect();
43
+ } catch (error) {
44
+ failure.current = { error };
45
+ logMacroError(`${macro} ${rawArgs}`, error);
46
+ }
47
+ }
48
+
49
+ if (!failure.current) return null;
50
+ return (
51
+ <MacroError
52
+ macro={macro}
53
+ error={failure.current.error}
54
+ />
55
+ );
56
+ }
@@ -1,36 +1,21 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
- import { unescapeQuoted } from './arg-utils';
4
3
  import { useDetachedBody } from './detached-body';
5
4
 
6
- export function parseLinkArgs(rawArgs: string): {
7
- display: string;
8
- passage: string | null;
9
- } {
10
- // {link "text" "Passage"} or {link "text"}. A backslash escapes a quote
11
- // or another backslash inside a quoted argument (#200).
12
- const parts: string[] = [];
13
- const re = /(["'])((?:\\[^]|(?!\1)[^\\])*)\1/g;
14
- let m;
15
- while ((m = re.exec(rawArgs)) !== null) {
16
- parts.push(unescapeQuoted(m[2]!));
17
- }
18
- if (parts.length >= 2) {
19
- return { display: parts[0]!, passage: parts[1]! };
20
- }
21
- if (parts.length === 1) {
22
- return { display: parts[0]!, passage: null };
23
- }
24
- // Fallback: treat entire rawArgs as display text
25
- return { display: rawArgs.trim(), passage: null };
26
- }
27
-
28
5
  defineMacro({
29
6
  name: 'link',
30
7
  block: true,
31
8
  interpolate: true,
9
+ // {link "text" "Passage"} or {link "text"}; unquoted, the whole arguments
10
+ // are the text.
11
+ parameters: [
12
+ { name: 'text', type: 'string', required: true },
13
+ { name: 'passage', type: 'string' },
14
+ ],
32
15
  render({ rawArgs, children = [] }, ctx) {
33
- const { display, passage } = parseLinkArgs(rawArgs);
16
+ const { text } = ctx.args;
17
+ const display = text ?? rawArgs.trim();
18
+ const passage = text === undefined ? null : (ctx.args.passage ?? null);
34
19
  const runBody = useDetachedBody();
35
20
 
36
21
  const perform = () => {
@@ -1,46 +1,21 @@
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';
9
3
 
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): {
18
- currentExpr: string;
19
- maxExpr: string;
20
- labelMode: string;
21
- } {
22
- const tokens = splitTopLevel(rawArgs.trim(), isWhitespace).filter(Boolean);
23
-
24
- let labelMode = '';
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();
32
- }
33
-
34
- if (tokens.length < 2) {
4
+ /** Check the arguments of `{meter currentExpr maxExpr ["label"]}`. */
5
+ export function meterArgs(args: {
6
+ current?: string;
7
+ max?: string;
8
+ label?: string;
9
+ }): { currentExpr: string; maxExpr: string; labelMode: string } {
10
+ if (args.current === undefined || args.max === undefined) {
35
11
  throw new Error(
36
12
  'meter requires two arguments: {meter currentExpr maxExpr}',
37
13
  );
38
14
  }
39
-
40
15
  return {
41
- currentExpr: tokens[0]!,
42
- maxExpr: tokens.slice(1).join(' '),
43
- labelMode,
16
+ currentExpr: args.current,
17
+ maxExpr: args.max,
18
+ labelMode: args.label ?? '',
44
19
  };
45
20
  }
46
21
 
@@ -60,9 +35,16 @@ defineMacro({
60
35
  name: 'meter',
61
36
  interpolate: true,
62
37
  merged: true,
63
- render({ rawArgs }, ctx) {
38
+ // {meter currentExpr maxExpr ["label"]}. A string after an operator
39
+ // (`$a ?? "5"`) is an operand of the max expression, not the label.
40
+ parameters: [
41
+ { name: 'current', type: 'expression', required: true },
42
+ { name: 'max', type: 'expression', required: true },
43
+ { name: 'label', type: 'string' },
44
+ ],
45
+ render(_props, ctx) {
64
46
  try {
65
- const { currentExpr, maxExpr, labelMode } = parseMeterArgs(rawArgs);
47
+ const { currentExpr, maxExpr, labelMode } = meterArgs(ctx.args);
66
48
  const current = Number(ctx.evaluate!(currentExpr));
67
49
  const max = Number(ctx.evaluate!(maxExpr));
68
50
  const pct =
@@ -1,33 +1,6 @@
1
- import { defineMacro } from '../../define-macro';
1
+ import { defineInputMacro } from './input-macro';
2
2
 
3
- defineMacro({
4
- name: 'numberbox',
5
- storeVar: true,
6
- render({ rawArgs }, ctx) {
7
- const { placeholder } = ctx.parseVarArgs(rawArgs);
8
-
9
- ctx.useAction({
10
- type: 'numberbox',
11
- key: `$${ctx.varName}`,
12
- authorId: ctx.id,
13
- label: placeholder || ctx.varName!,
14
- variable: ctx.varName,
15
- value: ctx.value,
16
- perform: (v) => ctx.setValue!(v !== undefined ? Number(v) : 0),
17
- });
18
-
19
- return (
20
- <input
21
- type="number"
22
- id={ctx.id}
23
- class={ctx.cls}
24
- value={ctx.value == null ? '' : String(ctx.value)}
25
- placeholder={placeholder}
26
- onInput={(e) => {
27
- const val = (e.target as HTMLInputElement).value;
28
- ctx.setValue!(val === '' ? 0 : Number(val));
29
- }}
30
- />
31
- );
32
- },
33
- });
3
+ // An empty input is 0
4
+ defineInputMacro('numberbox', 'number', (v) =>
5
+ v !== undefined ? Number(v) : 0,
6
+ );
@@ -54,6 +54,20 @@ function setCSSProperties(el: HTMLElement, config: ResolvedTransition): void {
54
54
  el.style.setProperty('--passage-pause', `${config.pause / 1000}s`);
55
55
  }
56
56
 
57
+ /**
58
+ * Set up `containerEl` for an outgoing transition and snapshot the passage
59
+ * it shows, or return null if there is no container or passage.
60
+ */
61
+ function snapshotPassage(
62
+ containerEl: HTMLElement | null,
63
+ config: ResolvedTransition,
64
+ ): HTMLElement | null {
65
+ if (!containerEl) return null;
66
+ setCSSProperties(containerEl, config);
67
+ const passageEl = containerEl.querySelector('.passage');
68
+ return passageEl ? createSnapshot(passageEl, config.type) : null;
69
+ }
70
+
57
71
  /**
58
72
  * Remove all snapshot elements and crossfading class from a container.
59
73
  */
@@ -183,23 +197,19 @@ defineMacro({
183
197
  return;
184
198
  }
185
199
 
200
+ // The outgoing transitions start from a snapshot of the current passage
201
+ const snapshot =
202
+ config.type === 'fade-through' || config.type === 'crossfade'
203
+ ? snapshotPassage(containerEl, config)
204
+ : null;
205
+ if (!containerEl || !snapshot) {
206
+ // Fallback: unknown type or nothing to snapshot, just mount
207
+ setDisplayedPassage(currentPassage);
208
+ return;
209
+ }
210
+
186
211
  if (config.type === 'fade-through') {
187
212
  // idle → outgoing (fade out snapshot) → paused → incoming (mount new, fade in) → idle
188
- if (!containerEl) {
189
- setDisplayedPassage(currentPassage);
190
- return;
191
- }
192
-
193
- setCSSProperties(containerEl, config);
194
-
195
- // Take snapshot of current passage
196
- const passageEl = containerEl.querySelector('.passage');
197
- if (!passageEl) {
198
- setDisplayedPassage(currentPassage);
199
- return;
200
- }
201
-
202
- const snapshot = createSnapshot(passageEl, config.type);
203
213
  containerEl.appendChild(snapshot);
204
214
 
205
215
  // Hide the current passage during outgoing phase by setting empty key
@@ -221,42 +231,22 @@ defineMacro({
221
231
  return;
222
232
  }
223
233
 
224
- if (config.type === 'crossfade') {
225
- // idle → crossfading (mount new + fade in, simultaneously fade out snapshot) → idle
226
- if (!containerEl) {
227
- setDisplayedPassage(currentPassage);
228
- return;
229
- }
234
+ // crossfade: idle → crossfading (mount new + fade in, simultaneously
235
+ // fade out snapshot) → idle
230
236
 
231
- setCSSProperties(containerEl, config);
237
+ // Use CSS grid stacking for crossfade
238
+ containerEl.classList.add('passage-container--crossfading');
239
+ containerEl.appendChild(snapshot);
232
240
 
233
- // Take snapshot of current passage
234
- const passageEl = containerEl.querySelector('.passage');
235
- if (!passageEl) {
236
- setDisplayedPassage(currentPassage);
237
- return;
238
- }
239
-
240
- const snapshot = createSnapshot(passageEl, config.type);
241
-
242
- // Use CSS grid stacking for crossfade
243
- containerEl.classList.add('passage-container--crossfading');
244
- containerEl.appendChild(snapshot);
245
-
246
- // Mount new passage immediately alongside snapshot
247
- setDisplayedPassage(currentPassage);
248
-
249
- // After duration, clean up snapshot and remove crossfading class
250
- const t1 = setTimeout(() => {
251
- cleanupSnapshots(containerEl);
252
- }, config.duration);
241
+ // Mount new passage immediately alongside snapshot
242
+ setDisplayedPassage(currentPassage);
253
243
 
254
- timeoutsRef.current = [t1];
255
- return;
256
- }
244
+ // After duration, clean up snapshot and remove crossfading class
245
+ const t1 = setTimeout(() => {
246
+ cleanupSnapshots(containerEl);
247
+ }, config.duration);
257
248
 
258
- // Fallback: unknown type, just mount
259
- setDisplayedPassage(currentPassage);
249
+ timeoutsRef.current = [t1];
260
250
  // eslint-disable-next-line react-hooks/exhaustive-deps
261
251
  }, [navigationId]);
262
252
 
@@ -3,6 +3,7 @@ import { MacroError } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'print',
6
+ parameters: [{ name: 'expression', type: 'expression', required: true }],
6
7
  interpolate: true,
7
8
  merged: true,
8
9
  render({ rawArgs }, ctx) {
@@ -1,46 +1,16 @@
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
- }
28
-
29
- const match = rawArgs.match(
30
- /^\s*["']?\$?[\w.]+["']?\s+["'](.+?)["']\s+["']?(.+?)["']?\s*$/s,
31
- );
32
- if (!match) {
33
- const parts = rawArgs.trim().split(/\s+/).slice(1);
34
- return { value: parts[0] ?? '', label: parts.slice(1).join(' ') };
35
- }
36
- return { value: match[1]!, label: match[2]! };
37
- }
38
2
 
39
3
  defineMacro({
40
4
  name: 'radiobutton',
41
5
  storeVar: true,
42
- render({ rawArgs }, ctx) {
43
- const { value: radioValue, label } = parseRadioArgs(rawArgs);
6
+ parameters: [
7
+ { name: 'variable', type: 'variable', required: true },
8
+ { name: 'value', type: 'text', required: true },
9
+ { name: 'label', type: 'text' },
10
+ ],
11
+ render(_props, ctx) {
12
+ const radioValue = ctx.args.value ?? '';
13
+ const label = ctx.args.label ?? '';
44
14
 
45
15
  ctx.useAction({
46
16
  type: 'radiobutton',
@@ -1,5 +1,4 @@
1
1
  import { createContext } from 'preact';
2
- import { parseDelay } from '../../utils/parse-delay';
3
2
  import { defineMacro } from '../../define-macro';
4
3
 
5
4
  export const RepeatContext = createContext<{ stop: () => void }>({
@@ -10,10 +9,11 @@ defineMacro({
10
9
  name: 'repeat',
11
10
  block: true,
12
11
  interpolate: true,
13
- render({ rawArgs, children = [] }, ctx) {
12
+ parameters: [{ name: 'delay', type: 'delay', required: true }],
13
+ render({ children = [] }, ctx) {
14
14
  const { useState, useEffect, useCallback } = ctx.hooks;
15
15
 
16
- const delay = parseDelay(rawArgs);
16
+ const delay = ctx.args.delay ?? 0;
17
17
  const [count, setCount] = useState(0);
18
18
  const [stopped, setStopped] = useState(false);
19
19