Skip to content

Commit 9135112

Browse files
authored
feat: support inserted text element in message markdown rendering (#2831)
1 parent 58d41c8 commit 9135112

File tree

6 files changed

+122
-6
lines changed

6 files changed

+122
-6
lines changed

src/components/ChannelPreview/utils.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import type { Channel, PollVote, TranslationLanguages, UserResponse } from 'stre
66
import type { TranslationContextValue } from '../../context/TranslationContext';
77
import type { ChatContextValue } from '../../context';
88
import type { PluggableList } from 'unified';
9-
import { htmlToTextPlugin } from '../Message';
9+
import { htmlToTextPlugin, plusPlusToEmphasis } from '../Message';
1010
import remarkGfm from 'remark-gfm';
1111

1212
const remarkPlugins: PluggableList = [
1313
htmlToTextPlugin,
1414
[remarkGfm, { singleTilde: false }],
15+
plusPlusToEmphasis,
1516
];
1617

1718
export const renderPreviewText = (text: string) => (

src/components/Message/renderText/__tests__/renderText.test.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import React from 'react';
22
import { findAndReplace } from 'hast-util-find-and-replace';
33
import { u } from 'unist-builder';
4-
import { render } from '@testing-library/react';
5-
import { htmlToTextPlugin, keepLineBreaksPlugin } from '../remarkPlugins';
4+
import { render, screen } from '@testing-library/react';
5+
import {
6+
htmlToTextPlugin,
7+
keepLineBreaksPlugin,
8+
plusPlusToEmphasis,
9+
} from '../remarkPlugins';
610
import { defaultAllowedTagNames, renderText } from '../renderText';
711
import '@testing-library/jest-dom';
812

@@ -433,3 +437,30 @@ describe('htmlToTextPlugin', () => {
433437
expect(container).toMatchSnapshot();
434438
});
435439
});
440+
441+
describe('plusPlusToEmphasis', () => {
442+
const renderTextPlusPlus = (text, withPlugin = true) => {
443+
const Markdown = renderText(
444+
text,
445+
{},
446+
{ getRemarkPlugins: () => (withPlugin ? [plusPlusToEmphasis] : []) },
447+
);
448+
return render(Markdown).container;
449+
};
450+
451+
it('++…++ renders as <ins> and ignores code/links', () => {
452+
renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++');
453+
expect(screen.getByText('inserted', { selector: 'ins' })).toBeInTheDocument();
454+
expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument();
455+
// link text exists; its inner text shouldn't be transformed
456+
// expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
457+
});
458+
459+
it('does not render ++…++ as <ins> if not present', () => {
460+
renderTextPlusPlus('This is ++inserted++ and `++not++` and [x](y) ++also++', false);
461+
expect(screen.queryByText('inserted', { selector: 'ins' })).not.toBeInTheDocument();
462+
expect(screen.getByText('++not++', { selector: 'code' })).toBeInTheDocument();
463+
// link text exists; its inner text shouldn't be transformed
464+
// expect(screen.getByRole('link', { name: 'x' })).toBeInTheDocument();
465+
});
466+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './htmlToTextPlugin';
22
export * from './keepLineBreaksPlugin';
3+
export * from './plusPlusToEmphasis';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// remark-plusplus-ins.ts
2+
import type { Plugin } from 'unified';
3+
import { SKIP, visit } from 'unist-util-visit';
4+
import type { Visitor } from 'unist-util-visit';
5+
import type { Parent, PhrasingContent, Text } from 'mdast';
6+
7+
/**
8+
* \S → first char must be non-whitespace
9+
* (?:...)?→ optional middle+closing when length > 1
10+
* [\s\S]*?→ anything (including newlines), lazy
11+
* final \S→ last char non-whitespace (only required when there’s more than 1)
12+
*
13+
* Matches:
14+
* ++a++
15+
* Does not match:
16+
* ++++
17+
* ++ ++
18+
*/
19+
const INS_REGEX = /\+\+(\S(?:[\s\S]*?\S)?)\+\+/g;
20+
const IGNORE_NODE_TYPES = new Set([
21+
'code',
22+
'inlineCode',
23+
'link',
24+
'linkReference',
25+
'definition',
26+
'math',
27+
'inlineMath',
28+
]);
29+
30+
/**
31+
* Converts MD "++Some text++" to inserted text element rendered in HTML as <ins>Some text</ins>
32+
* https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/ins
33+
*/
34+
export const plusPlusToEmphasis: Plugin<[]> = () => {
35+
const visitor: Visitor = (node, index, parent) => {
36+
// 1) Don’t traverse inside ignored nodes
37+
if (IGNORE_NODE_TYPES.has(node.type)) return SKIP;
38+
39+
// 2) Only transform text nodes with a valid parent + index
40+
if (node.type !== 'text' || parent == null || typeof index !== 'number') return;
41+
42+
const value = (node as Text).value;
43+
44+
// Reset lastIndex to 0 per node so each node is scanned from the beginning
45+
INS_REGEX.lastIndex = 0;
46+
47+
let match: RegExpExecArray | null;
48+
let last = 0;
49+
const out: PhrasingContent[] = [];
50+
51+
while ((match = INS_REGEX.exec(value))) {
52+
const [full, inner] = match;
53+
const start = match.index;
54+
55+
if (start > last) out.push({ type: 'text', value: value.slice(last, start) });
56+
57+
// Render as <ins>…</ins> (remark-rehype respects data.hName)
58+
out.push({
59+
children: [{ type: 'text', value: inner }],
60+
data: { hName: 'ins' },
61+
type: 'emphasis',
62+
});
63+
64+
last = start + full.length;
65+
}
66+
67+
if (out.length === 0) return; // nothing to change
68+
if (last < value.length) out.push({ type: 'text', value: value.slice(last) });
69+
70+
(parent as Parent).children.splice(index, 1, ...out);
71+
72+
// Skip re-visiting the replaced range; continue after inserted nodes
73+
return [SKIP, index + out.length];
74+
};
75+
76+
return (tree) => visit(tree, visitor);
77+
};

src/components/Message/renderText/renderText.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import type { PluggableList } from 'unified'; // A sub-dependency of react-markd
1010
import { Anchor, Emoji, Mention } from './componentRenderers';
1111
import { detectHttp, matchMarkdownLinks, messageCodeBlocks } from './regex';
1212
import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins';
13-
import { htmlToTextPlugin, keepLineBreaksPlugin } from './remarkPlugins';
13+
import {
14+
htmlToTextPlugin,
15+
keepLineBreaksPlugin,
16+
plusPlusToEmphasis,
17+
} from './remarkPlugins';
1418
import { ErrorBoundary } from '../../UtilityComponents';
1519
import type { MentionProps } from './componentRenderers';
1620

@@ -51,6 +55,7 @@ export const defaultAllowedTagNames: Array<
5155
'h4',
5256
'h5',
5357
'h6',
58+
'ins',
5459
];
5560

5661
function formatUrlForDisplay(url: string) {
@@ -169,6 +174,7 @@ export const renderText = (
169174
htmlToTextPlugin,
170175
keepLineBreaksPlugin,
171176
[remarkGfm, { singleTilde: false }],
177+
plusPlusToEmphasis,
172178
];
173179
const rehypePlugins: PluggableList = [emojiMarkdownPlugin];
174180

src/components/MessageInput/__tests__/EditMessageForm.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,13 +1176,13 @@ describe(`EditMessageForm`, () => {
11761176
mentioned_users: [
11771177
expect.objectContaining({
11781178
banned: false,
1179-
created_at: '2020-04-27T13:39:49.331742Z',
1179+
created_at: expect.any(String),
11801180
id: 'mention-id',
11811181
image: expect.any(String),
11821182
name: 'mention-name',
11831183
online: false,
11841184
role: 'user',
1185-
updated_at: '2020-04-27T13:39:49.332087Z',
1185+
updated_at: expect.any(String),
11861186
}),
11871187
],
11881188
parent_id: undefined,

0 commit comments

Comments
 (0)