diff --git a/src/Parser.events.spec.ts b/src/Parser.events.spec.ts index 40c4c85e0..1abee6fd9 100644 --- a/src/Parser.events.spec.ts +++ b/src/Parser.events.spec.ts @@ -301,6 +301,15 @@ describe("Events", () => { '', { xmlMode: true }, )); + + it("openImpliesClose=false: no implicit close via map", () => + runTest("

first

second

", { openImpliesClose: false })); + + it("openImpliesClose=false: no implicit p open before close", () => + runTest("
text

", { openImpliesClose: false })); + + it("openImpliesClose=false: no implicit br open before close", () => + runTest("
text
", { openImpliesClose: false })); }); describe("Helper", () => { diff --git a/src/Parser.ts b/src/Parser.ts index 33050243b..f0119fa5a 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -17,7 +17,7 @@ const tableSectionTags = new Set(["thead", "tbody", "tfoot", "tr", "td", "th"]); const ddtTags = new Set(["dd", "dt"]); const rtpTags = new Set(["rt", "rp"]); -const openImpliesClose = new Map>([ +const impliesCloseMap = new Map>([ ["tr", new Set(["tr", "th", "td"])], ["th", new Set(["th", "td"])], ["td", new Set(["thead", "th", "td"])], @@ -208,6 +208,18 @@ export interface ParserOptions { * Allows the default tokenizer to be overwritten. */ Tokenizer?: typeof Tokenizer; + + /** + * If set to `true`, the parser will implicitly open and close HTML tags + * according to the HTML5 spec (e.g. `

` will implicitly close another + * `

` that is still open, `

` will implicitly open `

` if none is + * open). Set to `false` to disable all implicit HTML changes, preserving + * the original document structure more closely. + * + * This option has no effect when `xmlMode` is `true`. + * @default !xmlMode + */ + openImpliesClose?: boolean; } /** @@ -280,6 +292,8 @@ export class Parser implements Callbacks { private readonly recognizeSelfClosing: boolean; /** We are parsing HTML. Inverse of the `xmlMode` option. */ private readonly htmlMode: boolean; + /** Whether implicit open/close logic is active (requires HTML mode + openImpliesClose option). */ + private readonly impliesCloseEnabled: boolean; private readonly tokenizer: Tokenizer; private readonly buffers: string[] = []; @@ -295,6 +309,7 @@ export class Parser implements Callbacks { ) { this.cbs = cbs ?? {}; this.htmlMode = !this.options.xmlMode; + this.impliesCloseEnabled = this.htmlMode && (options.openImpliesClose ?? this.htmlMode); this.lowerCaseTagNames = options.lowerCaseTags ?? this.htmlMode; this.lowerCaseAttributeNames = options.lowerCaseAttributeNames ?? this.htmlMode; @@ -410,12 +425,12 @@ export class Parser implements Callbacks { * stays null so endOpenTag is a no-op, and closeCurrentTag can't * match "" on the stack. */ - if (this.htmlMode && name === "form" && this.stack.includes("form")) { + if (this.impliesCloseEnabled && name === "form" && this.stack.includes("form")) { this.tagname = ""; return; } - const impliesClose = this.htmlMode && openImpliesClose.get(name); + const impliesClose = this.impliesCloseEnabled && impliesCloseMap.get(name); if (impliesClose) { while (this.stack.length > 0 && impliesClose.has(this.stack[0])) { @@ -481,12 +496,12 @@ export class Parser implements Callbacks { this.popElement(true); } this.popElement(false); - } else if (this.htmlMode && name === "p") { + } else if (this.impliesCloseEnabled && name === "p") { // Implicit open before close this.emitOpenTag("p"); this.closeCurrentTag(true); } - } else if (this.htmlMode && name === "br") { + } else if (this.impliesCloseEnabled && name === "br") { // We can't use `emitOpenTag` for implicit open, as `br` would be implicitly closed. this.cbs.onopentagname?.("br"); this.cbs.onopentag?.("br", {}, true); diff --git a/src/__snapshots__/Parser.events.spec.ts.snap b/src/__snapshots__/Parser.events.spec.ts.snap index 686fcafcc..33a711b7a 100644 --- a/src/__snapshots__/Parser.events.spec.ts.snap +++ b/src/__snapshots__/Parser.events.spec.ts.snap @@ -5304,6 +5304,161 @@ exports[`Events > open-implies-close case of (non-br) void close tag in non-XML ] `; +exports[`Events > openImpliesClose=false: no implicit br open before close 1`] = ` +[ + { + "$event": "opentagname", + "data": [ + "div", + ], + "endIndex": 4, + "startIndex": 0, + }, + { + "$event": "opentag", + "data": [ + "div", + {}, + false, + ], + "endIndex": 4, + "startIndex": 0, + }, + { + "$event": "text", + "data": [ + "text", + ], + "endIndex": 8, + "startIndex": 5, + }, + { + "$event": "closetag", + "data": [ + "div", + false, + ], + "endIndex": 19, + "startIndex": 14, + }, +] +`; + +exports[`Events > openImpliesClose=false: no implicit close via map 1`] = ` +[ + { + "$event": "opentagname", + "data": [ + "p", + ], + "endIndex": 2, + "startIndex": 0, + }, + { + "$event": "opentag", + "data": [ + "p", + {}, + false, + ], + "endIndex": 2, + "startIndex": 0, + }, + { + "$event": "text", + "data": [ + "first", + ], + "endIndex": 7, + "startIndex": 3, + }, + { + "$event": "opentagname", + "data": [ + "p", + ], + "endIndex": 10, + "startIndex": 8, + }, + { + "$event": "opentag", + "data": [ + "p", + {}, + false, + ], + "endIndex": 10, + "startIndex": 8, + }, + { + "$event": "text", + "data": [ + "second", + ], + "endIndex": 16, + "startIndex": 11, + }, + { + "$event": "closetag", + "data": [ + "p", + false, + ], + "endIndex": 20, + "startIndex": 17, + }, + { + "$event": "closetag", + "data": [ + "p", + false, + ], + "endIndex": 24, + "startIndex": 21, + }, +] +`; + +exports[`Events > openImpliesClose=false: no implicit p open before close 1`] = ` +[ + { + "$event": "opentagname", + "data": [ + "div", + ], + "endIndex": 4, + "startIndex": 0, + }, + { + "$event": "opentag", + "data": [ + "div", + {}, + false, + ], + "endIndex": 4, + "startIndex": 0, + }, + { + "$event": "text", + "data": [ + "text", + ], + "endIndex": 8, + "startIndex": 5, + }, + { + "$event": "closetag", + "data": [ + "div", + false, + ], + "endIndex": 18, + "startIndex": 13, + }, +] +`; + exports[`Events > plaintext 1`] = ` [ {