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 -