From 5a7555e98a8a0ee20e96212835c4af6a1bd98b07 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:12:48 +0600 Subject: [PATCH 1/4] feat(parser): add openImpliesClose option to disable implicit HTML changes (#2428) --- src/Parser.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Parser.ts b/src/Parser.ts index 33050243b..71f2c6996 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -208,6 +208,16 @@ 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. + * @default !xmlMode + */ + openImpliesClose?: boolean; } /** @@ -280,6 +290,7 @@ export class Parser implements Callbacks { private readonly recognizeSelfClosing: boolean; /** We are parsing HTML. Inverse of the `xmlMode` option. */ private readonly htmlMode: boolean; + private readonly openImpliesClose: boolean; private readonly tokenizer: Tokenizer; private readonly buffers: string[] = []; @@ -295,6 +306,7 @@ export class Parser implements Callbacks { ) { this.cbs = cbs ?? {}; this.htmlMode = !this.options.xmlMode; + this.openImpliesClose = options.openImpliesClose ?? this.htmlMode; this.lowerCaseTagNames = options.lowerCaseTags ?? this.htmlMode; this.lowerCaseAttributeNames = options.lowerCaseAttributeNames ?? this.htmlMode; @@ -410,12 +422,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.htmlMode && this.openImpliesClose && name === "form" && this.stack.includes("form")) { this.tagname = ""; return; } - const impliesClose = this.htmlMode && openImpliesClose.get(name); + const impliesClose = this.htmlMode && this.openImpliesClose && openImpliesClose.get(name); if (impliesClose) { while (this.stack.length > 0 && impliesClose.has(this.stack[0])) { @@ -481,12 +493,12 @@ export class Parser implements Callbacks { this.popElement(true); } this.popElement(false); - } else if (this.htmlMode && name === "p") { + } else if (this.htmlMode && this.openImpliesClose && name === "p") { // Implicit open before close this.emitOpenTag("p"); this.closeCurrentTag(true); } - } else if (this.htmlMode && name === "br") { + } else if (this.htmlMode && this.openImpliesClose && 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); From 84057663bd47bdc46500f0f56d212ff03c4823eb Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:44:28 +0600 Subject: [PATCH 2/4] refactor(parser): rename openImpliesClose map to impliesCloseMap and add tests for openImpliesClose:false --- src/Parser.events.spec.ts | 9 ++ src/Parser.ts | 4 +- src/__snapshots__/Parser.events.spec.ts.snap | 155 +++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) 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 71f2c6996..8894b70b0 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"])], @@ -427,7 +427,7 @@ export class Parser implements Callbacks { return; } - const impliesClose = this.htmlMode && this.openImpliesClose && openImpliesClose.get(name); + const impliesClose = this.htmlMode && this.openImpliesClose && impliesCloseMap.get(name); if (impliesClose) { while (this.stack.length > 0 && impliesClose.has(this.stack[0])) { 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`] = ` [ { From b5fb42031a81d8963297a99c6f9e48a0812e7e88 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:53:18 +0600 Subject: [PATCH 3/4] refactor(parser): centralize htmlMode && openImpliesClose into impliesCloseEnabled field --- src/Parser.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Parser.ts b/src/Parser.ts index 8894b70b0..6e53ed2e6 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -215,6 +215,8 @@ export interface ParserOptions { * `

` 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; @@ -291,6 +293,8 @@ export class Parser implements Callbacks { /** We are parsing HTML. Inverse of the `xmlMode` option. */ private readonly htmlMode: boolean; private readonly openImpliesClose: 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[] = []; @@ -307,6 +311,7 @@ export class Parser implements Callbacks { this.cbs = cbs ?? {}; this.htmlMode = !this.options.xmlMode; this.openImpliesClose = options.openImpliesClose ?? this.htmlMode; + this.impliesCloseEnabled = this.htmlMode && this.openImpliesClose; this.lowerCaseTagNames = options.lowerCaseTags ?? this.htmlMode; this.lowerCaseAttributeNames = options.lowerCaseAttributeNames ?? this.htmlMode; @@ -422,12 +427,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 && this.openImpliesClose && name === "form" && this.stack.includes("form")) { + if (this.impliesCloseEnabled && name === "form" && this.stack.includes("form")) { this.tagname = ""; return; } - const impliesClose = this.htmlMode && this.openImpliesClose && impliesCloseMap.get(name); + const impliesClose = this.impliesCloseEnabled && impliesCloseMap.get(name); if (impliesClose) { while (this.stack.length > 0 && impliesClose.has(this.stack[0])) { @@ -493,12 +498,12 @@ export class Parser implements Callbacks { this.popElement(true); } this.popElement(false); - } else if (this.htmlMode && this.openImpliesClose && name === "p") { + } else if (this.impliesCloseEnabled && name === "p") { // Implicit open before close this.emitOpenTag("p"); this.closeCurrentTag(true); } - } else if (this.htmlMode && this.openImpliesClose && 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); From ed5881af4ec7153fe9695843b549fa61f1e9f7ab Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 2 Jul 2026 08:59:40 +0600 Subject: [PATCH 4/4] refactor(parser): remove redundant openImpliesClose field, derive impliesCloseEnabled directly --- src/Parser.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Parser.ts b/src/Parser.ts index 6e53ed2e6..f0119fa5a 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -292,7 +292,6 @@ export class Parser implements Callbacks { private readonly recognizeSelfClosing: boolean; /** We are parsing HTML. Inverse of the `xmlMode` option. */ private readonly htmlMode: boolean; - private readonly openImpliesClose: boolean; /** Whether implicit open/close logic is active (requires HTML mode + openImpliesClose option). */ private readonly impliesCloseEnabled: boolean; private readonly tokenizer: Tokenizer; @@ -310,8 +309,7 @@ export class Parser implements Callbacks { ) { this.cbs = cbs ?? {}; this.htmlMode = !this.options.xmlMode; - this.openImpliesClose = options.openImpliesClose ?? this.htmlMode; - this.impliesCloseEnabled = this.htmlMode && this.openImpliesClose; + this.impliesCloseEnabled = this.htmlMode && (options.openImpliesClose ?? this.htmlMode); this.lowerCaseTagNames = options.lowerCaseTags ?? this.htmlMode; this.lowerCaseAttributeNames = options.lowerCaseAttributeNames ?? this.htmlMode;