const BLOCK_PLACEHOLDER_PREFIX = '\u0000BLOCK'
const INLINE_PLACEHOLDER_PREFIX = '\u0000INLINE'
export function markdownToHtml(markdown: string): string {
if (markdown.length === 0) {
return ''
}
const normalized = markdown.replace(/\r\n?/g, '\n')
if (normalized.trim().length === 0) {
return normalized
}
const { text, blocks } = extractCodeBlocks(normalized)
const lines = text.split('\n')
const output: string[] = []
let paragraph: string[] = []
const flushParagraph = () => {
if (paragraph.length === 0) {
return
}
output.push(processInline(paragraph.join('\n')).replace(/\n/g, '
'))
paragraph = []
}
for (let index = 0; index < lines.length; ) {
const line = lines[index]
const trimmed = line.trim()
if (trimmed.length === 0) {
flushParagraph()
index++
continue
}
if (isBlockPlaceholder(trimmed)) {
flushParagraph()
output.push(trimmed)
index++
continue
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/)
if (headingMatch) {
flushParagraph()
output.push(`
${processInline(quoteLines.join('\n')).replace(/\n/g, '`) continue } if (/^-\s+/.test(line)) { flushParagraph() const items: string[] = [] while (index < lines.length && /^-\s+/.test(lines[index])) { items.push(`
')}
${escapeHtml(code)}`)
})
text = text.replace(/\[([^\]]+)\]\(([^)\n]+)\)/g, (_, label: string, url: string) => {
if (!isSafeUrl(url)) {
return processInline(label)
}
return createInlinePlaceholder(placeholders, `${processInline(label)}`)
})
text = escapeHtml(text)
text = text.replace(/\*\*\*([^*\n]+)\*\*\*/g, '$1')
text = text.replace(/\*\*([^*\n]+)\*\*/g, '$1')
text = text.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, '$1$2')
return restorePlaceholders(text, placeholders)
}
function extractCodeBlocks(markdown: string): { text: string; blocks: string[] } {
const blocks: string[] = []
const text = markdown.replace(
/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)\n```/g,
(_, language: string | undefined, code: string) => {
const className = language ? ` class="language-${escapeHtml(language)}"` : ''
return createBlockPlaceholder(blocks, `${escapeHtml(code)}`)
},
)
return { text, blocks }
}
function createBlockPlaceholder(blocks: string[], html: string): string {
const token = `${BLOCK_PLACEHOLDER_PREFIX}${blocks.length}\u0000`
blocks.push(html)
return token
}
function createInlinePlaceholder(placeholders: string[], html: string): string {
const token = `${INLINE_PLACEHOLDER_PREFIX}${placeholders.length}\u0000`
placeholders.push(html)
return token
}
function isBlockPlaceholder(value: string): boolean {
return new RegExp(`^${BLOCK_PLACEHOLDER_PREFIX}\\d+\\u0000$`).test(value)
}
function restorePlaceholders(text: string, values: string[]): string {
let restored = text
for (const [index, value] of values.entries()) {
restored = restored.replaceAll(`${BLOCK_PLACEHOLDER_PREFIX}${index}\u0000`, value)
restored = restored.replaceAll(`${INLINE_PLACEHOLDER_PREFIX}${index}\u0000`, value)
}
return restored
}
const SAFE_URL_PATTERN = /^(https?:|mailto:|\/|#)/i
function isSafeUrl(url: string): boolean {
return SAFE_URL_PATTERN.test(url.trim())
}
export function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
}