add function to update HTML trees

This commit is contained in:
ChaosExAnima 2026-05-07 13:36:58 +02:00
parent 86e4ecfa20
commit e67f291b22
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
4 changed files with 231 additions and 130 deletions

View File

@ -15,7 +15,7 @@ exports[`html > htmlStringToComponents > copies attributes to props 1`] = `
exports[`html > htmlStringToComponents > handles nested elements 1`] = `
[
<p>
lorem
lorem
<strong>
ipsum
</strong>
@ -26,11 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = `
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
[
<p>
<span>
lorem ipsum
</span>
</p>,
]
`;
@ -38,8 +38,8 @@ exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
[
<p>
lorem
lorem
<em>
dolor
</em>

View File

@ -1,125 +0,0 @@
import React from 'react';
import * as html from '../html';
describe('html', () => {
describe('unescapeHTML', () => {
it('returns unescaped HTML', () => {
const output = html.unescapeHTML(
'<p>lorem</p><p>ipsum</p><br>&lt;br&gt;',
);
expect(output).toEqual('lorem\n\nipsum\n<br>');
});
});
describe('htmlStringToComponents', () => {
it('returns converted nodes from string', () => {
const input = '<p>lorem ipsum</p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('handles nested elements', () => {
const input = '<p>lorem <strong>ipsum</strong></p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('ignores empty text nodes', () => {
const input = '<p> <span>lorem ipsum</span> </p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('copies attributes to props', () => {
const input =
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('respects maxDepth option', () => {
const input = '<p><span>lorem <strong>ipsum</strong></span></p>';
const output = html.htmlStringToComponents(input, { maxDepth: 2 });
expect(output).toMatchSnapshot();
});
it('calls onText callback', () => {
const input = '<p>lorem ipsum</p>';
const onText = vi.fn((text: string) => text);
html.htmlStringToComponents(input, { onText });
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {});
});
it('calls onElement callback', () => {
const input = '<p>lorem ipsum</p>';
const onElement = vi.fn<html.OnElementHandler>(
(element, props, children) =>
React.createElement(
element.tagName.toLowerCase(),
props,
...children,
),
);
html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
});
it('uses default parsing if onElement returns undefined', () => {
const input = '<p>lorem ipsum</p>';
const onElement = vi.fn(() => undefined);
const output = html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
expect(output).toMatchSnapshot();
});
it('calls onAttribute callback', () => {
const input =
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
const onAttribute = vi.fn(
(name: string, value: string) =>
[name, value] satisfies [string, string],
);
html.htmlStringToComponents(input, { onAttribute });
expect(onAttribute).toHaveBeenCalledTimes(3);
expect(onAttribute).toHaveBeenCalledWith(
'href',
'https://example.com',
'a',
{},
);
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {});
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {});
});
it('respects allowedTags option', () => {
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
const output = html.htmlStringToComponents(input, {
allowedTags: { p: {}, em: {} },
});
expect(output).toMatchSnapshot();
});
it('ensure performance is acceptable with large input', () => {
const input = '<p>' + '<span>lorem</span>'.repeat(1_000) + '</p>';
const start = performance.now();
html.htmlStringToComponents(input);
const duration = performance.now() - start;
// Arbitrary threshold of 200ms for this test.
// Normally it's much less (<50ms), but the GH Action environment can be slow.
expect(duration).toBeLessThan(200);
});
});
});

View File

@ -0,0 +1,171 @@
import React from 'react';
import * as html from './html';
describe('unescapeHTML', () => {
it('returns unescaped HTML', () => {
const output = html.unescapeHTML('<p>lorem</p><p>ipsum</p><br>&lt;br&gt;');
expect(output).toEqual('lorem\n\nipsum\n<br>');
});
});
describe('htmlStringToComponents', () => {
it('returns converted nodes from string', () => {
const input = '<p>lorem ipsum</p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('handles nested elements', () => {
const input = '<p>lorem <strong>ipsum</strong></p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('ignores empty text nodes', () => {
const input = '<p> <span>lorem ipsum</span> </p>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('copies attributes to props', () => {
const input =
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
const output = html.htmlStringToComponents(input);
expect(output).toMatchSnapshot();
});
it('respects maxDepth option', () => {
const input = '<p><span>lorem <strong>ipsum</strong></span></p>';
const output = html.htmlStringToComponents(input, { maxDepth: 2 });
expect(output).toMatchSnapshot();
});
it('calls onText callback', () => {
const input = '<p>lorem ipsum</p>';
const onText = vi.fn((text: string) => text);
html.htmlStringToComponents(input, { onText });
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {});
});
it('calls onElement callback', () => {
const input = '<p>lorem ipsum</p>';
const onElement = vi.fn<html.OnElementHandler>((element, props, children) =>
React.createElement(element.tagName.toLowerCase(), props, ...children),
);
html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
});
it('uses default parsing if onElement returns undefined', () => {
const input = '<p>lorem ipsum</p>';
const onElement = vi.fn(() => undefined);
const output = html.htmlStringToComponents(input, { onElement });
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ key: expect.any(String) }),
expect.arrayContaining(['lorem ipsum']),
{},
);
expect(output).toMatchSnapshot();
});
it('calls onAttribute callback', () => {
const input =
'<a href="https://example.com" target="_blank" rel="nofollow">link</a>';
const onAttribute = vi.fn(
(name: string, value: string) => [name, value] satisfies [string, string],
);
html.htmlStringToComponents(input, { onAttribute });
expect(onAttribute).toHaveBeenCalledTimes(3);
expect(onAttribute).toHaveBeenCalledWith(
'href',
'https://example.com',
'a',
{},
);
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {});
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {});
});
it('respects allowedTags option', () => {
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
const output = html.htmlStringToComponents(input, {
allowedTags: { p: {}, em: {} },
});
expect(output).toMatchSnapshot();
});
it('ensure performance is acceptable with large input', () => {
const input = '<p>' + '<span>lorem</span>'.repeat(1_000) + '</p>';
const start = performance.now();
html.htmlStringToComponents(input);
const duration = performance.now() - start;
// Arbitrary threshold of 200ms for this test.
// Normally it's much less (<50ms), but the GH Action environment can be slow.
expect(duration).toBeLessThan(200);
});
});
describe('updateHtmlTree', () => {
it('updates nodes in place', () => {
const input = '<p>lorem ipsum</p>';
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const onNode = vi.fn((node: Node) => {
if (node instanceof Element && node.tagName === 'P') {
const newNode = document.createElement('section');
newNode.innerHTML = node.innerHTML;
return newNode;
}
return node;
});
html.updateHtmlTree(wrapper, { onNode });
expect(onNode).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
);
expect(wrapper.innerHTML).toEqual('<section>lorem ipsum</section>');
});
it('removes nodes when onNode returns null', () => {
const input = '<p>lorem <span>ipsum</span></p>';
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const onNode = vi.fn((node: Node) => {
if (node instanceof Element && node.tagName === 'SPAN') {
return null;
}
return node;
});
html.updateHtmlTree(wrapper, { onNode });
expect(onNode).toHaveBeenCalledWith(
expect.objectContaining({ tagName: 'SPAN' }),
);
expect(wrapper.innerHTML).toEqual('<p>lorem </p>');
});
it('replaces text nodes when onText is provided', () => {
const input = '<p>lorem ipsum</p>';
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const replacementNode = document.createElement('button');
replacementNode.textContent = 'click here';
const onText = vi.fn((text: string) => [
text.toUpperCase(),
': ',
replacementNode.cloneNode(true),
]);
html.updateHtmlTree(wrapper, { onText });
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum');
expect(wrapper.innerHTML).toEqual(
'<p>LOREM IPSUM: <button>click here</button></p>',
);
});
});

View File

@ -217,3 +217,58 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
return rootChildren;
}
interface UpdateHtmlTreeOptions {
clone?: boolean;
onNode?: (node: Node) => Node | null;
onText?: (text: string) => (Node | string)[];
}
/**
* Updates a node tree with callbacks, either in-place or by cloning.
*/
export function updateHtmlTree(
root: Element,
{ clone, onNode, onText }: UpdateHtmlTreeOptions,
) {
const rootNode = clone ? root.cloneNode(true) : root;
const queue: Node[] = [rootNode];
while (queue.length > 0) {
const node = queue.shift();
if (!node) {
break;
}
const parentNode = node.parentNode;
switch (node.nodeType) {
case Node.DOCUMENT_FRAGMENT_NODE:
case Node.ELEMENT_NODE: {
for (const child of node.childNodes) {
queue.push(child);
}
if (node.nodeType === Node.ELEMENT_NODE && onNode && parentNode) {
const newNode = onNode(node);
if (!newNode) {
parentNode.removeChild(node);
} else if (newNode !== node) {
parentNode.replaceChild(newNode, node);
}
}
break;
}
case Node.TEXT_NODE: {
if (onText && node.textContent !== null && parentNode) {
const newNodes = onText(node.textContent);
parentNode.replaceChildren(...newNodes);
}
break;
}
}
}
return rootNode;
}