mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user