(() => ({
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 : }
+
+
+ )
+}
+
+/**
+ * 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 = {source}
+ return (
+
+
)
}
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..6230529ffc
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css
@@ -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);
+}
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..578a9c9b32
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx
@@ -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 (
+
+ {fields.map((field, index) => (
+
+
- {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..ccdbbfe9cd
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts
@@ -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) }
+}
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..d42317f4b8 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
@@ -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 }
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..c344a9a744 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,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()
+ 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()
+ 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()
+ const block = await vi.waitUntil(() => view.container.querySelector('[data-document-frontmatter]:has(dl)'))
+ view.rerender()
+ expect(view.container.querySelector('[data-document-frontmatter]')).toBe(block)
+ expect(block.childElementCount).toBe(0)
+ view.rerender()
+ expect(view.container.querySelector('[data-document-frontmatter]')?.childElementCount).toBe(0)
+ expect(view.getByRole('heading', { name: 'Next' })).toBeDefined()
+ expect(view.getByText('Tail')).toBeDefined()
+ view.rerender()
+ 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()
+ 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()
expect(view.container.querySelector('[data-document-markdown]')?.textContent).toBe('')
diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-frontmatter-fallback.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-frontmatter-fallback.client.spec.tsx
new file mode 100644
index 0000000000..d17acca92e
--- /dev/null
+++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-frontmatter-fallback.client.spec.tsx
@@ -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( ({ 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()
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 535b8ec18d..02adcde858 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: