diff --git a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap
index 27361f4ec..c43992dcf 100644
--- a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap
+++ b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap
@@ -236,10 +236,6 @@ exports[`keepLineBreaksPlugin present keeps line between lines with strong text
-
-
-
-
This is the second line
@@ -256,17 +252,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a blockquote 1`]@@ -281,17 +269,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a blockquote 1`]
- - - -
- - - -c
@@ -308,17 +288,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a code block 1`]
- - - -
- - - -
b @@ -329,17 +301,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a code block 1`]
- - - -
- - - -c
@@ -356,33 +320,17 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a horizontal rule
- - - -
- - - -
- - - -
- - - -b
@@ -399,17 +347,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a strikethrough 1
- - - -
- - - -~~xxx~~
@@ -418,17 +358,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a strikethrough 1
- - - -
- - - -b
@@ -445,17 +377,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a table 1`] = `
- - - -
- - - -| a | b | c | d | | - | :- | -: | :-: | @@ -466,17 +390,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks around a table 1`] = `
- - - -
- - - -c
@@ -493,17 +409,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between paragraphs 1`] =
- - - -
- - - -b
@@ -512,17 +420,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between paragraphs 1`] =
- - - -
- - - -c
@@ -548,17 +448,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between the items in an
- - - -
- - - -@@ -573,17 +465,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between the items in an
- - - -
- - - -@@ -618,17 +502,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between the items in an
- - - -
- - - -@@ -643,17 +519,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks between the items in an
- - - -
- - - -@@ -679,17 +547,9 @@ exports[`keepLineBreaksPlugin present keeps line breaks under a heading 1`] = `
- - - -
- - - -a
diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index 8827aa073..084db68f8 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -7,6 +7,7 @@ import { imageToLink, keepLineBreaksPlugin, plusPlusToEmphasis, + remarkIgnoreMarkdown, } from '../remarkPlugins'; import { defaultAllowedTagNames, renderText } from '../renderText'; import '@testing-library/jest-dom'; @@ -306,19 +307,19 @@ describe(`renderText`, () => { }); }); -describe('keepLineBreaksPlugin', () => { - const lineBreaks = '\n\n\n'; - const paragraphText = `a${lineBreaks}b${lineBreaks}c`; - const unorderedListText = `* item 1${lineBreaks}* item 2${lineBreaks}* item 3`; - const orderedListText = `1. item 1${lineBreaks}2. item 2${lineBreaks}3. item 3`; - const headingText = `## Heading${lineBreaks}a`; - const codeBlockText = 'a\n\n\n```b```\n\n\nc'; - const horizontalRuleText = `a${lineBreaks}---${lineBreaks}b`; - const blockquoteText = `a${lineBreaks}>b${lineBreaks}c`; - const withStrikeThroughText = `a${lineBreaks}${strikeThroughText}${lineBreaks}b`; - const tableText = `a${lineBreaks}| a | b | c | d |\n| - | :- | -: | :-: |\n| a | b | c | d |${lineBreaks}c`; - const multilineWithStrongText = 'This is **the first** line\n\nThis is the second line'; +const lineBreaks = '\n\n\n'; +const paragraphText = `a${lineBreaks}b${lineBreaks}c`; +const unorderedListText = `* item 1${lineBreaks}* item 2${lineBreaks}* item 3`; +const orderedListText = `1. item 1${lineBreaks}2. item 2${lineBreaks}3. item 3`; +const headingText = `## Heading${lineBreaks}a`; +const codeBlockText = 'a\n\n\n```b```\n\n\nc'; +const horizontalRuleText = `a${lineBreaks}---${lineBreaks}b`; +const blockquoteText = `a${lineBreaks}>b${lineBreaks}c`; +const withStrikeThroughText = `a${lineBreaks}${strikeThroughText}${lineBreaks}b`; +const tableText = `a${lineBreaks}| a | b | c | d |\n| - | :- | -: | :-: |\n| a | b | c | d |${lineBreaks}c`; +const multilineWithStrongText = 'This is **the first** line\n\nThis is the second line'; +describe('keepLineBreaksPlugin', () => { const doRenderText = (text, present) => { const Markdown = renderText( text, @@ -500,3 +501,37 @@ describe('imageToLink', () => { ).not.toBeInTheDocument(); }); }); + +describe('remarkIgnoreMarkdown', () => { + const text = [ + headingText, + paragraphText, + unorderedListText, + orderedListText, + codeBlockText, + horizontalRuleText, + blockquoteText, + withStrikeThroughText, + tableText, + multilineWithStrongText, + ].join('\n'); + + const renderWithPlugin = (plugins = []) => { + const Markdown = renderText( + text, + {}, + { getRemarkPlugins: () => [...plugins, remarkIgnoreMarkdown] }, + ); + return render(Markdown).container; + }; + + it('skips the markdown transformation and keeps the original escaped text and lines', () => { + expect(renderWithPlugin().innerHTML).toBe(`${text.replace(/>/g, '>')}
`); + }); + + it('keeps line without keepLineBreaksPlugin', () => { + expect(renderWithPlugin([keepLineBreaksPlugin]).innerHTML).toBe( + renderWithPlugin().innerHTML, + ); + }); +}); diff --git a/src/components/Message/renderText/remarkPlugins/index.ts b/src/components/Message/renderText/remarkPlugins/index.ts index f77d648b8..0736443de 100644 --- a/src/components/Message/renderText/remarkPlugins/index.ts +++ b/src/components/Message/renderText/remarkPlugins/index.ts @@ -2,3 +2,4 @@ export * from './htmlToTextPlugin'; export * from './imageToLink'; export * from './keepLineBreaksPlugin'; export * from './plusPlusToEmphasis'; +export * from './remarkIgnoreMarkdown'; diff --git a/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts b/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts index 55b80a549..e52325ac6 100644 --- a/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts +++ b/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts @@ -1,38 +1,66 @@ -import type { Visitor } from 'unist-util-visit'; +import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; -import { u } from 'unist-builder'; +import type { Node, Parent as UnistParent } from 'unist'; +import type { Root, RootContent, ThematicBreak } from 'mdast'; -import type { Break } from 'mdast'; -import type { Nodes } from 'hast-util-find-and-replace/lib'; +/** Type guard: does the node have mdast children? */ +function isParentWithChildren( + node: Node, +): node is UnistParent & { children: RootContent[] } { + const maybe = node as unknown as { children?: unknown }; + return Array.isArray(maybe.children); +} -const visitor: Visitor = (node, index, parent) => { - if (!(index && parent && node.position)) return; +/** Build a single
by mapping a standard mdast node via data.hName */ +function brNode(): ThematicBreak { + return { data: { hName: 'br' }, type: 'thematicBreak' }; +} - const prevSibling = parent.children.at(index - 1); - if (!prevSibling?.position) return; +/** + * Inserts runs of
between sibling block nodes to preserve the exact + * number of *blank source lines* between them. No paragraph wrappers are added. + * + * Works because `mdast-util-to-hast` respects `data.hName`, turning our + * `thematicBreak` into `
`. Multiple blank lines -> multiple `
` siblings. + */ +export const keepLineBreaksPlugin: Plugin<[], Root> = () => (tree) => { + visit( + tree as unknown as UnistParent, // visit needs a Unist parent-like root + isParentWithChildren, // limit to parents with children + (parent) => { + const children = parent.children as RootContent[]; + if (children.length < 2) return; - if (node.position.start.line === prevSibling.position.start.line) return; - const ownStartLine = node.position.start.line; - const prevEndLine = prevSibling.position.end.line; + const out: RootContent[] = []; - // the -1 is adjustment for the single line break into which multiple line breaks are converted - const countTruncatedLineBreaks = ownStartLine - prevEndLine - 1; - if (countTruncatedLineBreaks < 1) return; + for (let i = 0; i < children.length; i++) { + const curr = children[i]; + out.push(curr); - const lineBreaks = Array.from( - { length: countTruncatedLineBreaks }, - () => u('break', { tagName: 'br' }), - ); + if (i === children.length - 1) break; - parent.children = [ - ...parent.children.slice(0, index), - ...lineBreaks, - ...parent.children.slice(index), - ]; - return; -}; -const transform = (tree: Nodes) => { - visit(tree, visitor); -}; + const next = children[i + 1]; + + const currEndLine = + curr.position && curr.position.end ? curr.position.end.line : undefined; + const nextStartLine = + next.position && next.position.start ? next.position.start.line : undefined; -export const keepLineBreaksPlugin = () => transform; + if (typeof currEndLine !== 'number' || typeof nextStartLine !== 'number') { + continue; + } + + // Markdown already separates blocks by at least one visual gap. + // We add back only the *extra* blank lines from the source. + const extraBlankLines = Math.max(0, nextStartLine - currEndLine - 1); + if (extraBlankLines > 0) { + for (let k = 0; k < extraBlankLines; k++) { + out.push(brNode()); + } + } + } + + parent.children = out; + }, + ); +}; diff --git a/src/components/Message/renderText/remarkPlugins/remarkIgnoreMarkdown.ts b/src/components/Message/renderText/remarkPlugins/remarkIgnoreMarkdown.ts new file mode 100644 index 000000000..56a4ea382 --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/remarkIgnoreMarkdown.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'unified'; +import type { Paragraph, Root, Text } from 'mdast'; + +/** + * Replace the parsed Markdown tree with a single paragraph containing the + * original source as a plain text node. No Markdown formatting is interpreted. + * React will escape it. + */ +export const remarkIgnoreMarkdown: Plugin<[], Root> = () => (tree, file) => { + const source = String(file.value ?? ''); + + const text: Text = { type: 'text', value: source }; + const paragraph: Paragraph = { children: [text], type: 'paragraph' }; + + tree.children = [paragraph]; +};