Render Mermaid diagrams and worktree images in agent Markdown
This commit is contained in:
+64
-31
@@ -1,14 +1,17 @@
|
||||
import { h } from "./dom.js";
|
||||
import { diagram } from "./mermaid.js";
|
||||
|
||||
type Block = HTMLElement;
|
||||
|
||||
export type ChoiceHook = (text: string) => HTMLElement | null;
|
||||
|
||||
export type Options = { choice?: ChoiceHook; taskId?: string };
|
||||
|
||||
const choiceMarker = /^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$/;
|
||||
const detailsOpen = /^\s*<details>\s*(?:<summary>(.*?)<\/summary>)?\s*$/i;
|
||||
const summaryLine = /^\s*<summary>(.*?)<\/summary>\s*$/i;
|
||||
const detailsClose = /^\s*<\/details>\s*$/i;
|
||||
const fence = /^\s*(```|~~~)/;
|
||||
const fence = /^\s*(```|~~~)\s*(\S*)/;
|
||||
const heading = /^(#{1,6})\s+(.*)$/;
|
||||
const bullet = /^(\s*)[-*+]\s+(.*)$/;
|
||||
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
||||
@@ -16,15 +19,15 @@ const quote = /^\s*>\s?(.*)$/;
|
||||
const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
||||
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
||||
|
||||
export function markdown(src: string, className = "md", choice?: ChoiceHook): HTMLElement {
|
||||
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), choice));
|
||||
export function markdown(src: string, className = "md", opts: Options = {}): HTMLElement {
|
||||
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), opts));
|
||||
}
|
||||
|
||||
export function inlineMarkdown(src: string): HTMLElement {
|
||||
return h("span", { class: "md-inline" }, ...inline(src));
|
||||
export function inlineMarkdown(src: string, opts: Options = {}): HTMLElement {
|
||||
return h("span", { class: "md-inline" }, ...inline(src, opts));
|
||||
}
|
||||
|
||||
function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
function blocks(lines: string[], opts: Options): Block[] {
|
||||
const out: Block[] = [];
|
||||
let i = 0;
|
||||
|
||||
@@ -46,7 +49,7 @@ function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
out.push(h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
|
||||
out.push(f[2]!.toLowerCase() === "mermaid" ? diagram(body.join("\n")) : h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -75,14 +78,14 @@ function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
}
|
||||
body.push(l);
|
||||
}
|
||||
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details")), h("div", { class: "md-details-body" }, ...blocks(body, choice))));
|
||||
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details", opts)), h("div", { class: "md-details-body" }, ...blocks(body, opts))));
|
||||
continue;
|
||||
}
|
||||
|
||||
const hd = heading.exec(line);
|
||||
if (hd) {
|
||||
const level = Math.min(hd[1]!.length + 2, 6) as 3 | 4 | 5 | 6;
|
||||
out.push(h(`h${level}`, {}, ...inline(hd[2]!)));
|
||||
out.push(h(`h${level}`, {}, ...inline(hd[2]!, opts)));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -99,12 +102,12 @@ function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
body.push(quote.exec(lines[i]!)![1]!);
|
||||
i++;
|
||||
}
|
||||
out.push(h("blockquote", {}, ...blocks(body)));
|
||||
out.push(h("blockquote", {}, ...blocks(body, { taskId: opts.taskId })));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bullet.test(line) || ordered.test(line)) {
|
||||
const [list, next] = parseList(lines, i, choice);
|
||||
const [list, next] = parseList(lines, i, opts);
|
||||
out.push(list);
|
||||
i = next;
|
||||
continue;
|
||||
@@ -117,7 +120,7 @@ function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
rows.push(cells(lines[i]!));
|
||||
i++;
|
||||
}
|
||||
out.push(table(rows));
|
||||
out.push(table(rows, opts));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ function blocks(lines: string[], choice?: ChoiceHook): Block[] {
|
||||
para.push(lines[i]!.trim());
|
||||
i++;
|
||||
}
|
||||
out.push(h("p", {}, ...joinLines(para)));
|
||||
out.push(h("p", {}, ...joinLines(para, opts)));
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -147,13 +150,13 @@ function startsBlock(lines: string[], i: number): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function joinLines(lines: string[]): Node[] {
|
||||
function joinLines(lines: string[], opts: Options): Node[] {
|
||||
const out: Node[] = [];
|
||||
lines.forEach((l, n) => {
|
||||
if (n > 0) {
|
||||
out.push(h("br"));
|
||||
}
|
||||
out.push(...inline(l));
|
||||
out.push(...inline(l, opts));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -162,7 +165,7 @@ function indentOf(line: string): number {
|
||||
return line.length - line.trimStart().length;
|
||||
}
|
||||
|
||||
function parseList(lines: string[], start: number, choice?: ChoiceHook): [HTMLElement, number] {
|
||||
function parseList(lines: string[], start: number, opts: Options): [HTMLElement, number] {
|
||||
const first = lines[start]!;
|
||||
const isOrdered = !bullet.test(first);
|
||||
const base = indentOf(first);
|
||||
@@ -201,7 +204,7 @@ function parseList(lines: string[], start: number, choice?: ChoiceHook): [HTMLEl
|
||||
body.push(indentOf(next) > base ? next.slice(Math.min(indentOf(next), base + 2)) : next.trim());
|
||||
i++;
|
||||
}
|
||||
list.append(item(body, isOrdered ? undefined : choice));
|
||||
list.append(item(body, isOrdered ? { taskId: opts.taskId } : opts));
|
||||
while (i < lines.length && lines[i]!.trim() === "" && lines[i + 1] !== undefined && indentOf(lines[i + 1]!) === base && (isOrdered ? ordered : bullet).test(lines[i + 1]!)) {
|
||||
i++;
|
||||
}
|
||||
@@ -209,10 +212,10 @@ function parseList(lines: string[], start: number, choice?: ChoiceHook): [HTMLEl
|
||||
return [list, i];
|
||||
}
|
||||
|
||||
function item(body: string[], choice?: ChoiceHook): HTMLElement {
|
||||
function item(body: string[], opts: Options): HTMLElement {
|
||||
const li = h("li");
|
||||
const m = choice && choiceMarker.exec(body[0]!);
|
||||
const picker = m ? choice(m[2]!) : null;
|
||||
const m = opts.choice && choiceMarker.exec(body[0]!);
|
||||
const picker = m ? opts.choice!(m[2]!) : null;
|
||||
if (picker) {
|
||||
li.className = "choice";
|
||||
li.append(picker);
|
||||
@@ -220,7 +223,7 @@ function item(body: string[], choice?: ChoiceHook): HTMLElement {
|
||||
const first = picker ? [] : [body[0]!];
|
||||
const rest = body.slice(1);
|
||||
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
|
||||
li.append(...joinLines([...first, ...rest].map((l) => l.trim())));
|
||||
li.append(...joinLines([...first, ...rest].map((l) => l.trim()), opts));
|
||||
return li;
|
||||
}
|
||||
const head: string[] = first;
|
||||
@@ -229,7 +232,7 @@ function item(body: string[], choice?: ChoiceHook): HTMLElement {
|
||||
head.push(body[k]!.trim());
|
||||
k++;
|
||||
}
|
||||
li.append(...joinLines(head), ...blocks(body.slice(k), choice));
|
||||
li.append(...joinLines(head, opts), ...blocks(body.slice(k), opts));
|
||||
return li;
|
||||
}
|
||||
|
||||
@@ -248,7 +251,7 @@ function cells(line: string): string[] {
|
||||
return s.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function table(rows: string[][]): HTMLElement {
|
||||
function table(rows: string[][], opts: Options): HTMLElement {
|
||||
const [head, ...body] = rows;
|
||||
return h(
|
||||
"div",
|
||||
@@ -256,15 +259,16 @@ function table(rows: string[][]): HTMLElement {
|
||||
h(
|
||||
"table",
|
||||
{},
|
||||
h("thead", {}, h("tr", {}, ...head!.map((c) => h("th", {}, ...inline(c))))),
|
||||
h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c)))))),
|
||||
h("thead", {}, h("tr", {}, ...head!.map((c) => h("th", {}, ...inline(c, opts))))),
|
||||
h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c, opts)))))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
|
||||
const inlineToken =
|
||||
/`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
|
||||
|
||||
function inline(src: string): Node[] {
|
||||
function inline(src: string, opts: Options): Node[] {
|
||||
const out: Node[] = [];
|
||||
let rest = src;
|
||||
while (rest.length > 0) {
|
||||
@@ -276,15 +280,17 @@ function inline(src: string): Node[] {
|
||||
if (m.index > 0) {
|
||||
out.push(document.createTextNode(rest.slice(0, m.index)));
|
||||
}
|
||||
const [whole, code, strong1, strong2, em1, em2, linkText, linkHref, bare] = m;
|
||||
const [whole, code, strong1, strong2, em1, em2, imgAlt, imgAngled, imgSrc, linkText, linkHref, bare] = m;
|
||||
if (code !== undefined) {
|
||||
out.push(h("code", {}, code));
|
||||
} else if (strong1 !== undefined || strong2 !== undefined) {
|
||||
out.push(h("strong", {}, ...inline(strong1 ?? strong2!)));
|
||||
out.push(h("strong", {}, ...inline(strong1 ?? strong2!, opts)));
|
||||
} else if (em1 !== undefined || em2 !== undefined) {
|
||||
out.push(h("em", {}, ...inline(em1 ?? em2!)));
|
||||
out.push(h("em", {}, ...inline(em1 ?? em2!, opts)));
|
||||
} else if (imgAlt !== undefined) {
|
||||
out.push(image((imgAngled ?? imgSrc!).trim(), imgAlt, opts.taskId));
|
||||
} else if (linkText !== undefined) {
|
||||
out.push(link(linkHref!, inline(linkText)));
|
||||
out.push(link(linkHref!, inline(linkText, opts)));
|
||||
} else if (bare !== undefined) {
|
||||
out.push(link(bare, [document.createTextNode(bare)]));
|
||||
}
|
||||
@@ -302,3 +308,30 @@ function link(href: string, children: Node[]): Node {
|
||||
}
|
||||
return h("span", {}, ...children);
|
||||
}
|
||||
|
||||
function worktreeImageURL(path: string, taskId: string | undefined): string | null {
|
||||
if (/^data:image\/(png|jpeg|gif|webp);/i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
let file = path;
|
||||
if (/^file:\/\//i.test(file)) {
|
||||
file = decodeURI(file.slice("file://".length));
|
||||
} else if (/^[a-z][a-z0-9+.-]*:/i.test(file)) {
|
||||
return null;
|
||||
}
|
||||
if (!taskId) {
|
||||
return null;
|
||||
}
|
||||
return `/api/tasks/${encodeURIComponent(taskId)}/image?path=${encodeURIComponent(file)}`;
|
||||
}
|
||||
|
||||
export function image(src: string, alt: string, taskId: string | undefined): Node {
|
||||
const url = worktreeImageURL(src, taskId);
|
||||
if (!url) {
|
||||
return link(src, [document.createTextNode(alt || src)]);
|
||||
}
|
||||
const img = h("img", { src: url, alt, loading: "lazy" });
|
||||
const el = h("a", { class: "md-img", href: url, target: "_blank", rel: "noopener" }, img);
|
||||
img.onerror = () => el.replaceWith(h("span", { class: "md-img-missing" }, `image not available: ${src}`));
|
||||
return el;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user