Skip to content

Commit b56528f

Browse files
seefeldbclaude
andcommitted
feat(ts-transformers): Support single-parameter recipe() calls
Add transformer support for recipe() calls without explicit type arguments. When recipe() is called with just a function parameter that has a type annotation, the transformer now infers and injects the schema automatically. Examples: - recipe((state: State) => ...) → recipe(toSchema(State), (state) => ...) - recipe("name", (state: State) => ...) → recipe(toSchema(State), (state) => ...) This change maintains backward compatibility by only transforming when an explicit type annotation is present on the function parameter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1d70899 commit b56528f

11 files changed

+349
-5
lines changed

packages/ts-transformers/src/transformers/schema-injection.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,10 @@ export class SchemaInjectionTransformer extends Transformer {
170170
const callKind = detectCallKind(node, checker);
171171

172172
if (callKind?.kind === "builder" && callKind.builderName === "recipe") {
173+
const factory = transformation.factory;
173174
const typeArgs = node.typeArguments;
175+
174176
if (typeArgs && typeArgs.length >= 1) {
175-
const factory = transformation.factory;
176177
const schemaArgs = typeArgs.map((typeArg) => typeArg).map((
177178
typeArg,
178179
) => createToSchemaCall(context, typeArg));
@@ -194,6 +195,43 @@ export class SchemaInjectionTransformer extends Transformer {
194195

195196
return ts.visitEachChild(updated, visit, transformation);
196197
}
198+
199+
// Handle single-parameter recipe() calls without type arguments
200+
// Only transform if the function parameter has a type annotation
201+
const argsArray = Array.from(node.arguments);
202+
let recipeFunction: ts.Expression | undefined;
203+
204+
if (argsArray.length === 1) {
205+
// Single argument - must be the function
206+
recipeFunction = argsArray[0];
207+
} else if (argsArray.length === 2 && argsArray[0] && ts.isStringLiteral(argsArray[0])) {
208+
// Two arguments with first being a string - second is the function
209+
recipeFunction = argsArray[1];
210+
}
211+
212+
if (
213+
recipeFunction &&
214+
(ts.isFunctionExpression(recipeFunction) ||
215+
ts.isArrowFunction(recipeFunction))
216+
) {
217+
const recipeFn = recipeFunction;
218+
if (recipeFn.parameters.length >= 1) {
219+
const inputParam = recipeFn.parameters[0];
220+
221+
// Only transform if there's an explicit type annotation
222+
if (inputParam?.type) {
223+
const toSchemaInput = createToSchemaCall(context, inputParam.type);
224+
225+
const updated = factory.createCallExpression(
226+
node.expression,
227+
undefined,
228+
[toSchemaInput, recipeFn],
229+
);
230+
231+
return ts.visitEachChild(updated, visit, transformation);
232+
}
233+
}
234+
}
197235
}
198236

199237
if (

packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,20 @@ export default recipe({
4646
Add
4747
</button>
4848
<ul>
49-
{state.items.map((item) => <li key={item}>{item}</li>)}
49+
{state.items.mapWithPattern(__ctHelpers.recipe({
50+
$schema: "https://json-schema.org/draft/2020-12/schema",
51+
type: "object",
52+
properties: {
53+
element: {
54+
type: "string"
55+
},
56+
params: {
57+
type: "object",
58+
properties: {}
59+
}
60+
},
61+
required: ["element", "params"]
62+
} as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => <li key={item}>{item}</li>), {})}
5063
</ul>
5164
</div>),
5265
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as __ctHelpers from "commontools";
2+
import { Cell, Default, handler, recipe, UI } from "commontools";
3+
interface Item {
4+
text: Default<string, "">;
5+
}
6+
interface InputSchema {
7+
items: Default<Item[], [
8+
]>;
9+
}
10+
const removeItem = handler(true as const satisfies __ctHelpers.JSONSchema, {
11+
$schema: "https://json-schema.org/draft/2020-12/schema",
12+
type: "object",
13+
properties: {
14+
items: {
15+
type: "array",
16+
items: {
17+
$ref: "#/$defs/Item"
18+
},
19+
asCell: true
20+
},
21+
index: {
22+
type: "number"
23+
}
24+
},
25+
required: ["items", "index"],
26+
$defs: {
27+
Item: {
28+
type: "object",
29+
properties: {
30+
text: {
31+
type: "string",
32+
default: ""
33+
}
34+
},
35+
required: ["text"]
36+
}
37+
}
38+
} as const satisfies __ctHelpers.JSONSchema, (_, _2) => {
39+
// Not relevant for repro
40+
});
41+
export default recipe({
42+
$schema: "https://json-schema.org/draft/2020-12/schema",
43+
type: "object",
44+
properties: {
45+
items: {
46+
type: "array",
47+
items: {
48+
$ref: "#/$defs/Item"
49+
},
50+
default: []
51+
}
52+
},
53+
required: ["items"],
54+
$defs: {
55+
Item: {
56+
type: "object",
57+
properties: {
58+
text: {
59+
type: "string",
60+
default: ""
61+
}
62+
},
63+
required: ["text"]
64+
}
65+
}
66+
} as const satisfies __ctHelpers.JSONSchema, ({ items }: InputSchema) => {
67+
return {
68+
[UI]: (<ul>
69+
{items.map((_, index) => (<li key={index}>
70+
<ct-button onClick={removeItem({ items, index })}>
71+
Remove
72+
</ct-button>
73+
</li>))}
74+
</ul>),
75+
};
76+
});
77+
// @ts-ignore: Internals
78+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
79+
// @ts-ignore: Internals
80+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/// <cts-enable />
2+
import { Cell, Default, handler, recipe, UI } from "commontools";
3+
4+
interface Item {
5+
text: Default<string, "">;
6+
}
7+
8+
interface InputSchema {
9+
items: Default<Item[], []>;
10+
}
11+
12+
const removeItem = handler<unknown, { items: Cell<Item[]>; index: number }>(
13+
(_, _2) => {
14+
// Not relevant for repro
15+
},
16+
);
17+
18+
export default recipe(
19+
({ items }: InputSchema) => {
20+
return {
21+
[UI]: (
22+
<ul>
23+
{items.map((_, index) => (
24+
<li key={index}>
25+
<ct-button onClick={removeItem({ items, index })}>
26+
Remove
27+
</ct-button>
28+
</li>
29+
))}
30+
</ul>
31+
),
32+
};
33+
},
34+
);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as __ctHelpers from "commontools";
2+
import { recipe, UI, handler, Cell } from "commontools";
3+
declare global {
4+
namespace JSX {
5+
interface IntrinsicElements {
6+
"ct-button": any;
7+
}
8+
}
9+
}
10+
// Event handler defined at module scope
11+
const handleClick = handler(true as const satisfies __ctHelpers.JSONSchema, {
12+
type: "object",
13+
properties: {
14+
count: {
15+
type: "number",
16+
asCell: true
17+
}
18+
},
19+
required: ["count"]
20+
} as const satisfies __ctHelpers.JSONSchema, (_, { count }) => {
21+
count.set(count.get() + 1);
22+
});
23+
interface Item {
24+
id: number;
25+
name: string;
26+
}
27+
interface State {
28+
items: Item[];
29+
count: Cell<number>;
30+
}
31+
export default recipe({
32+
$schema: "https://json-schema.org/draft/2020-12/schema",
33+
type: "object",
34+
properties: {
35+
items: {
36+
type: "array",
37+
items: {
38+
$ref: "#/$defs/Item"
39+
}
40+
},
41+
count: {
42+
type: "number",
43+
asCell: true
44+
}
45+
},
46+
required: ["items", "count"],
47+
$defs: {
48+
Item: {
49+
type: "object",
50+
properties: {
51+
id: {
52+
type: "number"
53+
},
54+
name: {
55+
type: "string"
56+
}
57+
},
58+
required: ["id", "name"]
59+
}
60+
}
61+
} as const satisfies __ctHelpers.JSONSchema, (state: State) => {
62+
return {
63+
[UI]: (<div>
64+
{/* Map callback references handler - should NOT capture it */}
65+
{state.items.map((item) => (<ct-button onClick={handleClick({ count: state.count })}>
66+
{item.name}
67+
</ct-button>))}
68+
</div>),
69+
};
70+
});
71+
// @ts-ignore: Internals
72+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
73+
// @ts-ignore: Internals
74+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/// <cts-enable />
2+
import { recipe, UI, handler, Cell } from "commontools";
3+
4+
declare global {
5+
namespace JSX {
6+
interface IntrinsicElements {
7+
"ct-button": any;
8+
}
9+
}
10+
}
11+
12+
// Event handler defined at module scope
13+
const handleClick = handler<unknown, { count: Cell<number> }>((_, { count }) => {
14+
count.set(count.get() + 1);
15+
});
16+
17+
interface Item {
18+
id: number;
19+
name: string;
20+
}
21+
22+
interface State {
23+
items: Item[];
24+
count: Cell<number>;
25+
}
26+
27+
export default recipe((state: State) => {
28+
return {
29+
[UI]: (
30+
<div>
31+
{/* Map callback references handler - should NOT capture it */}
32+
{state.items.map((item) => (
33+
<ct-button onClick={handleClick({ count: state.count })}>
34+
{item.name}
35+
</ct-button>
36+
))}
37+
</div>
38+
),
39+
};
40+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as __ctHelpers from "commontools";
2+
import { recipe, UI } from "commontools";
3+
interface State {
4+
items: Array<{
5+
price: number;
6+
}>;
7+
discount: number;
8+
}
9+
export default recipe({
10+
type: "object",
11+
properties: {
12+
items: {
13+
type: "array",
14+
items: {
15+
type: "object",
16+
properties: {
17+
price: {
18+
type: "number"
19+
}
20+
},
21+
required: ["price"]
22+
}
23+
},
24+
discount: {
25+
type: "number"
26+
}
27+
},
28+
required: ["items", "discount"]
29+
} as const satisfies __ctHelpers.JSONSchema, (state: State) => {
30+
return {
31+
[UI]: (<div>
32+
{state.items.map((item) => (<span>{__ctHelpers.derive({
33+
item: {
34+
price: item.price
35+
},
36+
state: {
37+
discount: state.discount
38+
}
39+
}, ({ item, state }) => item.price * state.discount)}</span>))}
40+
</div>),
41+
};
42+
});
43+
// @ts-ignore: Internals
44+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
45+
// @ts-ignore: Internals
46+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// <cts-enable />
2+
import { recipe, UI } from "commontools";
3+
4+
interface State {
5+
items: Array<{ price: number }>;
6+
discount: number;
7+
}
8+
9+
export default recipe((state: State) => {
10+
return {
11+
[UI]: (
12+
<div>
13+
{state.items.map((item) => (
14+
<span>{item.price * state.discount}</span>
15+
))}
16+
</div>
17+
),
18+
};
19+
});

packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as __ctHelpers from "commontools";
22
import { cell, recipe, UI } from "commontools";
3-
export default recipe((_state: any) => {
3+
export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: any) => {
44
const items = cell([1, 2, 3, 4, 5]);
55
return {
66
[UI]: (<div>

packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as __ctHelpers from "commontools";
22
import { cell, recipe, UI } from "commontools";
3-
export default recipe((_state: any) => {
3+
export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: any) => {
44
const items = cell([{ name: "apple" }, { name: "banana" }]);
55
const showList = cell(true);
66
return {

0 commit comments

Comments
 (0)