Skip to content

Commit 9aa896b

Browse files
authored
Merge pull request #3484 from ecency/feature/chat-inline-markdown
Chat: render inline markdown in messages
2 parents 1d63927 + 8165e17 commit 9aa896b

4 files changed

Lines changed: 497 additions & 8 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { parseInlineMarkdown } from './inlineMarkdown';
2+
3+
/** Collapses spans back to text, to assert nothing is ever dropped. */
4+
const flat = (s: string) =>
5+
parseInlineMarkdown(s)
6+
.map((p) => p.text)
7+
.join('');
8+
9+
const styled = (s: string) =>
10+
parseInlineMarkdown(s).filter((p) => p.bold || p.italic || p.strike || p.code);
11+
12+
describe('parseInlineMarkdown — formatting', () => {
13+
it('renders bold', () => {
14+
expect(parseInlineMarkdown('this is **important** stuff')).toEqual([
15+
{ text: 'this is ' },
16+
{ text: 'important', bold: true },
17+
{ text: ' stuff' },
18+
]);
19+
});
20+
21+
it('renders italic with both delimiters', () => {
22+
expect(styled('an *emphatic* word')).toEqual([{ text: 'emphatic', italic: true }]);
23+
expect(styled('an _emphatic_ word')).toEqual([{ text: 'emphatic', italic: true }]);
24+
});
25+
26+
it('renders strikethrough', () => {
27+
expect(styled('~~nope~~ yes')).toEqual([{ text: 'nope', strike: true }]);
28+
});
29+
30+
it('renders inline code', () => {
31+
expect(styled('run `yarn test` now')).toEqual([{ text: 'yarn test', code: true }]);
32+
});
33+
34+
it('nests italic inside bold', () => {
35+
expect(parseInlineMarkdown('**very *very* bold**')).toEqual([
36+
{ text: 'very ', bold: true },
37+
{ text: 'very', bold: true, italic: true },
38+
{ text: ' bold', bold: true },
39+
]);
40+
});
41+
42+
it('treats code spans as literal, so markdown inside them is not parsed', () => {
43+
expect(parseInlineMarkdown('`a **b** c`')).toEqual([{ text: 'a **b** c', code: true }]);
44+
});
45+
});
46+
47+
describe('parseInlineMarkdown — leaves ordinary text alone', () => {
48+
it('does not italicise snake_case identifiers', () => {
49+
expect(styled('file_name_here and snake_case_var')).toEqual([]);
50+
expect(flat('file_name_here and snake_case_var')).toBe('file_name_here and snake_case_var');
51+
});
52+
53+
it('does not mangle a URL containing underscores', () => {
54+
const url = 'https://ecency.com/@user/my_great_post_here';
55+
expect(styled(url)).toEqual([]);
56+
expect(flat(url)).toBe(url);
57+
});
58+
59+
it('does not italicise spaced asterisks used as multiplication', () => {
60+
expect(styled('2 * 3 * 4 = 24')).toEqual([]);
61+
expect(flat('2 * 3 * 4 = 24')).toBe('2 * 3 * 4 = 24');
62+
});
63+
64+
it('still finds a valid pair after an invalid opening delimiter', () => {
65+
// regression: skipping an invalid "*" must keep scanning the SAME rule, not abandon it
66+
expect(styled('2 * 3 and *italic* here')).toEqual([{ text: 'italic', italic: true }]);
67+
expect(flat('2 * 3 and *italic* here')).toBe('2 * 3 and italic here');
68+
expect(styled('a_b and _real_ emphasis')).toEqual([{ text: 'real', italic: true }]);
69+
});
70+
71+
it('leaves an unterminated delimiter as literal text', () => {
72+
expect(styled('*hello there')).toEqual([]);
73+
expect(flat('*hello there')).toBe('*hello there');
74+
expect(flat('a ** b')).toBe('a ** b');
75+
});
76+
77+
it('leaves bare delimiters alone', () => {
78+
['***', '___', '~~', '`', '*', '_'].forEach((s) => {
79+
expect(flat(s)).toBe(s);
80+
});
81+
});
82+
83+
it('handles empty and plain input', () => {
84+
expect(parseInlineMarkdown('')).toEqual([]);
85+
expect(parseInlineMarkdown('just chatting')).toEqual([{ text: 'just chatting' }]);
86+
});
87+
88+
it('does not treat block syntax as inline formatting', () => {
89+
// lists, headings and quotes stay literal by design
90+
['- a point', '# heading', '> quoted'].forEach((s) => {
91+
expect(flat(s)).toBe(s);
92+
expect(styled(s)).toEqual([]);
93+
});
94+
});
95+
});
96+
97+
describe('parseInlineMarkdown — never loses content', () => {
98+
it('preserves every character except consumed delimiters', () => {
99+
const samples = [
100+
'hey **there** how are _you_ today',
101+
'mixed **bold _and italic_** together',
102+
'code `x` and **bold**',
103+
'emoji 🥰 with **bold** after',
104+
'a*b*c',
105+
'**',
106+
'trailing *',
107+
'multi\nline **bold** text',
108+
];
109+
samples.forEach((s) => {
110+
const out = flat(s);
111+
// every non-delimiter character survives, in order
112+
const strip = (t: string) => t.replace(/[*_~`]/g, '');
113+
expect(strip(out)).toBe(strip(s));
114+
});
115+
});
116+
117+
it('terminates on pathological delimiter soup', () => {
118+
const nasty = `${'*'.repeat(200)}x${'_'.repeat(200)}`;
119+
const started = Date.now();
120+
const out = flat(nasty);
121+
expect(Date.now() - started).toBeLessThan(1000);
122+
expect(out.replace(/[*_~`]/g, '')).toBe('x');
123+
});
124+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Inline markdown for chat messages: bold, italic, strikethrough and inline code.
3+
*
4+
* Deliberately a SUBSET. Block constructs (lists, headings, blockquotes) are left alone so a
5+
* message that happens to start with "-" or "#" keeps reading the way the sender typed it, and
6+
* so the existing link, mention, emoji and image handling stays on its current code path.
7+
*
8+
* The rules below exist to stop ordinary chat text being mangled. The failure that matters is
9+
* not "some markdown went unformatted", it is "a URL or a variable name got eaten", so every
10+
* ambiguous case resolves toward leaving the text alone.
11+
*/
12+
13+
export interface InlineSpan {
14+
text: string;
15+
bold?: boolean;
16+
italic?: boolean;
17+
strike?: boolean;
18+
code?: boolean;
19+
}
20+
21+
type Style = Omit<InlineSpan, 'text'>;
22+
23+
const isWordChar = (ch: string | undefined) => !!ch && /[A-Za-z0-9]/.test(ch);
24+
const isSpace = (ch: string | undefined) => !ch || /\s/.test(ch);
25+
26+
interface Rule {
27+
open: string;
28+
close: string;
29+
style: Style;
30+
/** Underscore emphasis must not fire inside a word, so snake_case_names stay intact. */
31+
wordBoundary?: boolean;
32+
/** Code spans are literal: nothing inside them is parsed further. */
33+
literal?: boolean;
34+
}
35+
36+
// Order matters: longer delimiters first, so ** is not read as two separate * emphases.
37+
const RULES: Rule[] = [
38+
{ open: '`', close: '`', style: { code: true }, literal: true },
39+
{ open: '**', close: '**', style: { bold: true } },
40+
{ open: '__', close: '__', style: { bold: true }, wordBoundary: true },
41+
{ open: '~~', close: '~~', style: { strike: true } },
42+
{ open: '*', close: '*', style: { italic: true } },
43+
{ open: '_', close: '_', style: { italic: true }, wordBoundary: true },
44+
];
45+
46+
interface DelimiterMatch {
47+
rule: Rule;
48+
start: number;
49+
contentStart: number;
50+
contentEnd: number;
51+
end: number;
52+
}
53+
54+
/**
55+
* Finds the first usable delimiter pair at or after `from`.
56+
* Returns null when nothing in the remaining text qualifies.
57+
*
58+
* The return type is annotated explicitly: `best` is assigned inside a callback, which control
59+
* flow analysis does not track, so an inferred type collapses to null at the call site.
60+
*/
61+
const findMatch = (text: string, from: number): DelimiterMatch | null => {
62+
let best: DelimiterMatch | null = null;
63+
64+
RULES.forEach((rule) => {
65+
let searchFrom = from;
66+
67+
while (searchFrom < text.length) {
68+
const start = text.indexOf(rule.open, searchFrom);
69+
if (start === -1) {
70+
return;
71+
}
72+
73+
const contentStart = start + rule.open.length;
74+
// An opening delimiter must be followed by real content, so "2 * 3 * 4" is left alone, and
75+
// "_" only opens at a word boundary, so file_name_here is not italicised.
76+
const openInvalid =
77+
isSpace(text[contentStart]) || (rule.wordBoundary && isWordChar(text[start - 1]));
78+
79+
if (openInvalid) {
80+
// Not a real delimiter here. Step past it and keep scanning for THIS rule: "2 * 3 and
81+
// *italic*" must still find the later pair. Abandoning the rule would miss it.
82+
searchFrom = start + 1;
83+
} else {
84+
let closeAt = text.indexOf(rule.close, contentStart);
85+
while (closeAt !== -1) {
86+
const beforeClose = text[closeAt - 1];
87+
const afterClose = text[closeAt + rule.close.length];
88+
const closeOk = !isSpace(beforeClose) && !(rule.wordBoundary && isWordChar(afterClose));
89+
// Content has to be more than delimiters, so a run like "***" stays literal instead of
90+
// formatting a lone "*". Ambiguous punctuation resolves toward leaving text untouched.
91+
const hasSubstance = /[^*_~`]/.test(text.slice(contentStart, closeAt));
92+
if (closeOk && hasSubstance && closeAt > contentStart) {
93+
break;
94+
}
95+
closeAt = text.indexOf(rule.close, closeAt + 1);
96+
}
97+
98+
// closeAt === -1 means unterminated: leave it literal rather than swallowing the rest.
99+
if (closeAt !== -1 && (!best || start < best.start)) {
100+
best = {
101+
rule,
102+
start,
103+
contentStart,
104+
contentEnd: closeAt,
105+
end: closeAt + rule.close.length,
106+
};
107+
}
108+
return;
109+
}
110+
}
111+
});
112+
113+
return best;
114+
};
115+
116+
const mergeStyle = (a: Style, b: Style): Style => ({ ...a, ...b });
117+
118+
const parse = (text: string, inherited: Style, depth: number): InlineSpan[] => {
119+
if (!text) {
120+
return [];
121+
}
122+
123+
// Bounded recursion: pathological delimiter soup should degrade to plain text, not hang.
124+
if (depth > 4) {
125+
return [{ text, ...inherited }];
126+
}
127+
128+
const match = findMatch(text, 0);
129+
if (!match) {
130+
return [{ text, ...inherited }];
131+
}
132+
133+
const spans: InlineSpan[] = [];
134+
if (match.start > 0) {
135+
spans.push({ text: text.slice(0, match.start), ...inherited });
136+
}
137+
138+
const content = text.slice(match.contentStart, match.contentEnd);
139+
const style = mergeStyle(inherited, match.rule.style);
140+
141+
if (match.rule.literal) {
142+
spans.push({ text: content, ...style });
143+
} else {
144+
spans.push(...parse(content, style, depth + 1));
145+
}
146+
147+
spans.push(...parse(text.slice(match.end), inherited, depth));
148+
149+
return spans.filter((s) => s.text.length > 0);
150+
};
151+
152+
/**
153+
* Splits `text` into styled spans. Always returns at least one span for non-empty input, and
154+
* never drops characters: concatenating every `text` yields the input minus the delimiters that
155+
* were actually consumed.
156+
*/
157+
export const parseInlineMarkdown = (text: string): InlineSpan[] => {
158+
if (!text) {
159+
return [];
160+
}
161+
return parse(text, {}, 0);
162+
};
163+
164+
/** True when the text contains nothing this module would change. Lets callers skip the work. */
165+
export const hasInlineMarkdown = (text: string): boolean =>
166+
!!text &&
167+
/[*_~`]/.test(text) &&
168+
parseInlineMarkdown(text).some((s) => s.bold || s.italic || s.strike || s.code);

0 commit comments

Comments
 (0)