diff --git a/CHANGELOG.md b/CHANGELOG.md index b93cd09319..3f41e34354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - `styleOptions.bubbleMaxWidth`/`bubbleMinWidth` is being deprecated in favor of `styleOptions.bubbleAttachmentMaxWidth`/`bubbleAttachmentMinWidth` and `styleOptions.bubbleMessageMaxWidth`/`bubbleMessageMinWidth`. The option will be removed on or after 2026-10-08 - Moved to `micromark` for rendering Markdown, instead of `markdown-it` - Please refer to PR [#5330](https://github.com/microsoft/BotFramework-WebChat/pull/5330) for details +- HTML sanitizer is moved from `renderMarkdown` to HTML content transformer middleware, please refer to PR [#5338](https://github.com/microsoft/BotFramework-WebChat/pull/5338) + - If you customized `renderMarkdown` with a custom HTML sanitizer, please move the HTML sanitizer to the new HTML content transformer middleware ### Added @@ -64,6 +66,10 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/ - Added code viewer dialog with syntax highlighting, in PR [#5335](https://github.com/microsoft/BotFramework-WebChat/pull/5335), by [@OEvgeny](https://github.com/OEvgeny) - Added copy button to code blocks, in PR [#5334](https://github.com/microsoft/BotFramework-WebChat/pull/5334), by [@compulim](https://github.com/compulim) - Added copy button to view code dialog, in PR [#5336](https://github.com/microsoft/BotFramework-WebChat/pull/5336), by [@compulim](https://github.com/compulim) +- Added HTML content transformer middleware, in PR [#5338](https://github.com/microsoft/BotFramework-WebChat/pull/5338), by [@compulim](https://github.com/compulim) + - HTML content transformer is used by `useRenderMarkdown` to transform the result from `renderMarkdown` + - HTML sanitizer is moved from `renderMarkdown` into HTML content transformer for better coverage + - Copy button is added to fenced code blocks (`
`)
### Changed
diff --git a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
index e4281ecf09..c9cabfdec2 100644
--- a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
+++ b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
@@ -75,11 +75,10 @@
Ea sint elit anim enim voluptate aliquip aliqua nulla veniam.
-
-Ea et pariatur sint Lorem ex veniam adipisicing.
+Ea et pariatur sint Lorem ex veniam adipisicing.
Aliqua magna aliquip nisi quis.
-
+
Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`,
wrap: true
diff --git a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/layout.html b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/layout.html
index 4731313dbd..8b4644a026 100644
--- a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/layout.html
+++ b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/layout.html
@@ -56,11 +56,10 @@
Ea sint elit anim enim voluptate aliquip aliqua nulla veniam.
--Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, wrap: true diff --git a/__tests__/html2/markdown/codeBlockCopyButton/behavior.denied.html b/__tests__/html2/markdown/codeBlockCopyButton/behavior.denied.html index 541922c56d..3a69e453e6 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/behavior.denied.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/behavior.denied.html @@ -61,11 +61,10 @@ Ea sint elit anim enim voluptate aliquip aliqua nulla veniam. -+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
-Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, type: 'message' diff --git a/__tests__/html2/markdown/codeBlockCopyButton/behavior.html b/__tests__/html2/markdown/codeBlockCopyButton/behavior.html index 85ae882a33..8df8d87ec8 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/behavior.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/behavior.html @@ -61,11 +61,10 @@ Ea sint elit anim enim voluptate aliquip aliqua nulla veniam. -+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
-Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, type: 'message' diff --git a/__tests__/html2/markdown/codeBlockCopyButton/behavior.stringsChange.html b/__tests__/html2/markdown/codeBlockCopyButton/behavior.stringsChange.html index 6d4100052b..34287c3c00 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/behavior.stringsChange.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/behavior.stringsChange.html @@ -36,11 +36,10 @@ Ea sint elit anim enim voluptate aliquip aliqua nulla veniam. -+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
-Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, type: 'message' diff --git a/__tests__/html2/markdown/codeBlockCopyButton/fluent/layout.html b/__tests__/html2/markdown/codeBlockCopyButton/fluent/layout.html index c52ba09381..5a7dba8ba6 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/fluent/layout.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/fluent/layout.html @@ -54,11 +54,10 @@ Ea sint elit anim enim voluptate aliquip aliqua nulla veniam. -+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
-Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, type: 'message' diff --git a/__tests__/html2/markdown/codeBlockCopyButton/layout.html b/__tests__/html2/markdown/codeBlockCopyButton/layout.html index 5c24f47d46..4fb0ed8a9d 100644 --- a/__tests__/html2/markdown/codeBlockCopyButton/layout.html +++ b/__tests__/html2/markdown/codeBlockCopyButton/layout.html @@ -42,11 +42,10 @@ Ea sint elit anim enim voluptate aliquip aliqua nulla veniam. -+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
-Ea et pariatur sint Lorem ex veniam adipisicing. +Cupidatat nulla duis dolor nulla ut pariatur minim incididunt quis adipisicing velit id Lorem.`, type: 'message' diff --git a/packages/bundle/src/AddFullBundle.tsx b/packages/bundle/src/AddFullBundle.tsx index 5fdc9b5144..f2212fddd4 100644 --- a/packages/bundle/src/AddFullBundle.tsx +++ b/packages/bundle/src/AddFullBundle.tsx @@ -3,6 +3,7 @@ import { type AttachmentMiddleware, type StyleOptions } from 'botframework-webchat-api'; +import { type HTMLContentTransformMiddleware } from 'botframework-webchat-component'; import { singleToArray, warnOnce, type OneOrMany } from 'botframework-webchat-core'; import React, { type ReactNode } from 'react'; @@ -18,6 +19,7 @@ type AddFullBundleProps = Readonly<{ attachmentForScreenReaderMiddleware?: OneOrMany+Ea et pariatur sint Lorem ex veniam adipisicing. Aliqua magna aliquip nisi quis. -
Same line.\nSame line.
\n2nd line.
Same line.\nSame line.
\n2nd line.
Same Line.\nSame Line.
\n2nd line.
' + 'Same Line.\nSame Line.
\n2nd line.
' ); }); @@ -39,7 +39,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: false }; expect(renderMarkdown('Same Line.\r\nSame Line.\n\r2nd line.', styleOptions, renderMarkdownOptions)).toBe( - 'Same Line.\nSame Line.
\n2nd line.
' + 'Same Line.\nSame Line.
\n2nd line.
' ); }); @@ -48,7 +48,9 @@ describe('renderMarkdown', () => { expect( renderMarkdown('**Message with Markdown**\r\nShould see bold text.', styleOptions, renderMarkdownOptions) - ).toBe('Message with Markdown
\nShould see bold text.
'); + ).toBe( + 'Message with Markdown
\nShould see bold text.
' + ); }); it('should render code correctly', () => { @@ -60,11 +62,7 @@ describe('renderMarkdown', () => { styleOptions, renderMarkdownOptions ) - ) - .toBe(`{ + ).toBe(`
`); @@ -74,7 +72,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown('[example](https://sample.com)', styleOptions, renderMarkdownOptions)).toBe( - `{ "hello": "World!" }
\u200Bexample\u200B
` + `\u200Bexample\u200B
` ); }); @@ -83,7 +81,7 @@ describe('renderMarkdown', () => { const options = { externalLinkAlt: 'Opens in a new window, external.' }; expect(renderMarkdown('[example](https://sample.com)', styleOptions, options)).toBe( - `\u200Bexample\u200B
` + `\u200Bexample\u200B
` ); }); @@ -91,7 +89,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown(`[example@test.com](sip:example@test.com)`, styleOptions, renderMarkdownOptions)).toBe( - '\u200Bexample@test.com\u200B
' + '\u200Bexample@test.com\u200B
' ); }); @@ -99,7 +97,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown(`[(505)503-4455](tel:505-503-4455)`, styleOptions, renderMarkdownOptions)).toBe( - '\u200B(505)503-4455\u200B
' + '\u200B(505)503-4455\u200B
' ); }); @@ -107,7 +105,7 @@ describe('renderMarkdown', () => { const styleOptions = { markdownRespectCRLF: true }; expect(renderMarkdown(`~~strike text~~`, styleOptions, renderMarkdownOptions)).toBe( - '' + '
strike text' ); }); }); diff --git a/packages/bundle/src/markdown/createHTMLContentTransformMiddleware.ts b/packages/bundle/src/markdown/createHTMLContentTransformMiddleware.ts new file mode 100644 index 0000000000..e4691dbcc8 --- /dev/null +++ b/packages/bundle/src/markdown/createHTMLContentTransformMiddleware.ts @@ -0,0 +1,8 @@ +import { type HTMLContentTransformMiddleware } from 'botframework-webchat-component'; + +import createCodeBlockCopyButtonMiddleware from './middleware/createCodeBlockCopyButtonMiddleware'; +import createSanitizeMiddleware from './middleware/createSanitizeMiddleware'; + +export default function createHTMLContentTransformMiddleware(): readonly HTMLContentTransformMiddleware[] { + return Object.freeze([createCodeBlockCopyButtonMiddleware(), createSanitizeMiddleware()]); +} diff --git a/packages/bundle/src/markdown/middleware/createCodeBlockCopyButtonMiddleware.ts b/packages/bundle/src/markdown/middleware/createCodeBlockCopyButtonMiddleware.ts new file mode 100644 index 0000000000..b8989fca83 --- /dev/null +++ b/packages/bundle/src/markdown/middleware/createCodeBlockCopyButtonMiddleware.ts @@ -0,0 +1,18 @@ +import { type HTMLContentTransformMiddleware } from 'botframework-webchat-component'; + +import codeBlockCopyButtonDocumentMod from '../private/codeBlockCopyButtonDocumentMod'; + +export default function createCodeBlockCopyButtonMiddleware(): HTMLContentTransformMiddleware { + return () => next => request => + next( + Object.freeze({ + ...request, + documentFragment: codeBlockCopyButtonDocumentMod(request.documentFragment, { + codeBlockCopyButtonAltCopied: request.codeBlockCopyButtonAltCopied, + codeBlockCopyButtonAltCopy: request.codeBlockCopyButtonAltCopy, + codeBlockCopyButtonClassName: request.codeBlockCopyButtonClassName, + codeBlockCopyButtonTagName: request.codeBlockCopyButtonTagName + }) + }) + ); +} diff --git a/packages/bundle/src/markdown/middleware/createSanitizeMiddleware.ts b/packages/bundle/src/markdown/middleware/createSanitizeMiddleware.ts new file mode 100644 index 0000000000..e4bd6987bf --- /dev/null +++ b/packages/bundle/src/markdown/middleware/createSanitizeMiddleware.ts @@ -0,0 +1,109 @@ +import { + parseDocumentFragmentFromString, + serializeDocumentFragmentIntoString +} from 'botframework-webchat-component/internal'; +import sanitizeHTML from 'sanitize-html'; + +const BASE_SANITIZE_HTML_OPTIONS = Object.freeze({ + allowedAttributes: { + a: ['aria-label', 'class', 'href', 'name', 'rel', 'target'], + button: ['aria-label', 'class', 'type', 'value'], + img: ['alt', 'aria-label', 'class', 'src', 'title'], + pre: ['class'], + span: ['aria-label'] + }, + allowedSchemes: ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel'], + allowedTags: [ + 'a', + 'b', + 'blockquote', + 'br', + 'button', + 'caption', + 'code', + 'del', + 'div', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'i', + 'img', + 'ins', + 'li', + 'nl', + 'ol', + 'p', + 'pre', + 's', + 'span', + 'strike', + 'strong', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'ul', + + // Followings are for MathML elements, from https://developer.mozilla.org/en-US/docs/Web/MathML. + 'annotation-xml', + 'annotation', + 'math', + 'merror', + 'mfrac', + 'mi', + 'mmultiscripts', + 'mn', + 'mo', + 'mover', + 'mpadded', + 'mphantom', + 'mprescripts', + 'mroot', + 'mrow', + 'ms', + 'mspace', + 'msqrt', + 'mstyle', + 'msub', + 'msubsup', + 'msup', + 'mtable', + 'mtd', + 'mtext', + 'mtr', + 'munder', + 'munderover', + 'semantics' + ], + // Bug of https://github.com/apostrophecms/sanitize-html/issues/633. + // They should not remove `alt=""` even though it is empty. + nonBooleanAttributes: [] +}); + +export default function createSanitizeMiddleware() { + return () => () => request => { + const { codeBlockCopyButtonTagName, documentFragment } = request; + const sanitizeHTMLOptions = { + ...BASE_SANITIZE_HTML_OPTIONS, + allowedAttributes: { + ...BASE_SANITIZE_HTML_OPTIONS.allowedAttributes, + [codeBlockCopyButtonTagName]: ['class', 'data-alt-copy', 'data-alt-copied', 'data-testid', 'data-value'] + }, + allowedTags: [...BASE_SANITIZE_HTML_OPTIONS.allowedTags, codeBlockCopyButtonTagName] + }; + + const htmlAfterBetterLink = serializeDocumentFragmentIntoString(documentFragment); + + const htmlAfterSanitization = sanitizeHTML(htmlAfterBetterLink, sanitizeHTMLOptions); + + return parseDocumentFragmentFromString(htmlAfterSanitization); + }; +} diff --git a/packages/bundle/src/markdown/private/codeBlockCopyButtonDocumentMod.ts b/packages/bundle/src/markdown/private/codeBlockCopyButtonDocumentMod.ts index abf8804105..c8d34bc292 100644 --- a/packages/bundle/src/markdown/private/codeBlockCopyButtonDocumentMod.ts +++ b/packages/bundle/src/markdown/private/codeBlockCopyButtonDocumentMod.ts @@ -5,23 +5,29 @@ export default function codeBlockCopyButtonDocumentMod
strike text): T { for (const preElement of [...documentFragment.querySelectorAll('pre')]) { - const codeBlockCopyButtonElement = documentFragment.ownerDocument.createElement(codeBlockCopyButtonTagName); + if (preElement.children.length === 1) { + const [firstChild] = preElement.children; + + if (firstChild.matches('code')) { + const codeBlockCopyButtonElement = documentFragment.ownerDocument.createElement(codeBlockCopyButtonTagName); - codeBlockCopyButtonElement.className = codeBlockCopyButtonClassName; - codeBlockCopyButtonElement.dataset.altCopied = codeBlockCopyButtonAltCopied; - codeBlockCopyButtonElement.dataset.altCopy = codeBlockCopyButtonAltCopy; - codeBlockCopyButtonElement.dataset.value = preElement.textContent; + codeBlockCopyButtonElement.className = codeBlockCopyButtonClassName; + codeBlockCopyButtonElement.dataset.altCopied = codeBlockCopyButtonAltCopied; + codeBlockCopyButtonElement.dataset.altCopy = codeBlockCopyButtonAltCopy; + codeBlockCopyButtonElement.dataset.value = preElement.textContent; - preElement.classList.add('webchat__render-markdown__code-block'); - preElement.prepend(codeBlockCopyButtonElement); + preElement.classList.add('webchat__render-markdown__code-block'); + preElement.prepend(codeBlockCopyButtonElement); + } + } } return documentFragment; diff --git a/packages/bundle/src/markdown/renderMarkdown.ts b/packages/bundle/src/markdown/renderMarkdown.ts index f6ad12bd87..43e21b357b 100644 --- a/packages/bundle/src/markdown/renderMarkdown.ts +++ b/packages/bundle/src/markdown/renderMarkdown.ts @@ -6,125 +6,24 @@ import { onErrorResumeNext } from 'botframework-webchat-core'; import { micromark } from 'micromark'; import { gfm, gfmHtml } from 'micromark-extension-gfm'; import { math, mathHtml } from 'micromark-extension-math'; -import sanitizeHTML from 'sanitize-html'; import betterLinkDocumentMod, { BetterLinkDocumentModDecoration } from './private/betterLinkDocumentMod'; -import codeBlockCopyButtonDocumentMod from './private/codeBlockCopyButtonDocumentMod'; import iterateLinkDefinitions from './private/iterateLinkDefinitions'; import { pre as respectCRLFPre } from './private/respectCRLF'; -const SANITIZE_HTML_OPTIONS = Object.freeze({ - allowedAttributes: { - a: ['aria-label', 'class', 'href', 'name', 'rel', 'target'], - button: ['aria-label', 'class', 'type', 'value'], - img: ['alt', 'aria-label', 'class', 'src', 'title'], - pre: ['class'], - span: ['aria-label'] - }, - allowedSchemes: ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel'], - allowedTags: [ - 'a', - 'b', - 'blockquote', - 'br', - 'button', - 'caption', - 'code', - 'del', - 'div', - 'em', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'hr', - 'i', - 'img', - 'ins', - 'li', - 'nl', - 'ol', - 'p', - 'pre', - 's', - 'span', - 'strike', - 'strong', - 'table', - 'tbody', - 'td', - 'tfoot', - 'th', - 'thead', - 'tr', - 'ul', - - // Followings are for MathML elements, from https://developer.mozilla.org/en-US/docs/Web/MathML. - 'annotation-xml', - 'annotation', - 'math', - 'merror', - 'mfrac', - 'mi', - 'mmultiscripts', - 'mn', - 'mo', - 'mover', - 'mpadded', - 'mphantom', - 'mprescripts', - 'mroot', - 'mrow', - 'ms', - 'mspace', - 'msqrt', - 'mstyle', - 'msub', - 'msubsup', - 'msup', - 'mtable', - 'mtd', - 'mtext', - 'mtr', - 'munder', - 'munderover', - 'semantics' - ], - // Bug of https://github.com/apostrophecms/sanitize-html/issues/633. - // They should not remove `alt=""` even though it is empty. - nonBooleanAttributes: [] -}); - type RenderInit = Readonly<{ - codeBlockCopyButtonClassName: string; codeBlockCopyButtonTagName: string; - codeBlockCopyButtonAltCopied: string; - codeBlockCopyButtonAltCopy: string; externalLinkAlt: string; }>; +const ALLOWED_SCHEMES = ['data', 'http', 'https', 'ftp', 'mailto', 'sip', 'tel']; + export default function render( markdown: string, { markdownRespectCRLF, markdownRenderHTML }: Readonly<{ markdownRespectCRLF: boolean; markdownRenderHTML?: boolean }>, - { - codeBlockCopyButtonAltCopied, - codeBlockCopyButtonAltCopy, - codeBlockCopyButtonClassName, - codeBlockCopyButtonTagName, - externalLinkAlt - }: RenderInit + { externalLinkAlt }: RenderInit ): string { const linkDefinitions = Array.from(iterateLinkDefinitions(markdown)); - const sanitizeHTMLOptions = { - ...SANITIZE_HTML_OPTIONS, - allowedAttributes: { - ...SANITIZE_HTML_OPTIONS.allowedAttributes, - [codeBlockCopyButtonTagName]: ['class', 'data-alt-copy', 'data-alt-copied', 'data-testid', 'data-value'] - }, - allowedTags: [...SANITIZE_HTML_OPTIONS.allowedTags, codeBlockCopyButtonTagName] - }; if (markdownRespectCRLF) { markdown = respectCRLFPre(markdown); @@ -158,7 +57,7 @@ export default function render( // eslint-disable-next-line no-script-url if (protocol !== 'javascript:') { // For links that would be sanitized out, let's turn them into a button so we could handle them later. - if (!sanitizeHTMLOptions.allowedSchemes.map(scheme => `${scheme}:`).includes(protocol)) { + if (!ALLOWED_SCHEMES.map(scheme => `${scheme}:`).includes(protocol)) { decoration.asButton = true; classes.add('webchat__render-markdown__citation'); @@ -214,16 +113,6 @@ export default function render( const documentFragmentAfterMarkdown = parseDocumentFragmentFromString(htmlAfterMarkdown); betterLinkDocumentMod(documentFragmentAfterMarkdown, decorate); - codeBlockCopyButtonDocumentMod(documentFragmentAfterMarkdown, { - codeBlockCopyButtonAltCopied, - codeBlockCopyButtonAltCopy, - codeBlockCopyButtonClassName, - codeBlockCopyButtonTagName - }); - - const htmlAfterBetterLink = serializeDocumentFragmentIntoString(documentFragmentAfterMarkdown); - - const htmlAfterSanitization = sanitizeHTML(htmlAfterBetterLink, sanitizeHTMLOptions); - return htmlAfterSanitization; + return serializeDocumentFragmentIntoString(documentFragmentAfterMarkdown); } diff --git a/packages/bundle/src/useComposerProps.ts b/packages/bundle/src/useComposerProps.ts index 91a6b04182..e7eca3b7a9 100644 --- a/packages/bundle/src/useComposerProps.ts +++ b/packages/bundle/src/useComposerProps.ts @@ -1,20 +1,24 @@ import { AttachmentForScreenReaderMiddleware, AttachmentMiddleware } from 'botframework-webchat-api'; +import { type HTMLContentTransformMiddleware } from 'botframework-webchat-component'; import { useMemo } from 'react'; import createAdaptiveCardsAttachmentForScreenReaderMiddleware from './adaptiveCards/createAdaptiveCardsAttachmentForScreenReaderMiddleware'; import createAdaptiveCardsAttachmentMiddleware from './adaptiveCards/createAdaptiveCardsAttachmentMiddleware'; import createAdaptiveCardsStyleSet from './adaptiveCards/Styles/createAdaptiveCardsStyleSet'; +import createHTMLContentTransformMiddleware from './markdown/createHTMLContentTransformMiddleware'; import defaultRenderMarkdown from './markdown/renderMarkdown'; export default function useComposerProps({ attachmentForScreenReaderMiddleware, attachmentMiddleware, + htmlContentTransformMiddleware, renderMarkdown, styleOptions, styleSet -}: { +}: Readonly<{ attachmentForScreenReaderMiddleware: AttachmentForScreenReaderMiddleware[]; attachmentMiddleware: AttachmentMiddleware[]; + htmlContentTransformMiddleware: readonly HTMLContentTransformMiddleware[]; renderMarkdown?: ( markdown: string, newLineOptions: { markdownRespectCRLF: boolean }, @@ -22,16 +26,17 @@ export default function useComposerProps({ ) => string; styleOptions: any; styleSet: any; -}): { +}>): Readonly<{ attachmentForScreenReaderMiddleware: AttachmentForScreenReaderMiddleware[]; attachmentMiddleware: AttachmentMiddleware[]; + extraStyleSet: any; + htmlContentTransformMiddleware: readonly HTMLContentTransformMiddleware[]; renderMarkdown: ( markdown: string, newLineOptions: { markdownRespectCRLF: boolean }, linkOptions: { externalLinkAlt: string } ) => string; - extraStyleSet: any; -} { +}> { const patchedAttachmentMiddleware = useMemo( () => [...attachmentMiddleware, createAdaptiveCardsAttachmentMiddleware()], [attachmentMiddleware] @@ -53,10 +58,16 @@ export default function useComposerProps({ [renderMarkdown] ); - return { + const patchedHTMLContentTransformMiddleware = useMemo ( + () => Object.freeze([...(htmlContentTransformMiddleware || []), ...createHTMLContentTransformMiddleware()]), + [htmlContentTransformMiddleware] + ); + + return Object.freeze({ attachmentForScreenReaderMiddleware: patchedAttachmentForScreenReaderMiddleware, attachmentMiddleware: patchedAttachmentMiddleware, extraStyleSet, + htmlContentTransformMiddleware: patchedHTMLContentTransformMiddleware, renderMarkdown: patchedRenderMarkdown - }; + }); } diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index d2c3ecc2a3..7847f5917f 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -41,6 +41,8 @@ import createDefaultToastMiddleware from './Middleware/Toast/createCoreMiddlewar import createDefaultTypingIndicatorMiddleware from './Middleware/TypingIndicator/createCoreMiddleware'; import ActivityTreeComposer from './providers/ActivityTree/ActivityTreeComposer'; import CustomElementsComposer from './providers/CustomElements/CustomElementsComposer'; +import HTMLContentTransformComposer from './providers/HTMLContentTransformCOR/HTMLContentTransformComposer'; +import { type HTMLContentTransformMiddleware } from './providers/HTMLContentTransformCOR/private/HTMLContentTransformContext'; import SendBoxComposer from './providers/internal/SendBox/SendBoxComposer'; import { LiveRegionTwinComposer } from './providers/LiveRegionTwin'; import ModalDialogComposer from './providers/ModalDialog/ModalDialogComposer'; @@ -63,7 +65,7 @@ function styleSetToEmotionObjects(styleToEmotionObject, styleSet) { return mapMap(styleSet, (style, key) => (key === 'options' ? style : styleToEmotionObject(style))); } -type ComposerCoreUIProps = Readonly<{ children?: ReactNode }>; +type ComposerCoreUIProps = Readonly<{ children?: ReactNode | undefined }>; const ROOT_STYLE = { '&.webchat__css-custom-properties': { @@ -116,6 +118,7 @@ ComposerCoreUI.displayName = 'ComposerCoreUI'; type ComposerCoreProps = Readonly<{ children?: ReactNode; extraStyleSet?: any; + htmlContentTransformMiddleware?: readonly HTMLContentTransformMiddleware[] | undefined; nonce?: string; renderMarkdown?: ( markdown: string, @@ -312,6 +315,7 @@ const Composer = ({ cardActionMiddleware, children, extraStyleSet, + htmlContentTransformMiddleware, renderMarkdown, scrollToEndButtonMiddleware, sendBoxMiddleware: sendBoxMiddlewareFromProps, @@ -445,18 +449,20 @@ const Composer = ({ > diff --git a/packages/component/src/hooks/index.ts b/packages/component/src/hooks/index.ts index a169829818..80cbff59f6 100644 --- a/packages/component/src/hooks/index.ts +++ b/packages/component/src/hooks/index.ts @@ -1,3 +1,4 @@ +import { useTransformHTMLContent } from '../providers/HTMLContentTransformCOR/index'; import useDictateAbortable from './useDictateAbortable'; import useFocus from './useFocus'; import useMakeThumbnail from './useMakeThumbnail'; @@ -43,6 +44,7 @@ export { useStyleSet, useTextBoxSubmit, useTextBoxValue, + useTransformHTMLContent, useTypingIndicatorVisible, useWebSpeechPonyfill }; diff --git a/packages/component/src/hooks/useRenderMarkdownAsHTML.ts b/packages/component/src/hooks/useRenderMarkdownAsHTML.ts index d658ae3c45..df3192f98a 100644 --- a/packages/component/src/hooks/useRenderMarkdownAsHTML.ts +++ b/packages/component/src/hooks/useRenderMarkdownAsHTML.ts @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import { hooks, StrictStyleOptions } from 'botframework-webchat-api'; import { useMemo } from 'react'; -import useCodeBlockCopyButtonTagName from '../providers/CustomElements/useCodeBlockCopyButtonTagName'; +import { useTransformHTMLContent } from '../providers/HTMLContentTransformCOR/index'; import parseDocumentFragmentFromString from '../Utils/parseDocumentFragmentFromString'; import serializeDocumentFragmentIntoString from '../Utils/serializeDocumentFragmentIntoString'; import useWebChatUIContext from './internal/useWebChatUIContext'; @@ -20,13 +20,11 @@ export default function useRenderMarkdownAsHTML( ) => string) | undefined { const { renderMarkdown } = useWebChatUIContext(); - const [codeBlockCopyButtonTagName] = useCodeBlockCopyButtonTagName(); const [styleOptions] = useStyleOptions(); - const [{ codeBlockCopyButton: codeBlockCopyButtonClassName, renderMarkdown: renderMarkdownStyleSet }] = useStyleSet(); + const [{ renderMarkdown: renderMarkdownStyleSet }] = useStyleSet(); const localize = useLocalizer(); + const transformHTMLContent = useTransformHTMLContent(); - const codeBlockCopyButtonAltCopied = localize('COPY_BUTTON_COPIED_TEXT'); - const codeBlockCopyButtonAltCopy = localize('COPY_BUTTON_TEXT'); const externalLinkAlt = localize('MARKDOWN_EXTERNAL_LINK_ALT'); const containerClassName = useMemo( @@ -49,35 +47,19 @@ export default function useRenderMarkdownAsHTML( () => renderMarkdown && (markdown => { - const htmlAfterSanitization = renderMarkdown(markdown, styleOptions, { - codeBlockCopyButtonAltCopied, - codeBlockCopyButtonAltCopy, - codeBlockCopyButtonClassName, - codeBlockCopyButtonTagName, - containerClassName, - externalLinkAlt - }); + const html = renderMarkdown(markdown, styleOptions, { externalLinkAlt }); - const documentFragmentAfterSanitization = parseDocumentFragmentFromString(htmlAfterSanitization); + const documentFragment = transformHTMLContent(parseDocumentFragmentFromString(html)); const rootElement = document.createElement('div'); containerClassName && rootElement.classList.add(...containerClassName.split(' ').filter(Boolean)); - rootElement.append(...documentFragmentAfterSanitization.childNodes); - documentFragmentAfterSanitization.append(rootElement); + rootElement.append(...documentFragment.childNodes); + documentFragment.append(rootElement); - return serializeDocumentFragmentIntoString(documentFragmentAfterSanitization); + return serializeDocumentFragmentIntoString(documentFragment); }), - [ - codeBlockCopyButtonAltCopied, - codeBlockCopyButtonAltCopy, - codeBlockCopyButtonClassName, - codeBlockCopyButtonTagName, - containerClassName, - externalLinkAlt, - renderMarkdown, - styleOptions - ] + [containerClassName, externalLinkAlt, renderMarkdown, styleOptions, transformHTMLContent] ); } diff --git a/packages/component/src/index.ts b/packages/component/src/index.ts index 8a82c7c415..0835d92936 100644 --- a/packages/component/src/index.ts +++ b/packages/component/src/index.ts @@ -46,6 +46,12 @@ import createCoreActivityStatusMiddleware from './Middleware/ActivityStatus/crea import createStyleSet from './Styles/createStyleSet'; import getTabIndex from './Utils/TypeFocusSink/getTabIndex'; import Context from './hooks/internal/WebChatUIContext'; +import { + type HTMLContentTransformEnhancer, + type HTMLContentTransformFunction, + type HTMLContentTransformMiddleware, + type HTMLContentTransformRequest +} from './providers/HTMLContentTransformCOR/index'; import ThemeProvider from './providers/Theme/ThemeProvider'; import testIds from './testIds'; import withEmoji from './withEmoji/withEmoji'; @@ -130,4 +136,13 @@ export { withEmoji }; -export type { BasicWebChatProps, ComposerProps, ReactWebChatProps, WebChatActivity }; +export type { + BasicWebChatProps, + ComposerProps, + HTMLContentTransformEnhancer, + HTMLContentTransformFunction, + HTMLContentTransformMiddleware, + HTMLContentTransformRequest, + ReactWebChatProps, + WebChatActivity +}; diff --git a/packages/component/src/providers/HTMLContentTransformCOR/HTMLContentTransformComposer.tsx b/packages/component/src/providers/HTMLContentTransformCOR/HTMLContentTransformComposer.tsx new file mode 100644 index 0000000000..98324b4093 --- /dev/null +++ b/packages/component/src/providers/HTMLContentTransformCOR/HTMLContentTransformComposer.tsx @@ -0,0 +1,41 @@ +import React, { memo, useMemo, type ReactNode } from 'react'; + +import HTMLContentTransformContext, { + type HTMLContentTransformContextType, + type HTMLContentTransformFunction, + type HTMLContentTransformMiddleware +} from './private/HTMLContentTransformContext'; + +type HTMLContentTransformComposerProps = Readonly<{ + children?: ReactNode | undefined; + middleware?: readonly HTMLContentTransformMiddleware[] | undefined; +}>; + +const HTMLContentTransformComposer = memo(({ children, middleware }: HTMLContentTransformComposerProps) => { + const transform = useMemo - - {children} - {onTelemetry && +} - + + {children} + {onTelemetry && +} + (() => { + const enhancers = middleware.map(enhancer => enhancer()).reverse(); + + return enhancers.reduce( + (chain: HTMLContentTransformFunction, fn) => fn(chain), + fragment => { + // With bundle or sanitizer installed, it should not fall into this code. + + const result = document.createDocumentFragment(); + const paragraph = document.createElement('p'); + + paragraph.textContent = fragment.documentFragment.firstElementChild.outerHTML; + result.append(paragraph); + + return result; + } + ); + }, [middleware]); + + const context = useMemo (() => Object.freeze({ transform }), [transform]); + + return {children} ; +}); + +HTMLContentTransformComposer.displayName = 'HTMLContentTransformComposer'; + +export default HTMLContentTransformComposer; diff --git a/packages/component/src/providers/HTMLContentTransformCOR/index.ts b/packages/component/src/providers/HTMLContentTransformCOR/index.ts new file mode 100644 index 0000000000..2ec9d770c2 --- /dev/null +++ b/packages/component/src/providers/HTMLContentTransformCOR/index.ts @@ -0,0 +1,8 @@ +export { + type HTMLContentTransformEnhancer, + type HTMLContentTransformFunction, + type HTMLContentTransformMiddleware, + type HTMLContentTransformRequest +} from './private/HTMLContentTransformContext'; + +export { default as useTransformHTMLContent } from './useTransformHTMLContent'; diff --git a/packages/component/src/providers/HTMLContentTransformCOR/private/HTMLContentTransformContext.ts b/packages/component/src/providers/HTMLContentTransformCOR/private/HTMLContentTransformContext.ts new file mode 100644 index 0000000000..a0603e1aad --- /dev/null +++ b/packages/component/src/providers/HTMLContentTransformCOR/private/HTMLContentTransformContext.ts @@ -0,0 +1,30 @@ +import { createContext } from 'react'; + +export type HTMLContentTransformRequest = Readonly<{ + codeBlockCopyButtonAltCopied: string; + codeBlockCopyButtonAltCopy: string; + codeBlockCopyButtonClassName: string; + codeBlockCopyButtonTagName: string; + documentFragment: DocumentFragment; + externalLinkAlt: string; +}>; + +export type HTMLContentTransformFunction = (request: HTMLContentTransformRequest) => DocumentFragment; + +export type HTMLContentTransformEnhancer = (next: HTMLContentTransformFunction) => HTMLContentTransformFunction; + +export type HTMLContentTransformMiddleware = () => HTMLContentTransformEnhancer; + +export type HTMLContentTransformContextType = Readonly<{ + transform: HTMLContentTransformFunction; +}>; + +const HTMLContentTransformContext = createContext( + new Proxy({} as HTMLContentTransformContextType, { + get() { + throw new Error('botframework-webchat: This hook can only be used under .'); + } + }) +); + +export default HTMLContentTransformContext; diff --git a/packages/component/src/providers/HTMLContentTransformCOR/private/useHTMLContentTransformContext.ts b/packages/component/src/providers/HTMLContentTransformCOR/private/useHTMLContentTransformContext.ts new file mode 100644 index 0000000000..4d8c92190f --- /dev/null +++ b/packages/component/src/providers/HTMLContentTransformCOR/private/useHTMLContentTransformContext.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import HTMLContentTransformContext, { type HTMLContentTransformContextType } from './HTMLContentTransformContext'; + +export default function useHTMLContentTransformContext(): HTMLContentTransformContextType { + return useContext(HTMLContentTransformContext); +} diff --git a/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts b/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts new file mode 100644 index 0000000000..3a55cdbc6f --- /dev/null +++ b/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts @@ -0,0 +1,39 @@ +import { hooks } from 'botframework-webchat-api'; +import { useCallback } from 'react'; + +import { useStyleSet } from '../../hooks/index'; +import useCodeBlockCopyButtonTagName from '../CustomElements/useCodeBlockCopyButtonTagName'; +import useHTMLContentTransformContext from './private/useHTMLContentTransformContext'; + +const { useLocalizer } = hooks; + +export default function useTransformHTMLContent(): (documentFragment: DocumentFragment) => DocumentFragment { + const [{ codeBlockCopyButton: codeBlockCopyButtonClassName }] = useStyleSet(); + const [codeBlockCopyButtonTagName] = useCodeBlockCopyButtonTagName(); + const { transform } = useHTMLContentTransformContext(); + + const localize = useLocalizer(); + const codeBlockCopyButtonAltCopied = localize('COPY_BUTTON_COPIED_TEXT'); + const codeBlockCopyButtonAltCopy = localize('COPY_BUTTON_TEXT'); + const externalLinkAlt = localize('MARKDOWN_EXTERNAL_LINK_ALT'); + + return useCallback( + documentFragment => + transform({ + codeBlockCopyButtonAltCopied, + codeBlockCopyButtonAltCopy, + codeBlockCopyButtonClassName, + codeBlockCopyButtonTagName, + documentFragment, + externalLinkAlt + }), + [ + codeBlockCopyButtonAltCopied, + codeBlockCopyButtonAltCopy, + codeBlockCopyButtonClassName, + codeBlockCopyButtonTagName, + externalLinkAlt, + transform + ] + ); +}