Skip to content

Commit 097e00a

Browse files
authored
feat: add sanitizeTextForRender method (#58)
1 parent cf6901e commit 097e00a

5 files changed

Lines changed: 220 additions & 5 deletions

File tree

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
runs-on: ${{ matrix.os }}
88
strategy:
99
matrix:
10-
node: ['16.x', '20.x', '23.x']
10+
node: ['20.x', '23.x']
1111
os: ['ubuntu-latest']
1212

1313
steps:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@chatwoot/utils",
3-
"version": "0.0.50",
3+
"version": "0.0.51",
44
"description": "Chatwoot utils",
55
"private": false,
66
"license": "MIT",

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

test/string.test.ts

Lines changed: 154 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,156 @@ 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('');
44+
expect(sanitizeTextForRender(undefined)).toBe('');
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(
51+
'Multiple<br><br>Newlines'
52+
);
53+
});
54+
55+
it('should escape stray < characters', () => {
56+
expect(sanitizeTextForRender('if x < 5')).toBe('if x &lt; 5');
57+
expect(sanitizeTextForRender('< this is not a tag')).toBe(
58+
'&lt; this is not a tag'
59+
);
60+
expect(sanitizeTextForRender('price < $100')).toBe('price &lt; $100');
61+
});
62+
63+
it('should escape stray > characters', () => {
64+
expect(sanitizeTextForRender('if x > 5')).toBe('if x &gt; 5');
65+
expect(sanitizeTextForRender('this is not a tag >')).toBe(
66+
'this is not a tag &gt;'
67+
);
68+
expect(sanitizeTextForRender('score > 90%')).toBe('score &gt; 90%');
69+
});
70+
71+
it('should escape both stray < and > characters', () => {
72+
expect(sanitizeTextForRender('5 < x < 10')).toBe('5 &lt; x &lt; 10');
73+
expect(sanitizeTextForRender('x > 5 && y < 10')).toBe(
74+
'x &gt; 5 && y &lt; 10'
75+
);
76+
});
77+
78+
it('should preserve valid HTML tags', () => {
79+
expect(sanitizeTextForRender('<div>Hello</div>')).toBe('<div>Hello</div>');
80+
expect(sanitizeTextForRender('<span class="test">World</span>')).toBe(
81+
'<span class="test">World</span>'
82+
);
83+
expect(sanitizeTextForRender('<br>')).toBe('<br>');
84+
expect(sanitizeTextForRender('<img src="test.jpg" />')).toBe(
85+
'<img src="test.jpg" />'
86+
);
87+
});
88+
89+
it('should preserve nested HTML tags', () => {
90+
expect(sanitizeTextForRender('<div><span>Nested</span></div>')).toBe(
91+
'<div><span>Nested</span></div>'
92+
);
93+
expect(
94+
sanitizeTextForRender('<ul><li>Item 1</li><li>Item 2</li></ul>')
95+
).toBe('<ul><li>Item 1</li><li>Item 2</li></ul>');
96+
});
97+
98+
it('should handle mixed content with valid tags and stray characters', () => {
99+
expect(sanitizeTextForRender('Price < $100 <strong>on sale</strong>')).toBe(
100+
'Price &lt; $100 <strong>on sale</strong>'
101+
);
102+
expect(sanitizeTextForRender('<p>x > 5</p> and y < 10')).toBe(
103+
'<p>x &gt; 5</p> and y &lt; 10'
104+
);
105+
});
106+
107+
it('should handle edge cases with malformed HTML-like content', () => {
108+
expect(sanitizeTextForRender('<<invalid>>')).toBe('&lt;<invalid>&gt;');
109+
expect(sanitizeTextForRender('<not a tag')).toBe('&lt;not a tag');
110+
expect(sanitizeTextForRender('not a tag>')).toBe('not a tag&gt;');
111+
});
112+
113+
it('should handle email addresses and URLs with angle brackets', () => {
114+
expect(sanitizeTextForRender('Contact: <user@example.com>')).toBe(
115+
'Contact: &lt;user@example.com&gt;'
116+
);
117+
expect(sanitizeTextForRender('Email me at < user@example.com >')).toBe(
118+
'Email me at &lt; user@example.com &gt;'
119+
);
120+
});
121+
122+
it('should handle mathematical expressions', () => {
123+
expect(sanitizeTextForRender('if (x < y && y > z)')).toBe(
124+
'if (x &lt; y && y &gt; z)'
125+
);
126+
expect(sanitizeTextForRender('array[i] < array[j]')).toBe(
127+
'array[i] &lt; array[j]'
128+
);
129+
});
130+
131+
it('should handle HTML entities within valid tags', () => {
132+
expect(sanitizeTextForRender('<div>&lt;escaped&gt;</div>')).toBe(
133+
'<div>&lt;escaped&gt;</div>'
134+
);
135+
expect(sanitizeTextForRender('<span>already &amp; escaped</span>')).toBe(
136+
'<span>already &amp; escaped</span>'
137+
);
138+
});
139+
140+
it('should handle complex real-world email content', () => {
141+
const emailContent = `Hello,\n\nThe price is < $50 for items where quantity > 10.\n<p>Best regards,</p>\n<strong>Sales Team</strong>`;
142+
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>`;
143+
expect(sanitizeTextForRender(emailContent)).toBe(expected);
144+
});
145+
146+
it('should handle quoted email content', () => {
147+
const quoted = `Original message:\n> User wrote: x < 5\n<blockquote>Previous reply</blockquote>`;
148+
const expected = `Original message:<br>&gt; User wrote: x &lt; 5<br><blockquote>Previous reply</blockquote>`;
149+
expect(sanitizeTextForRender(quoted)).toBe(expected);
150+
});
151+
152+
it('should handle self-closing tags correctly', () => {
153+
expect(sanitizeTextForRender('<br />')).toBe('<br />');
154+
expect(sanitizeTextForRender('<img src="test.jpg" />')).toBe(
155+
'<img src="test.jpg" />'
156+
);
157+
expect(sanitizeTextForRender('<input type="text" value="test" />')).toBe(
158+
'<input type="text" value="test" />'
159+
);
160+
expect(sanitizeTextForRender('<hr/>')).toBe('<hr/>');
161+
expect(sanitizeTextForRender('Text before <br /> text after')).toBe(
162+
'Text before <br /> text after'
163+
);
164+
expect(sanitizeTextForRender('<meta charset="UTF-8" />')).toBe(
165+
'<meta charset="UTF-8" />'
166+
);
167+
});
168+
169+
it('should handle complex URLs in attributes', () => {
170+
expect(
171+
sanitizeTextForRender(
172+
'<img src="https://example.com/image.jpg?width=100&height=200&format=webp" />'
173+
)
174+
).toBe(
175+
'<img src="https://example.com/image.jpg?width=100&height=200&format=webp" />'
176+
);
177+
expect(
178+
sanitizeTextForRender(
179+
'<a href="https://api.example.com/v2/users/123/profile?include=posts&sort=desc">Profile</a>'
180+
)
181+
).toBe(
182+
'<a href="https://api.example.com/v2/users/123/profile?include=posts&sort=desc">Profile</a>'
183+
);
184+
expect(
185+
sanitizeTextForRender(
186+
'<iframe src="//cdn.example.com/embed/video/12345?autoplay=1&loop=0" />'
187+
)
188+
).toBe(
189+
'<iframe src="//cdn.example.com/embed/video/12345?autoplay=1&loop=0" />'
190+
);
191+
});
192+
});

0 commit comments

Comments
 (0)