Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions docs/content/docs/openui-lang/defining-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Use `defineComponent(...)` to register each component and `createLibrary(...)` t

```tsx
import { defineComponent, createLibrary } from "@openuidev/react-lang";
import { z } from "zod";
import { z } from "zod/v4";

const StatCard = defineComponent({
name: "StatCard",
Expand All @@ -32,6 +32,8 @@ export const myLibrary = createLibrary({
});
```

If you want one import path that works with both `zod@3.25.x` and `zod@4`, use `import { z } from "zod/v4"` for OpenUI component schemas.

## Required fields in `defineComponent`

1. `name`: component call name in OpenUI Lang.
Expand All @@ -43,7 +45,7 @@ export const myLibrary = createLibrary({

```tsx
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import { z } from "zod/v4";

const Item = defineComponent({
name: "Item",
Expand All @@ -68,7 +70,7 @@ To define container components that accepts multiple child components, you can u

```tsx
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import { z } from "zod/v4";

const TextBlock = defineComponent({
/* ... */
Expand All @@ -84,6 +86,30 @@ const TabItemSchema = z.object({
});
```

## Naming reusable helper schemas

Use `tagSchemaId(...)` when a prop uses a standalone helper schema and you want a readable name in generated prompt signatures instead of `any`.

```tsx
import { defineComponent, tagSchemaId } from "@openuidev/react-lang";
import { z } from "zod/v4";

const ActionExpression = z.any();
tagSchemaId(ActionExpression, "ActionExpression");

const Button = defineComponent({
name: "Button",
description: "Triggers an action",
props: z.object({
label: z.string(),
action: ActionExpression.optional(),
}),
component: ({ props }) => <button>{props.label}</button>,
});
```

Without `tagSchemaId(...)`, the generated prompt would fall back to `action?: any`. Components already get their names automatically through `defineComponent(...)`, so this is only needed for non-component helper schemas.

## The `root` field

The `root` option in `createLibrary` specifies which component the LLM must use as the entry point. The generated system prompt instructs the model to always start with `root = <RootName>(...)`.
Expand Down
23 changes: 23 additions & 0 deletions docs/content/docs/openui-lang/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@ title: Troubleshooting
description: Common issues and fixes when working with OpenUI Lang and the Renderer.
---

## Library Definition Issues

### Why do I get "Component was defined with a Zod 3 schema"?

If you're on `zod@3.25.x`, import component schemas from `zod/v4`, not `zod`. OpenUI component definitions expect the Zod 4 schema objects, and the `zod@3.25` package ships those under the `zod/v4` subpath.

```ts
import { z } from "zod/v4";
```

If you want one import path that works across `zod@3.25.x` and `zod@4`, prefer `zod/v4`.

### Why does a prop show up as `any` in the generated prompt?

`defineComponent(...)` automatically names component schemas, but standalone helper schemas do not get a friendly prompt name by default. Tag those helper schemas explicitly.

```ts
const ActionExpression = z.any();
tagSchemaId(ActionExpression, "ActionExpression");
```

This only affects prompt signatures. Validation behavior stays the same.

## LLM Output Issues

### Why are extra arguments being dropped?
Expand Down
4 changes: 2 additions & 2 deletions packages/lang-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openuidev/lang-core",
"version": "0.2.1",
"version": "0.2.2",
"description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -41,7 +41,7 @@
},
"peerDependencies": {
"@modelcontextprotocol/sdk": ">=1.0.0",
"zod": "^4.0.0"
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
Expand Down
200 changes: 200 additions & 0 deletions packages/lang-core/src/__tests__/library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { createLibrary, defineComponent, tagSchemaId } from "../library";

const Dummy = null as any;

// ─── tagSchemaId + registry integration ─────────────────────────────────────

describe("tagSchemaId", () => {
it("tagged schema appears in prompt signatures", () => {
const actionSchema = z.any();
tagSchemaId(actionSchema, "ActionExpression");

const Button = defineComponent({
name: "Button",
props: z.object({ label: z.string(), action: actionSchema.optional() }),
description: "A button",
component: Dummy,
});

const lib = createLibrary({ components: [Button], root: "Button" });
const prompt = lib.prompt();

expect(prompt).toContain("action?: ActionExpression");
});

it("tagged schema inside array is discovered", () => {
const itemSchema = z.object({ text: z.string() });
tagSchemaId(itemSchema, "ListItem");

const List = defineComponent({
name: "List",
props: z.object({ items: z.array(itemSchema) }),
description: "A list",
component: Dummy,
});

const lib = createLibrary({ components: [List], root: "List" });
const prompt = lib.prompt();

expect(prompt).toContain("items: ListItem[]");
});

it("tagged schema inside union is discovered", () => {
const textSchema = z.object({ text: z.string() });
tagSchemaId(textSchema, "TextNode");

const imgSchema = z.object({ src: z.string() });
tagSchemaId(imgSchema, "ImageNode");

const Card = defineComponent({
name: "Card",
props: z.object({ child: z.union([textSchema, imgSchema]) }),
description: "A card",
component: Dummy,
});

const lib = createLibrary({ components: [Card], root: "Card" });
const prompt = lib.prompt();

expect(prompt).toContain("TextNode | ImageNode");
});

it("tagged schema inside optional wrapper is discovered", () => {
const actionSchema = z.any();
tagSchemaId(actionSchema, "MyAction");

const Btn = defineComponent({
name: "Btn",
props: z.object({ action: actionSchema.optional() }),
description: "btn",
component: Dummy,
});

const lib = createLibrary({ components: [Btn], root: "Btn" });
const prompt = lib.prompt();

expect(prompt).toContain("action?: MyAction");
});
});

// ─── per-library registry isolation ─────────────────────────────────────────

describe("per-library registry", () => {
it("two libraries with same component name do not collide", () => {
const ButtonA = defineComponent({
name: "Button",
props: z.object({ label: z.string() }),
description: "Button A",
component: Dummy,
});

const ButtonB = defineComponent({
name: "Button",
props: z.object({ text: z.string() }),
description: "Button B",
component: Dummy,
});

// Both should create without throwing
const libA = createLibrary({ components: [ButtonA], root: "Button" });
const libB = createLibrary({ components: [ButtonB], root: "Button" });

expect(libA.prompt()).toContain("label: string");
expect(libB.prompt()).toContain("text: string");
});

it("toJSONSchema produces $defs with $ref pointers", () => {
const Text = defineComponent({
name: "TextContent",
props: z.object({ text: z.string() }),
description: "text",
component: Dummy,
});

const Card = defineComponent({
name: "Card",
props: z.object({
title: z.string(),
children: z.array(z.union([Text.ref])),
}),
description: "card",
component: Dummy,
});

const lib = createLibrary({ components: [Text, Card], root: "Card" });
const schema = lib.toJSONSchema() as Record<string, any>;

// $defs contain all component schemas
expect(schema.$defs).toBeDefined();
expect(schema.$defs["TextContent"].properties?.text).toBeDefined();
expect(schema.$defs["Card"].properties?.title).toBeDefined();

// $refs inside $defs use #/$defs/ format
const childrenItems = schema.$defs["Card"].properties?.children?.items;
const refs = JSON.stringify(childrenItems);
expect(refs).toContain("#/$defs/TextContent");
});
});

// ─── getSchemaId WeakMap fallback ────────────────────────────────────────────

describe("getSchemaId fallback", () => {
it("component .ref resolves name even when not in this library", () => {
// TextContent is defined but NOT passed to createLibrary
const TextContent = defineComponent({
name: "TextContent",
props: z.object({ text: z.string() }),
description: "text",
component: Dummy,
});

// Card references TextContent.ref in its children
const Card = defineComponent({
name: "Card",
props: z.object({ children: z.array(z.union([TextContent.ref])) }),
description: "card",
component: Dummy,
});

// Only Card is in the library — TextContent is NOT
const lib = createLibrary({ components: [Card], root: "Card" });
const prompt = lib.prompt();

// Should still show "TextContent" from the WeakMap fallback, not "any"
expect(prompt).toContain("TextContent");
expect(prompt).not.toContain("children: any");
});
});

// ─── assertV4Schema ─────────────────────────────────────────────────────────

describe("assertV4Schema", () => {
it("throws for zod v3 schemas with a helpful message", () => {
// Simulate a v3 schema shape: has _def but no _zod
const fakeV3Schema = { _def: { typeName: "ZodObject" } };

expect(() =>
defineComponent({
name: "Bad",
props: fakeV3Schema as any,
description: "test",
component: Dummy,
}),
).toThrow(/Zod 3 schema/);
});

it("accepts valid v4 schemas", () => {
const schema = z.object({ name: z.string() });

expect(() =>
defineComponent({
name: "Good",
props: schema,
description: "test",
component: Dummy,
}),
).not.toThrow();
});
});
2 changes: 1 addition & 1 deletion packages/lang-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// ── Library (framework-generic) ──
export { createLibrary, defineComponent } from "./library";
export { createLibrary, defineComponent, tagSchemaId } from "./library";
export type {
ComponentGroup,
ComponentRenderProps,
Expand Down
Loading
Loading