From 23fe6ab337e8e4f2e894d79188804212cff48d69 Mon Sep 17 00:00:00 2001
From: Itamar Dahan <125464728+DahanItamar@users.noreply.github.com>
Date: Tue, 6 Oct 2026 23:44:01 +0300
Subject: [PATCH 1/2] fix: close optional table tags at section boundaries
---
src/nodes/html.ts | 15 +++++++++++----
test/tests/issues/302.js | 34 ++++++++++++++++++++++++++++++++++
2 files changed, 45 insertions(+), 4 deletions(-)
create mode 100644 test/tests/issues/302.js
diff --git a/src/nodes/html.ts b/src/nodes/html.ts
index 04e0c3f..241ff87 100644
--- a/src/nodes/html.ts
+++ b/src/nodes/html.ts
@@ -1029,10 +1029,17 @@ const kElementsClosedByClosing = {
I: { div: true, DIV: true },
p: { div: true, DIV: true },
P: { div: true, DIV: true },
- td: { tr: true, table: true, TR: true, TABLE: true },
- TD: { tr: true, table: true, TR: true, TABLE: true },
- th: { tr: true, table: true, TR: true, TABLE: true },
- TH: { tr: true, table: true, TR: true, TABLE: true },
+ // Cells and rows may omit their end tags at the end of a table section.
+ td: { tr: true, table: true, thead: true, tbody: true, tfoot: true, TR: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ TD: { tr: true, table: true, thead: true, tbody: true, tfoot: true, TR: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ th: { tr: true, table: true, thead: true, tbody: true, tfoot: true, TR: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ TH: { tr: true, table: true, thead: true, tbody: true, tfoot: true, TR: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ tr: { table: true, thead: true, tbody: true, tfoot: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ TR: { table: true, thead: true, tbody: true, tfoot: true, TABLE: true, THEAD: true, TBODY: true, TFOOT: true },
+ tbody: { table: true, TABLE: true },
+ TBODY: { table: true, TABLE: true },
+ tfoot: { table: true, TABLE: true },
+ TFOOT: { table: true, TABLE: true },
dt: { dl: true, body: true, html: true, DL: true, BODY: true, HTML: true },
DT: { dl: true, body: true, html: true, DL: true, BODY: true, HTML: true },
dd: { dl: true, body: true, html: true, DL: true, BODY: true, HTML: true },
diff --git a/test/tests/issues/302.js b/test/tests/issues/302.js
new file mode 100644
index 0000000..c486b54
--- /dev/null
+++ b/test/tests/issues/302.js
@@ -0,0 +1,34 @@
+const { parse, valid } = require('@test/test-target');
+
+describe('issue 302 optional table end tags', function () {
+ for (const section of ['thead', 'tbody', 'tfoot']) {
+ for (const cell of ['td', 'th']) {
+ it(`closes an open ${cell} and tr when ${section} ends`, function () {
+ const html = `
<${section}><${cell}>value${section}>
`;
+ const root = parse(html);
+ valid(html).should.equal(true);
+ root.outerHTML.should.equal(`<${section}><${cell}>value${cell}>
${section}>
`);
+ root.querySelector('tr').parentNode.should.equal(root.querySelector(section));
+ });
+ }
+ }
+
+ it('closes an open cell, row, and tbody when the table ends', function () {
+ const html = '';
+ const root = parse(html);
+ valid(html).should.equal(true);
+ root.outerHTML.should.equal('');
+ });
+
+ it('keeps nested tables inside their outer cell', function () {
+ const html = '';
+ const root = parse(html);
+ valid(html).should.equal(true);
+ root.querySelectorAll('table').length.should.equal(2);
+ const tables = root.querySelectorAll('table');
+ tables[1].parentNode.should.equal(tables[0].querySelector('td'));
+ root.outerHTML.should.equal(
+ ''
+ );
+ });
+});
From 461a468557be5ae1ae7553142127a9de8b8d6db8 Mon Sep 17 00:00:00 2001
From: Itamar Dahan <125464728+DahanItamar@users.noreply.github.com>
Date: Tue, 6 Oct 2026 23:46:17 +0300
Subject: [PATCH 2/2] fix: keep omitted table rows and sections as siblings
---
src/nodes/html.ts | 22 +++++++----
test/tests/issues/302.js | 85 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 99 insertions(+), 8 deletions(-)
diff --git a/src/nodes/html.ts b/src/nodes/html.ts
index 241ff87..9c6d312 100644
--- a/src/nodes/html.ts
+++ b/src/nodes/html.ts
@@ -997,10 +997,17 @@ const kElementsClosedByOpening = {
P: { p: true, div: true, P: true, DIV: true },
b: { div: true, DIV: true },
B: { div: true, DIV: true },
- td: { td: true, th: true, TD: true, TH: true },
- TD: { td: true, th: true, TD: true, TH: true },
- th: { td: true, th: true, TD: true, TH: true },
- TH: { td: true, th: true, TD: true, TH: true },
+ // A new row or section also ends any open cell in the preceding row.
+ td: { td: true, th: true, tr: true, tbody: true, tfoot: true, TD: true, TH: true, TR: true, TBODY: true, TFOOT: true },
+ TD: { td: true, th: true, tr: true, tbody: true, tfoot: true, TD: true, TH: true, TR: true, TBODY: true, TFOOT: true },
+ th: { td: true, th: true, tr: true, tbody: true, tfoot: true, TD: true, TH: true, TR: true, TBODY: true, TFOOT: true },
+ TH: { td: true, th: true, tr: true, tbody: true, tfoot: true, TD: true, TH: true, TR: true, TBODY: true, TFOOT: true },
+ tr: { tr: true, tbody: true, tfoot: true, TR: true, TBODY: true, TFOOT: true },
+ TR: { tr: true, tbody: true, tfoot: true, TR: true, TBODY: true, TFOOT: true },
+ thead: { tbody: true, tfoot: true, TBODY: true, TFOOT: true },
+ THEAD: { tbody: true, tfoot: true, TBODY: true, TFOOT: true },
+ tbody: { tbody: true, tfoot: true, TBODY: true, TFOOT: true },
+ TBODY: { tbody: true, tfoot: true, TBODY: true, TFOOT: true },
h1: { h1: true, H1: true },
H1: { h1: true, H1: true },
h2: { h2: true, H2: true },
@@ -1176,10 +1183,9 @@ export function base_parse(data: string, options = {} as Partial) {
attrs[key.toLowerCase()] = isQuoted ? val.slice(1, val.length - 1) : val;
}
- const parentTagName = currentParent.rawTagName as IRawTagName;
-
- if (!closingSlash && !options.preserveTagNesting && kElementsClosedByOpening[parentTagName]) {
- if (kElementsClosedByOpening[parentTagName][tagName]) {
+ if (!closingSlash && !options.preserveTagNesting) {
+ // One opening tag can end a cell, its row, and its table section.
+ while (kElementsClosedByOpening[currentParent.rawTagName as IRawTagName]?.[tagName]) {
stack.pop();
currentParent = arr_back(stack);
}
diff --git a/test/tests/issues/302.js b/test/tests/issues/302.js
index c486b54..19cfcc6 100644
--- a/test/tests/issues/302.js
+++ b/test/tests/issues/302.js
@@ -31,4 +31,89 @@ describe('issue 302 optional table end tags', function () {
''
);
});
+
+ for (const options of [{}, { parseNoneClosedTags: true }, { closeAllByClosing: true }]) {
+ it(`keeps successive rows as siblings with options ${JSON.stringify(options)}`, function () {
+ const html = '';
+ const root = parse(html, options);
+ valid(html, options).should.equal(true);
+ const tbody = root.querySelector('tbody');
+ const rows = root.querySelectorAll('tr');
+ rows.map((row) => row.children.map((cell) => cell.text)).should.eql([
+ ['A', '1'],
+ ['B', '2'],
+ ]);
+ rows.forEach((row) => row.parentNode.should.equal(tbody));
+ });
+ }
+
+ for (const [previous, next] of [
+ ['thead', 'tbody'],
+ ['thead', 'tfoot'],
+ ['tbody', 'tbody'],
+ ['tbody', 'tfoot'],
+ ]) {
+ it(`closes an open cell, row, and ${previous} when ${next} starts`, function () {
+ const html = `<${previous}>| first<${next}> |
| second${next}> |
`;
+ const root = parse(html);
+ valid(html).should.equal(true);
+ root.querySelector('table')
+ .children.map((section) => section.rawTagName)
+ .should.eql([previous, next]);
+ root.querySelectorAll('tr')
+ .map((row) => row.text)
+ .should.eql(['first', 'second']);
+ });
+ }
+
+ it('parses the table from issue 302 without losing its rows', function () {
+ const html = '';
+ const root = parse(html);
+ valid(html).should.equal(true);
+ root.querySelectorAll('tr')
+ .map((row) => row.children.map((cell) => cell.text))
+ .should.eql([
+ ['Name', 'Value'],
+ ['A', '1'],
+ ['B', '2'],
+ ]);
+ root.querySelector('table')
+ .children.map((section) => section.rawTagName)
+ .should.eql(['thead', 'tbody']);
+ });
+
+ it('does not close outer rows when another row starts inside a nested table', function () {
+ const html = '';
+ const root = parse(html);
+ valid(html).should.equal(true);
+ const tables = root.querySelectorAll('table');
+ const outerRows = tables[0].querySelector('tbody').children;
+ const innerRows = tables[1].querySelector('tbody').children;
+ outerRows.length.should.equal(2);
+ innerRows.map((row) => row.text).should.eql(['inner', 'next']);
+ tables[1].parentNode.should.equal(outerRows[0].firstElementChild);
+ outerRows[1].text.should.equal('last');
+ });
+
+ it('respects preserveTagNesting when a new row starts', function () {
+ const root = parse('', { preserveTagNesting: true });
+ const rows = root.querySelectorAll('tr');
+ rows.length.should.equal(2);
+ rows[1].parentNode.should.equal(rows[0].firstElementChild);
+ });
+
+ for (const lowerCaseTagName of [false, true]) {
+ it(`handles uppercase table tags with lowerCaseTagName=${lowerCaseTagName}`, function () {
+ const html = '';
+ const options = { lowerCaseTagName };
+ const root = parse(html, options);
+ valid(html, options).should.equal(true);
+ root.querySelectorAll('tr')
+ .map((row) => row.text)
+ .should.eql(['Head', 'first', 'second']);
+ root.querySelector('table')
+ .children.map((section) => section.rawTagName)
+ .should.eql(lowerCaseTagName ? ['thead', 'tbody'] : ['THEAD', 'TBODY']);
+ });
+ }
});