diff --git a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap b/app/javascript/mastodon/utils/__snapshots__/html.test.ts.snap similarity index 95% rename from app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap rename to app/javascript/mastodon/utils/__snapshots__/html.test.ts.snap index ea4561bc610..983ad46d19c 100644 --- a/app/javascript/mastodon/utils/__tests__/__snapshots__/html-test.ts.snap +++ b/app/javascript/mastodon/utils/__snapshots__/html.test.ts.snap @@ -15,7 +15,7 @@ exports[`html > htmlStringToComponents > copies attributes to props 1`] = ` exports[`html > htmlStringToComponents > handles nested elements 1`] = ` [

- lorem + lorem ipsum @@ -26,11 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = ` exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = ` [

- + lorem ipsum - +

, ] `; @@ -38,8 +38,8 @@ exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = ` exports[`html > htmlStringToComponents > respects allowedTags option 1`] = ` [

- lorem - + lorem + dolor diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts deleted file mode 100644 index a48a8b572b3..00000000000 --- a/app/javascript/mastodon/utils/__tests__/html-test.ts +++ /dev/null @@ -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( - '

lorem

ipsum


<br>', - ); - expect(output).toEqual('lorem\n\nipsum\n
'); - }); - }); - - describe('htmlStringToComponents', () => { - it('returns converted nodes from string', () => { - const input = '

lorem ipsum

'; - const output = html.htmlStringToComponents(input); - expect(output).toMatchSnapshot(); - }); - - it('handles nested elements', () => { - const input = '

lorem ipsum

'; - const output = html.htmlStringToComponents(input); - expect(output).toMatchSnapshot(); - }); - - it('ignores empty text nodes', () => { - const input = '

lorem ipsum

'; - const output = html.htmlStringToComponents(input); - expect(output).toMatchSnapshot(); - }); - - it('copies attributes to props', () => { - const input = - 'link'; - const output = html.htmlStringToComponents(input); - expect(output).toMatchSnapshot(); - }); - - it('respects maxDepth option', () => { - const input = '

lorem ipsum

'; - const output = html.htmlStringToComponents(input, { maxDepth: 2 }); - expect(output).toMatchSnapshot(); - }); - - it('calls onText callback', () => { - const input = '

lorem ipsum

'; - const onText = vi.fn((text: string) => text); - html.htmlStringToComponents(input, { onText }); - expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {}); - }); - - it('calls onElement callback', () => { - const input = '

lorem ipsum

'; - const onElement = vi.fn( - (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 = '

lorem ipsum

'; - 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 = - 'link'; - 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 = '

lorem ipsum dolor

'; - const output = html.htmlStringToComponents(input, { - allowedTags: { p: {}, em: {} }, - }); - expect(output).toMatchSnapshot(); - }); - - it('ensure performance is acceptable with large input', () => { - const input = '

' + 'lorem'.repeat(1_000) + '

'; - 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); - }); - }); -}); diff --git a/app/javascript/mastodon/utils/html.test.ts b/app/javascript/mastodon/utils/html.test.ts new file mode 100644 index 00000000000..41348bc498a --- /dev/null +++ b/app/javascript/mastodon/utils/html.test.ts @@ -0,0 +1,171 @@ +import React from 'react'; + +import * as html from './html'; + +describe('unescapeHTML', () => { + it('returns unescaped HTML', () => { + const output = html.unescapeHTML('

lorem

ipsum


<br>'); + expect(output).toEqual('lorem\n\nipsum\n
'); + }); +}); + +describe('htmlStringToComponents', () => { + it('returns converted nodes from string', () => { + const input = '

lorem ipsum

'; + const output = html.htmlStringToComponents(input); + expect(output).toMatchSnapshot(); + }); + + it('handles nested elements', () => { + const input = '

lorem ipsum

'; + const output = html.htmlStringToComponents(input); + expect(output).toMatchSnapshot(); + }); + + it('ignores empty text nodes', () => { + const input = '

lorem ipsum

'; + const output = html.htmlStringToComponents(input); + expect(output).toMatchSnapshot(); + }); + + it('copies attributes to props', () => { + const input = + 'link'; + const output = html.htmlStringToComponents(input); + expect(output).toMatchSnapshot(); + }); + + it('respects maxDepth option', () => { + const input = '

lorem ipsum

'; + const output = html.htmlStringToComponents(input, { maxDepth: 2 }); + expect(output).toMatchSnapshot(); + }); + + it('calls onText callback', () => { + const input = '

lorem ipsum

'; + const onText = vi.fn((text: string) => text); + html.htmlStringToComponents(input, { onText }); + expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {}); + }); + + it('calls onElement callback', () => { + const input = '

lorem ipsum

'; + const onElement = vi.fn((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 = '

lorem ipsum

'; + 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 = + 'link'; + 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 = '

lorem ipsum dolor

'; + const output = html.htmlStringToComponents(input, { + allowedTags: { p: {}, em: {} }, + }); + expect(output).toMatchSnapshot(); + }); + + it('ensure performance is acceptable with large input', () => { + const input = '

' + 'lorem'.repeat(1_000) + '

'; + 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 = '

lorem ipsum

'; + 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('
lorem ipsum
'); + }); + + it('removes nodes when onNode returns null', () => { + const input = '

lorem ipsum

'; + 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('

lorem

'); + }); + + it('replaces text nodes when onText is provided', () => { + const input = '

lorem ipsum

'; + 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( + '

LOREM IPSUM:

', + ); + }); +}); diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index c87b5a34cfc..cefac0fd797 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -217,3 +217,58 @@ export function htmlStringToComponents>( 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; +}