fix(editor): rewrite local images in markdown runs of the split renderer

Every .md preview renders through MarkdownDocument (htmlInfo is ungated),
but its markdown segments passed the raw source into MarkdownText, whose
chat-security stance drops non-http(s) image destinations — so local
markdown-syntax images (![alt](./icon.svg), reference-style definitions)
always fell back to alt text. Only HTML <img src> tags were rewritten
(postProcessSanitized).

Apply rewriteLocalImageUrls to each markdown run (segment + appended
reference definitions) inside MarkdownDocument's prepared memo, through
the same media trust fence the sanitized HTML leaves already use.
Idempotent for already-absolute media URLs; remote destinations
untouched. Also correct the stale "non-null only for HTML documents"
comment on TextEditor's htmlInfo.
This commit is contained in:
Menghuan1918
2026-09-06 21:53:49 +08:00
parent bb21657eae
commit 2a99968fa3
3 changed files with 64 additions and 8 deletions
+11 -5
View File
@@ -10,9 +10,10 @@
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
* open in a new tab with noopener, and local media `src` attributes are
* rewritten through the session-scoped `/sidebar/file` media route — the same
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
* open in a new tab with noopener, and local media goes through the
* session-scoped `/sidebar/file` media route — markdown-syntax image
* destinations are rewritten via `markdown-images.ts` and `src` attributes
* through `resolveLocalMediaDest` (the same trust fence).
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
@@ -21,7 +22,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentType } from 'react'
import { markdownTextProps } from './markdown-labels.tsx'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest } from './markdown-images.ts'
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
@@ -235,7 +236,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
// MarkdownText drops non-http(s) image destinations (chat-security
// stance), so rewrite local ones into /sidebar/file media URLs first —
// the same trust fence the sanitized HTML leaves below go through.
// Idempotent: already-absolute media URLs pass through untouched.
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
return {
kind: 'markdown',
text,
+5 -3
View File
@@ -346,9 +346,11 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
[markdown, mode, previewMdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
* for documents that actually contain HTML — plain markdown keeps the
* legacy single-pass render path below, byte-for-byte. */
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
* every markdown preview, so the render below always takes the split
* renderer — its markdown runs rewrite local image destinations internally
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
* pre-rewritten `previewText`) are dead in the current wiring. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
[markdown, mode, previewMdText],
+48
View File
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
await unmount(root)
})
})
describe('MarkdownDocument (local markdown images)', () => {
it('renders markdown-syntax local images through the /sidebar/file route', async () => {
const { container, root } = await renderDocument([
'# Title',
'',
'![icon](./assets/icon.svg)',
'',
'![logo][logo]',
'',
'[logo]: ./logo.png',
].join('\n'))
const imgs = [...container.querySelectorAll('img')]
expect(imgs.length, 'both local images must render as <img>, not alt fallback').toBe(2)
expect(imgs[0]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fassets%2Ficon.svg&cwd=%2Fws')
expect(imgs[0]?.getAttribute('alt')).toBe('icon')
expect(imgs[1]?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Flogo.png&cwd=%2Fws')
expect(imgs[1]?.getAttribute('alt')).toBe('logo')
await unmount(root)
})
it('renders local markdown images in HTML-mixed documents (the production path)', async () => {
const { container, root } = await renderDocument([
'<div align="center">badges</div>',
'',
'![shot](./img.png)',
].join('\n'))
const img = container.querySelector('img')
expect(img, 'the markdown-syntax image after an HTML run must render').not.toBeNull()
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws')
await unmount(root)
})
it('keeps remote image destinations untouched', async () => {
const { container, root } = await renderDocument('![r](https://example.com/x.png)')
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://example.com/x.png')
await unmount(root)
})
it('is idempotent for already-rewritten media URLs', async () => {
const url = 'http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fimg.png&cwd=%2Fws'
const { container, root } = await renderDocument(`![a](${url})`)
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe(url)
await unmount(root)
})
})