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
|
* Security posture: every HTML string (block leaves, inline text, wrapper
|
||||||
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
||||||
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
* 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
|
* open in a new tab with noopener, and local media goes through the
|
||||||
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
* session-scoped `/sidebar/file` media route — markdown-syntax image
|
||||||
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
* destinations are rewritten via `markdown-images.ts` and `src` attributes
|
||||||
|
* through `resolveLocalMediaDest` (the same trust fence).
|
||||||
*/
|
*/
|
||||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import { createElement, type ReactNode } 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 type { ComponentType } from 'react'
|
||||||
import { markdownTextProps } from './markdown-labels.tsx'
|
import { markdownTextProps } from './markdown-labels.tsx'
|
||||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||||
import { resolveLocalMediaDest } from './markdown-images.ts'
|
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
|
||||||
import {
|
import {
|
||||||
analyzeHtmlSegment,
|
analyzeHtmlSegment,
|
||||||
type AnalyzedMarkdownHtml,
|
type AnalyzedMarkdownHtml,
|
||||||
@@ -235,7 +236,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
|
|||||||
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
||||||
if (segment.kind === 'markdown') {
|
if (segment.kind === 'markdown') {
|
||||||
const defs = info.referenceDefinitions
|
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 {
|
return {
|
||||||
kind: 'markdown',
|
kind: 'markdown',
|
||||||
text,
|
text,
|
||||||
|
|||||||
@@ -346,9 +346,11 @@ export function TextEditor(props: FileViewerProps) {
|
|||||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||||
[markdown, mode, previewMdText],
|
[markdown, mode, previewMdText],
|
||||||
)
|
)
|
||||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
|
||||||
* for documents that actually contain HTML — plain markdown keeps the
|
* every markdown preview, so the render below always takes the split
|
||||||
* legacy single-pass render path below, byte-for-byte. */
|
* 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(
|
const htmlInfo = useMemo(
|
||||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||||
[markdown, mode, previewMdText],
|
[markdown, mode, previewMdText],
|
||||||
|
|||||||
@@ -175,3 +175,51 @@ describe('MarkdownDocument (inline pass)', () => {
|
|||||||
await unmount(root)
|
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