2026-10-15 16:52:20 -07:00
|
|
|
import { copyable, h } from "./dom.js";
|
2026-10-15 07:27:17 -07:00
|
|
|
import { diagram } from "./mermaid.js";
|
2026-10-15 13:30:09 -07:00
|
|
|
import { taskStyle } from "./taskcolor.js";
|
2026-10-14 23:34:39 -07:00
|
|
|
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;
|
2026-10-15 07:27:17 -07:00
|
|
|
const fence = /^\s*(```|~~~)\s*(\S*)/;
|
2026-10-14 10:37:04 -07:00
|
|
|
const heading = /^(#{1,6})\s+(.*)$/;
|
|
|
|
|
const bullet = /^(\s*)[-*+]\s+(.*)$/;
|
|
|
|
|
const ordered = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
|
|
|
|
const quote = /^\s*>\s?(.*)$/;
|
|
|
|
|
const rule = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
|
|
|
|
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
2026-10-15 07:27:17 -07:00
|
|
|
export function markdown(src, className = "md", opts = {}) {
|
|
|
|
|
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
export function inlineMarkdown(src, opts = {}) {
|
|
|
|
|
return h("span", { class: "md-inline" }, ...inline(src, opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function blocks(lines, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const out = [];
|
|
|
|
|
let i = 0;
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
if (line.trim() === "") {
|
|
|
|
|
i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const f = fence.exec(line);
|
|
|
|
|
if (f) {
|
|
|
|
|
const marker = f[1];
|
|
|
|
|
const body = [];
|
|
|
|
|
i++;
|
|
|
|
|
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
|
|
|
|
body.push(lines[i]);
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
i++;
|
2026-10-15 16:52:20 -07:00
|
|
|
out.push(f[2].toLowerCase() === "mermaid" ? diagram(body.join("\n")) : copyable(h("pre", { class: "md-code" }, h("code", {}, body.join("\n")))));
|
2026-10-14 10:37:04 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-14 23:34:39 -07:00
|
|
|
if (detailsClose.test(line)) {
|
|
|
|
|
i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const d = detailsOpen.exec(line);
|
|
|
|
|
if (d) {
|
|
|
|
|
let title = d[1];
|
|
|
|
|
i++;
|
|
|
|
|
if (title === undefined && i < lines.length && summaryLine.test(lines[i])) {
|
|
|
|
|
title = summaryLine.exec(lines[i])[1];
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
const body = [];
|
|
|
|
|
let depth = 1;
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
const l = lines[i];
|
|
|
|
|
i++;
|
|
|
|
|
if (detailsOpen.test(l)) {
|
|
|
|
|
depth++;
|
|
|
|
|
}
|
|
|
|
|
else if (detailsClose.test(l) && --depth === 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
body.push(l);
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h("details", { class: "md-details" }, h("summary", {}, ...inline(title?.trim() || "Details", opts)), h("div", { class: "md-details-body" }, ...blocks(body, opts))));
|
2026-10-14 23:34:39 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-10-14 10:37:04 -07:00
|
|
|
const hd = heading.exec(line);
|
|
|
|
|
if (hd) {
|
|
|
|
|
const level = Math.min(hd[1].length + 2, 6);
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h(`h${level}`, {}, ...inline(hd[2], opts)));
|
2026-10-14 10:37:04 -07:00
|
|
|
i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (rule.test(line)) {
|
|
|
|
|
out.push(h("hr"));
|
|
|
|
|
i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (quote.test(line)) {
|
|
|
|
|
const body = [];
|
|
|
|
|
while (i < lines.length && quote.test(lines[i])) {
|
|
|
|
|
body.push(quote.exec(lines[i])[1]);
|
|
|
|
|
i++;
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h("blockquote", {}, ...blocks(body, { taskId: opts.taskId })));
|
2026-10-14 10:37:04 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (bullet.test(line) || ordered.test(line)) {
|
2026-10-15 07:27:17 -07:00
|
|
|
const [list, next] = parseList(lines, i, opts);
|
2026-10-14 10:37:04 -07:00
|
|
|
out.push(list);
|
|
|
|
|
i = next;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (line.includes("|") && i + 1 < lines.length && tableSep.test(lines[i + 1])) {
|
|
|
|
|
const rows = [cells(line)];
|
|
|
|
|
i += 2;
|
|
|
|
|
while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") {
|
|
|
|
|
rows.push(cells(lines[i]));
|
|
|
|
|
i++;
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(table(rows, opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const para = [];
|
|
|
|
|
while (i < lines.length && lines[i].trim() !== "" && !startsBlock(lines, i)) {
|
|
|
|
|
para.push(lines[i].trim());
|
|
|
|
|
i++;
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h("p", {}, ...joinLines(para, opts)));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function startsBlock(lines, i) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
return (fence.test(line) ||
|
2026-10-14 23:34:39 -07:00
|
|
|
detailsOpen.test(line) ||
|
|
|
|
|
detailsClose.test(line) ||
|
2026-10-14 10:37:04 -07:00
|
|
|
heading.test(line) ||
|
|
|
|
|
rule.test(line) ||
|
|
|
|
|
quote.test(line) ||
|
|
|
|
|
bullet.test(line) ||
|
|
|
|
|
ordered.test(line) ||
|
|
|
|
|
(line.includes("|") && i + 1 < lines.length && tableSep.test(lines[i + 1])));
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function joinLines(lines, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const out = [];
|
|
|
|
|
lines.forEach((l, n) => {
|
|
|
|
|
if (n > 0) {
|
|
|
|
|
out.push(h("br"));
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(...inline(l, opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
});
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function indentOf(line) {
|
|
|
|
|
return line.length - line.trimStart().length;
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function parseList(lines, start, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const first = lines[start];
|
|
|
|
|
const isOrdered = !bullet.test(first);
|
|
|
|
|
const base = indentOf(first);
|
|
|
|
|
const list = isOrdered ? h("ol") : h("ul");
|
|
|
|
|
if (isOrdered) {
|
|
|
|
|
const n = Number(ordered.exec(first)[2]);
|
|
|
|
|
if (n !== 1) {
|
|
|
|
|
list.start = n;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let i = start;
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
const m = isOrdered ? ordered.exec(line) : bullet.exec(line);
|
|
|
|
|
if (!m || indentOf(line) !== base) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const text = isOrdered ? m[3] : m[2];
|
|
|
|
|
const body = [text];
|
|
|
|
|
i++;
|
|
|
|
|
while (i < lines.length) {
|
|
|
|
|
const next = lines[i];
|
|
|
|
|
if (next.trim() === "") {
|
|
|
|
|
const after = lines[i + 1];
|
|
|
|
|
if (after !== undefined && indentOf(after) > base && after.trim() !== "") {
|
|
|
|
|
body.push("");
|
|
|
|
|
i++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (indentOf(next) <= base && (bullet.test(next) || ordered.test(next) || startsBlock(lines, i))) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
body.push(indentOf(next) > base ? next.slice(Math.min(indentOf(next), base + 2)) : next.trim());
|
|
|
|
|
i++;
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
list.append(item(body, isOrdered ? { taskId: opts.taskId } : opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
while (i < lines.length && lines[i].trim() === "" && lines[i + 1] !== undefined && indentOf(lines[i + 1]) === base && (isOrdered ? ordered : bullet).test(lines[i + 1])) {
|
|
|
|
|
i++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [list, i];
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function item(body, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const li = h("li");
|
2026-10-15 07:27:17 -07:00
|
|
|
const m = opts.choice && choiceMarker.exec(body[0]);
|
|
|
|
|
const picker = m ? opts.choice(m[2]) : null;
|
2026-10-14 23:34:39 -07:00
|
|
|
if (picker) {
|
|
|
|
|
li.className = "choice";
|
|
|
|
|
li.append(picker);
|
|
|
|
|
}
|
2026-10-15 11:46:24 -07:00
|
|
|
const target = picker ?? li;
|
2026-10-14 23:34:39 -07:00
|
|
|
const first = picker ? [] : [body[0]];
|
2026-10-14 10:37:04 -07:00
|
|
|
const rest = body.slice(1);
|
|
|
|
|
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
|
2026-10-15 11:46:24 -07:00
|
|
|
if (picker && rest.length > 0) {
|
|
|
|
|
target.append(h("br"));
|
|
|
|
|
}
|
|
|
|
|
target.append(...joinLines([...first, ...rest].map((l) => l.trim()), opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
return li;
|
|
|
|
|
}
|
2026-10-14 23:34:39 -07:00
|
|
|
const head = first;
|
2026-10-14 10:37:04 -07:00
|
|
|
let k = 1;
|
|
|
|
|
while (k < body.length && body[k] !== "" && !startsBlockLine(body[k])) {
|
|
|
|
|
head.push(body[k].trim());
|
|
|
|
|
k++;
|
|
|
|
|
}
|
2026-10-15 11:46:24 -07:00
|
|
|
if (picker && head.length > 0) {
|
|
|
|
|
target.append(h("br"));
|
|
|
|
|
}
|
|
|
|
|
target.append(...joinLines(head, opts), ...blocks(body.slice(k), opts));
|
2026-10-14 10:37:04 -07:00
|
|
|
return li;
|
|
|
|
|
}
|
|
|
|
|
function startsBlockLine(line) {
|
|
|
|
|
return fence.test(line) || heading.test(line) || quote.test(line) || bullet.test(line) || ordered.test(line);
|
|
|
|
|
}
|
|
|
|
|
function cells(line) {
|
|
|
|
|
let s = line.trim();
|
|
|
|
|
if (s.startsWith("|")) {
|
|
|
|
|
s = s.slice(1);
|
|
|
|
|
}
|
|
|
|
|
if (s.endsWith("|")) {
|
|
|
|
|
s = s.slice(0, -1);
|
|
|
|
|
}
|
|
|
|
|
return s.split("|").map((c) => c.trim());
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function table(rows, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const [head, ...body] = rows;
|
2026-10-15 07:27:17 -07:00
|
|
|
return h("div", { class: "md-table" }, h("table", {}, 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))))))));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
|
|
|
|
|
function inline(src, opts) {
|
2026-10-14 10:37:04 -07:00
|
|
|
const out = [];
|
|
|
|
|
let rest = src;
|
|
|
|
|
while (rest.length > 0) {
|
|
|
|
|
const m = inlineToken.exec(rest);
|
|
|
|
|
if (!m) {
|
|
|
|
|
out.push(document.createTextNode(rest));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (m.index > 0) {
|
|
|
|
|
out.push(document.createTextNode(rest.slice(0, m.index)));
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
const [whole, code, strong1, strong2, em1, em2, imgAlt, imgAngled, imgSrc, linkText, linkHref, bare] = m;
|
2026-10-14 10:37:04 -07:00
|
|
|
if (code !== undefined) {
|
|
|
|
|
out.push(h("code", {}, code));
|
|
|
|
|
}
|
|
|
|
|
else if (strong1 !== undefined || strong2 !== undefined) {
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h("strong", {}, ...inline(strong1 ?? strong2, opts)));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
|
|
|
|
else if (em1 !== undefined || em2 !== undefined) {
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(h("em", {}, ...inline(em1 ?? em2, opts)));
|
|
|
|
|
}
|
|
|
|
|
else if (imgAlt !== undefined) {
|
|
|
|
|
out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
|
|
|
|
else if (linkText !== undefined) {
|
2026-10-15 07:27:17 -07:00
|
|
|
out.push(link(linkHref, inline(linkText, opts)));
|
2026-10-14 10:37:04 -07:00
|
|
|
}
|
|
|
|
|
else if (bare !== undefined) {
|
|
|
|
|
out.push(link(bare, [document.createTextNode(bare)]));
|
|
|
|
|
}
|
|
|
|
|
rest = rest.slice(m.index + whole.length);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function link(href, children) {
|
|
|
|
|
if (/^https?:\/\//i.test(href)) {
|
|
|
|
|
return h("a", { href, target: "_blank", rel: "noopener noreferrer" }, ...children);
|
|
|
|
|
}
|
2026-10-15 18:55:38 -07:00
|
|
|
const task = /^#\/task\/(\w+)(\/\w+)?$/.exec(href);
|
2026-10-15 13:30:09 -07:00
|
|
|
if (task) {
|
|
|
|
|
return h("a", { href, class: "task-link", style: taskStyle(task[1]) }, ...children);
|
|
|
|
|
}
|
2026-10-14 10:37:04 -07:00
|
|
|
if (href.startsWith("#/")) {
|
|
|
|
|
return h("a", { href }, ...children);
|
|
|
|
|
}
|
|
|
|
|
return h("span", {}, ...children);
|
|
|
|
|
}
|
2026-10-15 07:27:17 -07:00
|
|
|
function worktreeImageURL(path, taskId) {
|
|
|
|
|
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, alt, taskId) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-10-14 10:37:04 -07:00
|
|
|
//# sourceMappingURL=markdown.js.map
|