import React, { type CSSProperties, type ReactNode } from 'react' import type { RichTextDocument, RichTextInlineStyle, RichTextNode, RichTextRenderOptions } from '../types' import { cn } from './cn' /** * 限制图片来源协议。 * * React 会自动转义文本和属性,但不会替我们判断 URL 协议是否安全,所以这里仍然做白名单。 */ const sanitizeImageSource = (value: string): string | null => { const normalized = value.trim() if (!normalized) { return null } if (/^https?:\/\//i.test(normalized)) { return normalized } if (/^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]+$/i.test(normalized)) { return normalized } return null } /** * 富文本表情的默认排版。 * * 这些规则属于 richtext 节点的渲染语义,不应该散落在各个平台模板里: * - 表情跟随当前文字大小缩放; * - 作为行内内容参与排版; * - 不撑破行高,也不突破当前文本块宽度。 */ const getRichTextEmojiStyle = (scale: number = 1): CSSProperties => ({ display: 'inline-block', height: `${1.5 * scale}em`, width: 'auto', verticalAlign: scale > 1 ? 'text-bottom' : 'middle', lineHeight: 1 }) /** 统一的搜索图标,避免 template 每次都自己重复拼 JSX。 */ const SearchKeywordIcon = ({ className }: { className?: string }) => { return ( ) } /** 话题图标 */ const TopicIcon = ({ className }: { className?: string }) => ( ) /** 抽奖图标 */ const LotteryIcon = ({ className }: { className?: string }) => ( ) /** 网页链接图标 */ const WebLinkIcon = ({ className }: { className?: string }) => ( ) /** 站内图文链接图标 */ const OpusLinkIcon = ({ className }: { className?: string }) => ( ) /** 投票图标 */ const VoteIcon = ({ className }: { className?: string }) => ( ) /** 查看图片图标 */ const ViewPictureIcon = ({ className }: { className?: string }) => ( ) /** * 渲染带行内样式的文本节点。 */ const renderStyledText = (text: string, style: RichTextInlineStyle | undefined, index: number | string): ReactNode => { if (!style) { return {text} } const classNames: string[] = [] if (style.bold) classNames.push('font-bold') if (style.italic) classNames.push('italic') if (style.strike) classNames.push('line-through') const inlineStyle: CSSProperties = {} if (style.color) { inlineStyle.color = style.color } if (style.fontSize) { inlineStyle.fontSize = style.fontSize } if (style.link) { return ( {text} ) } if (classNames.length === 0 && Object.keys(inlineStyle).length === 0) { return {text} } return ( {text} ) } /** * 将单个富文本节点渲染成 React 节点。 * * 这里的设计重点是"只在 template 侧落地 UI 语义": * - `core` 不需要依赖 React,也不用承担 JSX/SSR 运行时边界。 * - React 负责文本与属性转义,我们只补上图片协议白名单。 */ const renderNodeToReact = (node: RichTextNode, index: number, options: RichTextRenderOptions): ReactNode => { switch (node.type) { case 'text': { const text = node.text const urlRegex = /https?:\/\/[-\w._~:/?#[\]@!$&'()*+,;=%]+/g if (!urlRegex.test(text)) { return renderStyledText(text, node.style, index) } const parts: ReactNode[] = [] let lastIndex = 0 let match: RegExpExecArray | null urlRegex.lastIndex = 0 while ((match = urlRegex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(renderStyledText(text.slice(lastIndex, match.index), node.style, `${index}-pre-${match.index}`)) } parts.push( {renderStyledText(match[0], node.style, `${index}-url-${match.index}`)} ) lastIndex = match.index + match[0].length } if (lastIndex < text.length) { parts.push(renderStyledText(text.slice(lastIndex), node.style, `${index}-post`)) } return {parts} } case 'lineBreak': return
case 'mention': return ( {node.text} ) case 'searchKeyword': return ( {node.text} ) case 'emoji': { const safeSrc = sanitizeImageSource(node.src) if (!safeSrc) { return node.name } return ( {node.name} ) } case 'topic': return ( {node.text} ) case 'hashtag': return ( {node.text} ) case 'at': return ( {node.text} ) case 'lottery': return ( {node.text} ) case 'webLink': return ( {node.text} ) case 'opusLink': return ( {node.text} ) case 'vote': return ( {node.text} ) case 'viewPicture': return ( {node.text} ) case 'heading': { const HeadingTag = `h${node.level}` as keyof React.JSX.IntrinsicElements const sizeClass = node.level === 1 ? 'text-[64px]' : node.level === 2 ? 'text-[58px]' : node.level === 3 ? 'text-[52px]' : node.level === 4 ? 'text-[48px]' : node.level === 5 ? 'text-[44px]' : 'text-[40px]' return ( {node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))} ) } case 'paragraph': return (

{node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))}

) case 'image': { const safeSrc = sanitizeImageSource(node.src) if (!safeSrc) { return null } return (
{node.alt {node.caption &&
{node.caption}
}
) } case 'horizontalRule': return
case 'blockquote': return (
{node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))}
) case 'list': { const ListTag = node.ordered ? 'ol' : 'ul' return ( {node.items.map((item, itemIndex) => renderNodeToReact(item, itemIndex, options))} ) } case 'listItem': return (
  • {node.nodes.map((child, childIndex) => renderNodeToReact(child, childIndex, options))}
  • ) case 'codeBlock': { const lines = node.content.split('\n') const showLang = node.language && node.language !== 'plaintext' return (
    {showLang && (
    {node.language}
    )}
    {lines.map((line, lineIndex) => (
    {lineIndex + 1} {line || ' '}
    ))}
    ) } case 'linkCard': return ( {node.title} ) default: return null } } /** * 将富文本文档渲染成 React 节点。 * * 这是 React 模板的首选渲染方式: * - 不经过 HTML 字符串; * - 不需要 `dangerouslySetInnerHTML`; * - 更适合 SSR,因为传输边界仍然是纯 JSON。 */ export const renderRichTextToReact = (document: RichTextDocument, options: RichTextRenderOptions = {}): ReactNode => { return <>{document.nodes.map((node, index) => renderNodeToReact(node, index, options))} }