refactor: 用 marked.js 替换手写 Markdown 渲染器
Deploy PR-Helper / deploy (push) Successful in 30s

- 引入 marked.js(~40KB)作为专业 Markdown 解析引擎
- markdown.js 精简为 20 行薄封装,保留 renderMarkdown 兼容别名
- CSS 从 .md-* 选择器改为标准 HTML 标签选择器
- 支持 GFM(表格、任务列表、删除线)、代码块语法高亮
- 天然处理流式不完整 Markdown(未闭合代码块等)
This commit is contained in:
2026-06-21 16:23:14 +08:00
parent 496e3ba71e
commit 6dfd01445d
6 changed files with 38 additions and 190 deletions
+18 -21
View File
@@ -49,7 +49,7 @@
}
/* Markdown rendered content (inside review suggestions) */
.review-content .md-code-block {
.review-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px 14px;
@@ -60,16 +60,13 @@
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.review-content .md-code-block[data-lang]::before {
content: attr(data-lang);
display: block;
font-size: 10px;
color: #94a3b8;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
.review-content pre code {
background: none;
color: inherit;
padding: 0;
font-size: inherit;
}
.review-content .md-inline-code {
.review-content code {
background: #f1f5f9;
padding: 1px 5px;
border-radius: 4px;
@@ -77,40 +74,40 @@
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: #be185d;
}
.review-content .md-p {
.review-content p {
margin: 6px 0;
line-height: 1.6;
}
.review-content .md-h1, .review-content .md-h2, .review-content .md-h3 {
.review-content h1, .review-content h2, .review-content h3 {
font-weight: 600;
margin: 10px 0 4px;
}
.review-content .md-h1 { font-size: 1.2em; }
.review-content .md-h2 { font-size: 1.1em; }
.review-content .md-h3 { font-size: 1em; }
.review-content .md-ul, .review-content .md-ol {
.review-content h1 { font-size: 1.2em; }
.review-content h2 { font-size: 1.1em; }
.review-content h3 { font-size: 1em; }
.review-content ul, .review-content ol {
margin: 6px 0;
padding-left: 20px;
}
.review-content .md-li {
.review-content li {
margin: 2px 0;
line-height: 1.5;
}
.review-content .md-link {
.review-content a {
color: #2563eb;
text-decoration: underline;
}
.review-content .md-link:hover {
.review-content a:hover {
color: #1d4ed8;
}
.review-content .md-blockquote {
.review-content blockquote {
border-left: 3px solid #d1d5db;
padding-left: 12px;
color: #6b7280;
margin: 8px 0;
font-style: italic;
}
.review-content .md-hr {
.review-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 12px 0;
+10 -168
View File
@@ -1,177 +1,19 @@
// Shared Markdown renderer for PR-Helper
// Supports: fenced code blocks, inline code, bold, italic, strikethrough,
// headers, links, lists (bullet + numbered + nested), blockquotes, horizontal rules
// Shared Markdown renderer — thin wrapper around marked.js
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 (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 = `<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;
// marked handles all parsing: headers, code blocks, inline code,
// bold/italic, lists, blockquotes, links, GFM tables, etc.
// It gracefully handles incomplete/unclosed markdown during streaming.
return marked.parse(text);
},
_renderLines(text) {
const lines = text.split('\n');
const out = [];
let para = [];
const flushPara = () => {
if (para.length) {
out.push(`<p class="md-p">${this._renderInline(para.join('<br>'))}</p>`);
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('<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; }
// Blockquote
if (/^&gt;\s?/.test(trimmed)) { flushPara(); out.push(`<blockquote class="md-blockquote">${this._renderInline(trimmed.replace(/^&gt;\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);
}
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 `<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 (/^&gt;\s?/.test(block)) { const inner = block.replace(/^&gt;\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');
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;');
}
};
// Configure marked
marked.setOptions({
gfm: true, // GitHub Flavored Markdown (tables, task lists, strikethrough)
breaks: true, // Single newlines become <br>
});
// Expose globally
window.Markdown = Markdown;
+6
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -3,6 +3,7 @@
<head>
{{template "head" .}}
<script src="/static/js/sse.js"></script>
<script src="/static/lib/marked.min.js"></script>
<script src="/static/js/markdown.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
+1
View File
@@ -8,6 +8,7 @@
<script src="/static/lib/highlight.min.js"></script>
<script src="/static/js/sse.js"></script>
<script src="/static/js/graph.js"></script>
<script src="/static/lib/marked.min.js"></script>
<script src="/static/js/markdown.js"></script>
<script src="/static/js/diff-viewer.js"></script>
<script src="/static/js/review-inline.js"></script>
+1
View File
@@ -6,6 +6,7 @@
<script src="/static/lib/diff2html.min.js"></script>
<script src="/static/lib/highlight.min.js"></script>
<script src="/static/js/sse.js"></script>
<script src="/static/lib/marked.min.js"></script>
<script src="/static/js/markdown.js"></script>
<script src="/static/js/diff-viewer.js"></script>
<script src="/static/js/review-inline.js"></script>