diff --git a/docs/common/COMPONENTS.md b/docs/common/COMPONENTS.md
index 76c16a7e7..e1abe44c3 100644
--- a/docs/common/COMPONENTS.md
+++ b/docs/common/COMPONENTS.md
@@ -6,7 +6,7 @@ A styled button component matching the regular `button` API. Pass a handler to t
type InputSchema = { count: Cell };
type OutputSchema = { count: Cell };
-const MyRecipe = recipe("MyRecipe", ({ count }) => {
+const MyRecipe = recipe(({ count }) => {
const handleClick = handler }>((_event, { count }) => {
count.set(count.get() + 1);
});
@@ -22,7 +22,7 @@ Notice how handlers are bound to the cell from the input schema _in_ the VDOM de
(For even more detail, see `HANDLERS.md`)
-# Bidirectional Binding with $ Prefix
+## Bidirectional Binding with $ Prefix
Many CommonTools components support **bidirectional binding** through the `$` prefix. This powerful feature automatically updates cells when users interact with components, eliminating the need for explicit onChange handlers in most cases.
@@ -96,6 +96,7 @@ const toggle = handler<{detail: {checked: boolean}}, {item: Cell- }>(
```
The bidirectional binding version is:
+
- **Simpler**: No handler definition needed
- **Less code**: One line instead of five
- **More maintainable**: Less surface area for bugs
@@ -123,7 +124,7 @@ const toggle = handler(
one-directional binding.
Note that the actual name for the `onChange` handler may be different depending
-on the component being used. For example,
uses `onct-change`.
+on the component being used. For example, `` uses `onct-change`.
Consult the component for details.
### Validation Example
@@ -152,7 +153,7 @@ const validatedValue = derive(rawInput, (value) => {
This approach separates concerns: bidirectional binding handles the UI sync, while derive handles validation logic.
-# Styling: String vs Object Syntax
+## Styling: String vs Object Syntax
Different element types accept different style syntax in CommonTools JSX. This is a common source of TypeScript errors.
@@ -242,7 +243,7 @@ CommonTools custom elements (`common-hstack`, `common-vstack`, `ct-card`, etc.)
```
-# ct-input
+## ct-input
The `ct-input` component demonstrates bidirectional binding perfectly:
@@ -250,7 +251,7 @@ The `ct-input` component demonstrates bidirectional binding perfectly:
type InputSchema = { value: Cell };
type OutputSchema = { value: Cell };
-const MyRecipe = recipe("MyRecipe", ({ value }) => {
+const MyRecipe = recipe(({ value }: InputSchema) => {
// Option 1: Bidirectional binding (simplest)
const simpleInput = ;
@@ -275,7 +276,7 @@ const MyRecipe = recipe("MyRecipe", ({ value }) => {
Both inputs update the cell, but the second one logs changes. Use the simple bidirectional binding unless you need the extra logic.
-# ct-select
+## ct-select
The `ct-select` component creates a dropdown selector. **Important:** It uses an `items` attribute with an array of `{ label, value }` objects, **not** HTML `` elements.
@@ -284,7 +285,7 @@ type CategoryInput = {
category: Default;
};
-const MyRecipe = recipe("MyRecipe", ({ category }) => {
+const MyRecipe = recipe(({ category }: CategoryInput) => {
return {
[UI]: (
};
-const MyRecipe = recipe("MyRecipe", ({ items }) => {
+const MyRecipe = recipe(({ items }: ListSchema) => {
return {
[UI]:
@@ -418,16 +419,18 @@ interface ShoppingItem {
## Trade-offs
**Use ct-list when:**
+
- Your items only need `title` and optionally `done`
- You want a quick, pre-styled list component
- You don't need custom rendering
**Use manual rendering when:**
+
- You have custom fields
- You need custom styling or layout
- You need fine-grained control over interactions
-# ct-message-input
+## ct-message-input
This component bundles an input and a button to 'send a message' or 'add an item to a list' which is a common pattern. You don't need to worry about the value until submission time.
@@ -449,7 +452,7 @@ const addItem = handler<
/>
````
-# ct-outliner
+## ct-outliner
`ct-outliner` is conceptually similar to `ct-list` except it works on a tree data structure. Below is a demonstration of the minimal use, see `page.tsx` for a more complete example.
@@ -510,7 +513,7 @@ export default recipe
(
);
```
-# ct-render
+## ct-render
The `ct-render` component displays pattern instances within another pattern. Use this for **pattern composition** - combining multiple patterns together in a single recipe.
@@ -571,7 +574,7 @@ interface Input {
items: Default- ;
}
-export default recipe
("Multi-View", ({ items }) => {
+export default recipe(({ items }: Input) => {
// Both patterns receive the same items cell
const listView = ListView({ items });
const gridView = GridView({ items });
@@ -596,6 +599,7 @@ export default recipe ("Multi-View", ({ items }) => {
```
**What happens:**
+
- Both patterns receive the same `items` cell reference
- Changes in ListView automatically appear in GridView
- Changes in GridView automatically appear in ListView
@@ -619,6 +623,7 @@ const counter = Counter({ value: state.value });
```
**When to use each:**
+
- **Direct interpolation** (`{counter}`): Simple cases, most concise
- **JSX component syntax** (` `): When you want it to look like a component
- **ct-render** (` `): When the pattern wasn't instantiated from within this pattern but was passed in or was stored in a list.
diff --git a/docs/common/HANDLERS.md b/docs/common/HANDLERS.md
index 6ca814cac..4aa29bb00 100644
--- a/docs/common/HANDLERS.md
+++ b/docs/common/HANDLERS.md
@@ -99,6 +99,7 @@ const updateTitle = handler<
## Common TypeScript Issues & Fixes
### Handler Function Parameters
+
- **First parameter**: Event data (from UI interactions)
- **Second parameter**: State data (destructured from state schema)
- **Parameter naming**: Use descriptive names or `_` prefix for unused parameters
@@ -273,7 +274,7 @@ const removeItem = handler<
}
});
-export default recipe ("Shopping List", ({ items }) => {
+export default recipe (({ items }) => {
// items here is OpaqueRef
return {
@@ -307,7 +308,7 @@ export default recipe ("Shopping List", ({ items }) => {
**Rule of thumb:** In handler type signatures, use `Cell` for array parameters. The Cell wraps the entire array, not individual elements.
-#### Event Parameter Patterns
+### Event Parameter Patterns
You can handle the event parameter in different ways depending on your needs:
@@ -343,6 +344,7 @@ const simpleIncrement = handler, { count: Cell }>(
## Handler Invocation Patterns
### State Passing
+
When invoking handlers in UI, pass an object that matches your state schema:
```typescript
@@ -357,6 +359,7 @@ onClick={myHandler({ items: itemsArray, currentPage: currentPageString })}
```
### Event vs Props Confusion
+
- **Event type**: Describes data coming FROM the UI event
- **State type**: Describes data the handler needs to ACCESS
- **Invocation object**: Must match the state type, NOT the event type
@@ -364,11 +367,13 @@ onClick={myHandler({ items: itemsArray, currentPage: currentPageString })}
## Debugging Handler Issues
### Type Mismatches
+
1. Check that handler invocation object matches state type
2. Verify event type matches actual UI event structure
3. Ensure destructuring in handler function matches types
### Runtime Issues
+
1. Use `console.log` in handler functions to debug
2. Check that state objects have expected properties
3. Verify UI events are firing correctly
@@ -384,6 +389,7 @@ onClick={myHandler({ items: itemsArray, currentPage: currentPageString })}
## Examples by Use Case
### Counter/Simple State
+
```typescript
const increment = handler, { count: Cell }>(
(_, { count }) => count.set(count.get() + 1)
@@ -391,6 +397,7 @@ const increment = handler, { count: Cell }>(
```
### Form/Input Updates
+
```typescript
const updateField = handler<
{ detail: { value: string } },
@@ -401,6 +408,7 @@ const updateField = handler<
```
### List/Array Operations
+
```typescript
const addItem = handler<
{ message: string },
@@ -412,6 +420,7 @@ const addItem = handler<
```
### Complex State Updates
+
```typescript
const updatePageContent = handler<
{ detail: { value: string } },
@@ -426,6 +435,7 @@ const updatePageContent = handler<
## Advanced Patterns
### Handler Composition
+
```typescript
// Base handlers for reuse
const createTimestamp = () => Date.now();
@@ -491,6 +501,7 @@ const handleFormSubmit = handler<
## Common Pitfalls
### 1. Type Mismatch
+
```typescript
// ❌ Wrong: State type doesn't match invocation
const handler = handler, { count: number }>(
@@ -502,6 +513,7 @@ onClick={handler({ value: 5 })} // Should be { count: 5 }
```
### 2. Event Type Over-specification
+
```typescript
// ❌ Wrong: Over-complicated event type for simple clicks
const handler = handler<{
@@ -518,6 +530,7 @@ const handler = handler, any>(
```
### 3. Mutation vs Immutability
+
```typescript
// ❌ Wrong: Direct assignment to non-cell state
(_, { title }) => {
@@ -538,6 +551,7 @@ const handler = handler, any>(
## Debugging Handlers
### Console-based Debugging
+
```typescript
// In recipes, use console logging for debugging
const addItem = handler<
diff --git a/docs/common/PATTERN_DEV_DEPLOY.md b/docs/common/PATTERN_DEV_DEPLOY.md
index e2e0f3808..bad46eefe 100644
--- a/docs/common/PATTERN_DEV_DEPLOY.md
+++ b/docs/common/PATTERN_DEV_DEPLOY.md
@@ -23,7 +23,7 @@ interface Input {
items: Default- ;
}
-export default recipe
("My Pattern", ({ items }) => {
+export default recipe (({ items }) => {
return {
[NAME]: "My Pattern",
[UI]: (
@@ -79,17 +79,20 @@ const addItem = handler<
#### Common Error Categories
**Type Errors** (see `HANDLERS.md` for details):
+
- Missing `OpaqueRef` annotation in `.map()`
- Wrong style syntax (object vs string, see `COMPONENTS.md`)
- Using `Cell[]>` instead of `Cell` in handlers
- Forgetting `Cell<>` wrapper in handler state types
**Runtime Errors** (see `RECIPES.md` for details):
+
- DOM access (use cells instead)
- Conditionals in JSX (use `ifElse()`)
- Calling `llm()` from handlers (only works in the pattern body)
**Data Not Updating** (see `COMPONENTS.md` for details):
+
- Forgot `$` prefix for bidirectional binding
- Handler event name mismatch
- Cell not passed correctly to handler
@@ -109,7 +112,7 @@ const addItem = handler<
| Error Message | Check |
|---------------|-------|
-| "Property X does not exist on type 'OpaqueRef'" | Missing `OpaqueRef` in `.map()` - See `HANDLERS.md` |
+| "Property X does not exist on type '`OpaqueRef'`" | Missing `OpaqueRef` in `.map()` - See `HANDLERS.md` |
| "Type 'string' is not assignable to type 'CSSProperties'" | Using string style on HTML element - See `COMPONENTS.md` |
| Handler type mismatch | Check `Cell` vs `Cell>>` - See `HANDLERS.md` |
| Data not updating | Missing `$` prefix or wrong event name - See `COMPONENTS.md` |
@@ -119,7 +122,8 @@ const addItem = handler<
When building complex patterns across multiple files:
**Structure:**
-```
+
+```sh
patterns/feature/
main.tsx # Entry point
schemas.tsx # Shared types
@@ -127,11 +131,13 @@ patterns/feature/
```
**Best Practices:**
+
- Use relative imports: `import { Schema } from "./schemas.tsx"`
- Export shared schemas for reuse
- ct bundles all dependencies automatically on deployment
**Common Pitfall:**
+
- Schema mismatches between linked charms
- Solution: Export shared schemas from a common file
@@ -140,6 +146,7 @@ See `PATTERNS.md` Level 3-4 for linking and composition patterns.
### Development Tips
**DO:**
+
- Start simple, add features incrementally
- Use bidirectional binding when possible
- Reference `packages/patterns/` for examples
@@ -147,6 +154,7 @@ See `PATTERNS.md` Level 3-4 for linking and composition patterns.
- Read relevant doc files before asking questions
**DON'T:**
+
- Test syntax before deploying (unless deployment fails)
- Add multiple features before testing
- Use handlers for simple value updates
@@ -154,6 +162,7 @@ See `PATTERNS.md` Level 3-4 for linking and composition patterns.
- Duplicate content from `docs/common/` - reference it instead
## Testing
+
After developing the charm, if you have Playwright MCP, you must test the charm with it unless the user asks you not to.
### Navigate to the Charm URL
@@ -161,7 +170,8 @@ After developing the charm, if you have Playwright MCP, you must test the charm
```javascript
await page.goto("http://localhost:8000//");
```
-Note: Server may be https://toolshed.saga-castor.ts.net instead.
+
+Note: Server may be `https://toolshed.saga-castor.ts.net` instead.
### Register/Login (First Time Only)
@@ -203,6 +213,7 @@ cd /path/to/patterns && deno task ct init
Run `deno task ct --help` and `deno task ct charm --help` to discover available commands.
This tool is used to:
+
- Deploy a pattern as a new charm
- Read/write charm data directly
- Invoke charm handlers for complex operations
@@ -265,6 +276,7 @@ deno task ct charm getsrc -i claude.key -a https://toolshed.saga-castor.ts.net -
### Workflow Resources
Practical ct command workflows for:
+
- Setting up development environment
- Development cycle (deploy, iterate, debug)
- Common tasks (modify, link, visualize)
diff --git a/docs/common/RECIPES.md b/docs/common/RECIPES.md
index f008f630e..a1a5358bf 100644
--- a/docs/common/RECIPES.md
+++ b/docs/common/RECIPES.md
@@ -10,13 +10,12 @@ where recipes are autonomous modules that can import, process, and export data.
### Recipe
-A recipe is the fundamental building block, defined using the `recipe('My Recipe', (input) => {})`
-function. It takes two types and two parameters:
+A recipe is the fundamental building block, defined using the `recipe((input) => {})`
+function. It takes a function parameter:
- Types: define Input and Output relationships for composition with other recipes
- Properties such as `[UI]` and `[NAME]` do not have to be explicitly included in the output type
-- Parameters: a descriptive name and a function that receives the inputs and returns
- outputs
+- Parameters: a function that receives the inputs and returns outputs
### Types and Runtime Safety
@@ -183,12 +182,14 @@ Several utility functions are available:
Available paths:
- **`"/"`**: Access the root space cell and its properties
+
```typescript
const spaceConfig = wish("/config");
const nestedData = wish("/nested/deep/data");
```
- **`"#default"`**: Shortcut for `/defaultPattern` - access your default pattern
+
```typescript
const defaultTitle = wish("#default/title");
const defaultArg = wish("#default/argument/greeting");
@@ -196,29 +197,34 @@ Several utility functions are available:
- **`"#mentionable"`**: Access mentionable items from the backlinks index
(maps to `/defaultPattern/backlinksIndex/mentionable`)
+
```typescript
const mentionable = wish("#mentionable");
const firstMention = wish("#mentionable/0/name");
```
- **`"#recent"`**: Access recently used charms (maps to `/recentCharms`)
+
```typescript
const recentCharms = wish("#recent");
const latestCharm = wish("#recent/0/name");
```
- **`"#allCharms"`**: Access all charms in the system
+
```typescript
const allCharms = wish("#allCharms");
const firstCharm = wish("#allCharms/0/title");
```
- **`"#now"`**: Get the current timestamp (no additional path segments allowed)
+
```typescript
const timestamp = wish("#now");
```
You can also provide a default value as the second argument:
+
```typescript
const items = wish("/myItems", []); // Returns [] if /myItems doesn't exist
```
@@ -410,7 +416,7 @@ logic.
}, {} as Record);
});
- export default recipe("my-recipe", ({ items, name }) => {
+ export default recipe(({ items, name }) => {
const grouped = groupByCategory(items);
return {
[UI]: Add ,
@@ -419,7 +425,7 @@ logic.
});
// ❌ INCORRECT - Inside recipe function
- export default recipe("my-recipe", ({ items, name }) => {
+ export default recipe(({ items, name }) => {
const addItem = handler((_event, { items, name }) => { /* ... */ });
const grouped = lift((items) => { /* ... */ })(items);
// This creates new function instances on each evaluation
@@ -668,33 +674,33 @@ logic.
**Recommendation:** In most cases, direct property access or single-parameter lifts are clearer than multi-parameter curried functions.
15. **Reference Data Instead of Copying**: When transforming data, reference the
- original objects rather than copying all their properties. This maintains
- reactivity and creates cleaner code:
+ original objects rather than copying all their properties. This maintains
+ reactivity and creates cleaner code:
- ```typescript
- // DO THIS: Reference the original data
- const processedItems = items.map((item) => ({
- originalItem: item, // Direct reference
- processed: processItem(item),
- }));
-
- // NOT THIS: Spread/copy all properties
- const processedItems = items.map((item) => ({
- id: item.id, // Copying each field
- name: item.name, // breaks the reactive
- date: item.date, // connection to the
- // ... more fields // original data
- processed: processItem(item),
- }));
- ```
+ ```typescript
+ // DO THIS: Reference the original data
+ const processedItems = items.map((item) => ({
+ originalItem: item, // Direct reference
+ processed: processItem(item),
+ }));
+
+ // NOT THIS: Spread/copy all properties
+ const processedItems = items.map((item) => ({
+ id: item.id, // Copying each field
+ name: item.name, // breaks the reactive
+ date: item.date, // connection to the
+ // ... more fields // original data
+ processed: processItem(item),
+ }));
+ ```
16. **Use Reactive String Templates**: Use the `str` template literal to create
- reactive strings that update when their inputs change:
+ reactive strings that update when their inputs change:
- ```typescript
- const message =
- str`Hello ${user.name}, you have ${notifications.count} notifications`;
- ```
+ ```typescript
+ const message =
+ str`Hello ${user.name}, you have ${notification.count} notifications`;
+ ```
17. **Keep Logic Inside Recipes**: Place as much logic as possible inside recipe
functions or the `map` function. This creates a cleaner reactive system where
@@ -862,6 +868,7 @@ interface Item {
✅ **Use simple interfaces without [ID] for:**
- **Basic lists and CRUD operations**
+
```typescript
interface ShoppingItem {
title: string;
@@ -951,6 +958,7 @@ const removeItem = handler<
```
This works perfectly without [ID] because:
+
- Items are added to the end
- Removal uses item references with `.equals()`
- No cross-network references needed
@@ -986,6 +994,7 @@ const addBacklink = handler<
## Rule of Thumb
**Start without `[ID]`. Only add it if:**
+
1. You're generating new items within a `lift` function that have to be
references elsewhere.
diff --git a/docs/specs/recipe-construction/rollout-plan.md b/docs/specs/recipe-construction/rollout-plan.md
index 69ea80d37..96d6d34a6 100644
--- a/docs/specs/recipe-construction/rollout-plan.md
+++ b/docs/specs/recipe-construction/rollout-plan.md
@@ -89,7 +89,7 @@
- [ ] Have `Cell.set` return itself, so `Cell.for(..).set(..)` works
- [ ] `cell.assign(otherCell)` applies `cell`'s link to `otherCell` if it
doesn't have one yet. Useful to make self-referential loops.
-- [ ] In AST transformation add `.for(variableName, true)` for `const var = `
+- [ ] In AST transformation add `.for(variableName, true)` for `const var =`
cases
- [ ] Update JSON Schema to support new way to describe cells
@@ -101,7 +101,7 @@
- [ ] Add `.remove` and `.removeAll` which removes the element matching the
parameter from the list.
- [ ] Add overload to `.key` that accepts an array of keys
-- [ ] Make name parameter in recipe optional
+- [x] Make name parameter in recipe optional
## Planned Future Work
diff --git a/packages/api/index.ts b/packages/api/index.ts
index 0a1337a11..8d7c7331b 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -725,6 +725,15 @@ export interface BuiltInCompileAndRunState {
// Function type definitions
export type RecipeFunction = {
+ // Function-only overload
+ (
+ fn: (input: OpaqueRef>) => Opaque,
+ ): RecipeFactory;
+
+ (
+ fn: (input: OpaqueRef>) => any,
+ ): RecipeFactory>;
+
(
argumentSchema: S,
fn: (input: OpaqueRef>>) => any,
diff --git a/packages/patterns/array-in-cell-ast-nocomponents.tsx b/packages/patterns/array-in-cell-ast-nocomponents.tsx
index e435e70c6..a68697434 100644
--- a/packages/patterns/array-in-cell-ast-nocomponents.tsx
+++ b/packages/patterns/array-in-cell-ast-nocomponents.tsx
@@ -26,7 +26,7 @@ const addItem = handler(
},
);
-export default recipe("Simple List", ({ title, items }) => {
+export default recipe(({ title, items }) => {
return {
[NAME]: title,
[UI]: (
diff --git a/packages/patterns/charm-ref-in-cell.tsx b/packages/patterns/charm-ref-in-cell.tsx
index a75400bea..706502a15 100644
--- a/packages/patterns/charm-ref-in-cell.tsx
+++ b/packages/patterns/charm-ref-in-cell.tsx
@@ -31,7 +31,7 @@ type RecipeInOutput = {
};
// the simple charm (to which we'll store a reference within a cell)
-const SimpleRecipe = recipe<{ id: string }>("Simple Recipe", ({ id }) => ({
+const SimpleRecipe = recipe(({ id }: { id: string }) => ({
[NAME]: derive(id, (idValue) => `SimpleRecipe: ${idValue}`),
[UI]: Simple Recipe id {id} ,
}));
diff --git a/packages/patterns/charms-ref-in-cell.tsx b/packages/patterns/charms-ref-in-cell.tsx
index 353e8761a..bf030a8a1 100644
--- a/packages/patterns/charms-ref-in-cell.tsx
+++ b/packages/patterns/charms-ref-in-cell.tsx
@@ -29,7 +29,7 @@ interface AddCharmState {
const AddCharmSchema = toSchema();
// Simple charm that will be instantiated multiple times
-const SimpleRecipe = recipe<{ id: string }>("Simple Recipe", ({ id }) => ({
+const SimpleRecipe = recipe<{ id: string }>(({ id }) => ({
[NAME]: derive(id, (idValue) => `SimpleRecipe: ${idValue}`),
[UI]: Simple Recipe id {id} ,
}));
diff --git a/packages/patterns/cheeseboard.tsx b/packages/patterns/cheeseboard.tsx
index a015ee3c4..a239d166d 100644
--- a/packages/patterns/cheeseboard.tsx
+++ b/packages/patterns/cheeseboard.tsx
@@ -82,7 +82,7 @@ const createPizzaListCell = lift<{ result: WebReadResult }, CheeseboardEntry[]>(
},
);
-export default recipe("Cheeseboard", () => {
+export default recipe(() => {
const cheeseBoardUrl =
"https://cheeseboardcollective.coop/home/pizza/pizza-schedule/";
const { result } = fetchData({
diff --git a/packages/patterns/common-tools.tsx b/packages/patterns/common-tools.tsx
index 814c7be78..04085bda6 100644
--- a/packages/patterns/common-tools.tsx
+++ b/packages/patterns/common-tools.tsx
@@ -25,7 +25,7 @@ type CalculatorRequest = {
export const calculator = recipe<
CalculatorRequest,
string | { error: string }
->("Calculator", ({ expression, base }) => {
+>(({ expression, base }) => {
return derive({ expression, base }, ({ expression, base }) => {
const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, "");
let sanitizedBase = Number(base);
@@ -114,7 +114,7 @@ type SearchWebResult = {
export const searchWeb = recipe<
SearchQuery,
SearchWebResult | { error: string }
->("Search Web", ({ query }) => {
+>(({ query }) => {
const { result, error } = fetchData({
url: "/api/agent-tools/web-search",
mode: "json",
@@ -155,7 +155,7 @@ type ReadWebResult = {
export const readWebpage = recipe<
ReadWebRequest,
ReadWebResult | { error: string }
->("Read Webpage", ({ url }) => {
+>(({ url }) => {
const { result, error } = fetchData({
url: "/api/agent-tools/web-read",
mode: "json",
@@ -179,7 +179,7 @@ type ToolsInput = {
list: ListItem[];
};
-export default recipe("Tools", ({ list }) => {
+export default recipe(({ list }) => {
const tools: Record = {
search_web: {
pattern: searchWeb,
diff --git a/packages/patterns/counter.tsx b/packages/patterns/counter.tsx
index fe769ad07..a94a2c2ee 100644
--- a/packages/patterns/counter.tsx
+++ b/packages/patterns/counter.tsx
@@ -12,7 +12,7 @@ interface RecipeOutput {
decrement: Stream;
}
-export default recipe("Counter", (state) => {
+export default recipe((state) => {
return {
[NAME]: str`Simple counter: ${state.value}`,
[UI]: (
diff --git a/packages/patterns/ct-render.tsx b/packages/patterns/ct-render.tsx
index 896c40f69..6c684535a 100644
--- a/packages/patterns/ct-render.tsx
+++ b/packages/patterns/ct-render.tsx
@@ -33,7 +33,7 @@ function nth(value: number) {
return `${value}th`;
}
-export const Counter = recipe("Counter", (state) => {
+export const Counter = recipe((state) => {
return {
// str is used so we can directly interpolate the OpaqueRef into the string
[NAME]: str`Simple counter: ${state.value}`,
@@ -59,7 +59,7 @@ export const Counter = recipe("Counter", (state) => {
};
});
-export default recipe("Counter", (state) => {
+export default recipe((state: RecipeState) => {
const counter = Counter({ value: state.value });
return {
diff --git a/packages/patterns/dice.tsx b/packages/patterns/dice.tsx
index ae6b38b27..1c99c1125 100644
--- a/packages/patterns/dice.tsx
+++ b/packages/patterns/dice.tsx
@@ -14,7 +14,7 @@ interface RecipeOutput {
roll: Stream<{ sides?: number }>;
}
-export default recipe("Dice", (state) => {
+export default recipe((state) => {
return {
[NAME]: `Dice Roller`,
[UI]: (
diff --git a/packages/patterns/instantiate-recipe.tsx b/packages/patterns/instantiate-recipe.tsx
index 86b67c373..c21cff5db 100644
--- a/packages/patterns/instantiate-recipe.tsx
+++ b/packages/patterns/instantiate-recipe.tsx
@@ -39,7 +39,7 @@ function nth(value: number) {
return `${value}th`;
}
-export const Counter = recipe("Counter", (state) => {
+export const Counter = recipe((state) => {
return {
[NAME]: str`Simple counter: ${state.value}`,
[UI]: (
diff --git a/packages/patterns/linkedlist-in-cell.tsx b/packages/patterns/linkedlist-in-cell.tsx
index 41beff367..ceba81602 100644
--- a/packages/patterns/linkedlist-in-cell.tsx
+++ b/packages/patterns/linkedlist-in-cell.tsx
@@ -55,7 +55,7 @@ const addItem = handler(
},
);
-export default recipe("Simple LinkedList", ({ title }: InputSchema) => {
+export default recipe(({ title }) => {
const items_list = cell({ value: "1" });
// Create a derived value for the linked list string representation
diff --git a/packages/patterns/llm.tsx b/packages/patterns/llm.tsx
index 594ba4096..fc2a6bcb8 100644
--- a/packages/patterns/llm.tsx
+++ b/packages/patterns/llm.tsx
@@ -32,7 +32,7 @@ const askQuestion = handler<
}
});
-export default recipe("LLM Test", ({ title }) => {
+export default recipe(({ title }) => {
// It is possible to make inline cells like this, but always consider whether it should just be part of the argument cell.
// These cells are effectively 'hidden state' from other recipes
const question = cell("");
diff --git a/packages/patterns/nested-counter.tsx b/packages/patterns/nested-counter.tsx
index 84415203f..7202b3b8e 100644
--- a/packages/patterns/nested-counter.tsx
+++ b/packages/patterns/nested-counter.tsx
@@ -6,7 +6,7 @@ interface RecipeState {
value: Default;
}
-export const Counter = recipe("Counter", (state) => {
+export const Counter = recipe((state) => {
return {
// str is used so we can directly interpolate the OpaqueRef into the string
[NAME]: str`Simple counter: ${state.value}`,
@@ -36,7 +36,7 @@ export const Counter = recipe("Counter", (state) => {
This demonstrates a pattern of passing a Cell to a sub-recipe and keeping the value in sync between all locations.
It also demonstrates that any recipe can be invoked using JSX syntax.
*/
-export default recipe("Counter", (state) => {
+export default recipe((state) => {
// A recipe can be 'invoked' directly
const counter = Counter({ value: state.value });
diff --git a/packages/patterns/output_schema.tsx b/packages/patterns/output_schema.tsx
index c74dd38f9..4f563eb53 100644
--- a/packages/patterns/output_schema.tsx
+++ b/packages/patterns/output_schema.tsx
@@ -13,7 +13,7 @@ interface Output {
[UI]: VNode;
}
-export default recipe ("Counter", ({ value }) => {
+export default recipe (({ value }) => {
return {
[NAME]: "recipe output issue",
[UI]: (
diff --git a/packages/runner/src/builder/built-in.ts b/packages/runner/src/builder/built-in.ts
index 02c1b912f..e0b63de0b 100644
--- a/packages/runner/src/builder/built-in.ts
+++ b/packages/runner/src/builder/built-in.ts
@@ -199,9 +199,7 @@ export const patternTool = (<
fnOrRecipe: ((input: OpaqueRef>) => any) | RecipeFactory,
extraParams?: Opaque,
): OpaqueRef> => {
- const pattern = isRecipe(fnOrRecipe)
- ? fnOrRecipe
- : recipe("tool", fnOrRecipe);
+ const pattern = isRecipe(fnOrRecipe) ? fnOrRecipe : recipe(fnOrRecipe);
return {
pattern,
diff --git a/packages/runner/src/builder/opaque-ref.ts b/packages/runner/src/builder/opaque-ref.ts
index f6841a57a..f79eace6c 100644
--- a/packages/runner/src/builder/opaque-ref.ts
+++ b/packages/runner/src/builder/opaque-ref.ts
@@ -135,7 +135,6 @@ export function opaqueRef(
return mapFactory({
list: proxy,
op: recipe(
- "mapping function",
({ element, index, array }: Opaque) =>
fn(element, index, array),
),
diff --git a/packages/runner/src/builder/recipe.ts b/packages/runner/src/builder/recipe.ts
index 1433ad416..0d66b4fef 100644
--- a/packages/runner/src/builder/recipe.ts
+++ b/packages/runner/src/builder/recipe.ts
@@ -37,6 +37,10 @@ import { traverseValue } from "./traverse-utils.ts";
import { sanitizeSchemaForLinks } from "../link-utils.ts";
/** Declare a recipe
+ *
+ * @param fn A function that creates the recipe graph
+ *
+ * or
*
* @param description A human-readable description of the recipe
* @param fn A function that creates the recipe graph
@@ -83,16 +87,27 @@ export function recipe(
resultSchema: JSONSchema,
fn: (input: OpaqueRef>) => Opaque,
): RecipeFactory;
+// Function-only overload - must come after schema-based overloads
export function recipe(
- argumentSchema: string | JSONSchema,
- resultSchema:
+ fn: (input: OpaqueRef>) => Opaque,
+): RecipeFactory;
+export function recipe(
+ argumentSchema:
+ | string
+ | JSONSchema
+ | ((input: OpaqueRef>) => Opaque),
+ resultSchema?:
| JSONSchema
- | undefined
| ((input: OpaqueRef>) => Opaque),
fn?: (input: OpaqueRef>) => Opaque,
): RecipeFactory {
- // Cover the overload that just provides input schema
- if (typeof resultSchema === "function") {
+ // Cover the overload that just provides a function
+ if (typeof argumentSchema === "function") {
+ fn = argumentSchema;
+ argumentSchema = undefined as any;
+ resultSchema = undefined;
+ } // Cover the overload that just provides input schema
+ else if (typeof resultSchema === "function") {
fn = resultSchema;
resultSchema = undefined;
}
@@ -114,8 +129,8 @@ export function recipe(
applyInputIfcToOutput(inputs, outputs);
const result = factoryFromRecipe(
- argumentSchema,
- resultSchema,
+ argumentSchema as string | JSONSchema | undefined,
+ resultSchema as JSONSchema | undefined,
inputs,
outputs,
);
@@ -125,7 +140,7 @@ export function recipe(
// Same as above, but assumes the caller manages the frame
export function recipeFromFrame(
- argumentSchema: string | JSONSchema,
+ argumentSchema: string | JSONSchema | undefined,
resultSchema: JSONSchema | undefined,
fn: (input: OpaqueRef>) => Opaque,
): RecipeFactory {
@@ -140,7 +155,7 @@ export function recipeFromFrame(
}
function factoryFromRecipe(
- argumentSchemaArg: string | JSONSchema,
+ argumentSchemaArg: string | JSONSchema | undefined,
resultSchemaArg: JSONSchema | undefined,
inputs: OpaqueRef>,
outputs: Opaque,
@@ -298,10 +313,16 @@ function factoryFromRecipe(
let argumentSchema: JSONSchema;
- if (typeof argumentSchemaArg === "string") {
- // Create a writable schema
+ if (
+ typeof argumentSchemaArg === "string" || argumentSchemaArg === undefined
+ ) {
+ // Create a writable schema from defaults
const writableSchema: JSONSchemaMutable = createJsonSchema(defaults, true);
- writableSchema.description = argumentSchemaArg;
+
+ // Set description only if provided
+ if (typeof argumentSchemaArg === "string") {
+ writableSchema.description = argumentSchemaArg;
+ }
delete (writableSchema.properties as any)?.[UI]; // TODO(seefeld): This should be a schema for views
if (
diff --git a/packages/runner/test/recipe.test.ts b/packages/runner/test/recipe.test.ts
index 34739887c..6d9fc4c45 100644
--- a/packages/runner/test/recipe.test.ts
+++ b/packages/runner/test/recipe.test.ts
@@ -493,3 +493,45 @@ describe("recipe with mixed ifc properties", () => {
// in such a way that it does not end up classified. For now, I've decided
// not to do this, since I'm not confident enough that code can't get out.
});
+
+describe("recipe with function-only syntax (no schema)", () => {
+ it("creates a recipe with just a function", () => {
+ const doubleRecipe = recipe((input: { x: any }) => {
+ const double = lift((x) => x * 2);
+ return { double: double(input.x) };
+ });
+ expect(isRecipe(doubleRecipe)).toBe(true);
+ });
+
+ it("infers schema from default values", () => {
+ const doubleRecipe = recipe((input: { x: any }) => {
+ input.x.setDefault(42);
+ const double = lift((x) => x * 2);
+ return { double: double(input.x) };
+ });
+
+ expect(isRecipe(doubleRecipe)).toBe(true);
+ expect(doubleRecipe.argumentSchema).toMatchObject({
+ type: "object",
+ properties: {
+ x: { type: "integer", default: 42 },
+ },
+ });
+ // Should not have a description field
+ expect(doubleRecipe.argumentSchema).not.toHaveProperty("description");
+ });
+
+ it("creates nodes correctly with function-only syntax", () => {
+ const doubleRecipe = recipe((input: { x: any }) => {
+ input.x.setDefault(1);
+ const double = lift((x) => x * 2);
+ return { double: double(double(input.x)) };
+ });
+
+ const { nodes } = doubleRecipe;
+ expect(nodes.length).toBe(2);
+ expect(isModule(nodes[0].module) && nodes[0].module.type).toBe(
+ "javascript",
+ );
+ });
+});
diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts
index 5816576dc..a05a94a51 100644
--- a/packages/ts-transformers/src/transformers/schema-injection.ts
+++ b/packages/ts-transformers/src/transformers/schema-injection.ts
@@ -170,9 +170,10 @@ export class SchemaInjectionTransformer extends Transformer {
const callKind = detectCallKind(node, checker);
if (callKind?.kind === "builder" && callKind.builderName === "recipe") {
+ const factory = transformation.factory;
const typeArgs = node.typeArguments;
+
if (typeArgs && typeArgs.length >= 1) {
- const factory = transformation.factory;
const schemaArgs = typeArgs.map((typeArg) => typeArg).map((
typeArg,
) => createToSchemaCall(context, typeArg));
@@ -194,6 +195,56 @@ export class SchemaInjectionTransformer extends Transformer {
return ts.visitEachChild(updated, visit, transformation);
}
+
+ // Handle single-parameter recipe() calls without type arguments
+ // Only transform if the function parameter has a type annotation
+ const argsArray = Array.from(node.arguments);
+ let recipeFunction: ts.Expression | undefined;
+ let recipeName: ts.StringLiteral | undefined;
+
+ if (argsArray.length === 1) {
+ // Single argument - must be the function
+ recipeFunction = argsArray[0];
+ } else if (
+ argsArray.length === 2 && argsArray[0] &&
+ ts.isStringLiteral(argsArray[0])
+ ) {
+ // Two arguments with first being a string - second is the function
+ recipeName = argsArray[0];
+ recipeFunction = argsArray[1];
+ }
+
+ if (
+ recipeFunction &&
+ (ts.isFunctionExpression(recipeFunction) ||
+ ts.isArrowFunction(recipeFunction))
+ ) {
+ const recipeFn = recipeFunction;
+ if (recipeFn.parameters.length >= 1) {
+ const inputParam = recipeFn.parameters[0];
+
+ // Only transform if there's an explicit type annotation
+ if (inputParam?.type) {
+ const toSchemaInput = createToSchemaCall(
+ context,
+ inputParam.type,
+ );
+
+ // Preserve the name argument if it was provided
+ const newArgs = recipeName
+ ? [recipeName, toSchemaInput, recipeFn]
+ : [toSchemaInput, recipeFn];
+
+ const updated = factory.createCallExpression(
+ node.expression,
+ undefined,
+ newArgs,
+ );
+
+ return ts.visitEachChild(updated, visit, transformation);
+ }
+ }
+ }
}
if (
diff --git a/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.expected.tsx
new file mode 100644
index 000000000..c4b78d7bb
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.expected.tsx
@@ -0,0 +1,62 @@
+import * as __ctHelpers from "commontools";
+import { Cell, Default, handler, NAME, recipe, str, UI } from "commontools";
+interface CounterState {
+ value: Cell;
+}
+interface RecipeState {
+ value: Default;
+}
+const increment = handler(true as const satisfies __ctHelpers.JSONSchema, {
+ type: "object",
+ properties: {
+ value: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["value"]
+} as const satisfies __ctHelpers.JSONSchema, (_e, state) => {
+ state.value.set(state.value.get() + 1);
+});
+const decrement = handler(true as const satisfies __ctHelpers.JSONSchema, {
+ type: "object",
+ properties: {
+ value: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["value"]
+} as const satisfies __ctHelpers.JSONSchema, (_, state: {
+ value: Cell;
+}) => {
+ state.value.set(state.value.get() - 1);
+});
+export default recipe({
+ type: "object",
+ properties: {
+ value: {
+ type: "number",
+ default: 0
+ }
+ },
+ required: ["value"]
+} as const satisfies __ctHelpers.JSONSchema, (state) => {
+ return {
+ [NAME]: str `Simple counter: ${state.value}`,
+ [UI]: (
+ -
+
+ next number: {__ctHelpers.ifElse(state.value, __ctHelpers.derive({ state: {
+ value: state.value
+ } }, ({ state }) => state.value + 1), "unknown")}
+
+ +
+ ),
+ value: state.value,
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.input.tsx
new file mode 100644
index 000000000..f2631f46f
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-recipe-no-name.input.tsx
@@ -0,0 +1,34 @@
+///
+import { Cell, Default, handler, NAME, recipe, str, UI } from "commontools";
+
+interface CounterState {
+ value: Cell;
+}
+
+interface RecipeState {
+ value: Default;
+}
+
+const increment = handler((_e, state) => {
+ state.value.set(state.value.get() + 1);
+});
+
+const decrement = handler((_, state: { value: Cell }) => {
+ state.value.set(state.value.get() - 1);
+});
+
+export default recipe((state) => {
+ return {
+ [NAME]: str`Simple counter: ${state.value}`,
+ [UI]: (
+
+ -
+
+ next number: {state.value ? state.value + 1 : "unknown"}
+
+ +
+
+ ),
+ value: state.value,
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.expected.tsx
new file mode 100644
index 000000000..ee0e5f530
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.expected.tsx
@@ -0,0 +1,22 @@
+import * as __ctHelpers from "commontools";
+import { recipe } from "commontools";
+interface MyInput {
+ value: number;
+}
+export default recipe("MyRecipe", {
+ type: "object",
+ properties: {
+ value: {
+ type: "number"
+ }
+ },
+ required: ["value"]
+} as const satisfies __ctHelpers.JSONSchema, (input: MyInput) => {
+ return {
+ result: input.value * 2,
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.input.tsx
new file mode 100644
index 000000000..23ef8a901
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/ast-transform/recipe-with-name-and-type.input.tsx
@@ -0,0 +1,12 @@
+///
+import { recipe } from "commontools";
+
+interface MyInput {
+ value: number;
+}
+
+export default recipe("MyRecipe", (input: MyInput) => {
+ return {
+ result: input.value * 2,
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx
index fcbf6f469..12e4246e6 100644
--- a/packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx
+++ b/packages/ts-transformers/test/fixtures/ast-transform/schema-generation-builders.expected.tsx
@@ -46,7 +46,20 @@ export default recipe({
Add
- {state.items.map((item) => {item} )}
+ {state.items.mapWithPattern(__ctHelpers.recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ element: {
+ type: "string"
+ },
+ params: {
+ type: "object",
+ properties: {}
+ }
+ },
+ required: ["element", "params"]
+ } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: {} }) => {item} ), {})}
| ),
};
diff --git a/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.expected.tsx
new file mode 100644
index 000000000..8c3a4dae8
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.expected.tsx
@@ -0,0 +1,80 @@
+import * as __ctHelpers from "commontools";
+import { Cell, Default, handler, recipe, UI } from "commontools";
+interface Item {
+ text: Default;
+}
+interface InputSchema {
+ items: Default- ;
+}
+const removeItem = handler(true as const satisfies __ctHelpers.JSONSchema, {
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ $ref: "#/$defs/Item"
+ },
+ asCell: true
+ },
+ index: {
+ type: "number"
+ }
+ },
+ required: ["items", "index"],
+ $defs: {
+ Item: {
+ type: "object",
+ properties: {
+ text: {
+ type: "string",
+ default: ""
+ }
+ },
+ required: ["text"]
+ }
+ }
+} as const satisfies __ctHelpers.JSONSchema, (_, _2) => {
+ // Not relevant for repro
+});
+export default recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ $ref: "#/$defs/Item"
+ },
+ default: []
+ }
+ },
+ required: ["items"],
+ $defs: {
+ Item: {
+ type: "object",
+ properties: {
+ text: {
+ type: "string",
+ default: ""
+ }
+ },
+ required: ["text"]
+ }
+ }
+} as const satisfies __ctHelpers.JSONSchema, ({ items }: InputSchema) => {
+ return {
+ [UI]: (
+ {items.map((_, index) => (
+
+ Remove
+
+ ))}
+ ),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.input.tsx
new file mode 100644
index 000000000..44ef5c1bc
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-capture-cell-param-no-name.input.tsx
@@ -0,0 +1,34 @@
+///
+import { Cell, Default, handler, recipe, UI } from "commontools";
+
+interface Item {
+ text: Default;
+}
+
+interface InputSchema {
+ items: Default- ;
+}
+
+const removeItem = handler
- ; index: number }>(
+ (_, _2) => {
+ // Not relevant for repro
+ },
+);
+
+export default recipe(
+ ({ items }: InputSchema) => {
+ return {
+ [UI]: (
+
+ {items.map((_, index) => (
+
+
+ Remove
+
+
+ ))}
+
+ ),
+ };
+ },
+);
diff --git a/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.expected.tsx
new file mode 100644
index 000000000..8027b35b1
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.expected.tsx
@@ -0,0 +1,74 @@
+import * as __ctHelpers from "commontools";
+import { recipe, UI, handler, Cell } from "commontools";
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ "ct-button": any;
+ }
+ }
+}
+// Event handler defined at module scope
+const handleClick = handler(true as const satisfies __ctHelpers.JSONSchema, {
+ type: "object",
+ properties: {
+ count: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["count"]
+} as const satisfies __ctHelpers.JSONSchema, (_, { count }) => {
+ count.set(count.get() + 1);
+});
+interface Item {
+ id: number;
+ name: string;
+}
+interface State {
+ items: Item[];
+ count: Cell;
+}
+export default recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ $ref: "#/$defs/Item"
+ }
+ },
+ count: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["items", "count"],
+ $defs: {
+ Item: {
+ type: "object",
+ properties: {
+ id: {
+ type: "number"
+ },
+ name: {
+ type: "string"
+ }
+ },
+ required: ["id", "name"]
+ }
+ }
+} as const satisfies __ctHelpers.JSONSchema, (state: State) => {
+ return {
+ [UI]: (
+ {/* Map callback references handler - should NOT capture it */}
+ {state.items.map((item) => (
+ {item.name}
+ ))}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.input.tsx
new file mode 100644
index 000000000..6825270ed
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-no-name.input.tsx
@@ -0,0 +1,40 @@
+///
+import { recipe, UI, handler, Cell } from "commontools";
+
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ "ct-button": any;
+ }
+ }
+}
+
+// Event handler defined at module scope
+const handleClick = handler }>((_, { count }) => {
+ count.set(count.get() + 1);
+});
+
+interface Item {
+ id: number;
+ name: string;
+}
+
+interface State {
+ items: Item[];
+ count: Cell;
+}
+
+export default recipe((state: State) => {
+ return {
+ [UI]: (
+
+ {/* Map callback references handler - should NOT capture it */}
+ {state.items.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.expected.tsx
new file mode 100644
index 000000000..b755492ff
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.expected.tsx
@@ -0,0 +1,118 @@
+import * as __ctHelpers from "commontools";
+import { recipe, UI, handler, Cell } from "commontools";
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ "ct-button": any;
+ }
+ }
+}
+// Event handler defined at module scope
+const handleClick = handler(true as const satisfies __ctHelpers.JSONSchema, {
+ type: "object",
+ properties: {
+ count: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["count"]
+} as const satisfies __ctHelpers.JSONSchema, (_, { count }) => {
+ count.set(count.get() + 1);
+});
+interface Item {
+ id: number;
+ name: string;
+}
+interface State {
+ items: Item[];
+ count: Cell;
+}
+export default recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ $ref: "#/$defs/Item"
+ }
+ },
+ count: {
+ type: "number",
+ asCell: true
+ }
+ },
+ required: ["items", "count"],
+ $defs: {
+ Item: {
+ type: "object",
+ properties: {
+ id: {
+ type: "number"
+ },
+ name: {
+ type: "string"
+ }
+ },
+ required: ["id", "name"]
+ }
+ }
+} as const satisfies __ctHelpers.JSONSchema, (state) => {
+ return {
+ [UI]: (
+ {/* Map callback references handler - should NOT capture it */}
+ {state.items.mapWithPattern(__ctHelpers.recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ element: {
+ $ref: "#/$defs/Item"
+ },
+ params: {
+ type: "object",
+ properties: {
+ state: {
+ type: "object",
+ properties: {
+ count: {
+ type: "number",
+ asCell: true,
+ asOpaque: true
+ }
+ },
+ required: ["count"]
+ }
+ },
+ required: ["state"]
+ }
+ },
+ required: ["element", "params"],
+ $defs: {
+ Item: {
+ type: "object",
+ properties: {
+ id: {
+ type: "number"
+ },
+ name: {
+ type: "string"
+ }
+ },
+ required: ["id", "name"]
+ }
+ }
+ } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => (
+ {item.name}
+ )), {
+ state: {
+ count: state.count
+ }
+ })}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.input.tsx
new file mode 100644
index 000000000..1a4fd9198
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-handler-reference-with-type-arg-no-name.input.tsx
@@ -0,0 +1,40 @@
+///
+import { recipe, UI, handler, Cell } from "commontools";
+
+declare global {
+ namespace JSX {
+ interface IntrinsicElements {
+ "ct-button": any;
+ }
+ }
+}
+
+// Event handler defined at module scope
+const handleClick = handler }>((_, { count }) => {
+ count.set(count.get() + 1);
+});
+
+interface Item {
+ id: number;
+ name: string;
+}
+
+interface State {
+ items: Item[];
+ count: Cell;
+}
+
+export default recipe((state) => {
+ return {
+ [UI]: (
+
+ {/* Map callback references handler - should NOT capture it */}
+ {state.items.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.expected.tsx
new file mode 100644
index 000000000..ca4b50a42
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.expected.tsx
@@ -0,0 +1,46 @@
+import * as __ctHelpers from "commontools";
+import { recipe, UI } from "commontools";
+interface State {
+ items: Array<{
+ price: number;
+ }>;
+ discount: number;
+}
+export default recipe({
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ price: {
+ type: "number"
+ }
+ },
+ required: ["price"]
+ }
+ },
+ discount: {
+ type: "number"
+ }
+ },
+ required: ["items", "discount"]
+} as const satisfies __ctHelpers.JSONSchema, (state: State) => {
+ return {
+ [UI]: (
+ {state.items.map((item) => ({__ctHelpers.derive({
+ item: {
+ price: item.price
+ },
+ state: {
+ discount: state.discount
+ }
+ }, ({ item, state }) => item.price * state.discount)} ))}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.input.tsx
new file mode 100644
index 000000000..d40767aa4
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-single-capture-no-name.input.tsx
@@ -0,0 +1,19 @@
+///
+import { recipe, UI } from "commontools";
+
+interface State {
+ items: Array<{ price: number }>;
+ discount: number;
+}
+
+export default recipe((state: State) => {
+ return {
+ [UI]: (
+
+ {state.items.map((item) => (
+ {item.price * state.discount}
+ ))}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.expected.tsx
new file mode 100644
index 000000000..c461ca0a6
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.expected.tsx
@@ -0,0 +1,81 @@
+import * as __ctHelpers from "commontools";
+import { recipe, UI } from "commontools";
+interface State {
+ items: Array<{
+ price: number;
+ }>;
+ discount: number;
+}
+export default recipe({
+ type: "object",
+ properties: {
+ items: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ price: {
+ type: "number"
+ }
+ },
+ required: ["price"]
+ }
+ },
+ discount: {
+ type: "number"
+ }
+ },
+ required: ["items", "discount"]
+} as const satisfies __ctHelpers.JSONSchema, (state) => {
+ return {
+ [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ element: {
+ type: "object",
+ properties: {
+ price: {
+ type: "number"
+ }
+ },
+ required: ["price"]
+ },
+ params: {
+ type: "object",
+ properties: {
+ state: {
+ type: "object",
+ properties: {
+ discount: {
+ type: "number",
+ asOpaque: true
+ }
+ },
+ required: ["discount"]
+ }
+ },
+ required: ["state"]
+ }
+ },
+ required: ["element", "params"]
+ } as const satisfies __ctHelpers.JSONSchema, ({ element: item, params: { state } }) => ({__ctHelpers.derive({
+ item: {
+ price: item.price
+ },
+ state: {
+ discount: state.discount
+ }
+ }, ({ item, state }) => item.price * state.discount)} )), {
+ state: {
+ discount: state.discount
+ }
+ })}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.input.tsx
new file mode 100644
index 000000000..6431c01ac
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-single-capture-with-type-arg-no-name.input.tsx
@@ -0,0 +1,19 @@
+///
+import { recipe, UI } from "commontools";
+
+interface State {
+ items: Array<{ price: number }>;
+ discount: number;
+}
+
+export default recipe((state) => {
+ return {
+ [UI]: (
+
+ {state.items.map((item) => (
+ {item.price * state.discount}
+ ))}
+
+ ),
+ };
+});
\ No newline at end of file
diff --git a/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx
new file mode 100644
index 000000000..04f65d170
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx
@@ -0,0 +1,33 @@
+import * as __ctHelpers from "commontools";
+import { cell, recipe, UI } from "commontools";
+export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: any) => {
+ const items = cell([1, 2, 3, 4, 5]);
+ return {
+ [UI]: (
+ {items.mapWithPattern(__ctHelpers.recipe({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ type: "object",
+ properties: {
+ element: {
+ type: "number"
+ },
+ index: {
+ type: "number"
+ },
+ array: true,
+ params: {
+ type: "object",
+ properties: {}
+ }
+ },
+ required: ["element", "params"]
+ } as const satisfies __ctHelpers.JSONSchema, ({ element: item, index: index, array: array, params: {} }) => (
+ Item {item} at index {index} of {array.length} total items
+
)), {})}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.input.tsx
new file mode 100644
index 000000000..bc8d666c4
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.input.tsx
@@ -0,0 +1,18 @@
+///
+import { cell, recipe, UI } from "commontools";
+
+export default recipe((_state: any) => {
+ const items = cell([1, 2, 3, 4, 5]);
+
+ return {
+ [UI]: (
+
+ {items.map((item, index, array) => (
+
+ Item {item} at index {index} of {array.length} total items
+
+ ))}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.expected.tsx
new file mode 100644
index 000000000..05fe9f215
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.expected.tsx
@@ -0,0 +1,109 @@
+import * as __ctHelpers from "commontools";
+import { ifElse, recipe, UI } from "commontools";
+interface State {
+ isActive: boolean;
+ count: number;
+ userType: string;
+ score: number;
+ hasPermission: boolean;
+ isPremium: boolean;
+}
+export default recipe({
+ type: "object",
+ properties: {
+ isActive: {
+ type: "boolean"
+ },
+ count: {
+ type: "number"
+ },
+ userType: {
+ type: "string"
+ },
+ score: {
+ type: "number"
+ },
+ hasPermission: {
+ type: "boolean"
+ },
+ isPremium: {
+ type: "boolean"
+ }
+ },
+ required: ["isActive", "count", "userType", "score", "hasPermission", "isPremium"]
+} as const satisfies __ctHelpers.JSONSchema, (state) => {
+ return {
+ [UI]: (
+
Basic Ternary
+
{__ctHelpers.ifElse(state.isActive, "Active", "Inactive")}
+
{__ctHelpers.ifElse(state.hasPermission, "Authorized", "Denied")}
+
+
Ternary with Comparisons
+
{__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ count: state.count
+ } }, ({ state }) => state.count > 10), "High", "Low")}
+
{__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ score: state.score
+ } }, ({ state }) => state.score >= 90), "A", __ctHelpers.derive({ state: {
+ score: state.score
+ } }, ({ state }) => state.score >= 80 ? "B" : "C"))}
+
+ {__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ count: state.count
+ } }, ({ state }) => state.count === 0), "Empty", __ctHelpers.derive({ state: {
+ count: state.count
+ } }, ({ state }) => state.count === 1
+ ? "Single"
+ : "Multiple"))}
+
+
+
Nested Ternary
+
+ {__ctHelpers.ifElse(state.isActive, __ctHelpers.derive({ state: {
+ isPremium: state.isPremium
+ } }, ({ state }) => (state.isPremium ? "Premium Active" : "Regular Active")), "Inactive")}
+
+
+ {__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ userType: state.userType
+ } }, ({ state }) => state.userType === "admin"), "Admin", __ctHelpers.derive({ state: {
+ userType: state.userType
+ } }, ({ state }) => state.userType === "user"
+ ? "User"
+ : "Guest"))}
+
+
+
Complex Conditions
+
+ {__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ isActive: state.isActive,
+ hasPermission: state.hasPermission
+ } }, ({ state }) => state.isActive && state.hasPermission), "Full Access", "Limited Access")}
+
+
+ {__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ count: state.count
+ } }, ({ state }) => state.count > 0 && state.count < 10), "In Range", "Out of Range")}
+
+
+ {__ctHelpers.ifElse(__ctHelpers.derive({ state: {
+ isPremium: state.isPremium,
+ score: state.score
+ } }, ({ state }) => state.isPremium || state.score > 100), "Premium Features", "Basic Features")}
+
+
+
IfElse Component
+ {ifElse(state.isActive,
User is active with {state.count} items
,
User is inactive
)}
+
+ {ifElse(__ctHelpers.derive({ state: {
+ count: state.count
+ } }, ({ state }) => state.count > 5),
+ Many items: {state.count}
+ ,
Few items: {state.count}
)}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.input.tsx
new file mode 100644
index 000000000..893d7fa71
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/jsx-conditional-rendering-no-name.input.tsx
@@ -0,0 +1,78 @@
+///
+import { ifElse, recipe, UI } from "commontools";
+
+interface State {
+ isActive: boolean;
+ count: number;
+ userType: string;
+ score: number;
+ hasPermission: boolean;
+ isPremium: boolean;
+}
+
+export default recipe((state) => {
+ return {
+ [UI]: (
+
+
Basic Ternary
+
{state.isActive ? "Active" : "Inactive"}
+
{state.hasPermission ? "Authorized" : "Denied"}
+
+
Ternary with Comparisons
+
{state.count > 10 ? "High" : "Low"}
+
{state.score >= 90 ? "A" : state.score >= 80 ? "B" : "C"}
+
+ {state.count === 0
+ ? "Empty"
+ : state.count === 1
+ ? "Single"
+ : "Multiple"}
+
+
+
Nested Ternary
+
+ {state.isActive
+ ? (state.isPremium ? "Premium Active" : "Regular Active")
+ : "Inactive"}
+
+
+ {state.userType === "admin"
+ ? "Admin"
+ : state.userType === "user"
+ ? "User"
+ : "Guest"}
+
+
+
Complex Conditions
+
+ {state.isActive && state.hasPermission
+ ? "Full Access"
+ : "Limited Access"}
+
+
+ {state.count > 0 && state.count < 10 ? "In Range" : "Out of Range"}
+
+
+ {state.isPremium || state.score > 100
+ ? "Premium Features"
+ : "Basic Features"}
+
+
+
IfElse Component
+ {ifElse(
+ state.isActive,
+
User is active with {state.count} items
,
+
User is inactive
,
+ )}
+
+ {ifElse(
+ state.count > 5,
+
+ Many items: {state.count}
+ ,
+
Few items: {state.count}
,
+ )}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx
new file mode 100644
index 000000000..864393345
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx
@@ -0,0 +1,24 @@
+import * as __ctHelpers from "commontools";
+import { cell, recipe, UI } from "commontools";
+export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: any) => {
+ const items = cell([{ name: "apple" }, { name: "banana" }]);
+ const showList = cell(true);
+ return {
+ [UI]: (
+ {__ctHelpers.derive({
+ showList: showList,
+ items: items
+ }, ({ showList, items }) => showList && (
+ {items.map((item) => (
+ {__ctHelpers.derive({ item: {
+ name: item.name
+ } }, ({ item }) => item.name && {item.name} )}
+
))}
+
))}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.input.tsx
new file mode 100644
index 000000000..201044ac4
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.input.tsx
@@ -0,0 +1,23 @@
+///
+import { cell, recipe, UI } from "commontools";
+
+export default recipe((_state: any) => {
+ const items = cell([{ name: "apple" }, { name: "banana" }]);
+ const showList = cell(true);
+
+ return {
+ [UI]: (
+
+ {showList && (
+
+ {items.map((item) => (
+
+ {item.name && {item.name} }
+
+ ))}
+
+ )}
+
+ ),
+ };
+});
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx
new file mode 100644
index 000000000..7fb8bb300
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx
@@ -0,0 +1,19 @@
+import * as __ctHelpers from "commontools";
+import { cell, recipe, UI } from "commontools";
+export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: any) => {
+ const people = cell([
+ { id: "1", name: "Alice" },
+ { id: "2", name: "Bob" },
+ ]);
+ return {
+ [UI]: (
+ {__ctHelpers.derive(people, ({ people }) => people.length > 0 && (
+ {people.map((person) => ({person.name} ))}
+ ))}
+
),
+ };
+});
+// @ts-ignore: Internals
+function h(...args: any[]) { return __ctHelpers.h.apply(null, args); }
+// @ts-ignore: Internals
+h.fragment = __ctHelpers.h.fragment;
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.input.tsx
new file mode 100644
index 000000000..ec1214941
--- /dev/null
+++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.input.tsx
@@ -0,0 +1,23 @@
+///
+import { cell, recipe, UI } from "commontools";
+
+export default recipe((_state: any) => {
+ const people = cell([
+ { id: "1", name: "Alice" },
+ { id: "2", name: "Bob" },
+ ]);
+
+ return {
+ [UI]: (
+
+ {people.length > 0 && (
+
+ {people.map((person) => (
+ {person.name}
+ ))}
+
+ )}
+
+ ),
+ };
+});