import { marked, Renderer } from 'marked'; function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function safeUrl(value: string): string | null { const trimmed = value.trim(); if (trimmed.startsWith('#') || trimmed.startsWith('/')) return trimmed; try { const url = new URL(trimmed); return ['http:', 'https:'].includes(url.protocol) ? url.href : null; } catch { return null; } } const renderer = new Renderer(); renderer.html = ({ text }) => escapeHtml(text); renderer.link = function ({ href, title, tokens }) { const label = this.parser.parseInline(tokens); const url = safeUrl(href); if (!url) return label; const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ''; return `${label}`; }; renderer.image = ({ href, title, text }) => { const url = safeUrl(href); if (!url) return escapeHtml(text); const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ''; return `${escapeHtml(text)}`; }; export function renderReleaseMarkdown(markdown: string): string { const options = { async: false, breaks: true, gfm: true, renderer } as const; const tokens = marked.lexer(markdown, options); let html = ''; let entryOpen = false; for (const token of tokens) { const boundary = token.type === 'hr' || (token.type === 'heading' && token.depth <= 3); if (boundary && entryOpen) { html += '\n'; entryOpen = false; } if (token.type === 'heading' && token.depth === 3) { html += '
\n'; entryOpen = true; } html += marked.parser([token], options); } return html + (entryOpen ? '
\n' : ''); }