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(``),
);
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)]!);
}