diff --git a/lib/escape_html.ts b/lib/escape_html.ts index 404cf530..77e3655d 100644 --- a/lib/escape_html.ts +++ b/lib/escape_html.ts @@ -1,25 +1,146 @@ -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; } - 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); + } + } + + 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; + } + 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; + } + + 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; diff --git a/test/escape_html.spec.ts b/test/escape_html.spec.ts index 5f4528f5..86e44205 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,10 +12,14 @@ 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', () => { escapeHTML('   ').should.eql('   '); }); + + it('proper escape', () => { + escapeHTML('&0').should.eql('&0'); + }); });