@rohal12/spindle 0.52.4 → 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 (47) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +1502 -1521
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/class-registry.ts +6 -182
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/execute-mutation.ts +17 -97
  39. package/src/hooks/use-render-options.ts +26 -0
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/src/saves/save-manager.ts +190 -259
  43. package/src/saves/storage.ts +229 -402
  44. package/src/store.ts +244 -310
  45. package/src/structural.ts +388 -0
  46. package/types/index.d.ts +69 -6
  47. package/types/tooling.d.ts +33 -1
@@ -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
 
@@ -1,36 +1,10 @@
1
1
  import { defineMacro } from '../../define-macro';
2
- import { MacroError } from './MacroError';
2
+ import { useRunOnce } from './MacroError';
3
3
 
4
4
  defineMacro({
5
5
  name: 'set',
6
+ parameters: [{ name: 'code', type: 'expression', required: true }],
6
7
  render({ rawArgs }, ctx) {
7
- const ran = ctx.hooks.useRef(false);
8
- // Boxed: anything can be thrown, including null and other falsy values
9
- const failure = ctx.hooks.useRef<{ error: unknown } | null>(null);
10
-
11
- if (!ran.current) {
12
- ran.current = true;
13
-
14
- try {
15
- ctx.mutate(rawArgs);
16
- } catch (err) {
17
- failure.current = { error: err };
18
- console.error(
19
- `spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
20
- err,
21
- );
22
- }
23
- }
24
-
25
- if (failure.current) {
26
- return (
27
- <MacroError
28
- macro="set"
29
- error={failure.current.error}
30
- />
31
- );
32
- }
33
-
34
- return null;
8
+ return useRunOnce('set', rawArgs, () => ctx.mutate(rawArgs));
35
9
  },
36
10
  });
@@ -1,9 +1,32 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { MacroError } from './MacroError';
3
+ import type { Branch } from '../../markup/ast';
4
+
5
+ /**
6
+ * The first {case} branch whose value equals `value`, else the {default}
7
+ * branch, else null. Throws what a case expression throws.
8
+ */
9
+ function selectBranch(
10
+ value: unknown,
11
+ branches: Branch[],
12
+ evaluate: (expr: string) => unknown,
13
+ ): Branch | null {
14
+ let defaultBranch: Branch | null = null;
15
+ for (let i = 1; i < branches.length; i++) {
16
+ const branch = branches[i]!;
17
+ if (branch.rawArgs === '') {
18
+ defaultBranch = branch;
19
+ } else if (value === evaluate(branch.rawArgs)) {
20
+ return branch;
21
+ }
22
+ }
23
+ return defaultBranch;
24
+ }
3
25
 
4
26
  defineMacro({
5
27
  name: 'switch',
6
28
  subMacros: ['case', 'default'],
29
+ parameters: [{ name: 'expression', type: 'expression', required: true }],
7
30
  merged: true,
8
31
  render({ rawArgs, branches = [] }, ctx) {
9
32
  let switchValue: unknown;
@@ -18,46 +41,22 @@ defineMacro({
18
41
  );
19
42
  }
20
43
 
21
- let defaultBranch: (typeof branches)[number] | null = null;
22
- for (let i = 1; i < branches.length; i++) {
23
- const branch = branches[i]!;
24
- if (branch.rawArgs === '') {
25
- defaultBranch = branch;
26
- continue;
27
- }
28
-
29
- try {
30
- const caseValue = ctx.evaluate!(branch.rawArgs);
31
- if (switchValue === caseValue) {
32
- return <>{ctx.renderNodes(branch.children)}</>;
33
- }
34
- } catch (err) {
35
- return (
36
- <MacroError
37
- macro="case"
38
- error={err}
39
- />
40
- );
41
- }
42
- }
43
-
44
- if (defaultBranch) {
45
- return <>{ctx.renderNodes(defaultBranch.children)}</>;
44
+ let branch: Branch | null;
45
+ try {
46
+ branch = selectBranch(switchValue, branches, ctx.evaluate!);
47
+ } catch (err) {
48
+ return (
49
+ <MacroError
50
+ macro="case"
51
+ error={err}
52
+ />
53
+ );
46
54
  }
47
55
 
48
- return null;
56
+ return branch && <>{ctx.renderNodes(branch.children)}</>;
49
57
  },
50
58
  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) : '';
59
+ const branch = selectBranch(ctx.evaluate(rawArgs), branches, ctx.evaluate);
60
+ return branch ? ctx.renderText(branch.children) : '';
62
61
  },
63
62
  });
@@ -1,29 +1,3 @@
1
- import { defineMacro } from '../../define-macro';
1
+ import { defineInputMacro } from './input-macro';
2
2
 
3
- defineMacro({
4
- name: 'textarea',
5
- storeVar: true,
6
- render({ rawArgs }, ctx) {
7
- const { placeholder } = ctx.parseVarArgs(rawArgs);
8
-
9
- ctx.useAction({
10
- type: 'textarea',
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 ? String(v) : ''),
17
- });
18
-
19
- return (
20
- <textarea
21
- id={ctx.id}
22
- class={ctx.cls}
23
- value={ctx.value == null ? '' : String(ctx.value)}
24
- placeholder={placeholder}
25
- onInput={(e) => ctx.setValue!((e.target as HTMLTextAreaElement).value)}
26
- />
27
- );
28
- },
29
- });
3
+ defineInputMacro('textarea');
@@ -1,30 +1,3 @@
1
- import { defineMacro } from '../../define-macro';
1
+ import { defineInputMacro } from './input-macro';
2
2
 
3
- defineMacro({
4
- name: 'textbox',
5
- storeVar: true,
6
- render({ rawArgs }, ctx) {
7
- const { placeholder } = ctx.parseVarArgs(rawArgs);
8
-
9
- ctx.useAction({
10
- type: 'textbox',
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 ? String(v) : ''),
17
- });
18
-
19
- return (
20
- <input
21
- type="text"
22
- id={ctx.id}
23
- class={ctx.cls}
24
- value={ctx.value == null ? '' : String(ctx.value)}
25
- placeholder={placeholder}
26
- onInput={(e) => ctx.setValue!((e.target as HTMLInputElement).value)}
27
- />
28
- );
29
- },
30
- });
3
+ defineInputMacro('textbox', 'text');
@@ -1,13 +1,13 @@
1
- import { parseDelay } from '../../utils/parse-delay';
2
1
  import { defineMacro } from '../../define-macro';
3
2
 
4
3
  defineMacro({
5
4
  name: 'type',
6
5
  block: true,
7
6
  interpolate: true,
8
- render({ rawArgs, children = [] }, ctx) {
7
+ parameters: [{ name: 'delay', type: 'delay', required: true }],
8
+ render({ children = [] }, ctx) {
9
9
  const { useState, useEffect, useRef } = ctx.hooks;
10
- const speed = parseDelay(rawArgs);
10
+ const speed = ctx.args.delay ?? 0;
11
11
  const containerRef = useRef<HTMLSpanElement>(null);
12
12
  const [totalChars, setTotalChars] = useState(0);
13
13
  const [visibleChars, setVisibleChars] = useState(0);
@@ -1,52 +1,29 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { defineMacro } from '../../define-macro';
3
- import { MacroError } from './MacroError';
3
+ import { useRunOnce } from './MacroError';
4
4
 
5
5
  defineMacro({
6
6
  name: 'unset',
7
+ parameters: [{ name: 'variable', type: 'variable', required: true }],
7
8
  render({ rawArgs }, ctx) {
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);
11
-
12
- if (!ran.current) {
13
- ran.current = true;
9
+ return useRunOnce('unset', rawArgs, () => {
14
10
  const state = useStoryStore.getState();
15
- const name = rawArgs.trim();
11
+ const name = ctx.args.variable ?? '';
16
12
 
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 };
34
- console.error(
35
- `spindle: Error in {unset ${rawArgs}}${ctx.sourceLocation()}:`,
36
- err,
13
+ if (name.startsWith('$')) {
14
+ state.deleteVariable(name.slice(1));
15
+ } else if (name.startsWith('_')) {
16
+ state.deleteTemporary(name.slice(1));
17
+ } else if (name.startsWith('%')) {
18
+ state.deleteTransient(name.slice(1));
19
+ } else if (name.startsWith('@')) {
20
+ // Throws outside a locals scope ({for}, widget, {link}, {button})
21
+ ctx.update(name.slice(1), undefined);
22
+ } else {
23
+ throw new Error(
24
+ `{unset} expects a variable ($name, _name, %name, or @name), got "${name}"`,
37
25
  );
38
26
  }
39
- }
40
-
41
- if (failure.current) {
42
- return (
43
- <MacroError
44
- macro="unset"
45
- error={failure.current.error}
46
- />
47
- );
48
- }
49
-
50
- return null;
27
+ });
51
28
  },
52
29
  });
@@ -1,99 +1,30 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { addMacroTrigger, removeTrigger } from '../../triggers';
3
- import type { WatchOptions } from '../../triggers';
4
- import { isWhitespace, readQuoted, readWholeQuoted } from './arg-utils';
5
-
6
- const WORD_RE = /\w+/y;
7
- const DIGITS_RE = /\d+/y;
8
-
9
- /** Match the sticky regex `re` at `pos`, returning the matched text. */
10
- function matchAt(re: RegExp, src: string, pos: number): string | null {
11
- re.lastIndex = pos;
12
- return re.exec(src)?.[0] ?? null;
13
- }
14
-
15
- /**
16
- * Parse `{watch "condition" keyword value …}`: a quoted condition followed
17
- * by keyword options (`goto "X"`, `dialog "X"`, `run "X"`, `name "X"`,
18
- * `priority N`, `once`). Quoted strings accept `\"`, `\'` and `\\` escapes.
19
- * Returns `null` when the arguments do not start with a quoted condition.
20
- */
21
- export function parseWatchArgs(
22
- rawArgs: string,
23
- ): { condition: string; options: WatchOptions } | null {
24
- const raw = rawArgs.trim();
25
- const cond = readQuoted(raw, 0);
26
- if (!cond) return null;
27
-
28
- const options: WatchOptions = {};
29
- let i = cond.end;
30
- while (i < raw.length) {
31
- const key = matchAt(WORD_RE, raw, i);
32
- if (!key) {
33
- // Skip whitespace and stray characters, or a stray quoted string.
34
- i = readQuoted(raw, i)?.end ?? i + 1;
35
- continue;
36
- }
37
- i += key.length;
38
-
39
- // A value is a quoted string or a digit run, after whitespace.
40
- let val: string | undefined;
41
- let j = i;
42
- while (j < raw.length && isWhitespace(raw[j]!)) j++;
43
- if (j > i) {
44
- const quoted = readQuoted(raw, j);
45
- const digits = quoted ? null : matchAt(DIGITS_RE, raw, j);
46
- if (quoted) {
47
- val = quoted.value;
48
- i = quoted.end;
49
- } else if (digits) {
50
- val = digits;
51
- i = j + digits.length;
52
- }
53
- }
54
-
55
- switch (key) {
56
- case 'goto':
57
- options.goto = val;
58
- break;
59
- case 'dialog':
60
- options.dialog = val;
61
- break;
62
- case 'run':
63
- options.run = val;
64
- break;
65
- case 'name':
66
- options.name = val;
67
- break;
68
- case 'priority':
69
- options.priority = val ? Number(val) : 0;
70
- break;
71
- case 'once':
72
- options.once = true;
73
- break;
74
- }
75
- }
76
-
77
- return { condition: cond.value, options };
78
- }
79
-
80
- /**
81
- * Parse the watcher name of `{unwatch "name"}`: a quoted string, with the
82
- * same escapes as `{watch}`, or a bare name.
83
- */
84
- export function parseUnwatchName(rawArgs: string): string {
85
- const raw = rawArgs.trim();
86
- return readWholeQuoted(raw) ?? raw.replace(/^['"]|['"]$/g, '');
87
- }
88
3
 
89
4
  defineMacro({
90
5
  name: 'watch',
91
- render(props, ctx) {
6
+ // {watch "condition" goto "X" dialog "X" run "X" name "X" priority N once}
7
+ parameters: [
8
+ { name: 'condition', type: 'string', required: true },
9
+ {
10
+ name: 'options',
11
+ type: 'options',
12
+ parameters: [
13
+ { name: 'goto', type: 'string' },
14
+ { name: 'dialog', type: 'string' },
15
+ { name: 'run', type: 'string' },
16
+ { name: 'name', type: 'string' },
17
+ { name: 'priority', type: 'number' },
18
+ { name: 'once', type: 'flag' },
19
+ ],
20
+ },
21
+ ],
22
+ render(_props, ctx) {
92
23
  const { hooks } = ctx;
93
24
 
94
- const parsed = parseWatchArgs(props.rawArgs);
95
- if (!parsed) return null;
96
- const { condition, options } = parsed;
25
+ // Without a quoted condition there is nothing to watch.
26
+ const { condition, options } = ctx.args;
27
+ if (condition === undefined) return null;
97
28
 
98
29
  // Register during render (like {set}) — triggers survive navigation
99
30
  // and are cleaned up via resetTriggers() on restart or {unwatch}.
@@ -110,8 +41,9 @@ defineMacro({
110
41
 
111
42
  defineMacro({
112
43
  name: 'unwatch',
113
- render(props, ctx) {
114
- const name = parseUnwatchName(props.rawArgs);
44
+ parameters: [{ name: 'name', type: 'text', required: true }],
45
+ render(_props, ctx) {
46
+ const name = ctx.args.name ?? '';
115
47
 
116
48
  const ran = ctx.hooks.useRef(false);
117
49
  if (!ran.current) {