Merge pull request #5319 from deepseek-harness/turtle/frontmatter-render-9aa734baa39d

feat(client): render Markdown YAML frontmatter as fields in document preview
This commit is contained in:
Turtle authored and GitHub committed 2026-09-30 16:24:33 +08:00
commit 08f0721e9d
15 files changed
+292 -9

No files matched your search

@@ -1,3 +1,5 @@
- separator
- 'heading "name: policy-shared description: Available to both model and user invocation" [level=2]'
- term: name
- definition: policy-shared
- term: description
- definition: Available to both model and user invocation
- heading "policy-shared" [level=1]
@@ -170,6 +170,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
await skill.click()
const preview = page.locator('[data-document-markdown]')
await expect.poll(() => preview.textContent()).toContain('policy-shared')
await preview.locator('[data-document-frontmatter] dl').waitFor()
expect(await input.textContent()).toBe(draft)
const snapshot = await captureStableAria(page, '[data-document-markdown]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'), snapshot, MODE)
@@ -21,8 +21,8 @@
en: 949156aa02ec8b07
zh: d3675754e2a37b5d
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: 5cdbd4a218315e70
zh: d5054f541e0e0275
en: b056bbe0f74fe328
zh: fd9ac88f08825afc
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: b99b0e0b8efc2853
zh: 4327352d1334fc6b
@@ -65,6 +65,8 @@ Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its
Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Selections use translucent blue in both themes while the canvas retains the document text; blank line breaks remain unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
A leading YAML frontmatter block, opened by `---` on the first line and closed by `---` or `...`, renders above the Markdown body as a key/value list. String values show their resolved text, including folded and multiline strings; other scalars, lists, mappings, and aliases keep their authored YAML. Invalid YAML and non-mapping documents show the block verbatim, and a block without content shows nothing. An unterminated block remains Markdown. The YAML parser loads in the package-local `client.frontmatter-fields.js` chunk only when a document has frontmatter; the verbatim block stands in while it loads or if it fails to load. The source file is unchanged.
<a id="excel-preview"></a>
## Excel preview
@@ -65,6 +65,8 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
首次读取及渲染器准备(包括 Office 转换)共用居中的 28px ongoing `StateDot`,下方显示本地化状态文字“文档渲染中...”。状态向辅助技术提供相同的名称,并遵循减少动态效果偏好。追加文本页时使用紧凑的行内转圈,同时保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 与转换后的 Word/PowerPoint 页面组成纵向序列,页间距与四周留白均为 12px,露出当前主题的文档背景色;适应宽度会预留此留白,页面在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。两种主题均使用半透明蓝色选区,画布保留文档文字,空白换行不高亮;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
文档开头的 YAML frontmatter(首行 `---` 开始,以 `---` 或 `...` 结束)在 Markdown 正文上方显示为键值列表。字符串值显示解析后的文本,包括折叠与多行字符串;其他标量、列表、映射与别名保留原始 YAML 写法。无效 YAML 或非映射文档原样显示整个块,没有内容的块不显示。未闭合的块仍按 Markdown 渲染。YAML 解析器位于包内 `client.frontmatter-fields.js` 分块,仅在文档含 frontmatter 时加载;加载期间或加载失败时显示原样块。源文件不会被修改。
<a id="excel-preview"></a>
## Excel 预览
@@ -85,7 +85,8 @@
"pdfjs-dist": "6.3.289",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yaml": "^2.9.0"
},
"files": [
"lib/index.js",
@@ -5,3 +5,27 @@
font-family: var(--dsw-font, inherit);
white-space: normal;
}
/* Leading YAML frontmatter: a bordered metadata box above the body, never heading text. */
.frontmatter {
margin: 0 0 16px;
padding: 10px 12px;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-markdown-small);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* A document without frontmatter content leaves the box empty. */
.frontmatter:empty {
display: none;
}
.frontmatterSource {
margin: 0;
font-family: var(--ds-font-family-code);
white-space: inherit;
}
@@ -1,18 +1,33 @@
/** One retained Markdown renderer over the document owner's accumulated text. */
import { useMemo } from 'react'
import { lazy, Suspense, useMemo } from 'react'
import type { ReactNode } from 'react'
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 { splitFrontmatter } from './frontmatter.ts'
import type { FrontmatterFieldsProps } from './frontmatter-fields.tsx'
import { markdownImageUrl } from './path-images.ts'
import type {} from './locales.ts'
import css from './MarkdownBody.module.css'
/** Show the verbatim frontmatter when the field-list chunk cannot load. */
function FrontmatterFallback({ fallback }: FrontmatterFieldsProps): ReactNode {
return fallback
}
const FrontmatterFields = lazy(() => import('./frontmatter-fields.tsx').then(
module => ({ default: module.FrontmatterFields }),
// The failed chunk only removes the field layout; the body and verbatim metadata stay usable.
() => ({ default: FrontmatterFallback }),
))
/** Standard document inputs and this implementation's locale. */
export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMarkdown'>
/**
* Render one accumulated document; EOF completes the primitive's full parse.
* A leading YAML frontmatter block renders as a field list instead of Markdown; its
* verbatim source stands in while the field-list chunk loads or when it fails.
* @param props - owner-loaded contents and localized primitive labels.
* @returns Markdown content, or nothing for a non-text delivery.
*/
@@ -30,10 +45,27 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd
const labels = useMemo<MarkdownLabels>(() => ({
code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes,
}), [copyLabel, copiedLabel, footnotes, codeLabel, wrapLabel, unwrapLabel])
const text = content.kind === 'text' ? content.text : ''
const split = useMemo(() => splitFrontmatter(text), [text])
if (content.kind !== 'text') return null
return (
<div className={css.document} data-document-markdown>
<MarkdownText text={content.text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
{split === undefined ? null : <FrontmatterBox source={split.source} />}
<MarkdownText text={split?.body ?? text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
</div>
)
}
/**
* Frame one frontmatter block and choose its field list or verbatim source.
* @param props - YAML between the frontmatter delimiters.
* @returns the metadata box; it collapses when the document has no content.
*/
function FrontmatterBox({ source }: { source: string }): ReactNode {
const verbatim = <pre className={css.frontmatterSource}>{source}</pre>
return (
<div className={css.frontmatter} data-document-frontmatter>
<Suspense fallback={verbatim}><FrontmatterFields source={source} fallback={verbatim} /></Suspense>
</div>
)
}
@@ -0,0 +1,25 @@
/* Key/value grid inside the Markdown body's frontmatter box. */
.fields {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 4px 16px;
margin: 0;
}
.field {
display: contents;
}
.key {
color: var(--dsw-alias-label-secondary);
font-family: var(--ds-font-family-code);
}
.value,
.block {
margin: 0;
}
.block {
font-family: var(--ds-font-family-code);
}
@@ -0,0 +1,72 @@
/**
* Field list for a Markdown document's leading YAML frontmatter. The Markdown
* body loads this module lazily so the YAML parser stays out of the startup
* chunk.
*/
import { useMemo, type ReactNode } from 'react'
import { isMap, isNode, isScalar, parseDocument } from 'yaml'
import css from './frontmatter-fields.module.css'
/** One top-level entry; `block` marks the authored YAML of a collection or alias value. */
interface FrontmatterField {
key: string
value: string
block: boolean
}
/** Owner inputs for the lazily loaded field list. */
export interface FrontmatterFieldsProps {
/** YAML between the frontmatter delimiters. */
source: string
/** Verbatim block shown for invalid YAML and non-mapping documents. */
fallback: ReactNode
}
/**
* Resolve top-level mapping fields from the YAML syntax tree.
* @param source - YAML between the frontmatter delimiters.
* @returns the fields, `[]` for a document without content, or `undefined` for invalid YAML and non-mapping documents.
*/
function parseFields(source: string): readonly FrontmatterField[] | undefined {
const parsed = parseDocument(source)
if (parsed.errors.length > 0) return undefined
if (parsed.contents === null) return []
if (!isMap(parsed.contents)) return undefined
return parsed.contents.items.map(({ key, value }) => ({
key: nodeText(source, key),
value: nodeText(source, value),
block: isNode(value) && !isScalar(value),
}))
}
// Strings show their resolved value, so folded and multiline text reads as prose; other
// nodes keep their authored text, so `1.0` stays `1.0` and aliases stay unexpanded.
function nodeText(source: string, node: unknown): string {
if (isScalar(node) && typeof node.value === 'string') return node.value.trimEnd()
if (!isNode(node)) return ''
/* v8 ignore next -- parsed nodes always carry their source range. */
const [start, end] = node.range ?? [0, 0]
const indent = start - source.lastIndexOf('\n', start - 1) - 1
return source.slice(start, end).replace(new RegExp(`\\n {0,${indent}}`, 'gu'), '\n').trimEnd()
}
/**
* Render frontmatter fields, or the owner's verbatim block when the YAML is not a mapping.
* @param props - frontmatter source and its verbatim fallback.
* @returns the field list, the fallback, or nothing for a document without content.
*/
export function FrontmatterFields({ source, fallback }: FrontmatterFieldsProps): ReactNode {
const fields = useMemo(() => parseFields(source), [source])
if (fields === undefined) return fallback
if (fields.length === 0) return null
return (
<dl className={css.fields}>
{fields.map((field, index) => (
<div key={index} className={css.field}>
<dt className={css.key}>{field.key}</dt>
<dd className={field.block ? css.block : css.value}>{field.value}</dd>
</div>
))}
</dl>
)
}
@@ -0,0 +1,22 @@
/** Leading YAML frontmatter split from a Markdown document before Markdown parsing. */
/** Frontmatter source between its delimiters and the Markdown body after the closing delimiter. */
export interface FrontmatterSplit {
source: string
body: string
}
// Only an opening `---` on the first line (after an optional BOM) counts; the block ends at the
// first closing `---` or `...` line, and `??` lets an empty block close on the next line.
const FRONTMATTER = /^\uFEFF?---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)??(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/u
/**
* Split a leading YAML frontmatter block from Markdown text without parsing its YAML.
* @param text - Markdown source; an unterminated block is not frontmatter.
* @returns frontmatter source and the remaining body, or `undefined` when the text has none.
*/
export function splitFrontmatter(text: string): FrontmatterSplit | undefined {
const match = FRONTMATTER.exec(text)
if (match === null) return undefined
return { source: match[1] ?? '', body: text.slice(match[0].length) }
}
@@ -53,12 +53,13 @@ describe('published document preview licenses', () => {
], packageRoot, task.timeout)) as { filename: string; files: { path: string }[] }
expect(packed.files.map(file => file.path)).toContain('lib/client.js')
expect(packed.files.map(file => file.path)).toContain('lib/client.pdf.js')
expect(packed.files.map(file => file.path)).toContain('lib/client.frontmatter-fields.js')
expect(packed.files.some(file => file.path.endsWith('pdfjs-NOTICES.txt'))).toBe(false)
const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout)
const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout)
expect([...client.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual(['./client.pdf.js', './client.excel.js'])
.toEqual(['./client.frontmatter-fields.js', './client.pdf.js', './client.excel.js'])
expect(client).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
expect([...pdf.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1]))
.toEqual([])
@@ -76,6 +77,20 @@ describe('published document preview licenses', () => {
const license = readFileSync(join(root, 'LICENSE'), 'utf8').trimEnd()
expect(excel).toContain(license.split('\n').map(line => `// ${line}`).join('\n'))
}
const frontmatter = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.frontmatter-fields.js'], packageRoot, task.timeout)
expect(frontmatter).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u)
let frontmatterInitialized = false
runInNewContext(frontmatter, { window: { __ModuleLoader__: { load: (registration: {
factory: (resolve: (specifier: string) => unknown) => { FrontmatterFields: unknown }
}) => {
const loaded = registration.factory((specifier) => {
if (specifier === 'react' || specifier === 'react/jsx-runtime') return require(specifier)
throw new Error(`Unexpected browser dependency: ${specifier}`)
})
expect(typeof loaded.FrontmatterFields).toBe('function')
frontmatterInitialized = true
} } } })
expect(frontmatterInitialized).toBe(true)
let initialized = false
runInNewContext(excel, { window: { __ModuleLoader__: { load: (registration: {
factory: (resolve: (specifier: string) => unknown) => { ExcelBody: unknown }
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/** Markdown preview uses one accumulated document across page arrivals and EOF. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, 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'
@@ -129,6 +129,60 @@ describe('MarkdownBody', () => {
expect(view.getByRole('heading', { name: '脚注' })).toBeDefined()
})
it('renders leading YAML frontmatter as fields instead of a heading', async () => {
const text = [
'---', 'name: pdf', 'description: >-', ' Extract text', ' from PDF files.',
'metadata:', ' tags: [docs, pdf]', 'anchor: &list [a]', 'copy: *list', 'license:', 'version: 1.0', 'beta: true', '1: one', '? orphan',
'---', '', '# Body',
].join('\n')
const view = render(<MarkdownBody {...props(content([text], true))} />)
const fields = await vi.waitUntil(() => view.container.querySelector('[data-document-frontmatter] dl'))
expect([...fields.querySelectorAll('dt')].map(node => node.textContent))
.toEqual(['name', 'description', 'metadata', 'anchor', 'copy', 'license', 'version', 'beta', '1', 'orphan'])
expect([...fields.querySelectorAll('dd')].map(node => node.textContent)).toEqual([
'pdf', 'Extract text from PDF files.', 'tags: [docs, pdf]', '[a]', '*list', '', '1.0', 'true', 'one', '',
])
expect(view.getAllByRole('heading').map(node => node.textContent)).toEqual(['Body'])
})
it.each([
['invalid YAML', 'name: [unclosed'],
['a scalar document', '~'],
['a sequence document', '- first\n- second'],
])('shows the verbatim frontmatter source for %s', async (_, source) => {
const view = render(<MarkdownBody {...props(content([`---\r\n${source}\r\n...\r\nBody.`], true))} />)
await act(async () => { await import('../src/client/markdown/frontmatter-fields.tsx') })
const block = view.container.querySelector('[data-document-frontmatter]')
expect(block?.querySelector('dl')).toBeNull()
expect(block?.querySelector('pre')?.textContent).toBe(source)
expect(view.getByText('Body.')).toBeDefined()
})
it('collapses frontmatter without content and keeps an unterminated opening rule as Markdown', async () => {
const view = render(<MarkdownBody {...props(content(['---\nname: pdf\n---\nBody.'], true))} />)
const block = await vi.waitUntil(() => view.container.querySelector('[data-document-frontmatter]:has(dl)'))
view.rerender(<MarkdownBody {...props(content(['---\n# comment\n---\nBody.'], true))} />)
expect(view.container.querySelector('[data-document-frontmatter]')).toBe(block)
expect(block.childElementCount).toBe(0)
view.rerender(<MarkdownBody {...props(content(['---\n---\n# Next\n\n---\nTail'], true))} />)
expect(view.container.querySelector('[data-document-frontmatter]')?.childElementCount).toBe(0)
expect(view.getByRole('heading', { name: 'Next' })).toBeDefined()
expect(view.getByText('Tail')).toBeDefined()
view.rerender(<MarkdownBody {...props(content(['---\nname: pdf'], false))} />)
expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
expect(view.container.querySelector('hr')).not.toBeNull()
})
it.each([
['a leading blank line', '\n---\nname: pdf\n---\nBody.'],
['leading text', 'Intro.\n\n---\nname: pdf\n---\nBody.'],
['an indented opening rule', ' ---\nname: pdf\n---\nBody.'],
])('treats a delimiter pair after %s as Markdown', (_, text) => {
const view = render(<MarkdownBody {...props(content([text], true))} />)
expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
expect(view.getByText('Body.')).toBeDefined()
})
it('renders empty text and leaves non-text deliveries to their selected implementation', () => {
const view = render(<MarkdownBody {...props({ kind: 'text', text: '', pages: [], eof: true })} />)
expect(view.container.querySelector('[data-document-markdown]')?.textContent).toBe('')
@@ -0,0 +1,28 @@
// @vitest-environment jsdom
/** A field-list chunk that fails to load leaves the verbatim frontmatter and the Markdown body visible. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, 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 } from '../src/client/markdown/locales.ts'
vi.mock('../src/client/markdown/frontmatter-fields.tsx', () => {
throw new Error('chunk unavailable')
})
afterEach(cleanup)
describe('MarkdownBody frontmatter chunk failure', () => {
it('keeps the verbatim frontmatter and the body', async () => {
const text = '---\nname: pdf\n---\n# Body'
const view = render(<MarkdownBody {...{
resourceAddress: 'dsh-resource://file/session/markdown/SKILL.md', wrap: false, t: makeTranslate(en),
content: { kind: 'text', text, pages: [{ offset: 1, text, lines: 4 }], eof: true },
useResource: () => ({ status: 'loading', value: undefined, failure: undefined }),
} as MarkdownBodyProps} />)
await vi.waitFor(() => { expect(view.container.querySelector('[data-document-frontmatter] pre')?.textContent).toBe('name: pdf') })
await new Promise(resolve => setTimeout(resolve, 20))
expect(view.container.querySelector('[data-document-frontmatter] pre')?.textContent).toBe('name: pdf')
expect(view.getByRole('heading', { name: 'Body' })).toBeDefined()
})
})
+3
View File
@@ -5496,6 +5496,9 @@ importers:
xlsx:
specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
yaml:
specifier: ^2.9.0
version: 2.9.0
packages/client/ui-sidebar-files:
devDependencies: