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
7 changes: 3 additions & 4 deletions renderers/web_core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions renderers/web_core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,11 @@
"devDependencies": {
"@types/node": "^24.11.0",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2",
"zod-to-json-schema": "^3.25.1"
"wireit": "^0.15.0-pre.2"
},
"dependencies": {
"@preact/signals-core": "^1.13.0",
"zod": "^3.25.76"
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.1"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be bloating the front end, though. I was tempted to add this in the past too, but decided against it in order to keep the front end lean. If you think it's worth it, we should quantify the size hit.

}
}
16 changes: 14 additions & 2 deletions renderers/web_core/src/v0_9/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,25 @@ export class Catalog<T extends ComponentApi> {
*/
readonly functions: ReadonlyMap<string, FunctionImplementation>;

/**
* The schema for theme parameters used by this catalog.
*/
readonly themeSchema?: z.ZodObject<any>;

/**
* A ready-to-use FunctionInvoker callback that delegates to this catalog's functions.
* Can be passed directly to a DataContext.
*/
readonly invoker: FunctionInvoker;

constructor(id: string, components: T[], functions: FunctionImplementation[] = []) {
constructor(
id: string,
components: T[],
functions: FunctionImplementation[] = [],
themeSchema?: z.ZodObject<any>,
) {
this.id = id;

const compMap = new Map<string, T>();
for (const comp of components) {
compMap.set(comp.name, comp);
Expand All @@ -134,6 +144,8 @@ export class Catalog<T extends ComponentApi> {
}
this.functions = funcMap;

this.themeSchema = themeSchema;

this.invoker = (name, rawArgs, ctx, abortSignal) => {
const fn = this.functions.get(name);
if (!fn) {
Expand Down
191 changes: 191 additions & 0 deletions renderers/web_core/src/v0_9/processing/message-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import assert from "node:assert";
import { describe, it, beforeEach } from "node:test";
import { MessageProcessor } from "./message-processor.js";
import { Catalog, ComponentApi } from "../catalog/types.js";
import { z } from "zod";

describe("MessageProcessor", () => {
let processor: MessageProcessor<ComponentApi>;
Expand All @@ -32,6 +33,196 @@ describe("MessageProcessor", () => {
});
});

describe("getClientCapabilities", () => {
it("generates basic client capabilities with supportedCatalogIds", () => {
const caps: any = processor.getClientCapabilities();
assert.strictEqual((caps["v0.9"] as any).inlineCatalogs, undefined);
assert.deepStrictEqual(caps, {
"v0.9": {
supportedCatalogIds: ["test-catalog"],
},
});
});

it("generates inline catalogs when requested", () => {
const buttonApi: ComponentApi = {
name: "Button",
schema: z.object({
label: z.string().describe("The button label"),
}),
};
const cat = new Catalog("cat-1", [buttonApi]);
const proc = new MessageProcessor([cat]);

const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const inlineCat = caps["v0.9"].inlineCatalogs![0];

assert.strictEqual(inlineCat.catalogId, "cat-1");
const buttonSchema = inlineCat.components!.Button;

assert.ok(buttonSchema.allOf);
assert.strictEqual(
buttonSchema.allOf[0].$ref,
"common_types.json#/$defs/ComponentCommon",
);
assert.strictEqual(buttonSchema.allOf[1].properties.component.const, "Button");
assert.strictEqual(
buttonSchema.allOf[1].properties.label.description,
"The button label",
);
assert.deepStrictEqual(buttonSchema.allOf[1].required, ["component", "label"]);
});

it("transforms REF: descriptions into valid $ref nodes", () => {
const customApi: ComponentApi = {
name: "Custom",
schema: z.object({
title: z
.string()
.describe("REF:common_types.json#/$defs/DynamicString|The title"),
}),
};
const cat = new Catalog("cat-ref", [customApi]);
const proc = new MessageProcessor([cat]);

const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const titleSchema =
caps["v0.9"].inlineCatalogs![0].components!.Custom.allOf[1].properties.title;

assert.strictEqual(titleSchema.$ref, "common_types.json#/$defs/DynamicString");
assert.strictEqual(titleSchema.description, "The title");
// Ensure Zod's 'type: string' was removed
assert.strictEqual(titleSchema.type, undefined);
});

it("generates inline catalogs with functions and theme schema", () => {
const buttonApi: ComponentApi = {
name: "Button",
schema: z.object({
label: z.string(),
}),
};
const addFn = {
name: "add",
returnType: "number" as const,
schema: z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
}),
execute: (args: any) => args.a + args.b,
};

const themeSchema = z.object({
primaryColor: z.string().describe("REF:common_types.json#/$defs/Color|The main color"),
});

const cat = new Catalog("cat-full", [buttonApi], [addFn], themeSchema);
const proc = new MessageProcessor([cat]);

const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const inlineCat = caps["v0.9"].inlineCatalogs![0];

assert.strictEqual(inlineCat.catalogId, "cat-full");

// Verify Functions
assert.ok(inlineCat.functions);
assert.strictEqual(inlineCat.functions.length, 1);
const fn = inlineCat.functions[0];
assert.strictEqual(fn.name, "add");
assert.strictEqual(fn.returnType, "number");
assert.strictEqual(fn.parameters.properties.a.description, "First number");

// Verify Theme
assert.ok(inlineCat.theme);
assert.ok(inlineCat.theme.primaryColor);
assert.strictEqual(inlineCat.theme.primaryColor.$ref, "common_types.json#/$defs/Color");
assert.strictEqual(inlineCat.theme.primaryColor.description, "The main color");
});

it("omits functions and theme when catalog has none", () => {
const compApi: ComponentApi = { name: "EmptyComp", schema: z.object({}) };
const cat = new Catalog("cat-empty", [compApi]);
const proc = new MessageProcessor([cat]);
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const inlineCat = caps["v0.9"].inlineCatalogs![0];

assert.strictEqual(inlineCat.catalogId, "cat-empty");
assert.strictEqual(inlineCat.functions, undefined);
assert.strictEqual(inlineCat.theme, undefined);
});

it("processes REF: tags deeply nested in schema arrays and objects", () => {
const deepApi: ComponentApi = {
name: "DeepComp",
schema: z.object({
items: z.array(z.object({
action: z.string().describe("REF:common_types.json#/$defs/Action|The action to perform")
}))
})
};
const cat = new Catalog("cat-deep", [deepApi]);
const proc = new MessageProcessor([cat]);
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });

const properties = caps["v0.9"].inlineCatalogs![0].components!.DeepComp.allOf[1].properties;
const actionSchema = properties.items.items.properties.action;

assert.strictEqual(actionSchema.$ref, "common_types.json#/$defs/Action");
assert.strictEqual(actionSchema.description, "The action to perform");
assert.strictEqual(actionSchema.type, undefined);
});

it("handles REF: tags without pipes or with multiple pipes", () => {
const edgeApi: ComponentApi = {
name: "EdgeComp",
schema: z.object({
noPipe: z.string().describe("REF:common_types.json#/$defs/NoPipe"),
multiPipe: z.string().describe("REF:common_types.json#/$defs/MultiPipe|First|Second"),
})
};
const cat = new Catalog("cat-edge", [edgeApi]);
const proc = new MessageProcessor([cat]);
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });

const properties = caps["v0.9"].inlineCatalogs![0].components!.EdgeComp.allOf[1].properties;

assert.strictEqual(properties.noPipe.$ref, "common_types.json#/$defs/NoPipe");
assert.strictEqual(properties.noPipe.description, undefined);

assert.strictEqual(properties.multiPipe.$ref, "common_types.json#/$defs/MultiPipe");
assert.strictEqual(properties.multiPipe.description, "First");
});

it("handles multiple catalogs correctly", () => {
const compApi: ComponentApi = { name: "C1", schema: z.object({}) };
const cat1 = new Catalog("cat-1", [compApi]);

const addFn = {
name: "add",
returnType: "number" as const,
schema: z.object({}),
execute: () => 0,
};
const themeSchema = z.object({ color: z.string() });
const cat2 = new Catalog("cat-2", [], [addFn], themeSchema);

const proc = new MessageProcessor([cat1, cat2]);
const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });

assert.strictEqual(caps["v0.9"].inlineCatalogs!.length, 2);

const inlineCat1 = caps["v0.9"].inlineCatalogs![0];
assert.strictEqual(inlineCat1.catalogId, "cat-1");
assert.strictEqual(inlineCat1.functions, undefined);
assert.strictEqual(inlineCat1.theme, undefined);

const inlineCat2 = caps["v0.9"].inlineCatalogs![1];
assert.strictEqual(inlineCat2.catalogId, "cat-2");
assert.strictEqual(inlineCat2.functions!.length, 1);
assert.ok(inlineCat2.theme);
});
});

it("creates surface", () => {
processor.processMessages([
{
Expand Down
Loading
Loading