From 058e98b815064c0d5c701b8d8707465273189f23 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sat, 31 May 2025 18:48:08 +0800 Subject: [PATCH 1/6] perf: faster escape html & unescape html --- lib/escape_html.ts | 122 +++++++++++++++++++++++++++++++++++++------ lib/unescape_html.ts | 40 +++++++++----- 2 files changed, 133 insertions(+), 29 deletions(-) diff --git a/lib/escape_html.ts b/lib/escape_html.ts index 404cf530..e6f3fdcd 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -1,25 +1,113 @@ -const escapeTestNoEncode = /[<>"'`/=]|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/; -const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g'); -const escapeReplacements = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - '\'': ''', - '`': '`', - '/': '/', - '=': '=' -}; -const getEscapeReplacement = (ch: string) => escapeReplacements[ch]; +/** + * fast-escape-html - MIT License - Made by SukkaW + * The fastest known HTML unescape function. + * https://github.com/SukkaW/fast-escape-html + * https://github.com/SukkaW/fast-escape-html/blob/1bb80ac857f0645b321761cbd0dc0d0098240407/src/unescape.ts + * + * This is also modified by SukkaW for use w/ Hexo. Hexo needs to escape more characters (=, /, `) + * to work with template languages (nunjucks/pug/mustache), and also needs to avoid double escaping + * HTML entities. After modification, this function is of course slower than `fast-escape-html`, but + * is still faster than `lodash.escape` and `escape-goat` (where they even escape less symbols and do + * not avoid double escaping). + */ +const reHtmlEntity = /[&<>"'`/=]/; function escapeHTML(str: string) { if (typeof str !== 'string') throw new TypeError('str must be a string!'); - // https://github.com/markedjs/marked/blob/master/src/helpers.js - if (escapeTestNoEncode.test(str)) { - return str.replace(escapeReplaceNoEncode, getEscapeReplacement); + // if (rAlreadyEscaped.test(str)) { + // // If the string is already escaped, return it as is + // return str; + // } + + const match = reHtmlEntity.exec(str); + + if (match === null) { // faster than !match since no type conversion + return str; + } + + let escape = ''; + let html = ''; + + let index = match.index; + let lastIndex = 0; + const len = str.length; + + + let next = 0; + let nextIndex = index; + + // iterate from the first match + for (; index < len; index++) { + + /** + * Adjust order for commonly seen symbols: + * Take https://tc39.es/ecma262 as an example + */ + switch (str.charCodeAt(index)) { + case 60: // < + escape = '<'; + break; + case 62: // > + escape = '>'; + break; + case 34: // " + escape = '"'; + break; + case 39: // ' + escape = '''; + break; + case 38: { // & + // We need to skip already escaped entities + // But instead of matching with regexp, we manually check the char code + // https://github.com/markedjs/marked/blob/cb549065f16fbd4a01bab3292bfd2ab0b116c1b2/src/helpers.ts#L10 + nextIndex = index + 1; + next = str.charCodeAt(nextIndex); + if (next === 35) { // #, whether the it is "&#" combined + nextIndex++; + next = str.charCodeAt(nextIndex); + if (next === 120 || next === 88) { // x or X, whether the it is "&#x" combined + nextIndex++; + next = str.charCodeAt(nextIndex); + } + } + if ( // check whether it is /&#\w+/ or /&#x\w+/ + (next >= 48 && next <= 57) // 0-9 + || (next >= 97 && next <= 122) // a-z + || (next >= 65 && next <= 90) // A-Z + ) { // 0-9 + index = nextIndex + 1; // we already look ahead, let nextIndex catch up + continue; + } + escape = '&'; + break; + } + case 96: // ` + escape = '`'; + break; + case 47: // / + escape = '/'; + break; + case 61: // = + escape = '='; + break; + default: + continue; + } + + if (lastIndex !== index) { + html += str.slice(lastIndex, index); + } + html += escape; + + lastIndex = index + 1; } - return str; + + if (lastIndex !== index) { + html += str.slice(lastIndex, index); + } + + return html; } export = escapeHTML; diff --git a/lib/unescape_html.ts b/lib/unescape_html.ts index afa54c61..70828f4a 100644 --- a/lib/unescape_html.ts +++ b/lib/unescape_html.ts @@ -1,20 +1,36 @@ -const htmlEntityMap = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': '\'', - '`': '`', - '/': '/', - '=': '=' -}; +/** + * fast-escape-html - MIT License - Made by SukkaW + * The fastest known HTML unescape function. + * https://github.com/SukkaW/fast-escape-html + * https://github.com/SukkaW/fast-escape-html/blob/1bb80ac857f0645b321761cbd0dc0d0098240407/src/unescape.ts + * + * This is also modified by SukkaW for use w/ Hexo. Hexo needs to unescape more characters, but I managed + * to adopt a few techniques from `fast-escape-html` to make this function faster than before + */ + +// Specifically uses `Object.create(null)` to make lookup faster (no prototype chain lookup) +const htmlEntityMap = Object.create(null); + +// Common HTML entities is placed first for faster lookup +htmlEntityMap['<'] = '<'; +htmlEntityMap['>'] = '>'; +htmlEntityMap['"'] = '"'; +htmlEntityMap['''] = '\''; +htmlEntityMap['='] = '='; +htmlEntityMap['/'] = '/'; +htmlEntityMap['&'] = '&'; +htmlEntityMap['`'] = '`'; + +// This is specifically hand-crafted regexp to match common HTML entities first (for early return) +const reHtmlEntityGlobal = /&(?:[gl]t|quot|#39|#x(?:3D|2F)|amp|#6[02]|#34|apos|#38|#96);/g; -const regexHtml = new RegExp(Object.keys(htmlEntityMap).join('|'), 'g'); +// Hoist function to maximize the function cache +const replacer = (match: string) => htmlEntityMap[match]; const unescapeHTML = (str: string) => { if (typeof str !== 'string') throw new TypeError('str must be a string!'); - return str.replace(regexHtml, a => htmlEntityMap[a]); + return str.replace(reHtmlEntityGlobal, replacer); }; export = unescapeHTML; From 7164b01bb6416b4e2a624f9f927d72025cc4883f Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sat, 31 May 2025 19:15:49 +0800 Subject: [PATCH 2/6] test(escape_html): improve coverage --- test/escape_html.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/escape_html.spec.ts b/test/escape_html.spec.ts index 5f4528f5..356f4203 100644 --- a/test/escape_html.spec.ts +++ b/test/escape_html.spec.ts @@ -4,7 +4,7 @@ chai.should(); describe('escapeHTML', () => { it('default', () => { - escapeHTML('

Hello "world".

').should.eql('<p class="foo">Hello "world".</p>'); + escapeHTML('

Hello `world`.

').should.eql('<p class="foo">Hello `world`.</p>'); }); it('str must be a string', () => { @@ -12,7 +12,7 @@ describe('escapeHTML', () => { }); it('avoid double escape', () => { - escapeHTML('<foo>barbar { From a8893ef6e952f94259eb920e92698a143d559050 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sat, 31 May 2025 19:18:21 +0800 Subject: [PATCH 3/6] chore: make eslint happy --- lib/escape_html.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/escape_html.ts b/lib/escape_html.ts index e6f3fdcd..b9dd23a6 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -7,7 +7,7 @@ * This is also modified by SukkaW for use w/ Hexo. Hexo needs to escape more characters (=, /, `) * to work with template languages (nunjucks/pug/mustache), and also needs to avoid double escaping * HTML entities. After modification, this function is of course slower than `fast-escape-html`, but - * is still faster than `lodash.escape` and `escape-goat` (where they even escape less symbols and do + * is still faster than `lodash.escape` and `escape-goat` (where they even escape less symbols and do * not avoid double escaping). */ const reHtmlEntity = /[&<>"'`/=]/; From 0e16484fda938bc5c4a61ce8a46ba37b8d837319 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sat, 31 May 2025 19:22:04 +0800 Subject: [PATCH 4/6] fix(escape_html): an edge case --- lib/escape_html.ts | 2 +- test/escape_html.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/escape_html.ts b/lib/escape_html.ts index b9dd23a6..6d6c9104 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -76,7 +76,7 @@ function escapeHTML(str: string) { || (next >= 97 && next <= 122) // a-z || (next >= 65 && next <= 90) // A-Z ) { // 0-9 - index = nextIndex + 1; // we already look ahead, let nextIndex catch up + index = nextIndex; // we skip already looked up continue; } escape = '&'; diff --git a/test/escape_html.spec.ts b/test/escape_html.spec.ts index 356f4203..231f1f77 100644 --- a/test/escape_html.spec.ts +++ b/test/escape_html.spec.ts @@ -12,7 +12,7 @@ describe('escapeHTML', () => { }); it('avoid double escape', () => { - escapeHTML('<foo>barbar').should.eql('<foo>bar</foo>/|&6>'); }); it('avoid double escape https://github.com/hexojs/hexo/issues/4946', () => { From 29f074fddde6cec74fc3571d8f1b52696c33310c Mon Sep 17 00:00:00 2001 From: SukkaW Date: Tue, 10 Jun 2025 23:35:19 +0800 Subject: [PATCH 5/6] fix(escape_html): more edge cases --- lib/escape_html.ts | 43 +++++++++++++++++++++++++++++++++++----- test/escape_html.spec.ts | 6 +++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/lib/escape_html.ts b/lib/escape_html.ts index 6d6c9104..f20b5900 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -71,14 +71,47 @@ function escapeHTML(str: string) { next = str.charCodeAt(nextIndex); } } - if ( // check whether it is /&#\w+/ or /&#x\w+/ - (next >= 48 && next <= 57) // 0-9 - || (next >= 97 && next <= 122) // a-z - || (next >= 65 && next <= 90) // A-Z - ) { // 0-9 + + let breakout = false; + + console.log({ + index, + char: str.charAt(index), + nextIndex, + next, + nextChar: str.charAt(nextIndex), + }) + + while ( + nextIndex < len && ( + (next >= 48 && next <= 57) // 0-9 + || (next >= 97 && next <= 122) // a-z + || (next >= 65 && next <= 90) // A-Z + ) + ) { + nextIndex++; + next = str.charCodeAt(nextIndex); + + console.log({ + index, + char: str.charAt(index), + nextIndex, + next, + nextChar: str.charAt(nextIndex), + }) + + if (next === 59) { // ; + breakout = true; + break; + } + } + + if (breakout) { + // If we found a semicolon, we can skip the rest of the loop index = nextIndex; // we skip already looked up continue; } + escape = '&'; break; } diff --git a/test/escape_html.spec.ts b/test/escape_html.spec.ts index 231f1f77..679a27f7 100644 --- a/test/escape_html.spec.ts +++ b/test/escape_html.spec.ts @@ -12,10 +12,14 @@ describe('escapeHTML', () => { }); it('avoid double escape', () => { - escapeHTML('<foo>bar').should.eql('<foo>bar</foo>/|&6>'); + escapeHTML('<foo>bar').should.eql('<foo>bar</foo>/|&6>'); }); it('avoid double escape https://github.com/hexojs/hexo/issues/4946', () => { escapeHTML('   ').should.eql('   '); }); + + it('proper escape', () => { + escapeHTML('&0').should.eql('&0'); + }) }); From dfcfcdcc561b26b674d1ad4f6a34b01c34820ee7 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Tue, 10 Jun 2025 23:36:59 +0800 Subject: [PATCH 6/6] chore: make eslint happy --- lib/escape_html.ts | 8 ++++---- test/escape_html.spec.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/escape_html.ts b/lib/escape_html.ts index f20b5900..77e3655d 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -79,8 +79,8 @@ function escapeHTML(str: string) { char: str.charAt(index), nextIndex, next, - nextChar: str.charAt(nextIndex), - }) + nextChar: str.charAt(nextIndex) + }); while ( nextIndex < len && ( @@ -97,8 +97,8 @@ function escapeHTML(str: string) { char: str.charAt(index), nextIndex, next, - nextChar: str.charAt(nextIndex), - }) + nextChar: str.charAt(nextIndex) + }); if (next === 59) { // ; breakout = true; diff --git a/test/escape_html.spec.ts b/test/escape_html.spec.ts index 679a27f7..86e44205 100644 --- a/test/escape_html.spec.ts +++ b/test/escape_html.spec.ts @@ -21,5 +21,5 @@ describe('escapeHTML', () => { it('proper escape', () => { escapeHTML('&0').should.eql('&0'); - }) + }); });