mhtml-to-html
Version:
JavaScript library for parsing MHTML files and converting them into single HTML files
363 lines (331 loc) • 11.7 kB
JavaScript
import { parse, defaultTreeAdapter, html } from "parse5";
const { NS, TAG_NAMES, ATTRS } = html;
const VOID_TAG_NAMES = new Set([
TAG_NAMES.AREA,
TAG_NAMES.BASE,
TAG_NAMES.BASEFONT,
TAG_NAMES.BGSOUND,
TAG_NAMES.BR,
TAG_NAMES.COL,
TAG_NAMES.COMMAND,
TAG_NAMES.EMBED,
TAG_NAMES.FRAME,
TAG_NAMES.HR,
TAG_NAMES.IMG,
TAG_NAMES.INPUT,
TAG_NAMES.KEYGEN,
TAG_NAMES.LINK,
TAG_NAMES.META,
TAG_NAMES.PARAM,
TAG_NAMES.SOURCE,
TAG_NAMES.TRACK,
TAG_NAMES.WBR
]);
const RAW_TEXT_NODE_TAG_NAMES = new Set([
TAG_NAMES.STYLE,
TAG_NAMES.SCRIPT,
TAG_NAMES.XMP,
TAG_NAMES.IFRAME,
TAG_NAMES.NOEMBED,
TAG_NAMES.NOFRAMES,
TAG_NAMES.PLAINTEXT,
TAG_NAMES.NOSCRIPT
]);
const JAVASCRIPT_MIME_TYPE = "text/javascript";
const DOCTYPE_PROPERTY_NAME = "doctype";
const DOCUMENT_ELEMENT_PROPERTY_NAME = "documentElement";
const HEAD_PROPERTY_NAME = "head";
const FIRST_CHILD_PROPERTY_NAME = "firstChild";
const TEXT_CONTENT_PROPERTY_NAME = "textContent";
const OUTER_HTML_PROPERTY_NAME = "outerHTML";
const TEXT_NODE_NAME = "#text";
const COMMENT_NODE_NAME = "#comment";
const DOCTYPE_NODE_NAME = "#documentType";
const AMPERSAND_ENTITY = "&";
const QUOTE_ENTITY = """;
const NON_BREAKING_SPACE_ENTITY = " ";
const LESS_THAN_ENTITY = "<";
const GREATER_THAN_ENTITY = ">";
const XLINK_PREFIX = "xlink";
const XMLNS_PREFIX = "xmlns";
const XML_PREFIX = "xml";
const AMPERSAND_REGEXP = /&/g;
const QUOTE_REGEXP = /"/g;
const NON_BREAKING_SPACE_REGEXP = /\u00a0/g;
const LESS_THAN_REGEXP = /</g;
const GREATER_THAN_REGEXP = />/g;
const INVALID_TAG_NAME_REGEXP = /["'>/=]/;
const OPENING_TAG_MARKER = "<";
const CLOSING_ANGLE_BRACKET = ">";
const CLOSING_TAG_MARKER = "</";
const CLOSING_TAG_MARKER_REGEXP = /<\//gi;
const ESCAPED_CLOSING_TAG_MARKER = "<\\/";
const SELF_CLOSING_TAG_MARKER_REGEXP = /\/>/gi;
const ESCAPED_SELF_CLOSING_TAG_MARKER = "\\/>";
const COMMENT_START_MARKER = "<!--";
const COMMENT_END_MARKER = "-->";
const ATTRIBUTE_PREFIX_SEPARATOR = ":";
const ATTRIBUTE_VALUE_SEPARATOR = "=";
const EMPTY_STRING = "";
const SPACE_CHARACTER = " ";
const QUOTE_CHARACTER = "\"";
const treeAdapter = {
...defaultTreeAdapter,
createDocument() {
const document = defaultTreeAdapter.createDocument();
document.createElement = function (tagName) {
return treeAdapter.createElement(tagName, undefined, []);
};
document.createTextNode = function (data) {
return treeAdapter.createTextNode(data);
};
document.getElementsByTagName = getElementsByTagName;
Object.defineProperty(document, DOCTYPE_PROPERTY_NAME, {
get() {
const firstChild = treeAdapter.getFirstChild(this);
if (firstChild && firstChild.nodeName === DOCTYPE_NODE_NAME) {
return firstChild;
} else {
return undefined;
}
}
});
Object.defineProperty(document, DOCUMENT_ELEMENT_PROPERTY_NAME, {
get() {
return this.childNodes.find(node => testElement(node, TAG_NAMES.HTML));
}
});
Object.defineProperty(document, HEAD_PROPERTY_NAME, {
get() {
return this.documentElement.childNodes.find(node => testElement(node, TAG_NAMES.HEAD));
}
});
return document;
},
createElement(tagName, namespaceURI, attrs) {
const element = defaultTreeAdapter.createElement(tagName, namespaceURI, attrs);
element.getAttribute = getAttribute;
element.setAttribute = setAttribute;
element.removeAttribute = removeAttribute;
element.appendChild = appendChild;
element.remove = remove;
element.replaceWith = replaceWith;
element.prepend = prepend;
element.after = after;
element.getElementsByTagName = getElementsByTagName;
Object.defineProperty(element, FIRST_CHILD_PROPERTY_NAME, {
get() {
return treeAdapter.getFirstChild(this);
}
});
Object.defineProperty(element, TEXT_CONTENT_PROPERTY_NAME, {
get() {
return joinArray(this.childNodes.map(node => treeAdapter.getTextNodeContent(node)));
}
});
Object.defineProperty(element, OUTER_HTML_PROPERTY_NAME, {
get() {
return serialize(this);
}
});
return element;
}
};
export class DOMParser {
parseFromString(html) {
const document = parse(html, { treeAdapter });
if (!document.head) {
const headElement = document.createElement(TAG_NAMES.HEAD);
document.documentElement.prepend(headElement);
}
return document;
}
}
function getAttribute(name) {
const attribute = findAttribute(this, name);
if (attribute) {
return attribute.value;
}
}
function setAttribute(name, value) {
const indexAttribute = findIndexAttribute(this, name);
if (indexAttribute === -1) {
this.attrs.push({ name, value });
} else {
this.attrs[indexAttribute].value = value;
}
}
function removeAttribute(name) {
const indexAttribute = findIndexAttribute(this, name);
if (indexAttribute !== -1) {
this.attrs.splice(indexAttribute, 1);
}
}
function findAttribute(element, name) {
return element.attrs.find(attr => testAttributeName(attr, name));
}
function findIndexAttribute(element, name) {
return element.attrs.findIndex(attr => testAttributeName(attr, name));
}
function testAttributeName(attr, name) {
return testCaseInsensitive(attr.name, name);
}
function appendChild(child) {
return treeAdapter.appendChild(this, child);
}
function remove() {
if (this.parentNode) {
const indexNode = findIndexNode(this);
if (indexNode !== -1) {
this.parentNode.childNodes.splice(indexNode, 1);
this.parentNode = undefined;
}
}
}
function replaceWith(...nodes) {
if (this.parentNode) {
const indexNode = findIndexNode(this);
if (indexNode !== -1) {
const oldNodes = this.parentNode.childNodes.splice(indexNode, 1, ...nodes);
nodes.forEach(node => node.parentNode = this.parentNode);
oldNodes.forEach(node => node.parentNode = undefined);
}
}
}
function prepend(...nodes) {
this.childNodes.unshift(...nodes);
nodes.forEach(node => node.parentNode = this);
}
function after(...nodes) {
if (this.parentNode) {
const indexNode = findIndexNode(this);
if (indexNode !== -1) {
this.parentNode.childNodes.splice(indexNode + 1, 0, ...nodes);
nodes.forEach(node => node.parentNode = this.parentNode);
}
}
}
function getElementsByTagName(tagName) {
const elements = [];
this.childNodes.forEach(node => {
if (testElement(node, tagName)) {
elements.push(node);
}
if (node.getElementsByTagName) {
elements.push(...node.getElementsByTagName(tagName));
}
});
return elements;
}
function findIndexNode(node) {
return node.parentNode.childNodes.indexOf(node);
}
function testElement(node, name) {
return node.tagName && testCaseInsensitive(node.tagName, name);
}
function testCaseInsensitive(stringLeft, stringRight) {
return stringLeft.toLowerCase() === stringRight.toLowerCase();
}
function serialize(node) {
if (node.nodeName === TEXT_NODE_NAME) {
return serializeTextNode(node);
} else if (node.nodeName === COMMENT_NODE_NAME) {
return serializeCommentNode(node);
} else {
return serializeElement(node);
}
}
function serializeTextNode(textNode) {
if (shouldEscapeEntities(textNode)) {
return escapeEntitiesTextNode(textNode);
} else if (shouldEscapeTextNode(textNode)) {
return escapeTextNode(textNode);
} else {
return textNode.value;
}
}
function shouldEscapeEntities(textNode) {
const parentNodeTagName = textNode.parentNode.tagName && textNode.parentNode.tagName.toLowerCase();
return parentNodeTagName && !RAW_TEXT_NODE_TAG_NAMES.has(parentNodeTagName);
}
function shouldEscapeTextNode(textNode) {
const parentNode = textNode.parentNode;
const parentNodeTagName = textNode.parentNode.tagName && parentNode.tagName.toLowerCase();
let parentNodeType;
if (parentNode.attrs) {
parentNodeType = parentNode.getAttribute(ATTRS.TYPE);
}
return parentNodeTagName === TAG_NAMES.STYLE ||
(parentNodeTagName === TAG_NAMES.SCRIPT && (!parentNodeType || parentNodeType === JAVASCRIPT_MIME_TYPE));
}
function escapeTextNode(textNode) {
return textNode.value
.replace(CLOSING_TAG_MARKER_REGEXP, ESCAPED_CLOSING_TAG_MARKER)
.replace(SELF_CLOSING_TAG_MARKER_REGEXP, ESCAPED_SELF_CLOSING_TAG_MARKER);
}
function escapeEntitiesTextNode(textNode) {
return textNode.value
.replace(AMPERSAND_REGEXP, AMPERSAND_ENTITY)
.replace(NON_BREAKING_SPACE_REGEXP, NON_BREAKING_SPACE_ENTITY)
.replace(LESS_THAN_REGEXP, LESS_THAN_ENTITY)
.replace(GREATER_THAN_REGEXP, GREATER_THAN_ENTITY);
}
function serializeCommentNode(commentNode) {
return COMMENT_START_MARKER + commentNode.data + COMMENT_END_MARKER;
}
function serializeElement(element) {
let { tagName } = element;
tagName = tagName.toLowerCase();
let html = OPENING_TAG_MARKER + tagName;
const attributes = serializeAttributes(element);
if (attributes) {
html += SPACE_CHARACTER + attributes;
}
html += CLOSING_ANGLE_BRACKET;
html += serializeChildren(element);
if (!VOID_TAG_NAMES.has(tagName)) {
html += CLOSING_TAG_MARKER + tagName + CLOSING_ANGLE_BRACKET;
}
return html;
}
function serializeChildren(element) {
if (element.tagName.toLowerCase() === TAG_NAMES.TEMPLATE) {
return joinArray(element.content.childNodes.map(node => serialize(node)));
} else {
return joinArray(element.childNodes.map(node => serialize(node)));
}
}
function serializeAttributes(element) {
return joinArray(element.attrs.map(({ name, value, prefix, namespace }) => {
if (!name.match(INVALID_TAG_NAME_REGEXP)) {
value = escapeAttributeValue(value);
if (namespace === NS.XLINK) {
return serializeAttribute(name, value, XLINK_PREFIX);
} else if (namespace === NS.XML) {
return serializeAttribute(name, value, XML_PREFIX);
} else if (namespace === NS.XMLNS) {
return name === XMLNS_PREFIX ?
serializeAttribute(name, value) :
serializeAttribute(name, value, XMLNS_PREFIX);
} else {
return serializeAttribute(name, value, prefix);
}
}
}), SPACE_CHARACTER);
}
function escapeAttributeValue(value) {
return value
.replace(AMPERSAND_REGEXP, AMPERSAND_ENTITY)
.replace(NON_BREAKING_SPACE_REGEXP, NON_BREAKING_SPACE_ENTITY)
.replace(QUOTE_REGEXP, QUOTE_ENTITY);
}
function serializeAttribute(name, value, prefix) {
if (prefix) {
return prefix + ATTRIBUTE_PREFIX_SEPARATOR + name + ATTRIBUTE_VALUE_SEPARATOR + QUOTE_CHARACTER + value + QUOTE_CHARACTER;
} else {
return name + ATTRIBUTE_VALUE_SEPARATOR + QUOTE_CHARACTER + value + QUOTE_CHARACTER;
}
}
function joinArray(array, separator = EMPTY_STRING) {
return array.join(separator);
}