From 2a99968fa360d2d08bbd2545c1c13386177a4b70 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 6 Sep 2026 21:53:49 +0800 Subject: [PATCH] fix(editor): rewrite local images in markdown runs of the split renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/client/MarkdownHtml.tsx | 16 +++++++--- src/client/TextEditor.tsx | 8 +++-- tests/markdown-html-render.spec.tsx | 48 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index 70011ab..9f25286 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -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(() => 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, diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 459eaf0..865a034 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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], diff --git a/tests/markdown-html-render.spec.tsx b/tests/markdown-html-render.spec.tsx index 8e0ca9d..662152a 100644 --- a/tests/markdown-html-render.spec.tsx +++ b/tests/markdown-html-render.spec.tsx @@ -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 , 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([ + '
badges
', + '', + '![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) + }) +})