@rohal12/spindle 0.52.5 → 0.52.7

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 (43) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +705 -576
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/dist/pkg/story-variables.js +54 -32
  5. package/package.json +1 -1
  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/hooks/use-render-options.ts +26 -0
  39. package/src/js-lexer.ts +80 -45
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/types/index.d.ts +69 -6
  43. package/types/tooling.d.ts +33 -1
@@ -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
+ }
package/src/js-lexer.ts CHANGED
@@ -397,6 +397,14 @@ const MALFORMED = -2;
397
397
  /** A frame result: the scan stops at index `s` inside it (`STOPPED - s`). */
398
398
  const STOPPED = -3;
399
399
 
400
+ /**
401
+ * The result of a frame that closes at `end`. `bodyStarted`: the code inside
402
+ * started a function or class body. That body replaced the one to come, and
403
+ * is gone once the frame is closed, so after the frame no body is to come —
404
+ * a scan that skips the frame must know, as must the frames around it.
405
+ */
406
+ const frameEnd = (end: number, bodyStarted: boolean) => end * 2 + +bodyStarted;
407
+
400
408
  /** The closers whose search for their frame a frame result may depend on. */
401
409
  const CLOSERS = [')', ']', '}'] as const;
402
410
  const FRAME_KINDS: readonly Frame['kind'][] = [
@@ -423,11 +431,13 @@ function frameKey(f: Frame): number | undefined {
423
431
  */
424
432
  export interface JsScanCache {
425
433
  /**
426
- * Where each `{…}` frame, template literal or `${…}` interpolation closes:
427
- * the index of its `}` or closing backtick, `UNCLOSED` or `MALFORMED`. The
428
- * code inside such a frame lexes the same whatever surrounds it, given
429
- * where it opens and its kind — a stray `)` or `]` inside never closes it
430
- * — so a later scan entering the same frame skips to its end.
434
+ * Where each `{…}` frame, template literal or `${…}` interpolation closes,
435
+ * as a frame result (`frameEnd`): its `}` or closing backtick, and whether
436
+ * the code inside started a function or class body; or `UNCLOSED` or
437
+ * `MALFORMED`. The code inside such a frame lexes the same whatever
438
+ * surrounds it, given where it opens and its kind — a stray `)` or `]`
439
+ * inside never closes it — so a later scan entering the same frame skips
440
+ * to its end.
431
441
  */
432
442
  braces: Map<number, number>;
433
443
  /** Look-ahead bracket matches (`ScanContext.brackets`). */
@@ -457,10 +467,8 @@ export interface JsScanCache {
457
467
  stacks: Map<string, number>;
458
468
  /**
459
469
  * How `(…)` and `[…]` frames end, by the kind of scan and then by the frame
460
- * (`parenKey`): `end * 2 + 1` if it closes at `end` and the code inside
461
- * started a function or class body (which replaced the one to come, and
462
- * can't follow once the frame is closed), `end * 2` otherwise, or
463
- * `UNCLOSED`, `MALFORMED` or `STOPPED - s`. Unlike braces, a stray closer inside may close a frame
470
+ * (`parenKey`): a frame result (`frameEnd`), or `UNCLOSED`, `MALFORMED` or
471
+ * `STOPPED - s`. Unlike braces, a stray closer inside may close a frame
464
472
  * around them, so the code inside lexes the same only around frames for
465
473
  * which the closers it met find nothing to close; the key says which
466
474
  * closers met none.
@@ -748,15 +756,34 @@ function scan(
748
756
  : undefined;
749
757
  /**
750
758
  * How many function or class bodies to come the scan has started, and with
751
- * `parens`, that count when each open frame opened. Code in a `(…)` or
752
- * `[…]` that starts one replaces the one to come, and the new one can't
753
- * follow once the frame is closed: none is to come then.
759
+ * `braces` or `parens`, that count when each open frame opened. Code in a
760
+ * frame that starts one replaces the one to come, and the new one can't
761
+ * follow once the frame is closed: none is to come then. A scan skipping
762
+ * the frame learns this from its result (`frameEnd`, `resume`), and counts
763
+ * the body as started too, for the frames around it to record.
754
764
  */
755
765
  let bodyStarts = 0;
756
- const startsAt: number[] | undefined = parens ? [0] : undefined;
766
+ const startsAt: number[] | undefined = braces || parens ? [0] : undefined;
757
767
  /** A `(…)` or `[…]` frame result found in `parens`, to skip to. */
758
768
  let parenTo: { frame: Frame; result: number } | undefined;
759
769
 
770
+ /** The result of the frame at index `k`, closing at `i`. */
771
+ const closedAt = (k: number) =>
772
+ frameEnd(i, startsAt !== undefined && bodyStarts !== startsAt[k]);
773
+
774
+ /**
775
+ * Go on after a frame an earlier scan lexed, whose result (`frameEnd`) is
776
+ * `result`: returns the index it closes at. A body started in it is gone
777
+ * after it, with the one it replaced.
778
+ */
779
+ function resume(result: number): number {
780
+ if (result % 2 === 1) {
781
+ pendingBody = undefined;
782
+ bodyStarts++;
783
+ }
784
+ return Math.floor(result / 2);
785
+ }
786
+
760
787
  /** The closer `c` looked for its frame and found index `k`. */
761
788
  function lookedFor(c: (typeof CLOSERS)[number], k: number) {
762
789
  if (!lowest) return;
@@ -933,8 +960,8 @@ function scan(
933
960
  if (lowest) {
934
961
  for (let j = frames.length - 1; j >= k; j--) foldLowest(j);
935
962
  lowest.length = k;
936
- startsAt!.length = k;
937
963
  }
964
+ if (startsAt) startsAt.length = k;
938
965
  frames.length = k;
939
966
  if (stackIds) stackIds.length = k;
940
967
  for (const list of Object.values(closers)) {
@@ -1034,6 +1061,19 @@ function scan(
1034
1061
  keyNext = f.kind === 'object' || f.kind === 'class';
1035
1062
  }
1036
1063
 
1064
+ /**
1065
+ * Go past frame `f` if an earlier scan lexed it: to just after it, or to
1066
+ * the end of the source when it never closes. Whether it did, or
1067
+ * MALFORMED when that scan found the code malformed.
1068
+ */
1069
+ function skipLexed(f: Frame): boolean | typeof MALFORMED {
1070
+ const end = known(f);
1071
+ if (end === undefined) return false;
1072
+ if (end === MALFORMED) return MALFORMED;
1073
+ i = end === UNCLOSED ? src.length : resume(end) + 1;
1074
+ return true;
1075
+ }
1076
+
1037
1077
  function openBrace() {
1038
1078
  let f: Frame;
1039
1079
  if (pendingBody?.depth === frames.length) {
@@ -1079,12 +1119,11 @@ function scan(
1079
1119
  return;
1080
1120
  }
1081
1121
  const closed = frames[k]!;
1122
+ const result = closedAt(k);
1082
1123
  if (lowest && c !== '}') {
1083
1124
  // Its entry complete, with those of the frames still open in it
1084
1125
  for (let j = frames.length - 1; j > k; j--) foldLowest(j);
1085
- // Whether code in it started a function or class body: none is to come
1086
- const bodyStarted = bodyStarts !== startsAt![k];
1087
- recordParen(k, i * 2 + +bodyStarted);
1126
+ recordParen(k, result);
1088
1127
  }
1089
1128
  truncate(k);
1090
1129
  if (c === ']' && !ctx.lookahead) ctx.brackets.set(closed.open, i);
@@ -1095,7 +1134,7 @@ function scan(
1095
1134
  } else if (c === ']') {
1096
1135
  endPunct(c, false);
1097
1136
  } else {
1098
- record(closed, i);
1137
+ record(closed, result);
1099
1138
  afterBrace(closed);
1100
1139
  }
1101
1140
  }
@@ -1214,24 +1253,17 @@ function scan(
1214
1253
  } else if (ch === '`') {
1215
1254
  literal(ch, i);
1216
1255
  if (frames.length === 1) return i + 1; // the end of `lexTemplate`
1217
- record(top(), i);
1256
+ record(top(), closedAt(frames.length - 1));
1218
1257
  i++;
1219
1258
  truncate(frames.length - 1);
1220
1259
  endOperand();
1221
1260
  } else if (ch === '$' && src.charAt(i + 1) === '{') {
1222
1261
  const f = frame('expr', '}', i);
1223
1262
  f.interpolation = true;
1224
- const end = known(f);
1225
- if (end === MALFORMED) return malformed();
1226
- if (end === UNCLOSED) {
1227
- i = src.length;
1228
- break;
1229
- }
1230
- if (end !== undefined) {
1231
- // An interpolation an earlier scan lexed: on with the text after it
1232
- i = end + 1;
1233
- continue;
1234
- }
1263
+ // An interpolation an earlier scan lexed: on with the text after it
1264
+ const skipped = skipLexed(f);
1265
+ if (skipped === MALFORMED) return malformed();
1266
+ if (skipped) continue;
1235
1267
  literal('${', i);
1236
1268
  i += 2;
1237
1269
  nesting++;
@@ -1261,15 +1293,10 @@ function scan(
1261
1293
  if (ch === '`') {
1262
1294
  endWord();
1263
1295
  const f = frame('template', '`', i);
1264
- const end = known(f);
1265
- if (end === MALFORMED) return malformed();
1266
- if (end === UNCLOSED) {
1267
- i = src.length;
1268
- break;
1269
- }
1270
- if (end !== undefined) {
1271
- // A template literal an earlier scan lexed: on after it
1272
- i = end + 1;
1296
+ // A template literal an earlier scan lexed: on after it
1297
+ const skipped = skipLexed(f);
1298
+ if (skipped === MALFORMED) return malformed();
1299
+ if (skipped) {
1273
1300
  endOperand();
1274
1301
  continue;
1275
1302
  }
@@ -1291,7 +1318,7 @@ function scan(
1291
1318
  lookedFor('}', k);
1292
1319
  if (frames[k]!.interpolation) {
1293
1320
  endWord();
1294
- record(frames[k]!, i);
1321
+ record(frames[k]!, closedAt(k));
1295
1322
  truncate(k);
1296
1323
  nesting--;
1297
1324
  literal(ch, i);
@@ -1399,8 +1426,17 @@ function scan(
1399
1426
  }
1400
1427
  }
1401
1428
 
1402
- // End of an `outer` square bracket
1403
- if (ch === outer.closer && frames.length === 1) break;
1429
+ // End of an `outer` square bracket: a `]` that closes no `[` within the
1430
+ // innermost braces closes it, over any `(` left open in it, as it closes
1431
+ // a nested `[`. Look-ahead scans record where the `[`s nested in theirs
1432
+ // end (`ctx.brackets`), so one starting at a `[` must find the same.
1433
+ if (
1434
+ ch === outer.closer &&
1435
+ (frames.length === 1 ||
1436
+ (ch === ']' && innermost(']') === 0 && innermost('}') === 0))
1437
+ ) {
1438
+ break;
1439
+ }
1404
1440
 
1405
1441
  // Regular code character
1406
1442
  trackCode(ch);
@@ -1413,7 +1449,7 @@ function scan(
1413
1449
  i = src.length;
1414
1450
  break;
1415
1451
  }
1416
- i = end;
1452
+ i = resume(end);
1417
1453
  afterBrace(skipped);
1418
1454
  i++;
1419
1455
  continue;
@@ -1432,8 +1468,7 @@ function scan(
1432
1468
  strict!.stopped = true;
1433
1469
  return STOPPED - result;
1434
1470
  }
1435
- i = Math.floor(result / 2);
1436
- if (result % 2 === 1) pendingBody = undefined;
1471
+ i = resume(result);
1437
1472
  if (skipped.closer === ')') {
1438
1473
  endPunct(')', skipped.header);
1439
1474
  stmtNext = skipped.header;
@@ -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;
package/types/index.d.ts CHANGED
@@ -239,6 +239,35 @@ export interface StorageQuota {
239
239
  estimateSupported: boolean;
240
240
  }
241
241
 
242
+ /**
243
+ * How a macro argument is read into `ctx.args`. Quoted strings accept `\"`,
244
+ * `\'` and `\\` escapes.
245
+ * - `expression`: code, as written (the default).
246
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
247
+ * - `string`: one quoted string; anything else leaves the argument unset.
248
+ * - `text`: one quoted string, or text with any loose quotes stripped.
249
+ * - `names`: a comma-separated list of names (`@item, @i`).
250
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
251
+ * - `number`: a number.
252
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
253
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
254
+ * from those after it; a boolean.
255
+ * - `options`: keywords, the names of its `parameters`, each followed by a
256
+ * quoted string or a number unless it is a flag.
257
+ * @see {@link ../../src/registry.ts} for the implementation.
258
+ */
259
+ export type ParameterType =
260
+ | 'expression'
261
+ | 'variable'
262
+ | 'string'
263
+ | 'text'
264
+ | 'names'
265
+ | 'delay'
266
+ | 'number'
267
+ | 'flag'
268
+ | 'separator'
269
+ | 'options';
270
+
242
271
  /**
243
272
  * Parameter metadata for a macro definition.
244
273
  * @see {@link ../../src/registry.ts} for the implementation.
@@ -247,8 +276,32 @@ export interface ParameterDef {
247
276
  name: string;
248
277
  required?: boolean;
249
278
  description?: string;
279
+ /** How the argument is read (default `expression`). */
280
+ type?: ParameterType;
281
+ /** The options of an `options` parameter. */
282
+ parameters?: readonly ParameterDef[];
250
283
  }
251
284
 
285
+ type ArgValue<T, D> = T extends 'flag' | 'separator'
286
+ ? boolean
287
+ : T extends 'names'
288
+ ? string[] | undefined
289
+ : T extends 'delay' | 'number'
290
+ ? number | undefined
291
+ : T extends 'options'
292
+ ? D extends { parameters: infer Q extends readonly ParameterDef[] }
293
+ ? Partial<MacroArgs<Q>>
294
+ : Partial<MacroArgs>
295
+ : string | undefined;
296
+
297
+ /**
298
+ * A macro's arguments (`ctx.args`), by parameter name.
299
+ * @see {@link ../../src/registry.ts} for the implementation.
300
+ */
301
+ export type MacroArgs<P extends readonly ParameterDef[] = ParameterDef[]> = {
302
+ [D in P[number] as D['name']]: ArgValue<D['type'], D>;
303
+ };
304
+
252
305
  /**
253
306
  * Metadata about a registered macro, returned by `Story.getMacroRegistry()`.
254
307
  * @see {@link ../../src/registry.ts} for the implementation.
@@ -363,7 +416,9 @@ export interface UseActionOptions {
363
416
  * this package), so `ctx.hooks.useState<T>()`, `ctx.h()` etc. are fully typed.
364
417
  * @see {@link ../../src/define-macro.ts} for the implementation.
365
418
  */
366
- export interface MacroContext {
419
+ export interface MacroContext<A = MacroArgs> {
420
+ /** The arguments, read into the macro's declared `parameters`. */
421
+ args: A;
367
422
  className?: string;
368
423
  id?: string;
369
424
  resolve?: (s: string | undefined) => string | undefined;
@@ -437,7 +492,9 @@ export interface MacroTextContext {
437
492
  * Configuration object for `Story.defineMacro()`.
438
493
  * @see {@link ../../src/define-macro.ts} for the implementation.
439
494
  */
440
- export interface MacroDefinition {
495
+ export interface MacroDefinition<
496
+ P extends readonly ParameterDef[] = ParameterDef[],
497
+ > {
441
498
  name: string;
442
499
  /** Sub-macro names (e.g. `['option']`); a non-empty list makes the macro a block macro. */
443
500
  subMacros?: string[];
@@ -451,15 +508,21 @@ export interface MacroDefinition {
451
508
  storeVar?: boolean;
452
509
  /** Tooling hint: one-line description shown by editors. */
453
510
  description?: string;
454
- /** Tooling hint: positional parameters. */
455
- parameters?: ParameterDef[];
456
- render: (props: MacroProps, ctx: MacroContext) => ComponentChildren;
511
+ /** The parameters: read into `ctx.args`, and shown by tooling. */
512
+ parameters?: P;
513
+ render: (
514
+ props: MacroProps,
515
+ ctx: MacroContext<MacroArgs<P>>,
516
+ ) => ComponentChildren;
457
517
  /**
458
518
  * The macro's text form, used where markup becomes a string: HTML
459
519
  * attribute values, image alt text and link titles, macro labels. Without
460
520
  * one, the macro can't be used there and is reported as an error.
461
521
  */
462
- text?: (props: MacroProps, ctx: MacroTextContext) => string;
522
+ text?: (
523
+ props: MacroProps,
524
+ ctx: MacroTextContext & { args: MacroArgs<P> },
525
+ ) => string;
463
526
  }
464
527
 
465
528
  /**
@@ -1,7 +1,39 @@
1
+ /**
2
+ * How a macro argument is read into `ctx.args`. Quoted strings accept `\"`,
3
+ * `\'` and `\\` escapes.
4
+ * - `expression`: code, as written (the default).
5
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
6
+ * - `string`: one quoted string; anything else leaves the argument unset.
7
+ * - `text`: one quoted string, or text with any loose quotes stripped.
8
+ * - `names`: a comma-separated list of names (`@item, @i`).
9
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
10
+ * - `number`: a number.
11
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
12
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
13
+ * from those after it; a boolean.
14
+ * - `options`: keywords, the names of its `parameters`, each followed by a
15
+ * quoted string or a number unless it is a flag.
16
+ */
17
+ export type ParameterType =
18
+ | 'expression'
19
+ | 'variable'
20
+ | 'string'
21
+ | 'text'
22
+ | 'names'
23
+ | 'delay'
24
+ | 'number'
25
+ | 'flag'
26
+ | 'separator'
27
+ | 'options';
28
+
1
29
  export interface ParameterDef {
2
30
  name: string;
3
31
  required?: boolean;
4
32
  description?: string;
33
+ /** How the argument is read (default `expression`). */
34
+ type?: ParameterType;
35
+ /** The options of an `options` parameter. */
36
+ parameters?: readonly ParameterDef[];
5
37
  }
6
38
 
7
39
  export interface MacroMetadata {
@@ -24,7 +56,7 @@ export interface MacroDefinition {
24
56
  merged?: boolean;
25
57
  storeVar?: boolean;
26
58
  description?: string;
27
- parameters?: ParameterDef[];
59
+ parameters?: readonly ParameterDef[];
28
60
  render: (...args: any[]) => any;
29
61
  text?: (...args: any[]) => string;
30
62
  }