From 0cad3a0137fd2efb4a96a9f34397a97cd6a7a0b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 28 Sep 2026 12:06:45 +0800 Subject: [PATCH 1/3] feat(client): render Markdown YAML frontmatter as fields in document preview Closes #5195 --- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 + .../ui-sidebar-documentpreview/README.zh.md | 2 + .../ui-sidebar-documentpreview/package.json | 3 +- .../src/client/markdown/MarkdownBody.tsx | 11 +++- .../markdown/frontmatter-fields.module.css | 38 +++++++++++ .../client/markdown/frontmatter-fields.tsx | 65 +++++++++++++++++++ .../src/client/markdown/frontmatter.ts | 21 ++++++ ...ment-preview-license-bundle.client.spec.ts | 2 +- .../tests/markdown-body.client.spec.tsx | 39 ++++++++++- pnpm-lock.yaml | 3 + 11 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts 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: From 39c614a143378d83e5b9034d21ab6912e20df6fe Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 28 Sep 2026 12:29:25 +0800 Subject: [PATCH 2/3] fix(client): keep frontmatter preview robust and faithful to authored YAML Read fields from the YAML syntax tree so non-string scalars and aliases keep their authored text, show the verbatim block while the field chunk loads or when it fails, close an empty block at its first delimiter, and refresh the skill preview golden. --- .../preview.expected.md | 6 +- apps/web/tests/skill-invocation-policy.e2e.ts | 1 + .../README.i18n.yaml | 8 +-- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../client/markdown/MarkdownBody.module.css | 24 +++++++ .../src/client/markdown/MarkdownBody.tsx | 31 +++++++- .../markdown/frontmatter-fields.module.css | 19 +---- .../client/markdown/frontmatter-fields.tsx | 71 ++++++++++--------- .../src/client/markdown/frontmatter.ts | 5 +- ...ment-preview-license-bundle.client.spec.ts | 15 ++++ .../tests/markdown-body.client.spec.tsx | 41 ++++++----- ...kdown-frontmatter-fallback.client.spec.tsx | 28 ++++++++ 13 files changed, 177 insertions(+), 80 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/tests/markdown-frontmatter-fallback.client.spec.tsx diff --git a/apps/web/tests/expected/skill-invocation-policy/preview.expected.md b/apps/web/tests/expected/skill-invocation-policy/preview.expected.md index 7d6a82eb65..8329b5fd25 100644 --- a/apps/web/tests/expected/skill-invocation-policy/preview.expected.md +++ b/apps/web/tests/expected/skill-invocation-policy/preview.expected.md @@ -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] diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 98eef6fb5e..9723ae3494 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -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) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 38ef548ea5..b92710ea28 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -18,11 +18,11 @@ en: a8eaf01a3a3390ee zh: ddea36dbcc4a0159 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: - en: 4dba67ac961e1218 - zh: 535a307872ba3abe + 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 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 713af2205c..f4087e795e 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -43,8 +43,6 @@ 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 @@ -67,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. + ## Excel preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 95600cc437..fa16c8453a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -43,8 +43,6 @@ 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 时加载。源文件不会被修改。 - ## 怎么读 @@ -67,6 +65,8 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 首次读取及渲染器准备(包括 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 时加载;加载期间或加载失败时显示原样块。源文件不会被修改。 + ## Excel 预览 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css index 4de180e6c2..7966be0013 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.module.css @@ -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; +} 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 a2889fef67..714d68bafe 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/MarkdownBody.tsx @@ -5,18 +5,29 @@ import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from '@dee 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' -const FrontmatterBlock = lazy(async () => ({ default: (await import('./frontmatter-fields.tsx')).FrontmatterBlock })) +/** 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. + * 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. */ @@ -39,8 +50,22 @@ export function MarkdownBody({ content, resourceAddress, useResource, t }: Markd if (content.kind !== 'text') return null return (
- {split === undefined ? null : } + {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 index 203bf492e3..6230529ffc 100644 --- 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 @@ -1,22 +1,9 @@ -/* Leading YAML frontmatter: a bordered key/value list above the body, never heading text. */ -.frontmatter { +/* 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 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); + margin: 0; } .field { 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 index e3b7c33e0c..578a9c9b32 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter-fields.tsx @@ -1,61 +1,68 @@ /** - * 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. + * 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 { isMap, isNode, isScalar, parseDocument } from 'yaml' import css from './frontmatter-fields.module.css' -/** One top-level entry; `block` marks the YAML source of a collection value. */ +/** 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. + * Resolve top-level mapping fields from the YAML syntax tree. * @param source - YAML between the frontmatter delimiters. - * @returns the fields, or `undefined` for invalid YAML and non-mapping documents. + * @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 - 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)) + 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), + })) } -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 } +// 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 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. + * 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 FrontmatterBlock({ source }: { source: string }): ReactNode { +export function FrontmatterFields({ source, fallback }: FrontmatterFieldsProps): ReactNode { const fields = useMemo(() => parseFields(source), [source]) - if (fields === undefined) return
{source}
+ if (fields === undefined) return fallback if (fields.length === 0) return null return ( -
- {fields.map(field => ( -
+
+ {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 index 7ec058a607..26c186d03f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts @@ -6,8 +6,9 @@ export interface FrontmatterSplit { 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 +// Opening `---` on the first line, then the first closing `---` or `...` line; `??` lets an +// empty block close on the line right after the opening. +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. 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 87ffce5348..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,6 +53,7 @@ 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) @@ -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 8994003593..3bea55f798 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 @@ -132,35 +132,42 @@ describe('MarkdownBody', () => { 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', + '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]')) - 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']) + 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 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]'], + ['a scalar document', '~'], + ['a sequence document', '- first\n- second'], ])('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) + 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('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() + 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() 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() + }) +}) From 2ca5f648c390969dbda89c82735ed0b90ff319eb Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 28 Sep 2026 13:44:46 +0800 Subject: [PATCH 3/3] test(client): pin first-line-only frontmatter detection --- .../src/client/markdown/frontmatter.ts | 4 ++-- .../tests/markdown-body.client.spec.tsx | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts index 26c186d03f..ccdbbfe9cd 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/markdown/frontmatter.ts @@ -6,8 +6,8 @@ export interface FrontmatterSplit { body: string } -// Opening `---` on the first line, then the first closing `---` or `...` line; `??` lets an -// empty block close on the line right after the opening. +// 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 /** 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 3bea55f798..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 @@ -173,6 +173,16 @@ describe('MarkdownBody', () => { 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('')