Skip to content

Commit fe7eb0c

Browse files
seefeldbclaude
andcommitted
feat(api): Allow recipe() to be called with just a function
The recipe() function now accepts just a function parameter without requiring a name or schema. This provides a more ergonomic API for simple recipes where the schema can be inferred from default values. Changes: - Added function-only overload to recipe() that accepts just a function - Updated implementation to handle undefined argumentSchema - Refactored schema creation logic to eliminate code duplication - Schema generated from function-only recipes does not have a description field - Added tests for function-only recipe syntax - Added transformer fixtures for recipes without names Usage: recipe((input: { x: any }) => { input.x.setDefault(42); return { result: input.x * 2 }; }); 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a5ad113 commit fe7eb0c

File tree

9 files changed

+218
-12
lines changed

9 files changed

+218
-12
lines changed

packages/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,11 @@ export interface BuiltInCompileAndRunState<T> {
725725

726726
// Function type definitions
727727
export type RecipeFunction = {
728+
// Function-only overload
729+
<T, R>(
730+
fn: (input: OpaqueRef<Required<T>>) => Opaque<R>,
731+
): RecipeFactory<T, R>;
732+
728733
<S extends JSONSchema>(
729734
argumentSchema: S,
730735
fn: (input: OpaqueRef<Required<SchemaWithoutCell<S>>>) => any,

packages/runner/src/builder/recipe.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ import { traverseValue } from "./traverse-utils.ts";
3737
import { sanitizeSchemaForLinks } from "../link-utils.ts";
3838

3939
/** Declare a recipe
40+
*
41+
* @param fn A function that creates the recipe graph
42+
*
43+
* or
4044
*
4145
* @param description A human-readable description of the recipe
4246
* @param fn A function that creates the recipe graph
@@ -83,16 +87,27 @@ export function recipe<T, R>(
8387
resultSchema: JSONSchema,
8488
fn: (input: OpaqueRef<Required<T>>) => Opaque<R>,
8589
): RecipeFactory<T, R>;
90+
// Function-only overload - must come after schema-based overloads
8691
export function recipe<T, R>(
87-
argumentSchema: string | JSONSchema,
88-
resultSchema:
92+
fn: (input: OpaqueRef<Required<T>>) => Opaque<R>,
93+
): RecipeFactory<T, R>;
94+
export function recipe<T, R>(
95+
argumentSchema:
96+
| string
97+
| JSONSchema
98+
| ((input: OpaqueRef<Required<T>>) => Opaque<R>),
99+
resultSchema?:
89100
| JSONSchema
90-
| undefined
91101
| ((input: OpaqueRef<Required<T>>) => Opaque<R>),
92102
fn?: (input: OpaqueRef<Required<T>>) => Opaque<R>,
93103
): RecipeFactory<T, R> {
94-
// Cover the overload that just provides input schema
95-
if (typeof resultSchema === "function") {
104+
// Cover the overload that just provides a function
105+
if (typeof argumentSchema === "function") {
106+
fn = argumentSchema;
107+
argumentSchema = undefined as any;
108+
resultSchema = undefined;
109+
} // Cover the overload that just provides input schema
110+
else if (typeof resultSchema === "function") {
96111
fn = resultSchema;
97112
resultSchema = undefined;
98113
}
@@ -114,8 +129,8 @@ export function recipe<T, R>(
114129
applyInputIfcToOutput(inputs, outputs);
115130

116131
const result = factoryFromRecipe<T, R>(
117-
argumentSchema,
118-
resultSchema,
132+
argumentSchema as string | JSONSchema | undefined,
133+
resultSchema as JSONSchema | undefined,
119134
inputs,
120135
outputs,
121136
);
@@ -125,7 +140,7 @@ export function recipe<T, R>(
125140

126141
// Same as above, but assumes the caller manages the frame
127142
export function recipeFromFrame<T, R>(
128-
argumentSchema: string | JSONSchema,
143+
argumentSchema: string | JSONSchema | undefined,
129144
resultSchema: JSONSchema | undefined,
130145
fn: (input: OpaqueRef<Required<T>>) => Opaque<R>,
131146
): RecipeFactory<T, R> {
@@ -140,7 +155,7 @@ export function recipeFromFrame<T, R>(
140155
}
141156

142157
function factoryFromRecipe<T, R>(
143-
argumentSchemaArg: string | JSONSchema,
158+
argumentSchemaArg: string | JSONSchema | undefined,
144159
resultSchemaArg: JSONSchema | undefined,
145160
inputs: OpaqueRef<Required<T>>,
146161
outputs: Opaque<R>,
@@ -298,10 +313,14 @@ function factoryFromRecipe<T, R>(
298313

299314
let argumentSchema: JSONSchema;
300315

301-
if (typeof argumentSchemaArg === "string") {
302-
// Create a writable schema
316+
if (typeof argumentSchemaArg === "string" || argumentSchemaArg === undefined) {
317+
// Create a writable schema from defaults
303318
const writableSchema: JSONSchemaMutable = createJsonSchema(defaults, true);
304-
writableSchema.description = argumentSchemaArg;
319+
320+
// Set description only if provided
321+
if (typeof argumentSchemaArg === "string") {
322+
writableSchema.description = argumentSchemaArg;
323+
}
305324

306325
delete (writableSchema.properties as any)?.[UI]; // TODO(seefeld): This should be a schema for views
307326
if (

packages/runner/test/recipe.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,3 +493,45 @@ describe("recipe with mixed ifc properties", () => {
493493
// in such a way that it does not end up classified. For now, I've decided
494494
// not to do this, since I'm not confident enough that code can't get out.
495495
});
496+
497+
describe("recipe with function-only syntax (no schema)", () => {
498+
it("creates a recipe with just a function", () => {
499+
const doubleRecipe = recipe((input: { x: any }) => {
500+
const double = lift<number>((x) => x * 2);
501+
return { double: double(input.x) };
502+
});
503+
expect(isRecipe(doubleRecipe)).toBe(true);
504+
});
505+
506+
it("infers schema from default values", () => {
507+
const doubleRecipe = recipe((input: { x: any }) => {
508+
input.x.setDefault(42);
509+
const double = lift<number>((x) => x * 2);
510+
return { double: double(input.x) };
511+
});
512+
513+
expect(isRecipe(doubleRecipe)).toBe(true);
514+
expect(doubleRecipe.argumentSchema).toMatchObject({
515+
type: "object",
516+
properties: {
517+
x: { type: "integer", default: 42 },
518+
},
519+
});
520+
// Should not have a description field
521+
expect(doubleRecipe.argumentSchema).not.toHaveProperty("description");
522+
});
523+
524+
it("creates nodes correctly with function-only syntax", () => {
525+
const doubleRecipe = recipe((input: { x: any }) => {
526+
input.x.setDefault(1);
527+
const double = lift<number>((x) => x * 2);
528+
return { double: double(double(input.x)) };
529+
});
530+
531+
const { nodes } = doubleRecipe;
532+
expect(nodes.length).toBe(2);
533+
expect(isModule(nodes[0].module) && nodes[0].module.type).toBe(
534+
"javascript",
535+
);
536+
});
537+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as __ctHelpers from "commontools";
2+
import { cell, recipe, UI } from "commontools";
3+
export default recipe((_state: any) => {
4+
const items = cell([1, 2, 3, 4, 5]);
5+
return {
6+
[UI]: (<div>
7+
{items.mapWithPattern(__ctHelpers.recipe({
8+
$schema: "https://json-schema.org/draft/2020-12/schema",
9+
type: "object",
10+
properties: {
11+
element: {
12+
type: "number"
13+
},
14+
index: {
15+
type: "number"
16+
},
17+
array: true,
18+
params: {
19+
type: "object",
20+
properties: {}
21+
}
22+
},
23+
required: ["element", "params"]
24+
} as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: index, array: array, params: {} }) => (<div>
25+
Item {item} at index {index} of {array.length} total items
26+
</div>)), {})}
27+
</div>),
28+
};
29+
});
30+
// @ts-ignore: Internals
31+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
32+
// @ts-ignore: Internals
33+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/// <cts-enable />
2+
import { cell, recipe, UI } from "commontools";
3+
4+
export default recipe((_state: any) => {
5+
const items = cell([1, 2, 3, 4, 5]);
6+
7+
return {
8+
[UI]: (
9+
<div>
10+
{items.map((item, index, array) => (
11+
<div>
12+
Item {item} at index {index} of {array.length} total items
13+
</div>
14+
))}
15+
</div>
16+
),
17+
};
18+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as __ctHelpers from "commontools";
2+
import { cell, recipe, UI } from "commontools";
3+
export default recipe((_state: any) => {
4+
const items = cell([{ name: "apple" }, { name: "banana" }]);
5+
const showList = cell(true);
6+
return {
7+
[UI]: (<div>
8+
{__ctHelpers.derive({
9+
showList: showList,
10+
items: items
11+
}, ({ showList, items }) => showList && (<div>
12+
{items.map((item) => (<div>
13+
{__ctHelpers.derive({ item: {
14+
name: item.name
15+
} }, ({ item }) => item.name && <span>{item.name}</span>)}
16+
</div>))}
17+
</div>))}
18+
</div>),
19+
};
20+
});
21+
// @ts-ignore: Internals
22+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
23+
// @ts-ignore: Internals
24+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/// <cts-enable />
2+
import { cell, recipe, UI } from "commontools";
3+
4+
export default recipe((_state: any) => {
5+
const items = cell([{ name: "apple" }, { name: "banana" }]);
6+
const showList = cell(true);
7+
8+
return {
9+
[UI]: (
10+
<div>
11+
{showList && (
12+
<div>
13+
{items.map((item) => (
14+
<div>
15+
{item.name && <span>{item.name}</span>}
16+
</div>
17+
))}
18+
</div>
19+
)}
20+
</div>
21+
),
22+
};
23+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as __ctHelpers from "commontools";
2+
import { cell, recipe, UI } from "commontools";
3+
export default recipe((_state: any) => {
4+
const people = cell([
5+
{ id: "1", name: "Alice" },
6+
{ id: "2", name: "Bob" },
7+
]);
8+
return {
9+
[UI]: (<div>
10+
{__ctHelpers.derive(people, ({ people }) => people.length > 0 && (<ul>
11+
{people.map((person) => (<li key={person.id}>{person.name}</li>))}
12+
</ul>))}
13+
</div>),
14+
};
15+
});
16+
// @ts-ignore: Internals
17+
function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
18+
// @ts-ignore: Internals
19+
h.fragment = __ctHelpers.h.fragment;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/// <cts-enable />
2+
import { cell, recipe, UI } from "commontools";
3+
4+
export default recipe((_state: any) => {
5+
const people = cell([
6+
{ id: "1", name: "Alice" },
7+
{ id: "2", name: "Bob" },
8+
]);
9+
10+
return {
11+
[UI]: (
12+
<div>
13+
{people.length > 0 && (
14+
<ul>
15+
{people.map((person) => (
16+
<li key={person.id}>{person.name}</li>
17+
))}
18+
</ul>
19+
)}
20+
</div>
21+
),
22+
};
23+
});

0 commit comments

Comments
 (0)