fix(web): link inline-code URLs

This commit is contained in:
Yichen Jiang
2026-08-06 11:41:41 +08:00
parent 1b5f06ed0b
commit 178eaf3973
12 changed files with 267 additions and 28 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: aebcc06bf95b5bd843c2e8add6f89908d7bfa1fa
README.zh.md: 7e141148cee23416e344f57089d425c272e86db4
README.md: c64e86152737fd55c49ac39cc2e7b523e0323774
README.zh.md: 29123c3570122bc0fe6a1808a75c5bc659315eaa

View File

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -10,7 +10,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出

View File

@@ -1,4 +1,4 @@
import { isValidElement, useMemo } from 'react'
import { isValidElement, useMemo, type ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
@@ -36,6 +36,30 @@ function sanitizeUrl(url: string): string {
const safeUrl: UrlTransform = url => sanitizeUrl(url)
function renderSafeLink(href: string, children: ReactNode): ReactNode {
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>
)
}
function inlineCodeHttpUrl(value: string): string | undefined {
if (value.trim() !== value) return undefined
try {
const protocol = new URL(value).protocol
return protocol === 'http:' || protocol === 'https:' ? value : undefined
} catch {
return undefined
}
}
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
@@ -56,18 +80,10 @@ function remoteImageUrl(url: string): string | undefined {
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): 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>
)
a: ({ href = '', children }) => renderSafeLink(href, children),
code: ({ className, children }) => {
const href = typeof children === 'string' ? inlineCodeHttpUrl(children) : undefined
return <code className={className}>{href === undefined ? children : renderSafeLink(href, children)}</code>
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
@@ -90,10 +106,11 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C
),
// 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.
// languages); inline code keeps the <code> path (the :not(pre) rule
// styles it), with a safe anchor only for complete HTTP(S) values. 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.
@@ -128,7 +145,8 @@ const streamingComponents = buildComponents(true)
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* relative links, and unsafe protocols are disabled; complete HTTP(S)
* inline-code values become safe external links, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {

View File

@@ -115,6 +115,40 @@ describe('MarkdownText', () => {
expect(container.textContent).toContain('**注意:**内容')
})
it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => {
const localUrl = 'http://127.0.0.1:3199/?demo=1'
const remoteUrl = 'https://example.com/preview?q=one%20two#result'
const source = [
`\`${localUrl}\``,
`\`${remoteUrl}\``,
'`curl http://127.0.0.1:3199/?demo=1`',
'`javascript:alert(1)`',
'`mailto:dev@example.com`',
`\` ${localUrl} \``,
'```',
localUrl,
'```',
].join('\n\n')
const { container } = render(<MarkdownText text={source} />)
const links = screen.getAllByRole('link')
expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl])
for (const link of links) {
expect(link.closest('code')).not.toBeNull()
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toBe('noopener noreferrer')
}
links[0]?.focus()
expect(document.activeElement).toBe(links[0])
expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull()
expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull()
expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull()
const paddedCode = [...container.querySelectorAll('code')]
.find(code => code.textContent === ` ${localUrl} `)
expect(paddedCode?.querySelector('a')).toBeNull()
expect(container.querySelector('pre code a')).toBeNull()
})
it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
const data: { micromarkExtensions?: Extension[] } = {}
const processor = { data: () => data }