type ListType = "ol" | "ul";
type ParserState = {
output: string[];
paragraphLines: string[];
blockquoteLines: string[];
listItems: string[];
listType: ListType | null;
inCodeBlock: boolean;
codeLanguage: string;
codeLines: string[];
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function normalizeLinkTarget(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("/")) {
return escapeHtml(trimmed);
}
try {
const url = new URL(trimmed);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
} catch {
return null;
}
return null;
}
function renderInline(markdown: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
rendered = rendered.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `__CODE_TOKEN_${codeTokens.length}__`;
codeTokens.push(`${code}`);
return token;
});
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href);
if (!safeHref) {
return label;
}
return `${label}`;
},
);
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1");
rendered = rendered.replace(/\*([^*]+)\*/g, "$1");
return rendered.replace(/__CODE_TOKEN_(\d+)__/g, (_match, index: string) => {
return codeTokens[Number(index)] ?? "";
});
}
function createParserState(): ParserState {
return {
output: [],
paragraphLines: [],
blockquoteLines: [],
listItems: [],
listType: null,
inCodeBlock: false,
codeLanguage: "",
codeLines: [],
};
}
function flushParagraph(state: ParserState) {
if (state.paragraphLines.length === 0) {
return;
}
state.output.push(`
${renderInline(state.paragraphLines.join(" "))}
`); state.paragraphLines.length = 0; } function flushList(state: ParserState) { if (state.listItems.length === 0 || !state.listType) { return; } state.output.push( `<${state.listType}>${state.listItems.map((item) => ``, ); state.blockquoteLines.length = 0; } function flushCodeBlock(state: ParserState) { if (!state.inCodeBlock) { return; } const languageClass = state.codeLanguage ? ` class="language-${escapeHtml(state.codeLanguage)}"` : ""; state.output.push( `${renderInline(state.blockquoteLines.join(" "))}
${escapeHtml(state.codeLines.join("\n"))}`,
);
state.inCodeBlock = false;
state.codeLanguage = "";
state.codeLines.length = 0;
}
function flushInlineBlocks(state: ParserState) {
flushParagraph(state);
flushList(state);
flushBlockquote(state);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
if (!state.inCodeBlock) {
return false;
}
if (line.trim().startsWith("```")) {
flushCodeBlock(state);
} else {
state.codeLines.push(line);
}
return true;
}
function handleFenceStart(state: ParserState, line: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
flushInlineBlocks(state);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
function handleBlankLine(state: ParserState, line: string): boolean {
if (line.trim()) {
return false;
}
flushInlineBlocks(state);
return true;
}
function handleHeadingLine(state: ParserState, line: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
flushInlineBlocks(state);
const level = headingMatch[1].length;
state.output.push(`