diff --git a/src/nodes/html.ts b/src/nodes/html.ts index 04e0c3f..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 }, @@ -1029,10 +1036,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 }, @@ -1169,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 new file mode 100644 index 0000000..19cfcc6 --- /dev/null +++ b/test/tests/issues/302.js @@ -0,0 +1,119 @@ +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
`; + const root = parse(html); + valid(html).should.equal(true); + root.outerHTML.should.equal(`<${section}><${cell}>value
`); + root.querySelector('tr').parentNode.should.equal(root.querySelector(section)); + }); + } + } + + it('closes an open cell, row, and tbody when the table ends', function () { + const html = '
value
'; + const root = parse(html); + valid(html).should.equal(true); + root.outerHTML.should.equal('
value
'); + }); + + it('keeps nested tables inside their outer cell', function () { + const html = '
outer
inner
'; + 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( + '
outer
inner
' + ); + }); + + for (const options of [{}, { parseNoneClosedTags: true }, { closeAllByClosing: true }]) { + it(`keeps successive rows as siblings with options ${JSON.stringify(options)}`, function () { + const html = '
A1
B2
'; + 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
`; + 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 = '
NameValue
A1
B2
'; + 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 = '
outer
inner
next
last
'; + 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('
first
second
', { 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 = '
Head
first
second
'; + 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']); + }); + } +});