@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
@@ -2,9 +2,8 @@ import { useStoryStore } from '../../store';
2
2
  import { evaluate } from '../../expression';
3
3
  import { readState } from '../../execute-mutation';
4
4
  import { deepEqual } from '../../class-registry';
5
- import { currentSourceLocation } from '../../utils/source-location';
6
5
  import { defineMacro } from '../../define-macro';
7
- import { MacroError } from './MacroError';
6
+ import { MacroError, logMacroError } from './MacroError';
8
7
  import { checkVariableName } from '../../utils/namespace';
9
8
 
10
9
  /**
@@ -14,38 +13,26 @@ import { checkVariableName } from '../../utils/namespace';
14
13
  */
15
14
  const UNSET: unique symbol = Symbol('unset');
16
15
 
17
- function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
18
- const trimmed = rawArgs.trim();
19
-
20
- let depth = 0;
21
- for (let i = 0; i < trimmed.length; i++) {
22
- const ch = trimmed[i];
23
- if (ch === '(' || ch === '[' || ch === '{') depth++;
24
- else if (ch === ')' || ch === ']' || ch === '}') depth--;
25
- else if (ch === '=' && depth === 0) {
26
- if (trimmed[i + 1] === '=') {
27
- i++;
28
- continue;
29
- }
30
- if (i > 0 && trimmed[i - 1] === '!') continue;
31
-
32
- const target = trimmed.slice(0, i).trim();
33
- const expr = trimmed.slice(i + 1).trim();
34
-
35
- if (!target.match(/^[$_@]\w+$/)) {
36
- throw new Error(
37
- `{computed}: target must be $name, _name, or @name, got "${target}"`,
38
- );
39
- }
16
+ /** Check the arguments of "target = expression". */
17
+ function assignmentArgs(
18
+ rawArgs: string,
19
+ args: { target?: string; '=': boolean; expression?: string },
20
+ ): { target: string; expr: string } {
21
+ if (!args['=']) {
22
+ throw new Error(
23
+ `{computed}: expected "target = expression", got "${rawArgs}"`,
24
+ );
25
+ }
40
26
 
41
- checkVariableName(target.slice(1), target);
42
- return { target, expr };
43
- }
27
+ const { target = '', expression: expr = '' } = args;
28
+ if (!target.match(/^[$_@]\w+$/)) {
29
+ throw new Error(
30
+ `{computed}: target must be $name, _name, or @name, got "${target}"`,
31
+ );
44
32
  }
45
33
 
46
- throw new Error(
47
- `{computed}: expected "target = expression", got "${rawArgs}"`,
48
- );
34
+ checkVariableName(target.slice(1), target);
35
+ return { target, expr };
49
36
  }
50
37
 
51
38
  /**
@@ -73,10 +60,7 @@ function computeAndApply(
73
60
  const { variables, temporary, transient } = readState();
74
61
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
75
62
  } catch (err) {
76
- console.error(
77
- `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
78
- err,
79
- );
63
+ logMacroError(`computed ${rawArgs}`, err);
80
64
  return;
81
65
  }
82
66
 
@@ -86,10 +70,7 @@ function computeAndApply(
86
70
  try {
87
71
  localsUpdate!(name, newValue);
88
72
  } catch (err) {
89
- console.error(
90
- `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`,
91
- err,
92
- );
73
+ logMacroError(`computed ${rawArgs}`, err);
93
74
  }
94
75
  } else {
95
76
  const state = useStoryStore.getState();
@@ -102,13 +83,18 @@ function computeAndApply(
102
83
  defineMacro({
103
84
  name: 'computed',
104
85
  merged: true,
86
+ parameters: [
87
+ { name: 'target', type: 'variable', required: true },
88
+ { name: '=', type: 'separator' },
89
+ { name: 'expression', type: 'expression', required: true },
90
+ ],
105
91
  render({ rawArgs }, ctx) {
106
92
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!;
107
93
 
108
94
  let target: string;
109
95
  let expr: string;
110
96
  try {
111
- ({ target, expr } = parseComputedArgs(rawArgs));
97
+ ({ target, expr } = assignmentArgs(rawArgs, ctx.args));
112
98
  } catch (err) {
113
99
  return (
114
100
  <MacroError
@@ -124,9 +110,7 @@ defineMacro({
124
110
 
125
111
  const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
126
112
 
127
- const ran = ctx.hooks.useRef(false);
128
- if (!ran.current) {
129
- ran.current = true;
113
+ const compute = () =>
130
114
  computeAndApply(
131
115
  expr,
132
116
  name,
@@ -137,22 +121,21 @@ defineMacro({
137
121
  prevOutput,
138
122
  localsUpdate,
139
123
  );
124
+
125
+ const ran = ctx.hooks.useRef(false);
126
+ if (!ran.current) {
127
+ ran.current = true;
128
+ compute();
140
129
  }
141
130
 
142
131
  // The merged values only decide when to recompute; computeAndApply reads
143
132
  // the current ones.
144
- ctx.hooks.useLayoutEffect(() => {
145
- computeAndApply(
146
- expr,
147
- name,
148
- isTemp,
149
- isLocal,
150
- ctx.getValues,
151
- rawArgs,
152
- prevOutput,
153
- localsUpdate,
154
- );
155
- }, [mergedVars, mergedTemps, mergedLocals, mergedTrans]);
133
+ ctx.hooks.useLayoutEffect(compute, [
134
+ mergedVars,
135
+ mergedTemps,
136
+ mergedLocals,
137
+ mergedTrans,
138
+ ]);
156
139
 
157
140
  return null;
158
141
  },
@@ -4,6 +4,7 @@ defineMacro({
4
4
  name: 'cycle',
5
5
  block: true,
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,20 +1,19 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { PassageDialog } from '../PassageDialog';
3
- import { readWholeQuoted } from './arg-utils';
4
3
 
5
4
  defineMacro({
6
5
  name: 'dialog',
7
6
  block: true,
8
7
  interpolate: true,
9
- render({ rawArgs, children = [] }, ctx) {
8
+ parameters: [
9
+ { name: 'label', type: 'text', required: true },
10
+ { name: 'noclose', type: 'flag' },
11
+ ],
12
+ render({ children = [] }, ctx) {
10
13
  const [open, setOpen] = ctx.hooks.useState(false);
11
14
 
12
- const noclose = /\bnoclose\s*$/.test(rawArgs);
13
- const labelRaw = rawArgs.replace(/\bnoclose\s*$/, '').trim();
14
- const label =
15
- ctx.resolve?.(
16
- readWholeQuoted(labelRaw) ?? labelRaw.replace(/^["']|["']$/g, ''),
17
- ) ?? labelRaw;
15
+ const { noclose } = ctx.args;
16
+ const label = ctx.resolve!(ctx.args.label ?? '');
18
17
  const passageName = ctx
19
18
  .collectText(children)
20
19
  .trim()
@@ -1,4 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
+ import { logMacroError } from './MacroError';
2
3
 
3
4
  defineMacro({
4
5
  name: 'do',
@@ -11,7 +12,7 @@ defineMacro({
11
12
  try {
12
13
  ctx.mutate(code);
13
14
  } catch (err) {
14
- console.error(`spindle: Error in {do}${ctx.sourceLocation()}:`, err);
15
+ logMacroError('do', err);
15
16
  }
16
17
  }, []);
17
18
 
@@ -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 =