Adds new HTMLBlock component (#36262)

This commit is contained in:
Echo 2025-09-26 11:50:59 +02:00 committed by GitHub
commit e07b9dfdc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 260 additions and 79 deletions

View file

@ -26,9 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = `
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
[
<p>
<span>
lorem ipsum
</span>
</p>,
]
`;
@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
[
<p>
lorem
<em>
dolor
</em>

View file

@ -48,7 +48,7 @@ describe('html', () => {
const input = '<p>lorem ipsum</p>';
const onText = vi.fn((text: string) => text);
html.htmlStringToComponents(input, { onText });
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum');
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {});
});
it('calls onElement callback', () => {
@ -61,6 +61,7 @@ describe('html', () => {
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
expect.arrayContaining(['lorem ipsum']),
{},
);
});
@ -71,6 +72,7 @@ describe('html', () => {
expect(onElement).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ tagName: 'P' }),
expect.arrayContaining(['lorem ipsum']),
{},
);
expect(output).toMatchSnapshot();
});
@ -88,15 +90,16 @@ describe('html', () => {
'href',
'https://example.com',
'a',
{},
);
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a');
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', '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: new Set(['p', 'em']),
allowedTags: { p: {}, em: {} },
});
expect(output).toMatchSnapshot();
});

View file

@ -1,5 +1,7 @@
import React from 'react';
import htmlConfig from '../../config/html-tags.json';
// NB: This function can still return unsafe HTML
export const unescapeHTML = (html: string) => {
const wrapper = document.createElement('div');
@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => {
return wrapper.textContent;
};
interface AllowedTag {
/* True means allow, false disallows global attributes, string renames the attribute name for React. */
attributes?: Record<string, boolean | string>;
/* If false, the tag cannot have children. Undefined or true means allowed. */
children?: boolean;
}
type AllowedTagsType = {
[Tag in keyof React.ReactHTML]?: AllowedTag;
};
const globalAttributes: Record<string, boolean | string> = htmlConfig.global;
const defaultAllowedTags: AllowedTagsType = htmlConfig.tags;
interface QueueItem {
node: Node;
parent: React.ReactNode[];
depth: number;
}
interface Options {
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
maxDepth?: number;
onText?: (text: string) => React.ReactNode;
onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: (
element: HTMLElement,
children: React.ReactNode[],
extra: Arg,
) => React.ReactNode;
onAttribute?: (
name: string,
value: string,
tagName: string,
extra: Arg,
) => [string, unknown] | null;
allowedTags?: Set<string>;
allowedTags?: AllowedTagsType;
extraArgs?: Arg;
}
const DEFAULT_ALLOWED_TAGS: ReadonlySet<string> = new Set([
'a',
'abbr',
'b',
'blockquote',
'br',
'cite',
'code',
'del',
'dfn',
'dl',
'dt',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'li',
'ol',
'p',
'pre',
'small',
'span',
'strong',
'sub',
'sup',
'time',
'u',
'ul',
]);
export function htmlStringToComponents(
let uniqueIdCounter = 0;
export function htmlStringToComponents<Arg extends Record<string, unknown>>(
htmlString: string,
options: Options = {},
options: HTMLToStringOptions<Arg> = {},
) {
const wrapper = document.createElement('template');
wrapper.innerHTML = htmlString;
@ -79,10 +66,11 @@ export function htmlStringToComponents(
const {
maxDepth = 10,
allowedTags = DEFAULT_ALLOWED_TAGS,
allowedTags = defaultAllowedTags,
onAttribute,
onElement,
onText,
extraArgs = {} as Arg,
} = options;
while (queue.length > 0) {
@ -109,9 +97,9 @@ export function htmlStringToComponents(
// Text can be added directly if it has any non-whitespace content.
case Node.TEXT_NODE: {
const text = node.textContent;
if (text && text.trim() !== '') {
if (text) {
if (onText) {
parent.push(onText(text));
parent.push(onText(text, extraArgs));
} else {
parent.push(text);
}
@ -127,7 +115,9 @@ export function htmlStringToComponents(
}
// If the tag is not allowed, skip it and its children.
if (!allowedTags.has(node.tagName.toLowerCase())) {
const tagName = node.tagName.toLowerCase();
const tagInfo = allowedTags[tagName as keyof typeof allowedTags];
if (!tagInfo) {
continue;
}
@ -137,7 +127,8 @@ export function htmlStringToComponents(
// If onElement is provided, use it to create the element.
if (onElement) {
const component = onElement(node, children);
const component = onElement(node, children, extraArgs);
// Check for undefined to allow returning null.
if (component !== undefined) {
element = component;
@ -147,25 +138,56 @@ export function htmlStringToComponents(
// If the element wasn't created, use the default conversion.
if (element === undefined) {
const props: Record<string, unknown> = {};
props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
for (const attr of node.attributes) {
let name = attr.name.toLowerCase();
// Custom attribute handler.
if (onAttribute) {
const result = onAttribute(
attr.name,
name,
attr.value,
node.tagName.toLowerCase(),
extraArgs,
);
if (result) {
const [name, value] = result;
props[name] = value;
const [cbName, value] = result;
props[cbName] = value;
}
} else {
props[attr.name] = attr.value;
// Check global attributes first, then tag-specific ones.
const globalAttr = globalAttributes[name];
const tagAttr = tagInfo.attributes?.[name];
// Exit if neither global nor tag-specific attribute is allowed.
if (!globalAttr && !tagAttr) {
continue;
}
// Rename if needed.
if (typeof tagAttr === 'string') {
name = tagAttr;
} else if (typeof globalAttr === 'string') {
name = globalAttr;
}
let value: string | boolean | number = attr.value;
// Handle boolean attributes.
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
}
props[name] = value;
}
}
element = React.createElement(
node.tagName.toLowerCase(),
tagName,
props,
children,
tagInfo.children !== false ? children : undefined,
);
}