Skip to content

Commit 8e5a0a9

Browse files
committed
feat: add sanitizeTextForRender method
1 parent cf6901e commit 8e5a0a9

File tree

3 files changed

+151
-2
lines changed

3 files changed

+151
-2
lines changed

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { toURL, isSameHost, isValidDomain } from './url';
1616

1717
import { getRecipients } from './email';
1818

19-
import { parseBoolean } from './string';
19+
import { parseBoolean, sanitizeTextForRender } from './string';
2020
import {
2121
sortAsc,
2222
quantile,
@@ -62,6 +62,7 @@ export {
6262
parseBoolean,
6363
quantile,
6464
replaceVariablesInMessage,
65+
sanitizeTextForRender,
6566
sortAsc,
6667
splitName,
6768
toURL,

src/string.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,64 @@ export function parseBoolean(candidate: string | number) {
1616
return false;
1717
}
1818
}
19+
20+
/**
21+
* Sanitizes text for safe HTML rendering by escaping potentially dangerous characters
22+
* while preserving valid HTML tags.
23+
*
24+
* This function performs the following transformations:
25+
* - Converts newline characters (\n) to HTML line breaks (<br>)
26+
* - Escapes stray '<' characters that are not part of valid HTML tags (e.g., "x < 5" → "x &lt; 5")
27+
* - Escapes stray '>' characters that are not part of valid HTML tags (e.g., "x > 5" → "x &gt; 5")
28+
* - Preserves valid HTML tags and their attributes (e.g., <div>, <span class="test">, </p>)
29+
*
30+
* LIMITATIONS: This regex-based approach has known limitations:
31+
* - Cannot properly handle '>' characters inside HTML attributes (e.g., <div title="x > 5"> may not work correctly)
32+
* - Complex nested quotes or edge cases may not be handled perfectly
33+
* - For more complex HTML sanitization needs, consider using a proper HTML parser
34+
*
35+
* @param {string} text - The text to sanitize. Can be null or undefined.
36+
* @returns {string} The sanitized text safe for HTML rendering, or the original value if null/undefined.
37+
*
38+
* @example
39+
* sanitizeTextForRender('Hello\nWorld') // 'Hello<br>World'
40+
* sanitizeTextForRender('if x < 5') // 'if x &lt; 5'
41+
* sanitizeTextForRender('<div>Hello</div>') // '<div>Hello</div>'
42+
* sanitizeTextForRender('Price < $100 <strong>Sale!</strong>') // 'Price &lt; $100 <strong>Sale!</strong>'
43+
*/
44+
export function sanitizeTextForRender(text: string | null | undefined) {
45+
if (!text) return text;
46+
47+
return (
48+
text
49+
.replace(/\n/g, '<br>')
50+
51+
// Escape < that doesn't start a valid HTML tag
52+
// Regex breakdown:
53+
// < - matches '<'
54+
// (?! - negative lookahead (not followed by)
55+
// \/? - optional forward slash for closing tags
56+
// \w+ - one or more word characters (tag name)
57+
// (?: - non-capturing group for attributes
58+
// \s+ - whitespace before attributes
59+
// [^>]* - any characters except '>' (attribute content)
60+
// )? - attributes are optional
61+
// \/?> - optional self-closing slash, then '>'
62+
// ) - end lookahead
63+
.replace(/<(?!\/?\w+(?:\s+[^>]*)?\/?>)/g, '&lt;')
64+
65+
// Escape > that isn't part of an HTML tag
66+
// Regex breakdown:
67+
// (?<! - negative lookbehind (not preceded by)
68+
// < - opening '<'
69+
// \/? - optional forward slash for closing tags
70+
// \w+ - one or more word characters (tag name)
71+
// (?: - non-capturing group for attributes
72+
// \s+ - whitespace before attributes
73+
// [^>]* - any characters except '>' (attribute content)
74+
// )? - attributes are optional
75+
// ) - end lookbehind
76+
// > - matches '>'
77+
.replace(/(?<!<\/?\w+(?:\s+[^>]*)?)>/g, '&gt;')
78+
);
79+
}

test/string.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseBoolean } from '../src';
1+
import { parseBoolean, sanitizeTextForRender } from '../src';
22

33
describe('#parseBoolean', () => {
44
test('returns true for input "true"', () => {
@@ -37,3 +37,90 @@ describe('#parseBoolean', () => {
3737
expect(parseBoolean(undefined)).toBe(false);
3838
});
3939
});
40+
41+
describe('#sanitizeTextForRender', () => {
42+
it('should handle null and undefined values', () => {
43+
expect(sanitizeTextForRender(null)).toBe(null);
44+
expect(sanitizeTextForRender(undefined)).toBe(undefined);
45+
expect(sanitizeTextForRender('')).toBe('');
46+
});
47+
48+
it('should convert newlines to <br> tags', () => {
49+
expect(sanitizeTextForRender('Line 1\nLine 2')).toBe('Line 1<br>Line 2');
50+
expect(sanitizeTextForRender('Multiple\n\nNewlines')).toBe('Multiple<br><br>Newlines');
51+
});
52+
53+
it('should escape stray < characters', () => {
54+
expect(sanitizeTextForRender('if x < 5')).toBe('if x &lt; 5');
55+
expect(sanitizeTextForRender('< this is not a tag')).toBe('&lt; this is not a tag');
56+
expect(sanitizeTextForRender('price < $100')).toBe('price &lt; $100');
57+
});
58+
59+
it('should escape stray > characters', () => {
60+
expect(sanitizeTextForRender('if x > 5')).toBe('if x &gt; 5');
61+
expect(sanitizeTextForRender('this is not a tag >')).toBe('this is not a tag &gt;');
62+
expect(sanitizeTextForRender('score > 90%')).toBe('score &gt; 90%');
63+
});
64+
65+
it('should escape both stray < and > characters', () => {
66+
expect(sanitizeTextForRender('5 < x < 10')).toBe('5 &lt; x &lt; 10');
67+
expect(sanitizeTextForRender('x > 5 && y < 10')).toBe('x &gt; 5 && y &lt; 10');
68+
});
69+
70+
it('should preserve valid HTML tags', () => {
71+
expect(sanitizeTextForRender('<div>Hello</div>')).toBe('<div>Hello</div>');
72+
expect(sanitizeTextForRender('<span class="test">World</span>')).toBe('<span class="test">World</span>');
73+
expect(sanitizeTextForRender('<br>')).toBe('<br>');
74+
expect(sanitizeTextForRender('<img src="test.jpg" />')).toBe('<img src="test.jpg" />');
75+
});
76+
77+
it('should preserve nested HTML tags', () => {
78+
expect(sanitizeTextForRender('<div><span>Nested</span></div>')).toBe('<div><span>Nested</span></div>');
79+
expect(sanitizeTextForRender('<ul><li>Item 1</li><li>Item 2</li></ul>'))
80+
.toBe('<ul><li>Item 1</li><li>Item 2</li></ul>');
81+
});
82+
83+
it('should handle mixed content with valid tags and stray characters', () => {
84+
expect(sanitizeTextForRender('Price < $100 <strong>on sale</strong>'))
85+
.toBe('Price &lt; $100 <strong>on sale</strong>');
86+
expect(sanitizeTextForRender('<p>x > 5</p> and y < 10'))
87+
.toBe('<p>x &gt; 5</p> and y &lt; 10');
88+
});
89+
90+
it('should handle edge cases with malformed HTML-like content', () => {
91+
expect(sanitizeTextForRender('<<invalid>>')).toBe('&lt;<invalid>&gt;');
92+
expect(sanitizeTextForRender('<not a tag')).toBe('&lt;not a tag');
93+
expect(sanitizeTextForRender('not a tag>')).toBe('not a tag&gt;');
94+
});
95+
96+
it('should handle email addresses and URLs with angle brackets', () => {
97+
expect(sanitizeTextForRender('Contact: <[email protected]>'))
98+
.toBe('Contact: &lt;[email protected]&gt;');
99+
expect(sanitizeTextForRender('Email me at < [email protected] >'))
100+
.toBe('Email me at &lt; [email protected] &gt;');
101+
});
102+
103+
it('should handle mathematical expressions', () => {
104+
expect(sanitizeTextForRender('if (x < y && y > z)')).toBe('if (x &lt; y && y &gt; z)');
105+
expect(sanitizeTextForRender('array[i] < array[j]')).toBe('array[i] &lt; array[j]');
106+
});
107+
108+
it('should handle HTML entities within valid tags', () => {
109+
expect(sanitizeTextForRender('<div>&lt;escaped&gt;</div>'))
110+
.toBe('<div>&lt;escaped&gt;</div>');
111+
expect(sanitizeTextForRender('<span>already &amp; escaped</span>'))
112+
.toBe('<span>already &amp; escaped</span>');
113+
});
114+
115+
it('should handle complex real-world email content', () => {
116+
const emailContent = `Hello,\n\nThe price is < $50 for items where quantity > 10.\n<p>Best regards,</p>\n<strong>Sales Team</strong>`;
117+
const expected = `Hello,<br><br>The price is &lt; $50 for items where quantity &gt; 10.<br><p>Best regards,</p><br><strong>Sales Team</strong>`;
118+
expect(sanitizeTextForRender(emailContent)).toBe(expected);
119+
});
120+
121+
it('should handle quoted email content', () => {
122+
const quoted = `Original message:\n> User wrote: x < 5\n<blockquote>Previous reply</blockquote>`;
123+
const expected = `Original message:<br>&gt; User wrote: x &lt; 5<br><blockquote>Previous reply</blockquote>`;
124+
expect(sanitizeTextForRender(quoted)).toBe(expected);
125+
});
126+
});

0 commit comments

Comments
 (0)