/** * Self-contained markdown renderer. Port of app.js:12709-12876. * * Invariants (do not "modernize"): * 1. STREAMING SAFETY — an unclosed fence still renders as code, never as * headings / a leaked ```lang line. * 2. Inline code spans are tokenized out first so emphasis/link processing * never touches their contents. * 3. Whole-string esc() runs before any markup replacement. * 4. Attribute captures go through escQuote. * 5. Link href scheme whitelist is exactly `(https?:|mailto:|/|#)`. * 6. No marked, no DOMPurify. * * mdCodeBlock copy chrome uses t() key-name fallback until F-07/F-10 wire i18n. */ import { esc, escQuote } from "./esc"; import { mdHighlight } from "./highlight"; const COPY_ICON = ''; /** F-07 owns t(); key-name fallback matches t() when the dictionary is absent. */ function t(key: string): string { return key; } export function mdCodeBlock(code: string, lang: string): string { const label = (lang || "").trim(); return ( '
' + '
' + esc(label || "text") + "" + '
" + "
" +
    mdHighlight(code, label) +
    "
" ); } /** * 0.2.0 stored every default completion link as `/api/artifacts/`, a path * the contract-v1 gateway answers with 404 (it deliberately has no * un-versioned alias), so a reopened session's "Artifacts:" list was dead. * Exactly that one-segment form is rewritten onto the versioned reader; any * other href -- deeper paths, a query or fragment, dot segments -- is left as * written. */ const LEGACY_ARTIFACT_HREF = /^\/api\/artifacts\/([^/?#]+)$/; const ARTIFACT_READER = "/api/v1/artifacts/"; function mdHref(href: string): string { const m = LEGACY_ARTIFACT_HREF.exec(href); const segment = m ? m[1] || "" : ""; if (!segment || segment === "." || segment === "..") return href; return ARTIFACT_READER + segment; } const MDC0 = String.fromCharCode(0xe000); const MDC1 = String.fromCharCode(0xe001); const mdCodeRestore = new RegExp(MDC0 + "(\\d+)" + MDC1, "g"); export function mdInline(t: string | null | undefined): string { t = String(t == null ? "" : t); const codes: string[] = []; t = t.replace(/`([^`]+)`/g, (_m, c: string) => { codes.push(c); return MDC0 + (codes.length - 1) + MDC1; }); t = esc(t); // esc() now also escapes quotes (F-08). Capture groups interpolated into a // double-quoted HTML attribute still go through escQuote so an alt/href/src // value cannot close the attribute even if a future edit reorders the chain. t = t.replace( /!\[([^\]]*)\]\((data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+)\)/g, (_m, alt: string, src: string) => '' + escQuote(alt) + '', ); t = t.replace( /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, (_m, alt: string, src: string) => '' + escQuote(alt) + '', ); t = t.replace( /\[([^\]]+)\]\(((?:https?:|mailto:|\/|#)[^\s)]+)\)/g, (_m, text: string, href: string) => '' + text + "", ); t = t.replace(/\*\*\*([^*]+?)\*\*\*/g, "$1"); t = t.replace(/\*\*([^*]+?)\*\*/g, "$1"); t = t.replace(/(^|[^\w*])__([^_]+?)__(?!\w)/g, "$1$2"); t = t.replace(/(^|[^*])\*([^*\n]+?)\*/g, "$1$2"); t = t.replace(/(^|[^\w_])_([^_\n]+?)_(?!\w)/g, "$1$2"); t = t.replace(/~~([^~]+?)~~/g, "$1"); t = t.replace(mdCodeRestore, (_m, k: string) => { const idx = +k; return "" + esc(codes[idx]) + ""; }); return t; } type MdListItem = { indent: number; ordered: boolean; text: string }; type MdListCursor = { v: number }; function mdBuildList(items: MdListItem[], cur: MdListCursor): string { const start = items[cur.v]; if (!start) return ""; const ordered = start.ordered; const indent = start.indent; let html = "<" + (ordered ? "ol" : "ul") + ">"; while (cur.v < items.length) { const item = items[cur.v]; if (!item || item.indent < indent) break; if (item.indent > indent) break; const text = item.text; cur.v++; let nested = ""; const next = items[cur.v]; if (next && next.indent > indent) nested = mdBuildList(items, cur); html += "
  • " + mdInline(text) + nested + "
  • "; } return html + ""; } function mdList( lines: string[], start: number, n: number, ): { html: string; next: number } { const itemRe = /^(\s*)([-*+]|\d+[.)])[ \t]+(.*)$/; const items: MdListItem[] = []; let i = start; while (i < n) { const line = lines[i] ?? ""; const m = line.match(itemRe); if (m) { items.push({ indent: (m[1] || "").replace(/\t/g, " ").length, ordered: /\d/.test(m[2] || ""), text: m[3] || "", }); i++; } else if (!line.trim()) { let k = i + 1; while (k < n && !(lines[k] || "").trim()) k++; if (k < n && itemRe.test(lines[k] || "")) { i = k; continue; } break; } else if (/^\s+\S/.test(line) && items.length) { const last = items[items.length - 1]; if (last) last.text += " " + line.trim(); i++; } else break; } return { html: mdBuildList(items, { v: 0 }), next: i }; } export function renderMd(src: string | null | undefined): string { const lines = String(src == null ? "" : src) .replace(/\r\n?/g, "\n") .split("\n"); const n = lines.length; let i = 0; let html = ""; const fenceRe = /^(\s*)(`{3,}|~{3,})[ \t]*([\w+#.\-]*)[ \t]*$/; const listRe = /^(\s*)([-*+]|\d+[.)])[ \t]+/; const hrRe = /^\s*([-*_])[ \t]*(?:\1[ \t]*){2,}$/; // Table delimiter row, matched cell-by-cell so there is no nested-quantifier // regex to catastrophically backtrack (ReDoS-safe). A cell is `:?-+:?` padded. const cellDelimRe = /^[ \t]*:?-+:?[ \t]*$/; const isDelimRow = (s: string): boolean => { let tr = s.trim(); if (tr.indexOf("-") === -1) return false; if (tr.charAt(0) === "|") tr = tr.slice(1); if (tr.charAt(tr.length - 1) === "|") tr = tr.slice(0, -1); const parts = tr.split("|"); for (let j = 0; j < parts.length; j++) { if (!cellDelimRe.test(parts[j] || "")) return false; } return true; }; const looksTable = (idx: number): boolean => { const row = lines[idx]; const next = lines[idx + 1]; return ( row != null && row.indexOf("|") !== -1 && idx + 1 < n && next != null && isDelimRow(next) ); }; while (i < n) { const line = lines[i] ?? ""; const fm = line.match(fenceRe); if (fm) { const marker = fm[2] || ""; const fchar = marker.charAt(0); const flen = marker.length; const lang = fm[3] || ""; const code: string[] = []; i++; // Closing fence detected without a dynamically-built RegExp (regex-injection // safe): a line that trims to >= flen of the same fence char and nothing else. const isClose = (s: string): boolean => { const tr = s.trim(); if (tr.length < flen) return false; for (let j = 0; j < tr.length; j++) if (tr.charAt(j) !== fchar) return false; return true; }; while (i < n && !isClose(lines[i] || "")) { code.push(lines[i] || ""); i++; } if (i < n) i++; html += mdCodeBlock(code.join("\n"), lang); continue; } if (!line.trim()) { i++; continue; } const hm = line.match(/^(#{1,6})[ \t]+(.*?)[ \t]*#*$/); if (hm) { const lv = (hm[1] || "").length; html += "" + mdInline(hm[2]) + ""; i++; continue; } if (hrRe.test(line)) { html += "
    "; i++; continue; } if (/^\s*>\s?/.test(line)) { const q: string[] = []; while (i < n && /^\s*>\s?/.test(lines[i] || "")) { q.push((lines[i] || "").replace(/^\s*>\s?/, "")); i++; } html += "
    " + renderMd(q.join("\n")) + "
    "; continue; } if (looksTable(i)) { const cells = (r: string): string[] => r .trim() .replace(/^\||\|$/g, "") .split("|") .map((x) => x.trim()); const head = cells(lines[i] || ""); i += 2; let t = "" + head.map((c) => "").join("") + ""; while (i < n && (lines[i] || "").indexOf("|") !== -1 && (lines[i] || "").trim()) { const r = cells(lines[i] || ""); t += "" + head.map((_c, ci) => "").join("") + ""; i++; } html += '
    ' + t + "
    " + mdInline(c) + "
    " + mdInline(r[ci] || "") + "
    "; continue; } if (listRe.test(line)) { const lr = mdList(lines, i, n); html += lr.html; i = lr.next; continue; } const para = [line]; i++; while ( i < n && (lines[i] || "").trim() && !listRe.test(lines[i] || "") && !fenceRe.test(lines[i] || "") && !hrRe.test(lines[i] || "") && !/^(#{1,6}[ \t]|\s*>)/.test(lines[i] || "") && !looksTable(i) ) { para.push(lines[i] || ""); i++; } html += "

    " + mdInline(para.join(" ")) + "

    "; } return html; }