diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index de4c4d2265..38ef548ea5 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d420c2f34e..713af2205c 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -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. + ## How it reads diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 77e3cc79a8..95600cc437 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -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 时加载。源文件不会被修改。 + ## 怎么读 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index f9379f659e..d7d9448f86 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -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", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx index 27c672c60e..a2889fef67 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -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(() => ({ 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: