// Shared Markdown renderer for PR-Helper // Supports: fenced code blocks, inline code, bold, italic, strikethrough, // headers, links, lists (bullet + numbered + nested), blockquotes, horizontal rules const Markdown = { render(text) { if (!text) return ''; // Normalize line endings text = text.replace(/\r\n/g, '\n'); // Phase 1: Extract fenced code blocks to protect them const codeBlocks = []; text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { const idx = codeBlocks.length; codeBlocks.push({ lang, code }); return `\x00CODEBLOCK_${idx}\x00`; }); // Phase 2: Extract inline code const inlineCodes = []; text = text.replace(/`([^`\n]+?)`/g, (_, code) => { const idx = inlineCodes.length; inlineCodes.push(code); return `\x00INLINECODE_${idx}\x00`; }); // Phase 3: Escape HTML text = text .replace(/&/g, '&') .replace(//g, '>'); // Phase 4: Block-level processing (line-by-line to handle single-newline headers/lists) text = this._renderLines(text); // Phase 5: Restore fenced code blocks codeBlocks.forEach((cb, idx) => { const langLabel = cb.lang ? ` data-lang="${cb.lang}"` : ''; const escaped = this._escapeHtml(cb.code.replace(/\n$/, '')); const replacement = `
${escaped}`;
text = text.replace(`\x00CODEBLOCK_${idx}\x00`, replacement);
});
// Phase 6: Restore inline code
inlineCodes.forEach((code, idx) => {
const escaped = this._escapeHtml(code);
const replacement = `${escaped}`;
text = text.replace(`\x00INLINECODE_${idx}\x00`, replacement);
});
// Phase 7: Cleanup placeholder newlines
text = text.replace(/\x01NL\x01/g, '\n');
return text;
},
_renderLines(text) {
const lines = text.split('\n');
const out = [];
let para = [];
const flushPara = () => {
if (para.length) {
out.push(`${this._renderInline(para.join('
'))}
${this._renderInline(trimmed.replace(/^>\s?/, ''))}`); continue; } // Unordered list item if (/^[\-\*]\s/.test(trimmed)) { flushPara(); out.push(this._renderListItem(trimmed, 'ul')); continue; } // Ordered list item if (/^\d+\.\s/.test(trimmed)) { flushPara(); out.push(this._renderListItem(trimmed, 'ol')); continue; } // Regular text — accumulate into paragraph para.push(trimmed); } flushPara(); return out.join('\n'); }, _renderListItem(line, tag) { const m = line.match(/^[\s]*(?:[\-\*]|\d+\.)\s+(.*)$/); const content = m ? m[1] : line; // Return just the li; caller can wrap in ul/ol if needed return `
${this._renderInline(inner)}`; } if (/^[\-\*]\s/.test(block)) return this._renderList(block, 'ul'); if (/^\d+\.\s/.test(block)) return this._renderList(block, 'ol'); return `
${this._renderInline(block)}
`; }, _renderList(block, tag) { const lines = block.split('\n'); const items = []; let currentItem = ''; for (const line of lines) { const itemMatch = line.match(/^[\s]*(?:[\-\*]|\d+\.)\s+(.*)$/); if (itemMatch) { if (currentItem) items.push(currentItem); currentItem = itemMatch[1]; } else if (currentItem) { // Continuation of previous item currentItem += ' ' + line.trim(); } } if (currentItem) items.push(currentItem); const listItems = items.map(item => `