feat(markdown): show YAML front matter as a table in the preview

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
This commit is contained in:
l0ng-ai
2026-11-05 02:48:32 +08:00
parent 948e93e1a2
commit fde3ed6aae
2 changed files with 90 additions and 6 deletions
+46 -1
View File
@@ -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<HTMLElement>(":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", () => {
+44 -5
View File
@@ -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")
? `<pre>${escapeHtml(dedent(value.replace(/^\n+/, "")))}</pre>`
: escapeHtml(value);
return `<tr><th>${escapeHtml(key)}</th><td>${shown}</td></tr>`;
});
return `<table data-front-matter=""><tbody>${rows.join("")}</tbody></table>`;
}
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<string, number>();
let line = 0;
let html = "";
let line = front?.lines ?? 0;
let html = front ? `<div data-line="0">${frontMatterTable(front.fields)}</div>` : "";
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 `<style>` in a file would restyle the whole IDE.
return purify().sanitize(html, {
USE_PROFILES: { html: true },
ADD_ATTR: ["data-src", "data-line"],
ADD_ATTR: ["data-src", "data-line", "data-front-matter"],
FORBID_TAGS: ["style", "form"],
FORBID_ATTR: ["style"],
});