mirror of
https://github.com/mastodon/mastodon.git
synced 2026-05-09 04:22:42 -05:00
add function to update HTML trees
This commit is contained in:
parent
86e4ecfa20
commit
e67f291b22
|
|
@ -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>
|
||||
|
|
@ -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><br>',
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
app/javascript/mastodon/utils/html.test.ts
Normal file
171
app/javascript/mastodon/utils/html.test.ts
Normal 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><br>');
|
||||
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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user