diff --git a/renderers/angular/package-lock.json b/renderers/angular/package-lock.json index 05632b66f..7af8037d8 100644 --- a/renderers/angular/package-lock.json +++ b/renderers/angular/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a2ui/angular", - "version": "0.8.4", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2ui/angular", - "version": "0.8.4", + "version": "0.8.5", "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "file:../web_core", @@ -48,10 +48,11 @@ }, "../web_core": { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.8.5", "license": "Apache-2.0", "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/renderers/lit/package-lock.json b/renderers/lit/package-lock.json index bc14ae01b..77acb5c38 100644 --- a/renderers/lit/package-lock.json +++ b/renderers/lit/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a2ui/lit", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2ui/lit", - "version": "0.8.1", + "version": "0.8.2", "license": "Apache-2.0", "dependencies": { "@a2ui/web_core": "file:../web_core", @@ -24,10 +24,11 @@ }, "../web_core": { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.8.5", "license": "Apache-2.0", "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": { @@ -376,13 +377,6 @@ "queue-microtask": "^1.2.2" } }, - "../web_core/node_modules/rxjs": { - "version": "7.8.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "../web_core/node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -399,10 +393,6 @@ "node": ">=8.0" } }, - "../web_core/node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, "../web_core/node_modules/typescript": { "version": "5.9.3", "dev": true, diff --git a/renderers/markdown/markdown-it/package-lock.json b/renderers/markdown/markdown-it/package-lock.json index 952ed34ec..8a31c15cc 100644 --- a/renderers/markdown/markdown-it/package-lock.json +++ b/renderers/markdown/markdown-it/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a2ui/markdown-it", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2ui/markdown-it", - "version": "0.0.1", + "version": "0.0.2", "license": "Apache-2.0", "dependencies": { "dompurify": "^3.3.1", @@ -29,11 +29,12 @@ }, "../../web_core": { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.8.5", "dev": true, "license": "Apache-2.0", "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": { @@ -382,14 +383,6 @@ "queue-microtask": "^1.2.2" } }, - "../../web_core/node_modules/rxjs": { - "version": "7.8.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "../../web_core/node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -406,11 +399,6 @@ "node": ">=8.0" } }, - "../../web_core/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "license": "0BSD" - }, "../../web_core/node_modules/typescript": { "version": "5.9.3", "dev": true, diff --git a/renderers/web_core/CHANGELOG.md b/renderers/web_core/CHANGELOG.md index 3bdaa6c7f..b999f8e83 100644 --- a/renderers/web_core/CHANGELOG.md +++ b/renderers/web_core/CHANGELOG.md @@ -1,6 +1,15 @@ +## 0.8.6 + +- Update logical functions (`and`, `or`) to require a `values` array argument, removing deprecated individual arguments. +- Update `formatDate` to require `format` parameter to align with new configuration, utilizing `date-fns`. +- Add `date-fns` dependency for expression string formatting workflows. +- Update math and comparison expression schemas with preprocessing step to correctly coerce `null` parameters into `undefined` for tighter validation constraints. +- Fix associated tests in expressions and rendering models corresponding to validation updates. +- Improve error messages to include the function name and the catalog ID. + ## 0.8.5 -- Add `V8ErrorConstructor` interface to be able to access V8-only +- Add `V8ErrorConstructor` interface to be able to access V8-only `captureStackTrace` method in errors. - Removes dependency from `v0_8` to `v0_9` by duplicating the `errors.ts` file. diff --git a/renderers/web_core/package-lock.json b/renderers/web_core/package-lock.json index 0d8881461..721db3ea5 100644 --- a/renderers/web_core/package-lock.json +++ b/renderers/web_core/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": { @@ -56,7 +57,6 @@ }, "node_modules/@preact/signals-core": { "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz", "integrity": "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==", "license": "MIT", "funding": { @@ -65,8 +65,8 @@ } }, "node_modules/@types/node": { - "version": "24.11.0", - "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "version": "24.12.0", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -155,6 +155,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 5b0e275d5..e18205d97 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -1,6 +1,6 @@ { "name": "@a2ui/web_core", - "version": "0.8.5", + "version": "0.8.6", "description": "A2UI Core Library", "main": "./dist/src/v0_8/index.js", "types": "./dist/src/v0_8/index.d.ts", @@ -93,6 +93,7 @@ }, "dependencies": { "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" } } diff --git a/renderers/web_core/src/v0_8/data/guards.test.ts b/renderers/web_core/src/v0_8/data/guards.test.ts index c736e3fb7..215c35114 100644 --- a/renderers/web_core/src/v0_8/data/guards.test.ts +++ b/renderers/web_core/src/v0_8/data/guards.test.ts @@ -26,7 +26,10 @@ describe("v0.8 Guards", () => { describe("Basics", () => { it("isValueMap", () => { - assert.strictEqual(guards.isValueMap({ key: "k1", valueString: "v1" }), true); + assert.strictEqual( + guards.isValueMap({ key: "k1", valueString: "v1" }), + true, + ); assert.strictEqual(guards.isValueMap({ notKey: "k1" }), false); assert.strictEqual(guards.isValueMap(null), false); assert.strictEqual(guards.isValueMap("string"), false); @@ -46,8 +49,14 @@ describe("v0.8 Guards", () => { }); it("isComponentArrayReference", () => { - assert.strictEqual(guards.isComponentArrayReference({ explicitList: ["1", "2"] }), true); - assert.strictEqual(guards.isComponentArrayReference({ template: {} }), true); + assert.strictEqual( + guards.isComponentArrayReference({ explicitList: ["1", "2"] }), + true, + ); + assert.strictEqual( + guards.isComponentArrayReference({ template: {} }), + true, + ); assert.strictEqual(guards.isComponentArrayReference({}), false); assert.strictEqual(guards.isComponentArrayReference(null), false); }); @@ -55,108 +64,186 @@ describe("v0.8 Guards", () => { describe("Component Resolution Guards", () => { it("isResolvedAudioPlayer", () => { - assert.strictEqual(guards.isResolvedAudioPlayer({ url: validStringValue }), true); + assert.strictEqual( + guards.isResolvedAudioPlayer({ url: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedAudioPlayer({ url: 42 }), false); assert.strictEqual(guards.isResolvedAudioPlayer({}), false); }); it("isResolvedButton", () => { - assert.strictEqual(guards.isResolvedButton({ child: validComponentNode, action: {} }), true); - assert.strictEqual(guards.isResolvedButton({ child: validComponentNode }), false); // missing action + assert.strictEqual( + guards.isResolvedButton({ child: validComponentNode, action: {} }), + true, + ); + assert.strictEqual( + guards.isResolvedButton({ child: validComponentNode }), + false, + ); // missing action assert.strictEqual(guards.isResolvedButton({ action: {} }), false); // missing child assert.strictEqual(guards.isResolvedButton({}), false); }); it("isResolvedCard", () => { - assert.strictEqual(guards.isResolvedCard({ child: validComponentNode }), true); - assert.strictEqual(guards.isResolvedCard({ children: [validComponentNode] }), true); - assert.strictEqual(guards.isResolvedCard({ children: "not array" }), false); + assert.strictEqual( + guards.isResolvedCard({ child: validComponentNode }), + true, + ); + assert.strictEqual( + guards.isResolvedCard({ children: [validComponentNode] }), + true, + ); + assert.strictEqual( + guards.isResolvedCard({ children: "not array" }), + false, + ); assert.strictEqual(guards.isResolvedCard({}), false); assert.strictEqual(guards.isResolvedCard(null), false); }); it("isResolvedCheckbox", () => { - assert.strictEqual(guards.isResolvedCheckbox({ label: validStringValue, value: validBooleanValue }), true); - assert.strictEqual(guards.isResolvedCheckbox({ label: validStringValue }), false); - assert.strictEqual(guards.isResolvedCheckbox({ value: validBooleanValue }), false); + assert.strictEqual( + guards.isResolvedCheckbox({ + label: validStringValue, + value: validBooleanValue, + }), + true, + ); + assert.strictEqual( + guards.isResolvedCheckbox({ label: validStringValue }), + false, + ); + assert.strictEqual( + guards.isResolvedCheckbox({ value: validBooleanValue }), + false, + ); }); it("isResolvedColumn", () => { - assert.strictEqual(guards.isResolvedColumn({ children: [validComponentNode] }), true); + assert.strictEqual( + guards.isResolvedColumn({ children: [validComponentNode] }), + true, + ); assert.strictEqual(guards.isResolvedColumn({ children: {} }), false); assert.strictEqual(guards.isResolvedColumn({}), false); }); it("isResolvedDateTimeInput", () => { - assert.strictEqual(guards.isResolvedDateTimeInput({ value: validStringValue }), true); + assert.strictEqual( + guards.isResolvedDateTimeInput({ value: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedDateTimeInput({}), false); }); it("isResolvedDivider", () => { - assert.strictEqual(guards.isResolvedDivider({ anyOptionalProp: true }), true); + assert.strictEqual( + guards.isResolvedDivider({ anyOptionalProp: true }), + true, + ); assert.strictEqual(guards.isResolvedDivider(null), false); }); it("isResolvedImage", () => { - assert.strictEqual(guards.isResolvedImage({ url: validStringValue }), true); + assert.strictEqual( + guards.isResolvedImage({ url: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedImage({}), false); }); it("isResolvedIcon", () => { - assert.strictEqual(guards.isResolvedIcon({ name: validStringValue }), true); + assert.strictEqual( + guards.isResolvedIcon({ name: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedIcon({}), false); }); it("isResolvedList", () => { - assert.strictEqual(guards.isResolvedList({ children: [validComponentNode] }), true); + assert.strictEqual( + guards.isResolvedList({ children: [validComponentNode] }), + true, + ); assert.strictEqual(guards.isResolvedList({ children: {} }), false); assert.strictEqual(guards.isResolvedList({}), false); }); it("isResolvedModal", () => { assert.strictEqual( - guards.isResolvedModal({ entryPointChild: validComponentNode, contentChild: validComponentNode }), - true + guards.isResolvedModal({ + entryPointChild: validComponentNode, + contentChild: validComponentNode, + }), + true, + ); + assert.strictEqual( + guards.isResolvedModal({ entryPointChild: validComponentNode }), + false, ); - assert.strictEqual(guards.isResolvedModal({ entryPointChild: validComponentNode }), false); }); it("isResolvedMultipleChoice", () => { - assert.strictEqual(guards.isResolvedMultipleChoice({ selections: [] }), true); + assert.strictEqual( + guards.isResolvedMultipleChoice({ selections: [] }), + true, + ); assert.strictEqual(guards.isResolvedMultipleChoice({}), false); }); it("isResolvedRow", () => { - assert.strictEqual(guards.isResolvedRow({ children: [validComponentNode] }), true); + assert.strictEqual( + guards.isResolvedRow({ children: [validComponentNode] }), + true, + ); assert.strictEqual(guards.isResolvedRow({ children: {} }), false); assert.strictEqual(guards.isResolvedRow({}), false); }); it("isResolvedSlider", () => { - assert.strictEqual(guards.isResolvedSlider({ value: validNumberValue }), true); + assert.strictEqual( + guards.isResolvedSlider({ value: validNumberValue }), + true, + ); assert.strictEqual(guards.isResolvedSlider({}), false); }); it("isResolvedTabs (and isResolvedTabItem)", () => { const validItem = { title: validStringValue, child: validComponentNode }; - assert.strictEqual(guards.isResolvedTabs({ tabItems: [validItem] }), true); - assert.strictEqual(guards.isResolvedTabs({ tabItems: [{ title: validStringValue }] }), false); // missing child + assert.strictEqual( + guards.isResolvedTabs({ tabItems: [validItem] }), + true, + ); + assert.strictEqual( + guards.isResolvedTabs({ tabItems: [{ title: validStringValue }] }), + false, + ); // missing child assert.strictEqual(guards.isResolvedTabs({ tabItems: {} }), false); assert.strictEqual(guards.isResolvedTabs({}), false); }); it("isResolvedText", () => { - assert.strictEqual(guards.isResolvedText({ text: validStringValue }), true); + assert.strictEqual( + guards.isResolvedText({ text: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedText({}), false); }); it("isResolvedTextField", () => { - assert.strictEqual(guards.isResolvedTextField({ label: validStringValue }), true); + assert.strictEqual( + guards.isResolvedTextField({ label: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedTextField({}), false); }); it("isResolvedVideo", () => { - assert.strictEqual(guards.isResolvedVideo({ url: validStringValue }), true); + assert.strictEqual( + guards.isResolvedVideo({ url: validStringValue }), + true, + ); assert.strictEqual(guards.isResolvedVideo({}), false); }); }); @@ -164,24 +251,60 @@ describe("v0.8 Guards", () => { describe("Internal/Private structural guards via components", () => { it("isStringValue via Text", () => { assert.strictEqual(guards.isResolvedText({ text: { path: "." } }), true); - assert.strictEqual(guards.isResolvedText({ text: { literalString: "hello" } }), true); - assert.strictEqual(guards.isResolvedText({ text: { invalid: "string" } }), false); + assert.strictEqual( + guards.isResolvedText({ text: { literalString: "hello" } }), + true, + ); + assert.strictEqual( + guards.isResolvedText({ text: { invalid: "string" } }), + false, + ); }); it("isNumberValue via Slider", () => { - assert.strictEqual(guards.isResolvedSlider({ value: { path: "." } }), true); - assert.strictEqual(guards.isResolvedSlider({ value: { literalNumber: 42 } }), true); - assert.strictEqual(guards.isResolvedSlider({ value: { invalid: 42 } }), false); + assert.strictEqual( + guards.isResolvedSlider({ value: { path: "." } }), + true, + ); + assert.strictEqual( + guards.isResolvedSlider({ value: { literalNumber: 42 } }), + true, + ); + assert.strictEqual( + guards.isResolvedSlider({ value: { invalid: 42 } }), + false, + ); }); it("isBooleanValue via Checkbox", () => { - assert.strictEqual(guards.isResolvedCheckbox({ label: validStringValue, value: { path: "." } }), true); - assert.strictEqual(guards.isResolvedCheckbox({ label: validStringValue, value: { literalBoolean: true } }), true); - assert.strictEqual(guards.isResolvedCheckbox({ label: validStringValue, value: { invalid: true } }), false); + assert.strictEqual( + guards.isResolvedCheckbox({ + label: validStringValue, + value: { path: "." }, + }), + true, + ); + assert.strictEqual( + guards.isResolvedCheckbox({ + label: validStringValue, + value: { literalBoolean: true }, + }), + true, + ); + assert.strictEqual( + guards.isResolvedCheckbox({ + label: validStringValue, + value: { invalid: true }, + }), + false, + ); }); it("isAnyComponentNode edge cases via Row", () => { - assert.strictEqual(guards.isResolvedRow({ children: [{ id: "1", type: "Text" }] }), false); // missing properties + assert.strictEqual( + guards.isResolvedRow({ children: [{ id: "1", type: "Text" }] }), + false, + ); // missing properties assert.strictEqual(guards.isResolvedRow({ children: [null] }), false); assert.strictEqual(guards.isResolvedRow({ children: ["string"] }), false); }); diff --git a/renderers/web_core/src/v0_8/schema/common-types.ts b/renderers/web_core/src/v0_8/schema/common-types.ts index 1a2414fb5..5307a13ec 100644 --- a/renderers/web_core/src/v0_8/schema/common-types.ts +++ b/renderers/web_core/src/v0_8/schema/common-types.ts @@ -30,11 +30,14 @@ const exactlyOneKey = (val: any, ctx: z.RefinementCtx) => { } }; -export const StringValueSchema = z.object({ - path: z.string().optional(), - literalString: z.string().optional(), - literal: z.string().optional(), -}).strict().superRefine(exactlyOneKey); +export const StringValueSchema = z + .object({ + path: z.string().optional(), + literalString: z.string().optional(), + literal: z.string().optional(), + }) + .strict() + .superRefine(exactlyOneKey); export type StringValue = z.infer; const DataValueMapItemSchema: z.ZodType = z.lazy(() => @@ -70,7 +73,8 @@ export const DataValueSchema = z valueBoolean: z.boolean().optional(), valueMap: z.array(DataValueMapItemSchema).optional(), }) - .strict().superRefine((val: any, ctx: z.RefinementCtx) => { + .strict() + .superRefine((val: any, ctx: z.RefinementCtx) => { let count = 0; if (val.valueString !== undefined) count++; if (val.valueNumber !== undefined) count++; @@ -82,7 +86,8 @@ export const DataValueSchema = z message: `Value must have exactly one value property (valueString, valueNumber, valueBoolean, valueMap), found ${count}.`, }); } - }).superRefine((val: any, ctx: z.RefinementCtx) => { + }) + .superRefine((val: any, ctx: z.RefinementCtx) => { const checkDepth = (v: any, currentDepth: number) => { if (currentDepth > 5) { ctx.addIssue({ @@ -100,18 +105,24 @@ export const DataValueSchema = z checkDepth(val, 1); }); -export const NumberValueSchema = z.object({ - path: z.string().optional(), - literalNumber: z.number().optional(), - literal: z.number().optional(), -}).strict().superRefine(exactlyOneKey); +export const NumberValueSchema = z + .object({ + path: z.string().optional(), + literalNumber: z.number().optional(), + literal: z.number().optional(), + }) + .strict() + .superRefine(exactlyOneKey); export type NumberValue = z.infer; -export const BooleanValueSchema = z.object({ - path: z.string().optional(), - literalBoolean: z.boolean().optional(), - literal: z.boolean().optional(), -}).strict().superRefine(exactlyOneKey); +export const BooleanValueSchema = z + .object({ + path: z.string().optional(), + literalBoolean: z.boolean().optional(), + literal: z.boolean().optional(), + }) + .strict() + .superRefine(exactlyOneKey); export type BooleanValue = z.infer; /** @@ -140,9 +151,11 @@ export const ActionSchema = z.object({ literalNumber: z.number().optional(), literalBoolean: z.boolean().optional(), }) - .describe( - "The dynamic value. Define EXACTLY ONE of the nested properties.", - ).strict().superRefine(exactlyOneKey), + .describe( + "The dynamic value. Define EXACTLY ONE of the nested properties.", + ) + .strict() + .superRefine(exactlyOneKey), }), ) .describe( @@ -196,33 +209,42 @@ export const AudioPlayerSchema = z.object({ export const TabsSchema = z.object({ tabItems: z .array( - z.object({ - title: z.object({ - path: z - .string() - .describe( - "A data binding reference to a location in the data model (e.g., '/user/name').", - ) - .optional(), - literalString: z + z + .object({ + title: z.object({ + path: z + .string() + .describe( + "A data binding reference to a location in the data model (e.g., '/user/name').", + ) + .optional(), + literalString: z + .string() + .describe("A fixed, hardcoded string value.") + .optional(), + }), + child: z .string() - .describe("A fixed, hardcoded string value.") - .optional(), + .describe("A reference to a component instance by its unique ID."), + }) + .strict() + .superRefine((val: any, ctx: z.RefinementCtx) => { + if (!val.title) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Tab item is missing 'title'.", + }); + } + if (!val.child) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Tab item is missing 'child'.", + }); + } + if (val.title) { + exactlyOneKey(val.title, ctx); + } }), - child: z - .string() - .describe("A reference to a component instance by its unique ID."), - }).strict().superRefine((val: any, ctx: z.RefinementCtx) => { - if (!val.title) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Tab item is missing 'title'." }); - } - if (!val.child) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Tab item is missing 'child'." }); - } - if (val.title) { - exactlyOneKey(val.title, ctx); - } - }), ) .describe("A list of tabs, each with a title and a child component ID."), }); @@ -265,15 +287,18 @@ export const ButtonSchema = z.object({ export const CheckboxSchema = z.object({ label: StringValueSchema, - value: z.object({ - path: z - .string() - .describe( - "A data binding reference to a location in the data model (e.g., '/user/name').", - ) - .optional(), - literalBoolean: z.boolean().optional(), - }).strict().superRefine(exactlyOneKey), + value: z + .object({ + path: z + .string() + .describe( + "A data binding reference to a location in the data model (e.g., '/user/name').", + ) + .optional(), + literalBoolean: z.boolean().optional(), + }) + .strict() + .superRefine(exactlyOneKey), }); export const TextFieldSchema = z.object({ @@ -297,30 +322,36 @@ export const DateTimeInputSchema = z.object({ }); export const MultipleChoiceSchema = z.object({ - selections: z.object({ - path: z - .string() - .describe( - "A data binding reference to a location in the data model (e.g., '/user/name').", - ) - .optional(), - literalArray: z.array(z.string()).optional(), - }).strict().superRefine(exactlyOneKey), + selections: z + .object({ + path: z + .string() + .describe( + "A data binding reference to a location in the data model (e.g., '/user/name').", + ) + .optional(), + literalArray: z.array(z.string()).optional(), + }) + .strict() + .superRefine(exactlyOneKey), options: z .array( z.object({ - label: z.object({ - path: z - .string() - .describe( - "A data binding reference to a location in the data model (e.g., '/user/name').", - ) - .optional(), - literalString: z - .string() - .describe("A fixed, hardcoded string value.") - .optional(), - }).strict().superRefine(exactlyOneKey), + label: z + .object({ + path: z + .string() + .describe( + "A data binding reference to a location in the data model (e.g., '/user/name').", + ) + .optional(), + literalString: z + .string() + .describe("A fixed, hardcoded string value.") + .optional(), + }) + .strict() + .superRefine(exactlyOneKey), value: z.string(), }), ) @@ -331,15 +362,18 @@ export const MultipleChoiceSchema = z.object({ }); export const SliderSchema = z.object({ - value: z.object({ - path: z - .string() - .describe( - "A data binding reference to a location in the data model (e.g., '/user/name').", - ) - .optional(), - literalNumber: z.number().optional(), - }).strict().superRefine(exactlyOneKey), + value: z + .object({ + path: z + .string() + .describe( + "A data binding reference to a location in the data model (e.g., '/user/name').", + ) + .optional(), + literalNumber: z.number().optional(), + }) + .strict() + .superRefine(exactlyOneKey), minValue: z.number().optional(), maxValue: z.number().optional(), }); @@ -349,12 +383,15 @@ export const ComponentArrayTemplateSchema = z.object({ dataBinding: z.string(), }); -export const ComponentArrayReferenceSchema = z.object({ - explicitList: z.array(z.string()).optional(), - template: ComponentArrayTemplateSchema.describe( - "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", - ).optional(), -}).strict().superRefine(exactlyOneKey); +export const ComponentArrayReferenceSchema = z + .object({ + explicitList: z.array(z.string()).optional(), + template: ComponentArrayTemplateSchema.describe( + "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.", + ).optional(), + }) + .strict() + .superRefine(exactlyOneKey); export const RowSchema = z.object({ children: ComponentArrayReferenceSchema, diff --git a/renderers/web_core/src/v0_8/schema/server-to-client.ts b/renderers/web_core/src/v0_8/schema/server-to-client.ts index 7d0f282b9..ec5d1e86e 100644 --- a/renderers/web_core/src/v0_8/schema/server-to-client.ts +++ b/renderers/web_core/src/v0_8/schema/server-to-client.ts @@ -37,7 +37,6 @@ import { DataValueSchema, } from "./common-types.js"; - const validateValueProperty = (val: any, ctx: z.RefinementCtx) => { let count = 0; if (val.valueString !== undefined) count++; @@ -180,14 +179,22 @@ export const SurfaceUpdateMessageSchema = z if (properties.children && !Array.isArray(properties.children)) { const hasExplicit = !!properties.children.explicitList; const hasTemplate = !!properties.children.template; - if ((hasExplicit && hasTemplate) || (!hasExplicit && !hasTemplate)) { + if ( + (hasExplicit && hasTemplate) || + (!hasExplicit && !hasTemplate) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Component '${component.id}' must have either 'explicitList' or 'template' in children, but not both or neither.`, }); } - if (hasExplicit) checkRefs(properties.children.explicitList, component.id); - if (hasTemplate) checkRefs([properties.children.template?.componentId], component.id); + if (hasExplicit) + checkRefs(properties.children.explicitList, component.id); + if (hasTemplate) + checkRefs( + [properties.children.template?.componentId], + component.id, + ); } break; case "Card": @@ -201,7 +208,10 @@ export const SurfaceUpdateMessageSchema = z } break; case "Modal": - checkRefs([properties.entryPointChild, properties.contentChild], component.id); + checkRefs( + [properties.entryPointChild, properties.contentChild], + component.id, + ); break; case "Button": if (properties.child) checkRefs([properties.child], component.id); @@ -253,11 +263,19 @@ export const A2uiMessageSchema = z }) .strict() .superRefine((data, ctx) => { - const keys = Object.keys(data).filter(k => ["beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface"].includes(k)); + const keys = Object.keys(data).filter((k) => + [ + "beginRendering", + "surfaceUpdate", + "dataModelUpdate", + "deleteSurface", + ].includes(k), + ); if (keys.length !== 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "A2UI Protocol message must have exactly one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface.", + message: + "A2UI Protocol message must have exactly one of: surfaceUpdate, dataModelUpdate, beginRendering, deleteSurface.", }); } }) diff --git a/renderers/web_core/src/v0_8/styles/icons.ts b/renderers/web_core/src/v0_8/styles/icons.ts index e62d7f509..15e5cec70 100644 --- a/renderers/web_core/src/v0_8/styles/icons.ts +++ b/renderers/web_core/src/v0_8/styles/icons.ts @@ -29,7 +29,7 @@ export const icons = ` font-weight: normal; font-style: normal; font-display: optional; - font-size: 20px; + font-size: 24px; width: 1em; height: 1em; user-select: none; @@ -40,8 +40,11 @@ export const icons = ` white-space: nowrap; word-wrap: normal; direction: ltr; + font-feature-settings: "liga"; -webkit-font-feature-settings: "liga"; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + -moz-osx-font-smoothing: grayscale; overflow: hidden; font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 48, diff --git a/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.test.ts b/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.test.ts index 78710eb84..20eb3d37f 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.test.ts @@ -18,7 +18,6 @@ import { describe, it, beforeEach } from "node:test"; import * as assert from "node:assert"; import { ExpressionParser } from "./expression_parser.js"; - describe("ExpressionParser", () => { let parser: ExpressionParser; diff --git a/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts b/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts index efef77670..416ceedd1 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts @@ -17,7 +17,15 @@ import { DynamicValue } from "../../schema/common-types.js"; import { A2uiExpressionError } from "../../errors.js"; +/** + * A parser for A2UI expressions, supporting string interpolation and functional calls. + * + * The parser converts strings with `${...}` placeholders into arrays of `DynamicValue`s. + * It supports literals (strings, numbers, booleans), path-based data bindings, and + * nested function calls with named arguments. + */ export class ExpressionParser { + /** The maximum allowed recursion depth for nested expressions to prevent stack overflows. */ private static readonly MAX_DEPTH = 10; /** @@ -104,6 +112,13 @@ export class ExpressionParser { /** * Parses a single expression string into a DynamicValue. + * + * Unlike `parse()`, which handles mixed literal text and interpolations, + * this assumes the entire string is a single expression (e.g., as found inside `${...}`). + * + * @param expr The expression string to parse. + * @param depth The current recursion depth. + * @returns The resolved DynamicValue. */ public parseExpression(expr: string, depth = 0): DynamicValue { expr = expr.trim(); diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index ad5b402f3..176631842 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -17,30 +17,52 @@ import { describe, it } from "node:test"; import * as assert from "node:assert"; import { effect } from "@preact/signals-core"; + import { BASIC_FUNCTIONS } from "./basic_functions.js"; import { DataModel } from "../../state/data-model.js"; import { DataContext } from "../../rendering/data-context.js"; +import { A2uiExpressionError } from "../../errors.js"; +import { Catalog, ComponentApi } from "../../catalog/types.js"; + +const testCatalog = new Catalog("test", [], BASIC_FUNCTIONS); function invoke(name: string, args: Record, context: DataContext) { - const fn = BASIC_FUNCTIONS.find(f => f.name === name); - if (!fn) throw new Error(`Function ${name} not found`); - return fn.execute(fn.schema.parse(args), context); + return testCatalog.invoker(name, args, context); } +const createTestDataContext = ( + model: DataModel, + path: string, + functionInvoker: any = testCatalog.invoker, +) => { + const mockSurface = { + dataModel: model, + catalog: { invoker: functionInvoker }, + dispatchError: () => {}, + } as any; + return new DataContext(mockSurface, path); +}; + describe("BASIC_FUNCTIONS", () => { const dataModel = new DataModel({ a: 10, b: 20 }); - const context = new DataContext(dataModel, "/", () => null); + const context = createTestDataContext(dataModel, "/"); describe("Arithmetic", () => { it("add", () => { assert.strictEqual(invoke("add", { a: 1, b: 2 }, context), 3); assert.strictEqual(invoke("add", { a: "1", b: "2" }, context), 3); + assert.throws(() => invoke("add", { a: 10, b: undefined }, context), A2uiExpressionError); + assert.throws(() => invoke("add", { a: 10 }, context), A2uiExpressionError); }); it("subtract", () => { assert.strictEqual(invoke("subtract", { a: 5, b: 3 }, context), 2); + assert.throws(() => invoke("subtract", { a: 10, b: undefined }, context), A2uiExpressionError); + assert.throws(() => invoke("subtract", { a: 10 }, context), A2uiExpressionError); }); it("multiply", () => { assert.strictEqual(invoke("multiply", { a: 4, b: 2 }, context), 8); + assert.throws(() => invoke("multiply", { a: 10, b: undefined }, context), A2uiExpressionError); + assert.throws(() => invoke("multiply", { a: 10 }, context), A2uiExpressionError); }); it("divide", () => { assert.strictEqual(invoke("divide", { a: 10, b: 2 }, context), 5); @@ -48,23 +70,11 @@ describe("BASIC_FUNCTIONS", () => { invoke("divide", { a: 10, b: 0 }, context), Infinity, ); - assert.ok( - Number.isNaN(invoke("divide", { a: 10, b: undefined }, context)), - ); - assert.ok( - Number.isNaN(invoke("divide", { a: undefined, b: 10 }, context)), - ); - assert.ok( - Number.isNaN( - invoke("divide", { a: undefined, b: undefined }, context), - ), - ); - assert.ok( - Number.isNaN(invoke("divide", { a: 10, b: null }, context)), - ); - assert.ok( - Number.isNaN(invoke("divide", { a: 10, b: "invalid" }, context)), - ); + assert.throws(() => invoke("divide", { a: 10, b: undefined }, context), A2uiExpressionError); + assert.throws(() => invoke("divide", { a: undefined, b: 10 }, context), A2uiExpressionError); + assert.throws(() => invoke("divide", { a: undefined, b: undefined }, context), A2uiExpressionError); + assert.throws(() => invoke("divide", { a: 10, b: null }, context), A2uiExpressionError); + assert.throws(() => invoke("divide", { a: 10, b: "invalid" }, context), A2uiExpressionError); assert.strictEqual(invoke("divide", { a: 10, b: "2" }, context), 5); assert.strictEqual( invoke("divide", { a: "10", b: "2" }, context), @@ -80,12 +90,28 @@ describe("BASIC_FUNCTIONS", () => { invoke("equals", { a: 1, b: 2 }, context), false, ); + assert.throws( + () => invoke("equals", { a: 1 }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("equals", { b: 1 }, context), + A2uiExpressionError, + ); }); it("not_equals", () => { assert.strictEqual( invoke("not_equals", { a: 1, b: 2 }, context), true, ); + assert.throws( + () => invoke("not_equals", { a: 1 }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("not_equals", { b: 1 }, context), + A2uiExpressionError, + ); }); it("greater_than", () => { assert.strictEqual( @@ -96,12 +122,52 @@ describe("BASIC_FUNCTIONS", () => { invoke("greater_than", { a: 3, b: 5 }, context), false, ); + assert.throws( + () => invoke("greater_than", { a: 10, b: undefined }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("greater_than", { a: 10, b: null }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("greater_than", { a: 10, b: "invalid" }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("greater_than", { a: 10 }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("greater_than", { b: 10 }, context), + A2uiExpressionError, + ); }); it("less_than", () => { assert.strictEqual( invoke("less_than", { a: 3, b: 5 }, context), true, ); + assert.throws( + () => invoke("less_than", { a: 3, b: undefined }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("less_than", { a: 3, b: null }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("less_than", { a: 3, b: "invalid" }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("less_than", { a: 3 }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("less_than", { b: 3 }, context), + A2uiExpressionError, + ); }); }); @@ -116,9 +182,13 @@ describe("BASIC_FUNCTIONS", () => { invoke("and", { values: [true, false] }, context), false, ); - assert.strictEqual( - invoke("and", { a: true, b: true }, context), - true, + assert.throws( + () => invoke("and", { values: [true] }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("and", {}, context), + A2uiExpressionError, ); }); it("or", () => { @@ -130,45 +200,77 @@ describe("BASIC_FUNCTIONS", () => { invoke("or", { values: [false, false] }, context), false, ); - assert.strictEqual( - invoke("or", { a: false, b: true }, context), - true, + assert.throws( + () => invoke("or", { values: [true] }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("or", {}, context), + A2uiExpressionError, ); }); it("not", () => { assert.strictEqual(invoke("not", { value: false }, context), true); assert.strictEqual(invoke("not", { value: true }, context), false); + assert.throws( + () => invoke("not", {}, context), + A2uiExpressionError, + ); }); }); describe("String", () => { it("contains", () => { assert.strictEqual( - invoke("contains", + invoke("contains", { string: "hello world", substring: "world" }, context, ), true, ); assert.strictEqual( - invoke("contains", + invoke("contains", { string: "hello world", substring: "foo" }, context, ), false, ); + assert.throws( + () => invoke("contains", { string: "hello" }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("contains", { substring: "hello" }, context), + A2uiExpressionError, + ); }); it("starts_with", () => { assert.strictEqual( invoke("starts_with", { string: "hello", prefix: "he" }, context), true, ); + assert.throws( + () => invoke("starts_with", { string: "hello" }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("starts_with", { prefix: "he" }, context), + A2uiExpressionError, + ); }); it("ends_with", () => { assert.strictEqual( invoke("ends_with", { string: "hello", suffix: "lo" }, context), true, ); + assert.throws( + () => invoke("ends_with", { string: "hello" }, context), + A2uiExpressionError, + ); + assert.throws( + () => invoke("ends_with", { suffix: "lo" }, context), + A2uiExpressionError, + ); }); }); @@ -186,6 +288,10 @@ describe("BASIC_FUNCTIONS", () => { invoke("required", { value: null }, context), false, ); + assert.throws( + () => invoke("required", {}, context), + A2uiExpressionError, + ); }); it("length", () => { @@ -197,6 +303,10 @@ describe("BASIC_FUNCTIONS", () => { invoke("length", { value: "abc", max: 2 }, context), false, ); + assert.throws( + () => invoke("length", {}, context), + A2uiExpressionError, + ); }); it("numeric", () => { @@ -208,16 +318,26 @@ describe("BASIC_FUNCTIONS", () => { invoke("numeric", { value: 3, min: 5 }, context), false, ); + assert.throws( + () => invoke("numeric", {}, context), + A2uiExpressionError, + ); }); it("email", () => { - assert.strictEqual( - invoke("email", { value: "test@example.com" }, context), - true, - ); - assert.strictEqual( - invoke("email", { value: "invalid" }, context), - false, + assert.strictEqual(invoke("email", { value: "test@example.com" }, context), true); + assert.strictEqual(invoke("email", { value: "test.name@example.com" }, context), true); + assert.strictEqual(invoke("email", { value: "test+label@example.com" }, context), true); + assert.strictEqual(invoke("email", { value: "test@example-domain.com" }, context), true); + + assert.strictEqual(invoke("email", { value: "invalid" }, context), false); + assert.strictEqual(invoke("email", { value: "test@test" }, context), false); + assert.strictEqual(invoke("email", { value: "test@test.c" }, context), false); + assert.strictEqual(invoke("email", { value: "test@.com" }, context), false); + + assert.throws( + () => invoke("email", {}, context), + A2uiExpressionError, ); }); @@ -233,16 +353,16 @@ describe("BASIC_FUNCTIONS", () => { }); it("regex handles invalid pattern", () => { - assert.strictEqual( - invoke("regex", { value: "abc", pattern: "[" }, context), - false, // fallback when regex throws + assert.throws( + () => invoke("regex", { value: "abc", pattern: "[" }, context), + A2uiExpressionError, ); }); }); describe("Formatting", () => { it("formatString (static literal)", (_, done) => { - const result = invoke("formatString", + const result = invoke("formatString", { value: "hello world" }, context, ) as import("@preact/signals-core").Signal; @@ -260,7 +380,7 @@ describe("BASIC_FUNCTIONS", () => { it("formatString (with data binding)", (_, done) => { // Assuming dataModel has { "a": 10 } from setup - const result = invoke("formatString", + const result = invoke("formatString", { value: "Value: ${a}" }, context, ) as import("@preact/signals-core").Signal; @@ -292,14 +412,14 @@ describe("BASIC_FUNCTIONS", () => { it("formatString (with function call)", (_, done) => { // Need a functionInvoker for function calls - const ctxWithInvoker = new DataContext(dataModel, "/", (name, args) => { + const ctxWithInvoker = createTestDataContext(dataModel, "/", (name: string, args: any) => { if (name === "add") { return Number(args["a"]) + Number(args["b"]); } return null; }); - const result = invoke("formatString", + const result = invoke("formatString", { value: "Result: ${add(a: 5, b: 7)}" }, ctxWithInvoker, ) as import("@preact/signals-core").Signal; @@ -317,7 +437,7 @@ describe("BASIC_FUNCTIONS", () => { it("formatNumber", () => { // Test basic output as Intl behavior varies by environment. - const result = invoke("formatNumber", + const result = invoke("formatNumber", { value: 1234.56, decimals: 1 }, context, ); @@ -330,7 +450,7 @@ describe("BASIC_FUNCTIONS", () => { }); it("formatCurrency", () => { - const result = invoke("formatCurrency", + const result = invoke("formatCurrency", { value: 1234.56, currency: "USD" }, context, ); @@ -340,51 +460,29 @@ describe("BASIC_FUNCTIONS", () => { }); it("formatDate", () => { - const result = invoke("formatDate", - { value: "2025-01-01T00:00:00Z" }, + const result = invoke("formatDate", + { value: "2025-01-01T12:00:00Z", format: "yyyy-MM-dd" }, context, ); - assert.ok(typeof result === "string"); - assert.ok(result.length > 0); + assert.strictEqual(result, "2025-01-01"); - const resultISO = invoke("formatDate", - { value: "2025-01-01T00:00:00Z", format: "ISO" }, + const resultISO = invoke("formatDate", + { value: "2025-01-01T12:00:00Z", format: "ISO" }, context, ); - assert.strictEqual(resultISO, "2025-01-01T00:00:00.000Z"); + assert.strictEqual(resultISO, "2025-01-01T12:00:00.000Z"); }); it("formatDate handles invalid dates", () => { - const result = invoke("formatDate", - { value: "invalid-date" }, + const result = invoke("formatDate", + { value: "invalid-date", format: "yyyy" }, context, ); assert.strictEqual(result, ""); }); - it("formatDate uses options properly", () => { - const result = invoke("formatDate", - { - value: "2025-01-01T00:00:00Z", - options: { year: "numeric", timeZone: "UTC" }, - }, - context, - ); - assert.ok(typeof result === "string"); - assert.ok(result.includes("2025"), `Result was: ${result}`); - }); - - it("formatDate fallback on formatting error", () => { - const result = invoke("formatDate", - { value: "2025-01-01T00:00:00Z", locale: "invalid-locale-!!!11123" }, - context, - ); - // It should fallback to .toISOString() which starts with 2025 - assert.ok(typeof result === "string" && result.includes("2025")); - }); - it("formatCurrency fallback on formatting error", () => { - const result = invoke("formatCurrency", + const result = invoke("formatCurrency", { value: 1234.56, currency: "INVALID-CURRENCY", decimals: 2 }, context, ); @@ -394,14 +492,14 @@ describe("BASIC_FUNCTIONS", () => { it("pluralize", () => { assert.strictEqual( - invoke("pluralize", + invoke("pluralize", { value: 1, one: "apple", other: "apples" }, context, ), "apple", ); assert.strictEqual( - invoke("pluralize", + invoke("pluralize", { value: 2, one: "apple", other: "apples" }, context, ), @@ -424,6 +522,10 @@ describe("BASIC_FUNCTIONS", () => { try { invoke("openUrl", { url: "https://google.com" }, context); assert.strictEqual(openedUrl, "https://google.com"); + assert.throws( + () => invoke("openUrl", {}, context), + A2uiExpressionError, + ); } finally { (global as any).window = originalWindow; } diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts index d8130b236..7a66362a5 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts @@ -17,6 +17,7 @@ import { ExpressionParser } from "../expressions/expression_parser.js"; import { computed, Signal } from "@preact/signals-core"; import { createFunctionImplementation, FunctionImplementation } from "../../catalog/types.js"; +import { format } from "date-fns"; import { AddApi, SubtractApi, @@ -44,6 +45,7 @@ import { PluralizeApi, OpenUrlApi } from "./basic_functions_api.js"; +import { A2uiExpressionError } from "../../errors.js"; // Arithmetic export const AddImplementation = createFunctionImplementation(AddApi, (args) => args.a + args.b); @@ -74,16 +76,10 @@ export const LessThanImplementation = createFunctionImplementation(LessThanApi, // Logical export const AndImplementation = createFunctionImplementation(AndApi, (args) => { - if (Array.isArray(args.values)) { - return args.values.every((v: unknown) => !!v); - } - return !!(args.a && args.b); // Fallback + return args.values.every((v: unknown) => !!v); }); export const OrImplementation = createFunctionImplementation(OrApi, (args) => { - if (Array.isArray(args.values)) { - return args.values.some((v: unknown) => !!v); - } - return !!(args.a || args.b); // Fallback + return args.values.some((v: unknown) => !!v); }); export const NotImplementation = createFunctionImplementation(NotApi, (args) => !args.value); @@ -104,8 +100,7 @@ export const RegexImplementation = createFunctionImplementation(RegexApi, (args) try { return new RegExp(args.pattern).test(args.value); } catch (e) { - console.warn("Invalid regex pattern:", args.pattern); - return false; + throw new A2uiExpressionError(`Invalid regex pattern: ${args.pattern}`, "regex", e); } }); export const LengthImplementation = createFunctionImplementation(LengthApi, (args) => { @@ -125,7 +120,10 @@ export const NumericImplementation = createFunctionImplementation(NumericApi, (a return true; }); export const EmailImplementation = createFunctionImplementation(EmailApi, (args) => { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(args.value); + // TODO(gspencergoog): Use a "real" email validation function, preferably + // from an existing package. This is woefully insufficient, real email + // validation can't be done with a regex. + return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(args.value); }); // Formatting @@ -183,12 +181,8 @@ export const FormatDateImplementation = createFunctionImplementation(FormatDateA if (isNaN(date.getTime())) return ""; try { - if (args.options) { - return new Intl.DateTimeFormat(args.locale, args.options).format(date); - } if (args.format === "ISO") return date.toISOString(); - - return new Intl.DateTimeFormat(args.locale).format(date); + return format(date, args.format); } catch (e) { console.warn("Error formatting date:", e); return date.toISOString(); diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts index 5b886ba1e..3cb227bb0 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts @@ -17,164 +17,286 @@ import { z } from "zod"; // Arithmetic +/** + * Adds two numbers. + * + * Arguments: + * - `a`: The first number. + * - `b`: The second number. + */ export const AddApi = { name: "add" as const, returnType: "number" as const, schema: z.object({ - a: z.coerce.number().default(0), - b: z.coerce.number().default(0), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; +/** + * Subtracts one number from another. + * + * Arguments: + * - `a`: The number to subtract from. + * - `b`: The number to subtract. + */ export const SubtractApi = { name: "subtract" as const, returnType: "number" as const, schema: z.object({ - a: z.coerce.number().default(0), - b: z.coerce.number().default(0), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; +/** + * Multiplies two numbers. + * + * Arguments: + * - `a`: The first number. + * - `b`: The second number. + */ export const MultiplyApi = { name: "multiply" as const, returnType: "number" as const, schema: z.object({ - a: z.coerce.number().default(0), - b: z.coerce.number().default(0), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; +/** + * Divides one number by another. + * + * Arguments: + * - `a`: The dividend. + * - `b`: The divisor. + */ export const DivideApi = { name: "divide" as const, returnType: "number" as const, schema: z.object({ - a: z.any(), - b: z.any(), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; // Comparison +/** + * Checks if two values are equal. + * + * Arguments: + * - `a`: The first value. + * - `b`: The second value. + */ export const EqualsApi = { name: "equals" as const, returnType: "boolean" as const, schema: z.object({ - a: z.any(), - b: z.any(), + a: z.any().refine(v => v !== undefined, "Required"), + b: z.any().refine(v => v !== undefined, "Required"), }) }; +/** + * Checks if two values are not equal. + * + * Arguments: + * - `a`: The first value. + * - `b`: The second value. + */ export const NotEqualsApi = { name: "not_equals" as const, returnType: "boolean" as const, schema: z.object({ - a: z.any(), - b: z.any(), + a: z.any().refine(v => v !== undefined, "Required"), + b: z.any().refine(v => v !== undefined, "Required"), }) }; +/** + * Checks if the first number is greater than the second. + * + * Arguments: + * - `a`: The number to compare. + * - `b`: The threshold number. + */ export const GreaterThanApi = { name: "greater_than" as const, returnType: "boolean" as const, schema: z.object({ - a: z.coerce.number().default(0), - b: z.coerce.number().default(0), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; +/** + * Checks if the first number is less than the second. + * + * Arguments: + * - `a`: The number to compare. + * - `b`: The threshold number. + */ export const LessThanApi = { name: "less_than" as const, returnType: "boolean" as const, schema: z.object({ - a: z.coerce.number().default(0), - b: z.coerce.number().default(0), + a: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), + b: z.preprocess((v) => (v === null ? undefined : v), z.coerce.number()), }) }; // Logical +/** + * Performs a logical AND operation on a list of boolean values. + * + * Arguments: + * - `values`: List of items to evaluate (minimum 2). + */ export const AndApi = { name: "and" as const, returnType: "boolean" as const, schema: z.object({ - values: z.array(z.any()).optional(), - a: z.any().optional(), - b: z.any().optional(), + values: z.array(z.any()).min(2), }) }; +/** + * Performs a logical OR operation on a list of boolean values. + * + * Arguments: + * - `values`: List of items to evaluate (minimum 2). + */ export const OrApi = { name: "or" as const, returnType: "boolean" as const, schema: z.object({ - values: z.array(z.any()).optional(), - a: z.any().optional(), - b: z.any().optional(), + values: z.array(z.any()).min(2), }) }; +/** + * Performs a logical NOT operation on a boolean value. + * + * Arguments: + * - `value`: The value to negate. + */ export const NotApi = { name: "not" as const, returnType: "boolean" as const, schema: z.object({ - value: z.any(), + value: z.any().refine(v => v !== undefined, "Required"), }) }; // String +/** + * Checks if a string contains a substring. + * + * Arguments: + * - `string`: The source string. + * - `substring`: The substring to search for. + */ export const ContainsApi = { name: "contains" as const, returnType: "boolean" as const, schema: z.object({ - string: z.coerce.string().default(""), - substring: z.coerce.string().default(""), + string: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), + substring: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; +/** + * Checks if a string starts with a prefix. + * + * Arguments: + * - `string`: The source string. + * - `prefix`: The prefix to search for. + */ export const StartsWithApi = { name: "starts_with" as const, returnType: "boolean" as const, schema: z.object({ - string: z.coerce.string().default(""), - prefix: z.coerce.string().default(""), + string: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), + prefix: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; +/** + * Checks if a string ends with a suffix. + * + * Arguments: + * - `string`: The source string. + * - `suffix`: The suffix to search for. + */ export const EndsWithApi = { name: "ends_with" as const, returnType: "boolean" as const, schema: z.object({ - string: z.coerce.string().default(""), - suffix: z.coerce.string().default(""), + string: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), + suffix: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; // Validation +/** + * Checks that the value is not null, undefined, or empty. + * + * Arguments: + * - `value`: The value to check. + */ export const RequiredApi = { name: "required" as const, returnType: "boolean" as const, schema: z.object({ - value: z.any(), + value: z.any().refine(v => v !== undefined, "Required"), }) }; +/** + * Checks that the value matches a regular expression string. + * + * Arguments: + * - `value`: The string to test. + * - `pattern`: The regex pattern string. + */ export const RegexApi = { name: "regex" as const, returnType: "boolean" as const, schema: z.object({ - value: z.coerce.string().default(""), - pattern: z.coerce.string().default(""), + value: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), + pattern: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; +/** + * Checks string length constraints. + * + * Arguments: + * - `value`: The value to inspect. + * - `min`: Optional minimum length. + * - `max`: Optional maximum length. + */ export const LengthApi = { name: "length" as const, returnType: "boolean" as const, schema: z.object({ - value: z.any(), + value: z.any().refine(v => v !== undefined, "Required"), min: z.coerce.number().optional(), max: z.coerce.number().optional(), + }).refine(data => data.min !== undefined || data.max !== undefined, { + message: "Must provide either 'min' or 'max'", }) }; +/** + * Checks numeric range constraints. + * + * Arguments: + * - `value`: The value to inspect. + * - `min`: Optional minimum value. + * - `max`: Optional maximum value. + */ export const NumericApi = { name: "numeric" as const, returnType: "boolean" as const, @@ -182,26 +304,58 @@ export const NumericApi = { value: z.coerce.number(), min: z.coerce.number().optional(), max: z.coerce.number().optional(), + }).refine(data => data.min !== undefined || data.max !== undefined, { + message: "Must provide either 'min' or 'max'", }) }; +/** + * Checks that the value is a valid email address. + * + * Arguments: + * - `value`: The string to inspect. + */ export const EmailApi = { name: "email" as const, returnType: "boolean" as const, schema: z.object({ - value: z.coerce.string().default(""), + value: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; // Formatting +/** + * Performs string interpolation on a value, resolving model paths and functions. + * + * Interpolation uses the `${expression}` syntax. Supported expressions include: + * - **JSON Pointer paths**: `${/absolute/path}` or `${relative/path}` to access data model values. + * - **Function calls**: `${now()}` or with named arguments like `${formatDate(value:${/currentDate}, format:'MM-dd')}`. + * + * To include a literal `${` sequence, escape it as `\\${`. + * + * @example + * "Hello ${/user/name}" + * "Total: ${formatCurrency(value:${/total}, currency:'USD')}" + * + * Arguments: + * - `value`: The string template to interpolate. + */ export const FormatStringApi = { name: "formatString" as const, returnType: "any" as const, schema: z.object({ - value: z.coerce.string().default(""), + value: z.coerce.string(), }) }; +/** + * Formats a number with the specified grouping and decimal precision. + * + * Arguments: + * - `value`: The number to format. + * - `decimals`: Optional number of decimal places. + * - `grouping`: Whether to use thousands separators, defaults to true. + */ export const FormatNumberApi = { name: "formatNumber" as const, returnType: "string" as const, @@ -212,51 +366,97 @@ export const FormatNumberApi = { }) }; +/** + * Formats a number as a currency string. + * + * Arguments: + * - `value`: The number to format. + * - `currency`: Currency code (e.g. "USD"), defaults to "USD". + * - `decimals`: Optional number of decimal places. + * - `grouping`: Whether to use thousands separators, defaults to true. + */ export const FormatCurrencyApi = { name: "formatCurrency" as const, returnType: "string" as const, schema: z.object({ value: z.coerce.number(), - currency: z.coerce.string().default("USD"), + currency: z.coerce.string(), decimals: z.coerce.number().optional(), grouping: z.boolean().default(true), }) }; +/** + * Formats a timestamp into a string using a pattern. + * + * Token Reference: + * - Year: 'yy' (26), 'yyyy' (2026) + * - Month: 'M' (1), 'MM' (01), 'MMM' (Jan), 'MMMM' (January) + * - Day: 'd' (1), 'dd' (01), 'E' (Tue), 'EEEE' (Tuesday) + * - Hour (12h): 'h' (1-12), 'hh' (01-12) - requires 'a' for AM/PM + * - Hour (24h): 'H' (0-23), 'HH' (00-23) - Military Time + * - Minute: 'mm' (00-59), Second: 'ss' (00-59) + * - Period: 'a' (AM/PM) + * + * Arguments: + * - `value`: The date to format. + * - `format`: A Unicode TR35 date pattern string. + */ export const FormatDateApi = { name: "formatDate" as const, returnType: "string" as const, schema: z.object({ - value: z.any(), - locale: z.coerce.string().default("en-US"), - options: z.any().optional(), - format: z.coerce.string().optional(), + value: z.any().refine(v => v !== undefined, "Required"), + format: z.coerce.string(), }) }; +/** + * Returns a localized string based on the Common Locale Data Repository (CLDR) plural category of the count. + * + * Requires an 'other' fallback. For English, just use 'one' and 'other'. + * + * Arguments: + * - `value`: Count to evaluate. + * - `zero`: Optional text for count 0. + * - `one`: Optional text for count 1. + * - `two`: Optional text for count 2. + * - `few`: Optional text for few items. + * - `many`: Optional text for many items. + * - `other`: Default text fallback. + */ export const PluralizeApi = { name: "pluralize" as const, returnType: "string" as const, schema: z.object({ - value: z.coerce.number().default(0), + value: z.coerce.number(), zero: z.coerce.string().optional(), one: z.coerce.string().optional(), two: z.coerce.string().optional(), few: z.coerce.string().optional(), many: z.coerce.string().optional(), - other: z.coerce.string().default(""), + other: z.coerce.string(), }).passthrough() }; // Actions +/** + * Opens the specified URL in a browser or handler. This function has no return value. + * + * Arguments: + * - `url`: The address URL string. + */ export const OpenUrlApi = { name: "openUrl" as const, returnType: "void" as const, schema: z.object({ - url: z.coerce.string().default(""), + url: z.preprocess(v => v === undefined ? undefined : String(v), z.string()), }) }; +/** + * Collection containing ALL available Basic Function API descriptors. + */ export const BASIC_FUNCTION_APIS = [ AddApi, SubtractApi, diff --git a/renderers/web_core/src/v0_9/catalog/function_invoker.ts b/renderers/web_core/src/v0_9/catalog/function_invoker.ts new file mode 100644 index 000000000..60db28468 --- /dev/null +++ b/renderers/web_core/src/v0_9/catalog/function_invoker.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DataContext } from "../rendering/data-context.js"; + +/** + * A function that invokes a catalog function by name and returns its result synchronously or as a Signal. + * + * @param name The name of the function to invoke. + * @param args The arguments to pass to the function. + * @param context The data context in which the function is being executed. + * @param abortSignal An optional AbortSignal for asynchronous or long-running operations. + * @returns The result of the function call, which can be a literal, a Signal, or a Promise (handled by the caller). + */ +export type FunctionInvoker = ( + name: string, + args: Record, + context: DataContext, + abortSignal?: AbortSignal, +) => any; diff --git a/renderers/web_core/src/v0_9/catalog/types.test.ts b/renderers/web_core/src/v0_9/catalog/types.test.ts index a21d6caa9..9471bc92a 100644 --- a/renderers/web_core/src/v0_9/catalog/types.test.ts +++ b/renderers/web_core/src/v0_9/catalog/types.test.ts @@ -17,6 +17,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import { Catalog, ComponentApi, createFunctionImplementation } from "./types.js"; +import { A2uiExpressionError } from "../errors.js"; import { z } from "zod"; describe("Catalog Types", () => { @@ -35,11 +36,7 @@ describe("Catalog Types", () => { () => "result" ); - const catalog = new Catalog( - "test-cat", - [mockComponent], - [mockFunc] - ); + const catalog = new Catalog("test-cat", [mockComponent], [mockFunc]); assert.strictEqual(catalog.id, "test-cat"); assert.strictEqual(catalog.components.size, 1); @@ -49,4 +46,43 @@ describe("Catalog Types", () => { assert.strictEqual(catalog.functions.size, 1); assert.strictEqual(catalog.functions.get("mockFunc"), mockFunc); }); + + it("throws A2uiExpressionError when function is not found", () => { + const catalog = new Catalog("test-cat", []); + const ctx = {} as any; + + assert.throws( + () => catalog.invoker("nonExistent", {}, ctx), + (err: any) => { + return err instanceof A2uiExpressionError && + err.message.includes("Function not found") && + err.expression === "nonExistent"; + } + ); + }); + + it("throws A2uiExpressionError when zod validation fails", () => { + const mockFunc = createFunctionImplementation( + { + name: "test", + returnType: "string", + schema: z.object({ + requiredField: z.string() + }) + }, + () => "result" + ); + const catalog = new Catalog("test-cat", [], [mockFunc]); + const ctx = {} as any; + + assert.throws( + () => catalog.invoker("test", {}, ctx), + (err: any) => { + return err instanceof A2uiExpressionError && + err.message.includes("Validation failed") && + err.expression === "test" && + Array.isArray(err.details); + } + ); + }); }); diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index 2e79fdaab..01c172fb7 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -17,10 +17,11 @@ import { z } from "zod"; import { DataContext } from "../rendering/data-context.js"; import { Signal } from "@preact/signals-core"; +import { A2uiExpressionError } from "../errors.js"; export type A2uiReturnType = 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void'; -export type InferA2uiReturnType = +export type InferA2uiReturnType = T extends 'string' ? string : T extends 'number' ? number : T extends 'boolean' ? boolean : @@ -68,15 +69,7 @@ export function createFunctionImplementation< }; } -/** - * A function that invokes a catalog function by name and returns its result synchronously or as a Signal. - */ -export type FunctionInvoker = ( - name: string, - args: Record, - context: DataContext, - abortSignal?: AbortSignal, -) => any; +import { FunctionInvoker } from "./function_invoker.js"; /** * A definition of a UI component's API. @@ -121,7 +114,7 @@ export class Catalog { constructor(id: string, components: T[], functions: FunctionImplementation[] = []) { this.id = id; - + const compMap = new Map(); for (const comp of components) { compMap.set(comp.name, comp); @@ -137,12 +130,23 @@ export class Catalog { this.invoker = (name, rawArgs, ctx, abortSignal) => { const fn = this.functions.get(name); if (!fn) { - throw new Error(`Function not found in catalog '${this.id}': ${name}`); + throw new A2uiExpressionError(`Function not found in catalog '${this.id}': ${name}`, name); } - + // Provides runtime safety: Coerces and strips invalid arguments before execute() - const safeArgs = fn.schema.parse(rawArgs); - return fn.execute(safeArgs, ctx, abortSignal); + try { + const safeArgs = fn.schema.parse(rawArgs); + return fn.execute(safeArgs, ctx, abortSignal); + } catch (e: any) { + if (e?.name === "ZodError" || e instanceof z.ZodError) { + throw new A2uiExpressionError( + `Validation failed for function '${name}': ${e.message}`, + name, + e.errors ?? e.issues + ); + } + throw e; + } }; } } \ No newline at end of file diff --git a/renderers/web_core/src/v0_9/errors.ts b/renderers/web_core/src/v0_9/errors.ts index 8b0ef2582..a64a3bc95 100644 --- a/renderers/web_core/src/v0_9/errors.ts +++ b/renderers/web_core/src/v0_9/errors.ts @@ -24,8 +24,12 @@ interface V8ErrorConstructor extends ErrorConstructor { /** * Base class for all A2UI specific errors. + * + * Includes a machine-readable `code` for categorical handling and ensures + * proper stack trace capturing. */ export class A2uiError extends Error { + /** A machine-readable string identifying the error category. */ public readonly code: string; constructor(message: string, code: string = "UNKNOWN_ERROR") { @@ -71,6 +75,7 @@ export class A2uiExpressionError extends A2uiError { constructor( message: string, public readonly expression?: string, + public readonly details?: any, ) { super(message, "EXPRESSION_ERROR"); } diff --git a/renderers/web_core/src/v0_9/index.ts b/renderers/web_core/src/v0_9/index.ts index 7e7b818d3..b1fcc3fd3 100644 --- a/renderers/web_core/src/v0_9/index.ts +++ b/renderers/web_core/src/v0_9/index.ts @@ -14,6 +14,14 @@ * limitations under the License. */ +/** + * Core rendering and state management logic for A2UI v0.9. + * + * This module exports the fundamental building blocks for building web-based A2UI renderers, + * including the data model, component model, and expression parsing logic. + */ + +export * from "./catalog/function_invoker.js"; export * from "./catalog/types.js"; export * from "./common/events.js"; export * from "./processing/message-processor.js"; diff --git a/renderers/web_core/src/v0_9/rendering/component-context.ts b/renderers/web_core/src/v0_9/rendering/component-context.ts index b085f469c..1d4bb2f5e 100644 --- a/renderers/web_core/src/v0_9/rendering/component-context.ts +++ b/renderers/web_core/src/v0_9/rendering/component-context.ts @@ -15,6 +15,7 @@ */ import { DataContext } from "./data-context.js"; +import { FunctionInvoker } from "../catalog/function_invoker.js"; import { ComponentModel } from "../state/component-model.js"; import type { SurfaceModel } from "../state/surface-model.js"; import type { SurfaceComponentsModel } from "../state/surface-components-model.js"; @@ -25,11 +26,14 @@ import { A2uiStateError } from "../errors.js"; * It provides access to the component's model, the data context, and a way to dispatch actions. */ export class ComponentContext { - /** The state model for this specific component. */ + /** The state model for this specific component, providing access to its properties and state. */ readonly componentModel: ComponentModel; - /** The data context scoped to this component's position in the visual hierarchy. */ + /** + * The data context scoped to this component's position in the visual hierarchy. + * Uses the `dataModelBasePath` to resolve relative data paths. + */ readonly dataContext: DataContext; - /** The collection of all component models for the current surface. */ + /** The collection of all component models for the current surface, allowing lookups by ID. */ readonly surfaceComponents: SurfaceComponentsModel; /** @@ -52,9 +56,8 @@ export class ComponentContext { this.surfaceComponents = surface.componentsModel; this.dataContext = new DataContext( - surface.dataModel, - dataModelBasePath, - surface.catalog.invoker + surface, + dataModelBasePath ); this._actionDispatcher = (action) => surface.dispatchAction(action); } diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index c4ff5b219..22308dbdb 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -17,8 +17,24 @@ import assert from "node:assert"; import { describe, it, beforeEach } from "node:test"; import { signal } from "@preact/signals-core"; +import { z } from "zod"; import { DataModel } from "../state/data-model.js"; import { DataContext } from "./data-context.js"; +import { A2uiExpressionError } from "../errors.js"; + +const createTestDataContext = ( + model: DataModel, + path: string, + functionInvoker: any = () => null, + dispatchError: (err: any) => void = () => {}, +) => { + const mockSurface = { + dataModel: model, + catalog: { invoker: functionInvoker }, + dispatchError, + } as any; + return new DataContext(mockSurface, path); +}; describe("DataContext", () => { let model: DataModel; @@ -34,7 +50,7 @@ describe("DataContext", () => { }, list: ["a", "b"], }); - context = new DataContext(model, "/user", () => null); + context = createTestDataContext(model, "/user"); }); it("resolves relative paths", () => { @@ -67,7 +83,7 @@ describe("DataContext", () => { }); it("handles root context", () => { - const rootContext = new DataContext(model, "/", () => null); + const rootContext = createTestDataContext(model, "/"); assert.strictEqual( rootContext.resolveDynamicValue({ path: "user/name" }), "Alice", @@ -119,7 +135,7 @@ describe("DataContext", () => { if (name === "add") return args.a + args.b; return null; }; - const ctx = new DataContext(model, "/user", fnInvoker); + const ctx = createTestDataContext(model, "/user", fnInvoker); const result = ctx.resolveDynamicValue({ call: "add", args: { a: 1, b: 2 }, @@ -128,27 +144,40 @@ describe("DataContext", () => { assert.strictEqual(result, 3); }); - it("throws on function call without invoker synchronously", () => { - const ctx = new DataContext(model, "/user", () => { + it("returns FunctionCall on function call without invoker synchronously", () => { + const ctx = createTestDataContext(model, "/user", () => { throw new Error("Function invoker is not configured"); }); - assert.throws( - () => - ctx.resolveDynamicValue({ call: "add", args: {}, returnType: "any" }), - /Function invoker is not configured/, - ); + const result = ctx.resolveDynamicValue({ call: "add", args: {}, returnType: "any" }); + assert.deepStrictEqual(result, { call: "add", args: {}, returnType: "any" }); }); - it("throws on invalid dynamic value format synchronously", () => { - assert.throws( - () => context.resolveDynamicValue({ foo: "bar" } as any), - /Invalid DynamicValue format/, - ); + it("does not resolve arbitrary objects recursively", () => { + const obj = { + foo: "bar", + nested: { path: "name" }, + list: [{ path: "address/city" }, "literal"], + }; + + const resolved = context.resolveDynamicValue(obj as any); + assert.deepStrictEqual(resolved, obj); + }); + + it("subscribes to literal objects as signals without resolution", () => { + const obj = { foo: "bar", nested: { path: "name" } }; + const sig = context.resolveSignal(obj as any); + + // It should be a literal signal containing the object + assert.deepStrictEqual(sig.peek(), obj); + + // Updating the path should NOT affect it + context.set("name", "Bob"); + assert.deepStrictEqual(sig.peek(), obj); }); it("subscribes to function calls with no args", () => { const fnInvoker = (name: string) => (name === "getPi" ? Math.PI : 0); - const ctx = new DataContext(model, "/", fnInvoker); + const ctx = createTestDataContext(model, "/", fnInvoker); let called = false; ctx.subscribeDynamicValue( @@ -160,18 +189,15 @@ describe("DataContext", () => { assert.strictEqual(called, false); }); - it("throws on function call without invoker reactively", () => { - const ctx = new DataContext(model, "/user", () => { + it("returns undefined on function call without invoker reactively", () => { + const ctx = createTestDataContext(model, "/user", () => { throw new Error("Function invoker is not configured"); }); - assert.throws( - () => - ctx.subscribeDynamicValue( - { call: "add", args: {}, returnType: "any" }, - () => {}, - ), - /Function invoker is not configured/, + const sub = ctx.subscribeDynamicValue( + { call: "add", args: {}, returnType: "any" }, + () => {}, ); + assert.strictEqual(sub.value, undefined); }); it("subscribes to function call returning a signal", () => { @@ -179,7 +205,7 @@ describe("DataContext", () => { if (name === "obs") return signal("hello"); return null; }; - const ctx = new DataContext(model, "/", fnInvoker); + const ctx = createTestDataContext(model, "/", fnInvoker); let val: any; ctx.subscribeDynamicValue( { call: "obs", args: {}, returnType: "any" }, @@ -190,24 +216,184 @@ describe("DataContext", () => { assert.ok(true); // Verification occurs by absence of crash, and coverage hits the switch }); - it("subscribes to invalid dynamic value reactively (falls back to literal)", () => { - let val: any; - const sub = context.subscribeDynamicValue( - { unknown: "thing" } as any, - (v) => { - val = v; - }, - ); - assert.deepStrictEqual(sub.value, { unknown: "thing" }); + it("subscribes to invalid dynamic value reactively (falls back to literal signal)", () => { + const obj = { unknown: "thing" }; + const sub = context.subscribeDynamicValue(obj as any, () => {}); + assert.deepStrictEqual(sub.value, obj); }); it("handles path resolution edge cases", () => { assert.strictEqual(context.nested("").path, "/user"); assert.strictEqual(context.nested(".").path, "/user"); // Ensure trailing slash removal logic is hit - const rootCtx = new DataContext(model, "/", () => null); + const rootCtx = createTestDataContext(model, "/"); assert.strictEqual(rootCtx.nested("test").path, "/test"); - const trailingCtx = new DataContext(model, "/user/", () => null); + const trailingCtx = createTestDataContext(model, "/user/"); assert.strictEqual(trailingCtx.nested("test").path, "/user/test"); }); + it("subscribes to function call with arguments reactively", () => { + const fnInvoker = (name: string, args: any) => { + if (name === "greet") return `Hello ${args.name}`; + return null; + }; + const ctx = createTestDataContext(model, "/user", fnInvoker); + + const sub = ctx.subscribeDynamicValue( + { call: "greet", args: { name: { path: "name" } }, returnType: "any" }, + () => {}, + ); + + assert.strictEqual(sub.value, "Hello Alice"); + + // Update inner path + ctx.set("name", "Bob"); + assert.strictEqual(sub.value, "Hello Bob"); + + sub.unsubscribe(); + }); + + describe("resolveAction", () => { + it("resolves event actions non-recursively", () => { + const action = { + event: { + name: "save", + context: { + id: { path: "name" }, + metadata: { nested: { path: "something" } }, + }, + }, + }; + + const resolved = context.resolveAction(action as any); + + assert.deepStrictEqual(resolved, { + event: { + name: "save", + context: { + id: "Alice", + metadata: { nested: { path: "something" } }, // Literal, NOT resolved + }, + }, + }); + }); + + it("resolves functionCall actions", () => { + const fnInvoker = (name: string, args: any) => { + if (name === "greet") return `Hello ${args.name}`; + return null; + }; + const ctx = createTestDataContext(model, "/user", fnInvoker); + + const action = { + functionCall: { + call: "greet", + args: { name: { path: "name" } }, + }, + }; + + const resolved = ctx.resolveAction(action as any); + assert.strictEqual(resolved, "Hello Alice"); + }); + }); + + describe("Error Handling", () => { + it("translates ZodError into A2uiExpressionError and dispatches error", () => { + const invokerWithZodError = () => { + throw new z.ZodError([ + { + code: "invalid_type", + expected: "string", + received: "number", + path: ["foo"], + message: "Expected string, received number", + }, + ]); + }; + let dispatchedError: any = null; + const ctx = createTestDataContext( + model, + "/", + invokerWithZodError, + (err) => { + dispatchedError = err; + }, + ); + + const result = ctx.resolveDynamicValue({ + call: "fail", + args: {}, + returnType: "any", + }); + + assert.deepStrictEqual(result, { + call: "fail", + args: {}, + returnType: "any", + }); + + assert.ok(dispatchedError); + assert.strictEqual(dispatchedError.code, "EXPRESSION_ERROR"); + assert.strictEqual(dispatchedError.expression, "fail"); + }); + + it("does not translate other errors but returns object", () => { + const invokerWithRegularError = () => { + throw new Error("Generic failure"); + }; + let dispatchedError: any = null; + const ctx = createTestDataContext( + model, + "/", + invokerWithRegularError, + (err) => { + dispatchedError = err; + }, + ); + + const result = ctx.resolveDynamicValue({ + call: "fail", + args: {}, + returnType: "any", + }); + + assert.deepStrictEqual(result, { + call: "fail", + args: {}, + returnType: "any", + }); + + assert.strictEqual(dispatchedError, null); + }); + + it("dispatches A2uiExpressionError to surface", () => { + const invokerWithExpressionError = () => { + throw new A2uiExpressionError("Custom expr error", "custom_func"); + }; + let dispatchedError: any = null; + const ctx = createTestDataContext( + model, + "/", + invokerWithExpressionError, + (err) => { + dispatchedError = err; + }, + ); + + const result = ctx.resolveDynamicValue({ + call: "fail", + args: {}, + returnType: "any", + }); + + assert.deepStrictEqual(result, { + call: "fail", + args: {}, + returnType: "any", + }); + + assert.ok(dispatchedError); + assert.strictEqual(dispatchedError.code, "EXPRESSION_ERROR"); + assert.strictEqual(dispatchedError.expression, "custom_func"); + }); + }); }); diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index a69e3a49e..b97574f04 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -15,14 +15,18 @@ */ import { signal, computed, Signal, effect } from "@preact/signals-core"; +import { z } from "zod"; import { DataModel, DataSubscription } from "../state/data-model.js"; import type { DynamicValue, DataBinding, FunctionCall, + Action, } from "../schema/common-types.js"; import { A2uiExpressionError } from "../errors.js"; -import type { FunctionInvoker } from "../catalog/types.js"; + +import { FunctionInvoker } from "../catalog/function_invoker.js"; +import { SurfaceModel } from "../state/surface-model.js"; /** * A contextual view of the main DataModel, serving as the unified interface for resolving @@ -33,21 +37,24 @@ import type { FunctionInvoker } from "../catalog/types.js"; * and provides tools for evaluating complex, reactive expressions. */ export class DataContext { + /** The shared, global DataModel instance for the entire UI surface. */ + readonly dataModel: DataModel; + /** A callback for executing function calls defined in the A2UI component tree. */ + readonly functionInvoker: FunctionInvoker; + /** * Initializes a new DataContext. * - * @param dataModel The shared, global DataModel instance for the entire UI surface. + * @param surface The surface model this context belongs to. * @param path The absolute path in the DataModel that this context is scoped to (its "current working directory"). - * @param functionInvoker An optional callback for executing function calls defined in the A2UI component tree against a UI catalog. - * Architectural Note: We use a callback instead of passing the Catalog directly - * to prevent a circular dependency between DataContext and Catalog types. - * Pass `undefined` if this context does not need to evaluate functions. */ constructor( - readonly dataModel: DataModel, + readonly surface: SurfaceModel, readonly path: string, - readonly functionInvoker: FunctionInvoker, - ) {} + ) { + this.dataModel = surface.dataModel; + this.functionInvoker = surface.catalog.invoker; + } /** * Mutates the underlying DataModel at the specified path. @@ -75,8 +82,8 @@ export class DataContext { * @returns The synchronously resolved value. */ resolveDynamicValue(value: DynamicValue): V { - // 1. Literal Check - if (typeof value !== "object" || value === null || Array.isArray(value)) { + // 1. Literal check (excluding arrays and objects) + if (value === null || typeof value !== "object" || Array.isArray(value)) { return value as V; } @@ -95,17 +102,42 @@ export class DataContext { args[key] = this.resolveDynamicValue(argVal); } - // Synchronous resolution should not spawn long-running resources. const abortController = new AbortController(); - abortController.abort(); - - const result = this.functionInvoker(call.call, args, this, abortController.signal); - return (result instanceof Signal ? result.peek() : result) as V; + + try { + const result = this.functionInvoker( + call.call, + args, + this, + abortController.signal, + ); + return (result instanceof Signal ? result.peek() : result) as V; + } catch (e: any) { + if (e?.name === "ZodError" || e instanceof z.ZodError) { + const err = new A2uiExpressionError( + `Validation failed for function '${call.call}': ${e.message}`, + call.call, + e.errors ?? e.issues, + ); + this.surface.dispatchError({ + code: "EXPRESSION_ERROR", + message: err.message, + expression: call.call, + details: err.details, + }); + } + if (e instanceof A2uiExpressionError) { + this.surface.dispatchError({ + code: "EXPRESSION_ERROR", + message: e.message, + expression: e.expression, + details: e.details, + }); + } + } } - throw new A2uiExpressionError( - `Invalid DynamicValue format: ${JSON.stringify(value)}`, - ); + return value as V; } /** @@ -125,7 +157,7 @@ export class DataContext { onChange: (value: V | undefined) => void, ): DataSubscription { const sig = this.resolveSignal(value); - + let isSync = true; let currentValue = sig.peek(); @@ -153,6 +185,13 @@ export class DataContext { /** * Returns a Preact Signal representing the reactive dynamic value. + * + * This method recursively resolves any nested path bindings or function calls into a + * single, reactive `Signal`. Any changes to the underlying data or function dependencies + * will cause this signal's value to update. + * + * @param value The DynamicValue to evaluate and observe. + * @returns A Preact Signal containing the reactive result of the evaluation. */ resolveSignal(value: DynamicValue): Signal { // 1. Literal @@ -177,29 +216,63 @@ export class DataContext { if (Object.keys(argSignals).length === 0) { const abortController = new AbortController(); - const result = this.evaluateFunctionReactive(call.call, {}, abortController.signal); + const result = this.evaluateFunctionReactive( + call.call, + {}, + abortController.signal, + ); const sig = result instanceof Signal ? result : signal(result); (sig as any).unsubscribe = () => abortController.abort(); return sig; } const keys = Object.keys(argSignals); + const resultSig = signal(undefined); let abortController: AbortController | undefined; - - const sig = computed(() => { - if (abortController) abortController.abort(); - abortController = new AbortController(); - + let innerUnsubscribe: (() => void) | undefined; + + const argsSig = computed(() => { const argsRecord: Record = {}; for (let i = 0; i < keys.length; i++) { argsRecord[keys[i]] = argSignals[keys[i]].value; } - - const result = this.evaluateFunctionReactive(call.call, argsRecord, abortController.signal); - return result instanceof Signal ? result.value : result; + return argsRecord; + }); + + const stopper = effect(() => { + const args = argsSig.value; + if (abortController) abortController.abort(); + if (innerUnsubscribe) { + innerUnsubscribe(); + innerUnsubscribe = undefined; + } + abortController = new AbortController(); + + try { + const res = this.evaluateFunctionReactive( + call.call, + args, + abortController.signal, + ); + + if (res instanceof Signal) { + innerUnsubscribe = effect(() => { + resultSig.value = res.value; + }); + } else { + resultSig.value = res; + } + } catch (e) { + // In reactive mode, we might want to propagate errors through the signal + // or at least log them. For now, we'll let them bubble if it's the first run, + // or just store them if we had a better way. + throw e; + } }); - (sig as any).unsubscribe = () => { + (resultSig as any).unsubscribe = () => { + stopper(); + if (innerUnsubscribe) innerUnsubscribe(); if (abortController) abortController.abort(); for (let i = 0; i < keys.length; i++) { const argSig = argSignals[keys[i]]; @@ -209,18 +282,74 @@ export class DataContext { } }; - return sig; + return resultSig as unknown as Signal; } return signal(value as unknown as V); } + /** + * Resolves an action by evaluating its top-level dynamic values. + * + * For event actions, it resolves each value in the context map. + * For function call actions, it evaluates the call. + * + * This is non-recursive: it only resolves one level deep for the context record, + * in accordance with the schema specification that requires values to be single + * DynamicValue types and prevents arbitrary nesting. + */ + resolveAction(action: Action): any { + if ("event" in action) { + const resolvedContext: Record = {}; + if (action.event.context) { + for (const [key, value] of Object.entries(action.event.context)) { + resolvedContext[key] = this.resolveDynamicValue(value); + } + } + return { + event: { + ...action.event, + context: resolvedContext, + }, + }; + } + if ("functionCall" in action) { + return this.resolveDynamicValue(action.functionCall); + } + return action; + } + private evaluateFunctionReactive( name: string, args: Record, abortSignal?: AbortSignal, ): Signal | V { - return this.functionInvoker(name, args, this, abortSignal); + try { + return this.functionInvoker(name, args, this, abortSignal); + } catch (e: any) { + if (e?.name === "ZodError" || e instanceof z.ZodError) { + const err = new A2uiExpressionError( + `Validation failed for function '${name}': ${e.message}`, + name, + e.errors ?? e.issues, + ); + this.surface.dispatchError({ + code: "EXPRESSION_ERROR", + message: err.message, + expression: name, + details: err.details, + }); + } + if (e instanceof A2uiExpressionError) { + this.surface.dispatchError({ + code: "EXPRESSION_ERROR", + message: e.message, + expression: e.expression, + details: e.details, + }); + } + return undefined as any; + } } /** @@ -234,7 +363,7 @@ export class DataContext { */ nested(relativePath: string): DataContext { const newPath = this.resolvePath(relativePath); - return new DataContext(this.dataModel, newPath, this.functionInvoker); + return new DataContext(this.surface, newPath); } private resolvePath(path: string): string { diff --git a/renderers/web_core/src/v0_9/state/component-model.test.ts b/renderers/web_core/src/v0_9/state/component-model.test.ts index e15006d1d..9669c809f 100644 --- a/renderers/web_core/src/v0_9/state/component-model.test.ts +++ b/renderers/web_core/src/v0_9/state/component-model.test.ts @@ -62,8 +62,6 @@ describe("ComponentModel", () => { sub.unsubscribe(); component.properties = { label: "2" }; assert.strictEqual(callCount, 1); - component.properties = { label: "2" }; - assert.strictEqual(callCount, 1); }); it("returns component tree representation", () => { diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index 7c9866c2c..7455f2730 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -52,6 +52,12 @@ export class DataModel { /** * Retrieves a Preact Signal for a specific data path. + * + * This provides a reactive way to access a value. If the value at the path changes via `set()`, + * the signal will automatically be updated. + * + * @param path The JSON pointer path to create or retrieve a signal for. + * @returns A Preact Signal representing the value at the specified path. */ getSignal(path: string): Signal { const normalizedPath = this.normalizePath(path); @@ -73,7 +79,7 @@ export class DataModel { if (path === null || path === undefined) { throw new A2uiDataError("Path cannot be null or undefined."); } - + if (path === "/" || path === "") { this.data = value; this.notifyAllSignals(); @@ -83,6 +89,9 @@ export class DataModel { const segments = this.parsePath(path); const lastSegment = segments.pop()!; + if (!this.data) { + this.data = {}; + } let current: any = this.data; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; @@ -163,7 +172,14 @@ export class DataModel { /** * Subscribes to changes at the specified data path. - * Backwards-compatible layer using Preact Signals. + * + * This is a backwards-compatible layer using Preact Signals internally. It allows + * listeners to be notified whenever the value at the specified path (or any of its + * ancestors/descendants) changes. + * + * @param path The JSON pointer path to observe. + * @param onChange A callback fired whenever the value changes. + * @returns A `DataSubscription` containing the initial value and an `unsubscribe` method. */ subscribe( path: string, @@ -172,7 +188,7 @@ export class DataModel { const sig = this.getSignal(path); let isSync = true; let currentValue = sig.peek(); - + const dispose = effect(() => { const val = sig.value; currentValue = val; @@ -242,10 +258,6 @@ export class DataModel { private updateSignal(path: string): void { const sig = this.signals.get(path); if (sig) { - // Signals trigger updates based on strict equality checks. If an object or array - // in the data model is mutated, its reference doesn't change, and the signal - // won't update. By creating a shallow copy, we ensure a new reference is - // assigned, which correctly triggers dependent effects. const val = this.get(path); if (Array.isArray(val)) { sig.value = [...val]; diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts index f05419ad4..5a27e27ed 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts @@ -82,8 +82,11 @@ describe("SurfaceGroupModel", () => { const surface = new SurfaceModel("s1", catalog, {}); model.addSurface(surface); - await surface.dispatchAction({ type: "test" }); - assert.deepStrictEqual(receivedAction, { type: "test" }); + await surface.dispatchAction({ event: { name: "test" } }); + assert.deepStrictEqual(receivedAction, { + event: { name: "test" }, + surfaceId: "s1", + }); }); it("stops propagating actions after deletion", async () => { @@ -96,7 +99,7 @@ describe("SurfaceGroupModel", () => { model.addSurface(surface); model.deleteSurface("s1"); - await surface.dispatchAction({ type: "test" }); + await surface.dispatchAction({ event: { name: "test" } }); assert.strictEqual(callCount, 0); }); diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.ts b/renderers/web_core/src/v0_9/state/surface-group-model.ts index 5ae830e88..6f1245e0f 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.ts @@ -17,6 +17,10 @@ import { SurfaceModel } from "./surface-model.js"; import { ComponentApi } from "../catalog/types.js"; import { EventEmitter, EventSource, Subscription } from "../common/events.js"; +import { Action } from "../schema/common-types.js"; + +/** An action wrapper that includes the origin surface ID. */ +export type SurfaceGroupAction = Action & { surfaceId: string }; /** * The root state model for the A2UI system. @@ -28,7 +32,7 @@ export class SurfaceGroupModel { private readonly _onSurfaceCreated = new EventEmitter>(); private readonly _onSurfaceDeleted = new EventEmitter(); - private readonly _onAction = new EventEmitter(); + private readonly _onAction = new EventEmitter(); /** Fires when a new surface is added. */ readonly onSurfaceCreated: EventSource> = @@ -36,7 +40,7 @@ export class SurfaceGroupModel { /** Fires when a surface is removed. */ readonly onSurfaceDeleted: EventSource = this._onSurfaceDeleted; /** Fires when an action is dispatched from ANY surface in the group. */ - readonly onAction: EventSource = this._onAction; + readonly onAction: EventSource = this._onAction; /** * Adds a surface to the group. @@ -54,7 +58,7 @@ export class SurfaceGroupModel { // Subscribe to surface actions and propagate const sub = surface.onAction.subscribe((action) => - this._onAction.emit(action), + this._onAction.emit({ ...action, surfaceId: surface.id }), ); this.surfaceUnsubscribers.set(surface.id, sub); diff --git a/renderers/web_core/src/v0_9/state/surface-model.test.ts b/renderers/web_core/src/v0_9/state/surface-model.test.ts index 5f8471430..42e4c5a30 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.test.ts @@ -47,9 +47,9 @@ describe("SurfaceModel", () => { }); it("dispatches actions", async () => { - await surface.dispatchAction({ type: "click" }); + await surface.dispatchAction({ event: { name: "click" } }); assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, "click"); + assert.strictEqual(actions[0].event?.name, "click"); }); it("creates a component context", () => { @@ -73,7 +73,7 @@ describe("SurfaceModel", () => { // After dispose, no more actions should be emitted. // The EventEmitter.dispose method clears all listeners. - surface.dispatchAction({ type: "click" }); + surface.dispatchAction({ event: { name: "click" } }); assert.strictEqual( actionReceived, false, diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index 03c0922e4..7be5deb30 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -18,13 +18,18 @@ import { DataModel } from "./data-model.js"; import { Catalog, ComponentApi } from "../catalog/types.js"; import { SurfaceComponentsModel } from "./surface-components-model.js"; import { EventEmitter, EventSource } from "../common/events.js"; +import { Action } from "../schema/common-types.js"; /** A function that listens for actions emitted from a surface. */ -export type ActionListener = (action: any) => void | Promise; +export type ActionListener = (action: Action) => void | Promise; /** - * The state model for a single surface. - * @template T The concrete type of the ComponentApi. + * The state model for a single UI surface. + * + * A surface is the root container for a set of components and their associated data. + * It coordinates data binding, component state, and action dispatching. + * + * @template T The concrete type of the ComponentApi from the catalog. */ export class SurfaceModel { /** The data model for this surface. */ @@ -32,10 +37,14 @@ export class SurfaceModel { /** The collection of component models for this surface. */ readonly componentsModel: SurfaceComponentsModel; - private readonly _onAction = new EventEmitter(); + private readonly _onAction = new EventEmitter(); + private readonly _onError = new EventEmitter(); /** Fires whenever an action is dispatched from this surface. */ - readonly onAction: EventSource = this._onAction; + readonly onAction: EventSource = this._onAction; + + /** Fires whenever an error occurs on this surface. */ + readonly onError: EventSource = this._onError; /** * Creates a new surface model. @@ -58,10 +67,22 @@ export class SurfaceModel { * * @param action The action object to dispatch. */ - async dispatchAction(action: any): Promise { + async dispatchAction(action: Action): Promise { await this._onAction.emit(action); } + /** + * Dispatches an error from this surface to listeners. + * + * @param error The error object to dispatch, conforming to client_to_server schema. + */ + async dispatchError(error: { code: string; message: string; [key: string]: any }): Promise { + await this._onError.emit({ + ...error, + surfaceId: this.id, + }); + } + /** * Disposes of the surface and its resources. */ diff --git a/renderers/web_core/src/v0_9/test/function_execution.spec.ts b/renderers/web_core/src/v0_9/test/function_execution.spec.ts index 2e7eaf2d7..887565ce1 100644 --- a/renderers/web_core/src/v0_9/test/function_execution.spec.ts +++ b/renderers/web_core/src/v0_9/test/function_execution.spec.ts @@ -21,8 +21,21 @@ import { DataContext } from "../rendering/data-context.js"; import { signal } from "@preact/signals-core"; +const createTestDataContext = ( + model: DataModel, + path: string, + functionInvoker: any = () => null, +) => { + const mockSurface = { + dataModel: model, + catalog: { invoker: functionInvoker }, + dispatchError: () => {}, + } as any; + return new DataContext(mockSurface, path); +}; + describe("Function Execution in DataContext", () => { - it("resolves and subscribes to metronome function", (_t, done) => { + it("resolves and subscribes to metronome function", (_t) => { const dataModel = new DataModel(); const functions = new Map(); @@ -42,7 +55,7 @@ describe("Function Execution in DataContext", () => { return subj; }); - const context = new DataContext(dataModel, "/", (name, args, _ctx, abortSignal) => { + const context = createTestDataContext(dataModel, "/", (name: string, args: any, _ctx: any, abortSignal?: AbortSignal) => { const fn = functions.get(name); return fn ? fn(args, abortSignal) : undefined; }); @@ -57,26 +70,31 @@ describe("Function Execution in DataContext", () => { }; const values: string[] = []; - const subscription = context.subscribeDynamicValue( - dynamicValue, - (val) => { - if (val) values.push(val); - if (values.length >= 3) { - subscription.unsubscribe(); - try { - assert.strictEqual(values[0], "tick 0"); - assert.strictEqual(values[1], "tick 1"); - assert.strictEqual(values[2], "tick 2"); - done(); - } catch (e) { - done(e); + return new Promise((resolve, reject) => { + const subscription = context.subscribeDynamicValue( + dynamicValue, + (val) => { + if (val) values.push(val); + if (values.length >= 3) { + subscription.unsubscribe(); + try { + assert.strictEqual(values[0], "tick 0"); + assert.strictEqual(values[1], "tick 1"); + assert.strictEqual(values[2], "tick 2"); + resolve(); + } catch (e) { + reject(e); + } } - } - }, - ); + }, + ); + if (subscription.value) { + values.push(subscription.value); + } + }); }); - it("updates function output when arguments change", (_t, done) => { + it("updates function output when arguments change", (_t) => { const dataModel = new DataModel(); const functions = new Map(); @@ -84,7 +102,7 @@ describe("Function Execution in DataContext", () => { return `echo: ${args["val"]}`; }); - const context = new DataContext(dataModel, "/", (name, args, _ctx, abortSignal) => { + const context = createTestDataContext(dataModel, "/", (name: string, args: any, _ctx: any, abortSignal?: AbortSignal) => { const fn = functions.get(name); return fn ? fn(args, abortSignal) : undefined; }); @@ -100,29 +118,31 @@ describe("Function Execution in DataContext", () => { }; const values: string[] = []; - const subscription = context.subscribeDynamicValue( - dynamicValue, - (val) => { - if (val) values.push(val); - if (values.length === 2) { - subscription.unsubscribe(); - try { - assert.strictEqual(values[0], "echo: hello"); - assert.strictEqual(values[1], "echo: world"); - done(); - } catch (e) { - done(e); + return new Promise((resolve, reject) => { + const subscription = context.subscribeDynamicValue( + dynamicValue, + (val) => { + if (val) values.push(val); + if (values.length === 2) { + subscription.unsubscribe(); + try { + assert.strictEqual(values[0], "echo: hello"); + assert.strictEqual(values[1], "echo: world"); + resolve(); + } catch (e) { + reject(e); + } } - } - }, - ); - if (subscription.value) { - values.push(subscription.value); - } + }, + ); + if (subscription.value) { + values.push(subscription.value); + } - // Change data after a short delay to ensure first emit happens - setTimeout(() => { - dataModel.set("/msg", "world"); - }, 50); + // Change data after a short delay to ensure first emit happens + setTimeout(() => { + dataModel.set("/msg", "world"); + }, 50); + }); }); }); diff --git a/samples/client/angular/package-lock.json b/samples/client/angular/package-lock.json index e932eb0e5..89fa24dba 100644 --- a/samples/client/angular/package-lock.json +++ b/samples/client/angular/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "workspaces": [ "projects/*", - "../../../renderers/web_core" + "../../../renderers/web_core", + "../../../renderers/markdown/markdown-it" ], "dependencies": { "@a2a-js/sdk": "^0.3.4", @@ -26,6 +27,7 @@ "@angular/platform-server": "^21.2.0", "@angular/ssr": "^21.2.0", "@google/genai": "^1.22.0", + "@modelcontextprotocol/ext-apps": "^1.2.0", "chart.js": "^4.5.1", "chartjs-plugin-datalabels": "^2.2.0", "excalibur": "0.31.0", @@ -67,7 +69,7 @@ }, "../../../renderers/markdown/markdown-it": { "name": "@a2ui/markdown-it", - "version": "0.0.1", + "version": "0.0.2", "license": "Apache-2.0", "dependencies": { "dompurify": "^3.3.1", @@ -1152,10 +1154,11 @@ }, "../../../renderers/web_core": { "name": "@a2ui/web_core", - "version": "0.8.2", + "version": "0.9.0", "license": "Apache-2.0", "dependencies": { - "rxjs": "^7.8.2", + "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": { @@ -1197,8 +1200,16 @@ "node": ">= 8" } }, + "../../../renderers/web_core/node_modules/@preact/signals-core": { + "version": "1.13.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "../../../renderers/web_core/node_modules/@types/node": { - "version": "24.11.0", + "version": "24.12.0", "dev": true, "license": "MIT", "dependencies": { @@ -1281,6 +1292,14 @@ "fsevents": "~2.3.2" } }, + "../../../renderers/web_core/node_modules/date-fns": { + "version": "4.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "../../../renderers/web_core/node_modules/fast-glob": { "version": "3.3.3", "dev": true, @@ -1504,13 +1523,6 @@ "queue-microtask": "^1.2.2" } }, - "../../../renderers/web_core/node_modules/rxjs": { - "version": "7.8.2", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "../../../renderers/web_core/node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -1527,10 +1539,6 @@ "node": ">=8.0" } }, - "../../../renderers/web_core/node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, "../../../renderers/web_core/node_modules/typescript": { "version": "5.9.3", "dev": true, @@ -1585,19 +1593,7 @@ "zod": "^3.25 || ^4" } }, - "dist/lib": { - "name": "@a2ui/angular", - "version": "0.8.4", - "dependencies": { - "@a2ui/web_core": "file:../../../../../renderers/web_core", - "markdown-it": "^14.1.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^21.2.0", - "@angular/core": "^21.2.0" - } - }, + "dist/lib": {}, "node_modules/@a2a-js/sdk": { "version": "0.3.10", "license": "Apache-2.0", @@ -2796,19 +2792,19 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.0", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.0", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "dev": true, "license": "MIT", "optional": true, @@ -2817,8 +2813,8 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.0", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -3297,7 +3293,6 @@ }, "node_modules/@hono/node-server": { "version": "1.19.9", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3874,9 +3869,32 @@ "win32" ] }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.2.2", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", - "devOptional": true, "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -6132,7 +6150,6 @@ }, "node_modules/ajv": { "version": "8.18.0", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6147,7 +6164,6 @@ }, "node_modules/ajv-formats": { "version": "3.0.1", - "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -7250,7 +7266,6 @@ }, "node_modules/cors": { "version": "2.8.6", - "devOptional": true, "license": "MIT", "dependencies": { "object-assign": "^4", @@ -8061,7 +8076,6 @@ }, "node_modules/eventsource": { "version": "3.0.7", - "devOptional": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -8072,7 +8086,6 @@ }, "node_modules/eventsource-parser": { "version": "3.0.6", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -8176,7 +8189,6 @@ }, "node_modules/express-rate-limit": { "version": "8.2.1", - "devOptional": true, "license": "MIT", "dependencies": { "ip-address": "10.0.1" @@ -8245,12 +8257,10 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "devOptional": true, "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", - "devOptional": true, "funding": [ { "type": "github", @@ -8772,7 +8782,6 @@ }, "node_modules/hono": { "version": "4.12.2", - "devOptional": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -9063,7 +9072,6 @@ }, "node_modules/ip-address": { "version": "10.0.1", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 12" @@ -9340,7 +9348,6 @@ }, "node_modules/jose": { "version": "6.1.3", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9454,12 +9461,10 @@ }, "node_modules/json-schema-traverse": { "version": "1.0.0", - "devOptional": true, "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", - "devOptional": true, "license": "BSD-2-Clause" }, "node_modules/json-stringify-safe": { @@ -10948,7 +10953,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11391,7 +11395,6 @@ }, "node_modules/pkce-challenge": { "version": "5.0.1", - "devOptional": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -11718,7 +11721,6 @@ }, "node_modules/require-from-string": { "version": "2.0.2", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13976,7 +13978,6 @@ }, "node_modules/zod": { "version": "4.3.6", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -13984,7 +13985,6 @@ }, "node_modules/zod-to-json-schema": { "version": "3.25.1", - "devOptional": true, "license": "ISC", "peerDependencies": { "zod": "^3.25 || ^4" diff --git a/samples/client/lit/package-lock.json b/samples/client/lit/package-lock.json index fa7e29891..a0ae4e671 100644 --- a/samples/client/lit/package-lock.json +++ b/samples/client/lit/package-lock.json @@ -1587,6 +1587,7 @@ "license": "Apache-2.0", "dependencies": { "@preact/signals-core": "^1.13.0", + "date-fns": "^4.1.0", "zod": "^3.25.76" }, "devDependencies": {