Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions renderers/react/package-lock.json

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

90 changes: 75 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,93 @@
* 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(() => {
if (renderer) {
renderer(text).then(setHtml);

Check failure on line 33 in renderers/react/src/v0_9/catalog/basic/components/Text.tsx

View workflow job for this annotation

GitHub Actions / lint

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
} else {
setHtml(null); // Fallback to plain text
}
}, [text, renderer]);

const content = html === null ? text : undefined;
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 (

Check failure on line 99 in renderers/react/src/v0_9/catalog/basic/components/Text.tsx

View workflow job for this annotation

GitHub Actions / lint

Replace `(⏎····<MarkdownContent⏎······text={text}⏎······variant={props.variant}⏎······style={style}⏎····/>⏎··)` with `<MarkdownContent·text={text}·variant={props.variant}·style={style}·/>`
<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
80 changes: 80 additions & 0 deletions renderers/react/tests/v0_9/markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* 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, act } 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>
)
});

// Initial render might show plain text or nothing while promise resolves
// Wait for the mock renderer to be called and state to update
await act(async () => {
await Promise.resolve(); // Allow useEffect to trigger
await Promise.resolve(); // Allow renderer promise to resolve
await Promise.resolve(); // Allow setHtml to trigger re-render
});

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 act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

const h2 = view.container.querySelector('h2');
expect(h2).not.toBeNull();
expect(h2?.innerHTML).toBe('<u>Underline</u>');
});
});
21 changes: 21 additions & 0 deletions renderers/web_core/src/v0_9/catalog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ 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<string, string[]>;

/**
* The options for the markdown renderer passed from A2UI.
*/
export type MarkdownRendererOptions = {
tagClassMap?: MarkdownRendererTagClassMap;
};

/**
* Renders `markdown` using `options`.
* @returns A promise that resolves to the rendered HTML as a string.
*/
export type MarkdownRenderer = (
markdown: string,
options?: MarkdownRendererOptions,
) => Promise<string>;

/**
* A definition of a UI component's API.
* This interface defines the contract for a component's capabilities and properties,
Expand Down
3 changes: 3 additions & 0 deletions renderers/web_core/src/v0_9/rendering/component-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 =>
Expand Down
Loading