Merge pull request #569 from omdsh-dev/fix/markdown-preview-local-images
fix(editor): rewrite local images in markdown runs of the split renderer
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'![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>',
|
||||
'',
|
||||
'',
|
||||
].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('')
|
||||
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(``)
|
||||
const img = container.querySelector('img')
|
||||
expect(img?.getAttribute('src')).toBe(url)
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user