import { renderEmphasis } from "./emphasis.js"; import { renderLinks } from "./link.js"; /** Escape HTML special characters. */ export function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * URL schemes that execute script when navigated to or loaded. Markdown is * untrusted input and the format offers no other way to smuggle behaviour in, * so these are neutralised rather than passed through. */ const UNSAFE_SCHEME = /^(?:javascript|vbscript|data):/i; /** Inert replacement for a rejected URL. */ const BLOCKED_URL = "about:blank"; /** Sentinel wrapping parked tag indices. Carries no meaning in markdown. */ const MARK = ""; /** * Reject script-bearing URLs. Browsers ignore ASCII whitespace and control * characters inside a scheme (`java\tscript:` navigates), so those are stripped * before the scheme is tested. Relative URLs and fragments have no scheme and * always pass. */ export function safeUrl(url: string): string { const probe = url.replace(/[\x00-\x20]+/g, ""); return UNSAFE_SCHEME.test(probe) ? BLOCKED_URL : url; } /** Render inline markdown formatting to HTML. Pure string transform, SSR-safe. */ function applyHardBreaks(text: string): string { const lines = text.split("\n"); let out = ""; for (let i = 0; i < lines.length - 1; i++) { const line = lines[i]; let end = line.length; while (end > 0 && line[end - 1] === " ") end--; if (line.length - end >= 2) { out += `${line.slice(0, end)}
`; } else if (line.endsWith("\\")) { out += `${line.slice(0, -1)}
`; } else { out += `${line}\n`; } } return out + lines[lines.length - 1]; } export function renderInline(text: string): string { let s = escapeHtml(text).split(MARK).join(""); // Generated tags are parked here so later passes cannot rewrite their // attributes. Emphasis and code run as plain string replaces, and without // this a URL containing `_`, `*` or a backtick had ``/`` spliced // into its href. Link *text* stays outside the placeholder so it still // picks up formatting. const parked: string[] = []; const park = (html: string): string => { parked.push(html); return `${MARK}${parked.length - 1}${MARK}`; }; // Footnote references: [^id] s = s.replace(/\[\^([\w-]+)\]/g, (_, id: string) => park(`${id}`), ); s = renderLinks(s, { mark: MARK, park, safeUrl }); // Inline code — parked so its content stays literal s = s.replace(/`([^`]+)`/g, (_, code: string) => park(`${code}`)); s = s.replace(/\\(&(?:amp|lt|gt|quot|#39);|[!#$%()*+,\-./:;=?@[\\\]^_`{|}~])/g, (_, ch: string) => park(ch), ); // Strikethrough: ~~text~~ s = s.replace(/~~([^~]+)~~/g, (_, t: string) => `${t}`); s = renderEmphasis(s, MARK); s = applyHardBreaks(s); // Soft line breaks s = s.replace(/\n/g, " "); // Restore generated tags return s.replace(new RegExp(`${MARK}(\\d+)${MARK}`, "g"), (_, i: string) => parked[Number(i)]!); }