Files
cinny/src/app/plugins/react-custom-html-parser.tsx
T
Lotus CIandClaude Opus 5.5 1ea6987083
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 9m19s
fix(desktop): Lotus links use the public web app, and open in-app (#248)
Lotus permalinks (#130) were built from window.location.origin. In the
desktop app that's the local tauri-plugin-localhost server (hash-routed), so
"Copy Lotus Link" copied e.g. http://localhost:…/#/home/!room…, which works
for nobody else. And the link recogniser only knew that local base, so a real
https://chat.lotusguild.org/home/… link in a message opened the browser
instead of the room.

- useLotusShareBase: in the desktop app, links for other people use config
  `webAppUrl` (https only, set by cinny-desktop #23) in web path-routing
  form; otherwise the origin, as before. Used by "Copy Lotus Link" on
  messages, the space menu and space tabs.
- The recogniser accepts several bases: the origin, plus `webAppUrl` in the
  desktop app.
- The web app is unchanged.

Verified with a simulated desktop (Tauri bridge + webAppUrl) against a local
Synapse. Copied links are https://chat.lotusguild.org/home/<room>/<event> and
https://chat.lotusguild.org/<space>. A public link in a message renders as
the room pill and clicking it opens the room in-app, with nothing sent to the
system browser. The web app still copies origin links. Unit tests for the
base selection; Playwright 20 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-28 19:39:48 -04:00

763 lines
25 KiB
TypeScript

import React, {
type JSX,
ComponentPropsWithoutRef,
ReactEventHandler,
Suspense,
lazy,
useMemo,
useState,
} from 'react';
import {
DOMNode,
Element,
Text as DOMText,
HTMLReactParserOptions,
attributesToProps,
domToReact,
} from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames';
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import { stripTrackingParams } from '../utils/urlTracking';
import { analyzeLink } from '../utils/linkSafety';
import { SuspiciousLink } from '../components/suspicious-link';
import { lotusPermalinkToMatrixTo } from './lotus-permalink';
import * as css from '../styles/CustomHtml.css';
import {
getMxIdLocalPart,
getCanonicalAliasRoomId,
isRoomAlias,
mxcUrlToHttp,
} from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room';
import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex';
import { getHexcodeForEmoji, getShortcodeFor } from './emoji';
import { findAndReplace } from '../utils/findAndReplace';
import {
parseMatrixToRoom,
parseMatrixToRoomEvent,
parseMatrixToUser,
testMatrixTo,
} from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard';
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
import { tokenize, tokenStyle } from '../utils/syntaxHighlight';
import { splitMathSegments } from '../utils/mathParse';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
// KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import
// and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx.
const KaTeXMath = lazy(() => import('../components/math/KaTeX'));
/** Languages handled by the custom TDS tokenizer. */
const TDS_TOKENIZER_LANGS = new Set([
'js',
'javascript',
'ts',
'typescript',
'jsx',
'tsx',
'py',
'python',
'rs',
'rust',
]);
/**
* Renders a code string as an array of coloured <span> elements using the
* lightweight TDS tokenizer. Falls back to a plain text node when the
* language is not in the supported set.
*/
function renderTokenizedCode(code: string, lang: string): React.ReactNode {
const normalised = lang.toLowerCase().replace(/^language-/, '');
if (!TDS_TOKENIZER_LANGS.has(normalised)) return code;
const tokens = tokenize(code, normalised);
return tokens.map((tok, idx) => (
<span key={idx} style={tok.type !== 'plain' ? tokenStyle(tok.type) : undefined}>
{tok.text}
</span>
));
}
/**
* Renders LaTeX via the lazily-loaded KaTeX component.
*
* `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text).
* `errorFallback` is shown if rendering fails outright — for the spec
* `data-mx-maths` path this is the element's original children (the spec
* fallback content); for the plain-text `$…$` path it is the raw source.
*/
const renderMath = (
latex: string,
displayMode: boolean,
suspenseFallback: React.ReactNode,
errorFallback: React.ReactNode,
): JSX.Element => (
<ErrorBoundary fallback={<>{errorFallback}</>}>
<Suspense fallback={<>{suspenseFallback}</>}>
<KaTeXMath latex={latex} displayMode={displayMode} />
</Suspense>
</ErrorBoundary>
);
const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g');
// [Gitea #103] Render-time tracking-param stripping. LINKIFY_OPTS is spread
// into memoised per-timeline option objects, so the toggle lives in a module
// flag that the format callbacks read lazily on every render; the setting's
// subscriber (ClientNonUIFeatures) keeps it current.
let stripTrackingOnRender = true;
export const setStripTrackingOnRender = (enabled: boolean): void => {
stripTrackingOnRender = enabled;
};
const cleanHref = (href: string): string =>
stripTrackingOnRender ? stripTrackingParams(href) : href;
// [Gitea #130] Links to THIS deployment's room routes render and click like
// matrix.to links. The bases are set once from the client config
// (ClientNonUIFeatures) because this module has no access to hooks. [Gitea
// #248] Several: the desktop app knows both its local origin and the public web
// app (`webAppUrl`), and people share the public one.
let lotusPermalinkBases: string[] = [];
export const setLotusPermalinkBase = (baseUrls: string | string[] | undefined): void => {
lotusPermalinkBases = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).filter(
(b): b is string => !!b,
);
};
/**
* The matrix.to form of `href` when it is a matrix.to link or a Lotus
* permalink; undefined for every other link.
*/
export const toMatrixToHref = (href: string): string | undefined => {
if (testMatrixTo(href)) return href;
for (const base of lotusPermalinkBases) {
const matrixTo = lotusPermalinkToMatrixTo(base, href);
if (matrixTo) return matrixTo;
}
return undefined;
};
export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
rel: 'noreferrer noopener',
},
formatHref: {
url: cleanHref,
},
// Only the visible text of bare URLs is a URL, so cleaning it keeps the
// label honest about where the link goes.
format: {
url: cleanHref,
},
validate: {
url: (value) => /^(https?|ftp|mailto|magnet):/.test(value),
},
ignoreTags: ['span'],
};
export const makeMentionCustomProps = (
handleMentionClick?: ReactEventHandler<HTMLElement>,
content?: string,
): ComponentPropsWithoutRef<'a'> => ({
style: { cursor: 'pointer' },
target: '_blank',
rel: 'noreferrer noopener',
role: 'link',
tabIndex: handleMentionClick ? 0 : -1,
onKeyDown: handleMentionClick ? onEnterOrSpace(handleMentionClick) : undefined,
onClick: handleMentionClick,
children: content,
});
export const renderMatrixMention = (
mx: MatrixClient,
currentRoomId: string | undefined,
href: string,
customProps: ComponentPropsWithoutRef<'a'>,
) => {
const userId = parseMatrixToUser(href);
if (userId) {
const currentRoom = mx.getRoom(currentRoomId);
return (
<a
href={href}
{...customProps}
className={css.Mention({ highlight: mx.getUserId() === userId })}
data-mention-id={userId}
>
{`@${
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
}`}
</a>
);
}
const matrixToRoom = parseMatrixToRoom(href);
if (matrixToRoom) {
const { roomIdOrAlias, viaServers } = matrixToRoom;
const mentionRoom = mx.getRoom(
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
);
const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;
return (
<a
href={href}
{...customProps}
className={css.Mention({
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
})}
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
data-mention-via={viaServers?.join(',')}
>
{customProps.children ? customProps.children : fallbackContent}
</a>
);
}
const matrixToRoomEvent = parseMatrixToRoomEvent(href);
if (matrixToRoomEvent) {
const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent;
const mentionRoom = mx.getRoom(
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias,
);
return (
<a
href={href}
{...customProps}
className={css.Mention({
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
})}
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
data-mention-event-id={eventId}
data-mention-via={viaServers?.join(',')}
>
{customProps.children
? customProps.children
: `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`}
</a>
);
}
return undefined;
};
export const factoryRenderLinkifyWithMention = (
mentionRender: (href: string) => JSX.Element | undefined,
): OptFn<(ir: IntermediateRepresentation) => any> => {
const render: OptFn<(ir: IntermediateRepresentation) => any> = ({
tagName,
attributes,
content,
}) => {
const matrixHref =
tagName === 'a' ? toMatrixToHref(tryDecodeURIComponent(attributes.href)) : undefined;
if (matrixHref) {
const mention = mentionRender(matrixHref);
if (mention) return mention;
}
return <a {...attributes}>{content}</a>;
};
return render;
};
export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
findAndReplace(
text,
EMOJI_REG_G,
(match, pushIndex) => {
const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0]));
return (
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
<span
className={css.Emoticon()}
title={shortcode}
aria-label={shortcode || undefined}
role={shortcode ? 'img' : undefined}
>
{match[0]}
</span>
</span>
);
},
(txt) => txt,
);
export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => {
const pattern = highlights.map(sanitizeForRegex).join('|');
if (!pattern) return undefined;
return new RegExp(pattern, 'gi');
};
export const highlightText = (
regex: RegExp,
data: (string | JSX.Element)[],
): (string | JSX.Element)[] =>
data.flatMap((text) => {
if (typeof text !== 'string') return text;
return findAndReplace(
text,
regex,
(match, pushIndex) => (
<span key={`highlight-${pushIndex}`} className={css.highlightText}>
{match[0]}
</span>
),
(txt) => txt,
);
});
/**
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
*
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
* @returns {string} The concatenated plain text content of all descendant text nodes.
*/
const extractTextFromChildren = (nodes: ChildNode[]): string => {
let text = '';
nodes.forEach((node) => {
if (node.type === 'text') {
text += node.data;
} else if (node instanceof Element && node.children) {
text += extractTextFromChildren(node.children);
}
});
return text;
};
export // Session memory for the code-block Wrap toggle (#131).
const codeWrapPreference = { value: false };
function CodeBlock({ children, opts }: { children: ChildNode[]; opts: HTMLReactParserOptions }) {
const code = children[0];
const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const language =
languageClass && languageClass.startsWith('language-')
? languageClass.replace('language-', '')
: languageClass;
const LINE_LIMIT = 14;
const largeCodeBlock = useMemo(
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
[children],
);
const [expanded, setExpand] = useState(false);
const [copied, setCopied] = useTimeoutToggle();
// [Gitea #131] Line-wrap toggle for long lines; the choice is remembered for
// the session (module-level), not persisted.
const [wrap, setWrap] = useState(codeWrapPreference.value);
const toggleWrap = () => {
codeWrapPreference.value = !wrap;
setWrap(!wrap);
};
const handleCopy = () => {
copyToClipboard(extractTextFromChildren(children));
setCopied();
};
const toggleExpand = () => {
setExpand(!expanded);
};
return (
<Text size="T300" as="pre" className={css.CodeBlock}>
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
<Box grow="Yes">
<Text size="L400" truncate>
{customLabel ?? language ?? 'Code'}
</Text>
</Box>
<Box shrink="No" gap="200">
<Chip
variant={wrap ? 'Primary' : 'Surface'}
fill="None"
radii="Pill"
onClick={toggleWrap}
aria-pressed={wrap}
title={wrap ? 'Scroll long lines instead of wrapping' : 'Wrap long lines'}
>
<Text size="B300">Wrap</Text>
</Chip>
<Chip
variant={copied ? 'Success' : 'Surface'}
fill="None"
radii="Pill"
onClick={handleCopy}
before={copied && <Icon size="50" src={Icons.Check} />}
>
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
</Chip>
{largeCodeBlock && (
<IconButton
size="300"
variant="SurfaceVariant"
outlined
radii="300"
onClick={toggleExpand}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
</IconButton>
)}
</Box>
</Header>
<Scroll
style={{
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
}}
direction="Both"
variant="SurfaceVariant"
size="300"
visibility="Hover"
hideTrack
>
<div
id="code-block-content"
className={css.CodeBlockInternal}
style={wrap ? { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } : undefined}
>
{domToReact(children as unknown as DOMNode[], opts)}
</div>
</Scroll>
{largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
</Text>
);
}
export const getReactCustomHtmlParser = (
mx: MatrixClient,
roomId: string | undefined,
params: {
linkifyOpts: LinkifyOpts;
highlightRegex?: RegExp;
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
handleMentionClick?: ReactEventHandler<HTMLElement>;
useAuthentication?: boolean;
},
): HTMLReactParserOptions => {
const opts: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode instanceof Element && 'name' in domNode) {
const { name, attribs, children, parent } = domNode;
// [Gitea #103] Clean explicit <a href> targets from formatted_body
// before any branch below (mention detection, default render) sees them.
if (name === 'a' && stripTrackingOnRender && typeof attribs.href === 'string') {
const cleaned = stripTrackingParams(attribs.href);
if (cleaned !== attribs.href) attribs.href = cleaned;
}
const props = attributesToProps(attribs);
if (name === 'h1') {
return (
<Text {...props} className={css.Heading} size="H2">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'h2') {
return (
<Text {...props} className={css.Heading} size="H3">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'h3') {
return (
<Text {...props} className={css.Heading} size="H4">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'h4') {
return (
<Text {...props} className={css.Heading} size="H4">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'h5') {
return (
<Text {...props} className={css.Heading} size="H5">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'h6') {
return (
<Text {...props} className={css.Heading} size="H6">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'p') {
return (
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'pre') {
return <CodeBlock opts={opts}>{children}</CodeBlock>;
}
if (name === 'table') {
// Sanitize allows tables, but a wide one would otherwise overflow the
// message column and the page body on narrow screens. Wrap it in a
// horizontally-scrollable container (same idea as CodeBlock's Scroll).
return (
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
<table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
</div>
);
}
if (name === 'blockquote') {
return (
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
if (name === 'ul') {
return (
<ul {...props} className={css.List}>
{domToReact(children as unknown as DOMNode[], opts)}
</ul>
);
}
if (name === 'ol') {
return (
<ol {...props} className={css.List}>
{domToReact(children as unknown as DOMNode[], opts)}
</ol>
);
}
if (name === 'code') {
if (parent && 'name' in parent && parent.name === 'pre') {
const codeReact = domToReact(children as unknown as DOMNode[], opts);
if (typeof codeReact === 'string') {
let lang: string | undefined =
typeof props.className === 'string' ? props.className : undefined;
if (lang === 'language-rs') lang = 'language-rust';
else if (lang === 'language-js') lang = 'language-javascript';
else if (lang === 'language-ts') lang = 'language-typescript';
// Use lightweight TDS tokenizer for supported languages to render
// coloured <span> elements with inline TDS CSS variable styles.
const normLang = (lang ?? '').toLowerCase().replace(/^language-/, '');
if (TDS_TOKENIZER_LANGS.has(normLang)) {
return (
<code {...props} className={lang}>
{renderTokenizedCode(codeReact, normLang)}
</code>
);
}
return (
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
<Suspense fallback={<code {...props}>{codeReact}</code>}>
<ReactPrism>
{(ref) => (
<code ref={ref} {...props} className={lang}>
{codeReact}
</code>
)}
</ReactPrism>
</Suspense>
</ErrorBoundary>
);
}
} else {
return (
<Text as="code" size="T300" className={css.Code} {...props}>
{domToReact(children as unknown as DOMNode[], opts)}
</Text>
);
}
}
const matrixHref =
name === 'a' ? toMatrixToHref(tryDecodeURIComponent(String(props.href))) : undefined;
if (matrixHref) {
const content = children.find((child) => !(child instanceof DOMText))
? undefined
: children.map((c) => (c instanceof DOMText ? c.data : '')).join();
const mention = renderMatrixMention(
mx,
roomId,
matrixHref,
makeMentionCustomProps(params.handleMentionClick, content),
);
if (mention) return mention;
}
// [Gitea #122] Link text that names another site than the href (or a
// punycode destination) gets a confirm on click instead of a straight
// navigation. Only plain-text anchors are checked — an image or
// formatted child isn't a URL claim.
if (name === 'a' && typeof props.href === 'string' && !matrixHref) {
// Duck-typed: with duplicate domhandler copies (dev server) the
// parser's Text class is not the one we import, so instanceof lies.
const textOnly = children.every((c) => c.type === 'text');
const visible = textOnly
? children.map((c) => (c.type === 'text' ? (c as DOMText).data : '')).join('')
: '';
const safety = textOnly ? analyzeLink(visible, String(props.href)) : null;
if (safety && (safety.mismatch || safety.punycode)) {
return (
<SuspiciousLink href={String(props.href)} safety={safety} anchorProps={props}>
{domToReact(children as unknown as DOMNode[], opts)}
</SuspiciousLink>
);
}
}
if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) {
// Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX
// (block for <div>, inline for <span>). On failure fall back to the
// element's existing children, which the spec defines as the fallback
// representation.
const latex = String(props['data-mx-maths']);
const displayMode = name === 'div';
const fallback = displayMode ? (
<div {...props}>{domToReact(children as unknown as DOMNode[], opts)}</div>
) : (
<span {...props}>{domToReact(children as unknown as DOMNode[], opts)}</span>
);
return renderMath(latex, displayMode, latex, fallback);
}
if (name === 'span' && 'data-mx-spoiler' in props) {
return (
<span
{...props}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e);
}}
onClick={params.handleSpoilerClick}
className={css.Spoiler()}
aria-label="Spoiler — click to reveal"
aria-pressed
style={{ cursor: 'pointer' }}
>
{domToReact(children as unknown as DOMNode[], opts)}
</span>
);
}
if (name === 'img') {
const htmlSrc = mxcUrlToHttp(mx, String(props.src), params.useAuthentication);
if (htmlSrc && String(props.src).startsWith('mxc://') === false) {
return (
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || htmlSrc}
</a>
);
}
if (htmlSrc && 'data-mx-emoticon' in props) {
const emoticonAlt =
(typeof props.alt === 'string' && props.alt) ||
(typeof props.title === 'string' && props.title) ||
'emoji';
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()}>
<img
{...props}
alt={emoticonAlt}
className={css.EmoticonImg}
src={htmlSrc}
loading="lazy"
/>
</span>
</span>
);
}
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} loading="lazy" />;
}
}
if (domNode instanceof DOMText) {
const parentName =
domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined;
const linkify = parentName !== 'code' && parentName !== 'a';
// Never parse `$…$`/`$$…$$` math inside <pre>/<code> (verbatim regions).
const mathAllowed = parentName !== 'code' && parentName !== 'pre';
const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
let jsx = scaleSystemEmoji(text);
if (params.highlightRegex) {
jsx = highlightText(params.highlightRegex, jsx);
}
if (linkify) {
return <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
}
return jsx;
};
if (mathAllowed) {
const segments = splitMathSegments(domNode.data);
if (segments.some((segment) => segment.type !== 'text')) {
return (
<>
{segments.map((segment, index) => {
if (segment.type === 'text') {
return (
<React.Fragment key={index}>{renderTextChunk(segment.value)}</React.Fragment>
);
}
const raw =
segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
return (
<React.Fragment key={index}>
{renderMath(segment.value, segment.type === 'block', raw, raw)}
</React.Fragment>
);
})}
</>
);
}
}
return renderTextChunk(domNode.data);
}
return undefined;
},
};
return opts;
};