// 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 (split into blocks by blank lines) const blocks = text.split(/\n{2,}/); const htmlBlocks = blocks.map(block => this._renderBlock(block.trim())); text = htmlBlocks.join('\n'); // 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; }, _renderBlock(block) { if (!block) return ''; // Horizontal rule if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) { return '
'; } // Headers const headerMatch = block.match(/^(#{1,6}) (.+)$/); if (headerMatch) { const level = headerMatch[1].length; const content = this._renderInline(headerMatch[2]); return `${content}`; } // Blockquote if (/^>\s?/.test(block)) { const inner = block.replace(/^>\s?/gm, '').trim(); return `
${this._renderInline(inner)}
`; } // Unordered list if (/^[\-\*]\s/.test(block)) { return this._renderList(block, 'ul'); } // Ordered list if (/^\d+\.\s/.test(block)) { return this._renderList(block, 'ol'); } // Paragraph 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); }; }