import { useT } from "@agent-native/core/client/i18n"; import { useMemo } from "react"; import { messagesByLocale } from "@/i18n-data"; /** * Lightweight Markdown renderer — handles headings, bold, italic, code blocks, * inline code, links, unordered/ordered lists, horizontal rules, and tables. * No external deps required. */ export default function Markdown({ content }: { content: string }) { const t = useT(); const labels = useMemo( () => ({ embedBlocked: t("markdown.embedBlocked"), sameOriginEmbedsOnly: t("markdown.sameOriginEmbedsOnly"), embeddedContent: t("markdown.embeddedContent"), }), [t], ); const html = useMemo( () => renderMarkdown(content, labels), [content, labels], ); return
; } type MarkdownLabels = (typeof messagesByLocale)["en-US"]["markdown"]; const DEFAULT_MARKDOWN_LABELS = messagesByLocale["en-US"].markdown; function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function decodeHtmlEntities(value: string): string { let decoded = value; for (let i = 0; i < 3; i++) { const next = decoded .replace(/([0-9a-f]+);?/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)), ) .replace(/(\d+);?/g, (_, dec: string) => String.fromCodePoint(Number.parseInt(dec, 10)), ) .replace(/:?/gi, ":") .replace(/&tab;?/gi, "\t") .replace(/&newline;?/gi, "\n") .replace(/&?/gi, "&"); if (next === decoded) break; decoded = next; } return decoded; } /** * Sanitize a URL for use in an href. Rejects dangerous protocols * (javascript:, data:, vbscript:, file:) and empty strings. Returns a * safe-to-embed value — always HTML-escape the result when inserting * into an attribute. */ function sanitizeUrl(url: string, kind: "link" | "image" = "link"): string { const trimmed = url.trim(); if (!trimmed) return "#"; // Strip HTML entities and whitespace before protocol check so // `javascript:…` style attempts don't sneak through. const decoded = decodeHtmlEntities(trimmed); const stripped = decoded .replace(/[\s\u0000-\u001f\u007f]+/g, "") .toLowerCase(); if ( stripped.startsWith("javascript:") || stripped.startsWith("data:") || stripped.startsWith("vbscript:") || stripped.startsWith("file:") || stripped.startsWith("//") ) { return "#"; } if (kind === "image" && /^[a-z][a-z\d+.-]*:/i.test(stripped)) { if (!stripped.startsWith("http:") && !stripped.startsWith("https:")) { return "#"; } } // Defense-in-depth: when the URL carries a scheme, require http/https via // a real URL parse (catches encodings the deny-list above might miss). // Relative URLs (`/path`, `#anchor`, `?q=1`) without a scheme pass through. // (audit 03 defense-in-depth) if (/^[a-z][a-z\d+.-]*:/i.test(stripped)) { try { const parsed = new URL(trimmed); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return "#"; } } catch { return "#"; } } return trimmed; } const ALLOWED_EMBED_ASPECTS = new Set([ "16/9", "4/3", "1/1", "21/9", "3/2", "2/1", ]); function parseEmbedBody(body: string): { src?: string; aspect?: string; title?: string; height?: number; } { const out: { src?: string; aspect?: string; title?: string; height?: number; } = {}; for (const raw of body.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const idx = line.indexOf(":"); if (idx <= 0) continue; const key = line.slice(0, idx).trim().toLowerCase(); const value = line.slice(idx + 1).trim(); if (!value) continue; if (key === "src") out.src = value; else if (key === "aspect") out.aspect = value; else if (key === "title") out.title = value; else if (key === "height") { const n = Number(value); if (Number.isFinite(n) && n > 0) out.height = Math.min(2000, n); } } return out; } function sanitizeEmbedSrc(src: string | undefined): string | null { if (!src) return null; const trimmed = src.trim(); if (!trimmed || trimmed.startsWith("//")) return null; if ( trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../") ) { return trimmed; } return null; } function renderEmbedBlock( body: string, labels: MarkdownLabels = DEFAULT_MARKDOWN_LABELS, ): string { const parsed = parseEmbedBody(body); const safeSrc = sanitizeEmbedSrc(parsed.src); if (!safeSrc) { const blockedSourceHtml = parsed.src ? `$1")
// Images — must run before the link pattern since  contains [alt](url)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, rawUrl) => {
const safe = sanitizeUrl(rawUrl, "image");
if (safe === "#") return "";
return `${codeLines.map(escapeHtml).join("\n")}`,
);
continue;
}
// Blank line
if (line.trim() === "") {
closeList();
i++;
continue;
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
closeList();
out.push("| ${renderInline(c)} | `).join("") + "
|---|
| ${renderInline(c)} | `).join("") + "
${renderInline(line)}
`); i++; } closeList(); return out.join("\n"); }