From dd3ff94693ead04d6582757a8047218b536c4e58 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 18 Sep 2025 09:35:25 +0200 Subject: [PATCH 1/2] feat: support inserted text element in message markdown rendering --- src/components/ChannelPreview/utils.tsx | 3 +- .../renderText/__tests__/renderText.test.js | 35 ++++++++- .../Message/renderText/remarkPlugins/index.ts | 1 + .../remarkPlugins/plusPlusToEmphasis.ts | 77 +++++++++++++++++++ .../Message/renderText/renderText.tsx | 8 +- 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/components/Message/renderText/remarkPlugins/plusPlusToEmphasis.ts diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 1a3e09c488..e8903334d0 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -6,12 +6,13 @@ import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stre import type { TranslationContextValue } from '../../context/TranslationContext'; import type { ChatContextValue } from '../../context'; import type { PluggableList } from 'unified'; -import { htmlToTextPlugin } from '../Message'; +import { htmlToTextPlugin, plusPlusToEmphasis } from '../Message'; import remarkGfm from 'remark-gfm'; const remarkPlugins: PluggableList = [ htmlToTextPlugin, [remarkGfm, { singleTilde: false }], + plusPlusToEmphasis, ]; export const renderPreviewText = (text: string) => ( diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index 26ecc55f63..be2161e282 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -1,8 +1,12 @@ import React from 'react'; import { findAndReplace } from 'hast-util-find-and-replace'; import { u } from 'unist-builder'; -import { render } from '@testing-library/react'; -import { htmlToTextPlugin, keepLineBreaksPlugin } from '../remarkPlugins'; +import { render, screen } from '@testing-library/react'; +import { + htmlToTextPlugin, + keepLineBreaksPlugin, + plusPlusToEmphasis, +} from '../remarkPlugins'; import { defaultAllowedTagNames, renderText } from '../renderText'; import '@testing-library/jest-dom'; @@ -433,3 +437,30 @@ describe('htmlToTextPlugin', () => { expect(container).toMatchSnapshot(); }); }); + +describe('plusPlusToEmphasis', () => { + const renderTextPlusPlus = (text, withPlugin = true) => { + const Markdown = renderText( + text, + {}, + { getRemarkPlugins: () => (withPlugin ? [plusPlusToEmphasis] : []) }, + ); + return render(Markdown).container; + }; + + it('++…++ renders as and ignores code/links', () => { + renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++'); + expect(screen.getByText('inserted', { selector: 'ins' })).toBeInTheDocument(); + expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument(); + // link text exists; its inner text shouldn't be transformed + // expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); + }); + + it('does not render ++…++ as if not present', () => { + renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++', false); + expect(screen.queryByText('inserted', { selector: 'ins' })).not.toBeInTheDocument(); + expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument(); + // link text exists; its inner text shouldn't be transformed + // expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/Message/renderText/remarkPlugins/index.ts b/src/components/Message/renderText/remarkPlugins/index.ts index 525dc9f2c8..35cb04faf9 100644 --- a/src/components/Message/renderText/remarkPlugins/index.ts +++ b/src/components/Message/renderText/remarkPlugins/index.ts @@ -1,2 +1,3 @@ export * from './htmlToTextPlugin'; export * from './keepLineBreaksPlugin'; +export * from './plusPlusToEmphasis'; diff --git a/src/components/Message/renderText/remarkPlugins/plusPlusToEmphasis.ts b/src/components/Message/renderText/remarkPlugins/plusPlusToEmphasis.ts new file mode 100644 index 0000000000..e691b01efd --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/plusPlusToEmphasis.ts @@ -0,0 +1,77 @@ +// remark-plusplus-ins.ts +import type { Plugin } from 'unified'; +import { SKIP, visit } from 'unist-util-visit'; +import type { Visitor } from 'unist-util-visit'; +import type { Parent, PhrasingContent, Text } from 'mdast'; + +/** + * \S → first char must be non-whitespace + * (?:...)?→ optional middle+closing when length > 1 + * [\s\S]*?→ anything (including newlines), lazy + * final \S→ last char non-whitespace (only required when there’s more than 1) + * + * Matches: + * ++a++ + * Does not match: + * ++++ + * ++ ++ + */ +const INS_REGEX = /\+\+(\S(?:[\s\S]*?\S)?)\+\+/g; +const IGNORE_NODE_TYPES = new Set([ + 'code', + 'inlineCode', + 'link', + 'linkReference', + 'definition', + 'math', + 'inlineMath', +]); + +/** + * Converts MD "++Some text++" to inserted text element rendered in HTML as Some text + * https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/ins + */ +export const plusPlusToEmphasis: Plugin<[]> = () => { + const visitor: Visitor = (node, index, parent) => { + // 1) Don’t traverse inside ignored nodes + if (IGNORE_NODE_TYPES.has(node.type)) return SKIP; + + // 2) Only transform text nodes with a valid parent + index + if (node.type !== 'text' || parent == null || typeof index !== 'number') return; + + const value = (node as Text).value; + + // Reset lastIndex to 0 per node so each node is scanned from the beginning + INS_REGEX.lastIndex = 0; + + let match: RegExpExecArray | null; + let last = 0; + const out: PhrasingContent[] = []; + + while ((match = INS_REGEX.exec(value))) { + const [full, inner] = match; + const start = match.index; + + if (start > last) out.push({ type: 'text', value: value.slice(last, start) }); + + // Render as (remark-rehype respects data.hName) + out.push({ + children: [{ type: 'text', value: inner }], + data: { hName: 'ins' }, + type: 'emphasis', + }); + + last = start + full.length; + } + + if (out.length === 0) return; // nothing to change + if (last < value.length) out.push({ type: 'text', value: value.slice(last) }); + + (parent as Parent).children.splice(index, 1, ...out); + + // Skip re-visiting the replaced range; continue after inserted nodes + return [SKIP, index + out.length]; + }; + + return (tree) => visit(tree, visitor); +}; diff --git a/src/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 4ed2912004..731e039014 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -10,7 +10,11 @@ import type { PluggableList } from 'unified'; // A sub-dependency of react-markd import { Anchor, Emoji, Mention } from './componentRenderers'; import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex'; import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins'; -import { htmlToTextPlugin, keepLineBreaksPlugin } from './remarkPlugins'; +import { + htmlToTextPlugin, + keepLineBreaksPlugin, + plusPlusToEmphasis, +} from './remarkPlugins'; import { ErrorBoundary } from '../../UtilityComponents'; import type { MentionProps } from './componentRenderers'; @@ -51,6 +55,7 @@ export const defaultAllowedTagNames: Array< 'h4', 'h5', 'h6', + 'ins', ]; function formatUrlForDisplay(url: string) { @@ -169,6 +174,7 @@ export const renderText = ( htmlToTextPlugin, keepLineBreaksPlugin, [remarkGfm, { singleTilde: false }], + plusPlusToEmphasis, ]; const rehypePlugins: PluggableList = [emojiMarkdownPlugin]; From cfdb2bfe23d63fc031b81af0dd0cf9f4665a4d8e Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 18 Sep 2025 09:50:37 +0200 Subject: [PATCH 2/2] test: fix flaky test --- src/components/MessageInput/__tests__/EditMessageForm.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MessageInput/__tests__/EditMessageForm.test.js b/src/components/MessageInput/__tests__/EditMessageForm.test.js index a284b49597..b74f507de8 100644 --- a/src/components/MessageInput/__tests__/EditMessageForm.test.js +++ b/src/components/MessageInput/__tests__/EditMessageForm.test.js @@ -1176,13 +1176,13 @@ describe(`EditMessageForm`, () => { mentioned_users: [ expect.objectContaining({ banned: false, - created_at: '2020-04-27T13:39:49.331742Z', + created_at: expect.any(String), id: 'mention-id', image: expect.any(String), name: 'mention-name', online: false, role: 'user', - updated_at: '2020-04-27T13:39:49.332087Z', + updated_at: expect.any(String), }), ], parent_id: undefined,