diff --git a/cjs/html/title-element.js b/cjs/html/title-element.js index f7f345e0..01d40611 100644 --- a/cjs/html/title-element.js +++ b/cjs/html/title-element.js @@ -1,17 +1,21 @@ 'use strict'; const {registerHTMLClass} = require('../shared/register-html-class.js'); +const {escapeHtmlTextContent} = require('../shared/text-escaper.js'); -const {TextElement} = require('./text-element.js'); +const {HTMLElement} = require('./element.js'); const tagName = 'title'; /** * @implements globalThis.HTMLTitleElement */ -class HTMLTitleElement extends TextElement { +class HTMLTitleElement extends HTMLElement { constructor(ownerDocument, localName = tagName) { super(ownerDocument, localName); } + + get innerHTML() { return super.innerHTML; } + set innerHTML(html) { super.innerHTML = escapeHtmlTextContent(html); } } registerHTMLClass(tagName, HTMLTitleElement); diff --git a/cjs/interface/attr.js b/cjs/interface/attr.js index 65af5bac..303eea19 100644 --- a/cjs/interface/attr.js +++ b/cjs/interface/attr.js @@ -4,14 +4,12 @@ const {CHANGED, VALUE} = require('../shared/symbols.js'); const {String, ignoreCase} = require('../shared/utils.js'); const {attrAsJSON} = require('../shared/jsdon.js'); const {emptyAttributes} = require('../shared/attributes.js'); +const {escapeHtmlAttributeValue, escapeXmlAttributeValue} = require('../shared/text-escaper.js'); const {attributeChangedCallback: moAttributes} = require('./mutation-observer.js'); const {attributeChangedCallback: ceAttributes} = require('./custom-element-registry.js'); const {Node} = require('./node.js'); -const {escape} = require('../shared/text-escaper.js'); - -const QUOTE = /"/g; /** * @implements globalThis.Attr @@ -46,7 +44,7 @@ class Attr extends Node { if (emptyAttributes.has(name) && !value) { return ignoreCase(this) ? name : `${name}=""`; } - const escapedValue = (ignoreCase(this) ? value : escape(value)).replace(QUOTE, '"'); + const escapedValue = ignoreCase(this) ? escapeHtmlAttributeValue(value) : escapeXmlAttributeValue(value); return `${name}="${escapedValue}"`; } diff --git a/cjs/interface/element.js b/cjs/interface/element.js index 34cb95ba..6d32159f 100644 --- a/cjs/interface/element.js +++ b/cjs/interface/element.js @@ -48,7 +48,6 @@ const {ShadowRoot} = require('./shadow-root.js'); const {NodeList} = require('./node-list.js'); const {Attr} = require('./attr.js'); const {Text} = require('./text.js'); -const {escape} = require('../shared/text-escaper.js'); // const attributesHandler = { @@ -228,7 +227,7 @@ class Element extends ParentNode { if (name === 'class') return this.className; const attribute = this.getAttributeNode(name); - return attribute && (ignoreCase(this) ? attribute.value : escape(attribute.value)); + return attribute && attribute.value; } getAttributeNode(name) { diff --git a/cjs/interface/text.js b/cjs/interface/text.js index 241511a1..9bcd8875 100644 --- a/cjs/interface/text.js +++ b/cjs/interface/text.js @@ -1,7 +1,8 @@ 'use strict'; const {TEXT_NODE} = require('../shared/constants.js'); const {VALUE} = require('../shared/symbols.js'); -const {escape} = require('../shared/text-escaper.js'); +const {escapeHtmlTextContent, escapeXmlTextContent} = require('../shared/text-escaper.js'); +const {ignoreCase} = require('../shared/utils.js'); const {CharacterData} = require('./character-data.js'); @@ -39,6 +40,6 @@ class Text extends CharacterData { return new Text(ownerDocument, data); } - toString() { return escape(this[VALUE]); } + toString() { return ignoreCase(this) ? escapeHtmlTextContent(this[VALUE]) : escapeXmlTextContent(this[VALUE]); } } exports.Text = Text diff --git a/cjs/shared/text-escaper.js b/cjs/shared/text-escaper.js index 1d4cbd7a..2f356eed 100644 --- a/cjs/shared/text-escaper.js +++ b/cjs/shared/text-escaper.js @@ -1,24 +1,61 @@ 'use strict'; const {replace} = ''; -// escape -const ca = /[<>&\xA0]/g; +const htmlAttributeValueCharacters = /["&<>\xA0]/g; +const xmlAttributeValueCharacters = /[\t\n\r"&<>]/g; -const esca = { - '\xA0': ' ', +const htmlTextContentCharacters = /[&<>\xA0]/g; +const xmlTextContentCharacters = /[&<>]/g; + +const characterEntities = { + '\t': ' ', + '\n': ' ', + '\r': ' ', + '"': '"', '&': '&', '<': '<', - '>': '>' + '>': '>', + '\xA0': ' ' }; -const pe = m => esca[m]; +const replaceCharacterByEntity = character => characterEntities[character]; + +/** + * Safely escape HTML entities such as `"`, `&`, `<`, `>` and U+00A0 NO-BREAK SPACE only. + * @param {string} value the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +const escapeHtmlAttributeValue = value => replace.call(value, htmlAttributeValueCharacters, replaceCharacterByEntity); +exports.escapeHtmlAttributeValue = escapeHtmlAttributeValue; + +/** + * Safely escape XML entities such as `\t`, `\n`, `\r`, `"`, `&`, `<` and `>` only. + * @param {string} value the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +const escapeXmlAttributeValue = value => replace.call(value, xmlAttributeValueCharacters, replaceCharacterByEntity); +exports.escapeXmlAttributeValue = escapeXmlAttributeValue; + +/** + * Safely escape HTML entities such as `&`, `<`, `>` and U+00A0 NO-BREAK SPACE only. + * @param {string} content the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +const escapeHtmlTextContent = content => replace.call(content, htmlTextContentCharacters, replaceCharacterByEntity); +exports.escapeHtmlTextContent = escapeHtmlTextContent; /** - * Safely escape HTML entities such as `&`, `<`, `>` only. - * @param {string} es the input to safely escape + * Safely escape XML entities such as `&`, `<` and `>` only. + * @param {string} content the input to safely escape * @returns {string} the escaped input, and it **throws** an error if * the input type is unexpected, except for boolean and numbers, * converted as string. */ -const escape = es => replace.call(es, ca, pe); -exports.escape = escape; +const escapeXmlTextContent = content => replace.call(content, xmlTextContentCharacters, replaceCharacterByEntity); +exports.escapeXmlTextContent = escapeXmlTextContent; diff --git a/esm/html/title-element.js b/esm/html/title-element.js index 5f35471e..ad4c67e7 100644 --- a/esm/html/title-element.js +++ b/esm/html/title-element.js @@ -1,16 +1,20 @@ import {registerHTMLClass} from '../shared/register-html-class.js'; +import {escapeHtmlTextContent} from '../shared/text-escaper.js'; -import {TextElement} from './text-element.js'; +import {HTMLElement} from './element.js'; const tagName = 'title'; /** * @implements globalThis.HTMLTitleElement */ -class HTMLTitleElement extends TextElement { +class HTMLTitleElement extends HTMLElement { constructor(ownerDocument, localName = tagName) { super(ownerDocument, localName); } + + get innerHTML() { return super.innerHTML; } + set innerHTML(html) { super.innerHTML = escapeHtmlTextContent(html); } } registerHTMLClass(tagName, HTMLTitleElement); diff --git a/esm/interface/attr.js b/esm/interface/attr.js index 6c617f69..bd01524f 100644 --- a/esm/interface/attr.js +++ b/esm/interface/attr.js @@ -3,14 +3,12 @@ import {CHANGED, VALUE} from '../shared/symbols.js'; import {String, ignoreCase} from '../shared/utils.js'; import {attrAsJSON} from '../shared/jsdon.js'; import {emptyAttributes} from '../shared/attributes.js'; +import {escapeHtmlAttributeValue, escapeXmlAttributeValue} from '../shared/text-escaper.js'; import {attributeChangedCallback as moAttributes} from './mutation-observer.js'; import {attributeChangedCallback as ceAttributes} from './custom-element-registry.js'; import {Node} from './node.js'; -import {escape} from '../shared/text-escaper.js'; - -const QUOTE = /"/g; /** * @implements globalThis.Attr @@ -45,7 +43,7 @@ export class Attr extends Node { if (emptyAttributes.has(name) && !value) { return ignoreCase(this) ? name : `${name}=""`; } - const escapedValue = (ignoreCase(this) ? value : escape(value)).replace(QUOTE, '"'); + const escapedValue = ignoreCase(this) ? escapeHtmlAttributeValue(value) : escapeXmlAttributeValue(value); return `${name}="${escapedValue}"`; } diff --git a/esm/interface/element.js b/esm/interface/element.js index e7f927e1..3e3c824a 100644 --- a/esm/interface/element.js +++ b/esm/interface/element.js @@ -50,7 +50,6 @@ import {ShadowRoot} from './shadow-root.js'; import {NodeList} from './node-list.js'; import {Attr} from './attr.js'; import {Text} from './text.js'; -import {escape} from '../shared/text-escaper.js'; // const attributesHandler = { @@ -230,7 +229,7 @@ export class Element extends ParentNode { if (name === 'class') return this.className; const attribute = this.getAttributeNode(name); - return attribute && (ignoreCase(this) ? attribute.value : escape(attribute.value)); + return attribute && attribute.value; } getAttributeNode(name) { diff --git a/esm/interface/text.js b/esm/interface/text.js index a07a8f3a..cc13f1f0 100644 --- a/esm/interface/text.js +++ b/esm/interface/text.js @@ -1,6 +1,7 @@ import {TEXT_NODE} from '../shared/constants.js'; import {VALUE} from '../shared/symbols.js'; -import {escape} from '../shared/text-escaper.js'; +import {escapeHtmlTextContent, escapeXmlTextContent} from '../shared/text-escaper.js'; +import {ignoreCase} from '../shared/utils.js'; import {CharacterData} from './character-data.js'; @@ -38,5 +39,5 @@ export class Text extends CharacterData { return new Text(ownerDocument, data); } - toString() { return escape(this[VALUE]); } + toString() { return ignoreCase(this) ? escapeHtmlTextContent(this[VALUE]) : escapeXmlTextContent(this[VALUE]); } } diff --git a/esm/shared/text-escaper.js b/esm/shared/text-escaper.js index 1489cd0c..213a678b 100644 --- a/esm/shared/text-escaper.js +++ b/esm/shared/text-escaper.js @@ -1,22 +1,56 @@ const {replace} = ''; -// escape -const ca = /[<>&\xA0]/g; +const htmlAttributeValueCharacters = /["&<>\xA0]/g; +const xmlAttributeValueCharacters = /[\t\n\r"&<>]/g; -const esca = { - '\xA0': ' ', +const htmlTextContentCharacters = /[&<>\xA0]/g; +const xmlTextContentCharacters = /[&<>]/g; + +const characterEntities = { + '\t': ' ', + '\n': ' ', + '\r': ' ', + '"': '"', '&': '&', '<': '<', - '>': '>' + '>': '>', + '\xA0': ' ' }; -const pe = m => esca[m]; +const replaceCharacterByEntity = character => characterEntities[character]; + +/** + * Safely escape HTML entities such as `"`, `&`, `<`, `>` and U+00A0 NO-BREAK SPACE only. + * @param {string} value the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +export const escapeHtmlAttributeValue = value => replace.call(value, htmlAttributeValueCharacters, replaceCharacterByEntity); + +/** + * Safely escape XML entities such as `\t`, `\n`, `\r`, `"`, `&`, `<` and `>` only. + * @param {string} value the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +export const escapeXmlAttributeValue = value => replace.call(value, xmlAttributeValueCharacters, replaceCharacterByEntity); + +/** + * Safely escape HTML entities such as `&`, `<`, `>` and U+00A0 NO-BREAK SPACE only. + * @param {string} content the input to safely escape + * @returns {string} the escaped input, and it **throws** an error if + * the input type is unexpected, except for boolean and numbers, + * converted as string. + */ +export const escapeHtmlTextContent = content => replace.call(content, htmlTextContentCharacters, replaceCharacterByEntity); /** - * Safely escape HTML entities such as `&`, `<`, `>` only. - * @param {string} es the input to safely escape + * Safely escape XML entities such as `&`, `<` and `>` only. + * @param {string} content the input to safely escape * @returns {string} the escaped input, and it **throws** an error if * the input type is unexpected, except for boolean and numbers, * converted as string. */ -export const escape = es => replace.call(es, ca, pe); +export const escapeXmlTextContent = content => replace.call(content, xmlTextContentCharacters, replaceCharacterByEntity); diff --git a/test/html/anchor-element.js b/test/html/anchor-element.js index 1372b173..dbaee808 100644 --- a/test/html/anchor-element.js +++ b/test/html/anchor-element.js @@ -6,9 +6,9 @@ const {document} = parseHTML('click me< const {lastElementChild: a} = document; -assert(a.toString(), 'click me'); +assert(a.toString(), 'click me'); a.setAttribute('href', 'https://google.com/?q=1&page=2&test="'); -assert(a.toString(), 'click me'); +assert(a.toString(), 'click me'); a.setAttribute('href', 'https://google.com/?q=asd&lol=<2>"'); assert(a.href, 'https://google.com/?q=asd&lol=%3C2%3E%22'); a.setAttribute('href', 'https://google.com/path%20to%20some%20file.pdf'); diff --git a/test/html/document.js b/test/html/document.js index 524d1361..d165c896 100644 --- a/test/html/document.js +++ b/test/html/document.js @@ -45,7 +45,7 @@ document.title = 'I'; assert(document.title + document.title + document.title, 'III', 'side-effects detected when inspecting the title'); document.title = '&'; -assert(document.toString(), '&'); +assert(document.toString(), '&'); assert(document.all.length, 4); assert(document.all[0], document.querySelector('html')); diff --git a/test/html/i-frame-element.js b/test/html/i-frame-element.js index cc6fb5af..f68c13a9 100644 --- a/test/html/i-frame-element.js +++ b/test/html/i-frame-element.js @@ -16,7 +16,7 @@ assert(iframe.src, './test.html', 'Issue #82 - ` + `` ); } @@ -50,7 +50,7 @@ assert(iframe.src, './test.html', 'Issue #82 - ` - ); + ); const iframe = document.body.querySelector("iframe"); assert(iframe.allowFullscreen, false); @@ -58,4 +58,4 @@ assert(iframe.src, './test.html', 'Issue #82 -