diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts new file mode 100644 index 0000000..236ef4c --- /dev/null +++ b/src/lib/frontmatter.ts @@ -0,0 +1,40 @@ +const FRONTMATTER_DELIMITER = /^---[ \t]*$/; + +function lineWithoutCarriageReturn(src: string, start: number, end: number): string { + const line = src.slice(start, end); + return line.endsWith("\r") ? line.slice(0, -1) : line; +} + +/** + * Hide a leading YAML frontmatter block without changing source positions. + * + * Replacing non-newline characters with spaces keeps markdown-it token maps + * aligned with the original document for preview-to-source selection sync. + */ +export function maskYamlFrontmatter(src: string): string { + const firstNewline = src.indexOf("\n"); + const firstLineEnd = firstNewline === -1 ? src.length : firstNewline; + const firstLine = lineWithoutCarriageReturn(src, 0, firstLineEnd); + + if (!FRONTMATTER_DELIMITER.test(firstLine) || firstNewline === -1) { + return src; + } + + let lineStart = firstNewline + 1; + while (lineStart <= src.length) { + const newline = src.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? src.length : newline; + const line = lineWithoutCarriageReturn(src, lineStart, lineEnd); + + if (FRONTMATTER_DELIMITER.test(line)) { + const frontmatterEnd = newline === -1 ? lineEnd : newline + 1; + const masked = src.slice(0, frontmatterEnd).replace(/[^\r\n]/g, " "); + return masked + src.slice(frontmatterEnd); + } + + if (newline === -1) break; + lineStart = newline + 1; + } + + return src; +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 2d990d4..df4b43b 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import mark from "markdown-it-mark"; import taskLists from "markdown-it-task-lists"; import type { Highlighter } from "shiki"; +import { maskYamlFrontmatter } from "./frontmatter"; import { plantUmlUrl } from "./plantuml"; import type { Theme } from "./theme"; @@ -171,7 +172,8 @@ export async function ensureMarkdownReady(): Promise { } export async function renderMarkdown(src: string, theme: Theme): Promise { - const langs = extractLangs(src); + const renderSource = maskYamlFrontmatter(src); + const langs = extractLangs(renderSource); if (langs.length > 0) { const h = await getHighlighter(); const shikiTheme = THEMES[theme]; @@ -179,5 +181,5 @@ export async function renderMarkdown(src: string, theme: Theme): Promise await ensureLangsLoaded(h, langs); activeShikiTheme = shikiTheme; } - return md.render(src); + return md.render(renderSource); } diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts new file mode 100644 index 0000000..0f20f53 --- /dev/null +++ b/tests/markdown.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { renderMarkdown } from "../src/lib/markdown"; + +test("hides leading YAML frontmatter and preserves source line mapping", async () => { + const source = [ + "---", + "created: 2026-07-27", + "updated: 2026-07-31", + "type: reference", + "status: complete", + "tags: [workbook, principles, defaults]", + "---", + "", + "# Your Context Block: your defaults", + "", + "Body text here.", + ].join("\n"); + + const html = await renderMarkdown(source, "latte"); + + expect(html).not.toContain("created: 2026-07-27"); + expect(html).not.toContain("', + ); + expect(html).toContain("Body text here."); +}); + +test("keeps thematic breaks outside leading frontmatter", async () => { + const html = await renderMarkdown("# Heading\n\n---\n\nBody", "latte"); + + expect(html).toContain('
'); +}); + +test("leaves an unterminated opening delimiter as ordinary markdown", async () => { + const html = await renderMarkdown("---\nkey: value\n# Heading", "latte"); + + expect(html).toContain("