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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/react_renderer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions renderers/react/a2ui_explorer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>}) => {
const subscribeHook = useCallback(
Expand Down Expand Up @@ -206,7 +207,9 @@ export default function App() {
background: '#fff',
}}
>
<A2uiSurface surface={surface} />
<MarkdownProvider renderer={renderMarkdown}>
<A2uiSurface surface={surface} />
</MarkdownProvider>
</div>
</div>
);
Expand Down
29 changes: 29 additions & 0 deletions renderers/react/package-lock.json

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

5 changes: 3 additions & 2 deletions renderers/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 79 additions & 15 deletions renderers/react/src/v0_9/catalog/basic/components/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 <h1 style={style}>{text}</h1>;
return (
<h1 style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</h1>
);
case 'h2':
return <h2 style={style}>{text}</h2>;
return (
<h2 style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</h2>
);
case 'h3':
return <h3 style={style}>{text}</h3>;
return (
<h3 style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</h3>
);
case 'h4':
return <h4 style={style}>{text}</h4>;
return (
<h4 style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</h4>
);
case 'h5':
return <h5 style={style}>{text}</h5>;
return (
<h5 style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</h5>
);
case 'caption':
return <caption style={{...style, color: '#666', textAlign: 'left'}}>{text}</caption>;
return (
<caption
style={{...style, color: '#666', textAlign: 'left'}}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
>
{content}
</caption>
);
case 'body':
default:
return <span style={style}>{text}</span>;
return (
<span style={style} dangerouslySetInnerHTML={dangerouslySetInnerHTML}>
{content}
</span>
);
}
};

export const Text = createReactComponent(TextApi, ({props}) => {
const text = props.text ?? '';
const style: React.CSSProperties = {
...getBaseLeafStyle(),
display: 'inline-block',
};

return <MarkdownContent text={text} variant={props.variant} style={style} />;
});
29 changes: 29 additions & 0 deletions renderers/react/src/v0_9/context/MarkdownContext.tsx
Original file line number Diff line number Diff line change
@@ -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<MarkdownRenderer | undefined>(undefined);

export const MarkdownProvider: React.FC<{
renderer?: MarkdownRenderer;
children: React.ReactNode;
}> = ({renderer, children}) => (
<MarkdownContext.Provider value={renderer}>{children}</MarkdownContext.Provider>
);

export const useMarkdown = () => useContext(MarkdownContext);
1 change: 1 addition & 0 deletions renderers/react/src/v0_9/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 10 additions & 3 deletions renderers/react/tests/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>;
}

/**
Expand All @@ -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<ReactComponentImplementation>('test-surface', catalog);
const surface = new SurfaceModel<ReactComponentImplementation>('test-surface', catalog, theme);

// Setup data model
surface.dataModel.set('/', initialData);
Expand Down Expand Up @@ -91,7 +97,8 @@ export function renderA2uiComponent(
const ComponentToRender = impl.render;

const view = render(
<ComponentToRender context={mainContext} buildChild={buildChild} />
<ComponentToRender context={mainContext} buildChild={buildChild} />,
{ wrapper }
);

return {
Expand Down
69 changes: 69 additions & 0 deletions renderers/react/tests/v0_9/markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<strong>Bold</strong>');

renderA2uiComponent(Text, 't1', { text: '**Bold**' }, {
wrapper: ({ children }) => (
<MarkdownProvider renderer={mockRenderer}>
{children}
</MarkdownProvider>
)
});

// 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('<u>Underline</u>');

const { view } = renderA2uiComponent(Text, 't1', { text: 'text', variant: 'h2' }, {
wrapper: ({ children }) => (
<MarkdownProvider renderer={mockRenderer}>
{children}
</MarkdownProvider>
)
});

await waitFor(() => {
const h2 = view.container.querySelector('h2');
expect(h2).not.toBeNull();
expect(h2?.innerHTML).toBe('<u>Underline</u>');
});
});
});
Loading
Loading