@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
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The macro argument layer: reads a macro's raw argument text into the
3
+ * parameters its definition declares (see ParameterDef), so every macro
4
+ * splits and unquotes its arguments the same way. defineMacro() passes the
5
+ * result to the macro as `ctx.args`.
6
+ *
7
+ * Arguments are read in groups, split at `separator` parameters. A group's
8
+ * `flag` parameters are taken off its start or end first. Its last
9
+ * `expression` or `text` parameter (without one, its last parameter) reads
10
+ * the text the others leave; each parameter before it reads one term from
11
+ * the start, and each after it one term from the end. Terms are separated by
12
+ * whitespace outside literals and brackets (see arg-utils.ts), and a term
13
+ * that follows an operator belongs to the expression before it. A parameter
14
+ * with nothing to read is unset (a flag or separator is false, options are
15
+ * empty).
16
+ */
17
+ import { parseDelay } from '../../utils/parse-delay';
18
+ import type { MacroArgs, ParameterDef } from '../../registry';
19
+ import {
20
+ endsWithOperator,
21
+ isWhitespace,
22
+ readQuoted,
23
+ readWholeQuoted,
24
+ stripLooseQuotes,
25
+ topLevelIndices,
26
+ } from './arg-utils';
27
+
28
+ type Span = [start: number, end: number];
29
+
30
+ /** The terms of `src`: the runs of text between its depth-0 whitespace. */
31
+ function terms(src: string): Span[] {
32
+ const spans: Span[] = [];
33
+ let start = 0;
34
+ for (const i of [...topLevelIndices(src, isWhitespace), src.length]) {
35
+ if (i > start) spans.push([start, i]);
36
+ start = i + 1;
37
+ }
38
+ return spans;
39
+ }
40
+
41
+ const slice = (src: string, [start, end]: Span) => src.slice(start, end);
42
+
43
+ /**
44
+ * The value of a `text` argument: one quoted string, or the text with any
45
+ * loose quotes stripped (`"Red` → `Red`).
46
+ */
47
+ export function readText(src: string): string {
48
+ return readWholeQuoted(src) ?? stripLooseQuotes(src);
49
+ }
50
+
51
+ function readValue(param: ParameterDef, src: string): unknown {
52
+ switch (param.type) {
53
+ case 'string':
54
+ return readWholeQuoted(src) ?? undefined;
55
+ case 'text':
56
+ return readText(src);
57
+ case 'names':
58
+ return src.split(',').map((name) => name.trim());
59
+ case 'delay':
60
+ return parseDelay(src);
61
+ case 'number':
62
+ return Number(src);
63
+ case 'options':
64
+ return readOptions(param.parameters ?? [], src);
65
+ default:
66
+ return src;
67
+ }
68
+ }
69
+
70
+ function unset(param: ParameterDef): unknown {
71
+ if (param.type === 'flag' || param.type === 'separator') return false;
72
+ return param.type === 'options' ? {} : undefined;
73
+ }
74
+
75
+ const WORD_RE = /\w+/y;
76
+ const DIGITS_RE = /\d+/y;
77
+
78
+ /** Match the sticky regex `re` at `pos`, returning the matched text. */
79
+ function matchAt(re: RegExp, src: string, pos: number): string | null {
80
+ re.lastIndex = pos;
81
+ return re.exec(src)?.[0] ?? null;
82
+ }
83
+
84
+ /**
85
+ * Read keyword options (`goto "X" priority 5 once`). A keyword takes the
86
+ * quoted string or digit run after it as its value; keywords that aren't
87
+ * declared, and their values, are skipped.
88
+ */
89
+ function readOptions(
90
+ params: readonly ParameterDef[],
91
+ src: string,
92
+ ): Record<string, unknown> {
93
+ const options: Record<string, unknown> = {};
94
+ let i = 0;
95
+ while (i < src.length) {
96
+ const key = matchAt(WORD_RE, src, i);
97
+ if (!key) {
98
+ // Skip whitespace and stray characters, or a stray quoted string.
99
+ i = readQuoted(src, i)?.end ?? i + 1;
100
+ continue;
101
+ }
102
+ i += key.length;
103
+
104
+ // A value is a quoted string or a digit run, after whitespace.
105
+ let val: string | undefined;
106
+ let j = i;
107
+ while (j < src.length && isWhitespace(src[j]!)) j++;
108
+ if (j > i) {
109
+ const quoted = readQuoted(src, j);
110
+ const digits = quoted ? null : matchAt(DIGITS_RE, src, j);
111
+ if (quoted) {
112
+ val = quoted.value;
113
+ i = quoted.end;
114
+ } else if (digits) {
115
+ val = digits;
116
+ i = j + digits.length;
117
+ }
118
+ }
119
+
120
+ const param = params.find((p) => p.name === key);
121
+ if (param?.type === 'flag') options[key] = true;
122
+ else if (param?.type === 'number') options[key] = Number(val ?? 0);
123
+ else if (param) options[key] = val;
124
+ }
125
+ return options;
126
+ }
127
+
128
+ /** `inline %name` reads a transient variable; `inline % 2` is modulo. */
129
+ const LEADING_OPERATOR_RE = /^(?:[-+*/&|^=<>?:,.]|%(?![A-Za-z_]))/;
130
+
131
+ /**
132
+ * The rest of `src` without the flag `word` as its last or first term, or
133
+ * `null` if it has no such flag. Next to a binary operator the word is an
134
+ * operand (`"a" + inline`), not a flag; the closing `/` of a regex literal
135
+ * is not one.
136
+ */
137
+ function takeFlag(src: string, word: string): string | null {
138
+ const spans = terms(src);
139
+ if (spans.length < 2) return null;
140
+ if (slice(src, spans[spans.length - 1]!) === word) {
141
+ const rest = src.slice(0, spans[spans.length - 2]![1]);
142
+ if (!endsWithOperator(rest)) return rest;
143
+ }
144
+ if (slice(src, spans[0]!) === word) {
145
+ const rest = src.slice(spans[1]![0]);
146
+ if (!LEADING_OPERATOR_RE.test(rest)) return rest;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Where the separator `word` splits `src`, as the end of the text before it
153
+ * and the start of the text after it, or `null` if it doesn't. A word is a
154
+ * term with terms on both sides; `=` is an assignment at depth 0, not part
155
+ * of `==` or `!=`.
156
+ */
157
+ function findSeparator(src: string, word: string): Span | null {
158
+ if (word === '=') {
159
+ const indices = topLevelIndices(src, (ch) => ch === '=');
160
+ for (let k = 0; k < indices.length; k++) {
161
+ const i = indices[k]!;
162
+ if (src[i + 1] === '=') {
163
+ k++;
164
+ continue;
165
+ }
166
+ if (src[i - 1] !== '!') return [i, i + 1];
167
+ }
168
+ return null;
169
+ }
170
+ const spans = terms(src);
171
+ for (let k = 1; k < spans.length - 1; k++) {
172
+ if (slice(src, spans[k]!) === word) {
173
+ return [spans[k - 1]![1], spans[k + 1]![0]];
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ const takesRest = (param: ParameterDef) =>
180
+ param.type === undefined ||
181
+ param.type === 'expression' ||
182
+ param.type === 'text';
183
+
184
+ /** Read the parameters of one group from `src` (`null`: no text) into `args`. */
185
+ function readGroup(
186
+ params: readonly ParameterDef[],
187
+ src: string | null,
188
+ args: Record<string, unknown>,
189
+ ): void {
190
+ let text = src?.trim() ?? '';
191
+ for (const flag of params.filter((p) => p.type === 'flag')) {
192
+ const rest = src === null ? null : takeFlag(text, flag.name);
193
+ args[flag.name] = rest !== null;
194
+ text = rest ?? text;
195
+ }
196
+ const positional = params.filter((p) => p.type !== 'flag');
197
+ if (positional.length === 0) return;
198
+
199
+ // The last expression or text parameter, else the last one, reads the rest.
200
+ const lastText = positional.findIndex(
201
+ (p, i) => takesRest(p) && !positional.slice(i + 1).some(takesRest),
202
+ );
203
+ const restIndex = lastText < 0 ? positional.length - 1 : lastText;
204
+ const read = (param: ParameterDef, value: string) =>
205
+ value === '' ? unset(param) : readValue(param, value);
206
+
207
+ // One parameter reads the whole text: no need to split it.
208
+ if (positional.length === 1) {
209
+ args[positional[0]!.name] = read(positional[0]!, text);
210
+ return;
211
+ }
212
+
213
+ const spans = terms(text);
214
+ let from = 0;
215
+ let to = spans.length;
216
+ for (const param of positional.slice(0, restIndex)) {
217
+ args[param.name] =
218
+ from < to ? read(param, slice(text, spans[from++]!)) : unset(param);
219
+ }
220
+ for (const param of positional.slice(restIndex + 1).reverse()) {
221
+ const value =
222
+ from < to && !endsWithOperator(text.slice(0, spans[to - 1]![0]))
223
+ ? read(param, slice(text, spans[to - 1]!))
224
+ : undefined;
225
+ args[param.name] = value ?? unset(param);
226
+ if (value !== undefined) to--;
227
+ }
228
+ const rest = positional[restIndex]!;
229
+ args[rest.name] =
230
+ from < to
231
+ ? read(rest, text.slice(spans[from]![0], spans[to - 1]![1]))
232
+ : unset(rest);
233
+ }
234
+
235
+ /** Read `rawArgs` into the declared `parameters` (see above). */
236
+ export function parseMacroArgs<const P extends readonly ParameterDef[]>(
237
+ rawArgs: string,
238
+ parameters: P,
239
+ ): MacroArgs<P> {
240
+ const args: Record<string, unknown> = {};
241
+ // The text after the last separator read, null once one is missing
242
+ let rest: string | null = rawArgs.trim();
243
+ let groupStart = 0;
244
+ parameters.forEach((separator, i) => {
245
+ if (separator.type !== 'separator') return;
246
+ const at = rest === null ? null : findSeparator(rest, separator.name);
247
+ args[separator.name] = at !== null;
248
+ const before = at ? rest!.slice(0, at[0]) : rest;
249
+ readGroup(parameters.slice(groupStart, i), before, args);
250
+ rest = at ? rest!.slice(at[1]) : null;
251
+ groupStart = i + 1;
252
+ });
253
+ readGroup(parameters.slice(groupStart), rest, args);
254
+ return args as MacroArgs<P>;
255
+ }
256
+
257
+ /**
258
+ * The passage a passage-name argument names: the value of its expression
259
+ * or, when that can't be evaluated (`{goto Bob's room}`), its text.
260
+ */
261
+ export function evaluatePassageName(
262
+ expr: string | undefined,
263
+ evaluate: (expr: string) => unknown,
264
+ ): string {
265
+ try {
266
+ return String(evaluate(expr ?? ''));
267
+ } catch {
268
+ return readText(expr ?? '');
269
+ }
270
+ }
271
+
272
+ /**
273
+ * The variable a `storeVar` macro binds: its first term, as written. Custom
274
+ * macros may bind one without declaring parameters.
275
+ */
276
+ export function readBoundVariable(rawArgs: string): string {
277
+ const src = rawArgs.trim();
278
+ const first = terms(src)[0];
279
+ return first ? slice(src, first) : '';
280
+ }
@@ -1,22 +1,17 @@
1
1
  import type { ASTNode } from '../../markup/ast';
2
2
  import { readWholeQuoted } from './arg-utils';
3
+ import { parseMacroArgs } from './macro-args';
3
4
 
5
+ /** `$var "placeholder"`, the arguments of the input macros. */
4
6
  export function parseVarArgs(rawArgs: string): {
5
7
  varName: string;
6
8
  placeholder: string;
7
9
  } {
8
- // `s`: a quoted placeholder may span lines.
9
- const match = rawArgs.match(/^\s*(["']?\$[\w.]+["']?)\s*(["'].*["'])?\s*$/s);
10
- if (!match) {
11
- return { varName: rawArgs.trim(), placeholder: '' };
12
- }
13
- const varName = match[1]!.replace(/["']/g, '');
14
- // A quoted placeholder accepts \" \' and \\ escapes.
15
- const quoted = match[2];
16
- const placeholder = quoted
17
- ? (readWholeQuoted(quoted) ?? quoted.slice(1, -1))
18
- : '';
19
- return { varName, placeholder };
10
+ const { variable = '', placeholder = '' } = parseMacroArgs(rawArgs, [
11
+ { name: 'variable', type: 'variable' },
12
+ { name: 'placeholder', type: 'string' },
13
+ ]);
14
+ return { varName: variable.replace(/["']/g, ''), placeholder };
20
15
  }
21
16
 
22
17
  /**
@@ -11,12 +11,9 @@ import {
11
11
  } from 'preact/hooks';
12
12
  import { useInterpolate } from './hooks/use-interpolate';
13
13
  import { useMergedLocals } from './hooks/use-merged-locals';
14
+ import { useRenderOptions } from './hooks/use-render-options';
14
15
  import {
15
16
  LocalsUpdateContext,
16
- LocalsValuesContext,
17
- NobrContext,
18
- InlineContext,
19
- RawTextContext,
20
17
  renderNodes as _renderNodes,
21
18
  renderInlineNodes,
22
19
  } from './markup/render';
@@ -31,23 +28,34 @@ import type { UseActionOptions } from './hooks/use-action';
31
28
  import { collectText } from './utils/extract-text';
32
29
  import { currentSourceLocation } from './utils/source-location';
33
30
  import { parseVarArgs, extractOptions } from './components/macros/option-utils';
31
+ import {
32
+ parseMacroArgs,
33
+ readBoundVariable,
34
+ } from './components/macros/macro-args';
34
35
  import {
35
36
  registerMacro,
36
37
  registerMacroText,
37
38
  registerSubMacro,
38
39
  registerMacroMetadata,
39
40
  } from './registry';
40
- import type { MacroProps, MacroTextContext, ParameterDef } from './registry';
41
+ import type {
42
+ MacroArgs,
43
+ MacroProps,
44
+ MacroTextContext,
45
+ ParameterDef,
46
+ } from './registry';
41
47
  import { registerBlockMacro } from './markup/ast';
42
48
 
43
- export type { MacroTextContext };
49
+ export type { MacroArgs, MacroTextContext, ParameterDef };
44
50
 
45
51
  export function macroClass(type: string, className?: string): string {
46
52
  const base = `macro-${type}`;
47
53
  return className ? `${base} ${className}` : base;
48
54
  }
49
55
 
50
- export interface MacroContext {
56
+ export interface MacroContext<A = MacroArgs> {
57
+ /** The arguments, read into the declared parameters (see ParameterDef). */
58
+ args: A;
51
59
  className?: string;
52
60
  id?: string;
53
61
  resolve?: (s: string | undefined) => string | undefined;
@@ -86,7 +94,9 @@ export interface MacroContext {
86
94
  };
87
95
  }
88
96
 
89
- export interface MacroDefinition {
97
+ export interface MacroDefinition<
98
+ P extends readonly ParameterDef[] = ParameterDef[],
99
+ > {
90
100
  name: string;
91
101
  subMacros?: string[];
92
102
  block?: boolean;
@@ -94,14 +104,20 @@ export interface MacroDefinition {
94
104
  merged?: boolean;
95
105
  storeVar?: boolean;
96
106
  description?: string;
97
- parameters?: ParameterDef[];
98
- render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
107
+ parameters?: P;
108
+ render: (
109
+ props: MacroProps,
110
+ ctx: MacroContext<MacroArgs<P>>,
111
+ ) => ComponentChildren;
99
112
  /**
100
113
  * The macro's text form, used where markup becomes a string: HTML
101
114
  * attribute values, image alt text and link titles, macro labels. Without
102
115
  * one, the macro can't be used there and is reported as an error.
103
116
  */
104
- text?: (props: MacroProps, ctx: MacroTextContext) => string;
117
+ text?: (
118
+ props: MacroProps,
119
+ ctx: MacroTextContext & { args: MacroArgs<P> },
120
+ ) => string;
105
121
  }
106
122
 
107
123
  const sharedHooks = {
@@ -114,10 +130,12 @@ const sharedHooks = {
114
130
  useContext,
115
131
  };
116
132
 
117
- export function defineMacro(
118
- config: MacroDefinition,
133
+ export function defineMacro<const P extends readonly ParameterDef[] = []>(
134
+ config: MacroDefinition<P>,
119
135
  source: 'builtin' | 'user' = 'builtin',
120
136
  ): void {
137
+ const parameters: readonly ParameterDef[] = config.parameters ?? [];
138
+
121
139
  function Wrapper(props: MacroProps) {
122
140
  // className/id resolved first (interpolate may transform them)
123
141
  let className = props.className;
@@ -131,10 +149,7 @@ export function defineMacro(
131
149
 
132
150
  // Always-on: cssClass + mutation
133
151
  const { update, getValues } = useContext(LocalsUpdateContext);
134
- const nobr = useContext(NobrContext);
135
- const inline = useContext(InlineContext);
136
- const raw = useContext(RawTextContext);
137
- const localsValues = useContext(LocalsValuesContext);
152
+ const renderOptions = useRenderOptions();
138
153
  const renderNodes = (
139
154
  nodes: ASTNode[],
140
155
  options?: {
@@ -143,15 +158,9 @@ export function defineMacro(
143
158
  inline?: boolean;
144
159
  raw?: boolean;
145
160
  },
146
- ) =>
147
- _renderNodes(nodes, {
148
- nobr,
149
- inline,
150
- raw,
151
- locals: localsValues,
152
- ...options,
153
- });
154
- const ctx: MacroContext = {
161
+ ) => _renderNodes(nodes, { ...renderOptions, ...options });
162
+ const ctx: MacroContext<MacroArgs<P>> = {
163
+ args: parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>,
155
164
  collectText,
156
165
  sourceLocation: currentSourceLocation,
157
166
  parseVarArgs,
@@ -183,8 +192,7 @@ export function defineMacro(
183
192
  }
184
193
 
185
194
  if (config.storeVar) {
186
- const firstToken =
187
- props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? '';
195
+ const firstToken = readBoundVariable(props.rawArgs).replace(/["']/g, '');
188
196
 
189
197
  if (firstToken.startsWith('%')) {
190
198
  return h(
@@ -220,7 +228,16 @@ export function defineMacro(
220
228
  }
221
229
 
222
230
  registerMacro(config.name, Wrapper);
223
- registerMacroText(config.name, config.text);
231
+ const text = config.text;
232
+ registerMacroText(
233
+ config.name,
234
+ text &&
235
+ ((props, ctx) =>
236
+ text(props, {
237
+ ...ctx,
238
+ args: parseMacroArgs(props.rawArgs, parameters) as MacroArgs<P>,
239
+ })),
240
+ );
224
241
 
225
242
  // Store metadata for tooling API
226
243
  const isBlock =
@@ -234,7 +251,7 @@ export function defineMacro(
234
251
  interpolate: config.interpolate,
235
252
  merged: config.merged,
236
253
  description: config.description,
237
- parameters: config.parameters,
254
+ parameters: config.parameters && [...config.parameters],
238
255
  source,
239
256
  });
240
257
 
@@ -0,0 +1,26 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import {
3
+ InlineContext,
4
+ LocalsValuesContext,
5
+ NobrContext,
6
+ RawTextContext,
7
+ } from '../markup/render';
8
+
9
+ /**
10
+ * The renderNodes() options of the enclosing content: nobr, inline and raw
11
+ * rendering and the locals in scope. A macro or widget body rendered with
12
+ * them renders as the content around it does.
13
+ */
14
+ export function useRenderOptions(): {
15
+ nobr: boolean;
16
+ inline: boolean;
17
+ raw: boolean;
18
+ locals: Record<string, unknown>;
19
+ } {
20
+ return {
21
+ nobr: useContext(NobrContext),
22
+ inline: useContext(InlineContext),
23
+ raw: useContext(RawTextContext),
24
+ locals: useContext(LocalsValuesContext),
25
+ };
26
+ }
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
9
9
  import { h } from 'preact';
10
10
  import type { ASTNode, HtmlNode, MacroNode } from './ast';
11
11
  import { useTextScope } from '../hooks/use-interpolate';
12
+ import { useRenderOptions } from '../hooks/use-render-options';
12
13
  import {
13
14
  hasInterpolation,
14
15
  interpolateCode,
@@ -524,11 +525,8 @@ function resolveAttributeValue(
524
525
 
525
526
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
526
527
  const scope = useTextScope();
527
- const nobr = useContext(NobrContext);
528
- const locals = useContext(LocalsValuesContext);
529
- const inRaw = useContext(RawTextContext);
528
+ const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
530
529
  const inSvg = useContext(SvgContext);
531
- const parentInline = useContext(InlineContext);
532
530
  const tag = node.tag.toLowerCase();
533
531
  const isSvgRoot = tag === 'svg';
534
532
  const isRawRoot = !inRaw && (isSvgRoot || PREFORMATTED_ELEMENTS.has(tag));
package/src/registry.ts CHANGED
@@ -67,12 +67,60 @@ export function isSubMacro(name: string): boolean {
67
67
  return subMacros.has(name.toLowerCase());
68
68
  }
69
69
 
70
+ /**
71
+ * How a macro argument is read (see components/macros/macro-args.ts).
72
+ * Quoted strings accept `\"`, `\'` and `\\` escapes.
73
+ * - `expression`: code, as written (the default).
74
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
75
+ * - `string`: one quoted string; anything else leaves the argument unset.
76
+ * - `text`: one quoted string, or text with any loose quotes stripped.
77
+ * - `names`: a comma-separated list of names (`@item, @i`).
78
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
79
+ * - `number`: a number.
80
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
81
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
82
+ * from those after it; a boolean.
83
+ * - `options`: keywords, the names of its `parameters`, each followed by a
84
+ * quoted string or a number unless it is a flag.
85
+ */
86
+ export type ParameterType =
87
+ | 'expression'
88
+ | 'variable'
89
+ | 'string'
90
+ | 'text'
91
+ | 'names'
92
+ | 'delay'
93
+ | 'number'
94
+ | 'flag'
95
+ | 'separator'
96
+ | 'options';
97
+
70
98
  export interface ParameterDef {
71
99
  name: string;
72
100
  required?: boolean;
73
101
  description?: string;
102
+ type?: ParameterType;
103
+ /** The options of an `options` parameter. */
104
+ parameters?: readonly ParameterDef[];
74
105
  }
75
106
 
107
+ type ArgValue<T, D> = T extends 'flag' | 'separator'
108
+ ? boolean
109
+ : T extends 'names'
110
+ ? string[] | undefined
111
+ : T extends 'delay' | 'number'
112
+ ? number | undefined
113
+ : T extends 'options'
114
+ ? D extends { parameters: infer Q extends readonly ParameterDef[] }
115
+ ? Partial<MacroArgs<Q>>
116
+ : Partial<MacroArgs>
117
+ : string | undefined;
118
+
119
+ /** The arguments of a macro declaring `parameters`, by parameter name. */
120
+ export type MacroArgs<P extends readonly ParameterDef[] = ParameterDef[]> = {
121
+ [D in P[number] as D['name']]: ArgValue<D['type'], D>;
122
+ };
123
+
76
124
  export interface MacroMetadata {
77
125
  name: string;
78
126
  block: boolean;