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 de4c4d2265..b92710ea28 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -21,8 +21,8 @@ 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 d420c2f34e..f4087e795e 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -65,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 77e3cc79a8..fa16c8453a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -65,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/package.json b/packages/client/ui-sidebar-documentpreview/package.json index a85d1f6eaa..0996cb3b2c 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.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 27c672c60e..714d68bafe 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,33 @@ /** 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 type { FrontmatterFieldsProps } from './frontmatter-fields.tsx' import { markdownImageUrl } from './path-images.ts' import type {} from './locales.ts' import css from './MarkdownBody.module.css' +/** 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; 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. */ @@ -30,10 +45,27 @@ 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 : } + +
+ ) +} + +/** + * 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: