Skip to content

Commit 00abd17

Browse files
authored
feat: registry-based macro dispatch (#31)
## Summary - Replace 370-line switch statement in `renderMacro()` with `getMacro()`/`isSubMacro()` registry lookups (render.tsx: 627→258 lines) - Each of 34 macro components self-registers via `registerMacro()` at module level - Sub-macros (`option`, `case`, `default`, `next`) registered via `registerSubMacro()` - `MacroProps` extended with `children?: ASTNode[]` and `branches?: Branch[]` to cover all macro prop shapes - New `src/macros/register-builtins.ts` import manifest triggers all registrations - Updated custom macro docs to reflect new `children` type (raw AST nodes instead of pre-rendered) Closes #21 ## Test plan - [x] `npx tsc --noEmit` passes - [x] `npx vitest run` — all 656 tests pass - [x] `npm run test:e2e` — all 177 e2e tests pass - [x] Every case from old switch has a corresponding `registerMacro()` call - [x] All sub-macro names have `registerSubMacro()` calls release-npm 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 33ab1a1 + 577c529 commit 00abd17

40 files changed

+284
-648
lines changed

docs/custom-macros.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Call `Story.registerMacro()` from a `{do}` block in `StoryInit`:
1010
:: StoryInit
1111
{do}
1212
Story.registerMacro("alert", (props) => {
13-
return <div class="alert">{props.children}</div>;
13+
return <div class="alert">{renderNodes(props.children ?? [])}</div>;
1414
});
1515
{/do}
1616
```
@@ -27,12 +27,13 @@ Macro names are case-insensitive: `{alert}`, `{Alert}`, and `{ALERT}` all resolv
2727

2828
Every custom macro receives these props:
2929

30-
| Prop | Type | Description |
31-
| ----------- | -------------------------- | ----------------------------------------------------------------------------------- |
32-
| `rawArgs` | `string` | The raw argument string after the macro name, e.g. `"$x + 1"` in `{mymacro $x + 1}` |
33-
| `className` | `string \| undefined` | CSS class from selector syntax: `{.highlight mymacro}` |
34-
| `id` | `string \| undefined` | CSS id from selector syntax: `{#foo mymacro}` |
35-
| `children` | `preact.ComponentChildren` | Rendered child content (for block macros with `{/mymacro}`) |
30+
| Prop | Type | Description |
31+
| ----------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
32+
| `rawArgs` | `string` | The raw argument string after the macro name, e.g. `"$x + 1"` in `{mymacro $x + 1}` |
33+
| `className` | `string \| undefined` | CSS class from selector syntax: `{.highlight mymacro}` |
34+
| `id` | `string \| undefined` | CSS id from selector syntax: `{#foo mymacro}` |
35+
| `children` | `ASTNode[] \| undefined` | Raw AST child nodes (for block macros with `{/mymacro}`). Render with `renderNodes(children)` or `renderInlineNodes(children)`. |
36+
| `branches` | `Branch[] \| undefined` | Branch nodes from sub-macros like `{case}`, `{default}`, `{next}`. Only present for macros that define branching sub-macros. |
3637

3738
## Reading State
3839

@@ -114,7 +115,9 @@ function MyButton({ rawArgs, children }) {
114115
}
115116
};
116117

117-
return <button onClick={handleClick}>{children}</button>;
118+
return (
119+
<button onClick={handleClick}>{renderInlineNodes(children ?? [])}</button>
120+
);
118121
}
119122
```
120123
@@ -184,7 +187,9 @@ const { update, getValues } = useContext(LocalsUpdateContext);
184187
const handleClick = () => {
185188
executeMutation(rawArgs, stripLocalsPrefix(getValues()), update);
186189
};
187-
return <button onClick={handleClick}>{children}</button>;
190+
return (
191+
<button onClick={handleClick}>{renderInlineNodes(children ?? [])}</button>
192+
);
188193
```
189194
190195
## Variable Namespaces at a Glance
@@ -212,7 +217,7 @@ function MyOutput({ rawArgs, className, id, children }) {
212217
id={id}
213218
class={className}
214219
>
215-
{children}
220+
{renderNodes(children ?? [])}
216221
</div>
217222
);
218223
}
@@ -246,7 +251,7 @@ A `{confirm}` macro that shows a confirmation dialog before executing code:
246251

247252
return (
248253
<button id={props.id} class={cls} onClick={handleClick}>
249-
{props.children}
254+
{renderInlineNodes(props.children ?? [])}
250255
</button>
251256
);
252257
});

src/components/macros/Back.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { useStoryStore } from '../../store';
22
import { useAction } from '../../hooks/use-action';
3+
import { registerMacro } from '../../registry';
4+
import type { MacroProps } from '../../registry';
35

4-
interface BackProps {
5-
className?: string;
6-
id?: string;
7-
}
8-
9-
export function Back({ className, id }: BackProps) {
6+
export function Back({ className, id }: MacroProps) {
107
const goBack = useStoryStore((s) => s.goBack);
118
const canGoBack = useStoryStore((s) => s.historyIndex > 0);
129
const cls = className ? `menubar-button ${className}` : 'menubar-button';
@@ -31,3 +28,5 @@ export function Back({ className, id }: BackProps) {
3128
</button>
3229
);
3330
}
31+
32+
registerMacro('back', Back);

src/components/macros/Button.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,10 @@ import { useAction } from '../../hooks/use-action';
55
import { useInterpolate } from '../../hooks/use-interpolate';
66
import { executeMutation } from '../../execute-mutation';
77
import { currentSourceLocation } from '../../utils/source-location';
8-
import type { ASTNode } from '../../markup/ast';
8+
import { registerMacro } from '../../registry';
9+
import type { MacroProps } from '../../registry';
910

10-
interface ButtonProps {
11-
rawArgs: string;
12-
children: ASTNode[];
13-
className?: string;
14-
id?: string;
15-
}
16-
17-
export function Button({ rawArgs, children, className, id }: ButtonProps) {
11+
export function Button({ rawArgs, children = [], className, id }: MacroProps) {
1812
const resolve = useInterpolate();
1913
className = resolve(className);
2014
id = resolve(id);
@@ -51,3 +45,5 @@ export function Button({ rawArgs, children, className, id }: ButtonProps) {
5145
</button>
5246
);
5347
}
48+
49+
registerMacro('button', Button);

src/components/macros/Checkbox.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { useStoryStore } from '../../store';
22
import { useAction } from '../../hooks/use-action';
3-
4-
interface CheckboxProps {
5-
rawArgs: string;
6-
className?: string;
7-
id?: string;
8-
}
3+
import { registerMacro } from '../../registry';
4+
import type { MacroProps } from '../../registry';
95

106
function parseArgs(rawArgs: string): { varName: string; label: string } {
117
const match = rawArgs.match(/^\s*(["']?\$\w+["']?)\s+["']?(.+?)["']?\s*$/);
@@ -17,7 +13,7 @@ function parseArgs(rawArgs: string): { varName: string; label: string } {
1713
return { varName, label };
1814
}
1915

20-
export function Checkbox({ rawArgs, className, id }: CheckboxProps) {
16+
export function Checkbox({ rawArgs, className, id }: MacroProps) {
2117
const { varName, label } = parseArgs(rawArgs);
2218
const name = varName.startsWith('$') ? varName.slice(1) : varName;
2319

@@ -50,3 +46,5 @@ export function Checkbox({ rawArgs, className, id }: CheckboxProps) {
5046
</label>
5147
);
5248
}
49+
50+
registerMacro('checkbox', Checkbox);

src/components/macros/Computed.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import { useStoryStore } from '../../store';
33
import { evaluate } from '../../expression';
44
import { useMergedLocals } from '../../hooks/use-merged-locals';
55
import { currentSourceLocation } from '../../utils/source-location';
6-
7-
interface ComputedProps {
8-
rawArgs: string;
9-
}
6+
import { registerMacro } from '../../registry';
7+
import type { MacroProps } from '../../registry';
108

119
function parseComputedArgs(rawArgs: string): { target: string; expr: string } {
1210
const trimmed = rawArgs.trim();
@@ -87,7 +85,7 @@ function computeAndApply(
8785
}
8886
}
8987

90-
export function Computed({ rawArgs }: ComputedProps) {
88+
export function Computed({ rawArgs }: MacroProps) {
9189
const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals();
9290

9391
let target: string;
@@ -138,3 +136,5 @@ export function Computed({ rawArgs }: ComputedProps) {
138136

139137
return null;
140138
}
139+
140+
registerMacro('computed', Computed);

src/components/macros/Cycle.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import { useStoryStore } from '../../store';
22
import { extractOptions } from './option-utils';
33
import { useAction } from '../../hooks/use-action';
4-
import type { ASTNode } from '../../markup/ast';
4+
import { registerMacro } from '../../registry';
5+
import type { MacroProps } from '../../registry';
56

6-
interface CycleProps {
7-
rawArgs: string;
8-
children: ASTNode[];
9-
className?: string;
10-
id?: string;
11-
}
12-
13-
export function Cycle({ rawArgs, children, className, id }: CycleProps) {
7+
export function Cycle({ rawArgs, children = [], className, id }: MacroProps) {
148
const varName = rawArgs.trim().replace(/["']/g, '');
159
const name = varName.startsWith('$') ? varName.slice(1) : varName;
1610

@@ -55,3 +49,5 @@ export function Cycle({ rawArgs, children, className, id }: CycleProps) {
5549
</button>
5650
);
5751
}
52+
53+
registerMacro('cycle', Cycle);

src/components/macros/Do.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { useLayoutEffect, useContext } from 'preact/hooks';
2-
import type { ASTNode } from '../../markup/ast';
32
import { LocalsUpdateContext } from '../../markup/render';
43
import { executeMutation } from '../../execute-mutation';
54
import { currentSourceLocation } from '../../utils/source-location';
65
import { collectText } from '../../utils/extract-text';
6+
import { registerMacro } from '../../registry';
7+
import type { MacroProps } from '../../registry';
78

8-
interface DoProps {
9-
children: ASTNode[];
10-
}
11-
12-
export function Do({ children }: DoProps) {
9+
export function Do({ children = [] }: MacroProps) {
1310
const code = collectText(children);
1411
const { update, getValues } = useContext(LocalsUpdateContext);
1512

@@ -23,3 +20,5 @@ export function Do({ children }: DoProps) {
2320

2421
return null;
2522
}
23+
24+
registerMacro('do', Do);

src/components/macros/For.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,10 @@ import {
1414
import { useMergedLocals } from '../../hooks/use-merged-locals';
1515
import { useInterpolate } from '../../hooks/use-interpolate';
1616
import { currentSourceLocation } from '../../utils/source-location';
17+
import { registerMacro } from '../../registry';
18+
import type { MacroProps } from '../../registry';
1719
import type { ASTNode } from '../../markup/ast';
1820

19-
interface ForProps {
20-
rawArgs: string;
21-
children: ASTNode[];
22-
className?: string;
23-
id?: string;
24-
}
25-
2621
/**
2722
* Parse for-loop args: "@item, @i of $list" or "@item of $list"
2823
*/
@@ -94,7 +89,7 @@ function ForIteration({
9489
);
9590
}
9691

97-
export function For({ rawArgs, children, className, id }: ForProps) {
92+
export function For({ rawArgs, children = [], className, id }: MacroProps) {
9893
const resolve = useInterpolate();
9994
className = resolve(className);
10095
id = resolve(id);
@@ -167,3 +162,5 @@ export function For({ rawArgs, children, className, id }: ForProps) {
167162
);
168163
return <>{content}</>;
169164
}
165+
166+
registerMacro('for', For);

src/components/macros/Forward.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { useStoryStore } from '../../store';
22
import { useAction } from '../../hooks/use-action';
3+
import { registerMacro } from '../../registry';
4+
import type { MacroProps } from '../../registry';
35

4-
interface ForwardProps {
5-
className?: string;
6-
id?: string;
7-
}
8-
9-
export function Forward({ className, id }: ForwardProps) {
6+
export function Forward({ className, id }: MacroProps) {
107
const goForward = useStoryStore((s) => s.goForward);
118
const canGoForward = useStoryStore(
129
(s) => s.historyIndex < s.history.length - 1,
@@ -33,3 +30,5 @@ export function Forward({ className, id }: ForwardProps) {
3330
</button>
3431
);
3532
}
33+
34+
registerMacro('forward', Forward);

src/components/macros/Goto.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@ import { useLayoutEffect } from 'preact/hooks';
22
import { useStoryStore } from '../../store';
33
import { evaluate } from '../../expression';
44
import { useMergedLocals } from '../../hooks/use-merged-locals';
5+
import { registerMacro } from '../../registry';
6+
import type { MacroProps } from '../../registry';
57

6-
interface GotoProps {
7-
rawArgs: string;
8-
}
9-
10-
export function Goto({ rawArgs }: GotoProps) {
8+
export function Goto({ rawArgs }: MacroProps) {
119
const [variables, temporary, locals] = useMergedLocals();
1210

1311
useLayoutEffect(() => {
@@ -23,3 +21,5 @@ export function Goto({ rawArgs }: GotoProps) {
2321

2422
return null;
2523
}
24+
25+
registerMacro('goto', Goto);

0 commit comments

Comments
 (0)