Files
PR-Helper/static/js/markdown.js
T

161 lines
5.5 KiB
JavaScript

// 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, '&lt;')
.replace(/>/g, '&gt;');
// 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 = `<pre class="md-code-block"${langLabel}><code>${escaped}</code></pre>`;
text = text.replace(`\x00CODEBLOCK_${idx}\x00`, replacement);
});
// Phase 6: Restore inline code
inlineCodes.forEach((code, idx) => {
const escaped = this._escapeHtml(code);
const replacement = `<code class="md-inline-code">${escaped}</code>`;
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 '<hr class="md-hr">';
}
// Headers
const headerMatch = block.match(/^(#{1,6}) (.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const content = this._renderInline(headerMatch[2]);
return `<h${level} class="md-h${level}">${content}</h${level}>`;
}
// Blockquote
if (/^&gt;\s?/.test(block)) {
const inner = block.replace(/^&gt;\s?/gm, '').trim();
return `<blockquote class="md-blockquote">${this._renderInline(inner)}</blockquote>`;
}
// 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 `<p class="md-p">${this._renderInline(block)}</p>`;
},
_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 => `<li class="md-li">${this._renderInline(item)}</li>`).join('');
return `<${tag} class="md-${tag}">${listItems}</${tag}>`;
},
_renderInline(text) {
if (!text) return '';
// Links: [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="md-link" target="_blank" rel="noopener">$1</a>');
// Images: ![alt](url)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="md-img">');
// Bold + italic: ***text*** or ___text___
text = text.replace(/\*{3}(.+?)\*{3}/g, '<strong><em>$1</em></strong>');
text = text.replace(/_{3}(.+?)_{3}/g, '<strong><em>$1</em></strong>');
// Bold: **text** or __text__
text = text.replace(/\*{2}(.+?)\*{2}/g, '<strong>$1</strong>');
text = text.replace(/_{2}(.+?)_{2}/g, '<strong>$1</strong>');
// Italic: *text* or _text_
text = text.replace(/\*([^\s*](?:[^*]*[^\s*])?)\*/g, '<em>$1</em>');
text = text.replace(/_([^\s_](?:[^_]*[^\s_])?)_/g, '<em>$1</em>');
// Strikethrough: ~~text~~
text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
// Single newlines within a block become <br>
text = text.replace(/\n/g, '<br>');
return text;
},
_escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
};
// Expose globally
window.Markdown = Markdown;
// Backward-compatible alias
if (typeof window.renderMarkdown !== 'function') {
window.renderMarkdown = function(text) { return Markdown.render(text); };
}