Render Markdown, add ask_human context, simplify inbox responses, group inbox by task

This commit is contained in:
Ian Gulliver
2026-10-14 10:37:04 -07:00
parent e9fd7de556
commit ca2644374d
25 changed files with 950 additions and 283 deletions
+260
View File
@@ -0,0 +1,260 @@
import { h } from "./dom.js";
type Block = HTMLElement;
const fence = /^\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 tableSep = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
export function markdown(src: string, className = "md"): HTMLElement {
return h("div", { class: className }, ...blocks(src.replace(/\r\n?/g, "\n").split("\n")));
}
export function inlineMarkdown(src: string): HTMLElement {
return h("span", { class: "md-inline" }, ...inline(src));
}
function blocks(lines: string[]): Block[] {
const out: Block[] = [];
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: string[] = [];
i++;
while (i < lines.length && !lines[i]!.trimStart().startsWith(marker)) {
body.push(lines[i]!);
i++;
}
i++;
out.push(h("pre", { class: "md-code" }, h("code", {}, body.join("\n"))));
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]!)));
i++;
continue;
}
if (rule.test(line)) {
out.push(h("hr"));
i++;
continue;
}
if (quote.test(line)) {
const body: string[] = [];
while (i < lines.length && quote.test(lines[i]!)) {
body.push(quote.exec(lines[i]!)![1]!);
i++;
}
out.push(h("blockquote", {}, ...blocks(body)));
continue;
}
if (bullet.test(line) || ordered.test(line)) {
const [list, next] = parseList(lines, i);
out.push(list);
i = next;
continue;
}
if (line.includes("|") && i + 1 < lines.length && tableSep.test(lines[i + 1]!)) {
const rows: string[][] = [cells(line)];
i += 2;
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") {
rows.push(cells(lines[i]!));
i++;
}
out.push(table(rows));
continue;
}
const para: string[] = [];
while (i < lines.length && lines[i]!.trim() !== "" && !startsBlock(lines, i)) {
para.push(lines[i]!.trim());
i++;
}
out.push(h("p", {}, ...joinLines(para)));
}
return out;
}
function startsBlock(lines: string[], i: number): boolean {
const line = lines[i]!;
return (
fence.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: string[]): Node[] {
const out: Node[] = [];
lines.forEach((l, n) => {
if (n > 0) {
out.push(h("br"));
}
out.push(...inline(l));
});
return out;
}
function indentOf(line: string): number {
return line.length - line.trimStart().length;
}
function parseList(lines: string[], start: number): [HTMLElement, number] {
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 as HTMLOListElement).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: string[] = [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++;
}
list.append(item(body));
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];
}
function item(body: string[]): HTMLElement {
const li = h("li");
const rest = body.slice(1);
if (rest.every((l) => !startsBlockLine(l)) && !rest.includes("")) {
li.append(...joinLines(body.map((l) => l.trim())));
return li;
}
const head: string[] = [body[0]!];
let k = 1;
while (k < body.length && body[k] !== "" && !startsBlockLine(body[k]!)) {
head.push(body[k]!.trim());
k++;
}
li.append(...joinLines(head), ...blocks(body.slice(k)));
return li;
}
function startsBlockLine(line: string): boolean {
return fence.test(line) || heading.test(line) || quote.test(line) || bullet.test(line) || ordered.test(line);
}
function cells(line: string): string[] {
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: string[][]): HTMLElement {
const [head, ...body] = rows;
return h(
"div",
{ class: "md-table" },
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)))))),
),
);
}
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src: string): Node[] {
const out: Node[] = [];
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, 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!)));
} else if (em1 !== undefined || em2 !== undefined) {
out.push(h("em", {}, ...inline(em1 ?? em2!)));
} else if (linkText !== undefined) {
out.push(link(linkHref!, inline(linkText)));
} else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)]));
}
rest = rest.slice(m.index + whole.length);
}
return out;
}
function link(href: string, children: Node[]): Node {
if (/^https?:\/\//i.test(href)) {
return h("a", { href, target: "_blank", rel: "noopener noreferrer" }, ...children);
}
if (href.startsWith("#/")) {
return h("a", { href }, ...children);
}
return h("span", {}, ...children);
}