feat(client): render Markdown YAML frontmatter as fields in document preview

Closes #5195
This commit is contained in:
Turtle committed 2026-09-28 12:06:45 +08:00
1 parent 8be17176fe
commit 0cad3a0137
11 files changed
+183 -7

No files matched your search

@@ -18,8 +18,8 @@
en: a8eaf01a3a3390ee
zh: ddea36dbcc4a0159
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
en: 949156aa02ec8b07
zh: d3675754e2a37b5d
en: 4dba67ac961e1218
zh: 535a307872ba3abe
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
en: 5cdbd4a218315e70
zh: d5054f541e0e0275
@@ -43,6 +43,8 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
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 leading YAML frontmatter block, opened by `---` on the first line and closed by `---` or `...`, renders above the Markdown body as a key/value list. Scalar values keep their resolved text, including folded and multiline strings; lists and mappings show as YAML source. Invalid YAML and non-mapping documents show the block verbatim. 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 source file is unchanged.
<a id="how-it-reads"></a>
## How it reads
@@ -43,6 +43,8 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
全部文本分页加载完成后,Markdown 图片通过已鉴权的 `/api/file` 路由读取绝对文件路径,以及相对于源文档所在目录的路径。相对图片等待 Host 返回文档绝对路径。URL 转义只解码一次;查询参数与片段后缀不计入文件名。HTTP(S) 图片保留原始 URL,加载失败的图片显示 alt 文本。本地图片要求应用基址为 HTTP(S) URL;图片文件不加入自动刷新依赖。
文档开头的 YAML frontmatter(首行 `---` 开始,以 `---` 或 `...` 结束)在 Markdown 正文上方显示为键值列表。标量值显示解析后的文本,包括折叠与多行字符串;列表与映射显示为 YAML 源码。无效 YAML 或非映射文档原样显示整个块。未闭合的块仍按 Markdown 渲染。YAML 解析器位于包内 `client.frontmatter-fields.js` 分块,仅在文档含 frontmatter 时加载。源文件不会被修改。
<a id="how-it-reads"></a>
## 怎么读
@@ -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",
@@ -1,18 +1,22 @@
/** 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 { markdownImageUrl } from './path-images.ts'
import type {} from './locales.ts'
import css from './MarkdownBody.module.css'
const FrontmatterBlock = lazy(async () => ({ default: (await import('./frontmatter-fields.tsx')).FrontmatterBlock }))
/** 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.
* @param props - owner-loaded contents and localized primitive labels.
* @returns Markdown content, or nothing for a non-text delivery.
*/
@@ -30,10 +34,13 @@ 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 : <Suspense fallback={null}><FrontmatterBlock source={split.source} /></Suspense>}
<MarkdownText text={split?.body ?? text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
</div>
)
}
@@ -0,0 +1,38 @@
/* Leading YAML frontmatter: a bordered key/value list above the body, never heading text. */
.frontmatter {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 4px 16px;
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;
}
pre.frontmatter {
display: block;
font-family: var(--ds-font-family-code);
}
.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,65 @@
/**
* Compact 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 { parseDocument, stringify } from 'yaml'
import css from './frontmatter-fields.module.css'
/** One top-level entry; `block` marks the YAML source of a collection value. */
interface FrontmatterField {
key: string
value: string
block: boolean
}
/**
* Resolve top-level mapping fields.
* @param source - YAML between the frontmatter delimiters.
* @returns the fields, 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
let data: unknown
try {
data = parsed.toJS()
} catch {
// `toJS` rejects excessive alias expansion; the preview shows the source instead of the reason.
return undefined
}
if (data === null) return []
if (typeof data !== 'object' || Array.isArray(data)) return undefined
return Object.entries(data).map(([key, value]) => fieldOf(key, value))
}
function fieldOf(key: string, value: unknown): FrontmatterField {
if (value === null) return { key, value: '', block: false }
// Core-schema scalars are strings, numbers, and booleans; collections render as YAML source.
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return { key, value: String(value), block: false }
}
return { key, value: stringify(value).trimEnd(), block: true }
}
/**
* Render frontmatter fields, or the verbatim source when it is not a YAML mapping.
* @param props - YAML between the frontmatter delimiters.
* @returns the metadata block shown above the Markdown body, or nothing for an empty block.
*/
export function FrontmatterBlock({ source }: { source: string }): ReactNode {
const fields = useMemo(() => parseFields(source), [source])
if (fields === undefined) return <pre className={css.frontmatter} data-document-frontmatter>{source}</pre>
if (fields.length === 0) return null
return (
<dl className={css.frontmatter} data-document-frontmatter>
{fields.map(field => (
<div key={field.key} 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,21 @@
/** 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
}
// Opening `---` on the first line, closing `---` or `...` on its own 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) }
}
@@ -58,7 +58,7 @@ describe('published document preview licenses', () => {
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([])
@@ -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,43 @@ 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]', 'license:', 'version: 2', 'beta: true', '---', '', '# Body',
].join('\n')
const view = render(<MarkdownBody {...props(content([text], true))} />)
const fields = await vi.waitUntil(() => view.container.querySelector('[data-document-frontmatter]'))
expect([...fields.querySelectorAll('dt')].map(node => node.textContent)).toEqual(['name', 'description', 'metadata', 'license', 'version', 'beta'])
expect([...fields.querySelectorAll('dd')].map(node => node.textContent))
.toEqual(['pdf', 'Extract text from PDF files.', 'tags:\n - docs\n - pdf', '', '2', 'true'])
expect(view.getAllByRole('heading').map(node => node.textContent)).toEqual(['Body'])
})
it.each([
['invalid YAML', 'name: [unclosed'],
['a non-mapping document', '- first\n- second'],
['excessive aliases', 'a: &a [x]\nb: [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a, *a]'],
])('shows the verbatim frontmatter source for %s', async (_, source) => {
const view = render(<MarkdownBody {...props(content([`---\r\n${source}\r\n...\r\nBody.`], true))} />)
const block = await vi.waitUntil(() => view.container.querySelector('pre[data-document-frontmatter]'))
expect(block?.textContent).toBe(source)
expect(view.getByText('Body.')).toBeDefined()
})
it('hides empty frontmatter and keeps an unterminated opening rule as Markdown', async () => {
const view = render(<MarkdownBody {...props(content(['---\n# comment\n---\nBody.'], true))} />)
await act(async () => { await import('../src/client/markdown/frontmatter-fields.tsx') })
expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
expect(view.getByText('Body.')).toBeDefined()
view.rerender(<MarkdownBody {...props(content(['---\n---\nNext.'], true))} />)
expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
expect(view.getByText('Next.')).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('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('')
+3
View File
@@ -5390,6 +5390,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: