(() => ({
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 (
-
+ {split === undefined ? null : }
+
)
}
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css
new file mode 100644
index 0000000000..203bf492e3
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css
@@ -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);
+}
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx
new file mode 100644
index 0000000000..e3b7c33e0c
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx
@@ -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 {source}
+ if (fields.length === 0) return null
+ return (
+
+ {fields.map(field => (
+
+
- {field.key}
+ - {field.value}
+
+ ))}
+
+ )
+}
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts
new file mode 100644
index 0000000000..7ec058a607
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts
@@ -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) }
+}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-preview-license-bundle.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/document-preview-license-bundle.client.spec.ts
index 9354f741b8..87ffce5348 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/document-preview-license-bundle.client.spec.ts
+++ b/packages/client/ui-sidebar-documentpreview/tests/document-preview-license-bundle.client.spec.ts
@@ -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([])
diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx
index d32748e493..8994003593 100644
--- a/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx
+++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-body.client.spec.tsx
@@ -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()
+ 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()
+ 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()
+ 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()
+ expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
+ expect(view.getByText('Next.')).toBeDefined()
+ view.rerender()
+ 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()
expect(view.container.querySelector('[data-document-markdown]')?.textContent).toBe('')
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 244692dda7..a44891ebdd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: