diff --git a/CHANGELOG.md b/CHANGELOG.md index cd089bc7be..cc7dfaf41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Removed deprecated code: `connect*`, `useRenderActivity`, `useRenderActivityStatus`, `useRenderAvatar`, in PR [#5148](https://github.com/microsoft/BotFramework-WebChat/pull/5148), by [@compulim](https://github.com/compulim) - Added named exports in both CommonJS and ES Modules module format, in PR [#5148](https://github.com/microsoft/BotFramework-WebChat/pull/5148), by [@compulim](https://github.com/compulim) - Removed deprecated `useFocusSendBox()` hook, please use `useFocus('sendBox')` instead, in PR [#5150](https://github.com/microsoft/BotFramework-WebChat/pull/5150), by [@OEvgeny](https://github.com/OEvgeny) +- HTML-in-Markdown is now supported. To disable this feature, set `styleOptions.markdownRenderHTML` to `false` ### Added @@ -65,6 +66,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added `` component to apply theme pack to Web Chat, by [@compulim](https://github.com/compulim), in PR [#5120](https://github.com/microsoft/BotFramework-WebChat/pull/5120) - Added `useMakeThumbnail` hook option to create a thumbnail from the file given, by [@compulim](https://github.com/compulim), in PR [#5123](https://github.com/microsoft/BotFramework-WebChat/pull/5123) and [#5122](https://github.com/microsoft/BotFramework-WebChat/pull/5122) - Added `moduleFormat` and `transpiler` build info to `` tag, in PR [#5148](https://github.com/microsoft/BotFramework-WebChat/pull/5148), by [@compulim](https://github.com/compulim) +- Added support of rendering HTML-in-Markdown, in PR [#5161](https://github.com/microsoft/BotFramework-WebChat/pull/5161), by [@compulim](https://github.com/compulim), [@beyackle2](https://github.com/beyackle2), and [@OEvgeny](https://github.com/OEvgeny) ### Fixed diff --git a/README.md b/README.md index 8196c19802..63008a8195 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,12 @@ export default function MyComponent() { } ``` +#### Support HTML-in-Markdown + +Web Chat will now render HTML-in-Markdown. We have ported our sanitizer and accessibility fixer to work on HTML level. Both Markdown and HTML-in-Markdown will receive the same treatment and meet our security and accessibility requirements. + +You can turn off this option by setting `styleOptions.markdownRenderHTML` to `false`. + ### 4.16.1 notable changes Web Chat now supports [Adaptive Cards schema up to 1.6](https://adaptivecards.io/explorer/). Some features in Adaptive Cards are in preview or designed to use outside of Bot Framework. Web Chat does not support these features. diff --git a/__tests__/__image_snapshots__/chrome-docker/rich-cards-js-hero-card-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/rich-cards-js-hero-card-1-snap.png index 06544497df..3adb5dfb85 100644 Binary files a/__tests__/__image_snapshots__/chrome-docker/rich-cards-js-hero-card-1-snap.png and b/__tests__/__image_snapshots__/chrome-docker/rich-cards-js-hero-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-1-snap.png index 8850f3faeb..8f7c71cce2 100644 Binary files a/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-1-snap.png and b/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-2-snap.png b/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-2-snap.png index 1a0fe6a9e2..153284c88c 100644 Binary files a/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-2-snap.png and b/__tests__/__image_snapshots__/chrome-docker/style-options-js-style-options-hide-scroll-to-bottom-button-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/default-adaptive-cards-js-markdown-render-html-when-unset-should-render-sanitized-html-in-adaptive-cards-1-snap.png b/__tests__/__image_snapshots__/html/default-adaptive-cards-js-markdown-render-html-when-unset-should-render-sanitized-html-in-adaptive-cards-1-snap.png new file mode 100644 index 0000000000..5f7deee74b Binary files /dev/null and b/__tests__/__image_snapshots__/html/default-adaptive-cards-js-markdown-render-html-when-unset-should-render-sanitized-html-in-adaptive-cards-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/default-citation-modal-js-markdown-render-html-when-unset-should-render-sanitized-html-in-citation-modal-1-snap.png b/__tests__/__image_snapshots__/html/default-citation-modal-js-markdown-render-html-when-unset-should-render-sanitized-html-in-citation-modal-1-snap.png new file mode 100644 index 0000000000..480393d79c Binary files /dev/null and b/__tests__/__image_snapshots__/html/default-citation-modal-js-markdown-render-html-when-unset-should-render-sanitized-html-in-citation-modal-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/default-message-activity-js-markdown-render-html-when-unset-should-render-sanitized-html-in-message-activity-1-snap.png b/__tests__/__image_snapshots__/html/default-message-activity-js-markdown-render-html-when-unset-should-render-sanitized-html-in-message-activity-1-snap.png new file mode 100644 index 0000000000..cc28ba2df9 Binary files /dev/null and b/__tests__/__image_snapshots__/html/default-message-activity-js-markdown-render-html-when-unset-should-render-sanitized-html-in-message-activity-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/false-adaptive-cards-js-markdown-render-html-when-set-to-false-should-not-render-html-in-adaptive-cards-1-snap.png b/__tests__/__image_snapshots__/html/false-adaptive-cards-js-markdown-render-html-when-set-to-false-should-not-render-html-in-adaptive-cards-1-snap.png new file mode 100644 index 0000000000..04db333b8b Binary files /dev/null and b/__tests__/__image_snapshots__/html/false-adaptive-cards-js-markdown-render-html-when-set-to-false-should-not-render-html-in-adaptive-cards-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/false-citation-modal-js-markdown-render-html-when-set-to-false-should-not-render-html-in-citation-modal-1-snap.png b/__tests__/__image_snapshots__/html/false-citation-modal-js-markdown-render-html-when-set-to-false-should-not-render-html-in-citation-modal-1-snap.png new file mode 100644 index 0000000000..26fb25b06e Binary files /dev/null and b/__tests__/__image_snapshots__/html/false-citation-modal-js-markdown-render-html-when-set-to-false-should-not-render-html-in-citation-modal-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/false-message-activity-js-markdown-render-html-when-set-to-false-should-not-render-html-in-message-activity-1-snap.png b/__tests__/__image_snapshots__/html/false-message-activity-js-markdown-render-html-when-set-to-false-should-not-render-html-in-message-activity-1-snap.png new file mode 100644 index 0000000000..776a94cf1e Binary files /dev/null and b/__tests__/__image_snapshots__/html/false-message-activity-js-markdown-render-html-when-set-to-false-should-not-render-html-in-message-activity-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-1-snap.png b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-1-snap.png index 69e567ec96..d0de3645ed 100644 Binary files a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-1-snap.png and b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-2-snap.png b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-2-snap.png index 73a345c904..e779251f1b 100644 Binary files a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-2-snap.png and b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-hero-card-js-focus-management-after-receive-hero-card-click-on-scroll-to-end-button-should-focus-on-button-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-1-snap.png b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-1-snap.png index 58ac90fdb3..3f12c298dc 100644 Binary files a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-1-snap.png and b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-2-snap.png b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-2-snap.png index 129ebcee59..b90a402843 100644 Binary files a/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-2-snap.png and b/__tests__/__image_snapshots__/html/focus-management-scroll-to-end-button-receive-message-js-focus-management-after-receive-text-message-click-on-scroll-to-end-button-should-focus-on-send-box-2-snap.png differ diff --git a/__tests__/__image_snapshots__/html/markdown-hero-card-js-markdown-should-render-hero-card-1-snap.png b/__tests__/__image_snapshots__/html/markdown-hero-card-js-markdown-should-render-hero-card-1-snap.png index 48281aa4ee..1c8adf67ea 100644 Binary files a/__tests__/__image_snapshots__/html/markdown-hero-card-js-markdown-should-render-hero-card-1-snap.png and b/__tests__/__image_snapshots__/html/markdown-hero-card-js-markdown-should-render-hero-card-1-snap.png differ diff --git a/__tests__/__image_snapshots__/html/scroll-to-end-button-tab-order-js-scroll-to-end-button-should-have-correct-tab-order-1-snap.png b/__tests__/__image_snapshots__/html/scroll-to-end-button-tab-order-js-scroll-to-end-button-should-have-correct-tab-order-1-snap.png index 05712d0d90..1287c59355 100644 Binary files a/__tests__/__image_snapshots__/html/scroll-to-end-button-tab-order-js-scroll-to-end-button-should-have-correct-tab-order-1-snap.png and b/__tests__/__image_snapshots__/html/scroll-to-end-button-tab-order-js-scroll-to-end-button-should-have-correct-tab-order-1-snap.png differ diff --git a/__tests__/hooks/useRenderMarkdownAsHTML.js b/__tests__/hooks/useRenderMarkdownAsHTML.js index ef1a95af4f..3da3932de9 100644 --- a/__tests__/hooks/useRenderMarkdownAsHTML.js +++ b/__tests__/hooks/useRenderMarkdownAsHTML.js @@ -5,24 +5,36 @@ import { timeouts } from '../constants.json'; jest.setTimeout(timeouts.test); +const CSS_HASH_PATTERN = /webchat--css-[\d\w]*-[\d\w]*/u; + test('renderMarkdown should use Markdown-It if not set in props', async () => { const { pageObjects } = await setupWebDriver(); - await expect(pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Hello, World!'))).resolves.toBe( - '

Hello, World!

\n' + expect( + (await pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Hello, World!'))).replace( + CSS_HASH_PATTERN, + 'webchat--css-xxxxx-xxxxx' + ) + ).toBe( + '

Hello, World!

\n' ); }); test('renderMarkdown should use custom Markdown transform function from props', async () => { const { pageObjects } = await setupWebDriver({ props: { - renderMarkdown: text => text.toUpperCase() + renderMarkdown: text => `

${text.toUpperCase()}

` } }); - await expect( - pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Hello, World!')) - ).resolves.toMatchInlineSnapshot(`"HELLO, WORLD!"`); + expect( + (await pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Hello, World!'))).replace( + CSS_HASH_PATTERN, + 'webchat--css-xxxxx-xxxxx' + ) + ).toBe( + '

HELLO, WORLD!

\n' + ); }); test('renderMarkdown should return falsy if the custom Markdown transform function is null', async () => { @@ -32,27 +44,33 @@ test('renderMarkdown should return falsy if the custom Markdown transform functi } }); - await expect(pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => !!fn)).resolves.toMatchInlineSnapshot(`false`); + expect(await pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => !!fn)).toBe(false); }); test('renderMarkdown should add accessibility text for external links', async () => { const { pageObjects } = await setupWebDriver(); - await expect( - pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Click [here](https://aka.ms/) to find out more.')) - ).resolves.toMatchInlineSnapshot(` - "

Click \u200Bhere\u200B to find out more.

- " - `); + expect( + ( + await pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => + fn('Click [here](https://aka.ms/) to find out more.') + ) + ).replace(CSS_HASH_PATTERN, 'webchat--css-xxxxx-xxxxx') + ).toBe( + `

Click \u200Bhere\u200B to find out more.

\n` + ); }); test('renderMarkdown should add accessibility text for external links with yue', async () => { const { pageObjects } = await setupWebDriver({ props: { locale: 'yue' } }); - await expect( - pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => fn('Click [here](https://aka.ms/) to find out more.')) - ).resolves.toMatchInlineSnapshot(` - "

Click \u200Bhere\u200B to find out more.

- " - `); + expect( + ( + await pageObjects.runHook('useRenderMarkdownAsHTML', [], fn => + fn('Click [here](https://aka.ms/) to find out more.') + ) + ).replace(CSS_HASH_PATTERN, 'webchat--css-xxxxx-xxxxx') + ).toBe( + `

Click \u200Bhere\u200B to find out more.

\n` + ); }); diff --git a/__tests__/html/markdownRenderHTML/default.adaptiveCards.html b/__tests__/html/markdownRenderHTML/default.adaptiveCards.html new file mode 100644 index 0000000000..65b98d9200 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.adaptiveCards.html @@ -0,0 +1,69 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/default.adaptiveCards.js b/__tests__/html/markdownRenderHTML/default.adaptiveCards.js new file mode 100644 index 0000000000..6c7067fc09 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.adaptiveCards.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when unset', () => { + test('should render sanitized HTML in Adaptive Cards', () => runHTML('markdownRenderHTML/default.adaptiveCards')); +}); diff --git a/__tests__/html/markdownRenderHTML/default.citationModal.html b/__tests__/html/markdownRenderHTML/default.citationModal.html new file mode 100644 index 0000000000..4c0e1be68d --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.citationModal.html @@ -0,0 +1,105 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/default.citationModal.js b/__tests__/html/markdownRenderHTML/default.citationModal.js new file mode 100644 index 0000000000..efb93257e3 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.citationModal.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when unset', () => { + test('should render sanitized HTML in citation modal', () => runHTML('markdownRenderHTML/default.citationModal')); +}); diff --git a/__tests__/html/markdownRenderHTML/default.messageActivity.html b/__tests__/html/markdownRenderHTML/default.messageActivity.html new file mode 100644 index 0000000000..87d43022f6 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.messageActivity.html @@ -0,0 +1,42 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/default.messageActivity.js b/__tests__/html/markdownRenderHTML/default.messageActivity.js new file mode 100644 index 0000000000..ad85a6e956 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/default.messageActivity.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when unset', () => { + test('should render sanitized HTML in message activity', () => runHTML('markdownRenderHTML/default.messageActivity')); +}); diff --git a/__tests__/html/markdownRenderHTML/false.adaptiveCards.html b/__tests__/html/markdownRenderHTML/false.adaptiveCards.html new file mode 100644 index 0000000000..f7ec333bdd --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.adaptiveCards.html @@ -0,0 +1,68 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/false.adaptiveCards.js b/__tests__/html/markdownRenderHTML/false.adaptiveCards.js new file mode 100644 index 0000000000..6cd42b17ab --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.adaptiveCards.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when set to false', () => { + test('should not render HTML in Adaptive Cards', () => runHTML('markdownRenderHTML/false.adaptiveCards')); +}); diff --git a/__tests__/html/markdownRenderHTML/false.citationModal.html b/__tests__/html/markdownRenderHTML/false.citationModal.html new file mode 100644 index 0000000000..7f384e0125 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.citationModal.html @@ -0,0 +1,106 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/false.citationModal.js b/__tests__/html/markdownRenderHTML/false.citationModal.js new file mode 100644 index 0000000000..465afe9239 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.citationModal.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when set to false', () => { + test('should not render HTML in citation modal', () => runHTML('markdownRenderHTML/false.citationModal')); +}); diff --git a/__tests__/html/markdownRenderHTML/false.messageActivity.html b/__tests__/html/markdownRenderHTML/false.messageActivity.html new file mode 100644 index 0000000000..e6bd53ea7e --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.messageActivity.html @@ -0,0 +1,41 @@ + + + + + + + + + +
+ + + diff --git a/__tests__/html/markdownRenderHTML/false.messageActivity.js b/__tests__/html/markdownRenderHTML/false.messageActivity.js new file mode 100644 index 0000000000..d57afb8ef0 --- /dev/null +++ b/__tests__/html/markdownRenderHTML/false.messageActivity.js @@ -0,0 +1,5 @@ +/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */ + +describe('markdownRenderHTML when set to false', () => { + test('should not render HTML in message activity', () => runHTML('markdownRenderHTML/false.messageActivity')); +}); diff --git a/packages/api/src/StyleOptions.ts b/packages/api/src/StyleOptions.ts index 323b197540..53db634608 100644 --- a/packages/api/src/StyleOptions.ts +++ b/packages/api/src/StyleOptions.ts @@ -211,10 +211,19 @@ type StyleOptions = { markdownRespectCRLF?: boolean; /** - * Assign new image for anchor links to indicate external + * Render HTML inside Markdown. + * + * `true` to render HTML inside Markdown, otherwise, `false`. Defaults to `true`. + * + * New in 4.17: This option is enabled by default. */ + markdownRenderHTML?: boolean; + /** + * Assign new image for anchor links to indicate external + */ markdownExternalLinkIconImage?: string; + /** * Scroll behavior styling */ diff --git a/packages/api/src/defaultStyleOptions.ts b/packages/api/src/defaultStyleOptions.ts index 422b84a004..68dd6acf55 100644 --- a/packages/api/src/defaultStyleOptions.ts +++ b/packages/api/src/defaultStyleOptions.ts @@ -82,6 +82,7 @@ const DEFAULT_OPTIONS: Required = { markdownExternalLinkIconImage: 'url(data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIzIDMgMTggMTgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuMjUwMSA0LjUwMDE3SDEwLjc0OTVDMTEuMTYzNyA0LjUwMDE3IDExLjQ5OTUgNC44MzU5NiAxMS40OTk1IDUuMjUwMTdDMTEuNDk5NSA1LjYyOTg2IDExLjIxNzMgNS45NDM2NiAxMC44NTEzIDUuOTkzMzJMMTAuNzQ5NSA2LjAwMDE3SDcuMjQ5NzRDNi4wNzA3OSA1Ljk5OTYxIDUuMTAzNDkgNi45MDY1NiA1LjAwNzg2IDguMDYxMTJMNS4wMDAyOCA4LjIyMDAzTDUuMDAzMTIgMTYuNzUwN0M1LjAwMzQzIDE3Ljk0MTUgNS45Mjg4NSAxOC45MTYxIDcuMDk5NjYgMTguOTk0OUw3LjI1MzcxIDE5LjAwMDFMMTUuNzUxOCAxOC45ODg0QzE2Ljk0MTUgMTguOTg2OCAxNy45MTQ1IDE4LjA2MiAxNy45OTM1IDE2Ljg5MjNMMTcuOTk4NyAxNi43Mzg0VjEzLjIzMjFDMTcuOTk4NyAxMi44MTc5IDE4LjMzNDUgMTIuNDgyMSAxOC43NDg3IDEyLjQ4MjFDMTkuMTI4NCAxMi40ODIxIDE5LjQ0MjIgMTIuNzY0MyAxOS40OTE4IDEzLjEzMDNMMTkuNDk4NyAxMy4yMzIxVjE2LjczODRDMTkuNDk4NyAxOC43NDA3IDE3LjkyOTMgMjAuMzc2OSAxNS45NTI4IDIwLjQ4MjlMMTUuNzUzOCAyMC40ODg0TDcuMjU4MjcgMjAuNTAwMUw3LjA1NDk1IDIwLjQ5NDlDNS4xNDIzOSAyMC4zOTU0IDMuNjA4OTUgMTguODYyNyAzLjUwODM3IDE2Ljk1MDJMMy41MDMxMiAxNi43NTExTDMuNTAwODkgOC4yNTI3TDMuNTA1MjkgOC4wNTAyQzMuNjA1MzkgNi4xMzc0OSA1LjEzODY3IDQuNjA0NDkgNy4wNTA5NiA0LjUwNTI3TDcuMjUwMSA0LjUwMDE3SDEwLjc0OTVINy4yNTAxWk0xMy43NDgxIDMuMDAxNDZMMjAuMzAxOCAzLjAwMTk3TDIwLjQwMTQgMy4wMTU3NUwyMC41MDIyIDMuMDQzOTNMMjAuNTU5IDMuMDY4MDNDMjAuNjEyMiAzLjA5MTIyIDIwLjY2MzQgMy4xMjE2MyAyMC43MTExIDMuMTU4ODVMMjAuNzgwNCAzLjIyMTU2TDIwLjg2NDEgMy4zMjAxNEwyMC45MTgzIDMuNDEwMjVMMjAuOTU3IDMuNTAwNTdMMjAuOTc2MiAzLjU2NDc2TDIwLjk4OTggMy42Mjg2MkwyMC45OTkyIDMuNzIyODJMMjAuOTk5NyAxMC4yNTU0QzIwLjk5OTcgMTAuNjY5NiAyMC42NjM5IDExLjAwNTQgMjAuMjQ5NyAxMS4wMDU0QzE5Ljg3IDExLjAwNTQgMTkuNTU2MiAxMC43MjMyIDE5LjUwNjUgMTAuMzU3MUwxOS40OTk3IDEwLjI1NTRMMTkuNDk4OSA1LjU2MTQ3TDEyLjI3OTcgMTIuNzg0N0MxMi4wMTM0IDEzLjA1MSAxMS41OTY4IDEzLjA3NTMgMTEuMzAzMSAxMi44NTc1TDExLjIxOSAxMi43ODQ5QzEwLjk1MjcgMTIuNTE4NyAxMC45Mjg0IDEyLjEwMjEgMTEuMTQ2MiAxMS44MDg0TDExLjIxODggMTEuNzI0M0wxOC40MzY5IDQuNTAxNDZIMTMuNzQ4MUMxMy4zNjg0IDQuNTAxNDYgMTMuMDU0NiA0LjIxOTMxIDEzLjAwNSAzLjg1MzI0TDEyLjk5ODEgMy43NTE0NkMxMi45OTgxIDMuMzcxNzcgMTMuMjgwMyAzLjA1Nzk3IDEzLjY0NjQgMy4wMDgzMUwxMy43NDgxIDMuMDAxNDZaIiBmaWxsPSIjMjEyMTIxIiAvPjwvc3ZnPg==)', markdownRespectCRLF: true, + markdownRenderHTML: true, // Scroll behavior hideScrollToEndButton: undefined, // Deprecated as of 4.14.0. Use "scrollToEndButtonBehavior" instead. Remove on or after 2023-06-02. diff --git a/packages/bundle/src/__tests__/renderMarkdown.spec.js b/packages/bundle/src/__tests__/renderMarkdown.spec.js index 927e51785d..eb01babb16 100644 --- a/packages/bundle/src/__tests__/renderMarkdown.spec.js +++ b/packages/bundle/src/__tests__/renderMarkdown.spec.js @@ -1,3 +1,4 @@ +/** @jest-environment jsdom */ /* eslint no-magic-numbers: ["error", { "ignore": [2] }] */ import renderMarkdown from '../markdown/renderMarkdown'; @@ -54,7 +55,7 @@ describe('renderMarkdown', () => { expect(renderMarkdown('[example](https://sample.com){aria-label="Sample label"}', styleOptions)) .toMatchInlineSnapshot(` - "

\u200Bexample\u200B

+ "

\u200Bexample\u200B

" `); }); diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx index 30ddab726f..65d5b3f217 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.tsx @@ -52,7 +52,7 @@ const AdaptiveCardRenderer: VFC = ({ const contentRef = useRef(); const localize = useLocalizer(); const performCardAction = usePerformCardAction(); - const renderMarkdownAsHTML = useRenderMarkdownAsHTML(); + const renderMarkdownAsHTML = useRenderMarkdownAsHTML('adaptive cards'); const scrollToEnd = useScrollToEnd(); const disabled = disabledFromComposer || disabledFromProps; diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.ariaLabel.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.ariaLabel.spec.ts new file mode 100644 index 0000000000..a33fcf4347 --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.ariaLabel.spec.ts @@ -0,0 +1,53 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "ariaLabel" option with "Hello, World!"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { ariaLabel: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have "aria-label" attribute set to "Hello, World!"', () => + expect(actual.querySelector('a').getAttribute('aria-label')).toBe('Hello, World!')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "ariaLabel" option with false', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { ariaLabel: false }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString('Example'), + () => decoration + ); + }); + + test('should have "aria-label" attribute removed', () => + expect(actual.querySelector('a').hasAttribute('aria-label')).toBe(false)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + 'Example\n' + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.asButton.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.asButton.spec.ts new file mode 100644 index 0000000000..ac8fc34fb3 --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.asButton.spec.ts @@ -0,0 +1,135 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "asButton" option with true', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { asButton: true }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should replace with

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "asButton" option with true and "iconClassName" with "my-icon"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { asButton: true, iconClassName: 'my-icon' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should replace with

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "asButton" option with true and "title" with "Hello, World!"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { asButton: true, title: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should replace with

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "asButton" option with true and "className" with "my-link"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { asButton: true, className: 'my-link' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should replace with

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "asButton" option with true and "aria-label" with "Hello, World!"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { asButton: true, ariaLabel: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should replace with

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.className.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.className.spec.ts new file mode 100644 index 0000000000..eba5302b7a --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.className.spec.ts @@ -0,0 +1,53 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "className" option with "my-link"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { className: 'my-link' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have "className" attribute set to "my-link"', () => + expect(actual.querySelector('a').classList.contains('my-link')).toBe(true)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "className" option with false', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { className: false }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString('Example'), + () => decoration + ); + }); + + test('should have "class" attribute removed', () => + expect(actual.querySelector('a').hasAttribute('class')).toBe(false)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + 'Example\n' + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.iconClassName.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.iconClassName.spec.ts new file mode 100644 index 0000000000..0b07294b0c --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.iconClassName.spec.ts @@ -0,0 +1,44 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "iconAlt" option with "Hello, World!" and "iconClassName" with "my-icon"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { iconAlt: 'Hello, World!', iconClassName: 'my-icon' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have icon image with "alt" attribute set to empty string', () => + expect(actual.querySelector('img').getAttribute('alt')).toBe('')); + + test('should have icon image with "class" attribute set to "my-icon"', () => + expect(actual.querySelector('img').classList.contains('my-icon')).toBe(true)); + + test('should have icon image with "src" attribute set to a transparent GIF', () => + expect(actual.querySelector('img').getAttribute('src')).toBe( + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + )); + + test('should have icon image with "title" attribute set to "Hello, World!"', () => + expect(actual.querySelector('img').getAttribute('title')).toBe('Hello, World!')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.rel.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.rel.spec.ts new file mode 100644 index 0000000000..2a1d466a62 --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.rel.spec.ts @@ -0,0 +1,52 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "rel" option with "noopener noreferer"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { rel: 'noopener noreferer' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have "rel" attribute set to "noopener noreferer"', () => + expect(actual.querySelector('a').getAttribute('rel')).toBe('noopener noreferer')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "rel" option with false', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { rel: false }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString('Example'), + () => decoration + ); + }); + + test('should have "rel" attribute removed', () => expect(actual.querySelector('a').hasAttribute('rel')).toBe(false)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + 'Example\n' + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.selector.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.selector.spec.ts new file mode 100644 index 0000000000..470540f408 --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.selector.spec.ts @@ -0,0 +1,71 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Hello, World!](https://example.com/1)\n\n[Aloha!](https://example.com/2)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "ariaLabel" option with "Hello, World!" for a specific anchor based on "href"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { ariaLabel: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString(BASE_HTML), + href => href === 'https://example.com/1' && decoration + ); + }); + + test('should have "aria-label" attribute set to "Hello, World!"', () => + expect(actual.querySelector('a').getAttribute('aria-label')).toBe('Hello, World!')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Hello, World!

\n

Aloha!

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString( + new MarkdownIt() + .use(betterLink, (_, textContent) => textContent === 'Hello, World!' && decoration) + .render(BASE_MARKDOWN) + ) + ) + )); +}); + +describe('When passing "ariaLabel" option with "Hello, World!" for a specific anchor based on "textContent"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { ariaLabel: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString(BASE_HTML), + (_, textContent) => textContent === 'Hello, World!' && decoration + ); + }); + + test('should have "aria-label" attribute set to "Hello, World!"', () => + expect(actual.querySelector('a').getAttribute('aria-label')).toBe('Hello, World!')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Hello, World!

\n

Aloha!

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString( + new MarkdownIt() + .use(betterLink, (_, textContent) => textContent === 'Hello, World!' && decoration) + .render(BASE_MARKDOWN) + ) + ) + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.target.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.target.spec.ts new file mode 100644 index 0000000000..4094ae3dad --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.target.spec.ts @@ -0,0 +1,53 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "target" option with "noopener noreferer"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { target: 'noopener noreferer' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have "target" attribute set to "noopener noreferer"', () => + expect(actual.querySelector('a').getAttribute('target')).toBe('noopener noreferer')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "target" option with false', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { target: false }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString('Example'), + () => decoration + ); + }); + + test('should have "target" attribute removed', () => + expect(actual.querySelector('a').hasAttribute('target')).toBe(false)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + 'Example\n' + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.title.spec.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.title.spec.ts new file mode 100644 index 0000000000..82ef7a567c --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.title.spec.ts @@ -0,0 +1,53 @@ +/** @jest-environment jsdom */ + +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; +import MarkdownIt from 'markdown-it'; +import betterLink from '../markdownItPlugins/betterLink'; +import betterLinkDocumentMod, { type BetterLinkDocumentModDecoration } from './betterLinkDocumentMod'; + +const BASE_MARKDOWN = '[Example](https://example.com)'; +const BASE_HTML = new MarkdownIt().render(BASE_MARKDOWN); + +describe('When passing "title" option with "Hello, World!"', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { title: 'Hello, World!' }; + + beforeEach(() => { + actual = betterLinkDocumentMod(parseDocumentFromString(BASE_HTML), () => decoration); + }); + + test('should have "title" attribute set to "Hello, World!"', () => + expect(actual.querySelector('a').getAttribute('title')).toBe('Hello, World!')); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + '

Example

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); + +describe('When passing "title" option with false', () => { + let actual: Document; + const decoration: BetterLinkDocumentModDecoration = { title: false }; + + beforeEach(() => { + actual = betterLinkDocumentMod( + parseDocumentFromString('Example'), + () => decoration + ); + }); + + test('should have "title" attribute removed', () => + expect(actual.querySelector('a').hasAttribute('title')).toBe(false)); + + test('should match snapshot', () => + expect(serializeDocumentIntoString(actual)).toBe( + 'Example\n' + )); +}); diff --git a/packages/bundle/src/markdown/private/betterLinkDocumentMod.ts b/packages/bundle/src/markdown/private/betterLinkDocumentMod.ts new file mode 100644 index 0000000000..93b5987477 --- /dev/null +++ b/packages/bundle/src/markdown/private/betterLinkDocumentMod.ts @@ -0,0 +1,113 @@ +export type AttributeSetter = false | string | ((value?: string) => string); + +export type BetterLinkDocumentModDecoration = { + /** Value of "aria-label" attribute of the link. If set to `false`, remove existing attribute. */ + ariaLabel?: AttributeSetter; + + /** Turns this link into a \u200b

\n' + )); + + test('should match baseline', () => + expect(serializeDocumentIntoString(actual)).toBe( + serializeDocumentIntoString( + parseDocumentFromString(new MarkdownIt().use(betterLink, () => decoration).render(BASE_MARKDOWN)) + ) + )); +}); diff --git a/packages/bundle/src/markdown/renderMarkdown.ts b/packages/bundle/src/markdown/renderMarkdown.ts index d3a63e3494..304cce45d7 100644 --- a/packages/bundle/src/markdown/renderMarkdown.ts +++ b/packages/bundle/src/markdown/renderMarkdown.ts @@ -2,9 +2,10 @@ import { onErrorResumeNext } from 'botframework-webchat-core'; import MarkdownIt from 'markdown-it'; import sanitizeHTML from 'sanitize-html'; +import { parseDocumentFromString, serializeDocumentIntoString } from 'botframework-webchat-component/internal'; import ariaLabel, { post as ariaLabelPost, pre as ariaLabelPre } from './markdownItPlugins/ariaLabel'; -import betterLink from './markdownItPlugins/betterLink'; import { pre as respectCRLFPre } from './markdownItPlugins/respectCRLF'; +import betterLinkDocumentMod, { BetterLinkDocumentModDecoration } from './private/betterLinkDocumentMod'; import iterateLinkDefinitions from './private/iterateLinkDefinitions'; const SANITIZE_HTML_OPTIONS = Object.freeze({ @@ -59,94 +60,101 @@ const SANITIZE_HTML_OPTIONS = Object.freeze({ nonBooleanAttributes: [] }); -const MARKDOWN_IT_INIT = Object.freeze({ - breaks: false, - html: false, - linkify: true, - typographer: true, - xhtmlOut: true -}); - -type BetterLinkDecoration = Exclude[1]>, undefined>; -type RenderInit = { externalLinkAlt?: string }; +type RenderInit = Readonly<{ containerClassName?: string; externalLinkAlt?: string }>; export default function render( markdown: string, - { markdownRespectCRLF }: Readonly<{ markdownRespectCRLF: boolean }>, - { externalLinkAlt = '' }: Readonly = Object.freeze({}) + { markdownRespectCRLF, markdownRenderHTML }: Readonly<{ markdownRespectCRLF: boolean; markdownRenderHTML?: boolean }>, + { externalLinkAlt = '' }: RenderInit = Object.freeze({}) ): string { const linkDefinitions = Array.from(iterateLinkDefinitions(markdown)); + const MARKDOWN_IT_INIT = Object.freeze({ + breaks: false, + html: markdownRenderHTML ?? true, + linkify: true, + typographer: true, + xhtmlOut: true + }); + if (markdownRespectCRLF) { markdown = respectCRLFPre(markdown); } markdown = ariaLabelPre(markdown); - const markdownIt = new MarkdownIt(MARKDOWN_IT_INIT) - .use(ariaLabel) - .use(betterLink, (href: string, textContent: string): BetterLinkDecoration | undefined => { - const decoration: BetterLinkDecoration = { - rel: 'noopener noreferrer', - target: '_blank', - wrapZeroWidthSpace: true - }; + const decorate = (href: string, textContent: string): BetterLinkDocumentModDecoration => { + const decoration: BetterLinkDocumentModDecoration = { + rel: 'noopener noreferrer', + target: '_blank', + wrapZeroWidthSpace: true + }; + + const ariaLabelSegments: string[] = [textContent]; + const classes: Set = new Set(); + const linkDefinition = linkDefinitions.find(({ url }) => url === href); + const protocol = onErrorResumeNext(() => new URL(href).protocol); + + if (linkDefinition) { + ariaLabelSegments.push( + linkDefinition.title || onErrorResumeNext(() => new URL(linkDefinition.url).host) || linkDefinition.url + ); + + // linkDefinition.identifier is uppercase, while linkDefinition.label is as-is. + linkDefinition.label === textContent && classes.add('webchat__render-markdown__pure-identifier'); + } + + // For links that would be sanitized out, let's turn them into a button so we could handle them later. + if (!SANITIZE_HTML_OPTIONS.allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) { + decoration.asButton = true; - const ariaLabelSegments: string[] = [textContent]; - const classes: Set = new Set(); - const linkDefinition = linkDefinitions.find(({ url }) => url === href); - const protocol = onErrorResumeNext(() => new URL(href).protocol); + classes.add('webchat__render-markdown__citation'); + } else if (protocol === 'http:' || protocol === 'https:') { + decoration.iconAlt = externalLinkAlt; + decoration.iconClassName = 'webchat__render-markdown__external-link-icon'; - if (linkDefinition) { - ariaLabelSegments.push( - linkDefinition.title || onErrorResumeNext(() => new URL(linkDefinition.url).host) || linkDefinition.url - ); + ariaLabelSegments.push(externalLinkAlt); + } - // linkDefinition.identifier is uppercase, while linkDefinition.label is as-is. - linkDefinition.label === textContent && classes.add('webchat__render-markdown__pure-identifier'); - } + // The first segment is textContent. Putting textContent is aria-label is useless. + if (ariaLabelSegments.length > 1) { + // If "aria-label" is already applied, do not overwrite it. + decoration.ariaLabel = (value: string) => value || ariaLabelSegments.join(' '); + } - // For links that would be sanitized out, let's turn them into a button so we could handle them later. - if (!SANITIZE_HTML_OPTIONS.allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) { - decoration.asButton = true; + decoration.className = Array.from(classes).join(' '); - classes.add('webchat__render-markdown__citation'); - } else if (protocol === 'http:' || protocol === 'https:') { - decoration.iconAlt = externalLinkAlt; - decoration.iconClassName = 'webchat__render-markdown__external-link-icon'; + // By default, Markdown-It will set "title" to the link title in link definition. - ariaLabelSegments.push(externalLinkAlt); - } + // However, "title" may be narrated by screen reader: + // - Edge + // - will narrate "aria-label" but not "title" + // -