Merge pull request #394 from Beverly621/fix/markdown-frontmatter-preview
fix(editor): hide YAML frontmatter in Markdown preview(在 Markdown 预览中隐藏 YAML Front Matter)
This commit is contained in:
+14
-10
@@ -22,6 +22,7 @@ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { api, htmlUrl } from './api.ts'
|
||||
import { markdownPreviewSource } from './markdown-frontmatter.ts'
|
||||
import { rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
@@ -237,25 +238,28 @@ export function TextEditor(props: FileViewerProps) {
|
||||
|
||||
/** The markdown source the preview renders (draft wins over saved content). */
|
||||
const mdText = draft ?? content ?? ''
|
||||
/** The preview source: `mdText` with local image destinations rewritten to
|
||||
* absolute media URLs (see {@link rewriteLocalImageUrls}); the raw
|
||||
* `mdText` stays untouched for selection/line lookup and for mermaid-block
|
||||
* detection, which are unaffected by image syntax. */
|
||||
/** Preview-only source with a closed leading YAML frontmatter block hidden.
|
||||
* The raw `mdText` stays untouched for editing, saving, and selection line
|
||||
* lookup. All preview renderers share this source so plain Markdown,
|
||||
* Mermaid, and documents containing raw HTML behave consistently. */
|
||||
const previewMdText = markdown ? markdownPreviewSource(mdText) : mdText
|
||||
/** The preview source with local image destinations rewritten to absolute
|
||||
* media URLs (see {@link rewriteLocalImageUrls}). */
|
||||
const previewText = markdown
|
||||
? rewriteLocalImageUrls(mdText, scope, path, window.location.origin)
|
||||
: mdText
|
||||
? rewriteLocalImageUrls(previewMdText, scope, path, window.location.origin)
|
||||
: previewMdText
|
||||
/** md/mermaid block split for the preview (mermaid fences lift out). Split
|
||||
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
|
||||
const mdBlocks = useMemo(
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
|
||||
[markdown, mode, mdText],
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
|
||||
[markdown, mode, mdText],
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
const hasMermaid = useMemo(
|
||||
() => htmlInfo !== null
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Return the Markdown source used by preview renderers.
|
||||
*
|
||||
* A closed YAML frontmatter block is metadata, but the shared MarkdownText
|
||||
* parser treats its delimiters as a thematic break and a setext heading. Hide
|
||||
* only a block that starts on the first line and has its own closing `---`
|
||||
* line. Unclosed or non-leading delimiters remain byte-for-byte unchanged so
|
||||
* ordinary Markdown is never truncated. The editor and save path keep using
|
||||
* the original source; this helper is preview-only.
|
||||
*/
|
||||
export function markdownPreviewSource(source: string): string {
|
||||
const firstLineEnd = source.indexOf('\n')
|
||||
if (firstLineEnd === -1) return source
|
||||
|
||||
const firstLineStart = source.charCodeAt(0) === 0xFEFF ? 1 : 0
|
||||
const firstLine = source.slice(firstLineStart, firstLineEnd).replace(/\r$/u, '')
|
||||
if (firstLine !== '---') return source
|
||||
|
||||
let lineStart = firstLineEnd + 1
|
||||
while (lineStart <= source.length) {
|
||||
const newline = source.indexOf('\n', lineStart)
|
||||
const lineEnd = newline === -1 ? source.length : newline
|
||||
const line = source.slice(lineStart, lineEnd).replace(/\r$/u, '')
|
||||
if (line === '---') return newline === -1 ? '' : source.slice(newline + 1)
|
||||
if (newline === -1) return source
|
||||
lineStart = newline + 1
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Markdown frontmatter preview regression (#251): a leading, closed YAML
|
||||
* metadata block must not reach the shared MarkdownText parser, where its
|
||||
* delimiters are otherwise interpreted as a thematic break and setext H2.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import './browser-globals.ts'
|
||||
import { markdownPreviewSource } from '../src/client/markdown-frontmatter.ts'
|
||||
import { TextEditor } from '../src/client/TextEditor.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { attachLocale } from '../src/client/locales.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
const FRONTMATTER = [
|
||||
'---',
|
||||
'name: example',
|
||||
'description: hello',
|
||||
'---',
|
||||
'# Title',
|
||||
'',
|
||||
'Body',
|
||||
].join('\n')
|
||||
|
||||
const CTX = {} as Parameters<typeof TextEditor>[0]['ctx']
|
||||
|
||||
function viewerProps(content: string): FileViewerProps {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store: createSidebarStore(),
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/SKILL.md',
|
||||
title: 'SKILL.md',
|
||||
viewerId: 'markdown',
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
attachLocale(undefined)
|
||||
})
|
||||
|
||||
describe('markdownPreviewSource', () => {
|
||||
it('removes a closed YAML frontmatter block only from the preview source', () => {
|
||||
expect(markdownPreviewSource(FRONTMATTER)).toBe('# Title\n\nBody')
|
||||
})
|
||||
|
||||
it('supports CRLF without normalizing the body', () => {
|
||||
expect(markdownPreviewSource('---\r\nname: example\r\n---\r\n# Title\r\nBody'))
|
||||
.toBe('# Title\r\nBody')
|
||||
})
|
||||
|
||||
it('leaves unclosed and non-leading delimiters unchanged', () => {
|
||||
const unclosed = '---\nname: example\n# Title'
|
||||
const nonLeading = '# Title\n\n---\nname: example\n---\n'
|
||||
expect(markdownPreviewSource(unclosed)).toBe(unclosed)
|
||||
expect(markdownPreviewSource(nonLeading)).toBe(nonLeading)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TextEditor markdown frontmatter preview', () => {
|
||||
it('renders the body without exposing YAML metadata as a heading', () => {
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(FRONTMATTER)))
|
||||
expect(html).toContain('<h1')
|
||||
expect(html).not.toContain('<h2')
|
||||
expect(html).toContain('Title')
|
||||
expect(html).toContain('Body')
|
||||
expect(html).not.toContain('name: example')
|
||||
expect(html).not.toContain('description: hello')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user