Files
PR-Helper/static/js/markdown.js
T
wonder 779bd9e332
Deploy PR-Helper / deploy (push) Successful in 27s
fix: 真正的流式输出 + 修复 Markdown 标题渲染
流式输出:
- 恢复 content 事件处理,实时累积 LLM 原始输出
- parsePartialJSON 从不完整 JSON 中渐进提取已闭合的字段
- 每次提取到新字段即更新两栏,实现左栏渐进渲染
- 结构化事件(title/detail等)覆盖解析结果,确保最终一致

Markdown 渲染:
- 重写为逐行处理(_renderLines),不再按双换行分块
- ### 及更深层级标题在单换行后也能正确识别为独立块
- 列表项、水平线等同样受益于逐行解析
2026-06-21 15:57:54 +08:00

183 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (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;
},
_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;');
}
};
// Expose globally
window.Markdown = Markdown;
// Backward-compatible alias
if (typeof window.renderMarkdown !== 'function') {
window.renderMarkdown = function(text) { return Markdown.render(text); };
}