Merge remote-tracking branch 'origin/master' into codex/pr-1037-resolution
# Conflicts: # docs/config-catalog.md # packages/ui/tui/README.i18n.yaml
This commit is contained in:
@@ -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: 8bdc3f043488631424c85a4319020c3d8ba5437b
|
||||
README.zh.md: 52b507bb924ca4c05e92ed09d819339621c3bc20
|
||||
README.md: 7318acd9b9a6047b1144789bcd2655132237f6c5
|
||||
README.zh.md: e326846dc2099472bc0a81dff093ff24b614559b
|
||||
|
||||
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM and `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `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, 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 `$…$` / `$$…$$` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled. 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).
|
||||
|
||||
## Terminal output
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$` / `$$…$$` TeX 公式,公式由 KaTeX 排版并禁用受信任命令。它会省略原始 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)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
|
||||
@@ -230,3 +230,14 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,15 @@ export interface MarkdownCodeLabels {
|
||||
copiedLabel?: string | undefined
|
||||
}
|
||||
|
||||
function remoteImageUrl(url: string): string | undefined {
|
||||
try {
|
||||
const protocol = new URL(url).protocol
|
||||
return protocol === 'http:' || protocol === 'https:' ? url : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
|
||||
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
|
||||
return {
|
||||
@@ -53,7 +62,20 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
img: ({ alt = '', src = '' }) => {
|
||||
const imageSrc = remoteImageUrl(src)
|
||||
if (imageSrc === undefined) return <span className={css.imageAlt}>{alt}</span>
|
||||
return (
|
||||
<img
|
||||
className={css.image}
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
@@ -98,7 +120,9 @@ const streamingComponents = buildComponents(true)
|
||||
* pass a reference-stable object (memoized per locale revision), because the
|
||||
* 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 and raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
|
||||
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
||||
* images render directly.
|
||||
*/
|
||||
export function MarkdownText({ text, streaming = false, codeLabels }: {
|
||||
text: string
|
||||
|
||||
@@ -99,13 +99,35 @@ describe('MarkdownText', () => {
|
||||
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
it('renders absolute HTTP(S) images with bounded presentation', () => {
|
||||
const markdown = [
|
||||
'',
|
||||
'',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
const images = [...container.querySelectorAll('img')]
|
||||
expect(images.map(image => image.getAttribute('src'))).toEqual([
|
||||
'https://example.com/secure.png',
|
||||
'http://example.com/plain.png',
|
||||
])
|
||||
for (const image of images) {
|
||||
expect(image.getAttribute('loading')).toBe('lazy')
|
||||
expect(image.getAttribute('decoding')).toBe('async')
|
||||
expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
|
||||
}
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
'<img src="x" onerror="globalThis.compromised = true">',
|
||||
'[script](javascript:alert(1)) [relative](/settings)',
|
||||
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
')',
|
||||
'',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
@@ -117,7 +139,11 @@ describe('MarkdownText', () => {
|
||||
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByText('remote diagram')).toBeTruthy()
|
||||
expect(screen.getByText('relative diagram')).toBeTruthy()
|
||||
expect(screen.getByText('absolute diagram')).toBeTruthy()
|
||||
expect(screen.getByText('file diagram')).toBeTruthy()
|
||||
expect(screen.getByText('script diagram')).toBeTruthy()
|
||||
expect(screen.getByText('mail diagram')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps incomplete streaming Markdown renderable', () => {
|
||||
|
||||
Reference in New Issue
Block a user