2026-04-08 06:03:48 -04:00
|
|
|
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, """)
|
|
|
|
|
.replace(/'/g, "'");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
|
2026-04-08 06:03:48 -04:00
|
|
|
const trimmed = value.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (trimmed.startsWith("/")) {
|
|
|
|
|
return escapeHtml(trimmed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-13 18:19:50 -04:00
|
|
|
const url = new URL(trimmed, baseUrl || undefined);
|
2026-04-08 06:03:48 -04:00
|
|
|
if (url.protocol === "http:" || url.protocol === "https:") {
|
|
|
|
|
return escapeHtml(url.toString());
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function renderInline(markdown: string, baseUrl?: string): string {
|
2026-04-08 06:03:48 -04:00
|
|
|
const codeTokens: string[] = [];
|
|
|
|
|
let rendered = escapeHtml(markdown);
|
|
|
|
|
|
|
|
|
|
rendered = rendered.replace(/`([^`]+)`/g, (_match, code: string) => {
|
|
|
|
|
const token = `__CODE_TOKEN_${codeTokens.length}__`;
|
|
|
|
|
codeTokens.push(`<code>${code}</code>`);
|
|
|
|
|
return token;
|
|
|
|
|
});
|
2026-04-13 18:19:50 -04:00
|
|
|
rendered = rendered.replace(
|
|
|
|
|
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
|
|
|
|
(_match, label: string, href: string) => {
|
|
|
|
|
const safeHref = normalizeLinkTarget(href, baseUrl);
|
|
|
|
|
if (!safeHref) {
|
|
|
|
|
return label;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `<img src="${safeHref}" alt="${label}" loading="lazy" />`;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-08 06:03:48 -04:00
|
|
|
rendered = rendered.replace(
|
|
|
|
|
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
|
|
|
|
|
(_match, label: string, href: string) => {
|
2026-04-13 18:19:50 -04:00
|
|
|
const safeHref = normalizeLinkTarget(href, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
if (!safeHref) {
|
|
|
|
|
return label;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `<a href="${safeHref}" target="_blank" rel="noreferrer">${label}</a>`;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
|
|
|
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
|
|
|
|
|
|
|
|
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: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function flushParagraph(state: ParserState, baseUrl?: string) {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (state.paragraphLines.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "), baseUrl)}</p>`);
|
2026-04-08 06:03:48 -04:00
|
|
|
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) => `<li>${item}</li>`).join("")}</${state.listType}>`,
|
|
|
|
|
);
|
|
|
|
|
state.listItems.length = 0;
|
|
|
|
|
state.listType = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function flushBlockquote(state: ParserState, baseUrl?: string) {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (state.blockquoteLines.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.output.push(
|
2026-04-13 18:19:50 -04:00
|
|
|
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "), baseUrl)}</p></blockquote>`,
|
2026-04-08 06:03:48 -04:00
|
|
|
);
|
|
|
|
|
state.blockquoteLines.length = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flushCodeBlock(state: ParserState) {
|
|
|
|
|
if (!state.inCodeBlock) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const languageClass = state.codeLanguage
|
|
|
|
|
? ` class="language-${escapeHtml(state.codeLanguage)}"`
|
|
|
|
|
: "";
|
|
|
|
|
state.output.push(
|
|
|
|
|
`<pre><code${languageClass}>${escapeHtml(state.codeLines.join("\n"))}</code></pre>`,
|
|
|
|
|
);
|
|
|
|
|
state.inCodeBlock = false;
|
|
|
|
|
state.codeLanguage = "";
|
|
|
|
|
state.codeLines.length = 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function flushInlineBlocks(state: ParserState, baseUrl?: string) {
|
|
|
|
|
flushParagraph(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
flushList(state);
|
2026-04-13 18:19:50 -04:00
|
|
|
flushBlockquote(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (!line.trim().startsWith("```")) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushInlineBlocks(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
state.inCodeBlock = true;
|
|
|
|
|
state.codeLanguage = line.trim().slice(3).trim();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (line.trim()) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushInlineBlocks(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
|
|
|
if (!headingMatch) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushInlineBlocks(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
const level = headingMatch[1].length;
|
2026-04-13 18:19:50 -04:00
|
|
|
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim(), baseUrl)}</h${level}>`);
|
2026-04-08 06:03:48 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushInlineBlocks(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
state.output.push("<hr />");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleListLine(
|
|
|
|
|
state: ParserState,
|
|
|
|
|
line: string,
|
|
|
|
|
listType: ListType,
|
|
|
|
|
baseUrl?: string,
|
|
|
|
|
): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
|
|
|
|
|
const match = line.match(pattern);
|
|
|
|
|
if (!match) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushParagraph(state, baseUrl);
|
|
|
|
|
flushBlockquote(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
if (state.listType !== listType) {
|
|
|
|
|
flushList(state);
|
|
|
|
|
state.listType = listType;
|
|
|
|
|
}
|
2026-04-13 18:19:50 -04:00
|
|
|
state.listItems.push(renderInline(match[1].trim(), baseUrl));
|
2026-04-08 06:03:48 -04:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
|
2026-04-08 06:03:48 -04:00
|
|
|
const match = line.match(/^>\s?(.*)$/);
|
|
|
|
|
if (!match) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushParagraph(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
flushList(state);
|
|
|
|
|
state.blockquoteLines.push(match[1].trim());
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
|
2026-04-08 06:03:48 -04:00
|
|
|
flushList(state);
|
2026-04-13 18:19:50 -04:00
|
|
|
flushBlockquote(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
state.paragraphLines.push(line.trim());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
|
2026-04-08 06:03:48 -04:00
|
|
|
if (handleCodeBlockLine(state, line)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleFenceStart(state, line, baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleBlankLine(state, line, baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleHeadingLine(state, line, baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleRuleLine(state, line, baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
if (handleBlockquoteLine(state, line, baseUrl)) {
|
2026-04-08 06:03:48 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
handleParagraphLine(state, line, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
function markdownToHtml(markdown: string, baseUrl?: string): string {
|
2026-04-08 06:03:48 -04:00
|
|
|
const state = createParserState();
|
|
|
|
|
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
2026-04-13 18:19:50 -04:00
|
|
|
processMarkdownLine(state, line, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
flushInlineBlocks(state, baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
flushCodeBlock(state);
|
|
|
|
|
return state.output.join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stripLeadingTitleHeading(markdown: string, title: string): string {
|
|
|
|
|
const trimmed = markdown.trimStart();
|
|
|
|
|
if (!trimmed.startsWith("#")) {
|
|
|
|
|
return markdown;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lines = trimmed.split("\n");
|
|
|
|
|
const firstLine = lines[0]?.trim() ?? "";
|
|
|
|
|
if (firstLine === `# ${title}`) {
|
|
|
|
|
return lines.slice(1).join("\n").trimStart();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return markdown;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 18:19:50 -04:00
|
|
|
export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
|
|
|
|
|
const html = markdownToHtml(props.markdown, props.baseUrl);
|
2026-04-08 06:03:48 -04:00
|
|
|
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
|
|
|
|
|
|
|
|
|
|
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
|
|
|
|
}
|