fix(web): render local images in Markdown previews

This commit is contained in:
Yichen Jiang committed 2026-09-19 13:21:37 +08:00
1 parent c167f6d3e0
commit 7951cee0d5
10 files changed
+160 -11

No files matched your search

+15
View File
@@ -169,7 +169,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
'# Markdown smoke', '', 'Rendered from the workspace.', '',
...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
'# Markdown tail',
'', '![relative image](preview-images/local%20image.png)',
'', `![absolute image](<${join(cwd, 'tiny.png').replaceAll('\\', '/')}>)`,
'', '![reference image][local-image]', '', '[local-image]: preview-images/local%20image.png',
].join('\n')
await mkdir(join(cwd, 'preview-images'))
const codeLines = [
...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`),
'const tail = "CODE_TAIL";',
@@ -204,6 +208,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
writeFile(join(cwd, 'tiny.png'), TINY_PNG),
writeFile(join(cwd, 'preview-images', 'local image.png'), TINY_PNG),
writeFile(join(cwd, 'large.svg'), [
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
'<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
@@ -282,6 +287,15 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1)
expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1)
const tailHeading = await markdownTail.innerText()
const markdownImages: string[] = []
for (const alt of ['relative image', 'absolute image', 'reference image']) {
const image = preview.getByRole('img', { name: alt, exact: true })
await image.scrollIntoViewIfNeeded()
await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true)
const source = new URL(await image.getAttribute('src') ?? '')
expect(source.pathname).toBe('/api/file')
markdownImages.push(alt)
}
await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
await successShot(page, 'markdown')
const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
@@ -314,6 +328,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
'## Markdown', '',
`- Heading: ${heading}`,
`- Tail loaded by scrolling: ${tailHeading}`,
`- Loaded images: ${markdownImages.join(' | ')}`,
`- Viewers: ${markdownViewers.join(' -> ')}`,
`- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
].join('\n'))
@@ -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-sidebar-documentpreview/README.md
README.md: 2a42b50684239da216c65cd2378256fcf7760299
README.zh.md: 6e40ceca2f96d1fc2ab1989dfb4985d04584c2aa
README.md: e7360434961ce480d2e97cb2ef41685b8cd8e14f
README.zh.md: 7a60e3a94b931ed50e37214a9c2c63ed5ce6cbbb
@@ -40,6 +40,8 @@ Document implementations register metadata with `ctx.documentPreviews.register({
A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths through the Session filesystem, whose backend controls read authority. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address.
After all text pages load, Markdown images use the authenticated `/api/file` route for absolute file paths and paths relative to the source document's directory. Relative images wait for the Host's absolute document path. URL escapes are decoded once; query and fragment suffixes are excluded from the filename. HTTP(S) images retain their authored URLs, and failed image loads show alt text. Local images require an HTTP(S) application base URL; image files are not added to automatic-refresh dependencies.
<a id="how-it-reads"></a>
## How it reads
@@ -40,6 +40,8 @@ kind: "package-reference"
tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 通过 Session 文件系统解析文件及关联路径,由该后端控制读取权限。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md) 定义这些规则;渲染器选择不改变导航地址。
全部文本分页加载完成后,Markdown 图片通过已鉴权的 `/api/file` 路由读取绝对文件路径,以及相对于源文档所在目录的路径。相对图片等待 Host 返回文档绝对路径。URL 转义只解码一次;查询参数与片段后缀不计入文件名。HTTP(S) 图片保留原始 URL,加载失败的图片显示 alt 文本。本地图片要求应用基址为 HTTP(S) URL;图片文件不加入自动刷新依赖。
<a id="how-it-reads"></a>
## 怎么读
@@ -1,9 +1,10 @@
/** One retained Markdown renderer over the document owner's accumulated text. */
import { useMemo } from 'react'
import type { ReactNode } from 'react'
import { MarkdownText, type MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { DocumentPreviewProps } from '../document/contract.ts'
import { markdownImageUrl } from './path-images.ts'
import type {} from './locales.ts'
import css from './MarkdownBody.module.css'
@@ -15,7 +16,11 @@ export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMark
* @param props - owner-loaded contents and localized primitive labels.
* @returns Markdown content, or nothing for a non-text delivery.
*/
export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode {
export function MarkdownBody({ content, resourceAddress, useResource, t }: MarkdownBodyProps): ReactNode {
const absolutePath = useResource<'file'>(resourceAddress).value?.absolutePath
const pathImages = useMemo<MarkdownPathImages>(() => ({
resolve: value => markdownImageUrl(document.baseURI, absolutePath, value),
}), [absolutePath])
const copyLabel = t('code.copy')
const copiedLabel = t('code.copied')
const footnotes = t('footnotes')
@@ -25,7 +30,7 @@ export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode {
if (content.kind !== 'text') return null
return (
<div className={css.document} data-document-markdown>
<MarkdownText text={content.text} streaming={!content.eof} labels={labels} />
<MarkdownText text={content.text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
</div>
)
}
@@ -0,0 +1,29 @@
/** Local Markdown image destinations served by the authenticated file route. */
import { isAbsoluteWorkspacePath, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
/**
* Build a file URL, resolving relative destinations beside the previewed file.
* @param base - document base URI, including any deployment prefix.
* @param documentPath - absolute source path reported by the Host, when available.
* @param destination - authored Markdown image URL; query and fragment are not filename components.
* @returns an HTTP(S) file URL, or undefined for unsupported or malformed destinations.
*/
export function markdownImageUrl(base: string, documentPath: string | undefined, destination: string): string | undefined {
if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined
const suffix = destination.search(/[?#]/u)
let path: string
try {
path = decodeURIComponent(suffix === -1 ? destination : destination.slice(0, suffix))
} catch (_error) {
// Malformed percent escapes do not identify a local file.
return undefined
}
if (path.length === 0 || path.includes('\0') || /^[/\\]{2}/u.test(path)) return undefined
const windowsDrive = /^[a-z]:[/\\]/iu.test(path)
if (!windowsDrive && /^[a-z][a-z\d+.-]*:/iu.test(path)) return undefined
if (!isAbsoluteWorkspacePath(path)) {
if (documentPath === undefined) return undefined
path = pathPartsOf(documentPath).directory + path
}
return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href
}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/** Markdown preview uses one accumulated document across page arrivals and EOF. */
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { MarkdownBody, type MarkdownBodyProps } from '../src/client/markdown/MarkdownBody.tsx'
import { en, zh } from '../src/client/markdown/locales.ts'
@@ -20,12 +20,45 @@ function content(pageTexts: readonly string[], eof: boolean): DocumentContent {
return { kind: 'text', text: pageTexts.join('\n'), pages, eof }
}
// The body reads content and locale only; the other standard seats belong to the slot integration tests.
function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate(en)): MarkdownBodyProps {
return { resourceAddress: 'dsh-resource://file/session/markdown/notes.md', content: value, wrap: false, t } as MarkdownBodyProps
// Unused framework seats belong to the slot integration tests.
function props(value: DocumentContent, t: MarkdownBodyProps['t'] = makeTranslate(en), absolutePath?: string): MarkdownBodyProps {
return {
resourceAddress: 'dsh-resource://file/session/markdown/notes.md', content: value, wrap: false, t,
useResource: () => ({
status: absolutePath === undefined ? 'loading' : 'live',
value: absolutePath === undefined ? undefined : { absolutePath, version: 'v1' },
failure: undefined,
}),
} as MarkdownBodyProps
}
describe('MarkdownBody', () => {
it('loads local images beside the Host document and retains external images and failure text', () => {
const text = '![relative](images/a.png) ![absolute](/tmp/a.png) ![external](https://example.test/a.png)'
const view = render(<MarkdownBody {...props(content([text], true), undefined, '/work/guide/notes.md')} />)
expect(new URL(view.getByAltText('relative').getAttribute('src')!).searchParams.get('path'))
.toBe('/work/guide/images/a.png')
expect(new URL(view.getByAltText('absolute').getAttribute('src')!).searchParams.get('path')).toBe('/tmp/a.png')
expect(view.getByAltText('external').getAttribute('src')).toBe('https://example.test/a.png')
fireEvent.error(view.getByAltText('relative'))
expect(view.getByText('relative').tagName).toBe('SPAN')
})
it('resolves reference images at EOF and updates their directory when metadata changes', () => {
const first = '![figure][image]'
const second = '\n[image]: images/a.png'
const view = render(<MarkdownBody {...props(content([first], false))} />)
view.rerender(<MarkdownBody {...props(content([first, second], false), undefined, '/work/guide/notes.md')} />)
expect(view.container.querySelector('img')).toBeNull()
view.rerender(<MarkdownBody {...props(content([first, second], true))} />)
expect(view.container.querySelector('img')).toBeNull()
for (const directory of ['/work/guide', '/other']) {
view.rerender(<MarkdownBody {...props(content([first, second], true), undefined, `${directory}/notes.md`)} />)
expect(new URL(view.getByAltText('figure').getAttribute('src')!).searchParams.get('path'))
.toBe(`${directory}/images/a.png`)
}
})
it('renders GFM headings, tables, task lists, strikeout, and localized code and footnote chrome', () => {
const text = [
'# Notes', '', '| Item | Value |', '| --- | --- |', '| a | 1 |', '',
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { markdownImageUrl } from '../src/client/markdown/path-images.ts'
const BASE = 'https://example.test/tools/dsh/'
describe('Markdown preview image URLs', () => {
it.each([
['images/a.png', '/work/guide/images/a.png'],
['./images/a.png', '/work/guide/./images/a.png'],
['../a.png', '/work/guide/../a.png'],
['/tmp/a.png', '/tmp/a.png'],
['images/中文%20图.png?size=2#detail', '/work/guide/images/中文 图.png'],
['images/a%23b%3Fc%25.png', '/work/guide/images/a#b?c%.png'],
])('serves %s through the deployment file route', (destination, path) => {
const url = new URL(markdownImageUrl(BASE, '/work/guide/notes.md', destination)!)
expect(url.origin + url.pathname).toBe(`${BASE}api/file`)
expect(url.searchParams.get('path')).toBe(path)
expect(url.hash).toBe('')
})
it.each([
['C:\\work\\guide\\notes.md', 'images/a.png', 'C:\\work\\guide\\images/a.png'],
['C:/work/guide/notes.md', 'C:/images/a.png', 'C:/images/a.png'],
['C:/work/guide/notes.md', 'C:\\images\\a.png', 'C:\\images\\a.png'],
])('preserves filesystem spelling from %s', (documentPath, destination, path) => {
expect(new URL(markdownImageUrl(BASE, documentPath, destination)!).searchParams.get('path')).toBe(path)
})
it('waits for document metadata only for relative images', () => {
expect(markdownImageUrl(BASE, undefined, 'a.png')).toBeUndefined()
expect(new URL(markdownImageUrl(BASE, undefined, '/tmp/a.png')!).searchParams.get('path')).toBe('/tmp/a.png')
})
it.each(['', '#figure', '?size=2', '//cdn.test/a.png', '\\\\server\\a.png', 'file:///tmp/a.png',
'javascript:alert(1)', 'data:image/png;base64,AAAA', 'https://example.test/a.png', 'C:a.png', '%ZZ.png', 'a%00.png',
])('does not rewrite unsupported destination %j', (destination) => {
expect(markdownImageUrl(BASE, '/work/notes.md', destination)).toBeUndefined()
})
it.each(['about:blank', 'dsh-app://app/', 'file:///app', 'ws://localhost/'])(
'keeps the file route unavailable for %s', (base) => {
expect(markdownImageUrl(base, '/work/notes.md', 'a.png')).toBeUndefined()
},
)
it('retains the deployment prefix when the base includes an HTML filename', () => {
expect(markdownImageUrl(`${BASE}index.html`, '/work/notes.md', 'a.png'))
.toBe(`${BASE}api/file?path=${encodeURIComponent('/work/a.png')}`)
})
})
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
/** Markdown metadata, deferred slot registration, localization, and unload through the real renderer. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act } from '@testing-library/react'
import { act, waitFor } from '@testing-library/react'
import { apply as resourcesApply, inject as resourcesInject } from '@deepseek-ai/dsh-client-resources/src/client/index.ts'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
@@ -47,6 +48,13 @@ describe('Markdown implementation registration', () => {
await runtime.sessions.add({ id: sessionId })
const reference = runtime.sessions.retainFor(runtime.ctx, sessionId)
await reference.ready
await runtime.mount({ inject: [...resourcesInject], apply: resourcesApply })
runtime.ctx.effect(() => runtime.ctx.resources.register({
protocol: 'file',
open: async function* () {
yield { ok: true as const, value: { absolutePath: '/work/guide/notes.md', version: 'v1' } }
},
}))
const feature = await runtime.mount({ inject: ['slots', 'locale', 'documentPreviews'], apply })
expect(previews.getSnapshot().map(definition => definition.id)).toEqual([MARKDOWN_BODY_ID])
const useTabInfo = vi.fn<UseSidebarRightTabInfo>(() => { throw new Error('Markdown rendering does not need tab actions') })
@@ -58,7 +66,7 @@ describe('Markdown implementation registration', () => {
<SessionProvider session={reference}>
{renderSlot('sidebar.right.tab.document', {
resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md',
content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```\n\n![diagram](images/a.png)', pages: [], eof: true },
wrap: false,
scrollportRef: vi.fn(),
addResource: vi.fn(), setResources: vi.fn(),
@@ -72,6 +80,10 @@ describe('Markdown implementation registration', () => {
const view = runtime.renderRoot()
expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined()
expect(view.getByRole('button', { name: 'Copy' })).toBeDefined()
await waitFor(() => {
expect(new URL(view.getByAltText('diagram').getAttribute('src')!).searchParams.get('path'))
.toBe('/work/guide/images/a.png')
})
expect(runtime.slots.entries('sidebar.right.tab.document')).toHaveLength(1)
const t = locale.bind('documentMarkdown')
await act(async () => { locale.setLocale('zh') })
@@ -11,6 +11,7 @@
- Heading: Markdown smoke
- Tail loaded by scrolling: Markdown tail
- Loaded images: relative image | absolute image | reference image
- Viewers: Markdown -> Code -> Plain text
- Same tab: true