diff --git a/.github/workflows/react_renderer.yml b/.github/workflows/react_renderer.yml index 50323d3b7..2e9eee128 100644 --- a/.github/workflows/react_renderer.yml +++ b/.github/workflows/react_renderer.yml @@ -47,6 +47,12 @@ jobs: npm ci npm run build + - name: Build markdown-it dependency + working-directory: ./renderers/markdown/markdown-it + run: | + npm ci + npm run build + - name: Install React renderer deps working-directory: ./renderers/react run: npm ci @@ -76,6 +82,12 @@ jobs: npm ci npm run build + - name: Build markdown-it dependency + working-directory: ./renderers/markdown/markdown-it + run: | + npm ci + npm run build + - name: Install React renderer deps working-directory: ./renderers/react run: npm ci @@ -105,6 +117,12 @@ jobs: npm ci npm run build + - name: Build markdown-it dependency + working-directory: ./renderers/markdown/markdown-it + run: | + npm ci + npm run build + - name: Install React renderer deps working-directory: ./renderers/react run: npm ci diff --git a/renderers/react/a2ui_explorer/src/App.tsx b/renderers/react/a2ui_explorer/src/App.tsx index f321c5034..92f5dba0b 100644 --- a/renderers/react/a2ui_explorer/src/App.tsx +++ b/renderers/react/a2ui_explorer/src/App.tsx @@ -16,8 +16,9 @@ import {useState, useEffect, useSyncExternalStore, useCallback} from 'react'; import {MessageProcessor, SurfaceModel} from '@a2ui/web_core/v0_9'; -import {minimalCatalog, basicCatalog, A2uiSurface, type ReactComponentImplementation} from '@a2ui/react/v0_9'; +import {minimalCatalog, basicCatalog, A2uiSurface, type ReactComponentImplementation, MarkdownProvider} from '@a2ui/react/v0_9'; import {exampleFiles, getMessages} from './examples'; +import {renderMarkdown} from '@a2ui/markdown-it'; const DataModelViewer = ({surface}: {surface: SurfaceModel}) => { const subscribeHook = useCallback( @@ -206,7 +207,9 @@ export default function App() { background: '#fff', }} > - + + + ); diff --git a/renderers/react/package-lock.json b/renderers/react/package-lock.json index fad1480eb..e1d7f0f2e 100644 --- a/renderers/react/package-lock.json +++ b/renderers/react/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.0", "license": "Apache-2.0", "dependencies": { + "@a2ui/markdown-it": "file:../markdown/markdown-it", "@a2ui/web_core": "file:../web_core", "clsx": "^2.1.0", "markdown-it": "^14.0.0", @@ -72,6 +73,29 @@ "wireit": "^0.15.0-pre.2" } }, + "../markdown/markdown-it": { + "name": "@a2ui/markdown-it", + "version": "0.0.2", + "license": "Apache-2.0", + "dependencies": { + "dompurify": "^3.3.1", + "markdown-it": "^14.1.0" + }, + "devDependencies": { + "@a2ui/web_core": "file:../../web_core", + "@types/dompurify": "^3.0.5", + "@types/jsdom": "^28.0.0", + "@types/markdown-it": "^14.1.2", + "@types/node": "^24.10.1", + "jsdom": "^28.1.0", + "prettier": "^3.4.2", + "typescript": "^5.8.3", + "wireit": "^0.15.0-pre.2" + }, + "peerDependencies": { + "@a2ui/web_core": "file:../../web_core" + } + }, "../web_core": { "name": "@a2ui/web_core", "version": "0.8.7", @@ -85,10 +109,15 @@ "devDependencies": { "@types/node": "^24.11.0", "c8": "^11.0.0", + "gts": "^7.0.0", "typescript": "^5.8.3", "wireit": "^0.15.0-pre.2" } }, + "node_modules/@a2ui/markdown-it": { + "resolved": "../markdown/markdown-it", + "link": true + }, "node_modules/@a2ui/web_core": { "resolved": "../web_core", "link": true diff --git a/renderers/react/package.json b/renderers/react/package.json index b1c2d0ee6..07443da29 100644 --- a/renderers/react/package.json +++ b/renderers/react/package.json @@ -71,11 +71,12 @@ "test:demo": "cd a2ui_explorer && vitest run src" }, "dependencies": { + "@a2ui/markdown-it": "file:../markdown/markdown-it", "@a2ui/web_core": "file:../web_core", "clsx": "^2.1.0", "markdown-it": "^14.0.0", - "zod": "^3.23.8", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/renderers/react/src/v0_9/catalog/basic/components/Text.tsx b/renderers/react/src/v0_9/catalog/basic/components/Text.tsx index d24c1b424..1771fb452 100644 --- a/renderers/react/src/v0_9/catalog/basic/components/Text.tsx +++ b/renderers/react/src/v0_9/catalog/basic/components/Text.tsx @@ -14,33 +14,97 @@ * limitations under the License. */ -import React from 'react'; +import React, {useState, useEffect} from 'react'; import {createReactComponent} from '../../../adapter'; import {TextApi} from '@a2ui/web_core/v0_9/basic_catalog'; import {getBaseLeafStyle} from '../utils'; +import {useMarkdown} from '../../../context/MarkdownContext'; -export const Text = createReactComponent(TextApi, ({props}) => { - const text = props.text ?? ''; - const style: React.CSSProperties = { - ...getBaseLeafStyle(), - display: 'inline-block', - }; +const MarkdownContent: React.FC<{ + text: string; + variant?: string; + style?: React.CSSProperties; +}> = ({text, variant, style}) => { + const renderer = useMarkdown(); + const [html, setHtml] = useState(null); + + useEffect(() => { + let isMounted = true; + if (renderer) { + renderer(text) + .then((htmlResult) => { + if (isMounted) { + setHtml(htmlResult); + } + }) + .catch(console.error); + } else { + setHtml(null); // Fallback to plain text + } + return () => { + isMounted = false; + }; + }, [text, renderer]); + + const content = renderer ? undefined : text; + const dangerouslySetInnerHTML = html !== null ? {__html: html} : undefined; - switch (props.variant) { + switch (variant) { case 'h1': - return

{text}

; + return ( +

+ {content} +

+ ); case 'h2': - return

{text}

; + return ( +

+ {content} +

+ ); case 'h3': - return

{text}

; + return ( +

+ {content} +

+ ); case 'h4': - return

{text}

; + return ( +

+ {content} +

+ ); case 'h5': - return
{text}
; + return ( +
+ {content} +
+ ); case 'caption': - return {text}; + return ( + + {content} + + ); case 'body': default: - return {text}; + return ( + + {content} + + ); } +}; + +export const Text = createReactComponent(TextApi, ({props}) => { + const text = props.text ?? ''; + const style: React.CSSProperties = { + ...getBaseLeafStyle(), + display: 'inline-block', + }; + + return ; }); diff --git a/renderers/react/src/v0_9/context/MarkdownContext.tsx b/renderers/react/src/v0_9/context/MarkdownContext.tsx new file mode 100644 index 000000000..231edf6e7 --- /dev/null +++ b/renderers/react/src/v0_9/context/MarkdownContext.tsx @@ -0,0 +1,29 @@ +/** + * Copyright 2026 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 + * + * http://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 React, {createContext, useContext} from 'react'; +import {type MarkdownRenderer} from '@a2ui/web_core/v0_9'; + +const MarkdownContext = createContext(undefined); + +export const MarkdownProvider: React.FC<{ + renderer?: MarkdownRenderer; + children: React.ReactNode; +}> = ({renderer, children}) => ( + {children} +); + +export const useMarkdown = () => useContext(MarkdownContext); diff --git a/renderers/react/src/v0_9/index.ts b/renderers/react/src/v0_9/index.ts index b4c1ee5fd..9797df0be 100644 --- a/renderers/react/src/v0_9/index.ts +++ b/renderers/react/src/v0_9/index.ts @@ -16,6 +16,7 @@ export * from './A2uiSurface'; export * from './adapter'; +export * from './context/MarkdownContext'; // Export basic catalog components directly for 3P developers export * from './catalog/basic'; diff --git a/renderers/react/tests/utils.tsx b/renderers/react/tests/utils.tsx index 292e31c37..592f0bbaf 100644 --- a/renderers/react/tests/utils.tsx +++ b/renderers/react/tests/utils.tsx @@ -29,6 +29,10 @@ export interface RenderA2uiOptions { additionalComponents?: ComponentModel[]; /** Functions to include in the catalog */ functions?: any[]; + /** Theme to apply to the surface */ + theme?: any; + /** Optional wrapper for the component under test (e.g. providers) */ + wrapper?: React.JSXElementConstructor<{children: React.ReactNode}>; } /** @@ -45,13 +49,15 @@ export function renderA2uiComponent( initialData = {}, additionalImpls = [], additionalComponents = [], - functions = BASIC_FUNCTIONS + functions = BASIC_FUNCTIONS, + theme = {}, + wrapper } = options; // Combine all implementations into the catalog const allImpls = [impl, ...additionalImpls]; const catalog = new Catalog('test-catalog', allImpls, functions); - const surface = new SurfaceModel('test-surface', catalog); + const surface = new SurfaceModel('test-surface', catalog, theme); // Setup data model surface.dataModel.set('/', initialData); @@ -91,7 +97,8 @@ export function renderA2uiComponent( const ComponentToRender = impl.render; const view = render( - + , + { wrapper } ); return { diff --git a/renderers/react/tests/v0_9/markdown.test.tsx b/renderers/react/tests/v0_9/markdown.test.tsx new file mode 100644 index 000000000..703ed59b8 --- /dev/null +++ b/renderers/react/tests/v0_9/markdown.test.tsx @@ -0,0 +1,69 @@ +/** + * Copyright 2026 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 + * + * http://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 React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderA2uiComponent } from '../utils'; +import { MarkdownProvider, Text } from '../../src/v0_9'; +import { type MarkdownRenderer } from '@a2ui/web_core/v0_9'; + +describe('Markdown Rendering in React v0.9', () => { + it('renders plain text when no markdown renderer is provided', async () => { + renderA2uiComponent(Text, 't1', { text: '**Bold**' }); + + // It should render the raw markdown string as plain text + expect(screen.getByText('**Bold**')).toBeDefined(); + }); + + it('renders markdown when a renderer is provided via MarkdownProvider', async () => { + const mockRenderer: MarkdownRenderer = vi.fn().mockResolvedValue('Bold'); + + renderA2uiComponent(Text, 't1', { text: '**Bold**' }, { + wrapper: ({ children }) => ( + + {children} + + ) + }); + + // Wait for the mock renderer to be called and state to update + await waitFor(() => expect(mockRenderer).toHaveBeenCalledWith('**Bold**')); + + // Check for rendered HTML. dangerouslySetInnerHTML is used. + const element = screen.getByText('Bold'); + expect(element.tagName).toBe('STRONG'); + expect(element.parentElement?.tagName).toBe('SPAN'); // Default body variant is span + }); + + it('renders with correct semantic wrapper and injected markdown', async () => { + const mockRenderer: MarkdownRenderer = vi.fn().mockResolvedValue('Underline'); + + const { view } = renderA2uiComponent(Text, 't1', { text: 'text', variant: 'h2' }, { + wrapper: ({ children }) => ( + + {children} + + ) + }); + + await waitFor(() => { + const h2 = view.container.querySelector('h2'); + expect(h2).not.toBeNull(); + expect(h2?.innerHTML).toBe('Underline'); + }); + }); +}); diff --git a/renderers/web_core/src/v0_9/catalog/types.ts b/renderers/web_core/src/v0_9/catalog/types.ts index ae24f892d..5002e4074 100644 --- a/renderers/web_core/src/v0_9/catalog/types.ts +++ b/renderers/web_core/src/v0_9/catalog/types.ts @@ -94,6 +94,32 @@ export function createFunctionImplementation< import {FunctionInvoker} from './function_invoker.js'; +/** + * A map of tag names to a list of classnames to be applied to a tag. + */ +export type MarkdownRendererTagClassMap = Record; + +/** + * The options for the markdown renderer passed from A2UI. + */ +export type MarkdownRendererOptions = { + tagClassMap?: MarkdownRendererTagClassMap; +}; + +/** + * Renders `markdown` using `options`. + * + * SECURITY WARNING: The resulting HTML will be injected directly into the DOM + * (e.g., using dangerouslySetInnerHTML). Implementations MUST ensure the + * HTML is properly sanitized to prevent Cross-Site Scripting (XSS) attacks. + * + * @returns A promise that resolves to the rendered HTML as a string. + */ +export type MarkdownRenderer = ( + markdown: string, + options?: MarkdownRendererOptions, +) => Promise; + /** * A definition of a UI component's API. * This interface defines the contract for a component's capabilities and properties, 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 910ee2345..8fe96b3b8 100644 --- a/renderers/web_core/src/v0_9/rendering/component-context.ts +++ b/renderers/web_core/src/v0_9/rendering/component-context.ts @@ -34,6 +34,8 @@ export class ComponentContext { readonly dataContext: DataContext; /** The collection of all component models for the current surface, allowing lookups by ID. */ readonly surfaceComponents: SurfaceComponentsModel; + /** The theme applied to this surface. */ + readonly theme?: any; /** * Creates a new component context. @@ -53,6 +55,7 @@ export class ComponentContext { } this.componentModel = model; this.surfaceComponents = surface.componentsModel; + this.theme = surface.theme; this.dataContext = new DataContext(surface, dataModelBasePath); this._actionDispatcher = action =>