Skip to content

Commit b8e2b69

Browse files
committed
Fix stripHtml function to properly strip HTML but leave non-HTML angle brackets
1 parent 5ad5dcf commit b8e2b69

2 files changed

Lines changed: 231 additions & 8 deletions

File tree

lib/utils.js

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,17 +175,122 @@ function reresolve (node, baseurl) {
175175
}
176176
exports.reresolve = reresolve;
177177

178+
var HTML_TAGS = new Set([
179+
'a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio',
180+
'b', 'base', 'basefont', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button',
181+
'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup',
182+
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt',
183+
'em', 'embed',
184+
'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset',
185+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
186+
'i', 'iframe', 'img', 'input', 'ins', 'isindex',
187+
'kbd',
188+
'label', 'legend', 'li', 'link', 'listing',
189+
'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meta', 'meter', 'multicol',
190+
'nav', 'nextid', 'nobr', 'noembed', 'noframes', 'noscript',
191+
'object', 'ol', 'optgroup', 'option', 'output',
192+
'p', 'param', 'picture', 'plaintext', 'pre', 'progress',
193+
'q',
194+
'rb', 'rp', 'rt', 'rtc', 'ruby',
195+
's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup',
196+
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th',
197+
'thead', 'time', 'title', 'tr', 'track', 'tt',
198+
'u', 'ul',
199+
'var', 'video',
200+
'wbr',
201+
'xmp'
202+
]);
203+
204+
/*
205+
* Scan markup starting at str[i] (which must be '<') and return its length
206+
* and type if it is recognized markup, or null if it isn't.
207+
* Recognized types:
208+
* { alwaysStrip: true, len } - comments, doctypes, PIs
209+
* { tagName, len } - opening or closing HTML tags
210+
*
211+
* Respects quoted attribute values so that an attribute like title="1 > 0"
212+
* doesn't cause a premature close.
213+
*
214+
* @param {string} str
215+
* @param {number} i
216+
* @returns {Object|null}
217+
* @private
218+
*/
219+
function readMarkupAt (str, i) {
220+
// HTML comment: <!-- ... -->
221+
if (str.slice(i, i + 4) === '<!--') {
222+
var commentEnd = str.indexOf('-->', i + 4);
223+
return commentEnd !== -1 ? { alwaysStrip: true, len: commentEnd + 3 - i } : null;
224+
}
225+
226+
// Processing instruction: <? ... ?>
227+
if (str[i + 1] === '?') {
228+
var piEnd = str.indexOf('?>', i + 2);
229+
return piEnd !== -1 ? { alwaysStrip: true, len: piEnd + 2 - i } : null;
230+
}
231+
232+
// Doctype / other <! declarations: <! ... >
233+
if (str[i + 1] === '!') {
234+
var declEnd = str.indexOf('>', i + 2);
235+
return declEnd !== -1 ? { alwaysStrip: true, len: declEnd + 1 - i } : null;
236+
}
237+
238+
// Closing tag or opening tag: </tagName ...> or <tagName ...>
239+
var isClosing = str[i + 1] === '/';
240+
var j = isClosing ? i + 2 : i + 1;
241+
var nameStart = j;
242+
while (j < str.length) {
243+
var code = str.charCodeAt(j);
244+
var isLetter = (code >= 97 && code <= 122) || (code >= 65 && code <= 90);
245+
var isDigit = code >= 48 && code <= 57;
246+
if (j === nameStart ? !isLetter : !(isLetter || isDigit)) break;
247+
j++;
248+
}
249+
var tagName = str.slice(nameStart, j).toLowerCase();
250+
if (!tagName) return null;
251+
252+
// Scan for >, respecting quoted attribute values
253+
var quote = null;
254+
while (j < str.length) {
255+
var ch = str[j];
256+
if (quote) {
257+
if (ch === quote) quote = null;
258+
} else if (ch === '"' || ch === '\'') {
259+
quote = ch;
260+
} else if (ch === '>') {
261+
return { tagName: tagName, len: j + 1 - i };
262+
}
263+
j++;
264+
}
265+
return null; // unclosed tag
266+
}
267+
178268
/*
179-
* Aggressivly strip HTML tags
180-
* Pulled out of node-resanitize because it was all that was being used
181-
* and it's way lighter...
269+
* Strip HTML tags, leaving bare text content.
270+
* Scans the string for markup - HTML tags, comments, doctypes, and processing
271+
* instructions - and removes them. Only tags with known HTML element names
272+
* are stripped; unknown angle-bracket content like <<<NotHTML>>> is preserved.
182273
*
183274
* @param {string} str
184275
* @returns {string}
185276
* @private
186277
*/
187278
function stripHtml (str) {
188-
return str.replace(/<.*?>/g, '');
279+
var out = '';
280+
var i = 0;
281+
while (i < str.length) {
282+
if (str[i] === '<') {
283+
var markup = readMarkupAt(str, i);
284+
if (markup && (markup.alwaysStrip || HTML_TAGS.has(markup.tagName))) {
285+
i += markup.len;
286+
continue;
287+
}
288+
}
289+
out += str[i];
290+
i++;
291+
}
292+
return out;
189293
}
190294

295+
exports.HTML_TAGS = HTML_TAGS;
191296
exports.stripHtml = stripHtml;

test/utils.js

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,14 @@ describe('utils', function () {
307307

308308
describe('stripHtml', function () {
309309

310+
it('returns the string unchanged when there are no tags', function () {
311+
assert.strictEqual(utils.stripHtml('plain text'), 'plain text');
312+
});
313+
314+
it('returns an empty string for an empty string', function () {
315+
assert.strictEqual(utils.stripHtml(''), '');
316+
});
317+
310318
it('removes simple HTML tags', function () {
311319
assert.strictEqual(utils.stripHtml('<b>bold</b>'), 'bold');
312320
});
@@ -323,12 +331,122 @@ describe('utils', function () {
323331
assert.strictEqual(utils.stripHtml('<a href="http://example.com">link</a>'), 'link');
324332
});
325333

326-
it('returns the string unchanged when there are no tags', function () {
327-
assert.strictEqual(utils.stripHtml('plain text'), 'plain text');
334+
it('strips nested tags leaving inner text', function () {
335+
assert.strictEqual(utils.stripHtml('<div>You <strong>MUST</strong> remove tags</div>'), 'You MUST remove tags');
328336
});
329337

330-
it('returns an empty string for an empty string', function () {
331-
assert.strictEqual(utils.stripHtml(''), '');
338+
it('strips a self-closing tag without trailing slash', function () {
339+
assert.strictEqual(utils.stripHtml('before<hr>after'), 'beforeafter');
340+
});
341+
342+
it('strips a self-closing tag with trailing slash', function () {
343+
assert.strictEqual(utils.stripHtml('before<br/>after'), 'beforeafter');
344+
});
345+
346+
it('strips a self-closing tag with attributes', function () {
347+
assert.strictEqual(utils.stripHtml('before<img src="x.png" alt="x">after'), 'beforeafter');
348+
});
349+
350+
it('strips img with self-closing slash and attributes', function () {
351+
assert.strictEqual(utils.stripHtml('before<img src="x.png" />after'), 'beforeafter');
352+
});
353+
354+
it('strips closing tags', function () {
355+
assert.strictEqual(utils.stripHtml('text</p>'), 'text');
356+
});
357+
358+
it('strips tags with multiple attributes', function () {
359+
assert.strictEqual(utils.stripHtml('<div class="foo" id="bar">content</div>'), 'content');
360+
});
361+
362+
// --- edge cases: non-HTML angle brackets must be preserved ---
363+
364+
it('preserves literal angle brackets that are not HTML tags', function () {
365+
assert.strictEqual(utils.stripHtml('1 < 2'), '1 < 2');
366+
});
367+
368+
it('preserves less-than followed by a number', function () {
369+
assert.strictEqual(utils.stripHtml('x<3'), 'x<3');
370+
});
371+
372+
it('preserves triple left angle brackets', function () {
373+
assert.strictEqual(utils.stripHtml('<<<'), '<<<');
374+
});
375+
376+
it('preserves triple right angle brackets', function () {
377+
assert.strictEqual(utils.stripHtml('>>>'), '>>>');
378+
});
379+
380+
it('preserves encoded angle brackets decoded by XML parser', function () {
381+
assert.strictEqual(utils.stripHtml('RSS <<<Tutorial>>>'), 'RSS <<<Tutorial>>>');
382+
});
383+
384+
it('does not treat unknown tag names as HTML', function () {
385+
assert.strictEqual(utils.stripHtml('a <foo> b'), 'a <foo> b');
386+
});
387+
388+
it('does not treat a closing tag with unknown name as HTML', function () {
389+
assert.strictEqual(utils.stripHtml('</foo>'), '</foo>');
390+
});
391+
392+
it('preserves bare angle brackets adjacent to real HTML tags', function () {
393+
assert.strictEqual(utils.stripHtml('<<b>bold</b>>'), '<bold>');
394+
});
395+
396+
it('strips real HTML but preserves non-HTML angle brackets in the same string', function () {
397+
assert.strictEqual(utils.stripHtml('if a < b then <em>yes</em> else x<y>z'), 'if a < b then yes else x<y>z');
398+
});
399+
400+
it('strips non-void elements written as self-closing like <div />', function () {
401+
assert.strictEqual(utils.stripHtml('<div />text</div>'), 'text');
402+
});
403+
404+
it('strips multiline tags spanning several lines', function () {
405+
assert.strictEqual(
406+
utils.stripHtml('<img \n src="logo.png" \n alt="Company Logo" \n width="200" \n height="100"\n>'),
407+
''
408+
);
409+
});
410+
411+
it('strips HTML comments', function () {
412+
assert.strictEqual(utils.stripHtml('before<!-- comment -->after'), 'beforeafter');
413+
});
414+
415+
it('strips multi-line HTML comments', function () {
416+
assert.strictEqual(utils.stripHtml('before<!-- a\ncomment -->after'), 'beforeafter');
417+
});
418+
419+
it('strips doctype declarations', function () {
420+
assert.strictEqual(utils.stripHtml('<!DOCTYPE html>Title'), 'Title');
421+
});
422+
423+
it('strips XML processing instructions', function () {
424+
assert.strictEqual(utils.stripHtml('<?xml version="1.0"?>text'), 'text');
425+
});
426+
427+
it('strips xml-stylesheet processing instructions', function () {
428+
assert.strictEqual(utils.stripHtml('<?xml-stylesheet href="style.xsl" type="text/xsl"?>text'), 'text');
429+
});
430+
431+
it('strips mixed HTML tags, comments, and processing instructions in one string', function () {
432+
assert.strictEqual(
433+
utils.stripHtml('<?xml version="1.0"?><p><!-- intro -->Hello <em>world</em></p>'),
434+
'Hello world'
435+
);
436+
});
437+
438+
it('strips tags with double-quoted attribute values containing >', function () {
439+
assert.strictEqual(utils.stripHtml('<a title="1 > 0">link</a>'), 'link');
440+
});
441+
442+
it('strips tags with single-quoted attribute values containing >', function () {
443+
assert.strictEqual(utils.stripHtml('<a title=\'1 > 0\'>link</a>'), 'link');
444+
});
445+
446+
utils.HTML_TAGS.forEach(function (tag) {
447+
it(`strips ${tag} HTML tag opening and closing and self-closing`, function () {
448+
assert.strictEqual(utils.stripHtml('<' + tag + '>content</' + tag + '> and <' + tag + ' />more'), 'content and more', 'expected <' + tag + '> to be stripped');
449+
});
332450
});
333451

334452
});

0 commit comments

Comments
 (0)