feat(client): render Markdown YAML frontmatter as fields in document preview
Closes #5195
This commit is contained in:
1 parent
8be17176fe
commit
0cad3a0137
11 files changed
+183
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<a id="how-it-reads"></a>
|
||||
## How it reads
|
||||
|
||||
|
||||
@@ -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 时加载。源文件不会被修改。
|
||||
|
||||
<a id="how-it-reads"></a>
|
||||
## 怎么读
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<MarkdownLabels>(() => ({
|
||||
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 (
|
||||
<div className={css.document} data-document-markdown>
|
||||
<MarkdownText text={content.text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
|
||||
{split === undefined ? null : <Suspense fallback={null}><FrontmatterBlock source={split.source} /></Suspense>}
|
||||
<MarkdownText text={split?.body ?? text} streaming={!content.eof} labels={labels} pathImages={pathImages} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
@@ -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 <pre className={css.frontmatter} data-document-frontmatter>{source}</pre>
|
||||
if (fields.length === 0) return null
|
||||
return (
|
||||
<dl className={css.frontmatter} data-document-frontmatter>
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className={css.field}>
|
||||
<dt className={css.key}>{field.key}</dt>
|
||||
<dd className={field.block ? css.block : css.value}>{field.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
+1
-1
@@ -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([])
|
||||
|
||||
@@ -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(<MarkdownBody {...props(content([text], true))} />)
|
||||
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(<MarkdownBody {...props(content([`---\r\n${source}\r\n...\r\nBody.`], true))} />)
|
||||
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(<MarkdownBody {...props(content(['---\n# comment\n---\nBody.'], true))} />)
|
||||
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(<MarkdownBody {...props(content(['---\n---\nNext.'], true))} />)
|
||||
expect(view.container.querySelector('[data-document-frontmatter]')).toBeNull()
|
||||
expect(view.getByText('Next.')).toBeDefined()
|
||||
view.rerender(<MarkdownBody {...props(content(['---\nname: pdf'], false))} />)
|
||||
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(<MarkdownBody {...props({ kind: 'text', text: '', pages: [], eof: true })} />)
|
||||
expect(view.container.querySelector('[data-document-markdown]')?.textContent).toBe('')
|
||||
|
||||
Generated
+3
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user