feat: 共享 markdown 渲染器,完整语法支持
This commit is contained in:
@@ -48,6 +48,81 @@
|
|||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown rendered content (inside review suggestions) */
|
||||||
|
.review-content .md-code-block {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
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 .md-inline-code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
color: #be185d;
|
||||||
|
}
|
||||||
|
.review-content .md-p {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.review-content .md-h1, .review-content .md-h2, .review-content .md-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 {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.review-content .md-li {
|
||||||
|
margin: 2px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.review-content .md-link {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.review-content .md-link:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.review-content .md-blockquote {
|
||||||
|
border-left: 3px solid #d1d5db;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.review-content .md-hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.review-content em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.review-content del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
/* Severity badge colors (used by review) */
|
/* Severity badge colors (used by review) */
|
||||||
.border-red-400 { border-color: #f87171; }
|
.border-red-400 { border-color: #f87171; }
|
||||||
.border-yellow-400 { border-color: #facc15; }
|
.border-yellow-400 { border-color: #facc15; }
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// 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, '>');
|
||||||
|
|
||||||
|
// 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 (/^>\s?/.test(block)) {
|
||||||
|
const inner = block.replace(/^>\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: 
|
||||||
|
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); };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
{{template "head" .}}
|
{{template "head" .}}
|
||||||
<script src="/static/js/sse.js"></script>
|
<script src="/static/js/sse.js"></script>
|
||||||
|
<script src="/static/js/markdown.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen">
|
<body class="bg-gray-50 min-h-screen">
|
||||||
{{template "nav" .}}
|
{{template "nav" .}}
|
||||||
@@ -266,28 +267,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown rendering (bold, headers, lists, code)
|
// Markdown rendering (delegates to shared Markdown.render)
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
if (!text) return '';
|
return Markdown.render(text);
|
||||||
let html = text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
||||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>')
|
|
||||||
.replace(/^- (.+)$/gm, '<li class="bullet-item">$1</li>')
|
|
||||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="numbered-item">$2</li>')
|
|
||||||
.replace(/\n/g, '\n');
|
|
||||||
// Wrap consecutive bullet <li> in <ul>
|
|
||||||
html = html.replace(/((?:<li class="bullet-item">.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
|
||||||
// Wrap consecutive numbered <li> in <ol>
|
|
||||||
html = html.replace(/((?:<li class="numbered-item">.*<\/li>\n?)+)/g, '<ol>$1</ol>');
|
|
||||||
// Convert remaining newlines to <br> (outside lists)
|
|
||||||
html = html.replace(/\n/g, '<br>');
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyMarkdown() {
|
function copyMarkdown() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<script src="/static/lib/highlight.min.js"></script>
|
<script src="/static/lib/highlight.min.js"></script>
|
||||||
<script src="/static/js/sse.js"></script>
|
<script src="/static/js/sse.js"></script>
|
||||||
<script src="/static/js/graph.js"></script>
|
<script src="/static/js/graph.js"></script>
|
||||||
|
<script src="/static/js/markdown.js"></script>
|
||||||
<script src="/static/js/diff-viewer.js"></script>
|
<script src="/static/js/diff-viewer.js"></script>
|
||||||
<script src="/static/js/review-inline.js"></script>
|
<script src="/static/js/review-inline.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@@ -552,17 +553,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderInlineMarkdown(text) {
|
function renderInlineMarkdown(text) {
|
||||||
if (!text) return '';
|
return Markdown.render(text);
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/^### (.+)$/gm, '<h3 class="font-semibold text-sm mt-2">$1</h3>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h2 class="font-semibold mt-2">$1</h2>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded text-xs">$1</code>')
|
|
||||||
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<script src="/static/lib/diff2html.min.js"></script>
|
<script src="/static/lib/diff2html.min.js"></script>
|
||||||
<script src="/static/lib/highlight.min.js"></script>
|
<script src="/static/lib/highlight.min.js"></script>
|
||||||
<script src="/static/js/sse.js"></script>
|
<script src="/static/js/sse.js"></script>
|
||||||
|
<script src="/static/js/markdown.js"></script>
|
||||||
<script src="/static/js/diff-viewer.js"></script>
|
<script src="/static/js/diff-viewer.js"></script>
|
||||||
<script src="/static/js/review-inline.js"></script>
|
<script src="/static/js/review-inline.js"></script>
|
||||||
<script src="/static/js/note-editor.js"></script>
|
<script src="/static/js/note-editor.js"></script>
|
||||||
@@ -418,28 +419,9 @@
|
|||||||
btn.textContent = diffVisible ? '隐藏 Diff' : '显示 Diff';
|
btn.textContent = diffVisible ? '隐藏 Diff' : '显示 Diff';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown rendering
|
// Markdown rendering (delegates to shared Markdown.render)
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
if (!text) return '';
|
return Markdown.render(text);
|
||||||
let html = text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
||||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>')
|
|
||||||
.replace(/^- (.+)$/gm, '<li class="bullet-item">$1</li>')
|
|
||||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="numbered-item">$2</li>')
|
|
||||||
.replace(/\n/g, '\n');
|
|
||||||
// Wrap consecutive bullet <li> in <ul>
|
|
||||||
html = html.replace(/((?:<li class="bullet-item">.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
|
||||||
// Wrap consecutive numbered <li> in <ol>
|
|
||||||
html = html.replace(/((?:<li class="numbered-item">.*<\/li>\n?)+)/g, '<ol>$1</ol>');
|
|
||||||
// Convert remaining newlines to <br> (outside lists)
|
|
||||||
html = html.replace(/\n/g, '<br>');
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── History loading ────────────────────────────────────────
|
// ── History loading ────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user