From 3b02e6899d55352e09c7770bd672027bb0a5f442 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:48:51 +0800 Subject: [PATCH] feat(markdown): show YAML front matter as a table in the preview (#1351) A Markdown file starting with YAML front matter (agent SKILL.md files, static site pages) showed it in the preview as a thematic break and a run of text. The preview now shows every top-level field in a key / value table above the body; nested lists and mappings keep their YAML layout. Blocks after it keep their source lines for scroll sync. Text between two thematic breaks that isn't a mapping is rendered as before. Fixes #1239 --- src/features/viewers/markdown/render.test.ts | 47 ++++++++++++++++++- src/features/viewers/markdown/render.ts | 49 ++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/features/viewers/markdown/render.test.ts b/src/features/viewers/markdown/render.test.ts index 6519c8bc..cc2b8489 100644 --- a/src/features/viewers/markdown/render.test.ts +++ b/src/features/viewers/markdown/render.test.ts @@ -1,5 +1,50 @@ import { describe, expect, it } from "vitest"; -import { joinPath, previewLineAtOffset, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; +import { + frontMatter, + joinPath, + previewLineAtOffset, + previewOffsetForLine, + renderMarkdown, + resolveLink, + slugify, +} from "./render"; + +describe("front matter", () => { + const skill = + "---\nname: review\ndescription: Reviews a diff: bugs first\ntools:\n - Read\n - Grep\nmeta:\n owner: team\n---\n\n# Review\n\nBody\n"; + + it("shows every top-level field above the body, nested values as written", () => { + expect(frontMatter(skill)).toEqual({ + fields: [ + ["name", "review"], + ["description", "Reviews a diff: bugs first"], + ["tools", " - Read\n - Grep"], + ["meta", " owner: team"], + ], + lines: 9, + }); + const root = document.createElement("div"); + root.innerHTML = renderMarkdown(skill); + const blocks = [...root.querySelectorAll(":scope > [data-line]")]; + expect(blocks.map((b) => [b.dataset.line, b.firstElementChild?.tagName])).toEqual([ + ["0", "TABLE"], + ["10", "H1"], + ["12", "P"], + ]); + const rows = [...root.querySelectorAll("table[data-front-matter] tr")].map((r) => [ + r.querySelector("th")?.textContent, + r.querySelector("td")?.textContent, + ]); + expect(rows[1]).toEqual(["description", "Reviews a diff: bugs first"]); + expect(root.querySelector("td pre")?.textContent).toBe("- Read\n- Grep"); + }); + + it("leaves a document without front matter, or a thematic break, alone", () => { + expect(frontMatter("# Title\n---\nx: y\n---\n")).toBeNull(); + expect(frontMatter("---\nJust a paragraph between rules.\n---\n")).toBeNull(); + expect(frontMatter("---\n---\n")).toBeNull(); + }); +}); describe("markdown preview rendering", () => { it("tags blocks with their source line", () => { diff --git a/src/features/viewers/markdown/render.ts b/src/features/viewers/markdown/render.ts index a08c39dc..e3fe1cff 100644 --- a/src/features/viewers/markdown/render.ts +++ b/src/features/viewers/markdown/render.ts @@ -3,7 +3,8 @@ * in an element carrying its 0-based source line (`data-line`) for scroll * sync; images keep their source in `data-src` and are loaded by the preview * (local files through the backend, as the CSP blocks file URLs). Mermaid - * blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`. + * blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`. YAML front matter is shown + * as a table of its top-level fields. */ import { Marked, type Token, type TokensList } from "marked"; import DOMPurify from "dompurify"; @@ -62,11 +63,49 @@ const countLines = (s: string) => { return n; }; +/** The YAML front matter at the start of `text`: its top-level fields and how many lines it spans. */ +export function frontMatter(text: string): { fields: [string, string][]; lines: number } | null { + const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/.exec(text); + if (!m) return null; + const fields: [string, string][] = []; + for (const raw of m[1]!.split(/\r?\n/)) { + const field = /^([^\s#:-][^:]*?):(?:[ \t]+(.*))?$/.exec(raw); + if (field) fields.push([field[1]!.trim(), (field[2] ?? "").trim()]); + else if (raw.trim() && !raw.trimStart().startsWith("#")) { + // A nested mapping or a list belongs to the field above it; anything else isn't front matter. + const last = fields.at(-1); + if (!last || !/^[\s-]/.test(raw)) return null; + last[1] = last[1] ? `${last[1]}\n${raw}` : raw; + } + } + return fields.length ? { fields, lines: countLines(m[0]) + (m[0].endsWith("\n") ? 0 : 1) } : null; +} + +/** `text` without the indentation all its lines share. */ +function dedent(text: string): string { + const lines = text.split("\n"); + const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => /^ */.exec(l)![0].length)); + return lines.map((l) => l.slice(indent)).join("\n"); +} + +function frontMatterTable(fields: readonly [string, string][]): string { + const rows = fields.map(([key, value]) => { + // Nested values keep their YAML layout. + const shown = value.includes("\n") + ? `
${escapeHtml(dedent(value.replace(/^\n+/, "")))}
` + : escapeHtml(value); + return `${escapeHtml(key)}${shown}`; + }); + return `${rows.join("")}
`; +} + export function renderMarkdown(text: string): string { - const tokens = marked.lexer(text); + const front = frontMatter(text); + const body = front ? text.split("\n").slice(front.lines).join("\n") : text; + const tokens = marked.lexer(body); const slugs = new Map(); - let line = 0; - let html = ""; + let line = front?.lines ?? 0; + let html = front ? `
${frontMatterTable(front.fields)}
` : ""; for (const token of tokens) { const start = line; line += countLines(token.raw); @@ -84,7 +123,7 @@ export function renderMarkdown(text: string): string { // The preview shares the IDE's document: a `