Adds new HTMLBlock component (#36262)

This commit is contained in:
Echo 2025-09-26 11:50:59 +02:00 committed by GitHub
parent 1571514e49
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

@ -0,0 +1,61 @@
{
"global": {
"class": "className",
"id": true,
"title": true,
"dir": true,
"lang": true
},
"tags": {
"p": {},
"br": {
"children": false
},
"span": {
"attributes": {
"translate": true
}
},
"a": {
"attributes": {
"href": true,
"rel": true,
"translate": true,
"target": true
}
},
"del": {},
"s": {},
"pre": {},
"blockquote": {},
"code": {},
"b": {},
"strong": {},
"u": {},
"i": {},
"img": {
"children": false,
"attributes": {
"src": true,
"alt": true,
"title": true
}
},
"em": {},
"ul": {},
"ol": {
"attributes": {
"start": true,
"reversed": true
}
},
"li": {
"attributes": {
"value": true
}
},
"ruby": {},
"rt": {},
"rp": {}
}
}

View File

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect } from 'storybook/test';
import { HTMLBlock } from './index';
const meta = {
title: 'Components/HTMLBlock',
component: HTMLBlock,
args: {
contents:
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
},
render(args) {
return (
// Just for visual clarity in Storybook.
<div
style={{
border: '1px solid black',
padding: '1rem',
minWidth: '300px',
}}
>
<HTMLBlock {...args} />
</div>
);
},
} satisfies Meta<typeof HTMLBlock>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
async play({ canvas }) {
const link = canvas.queryByRole('link');
await expect(link).toBeInTheDocument();
const button = canvas.queryByRole('button');
await expect(button).not.toBeInTheDocument();
},
};

View File

@ -0,0 +1,50 @@
import type { FC, ReactNode } from 'react';
import { useMemo } from 'react';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { htmlStringToComponents } from '../../utils/html';
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
interface HTMLBlockProps {
contents: string;
extraEmojis?: CustomEmojiMapArg;
}
export const HTMLBlock: FC<HTMLBlockProps> = ({
contents: raw,
extraEmojis,
}) => {
const customEmojis = useMemo(
() => cleanExtraEmojis(extraEmojis),
[extraEmojis],
);
const contents = useMemo(() => {
const key = JSON.stringify({ raw, customEmojis });
if (cache.has(key)) {
return cache.get(key);
}
const rendered = htmlStringToComponents(raw, {
onText,
extraArgs: { customEmojis },
});
cache.set(key, rendered);
return rendered;
}, [raw, customEmojis]);
return contents;
};
function onText(
text: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
) {
return text;
}

View File

@ -1,19 +1,13 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { isList } from 'immutable';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { useAppSelector } from '@/mastodon/store'; import { useAppSelector } from '@/mastodon/store';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode'; import { determineEmojiMode } from './mode';
import { cleanExtraEmojis } from './normalize';
import { emojifyElement, emojifyText } from './render'; import { emojifyElement, emojifyText } from './render';
import type { import type { CustomEmojiMapArg, EmojiAppState } from './types';
CustomEmojiMapArg,
EmojiAppState,
ExtraCustomEmojiMap,
} from './types';
import { stringHasAnyEmoji } from './utils'; import { stringHasAnyEmoji } from './utils';
interface UseEmojifyOptions { interface UseEmojifyOptions {
@ -30,20 +24,7 @@ export function useEmojify({
const [emojifiedText, setEmojifiedText] = useState<string | null>(null); const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
const appState = useEmojiAppState(); const appState = useEmojiAppState();
const extra: ExtraCustomEmojiMap = useMemo(() => { const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
if (!extraEmojis) {
return {};
}
if (isList(extraEmojis)) {
return (
extraEmojis.toJS() as ApiCustomEmojiJSON[]
).reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
return extraEmojis;
}, [extraEmojis]);
const emojify = useCallback( const emojify = useCallback(
async (input: string) => { async (input: string) => {
@ -51,11 +32,11 @@ export function useEmojify({
if (deep) { if (deep) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = input; wrapper.innerHTML = input;
if (await emojifyElement(wrapper, appState, extra)) { if (await emojifyElement(wrapper, appState, extra ?? {})) {
result = wrapper.innerHTML; result = wrapper.innerHTML;
} }
} else { } else {
result = await emojifyText(text, appState, extra); result = await emojifyText(text, appState, extra ?? {});
} }
if (result) { if (result) {
setEmojifiedText(result); setEmojifiedText(result);

View File

@ -1,3 +1,5 @@
import { isList } from 'immutable';
import { import {
VARIATION_SELECTOR_CODE, VARIATION_SELECTOR_CODE,
KEYCAP_CODE, KEYCAP_CODE,
@ -7,7 +9,11 @@ import {
EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER, EMOJIS_WITH_LIGHT_BORDER,
} from './constants'; } from './constants';
import type { TwemojiBorderInfo } from './types'; import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
TwemojiBorderInfo,
} from './types';
// Misc codes that have special handling // Misc codes that have special handling
const SKIER_CODE = 0x26f7; const SKIER_CODE = 0x26f7;
@ -150,6 +156,21 @@ export function twemojiToUnicodeInfo(
return hexNumbersToString(mappedCodes); return hexNumbersToString(mappedCodes);
} }
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
if (!extraEmojis) {
return null;
}
if (!isList(extraEmojis)) {
return extraEmojis;
}
return extraEmojis
.toJSON()
.reduce<ExtraCustomEmojiMap>(
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
{},
);
}
function hexStringToNumbers(hexString: string): number[] { function hexStringToNumbers(hexString: string): number[] {
return hexString return hexString
.split('-') .split('-')

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import htmlConfig from '../../config/html-tags.json';
// NB: This function can still return unsafe HTML // NB: This function can still return unsafe HTML
export const unescapeHTML = (html: string) => { export const unescapeHTML = (html: string) => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => {
return wrapper.textContent; 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 { interface QueueItem {
node: Node; node: Node;
parent: React.ReactNode[]; parent: React.ReactNode[];
depth: number; depth: number;
} }
interface Options { export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
maxDepth?: number; maxDepth?: number;
onText?: (text: string) => React.ReactNode; onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: ( onElement?: (
element: HTMLElement, element: HTMLElement,
children: React.ReactNode[], children: React.ReactNode[],
extra: Arg,
) => React.ReactNode; ) => React.ReactNode;
onAttribute?: ( onAttribute?: (
name: string, name: string,
value: string, value: string,
tagName: string, tagName: string,
extra: Arg,
) => [string, unknown] | null; ) => [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, htmlString: string,
options: Options = {}, options: HTMLToStringOptions<Arg> = {},
) { ) {
const wrapper = document.createElement('template'); const wrapper = document.createElement('template');
wrapper.innerHTML = htmlString; wrapper.innerHTML = htmlString;
@ -79,10 +66,11 @@ export function htmlStringToComponents(
const { const {
maxDepth = 10, maxDepth = 10,
allowedTags = DEFAULT_ALLOWED_TAGS, allowedTags = defaultAllowedTags,
onAttribute, onAttribute,
onElement, onElement,
onText, onText,
extraArgs = {} as Arg,
} = options; } = options;
while (queue.length > 0) { while (queue.length > 0) {
@ -109,9 +97,9 @@ export function htmlStringToComponents(
// Text can be added directly if it has any non-whitespace content. // Text can be added directly if it has any non-whitespace content.
case Node.TEXT_NODE: { case Node.TEXT_NODE: {
const text = node.textContent; const text = node.textContent;
if (text && text.trim() !== '') { if (text) {
if (onText) { if (onText) {
parent.push(onText(text)); parent.push(onText(text, extraArgs));
} else { } else {
parent.push(text); parent.push(text);
} }
@ -127,7 +115,9 @@ export function htmlStringToComponents(
} }
// If the tag is not allowed, skip it and its children. // 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; continue;
} }
@ -137,7 +127,8 @@ export function htmlStringToComponents(
// If onElement is provided, use it to create the element. // If onElement is provided, use it to create the element.
if (onElement) { if (onElement) {
const component = onElement(node, children); const component = onElement(node, children, extraArgs);
// Check for undefined to allow returning null. // Check for undefined to allow returning null.
if (component !== undefined) { if (component !== undefined) {
element = component; element = component;
@ -147,25 +138,56 @@ export function htmlStringToComponents(
// If the element wasn't created, use the default conversion. // If the element wasn't created, use the default conversion.
if (element === undefined) { if (element === undefined) {
const props: Record<string, unknown> = {}; const props: Record<string, unknown> = {};
props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
for (const attr of node.attributes) { for (const attr of node.attributes) {
let name = attr.name.toLowerCase();
// Custom attribute handler.
if (onAttribute) { if (onAttribute) {
const result = onAttribute( const result = onAttribute(
attr.name, name,
attr.value, attr.value,
node.tagName.toLowerCase(), node.tagName.toLowerCase(),
extraArgs,
); );
if (result) { if (result) {
const [name, value] = result; const [cbName, value] = result;
props[name] = value; props[cbName] = value;
} }
} else { } 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( element = React.createElement(
node.tagName.toLowerCase(), tagName,
props, props,
children, tagInfo.children !== false ? children : undefined,
); );
} }