Files
byre/web/dist/markdown.js
T
2026-10-17 09:47:48 -07:00

397 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { copyable, h } from "./dom.js";
import { diagram } from "./mermaid.js";
import { taskStyle } from "./taskcolor.js";
import { todoLink } from "./todolink.js";
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*(```|~~~)\s*(\S*)/;
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 imageOnly = /^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$/;
const tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
export function markdown(src, className = "md", opts = {}) {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n"), opts));
}
export function inlineMarkdown(src, opts = {}) {
return h("span", { class: "md-inline" }, ...inline(src, opts));
}
function blocks(lines, opts) {
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++;
const lang = f[2].toLowerCase();
if (lang === "mermaid") {
out.push(diagram(body.join("\n")));
continue;
}
if (lang === "diff" || lang === "patch") {
out.push(copyable(h("pre", { class: "md-code diff" }, h("code", {}, ...body.map(diffLine)))));
continue;
}
out.push(copyable(h("pre", { class: "md-code" }, h("code", {}, body.join("\n")))));
continue;
}
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);
}
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);
out.push(h(`h${level}`, {}, ...inline(hd[2], opts)));
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++;
}
out.push(h("blockquote", {}, ...blocks(body, { taskId: opts.taskId })));
continue;
}
if (bullet.test(line) || ordered.test(line)) {
const [list, next] = parseList(lines, i, opts);
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++;
}
out.push(table(rows, opts));
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() !== "" && !startsBlock(lines, i)) {
para.push(lines[i].trim());
i++;
}
out.push(h("p", {}, ...joinLines(para, opts)));
}
return out;
}
function diffLine(l) {
const cls = l.startsWith("@@") ? "hunk" : l.startsWith("+++") || l.startsWith("---") ? "meta" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : "ctx";
return h("span", { class: `line ${cls}` }, l + "\n");
}
function startsBlock(lines, i) {
const line = lines[i];
return (fence.test(line) ||
detailsOpen.test(line) ||
detailsClose.test(line) ||
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])));
}
function joinLines(lines, opts) {
const out = [];
lines.forEach((l, n) => {
if (n > 0) {
out.push(h("br"));
}
out.push(...inline(l, opts));
});
return out;
}
function indentOf(line) {
return line.length - line.trimStart().length;
}
function parseList(lines, start, opts) {
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;
let imageChoices = !isOrdered && !!opts.choice;
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++;
}
const detail = body.slice(1).filter((l) => l.trim() !== "");
imageChoices &&= choiceMarker.test(text) && detail.length === 1 && imageOnly.test(detail[0]);
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++;
}
}
if (imageChoices) {
list.className = "choice-grid";
}
return [list, i];
}
function item(body, opts) {
const li = h("li");
const m = opts.choice && choiceMarker.exec(body[0]);
const picker = m ? opts.choice(m[2]) : null;
const detail = h("div", { class: "choice-detail" });
if (picker) {
li.className = "choice";
li.append(picker);
if (body.slice(1).some((l) => l.trim() !== "")) {
picker.append(detail);
}
}
const target = picker ? detail : li;
const first = picker ? [] : [body[0]];
const rest = body.slice(1);
if (rest.every((_, n) => !startsBlock(body, n + 1)) && !rest.includes("")) {
target.append(...joinLines([...first, ...rest].map((l) => l.trim()), opts));
return li;
}
const head = first;
let k = 1;
while (k < body.length && body[k] !== "" && !startsBlock(body, k)) {
head.push(body[k].trim());
k++;
}
target.append(...joinLines(head, opts), ...blocks(body.slice(k), opts));
return li;
}
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());
}
function table(rows, opts) {
const [head, ...body] = rows;
return h("div", { class: "md-table" }, h("table", {}, h("thead", {}, h("tr", {}, ...head.map((c) => cell("th", c, opts)))), h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => cell("td", c, opts)))))));
}
function cell(tag, src, opts) {
const whole = markOf(src);
if (whole) {
return h(tag, { class: `md-mark-cell ${whole}` }, ...inline(src, opts));
}
const [, lead = "", rest = ""] = /^\s*(\S*)(.*)$/s.exec(src);
const mark = markOf(lead);
if (!mark) {
return h(tag, {}, ...spans(src, opts));
}
return h(tag, { class: `md-mark-cell ${mark}` }, h("span", { class: `md-mark ${mark}` }, ...spans(lead, opts)), ...spans(rest, opts));
}
const marks = {
"✓": "ok",
"✔": "ok",
"✔️": "ok",
"✅": "ok",
"☑": "ok",
"☑️": "ok",
"~": "warn",
"≈": "warn",
"〜": "warn",
"⚠": "warn",
"⚠️": "warn",
"🟡": "warn",
x: "danger",
X: "danger",
"×": "danger",
"✗": "danger",
"✘": "danger",
"✕": "danger",
"✖": "danger",
"✖️": "danger",
"❌": "danger",
"-": "none",
"–": "none",
"—": "none",
"−": "none",
};
function markOf(src) {
return marks[src.trim().replace(/^(\*\*|__)(.*)\1$/, "$2").trim()];
}
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src, opts) {
const mark = markOf(src);
if (mark) {
return [h("span", { class: `md-mark ${mark}` }, ...spans(src, opts))];
}
return spans(src, opts);
}
function spans(src, opts) {
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)));
}
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", {}, ...spans(strong1 ?? strong2, opts)));
}
else if (em1 !== undefined || em2 !== undefined) {
out.push(h("em", {}, ...spans(em1 ?? em2, opts)));
}
else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId));
}
else if (linkText !== undefined) {
out.push(link(linkHref, spans(linkText, opts)));
}
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);
}
const task = /^#\/task\/(\w+)(\/\w+)?$/.exec(href);
if (task) {
return h("a", { href, class: "task-link", style: taskStyle(task[1]) }, ...children);
}
const todo = /^#\/todo\/(\w+)\/(.+)$/.exec(href);
if (todo) {
try {
return todoLink(todo[1], decodeURIComponent(todo[2]), children);
}
catch {
return h("span", {}, ...children);
}
}
const suggest = /^#\/suggest\/(\w+)\/([^/]+)\/(.+)$/.exec(href);
if (suggest) {
try {
return todoLink(suggest[1], decodeURIComponent(suggest[2]), children, decodeURIComponent(suggest[3]));
}
catch {
return h("span", {}, ...children);
}
}
if (href.startsWith("#/")) {
return h("a", { href }, ...children);
}
return h("span", {}, ...children);
}
function worktreeImageURL(path, taskId) {
if (/^data:image\/(png|jpeg|gif|webp);/i.test(path)) {
return path;
}
let file = path;
if (/^file:\/\//i.test(file)) {
try {
file = decodeURI(file.slice("file://".length));
}
catch {
return null;
}
}
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;
}
//# sourceMappingURL=markdown.js.map