/**
* 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) =>
'',
);
t = t.replace(
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
(_m, alt: string, src: string) =>
'',
);
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 + "" + (ordered ? "ol" : "ul") + ">";
}
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) => "
" + mdInline(c) + "
").join("") +
"
";
while (i < n && (lines[i] || "").indexOf("|") !== -1 && (lines[i] || "").trim()) {
const r = cells(lines[i] || "");
t +=
"
" +
head.map((_c, ci) => "
" + mdInline(r[ci] || "") + "
").join("") +
"
";
i++;
}
html += '
' + t + "
";
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 += "