2026-06-19 23:29:05 +08:00
|
|
|
|
// 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, '<')
|
|
|
|
|
|
.replace(/>/g, '>');
|
|
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
// Phase 4: Block-level processing (line-by-line to handle single-newline headers/lists)
|
|
|
|
|
|
text = this._renderLines(text);
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
_renderLines(text) {
|
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
let para = [];
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
const flushPara = () => {
|
|
|
|
|
|
if (para.length) {
|
|
|
|
|
|
out.push(`<p class="md-p">${this._renderInline(para.join('<br>'))}</p>`);
|
|
|
|
|
|
para = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
const trimmed = line.trim();
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
// Blank line — flush paragraph
|
|
|
|
|
|
if (!trimmed) { flushPara(); continue; }
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
// Horizontal rule
|
|
|
|
|
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushPara(); out.push('<hr class="md-hr">'); continue; }
|
|
|
|
|
|
|
|
|
|
|
|
// Header (h1–h6)
|
|
|
|
|
|
const hm = trimmed.match(/^(#{1,6}) (.+)$/);
|
|
|
|
|
|
if (hm) { flushPara(); out.push(`<h${hm[1].length} class="md-h${hm[1].length}">${this._renderInline(hm[2])}</h${hm[1].length}>`); continue; }
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
// Blockquote
|
|
|
|
|
|
if (/^>\s?/.test(trimmed)) { flushPara(); out.push(`<blockquote class="md-blockquote">${this._renderInline(trimmed.replace(/^>\s?/, ''))}</blockquote>`); 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);
|
2026-06-19 23:29:05 +08:00
|
|
|
|
}
|
2026-06-21 15:57:54 +08:00
|
|
|
|
flushPara();
|
|
|
|
|
|
return out.join('\n');
|
|
|
|
|
|
},
|
2026-06-19 23:29:05 +08:00
|
|
|
|
|
2026-06-21 15:57:54 +08:00
|
|
|
|
_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 `<li class="md-li">${this._renderInline(content)}</li>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
_renderBlock(block) {
|
|
|
|
|
|
// Kept as fallback; primary rendering now uses _renderLines
|
|
|
|
|
|
if (!block) return '';
|
|
|
|
|
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(block)) return '<hr class="md-hr">';
|
|
|
|
|
|
const hm = block.match(/^(#{1,6}) (.+)$/);
|
|
|
|
|
|
if (hm) return `<h${hm[1].length} class="md-h${hm[1].length}">${this._renderInline(hm[2])}</h${hm[1].length}>`;
|
|
|
|
|
|
if (/^>\s?/.test(block)) { const inner = block.replace(/^>\s?/gm, '').trim(); return `<blockquote class="md-blockquote">${this._renderInline(inner)}</blockquote>`; }
|
|
|
|
|
|
if (/^[\-\*]\s/.test(block)) return this._renderList(block, 'ul');
|
|
|
|
|
|
if (/^\d+\.\s/.test(block)) return this._renderList(block, 'ol');
|
2026-06-19 23:29:05 +08:00
|
|
|
|
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: 
|
|
|
|
|
|
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, '&')
|
|
|
|
|
|
.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); };
|
|
|
|
|
|
}
|