import type { ComponentType } from 'react' import type { Components, StreamdownProps } from 'streamdown' import { createMathPlugin } from '@streamdown/math' import dynamic from 'next/dynamic' import { memo, useMemo } from 'react' import RemarkBreaks from 'remark-breaks' import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown' import { AudioBlock, Img, Link, MarkdownButton, MarkdownForm, Paragraph, PluginImg, PluginParagraph, ThinkBlock, VideoBlock, } from '@/app/components/base/markdown-blocks' import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' import { customUrlTransform } from './markdown-utils' import 'katex/dist/katex.min.css' type PluggableList = NonNullable type Pluggable = PluggableList[number] type AttributeDefinition = string | [string, ...(string | boolean | RegExp)[]] type SanitizeSchema = { tagNames?: string[] attributes?: Record required?: Record> clobber?: string[] clobberPrefix?: string [key: string]: unknown } const CodeBlock = dynamic(() => import('@/app/components/base/markdown-blocks/code-block'), { ssr: false }) const mathPlugin = createMathPlugin({ singleDollarTextMath: ENABLE_SINGLE_DOLLAR_LATEX, }) /** * Allowed HTML tags and their permitted data attributes for rehype-sanitize. * Keys = tag names to allow; values = attribute names in **hast** property format * (camelCase, e.g. `dataThink` for `data-think`). * * Prefer explicit attribute lists over wildcards (e.g. `data*`) to * minimise the attack surface when LLM-generated content is rendered. */ const ALLOWED_TAGS: Record = { button: ['dataVariant', 'dataSize', 'dataMessage', 'dataLink'], form: ['dataFormat'], input: ['type', 'name', 'value', 'placeholder', 'checked', 'dataTip', 'dataOptions'], textarea: ['name', 'placeholder', 'value'], label: ['htmlFor'], details: ['dataThink'], video: ['src'], audio: ['src'], source: ['src'], mark: [], sub: [], sup: [], kbd: [], // custom tags from human input node variable: ['dataPath'], section: ['dataName'], } /** * Build a rehype plugin list that includes the default raw → sanitize → harden * pipeline with `ALLOWED_TAGS` baked into the sanitize schema, plus any extra * plugins the caller provides. * * This sidesteps the streamdown `allowedTags` prop, which only takes effect * when `rehypePlugins` is the exact default reference (identity check). */ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { const [sanitizePlugin, defaultSanitizeSchema] = defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema] const tagNamesSet = new Set([ ...(defaultSanitizeSchema.tagNames ?? []), ...Object.keys(ALLOWED_TAGS), ]) const mergedAttributes: Record = { ...(defaultSanitizeSchema.attributes ?? {}), } for (const tag of Object.keys(ALLOWED_TAGS)) { const existing = mergedAttributes[tag] if (existing) { // When we add an unrestricted attribute (bare string), remove any // existing restricted tuple for the same name. hast-util-sanitize's // `findDefinition` returns the *first* match, so a restricted tuple // like `['type','checkbox']` would shadow our unrestricted `'type'`. const overrideNames = new Set(ALLOWED_TAGS[tag]) const filtered = existing.filter((entry) => { const name = typeof entry === 'string' ? entry : entry[0] return !overrideNames.has(name as string) }) mergedAttributes[tag] = [...filtered, ...ALLOWED_TAGS[tag]] } else { mergedAttributes[tag] = ALLOWED_TAGS[tag] } } // The default schema forces `input` to be `{disabled:true, type:'checkbox'}` // via `required`. Drop that so form inputs keep their original attributes. const { input: _inputRequired, ...requiredRest } = (defaultSanitizeSchema.required ?? {}) // `name` is in the default `clobber` list, which prefixes every `name` value // with `user-content-`. Form fields need the original `name`, and our form // component validates names with `isSafeName()`, so remove it. const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name') const customSchema: SanitizeSchema = { ...defaultSanitizeSchema, tagNames: [...tagNamesSet], attributes: mergedAttributes, required: requiredRest, clobber, } return [ defaultRehypePlugins.raw, ...(extraPlugins ?? []), [sanitizePlugin, customSchema] as Pluggable, defaultRehypePlugins.harden, ] } export type SimplePluginInfo = { pluginUniqueIdentifier: string pluginId: string } export type StreamdownWrapperProps = { latexContent: string customDisallowedElements?: string[] customComponents?: Components pluginInfo?: SimplePluginInfo remarkPlugins?: StreamdownProps['remarkPlugins'] rehypePlugins?: StreamdownProps['rehypePlugins'] isAnimating?: boolean className?: string mode?: StreamdownProps['mode'] } const StreamdownWrapper = (props: StreamdownWrapperProps) => { const { customComponents, latexContent, pluginInfo, isAnimating, className, mode = 'streaming', } = props const remarkPlugins = useMemo( () => [ [Array.isArray(defaultRemarkPlugins.gfm) ? defaultRemarkPlugins.gfm[0] : defaultRemarkPlugins.gfm, { singleTilde: false }] as Pluggable, RemarkBreaks, ...(props.remarkPlugins ?? []), ], [props.remarkPlugins], ) const rehypePlugins = useMemo( () => buildRehypePlugins(props.rehypePlugins ?? undefined), [props.rehypePlugins], ) const plugins = useMemo( () => ({ math: mathPlugin, }), [], ) const disallowedElements = useMemo( () => ['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])], [props.customDisallowedElements], ) const components: Components = useMemo( () => ({ code: CodeBlock, img: imgProps => pluginInfo ? : , video: VideoBlock, audio: AudioBlock, a: Link, p: pProps => pluginInfo ? : , button: MarkdownButton, form: MarkdownForm as ComponentType, details: ThinkBlock as ComponentType, ...customComponents, }), [pluginInfo, customComponents], ) return ( {latexContent} ) } export default memo(StreamdownWrapper)