diff --git a/renderers/angular/src/lib/catalog/text.ts b/renderers/angular/src/lib/catalog/text.ts index 631849760..eba0228c0 100644 --- a/renderers/angular/src/lib/catalog/text.ts +++ b/renderers/angular/src/lib/catalog/text.ts @@ -22,6 +22,7 @@ import { input, ViewEncapsulation, } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; import { DynamicComponent } from '../rendering/dynamic-component'; import * as Primitives from '@a2ui/web_core/types/primitives'; import * as Styles from '@a2ui/web_core/styles/index'; @@ -45,10 +46,11 @@ interface HintedStyles {
`, encapsulation: ViewEncapsulation.None, + imports: [AsyncPipe], styles: ` a2ui-text { display: block; @@ -75,7 +77,7 @@ export class Text extends DynamicComponent { let value = super.resolvePrimitive(this.text()); if (value == null) { - return '(empty)'; + return Promise.resolve('(empty)'); } switch (usageHint) { diff --git a/renderers/angular/src/lib/data/markdown.ts b/renderers/angular/src/lib/data/markdown.ts index 4250b9733..a4914ee29 100644 --- a/renderers/angular/src/lib/data/markdown.ts +++ b/renderers/angular/src/lib/data/markdown.ts @@ -28,7 +28,7 @@ export class MarkdownRenderer { private sanitizer = inject(DomSanitizer); private static defaultMarkdownWarningLogged = false; - render(value: string, markdownOptions?: Types.MarkdownRendererOptions) { + async render(value: string, markdownOptions?: Types.MarkdownRendererOptions): Promise { if (this.markdownRenderer) { // The markdownRenderer should return a sanitized string. return this.markdownRenderer(value, markdownOptions); diff --git a/renderers/lit/src/0.8/ui/directives/markdown.ts b/renderers/lit/src/0.8/ui/directives/markdown.ts index 20ee6b0be..4536fa7f4 100644 --- a/renderers/lit/src/0.8/ui/directives/markdown.ts +++ b/renderers/lit/src/0.8/ui/directives/markdown.ts @@ -22,6 +22,7 @@ import { directive, } from "lit/directive.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { until } from "lit/directives/until.js"; import * as Types from "@a2ui/web_core/types/types"; class MarkdownDirective extends Directive { @@ -49,10 +50,15 @@ class MarkdownDirective extends Directive { */ render(value: string, markdownRenderer?: Types.MarkdownRenderer, markdownOptions?: Types.MarkdownRendererOptions) { if (markdownRenderer) { - // The markdown renderer returns a string, which we need to convert to a - // template result using unsafeHTML. - // It is the responsibilty of the markdown renderer to sanitize the HTML. - return unsafeHTML(markdownRenderer(value, markdownOptions)); + const rendered = markdownRenderer(value, markdownOptions).then((value) => { + // `value` is a plain string, which we need to convert to a template + // with the `unsafeHTML` directive. + // It is the responsibility of the markdown renderer to sanitize the HTML. + return unsafeHTML(value); + }) + // The until directive lets us render a placeholder *until* the rendered + // content resolves. + return until(rendered, html`${value}`); } if (!MarkdownDirective.defaultMarkdownWarningLogged) { diff --git a/renderers/markdown/markdown-it/CHANGELOG.md b/renderers/markdown/markdown-it/CHANGELOG.md new file mode 100644 index 000000000..34ed72139 --- /dev/null +++ b/renderers/markdown/markdown-it/CHANGELOG.md @@ -0,0 +1,7 @@ +## 0.0.2 + +- Made the markdown renderer async to support async markdown renderers. + +## 0.0.1 + +- Initial release diff --git a/renderers/markdown/markdown-it/package.json b/renderers/markdown/markdown-it/package.json index 69de3d150..03580ba74 100644 --- a/renderers/markdown/markdown-it/package.json +++ b/renderers/markdown/markdown-it/package.json @@ -1,6 +1,6 @@ { "name": "@a2ui/markdown-it", - "version": "0.0.1", + "version": "0.0.2", "description": "A Markdown renderer using markdown-it and dompurify.", "keywords": [], "homepage": "https://github.com/google/A2UI/tree/main/web#readme", diff --git a/renderers/markdown/markdown-it/src/markdown.test.ts b/renderers/markdown/markdown-it/src/markdown.test.ts index 4378f8f95..e3ca65674 100644 --- a/renderers/markdown/markdown-it/src/markdown.test.ts +++ b/renderers/markdown/markdown-it/src/markdown.test.ts @@ -50,42 +50,42 @@ describe('MarkdownItRenderer', () => { }); describe('renderMarkdown', () => { - it('renders markdown successfully', () => { - const html = renderMarkdown('# Hello World'); + it('renders markdown successfully', async () => { + const html = await renderMarkdown('# Hello World'); assert.match(html, /

Hello World<\/h1>/); }); - it('sanitizes malicious markdown links', () => { + it('sanitizes malicious markdown links', async () => { // Markdown-it strips javascript links by default, emitting the raw markdown string. // DOMPurify acts as a secondary layer of defense. const input = 'This is a test [link](javascript:alert("XSS"))'; - const html = renderMarkdown(input); + const html = await renderMarkdown(input); // Ensure the javascript protocol link is neutralized completely assert.doesNotMatch(html, /href="javascript:alert/); assert.match(html, /\[link\]\(javascript:alert\("XSS"\)\)/); // It remains raw text }); - it('safely escapes HTML input without enabling raw HTML', () => { + it('safely escapes HTML input without enabling raw HTML', async () => { const input = 'This is a test '; - const html = renderMarkdown(input); + const html = await renderMarkdown(input); // Markdown-it will escape it to <script> assert.match(html, /<script>alert\("XSS"\)<\/script>/); assert.doesNotMatch(html, /