diff --git a/packages/adapters/adaptive-cards/examples/teams-webhook.ts b/packages/adapters/adaptive-cards/examples/teams-webhook.ts new file mode 100644 index 0000000..0e764c7 --- /dev/null +++ b/packages/adapters/adaptive-cards/examples/teams-webhook.ts @@ -0,0 +1,57 @@ +import { UITree } from "@json-render/core"; +import { renderAdaptiveCard, AdaptiveCardRegistry, createActionSubmit } from "../src"; + +const tree: UITree = { + root: "root", + elements: { + root: { + key: "root", + type: "Card", + props: { title: "Daily Report" }, + children: ["metric1", "button1"] + }, + metric1: { + key: "metric1", + type: "Metric", + props: { label: "Revenue", value: "$10,000" } + }, + button1: { + key: "button1", + type: "Button", + props: { label: "Approve", action: { name: "approve", params: { id: "123" } } } + } + } +}; + +const registry: AdaptiveCardRegistry = { + Card: (element, children) => ({ + type: "Container", + items: [ + { + type: "TextBlock", + text: element.props.title, + size: "Large", + weight: "Bolder" + }, + ...children + ] + }), + Metric: (element) => ({ + type: "FactSet", + facts: [ + { title: element.props.label, value: element.props.value } + ] + }), + Button: (element) => ({ + type: "ActionSet", + actions: [ + createActionSubmit(element.props.label, element.props.action) + ] + }) +}; + +const card = renderAdaptiveCard(tree, registry); + +// Output the card JSON +// This JSON can be sent to a Teams Incoming Webhook or used in Bot Framework +console.log(JSON.stringify(card, null, 2)); diff --git a/packages/adapters/adaptive-cards/package.json b/packages/adapters/adaptive-cards/package.json new file mode 100644 index 0000000..f6cb690 --- /dev/null +++ b/packages/adapters/adaptive-cards/package.json @@ -0,0 +1,54 @@ +{ + "name": "@json-render/adaptive-cards", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "Adaptive Cards adapter for @json-render/core.", + "keywords": [ + "json", + "ui", + "adaptive-cards", + "microsoft", + "teams", + "outlook", + "ai", + "generative-ui" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/vercel-labs/json-render.git", + "directory": "packages/adapters/adaptive-cards" + }, + "homepage": "https://github.com/vercel-labs/json-render#readme", + "bugs": { + "url": "https://github.com/vercel-labs/json-render/issues" + }, + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@json-render/core": "workspace:*" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "tsup": "^8.0.2", + "typescript": "^5.4.5" + } +} diff --git a/packages/adapters/adaptive-cards/src/index.test.ts b/packages/adapters/adaptive-cards/src/index.test.ts new file mode 100644 index 0000000..1a8a13c --- /dev/null +++ b/packages/adapters/adaptive-cards/src/index.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { UITree } from "@json-render/core"; +import { renderAdaptiveCard, AdaptiveCardRegistry, createActionSubmit } from "./index"; + +describe("renderAdaptiveCard", () => { + it("should render a simple tree to Adaptive Card", () => { + const tree: UITree = { + root: "root", + elements: { + root: { + key: "root", + type: "Card", + props: { title: "Test Card" }, + children: ["child1"] + }, + child1: { + key: "child1", + type: "Text", + props: { content: "Hello World" } + } + } + }; + + const registry: AdaptiveCardRegistry = { + Card: (element, children) => ({ + type: "Container", + items: [ + { type: "TextBlock", text: element.props.title, weight: "Bolder" }, + ...children + ] + }), + Text: (element) => ({ + type: "TextBlock", + text: element.props.content + }) + }; + + const result = renderAdaptiveCard(tree, registry); + + expect(result).toEqual({ + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "Container", + items: [ + { type: "TextBlock", text: "Test Card", weight: "Bolder" }, + { type: "TextBlock", text: "Hello World" } + ] + } + ] + }); + }); + + it("should handle actions", () => { + const tree: UITree = { + root: "btn", + elements: { + btn: { + key: "btn", + type: "Button", + props: { label: "Click Me", action: { name: "click" } } + } + } + }; + + const registry: AdaptiveCardRegistry = { + Button: (element) => createActionSubmit(element.props.label, element.props.action) + }; + + const result = renderAdaptiveCard(tree, registry); + + // Note: renderAdaptiveCard wraps root elements in body. + // If root element is Action.Submit, it might not be valid inside body directly without ActionSet, + // but the adapter blindly puts it there. + // Adaptive Cards schema requires Actions to be in 'actions' or 'ActionSet'. + // But let's verify the adapter output matches what we expect. + + expect(result).toEqual({ + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "Action.Submit", + title: "Click Me", + data: { name: "click" } + } + ] + }); + }); + + it("should handle fragments (array of elements from component)", () => { + const tree: UITree = { + root: "root", + elements: { + root: { key: "root", type: "Container", children: ["fields"] }, + fields: { key: "fields", type: "Fields", props: {} } + } + }; + + const registry: AdaptiveCardRegistry = { + Container: (element, children) => ({ + type: "Container", + items: children + }), + Fields: () => [ + { type: "Input.Text", id: "f1" }, + { type: "Input.Text", id: "f2" } + ] + }; + + const result = renderAdaptiveCard(tree, registry); + + expect(result).toMatchObject({ + body: [ + { + type: "Container", + items: [ + { type: "Input.Text", id: "f1" }, + { type: "Input.Text", id: "f2" } + ] + } + ] + }); + }); +}); diff --git a/packages/adapters/adaptive-cards/src/index.ts b/packages/adapters/adaptive-cards/src/index.ts new file mode 100644 index 0000000..1df7056 --- /dev/null +++ b/packages/adapters/adaptive-cards/src/index.ts @@ -0,0 +1,94 @@ +import { UIElement, UITree } from "@json-render/core"; + +export type AdaptiveCardElement = Record; + +export type AdaptiveCardNode = AdaptiveCardElement | AdaptiveCardElement[]; + +export type AdaptiveCardComponent

= ( + element: UIElement, + children: AdaptiveCardElement[] +) => AdaptiveCardNode; + +export type AdaptiveCardRegistry = Record; + +export interface AdaptiveCardOptions { + version?: string; + fallbackText?: string; +} + +function renderElement( + key: string, + tree: UITree, + registry: AdaptiveCardRegistry +): AdaptiveCardElement[] { + const element = tree.elements[key]; + if (!element) { + return []; + } + + const component = registry[element.type]; + if (!component) { + // If component is not found, return a warning TextBlock or just skip? + // Returning a warning is safer for debugging. + return [{ + type: "TextBlock", + text: `[Unknown component: ${element.type}]`, + color: "Attention", + wrap: true + }]; + } + + // Recursively render children + const children: AdaptiveCardElement[] = []; + if (element.children) { + for (const childKey of element.children) { + children.push(...renderElement(childKey, tree, registry)); + } + } + + const result = component(element, children); + return Array.isArray(result) ? result : [result]; +} + +export function renderAdaptiveCard( + tree: UITree, + registry: AdaptiveCardRegistry, + options: AdaptiveCardOptions = {} +): AdaptiveCardElement { + const rootElements = renderElement(tree.root, tree, registry); + + const result: AdaptiveCardElement = { + type: "AdaptiveCard", + version: options.version || "1.5", + body: rootElements, + }; + + if (options.fallbackText !== undefined) { + result.fallbackText = options.fallbackText; + } + + return result; +} + +/** + * Helper to create an Action.Submit + */ +export function createActionSubmit(title: string, data: unknown): AdaptiveCardElement { + return { + type: "Action.Submit", + title, + data, + }; +} + +/** + * Helper to create an Action.Execute (for Adaptive Cards 1.4+) + */ +export function createActionExecute(title: string, verb: string, data: unknown): AdaptiveCardElement { + return { + type: "Action.Execute", + title, + verb, + data, + }; +} diff --git a/packages/adapters/adaptive-cards/tsconfig.json b/packages/adapters/adaptive-cards/tsconfig.json new file mode 100644 index 0000000..eb8e6c3 --- /dev/null +++ b/packages/adapters/adaptive-cards/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/adapters/adaptive-cards/tsup.config.ts b/packages/adapters/adaptive-cards/tsup.config.ts new file mode 100644 index 0000000..781ca50 --- /dev/null +++ b/packages/adapters/adaptive-cards/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5209e1..e148dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,22 @@ importers: specifier: ^5.7.2 version: 5.9.2 + packages/adapters/adaptive-cards: + dependencies: + '@json-render/core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@repo/typescript-config': + specifier: workspace:* + version: link:../../typescript-config + tsup: + specifier: ^8.0.2 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.2) + typescript: + specifier: ^5.4.5 + version: 5.9.2 + packages/codegen: dependencies: '@json-render/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2994b8c..442047c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - "apps/*" - "examples/*" - "packages/*" + - "packages/adapters/*"