// 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('
'))}

`); para = []; } }; for (const line of lines) { const trimmed = line.trim(); // Blank line — flush paragraph if (!trimmed) { flushPara(); continue; } // Horizontal rule if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushPara(); out.push('
'); continue; } // Header (h1–h6) const hm = trimmed.match(/^(#{1,6}) (.+)$/); if (hm) { flushPara(); out.push(`${this._renderInline(hm[2])}`); continue; } // Blockquote if (/^>\s?/.test(trimmed)) { flushPara(); out.push(`
${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(content)}
  • `; }, _renderBlock(block) { // Kept as fallback; primary rendering now uses _renderLines if (!block) return ''; if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) return '
    '; const hm = block.match(/^(#{1,6}) (.+)$/); if (hm) return `${this._renderInline(hm[2])}`; if (/^>\s?/.test(block)) { const inner = block.replace(/^>\s?/gm, '').trim(); 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 => `
  • ${this._renderInline(item)}
  • `).join(''); return `<${tag} class="md-${tag}">${listItems}`; }, _renderInline(text) { if (!text) return ''; // Links: [text](url) text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // Images: ![alt](url) text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); // Bold + italic: ***text*** or ___text___ text = text.replace(/\*{3}(.+?)\*{3}/g, '$1'); text = text.replace(/_{3}(.+?)_{3}/g, '$1'); // Bold: **text** or __text__ text = text.replace(/\*{2}(.+?)\*{2}/g, '$1'); text = text.replace(/_{2}(.+?)_{2}/g, '$1'); // Italic: *text* or _text_ text = text.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '$1'); text = text.replace(/_([^\s_](?:[^_]*[^\s_])?)_/g, '$1'); // Strikethrough: ~~text~~ text = text.replace(/~~(.+?)~~/g, '$1'); // Single newlines within a block become
    text = text.replace(/\n/g, '
    '); return text; }, _escapeHtml(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } }; // Expose globally window.Markdown = Markdown; // Backward-compatible alias if (typeof window.renderMarkdown !== 'function') { window.renderMarkdown = function(text) { return Markdown.render(text); }; }