Files
deepseek-harness/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
T
imccyu cdd4d59ea0 chore(lint): clear the semantic .tsx backlog
Hand fixes for the findings --fix cannot touch, mirroring the fixes
already applied on the fe-docs feature branch (same file, same shape)
so its eventual rebase resolves cleanly:

- restore the return the no-confusing-void-expression autofix ate in
  useAbsentSnapshot (typed S | undefined; hook call kept for hook-order
  stability, undefined returned explicitly);
- re-type DOM queries the no-unnecessary-type-assertion autofix broke:
  getByRole<HTMLButtonElement>(...) generics instead of the removed
  as-casts (the eslint program and the client tsconfig aggregate
  disagree about these casts; the generic form satisfies both);
- justified eslint-disable for the deliberate legacy paths: keyCode 229
  IME-composition detection, execCommand clipboard fallbacks, lib.dom
  clipboard optionality, and the any-typed Reflect.get/this probes in
  test fakes;
- drop the dead react/no-danger directive (eslint-plugin-react is not
  loaded, so the rule never applied) keeping its shiki rationale;
- delete the tautological 'Z' comparison and the renameTarget null
  check already implied by renameBlocked;
- css-module non-null assertions replaced by type widening
  (Button className, TAG_CLASS Record) per the established pattern;
- misc: max-len comment wraps, void generic drop in the deferred test
  helper, unused type imports, floating selectWorkspace promises voided,
  member-delimiter newlines in inline type literals.
2026-07-27 22:23:41 +08:00

92 lines
3.3 KiB
TypeScript

import { isValidElement } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './CodeBlock.tsx'
import css from './MarkdownText.module.css'
const remarkPlugins = [remarkGfm]
function sanitizeUrl(url: string): string {
try {
switch (new URL(url).protocol) {
case 'http:':
case 'https:':
case 'mailto:':
return url
default:
return ''
}
} catch {
return ''
}
}
const safeUrl: UrlTransform = url => sanitizeUrl(url)
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
if (safeHref === '') return <>{children}</>
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
return (
<a
href={safeHref}
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
{children}
</a>
)
},
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
table: ({ children }) => (
<div className={css.tableScroll}>
<table>{children}</table>
</div>
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
// languages); inline code keeps the default <code> path (the :not(pre)
// rule styles it). While the message streams, the fence renders the
// plain arm — retokenizing a growing fence on every chunk is quadratic
// main-thread work; the finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
/* v8 ignore next 2 */
const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
const raw = child?.props.children
// A fence whose content isn't one plain string (e.g. an empty fence)
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
},
}
}
const staticComponents = buildComponents(false)
const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap).
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={streaming ? streamingComponents : staticComponents}
urlTransform={safeUrl}
>
{text}
</ReactMarkdown>
</div>
)
}