diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 405f008..317e4d8 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -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 diff --git a/src/client/markdown-frontmatter.ts b/src/client/markdown-frontmatter.ts new file mode 100644 index 0000000..2c31dff --- /dev/null +++ b/src/client/markdown-frontmatter.ts @@ -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 +} diff --git a/tests/markdown-frontmatter.spec.tsx b/tests/markdown-frontmatter.spec.tsx new file mode 100644 index 0000000..7ac43e2 --- /dev/null +++ b/tests/markdown-frontmatter.spec.tsx @@ -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[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('