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(/&#x([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 ? `
${escapeHtml(parsed.src)}
` // i18n-ignore stable sanitized URL echo in generated HTML : ""; return [ '
', `${escapeHtml(labels.embedBlocked)}`, `
${escapeHtml(labels.sameOriginEmbedsOnly)}
`, // i18n-ignore generated HTML expression boundary blockedSourceHtml, "
", ].join(""); } const aspect = parsed.aspect && ALLOWED_EMBED_ASPECTS.has(parsed.aspect) ? parsed.aspect : "16/9"; const style = parsed.height ? `height:${parsed.height}px` : `aspect-ratio:${aspect.replace("/", " / ")}`; return [ `
`, ``, "
", ].join(""); } function renderInline(text: string): string { // Escape the raw text before applying markdown replacements so any // HTML the agent emits is inert. Note: markdown tokens like `**` and // `[text](url)` are detected AFTER escaping — that's safe because our // tokens don't overlap with escaped entities. return ( escapeHtml(text) // Bold .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/__(.+?)__/g, "$1") // Italic .replace(/\*(.+?)\*/g, "$1") .replace(/_(.+?)_/g, "$1") // Inline code .replace(/`([^`]+)`/g, "$1") // Images — must run before the link pattern since ![alt](url) contains [alt](url) .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, rawUrl) => { const safe = sanitizeUrl(rawUrl, "image"); if (safe === "#") return ""; return `${escapeHtml(alt)}`; }) // Links — sanitize URL to block javascript:/data:/vbscript:/file: .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, rawUrl) => { const safe = sanitizeUrl(rawUrl); return `${escapeHtml(label)}`; }) ); } export function renderMarkdown( md: string, labels: MarkdownLabels = DEFAULT_MARKDOWN_LABELS, ): string { const lines = md.split("\n"); const out: string[] = []; let i = 0; let inList: "ul" | "ol" | null = null; function closeList() { if (inList) { out.push(inList === "ul" ? "" : ""); inList = null; } } while (i < lines.length) { const line = lines[i]; // Code block if (line.startsWith("```")) { closeList(); // Language hint is user-controlled (via the markdown fence); normalize // to a safe identifier and then HTML-escape as a belt-and-suspenders. const rawLang = line.slice(3).trim(); const safeLang = rawLang.replace(/[^a-zA-Z0-9_+#.-]/g, "").slice(0, 32); const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].startsWith("```")) { codeLines.push(lines[i]); i++; } i++; // skip closing ``` if (safeLang === "embed") { out.push(renderEmbedBlock(codeLines.join("\n"), labels)); continue; } out.push( `
${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("
"); i++; continue; } // Headings const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { closeList(); const level = headingMatch[1].length; out.push(`${renderInline(headingMatch[2])}`); i++; continue; } // Table if ( line.includes("|") && i + 1 < lines.length && /^\|?\s*[-:]+/.test(lines[i + 1]) ) { closeList(); const headerCells = line .split("|") .map((c) => c.trim()) .filter(Boolean); i += 2; // skip header + separator const rows: string[][] = []; while ( i < lines.length && lines[i].includes("|") && lines[i].trim() !== "" ) { rows.push( lines[i] .split("|") .map((c) => c.trim()) .filter(Boolean), ); i++; } out.push(""); out.push( "" + headerCells.map((c) => ``).join("") + "", ); out.push(""); for (const row of rows) { out.push( "" + row.map((c) => ``).join("") + "", ); } out.push("
${renderInline(c)}
${renderInline(c)}
"); continue; } // Unordered list const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)$/); if (ulMatch) { if (inList !== "ul") { closeList(); inList = "ul"; out.push("