diff --git a/src/nodes/html.ts b/src/nodes/html.ts
index 04e0c3f..09432b0 100644
--- a/src/nodes/html.ts
+++ b/src/nodes/html.ts
@@ -1306,9 +1306,7 @@ export function parse(data: string, options = {} as Partial) {
// this is wrong, becouse this will put the H3 outside the current right position which should be inside the current Html Element, see issue 152 for more info
if (options.parseNoneClosedTags !== true) {
oneBefore.removeChild(last);
- last.childNodes.forEach((child) => {
- oneBefore.parentNode.appendChild(child);
- });
+ reparentChildren(last, oneBefore.parentNode);
stack.pop();
}
} else {
@@ -1317,9 +1315,7 @@ export function parse(data: string, options = {} as Partial) {
// eslint-disable-next-line no-lonely-if
if (options.parseNoneClosedTags !== true) {
oneBefore.removeChild(last);
- last.childNodes.forEach((child) => {
- oneBefore.appendChild(child);
- });
+ reparentChildren(last, oneBefore);
}
}
} else {
@@ -1334,6 +1330,17 @@ export function parse(data: string, options = {} as Partial) {
return root;
}
+function reparentChildren(source: HTMLElement, parent: HTMLElement) {
+ const children = source.childNodes;
+ source.childNodes = [];
+ for (const child of children) {
+ // Detach in bulk: appendChild() would otherwise filter the shrinking source
+ // array once per child, making repair quadratic in the number of siblings.
+ child.parentNode = null;
+ parent.appendChild(child);
+ }
+}
+
/**
* Resolves a list of {@link NodeInsertable} to a list of nodes,
* and removes nodes from any potential parent.
diff --git a/test/tests/issues/260-unclosed-tags.js b/test/tests/issues/260-unclosed-tags.js
new file mode 100644
index 0000000..1588502
--- /dev/null
+++ b/test/tests/issues/260-unclosed-tags.js
@@ -0,0 +1,78 @@
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const { parse } = require('@test/test-target');
+
+function checkParents(parent) {
+ parent.childNodes.forEach((child) => {
+ assert.equal(child.parentNode, parent);
+ checkParents(child);
+ });
+}
+
+describe('unclosed tag repair', function () {
+ const content = 'alphabetaomega';
+ for (const sameTag of [false, true]) {
+ const opening = sameTag ? '' : '';
+ const input = `before${opening}${content}`;
+ const repaired = `before${sameTag ? '' : ''}${content}`;
+
+ for (const preserve of [undefined, false, true]) {
+ it(`preserves child order and parent links (${sameTag ? 'same' : 'different'} tags, parseNoneClosedTags=${preserve})`, function () {
+ const root = parse(input, { comment: true, parseNoneClosedTags: preserve });
+ const expected = preserve ? `${input}${sameTag ? '' : ''}` : repaired;
+ assert.equal(root.toString(), expected);
+ checkParents(root);
+ const bold = root.querySelector('b');
+ assert.equal(bold.previousSibling.toString(), '');
+ assert.equal(bold.nextSibling.toString(), 'omega');
+ });
+ }
+
+ it(`handles empty unclosed ${sameTag ? 'same' : 'different'} tags`, function () {
+ const root = parse(`${opening}`);
+ assert.equal(root.toString(), `${sameTag ? '' : ''}`);
+ checkParents(root);
+ });
+
+ it(`repairs many children of unclosed ${sameTag ? 'same' : 'different'} tags within a bounded process`, function () {
+ this.timeout(15000);
+ const args = [];
+ if (process.env.TEST_TARGET !== 'dist' && !process.env.CI) {
+ args.push('-r', require.resolve('ts-node/register'));
+ }
+ args.push('-e', `
+ const assert = require('node:assert/strict');
+ const { parse } = require(process.argv[1]);
+ const sameTag = process.argv[2] === 'true';
+ const count = 32000;
+ const content = 'item'.repeat(count);
+ const opening = sameTag ? '' : '';
+ const root = parse('' + opening + content);
+ const parent = root.firstChild;
+ const items = sameTag ? parent.childNodes.slice(1) : parent.childNodes;
+ assert.equal(items.length, count);
+ for (const item of items) {
+ assert.equal(item.rawTagName, 'b');
+ assert.equal(item.text, 'item');
+ assert.equal(item.parentNode, parent);
+ assert.equal(item.firstChild.parentNode, item);
+ }
+ assert.equal(root.toString(), '' + (sameTag ? '' : '') + content + '');
+ process.stdout.write('ok');
+ `, require.resolve('@test/test-target'), String(sameTag));
+ // Isolate synchronous parsing so a regression cannot hang the test runner.
+ // The linear path has ample headroom; repeated sibling filtering times out.
+ const result = spawnSync(process.execPath, args, { timeout: 8000, encoding: 'utf8', windowsHide: true });
+ assert.ifError(result.error);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout, 'ok');
+ });
+ }
+
+ it('keeps properly closed parents and their mixed children', function () {
+ const html = `beforeafter`;
+ const root = parse(html, { comment: true });
+ assert.equal(root.toString(), html);
+ checkParents(root);
+ });
+});