html-dom-parser
Version:
HTML to DOM parser.
569 lines (568 loc) • 16.7 kB
JavaScript
const require_Tokenizer = require("./Tokenizer.js");
//#region node_modules/htmlparser2/dist/Parser.js
const { fromCodePoint } = String;
const formTags = /* @__PURE__ */ new Set([
"input",
"option",
"optgroup",
"select",
"button",
"datalist",
"textarea"
]);
const pTag = /* @__PURE__ */ new Set(["p"]);
const headingTags = /* @__PURE__ */ new Set([
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p"
]);
const tableSectionTags = /* @__PURE__ */ new Set(["thead", "tbody"]);
const ddtTags = /* @__PURE__ */ new Set(["dd", "dt"]);
const rtpTags = /* @__PURE__ */ new Set(["rt", "rp"]);
const openImpliesClose = /* @__PURE__ */ new Map([
["tr", /* @__PURE__ */ new Set([
"tr",
"th",
"td"
])],
["th", /* @__PURE__ */ new Set(["th"])],
["td", /* @__PURE__ */ new Set([
"thead",
"th",
"td"
])],
["body", /* @__PURE__ */ new Set([
"head",
"link",
"script"
])],
["a", /* @__PURE__ */ new Set(["a"])],
["li", /* @__PURE__ */ new Set(["li"])],
["p", pTag],
["h1", headingTags],
["h2", headingTags],
["h3", headingTags],
["h4", headingTags],
["h5", headingTags],
["h6", headingTags],
["select", formTags],
["input", formTags],
["output", formTags],
["button", formTags],
["datalist", formTags],
["textarea", formTags],
["option", /* @__PURE__ */ new Set(["option"])],
["optgroup", /* @__PURE__ */ new Set(["optgroup", "option"])],
["dd", ddtTags],
["dt", ddtTags],
["address", pTag],
["article", pTag],
["aside", pTag],
["blockquote", pTag],
["details", pTag],
["div", pTag],
["dl", pTag],
["fieldset", pTag],
["figcaption", pTag],
["figure", pTag],
["footer", pTag],
["form", pTag],
["header", pTag],
["hr", pTag],
["main", pTag],
["nav", pTag],
["ol", pTag],
["pre", pTag],
["section", pTag],
["table", pTag],
["ul", pTag],
["rt", rtpTags],
["rp", rtpTags],
["tbody", tableSectionTags],
["tfoot", tableSectionTags]
]);
const DOCUMENT_TYPE = "doctype";
const voidElements = /* @__PURE__ */ new Set([
"area",
"base",
"basefont",
"br",
"col",
"command",
"embed",
"frame",
"hr",
"img",
"input",
"isindex",
"keygen",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]);
const foreignContextElements = /* @__PURE__ */ new Set(["math", "svg"]);
/**
* Elements that can be used to integrate HTML content within foreign namespaces (e.g., SVG or MathML).
*
* Entries must use the SVG-adjusted casing (e.g. "foreignObject" not
* "foreignobject") since they are compared against adjusted tag names.
*/
const htmlIntegrationElements = /* @__PURE__ */ new Set([
"mi",
"mo",
"mn",
"ms",
"mtext",
"annotation-xml",
"foreignObject",
"desc",
"title"
]);
const svgTagNameAdjustments = /* @__PURE__ */ new Map([
["altglyph", "altGlyph"],
["altglyphdef", "altGlyphDef"],
["altglyphitem", "altGlyphItem"],
["animatecolor", "animateColor"],
["animatemotion", "animateMotion"],
["animatetransform", "animateTransform"],
["clippath", "clipPath"],
["feblend", "feBlend"],
["fecolormatrix", "feColorMatrix"],
["fecomponenttransfer", "feComponentTransfer"],
["fecomposite", "feComposite"],
["feconvolvematrix", "feConvolveMatrix"],
["fediffuselighting", "feDiffuseLighting"],
["fedisplacementmap", "feDisplacementMap"],
["fedistantlight", "feDistantLight"],
["fedropshadow", "feDropShadow"],
["feflood", "feFlood"],
["fefunca", "feFuncA"],
["fefuncb", "feFuncB"],
["fefuncg", "feFuncG"],
["fefuncr", "feFuncR"],
["fegaussianblur", "feGaussianBlur"],
["feimage", "feImage"],
["femerge", "feMerge"],
["femergenode", "feMergeNode"],
["femorphology", "feMorphology"],
["feoffset", "feOffset"],
["fepointlight", "fePointLight"],
["fespecularlighting", "feSpecularLighting"],
["fespotlight", "feSpotLight"],
["fetile", "feTile"],
["feturbulence", "feTurbulence"],
["foreignobject", "foreignObject"],
["glyphref", "glyphRef"],
["lineargradient", "linearGradient"],
["radialgradient", "radialGradient"],
["textpath", "textPath"]
]);
var ForeignContext;
(function(ForeignContext) {
ForeignContext[ForeignContext["None"] = 0] = "None";
ForeignContext[ForeignContext["Svg"] = 1] = "Svg";
ForeignContext[ForeignContext["MathML"] = 2] = "MathML";
})(ForeignContext || (ForeignContext = {}));
const reNameEnd = /\s|\//;
/**
* Incremental parser implementation.
*/
var Parser = class {
options;
/** The start index of the last event. */
startIndex = 0;
/** The end index of the last event. */
endIndex = 0;
/**
* Store the start index of the current open tag,
* so we can update the start index for attributes.
*/
openTagStart = 0;
tagname = "";
attribname = "";
attribvalue = "";
attribs = null;
stack = [];
foreignContext;
cbs;
lowerCaseTagNames;
lowerCaseAttributeNames;
recognizeSelfClosing;
/** We are parsing HTML. Inverse of the `xmlMode` option. */
htmlMode;
tokenizer;
buffers = [];
bufferOffset = 0;
/** The index of the last written buffer. Used when resuming after a `pause()`. */
writeIndex = 0;
/** Indicates whether the parser has finished running / `.end` has been called. */
ended = false;
constructor(cbs, options = {}) {
this.options = options;
this.cbs = cbs ?? {};
this.htmlMode = !this.options.xmlMode;
this.lowerCaseTagNames = options.lowerCaseTags ?? this.htmlMode;
this.lowerCaseAttributeNames = options.lowerCaseAttributeNames ?? this.htmlMode;
this.recognizeSelfClosing = options.recognizeSelfClosing ?? !this.htmlMode;
this.tokenizer = new (options.Tokenizer ?? require_Tokenizer.default)(this.options, this);
this.foreignContext = [ForeignContext.None];
this.cbs.onparserinit?.(this);
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
ontext(start, endIndex) {
const data = this.getSlice(start, endIndex);
this.endIndex = endIndex - 1;
this.cbs.ontext?.(data);
this.startIndex = endIndex;
}
/**
* @param cp Current Unicode code point.
* @param endIndex End index for the current parser event.
* @internal
*/
ontextentity(cp, endIndex) {
this.endIndex = endIndex - 1;
this.cbs.ontext?.(fromCodePoint(cp));
this.startIndex = endIndex;
}
/** @internal */
isInForeignContext() {
return this.foreignContext[0] !== ForeignContext.None;
}
/**
* Checks if the current tag is a void element. Override this if you want
* to specify your own additional void elements.
* @param name Name of the pseudo selector.
*/
isVoidElement(name) {
return this.htmlMode && voidElements.has(name);
}
/**
* Read a tag name from the buffer.
*
* When `lowerCaseTagNames` is enabled (the default in HTML mode), the name
* is lowercased and may be adjusted for SVG casing or the `image` → `img`
* alias.
* @param start Start index of the tag name in the buffer.
* @param endIndex End index of the tag name in the buffer.
*/
readTagName(start, endIndex) {
const name = this.lowerCaseTagNames ? this.getSlice(start, endIndex).toLowerCase() : this.getSlice(start, endIndex);
if (!(this.lowerCaseTagNames && this.htmlMode)) return name;
if (this.foreignContext[0] === ForeignContext.Svg) return svgTagNameAdjustments.get(name) ?? name;
if (this.foreignContext.length > 1) {
const adjusted = svgTagNameAdjustments.get(name);
if (adjusted !== void 0 && this.stack.includes(adjusted)) return adjusted;
}
if (!this.isInForeignContext()) return name === "image" ? "img" : name;
return name;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
onopentagname(start, endIndex) {
this.endIndex = endIndex;
this.emitOpenTag(this.readTagName(start, endIndex));
}
emitOpenTag(name) {
this.openTagStart = this.startIndex;
this.tagname = name;
if (this.htmlMode && name === "form" && this.stack.includes("form")) {
this.tagname = "";
return;
}
const impliesClose = this.htmlMode && openImpliesClose.get(name);
if (impliesClose) while (this.stack.length > 0 && impliesClose.has(this.stack[0])) this.popElement(true);
if (!this.isVoidElement(name)) {
this.stack.unshift(name);
if (this.htmlMode) {
if (name === "svg") this.foreignContext.unshift(ForeignContext.Svg);
else if (name === "math") this.foreignContext.unshift(ForeignContext.MathML);
else if (htmlIntegrationElements.has(name)) this.foreignContext.unshift(ForeignContext.None);
}
}
this.cbs.onopentagname?.(name);
if (this.cbs.onopentag) this.attribs = {};
}
endOpenTag(isImplied) {
this.startIndex = this.openTagStart;
if (this.attribs) {
this.cbs.onopentag?.(this.tagname, this.attribs, isImplied);
this.attribs = null;
}
if (this.cbs.onclosetag && this.isVoidElement(this.tagname)) this.cbs.onclosetag(this.tagname, true);
this.tagname = "";
}
/**
* @param endIndex End index for the current parser event.
* @internal
*/
onopentagend(endIndex) {
this.endIndex = endIndex;
this.endOpenTag(false);
this.startIndex = endIndex + 1;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
onclosetag(start, endIndex) {
this.endIndex = endIndex;
const name = this.readTagName(start, endIndex);
if (!this.isVoidElement(name)) {
const pos = this.stack.indexOf(name);
if (pos !== -1) {
for (let index = 0; index < pos; index++) this.popElement(true);
this.popElement(false);
} else if (this.htmlMode && name === "p") {
this.emitOpenTag("p");
this.closeCurrentTag(true);
}
} else if (this.htmlMode && name === "br") {
this.cbs.onopentagname?.("br");
this.cbs.onopentag?.("br", {}, true);
this.cbs.onclosetag?.("br", false);
}
this.startIndex = endIndex + 1;
}
/**
* @param endIndex End index for the current parser event.
* @internal
*/
onselfclosingtag(endIndex) {
this.endIndex = endIndex;
if (this.recognizeSelfClosing || this.isInForeignContext()) {
this.closeCurrentTag(false);
this.startIndex = endIndex + 1;
} else this.onopentagend(endIndex);
}
/**
* Pop the top element off the stack, emit a close event, and maintain
* the foreign context stack.
* @param implied Whether this close is implied (not from an explicit end tag).
*/
popElement(implied) {
const element = this.stack.shift();
if (this.htmlMode && (foreignContextElements.has(element) || htmlIntegrationElements.has(element))) this.foreignContext.shift();
this.cbs.onclosetag?.(element, implied);
}
closeCurrentTag(isOpenImplied) {
const name = this.tagname;
this.endOpenTag(isOpenImplied);
if (this.stack[0] === name) this.popElement(!isOpenImplied);
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
onattribname(start, endIndex) {
this.startIndex = start;
const name = this.getSlice(start, endIndex);
this.attribname = this.lowerCaseAttributeNames ? name.toLowerCase() : name;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
onattribdata(start, endIndex) {
this.attribvalue += this.getSlice(start, endIndex);
}
/**
* @param cp Current Unicode code point.
* @internal
*/
onattribentity(cp) {
this.attribvalue += fromCodePoint(cp);
}
/**
* @param quote Quote type used for the current attribute.
* @param endIndex End index for the current parser event.
* @internal
*/
onattribend(quote, endIndex) {
this.endIndex = endIndex;
this.cbs.onattribute?.(this.attribname, this.attribvalue, quote === require_Tokenizer.QuoteType.Double ? "\"" : quote === require_Tokenizer.QuoteType.Single ? "'" : quote === require_Tokenizer.QuoteType.NoValue ? void 0 : null);
if (this.attribs && !Object.hasOwn(this.attribs, this.attribname)) this.attribs[this.attribname] = this.attribvalue;
this.attribvalue = "";
}
getInstructionName(value) {
const index = value.search(reNameEnd);
let name = index < 0 ? value : value.substr(0, index);
if (this.lowerCaseTagNames) name = name.toLowerCase();
return name;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
ondeclaration(start, endIndex) {
this.endIndex = endIndex;
const value = this.getSlice(start, endIndex);
if (this.cbs.onprocessinginstruction) {
const name = this.htmlMode ? this.lowerCaseTagNames ? DOCUMENT_TYPE : value.slice(0, 7) : this.getInstructionName(value);
this.cbs.onprocessinginstruction(`!${name}`, `!${value}`);
}
this.startIndex = endIndex + 1;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @internal
*/
onprocessinginstruction(start, endIndex) {
this.endIndex = endIndex;
const value = this.getSlice(start, endIndex);
if (this.cbs.onprocessinginstruction) {
const name = this.getInstructionName(value);
this.cbs.onprocessinginstruction(`?${name}`, `?${value}`);
}
this.startIndex = endIndex + 1;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @param offset Offset applied when computing parser indices.
* @internal
*/
oncomment(start, endIndex, offset) {
this.endIndex = endIndex;
this.cbs.oncomment?.(this.getSlice(start, endIndex - offset));
this.cbs.oncommentend?.();
this.startIndex = endIndex + 1;
}
/**
* @param start Start index for the current parser event.
* @param endIndex End index for the current parser event.
* @param offset Offset applied when computing parser indices.
* @internal
*/
oncdata(start, endIndex, offset) {
this.endIndex = endIndex;
const value = this.getSlice(start, endIndex - offset);
if (!this.htmlMode || this.options.recognizeCDATA) {
this.cbs.oncdatastart?.();
this.cbs.ontext?.(value);
this.cbs.oncdataend?.();
} else if (this.isInForeignContext()) this.cbs.ontext?.(value);
else {
this.cbs.oncomment?.(`[CDATA[${value}]]`);
this.cbs.oncommentend?.();
}
this.startIndex = endIndex + 1;
}
/** @internal */
onend() {
if (this.cbs.onclosetag) {
this.endIndex = this.startIndex;
for (let index = 0; index < this.stack.length; index++) this.cbs.onclosetag(this.stack[index], true);
}
this.cbs.onend?.();
}
/**
* Resets the parser to a blank state, ready to parse a new HTML document
*/
reset() {
this.cbs.onreset?.();
this.tokenizer.reset();
this.tagname = "";
this.attribname = "";
this.attribvalue = "";
this.attribs = null;
this.stack.length = 0;
this.startIndex = 0;
this.endIndex = 0;
this.cbs.onparserinit?.(this);
this.buffers.length = 0;
this.foreignContext.length = 0;
this.foreignContext.unshift(ForeignContext.None);
this.bufferOffset = 0;
this.writeIndex = 0;
this.ended = false;
}
/**
* Resets the parser, then parses a complete document and
* pushes it to the handler.
* @param data Document to parse.
*/
parseComplete(data) {
this.reset();
this.end(data);
}
getSlice(start, end) {
if (start === end) return "";
while (start - this.bufferOffset >= this.buffers[0].length) this.shiftBuffer();
let slice = this.buffers[0].slice(start - this.bufferOffset, end - this.bufferOffset);
while (end - this.bufferOffset > this.buffers[0].length) {
this.shiftBuffer();
slice += this.buffers[0].slice(0, end - this.bufferOffset);
}
return slice;
}
shiftBuffer() {
this.bufferOffset += this.buffers[0].length;
this.writeIndex--;
this.buffers.shift();
}
/**
* Parses a chunk of data and calls the corresponding callbacks.
* @param chunk Chunk to parse.
*/
write(chunk) {
if (this.ended) {
this.cbs.onerror?.(/* @__PURE__ */ new Error(".write() after done!"));
return;
}
this.buffers.push(chunk);
if (this.tokenizer.running) {
this.tokenizer.write(chunk);
this.writeIndex++;
}
}
/**
* Parses the end of the buffer and clears the stack, calls onend.
* @param chunk Optional final chunk to parse.
*/
end(chunk) {
if (this.ended) {
this.cbs.onerror?.(/* @__PURE__ */ new Error(".end() after done!"));
return;
}
if (chunk) this.write(chunk);
this.ended = true;
this.tokenizer.end();
}
/**
* Pauses parsing. The parser won't emit events until `resume` is called.
*/
pause() {
this.tokenizer.pause();
}
/**
* Resumes parsing after `pause` was called.
*/
resume() {
this.tokenizer.resume();
while (this.tokenizer.running && this.writeIndex < this.buffers.length) this.tokenizer.write(this.buffers[this.writeIndex++]);
if (this.ended) this.tokenizer.end();
}
};
//#endregion
exports.Parser = Parser;
//# sourceMappingURL=Parser.js.map