forked from Gitlink/gitlink-cli
329 lines
10 KiB
JavaScript
329 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Markdown 报告 → HTML 可视化转换器
|
||
* 用法: node md-to-html.js <输入.md> [输出.html]
|
||
* 如果输出路径省略,自动在同目录生成同名 .html 文件
|
||
*/
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const inputFile = process.argv[2];
|
||
if (!inputFile) {
|
||
console.error('用法: node md-to-html.js <输入.md> [输出.html]');
|
||
process.exit(1);
|
||
}
|
||
|
||
const outputFile = process.argv[3] || inputFile.replace(/\.md$/i, '.html');
|
||
const md = fs.readFileSync(inputFile, 'utf8');
|
||
|
||
// ==== 简易 Markdown → HTML 转换 ====
|
||
let html = md;
|
||
|
||
// 提取标题
|
||
const titleMatch = html.match(/^#\s+(.+)/m);
|
||
const pageTitle = titleMatch ? titleMatch[1].replace(/[*🔬📊📈]/g, '').trim() : path.basename(inputFile, '.md');
|
||
|
||
// 提取综合评分
|
||
const scoreMatch = html.match(/\*\*综合评分[::]\*\*\s*([\d.]+)\/10\s*(.*?)(?:\n|$)/);
|
||
const totalScore = scoreMatch ? scoreMatch[1] : null;
|
||
const scoreLevel = scoreMatch ? scoreMatch[2].trim() : '';
|
||
|
||
// 表格处理
|
||
function renderTable(tableHtml) {
|
||
const lines = tableHtml.split('\n').filter(l => l.trim());
|
||
if (lines.length < 2) return tableHtml;
|
||
|
||
// 提取表头
|
||
const header = lines[0].replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
||
const hasSeparator = lines[1] && lines[1].includes('---');
|
||
|
||
let result = '<div class="table-wrap"><table><thead><tr>';
|
||
result += header.map(h => `<th>${h}</th>`).join('');
|
||
result += '</tr></thead><tbody>';
|
||
|
||
const dataLines = hasSeparator ? lines.slice(2) : lines.slice(1);
|
||
dataLines.forEach(line => {
|
||
if (!line.trim()) return;
|
||
const cols = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
||
result += '<tr>' + cols.map(c => {
|
||
// 处理 emoji 状态标记
|
||
let cellClass = '';
|
||
if (c.includes('✅')) cellClass = ' class="cell-ok"';
|
||
else if (c.includes('❌')) cellClass = ' class="cell-fail"';
|
||
else if (c.includes('🟢')) cellClass = ' class="cell-green"';
|
||
else if (c.includes('🟡')) cellClass = ' class="cell-yellow"';
|
||
else if (c.includes('🔴')) cellClass = ' class="cell-red"';
|
||
else if (c.includes('⚠️')) cellClass = ' class="cell-warn"';
|
||
return `<td${cellClass}>${c}</td>`;
|
||
}).join('') + '</tr>';
|
||
});
|
||
|
||
result += '</tbody></table></div>';
|
||
return result;
|
||
}
|
||
|
||
// 处理代码块
|
||
html = html.replace(/```[\s\S]*?```/g, m => {
|
||
const code = m.replace(/```\w*\n?/, '').replace(/```$/, '');
|
||
return `<pre class="code-block"><code>${escapeHtml(code)}</code></pre>`;
|
||
});
|
||
|
||
// 处理表格
|
||
html = html.replace(/\|(.+)\|\n\|[-| :]+\|\n(?:\|.+\|\n?)*/g, renderTable);
|
||
|
||
// 处理标题
|
||
html = html.replace(/^####\s+(.+)/gm, '<h4>$1</h4>');
|
||
html = html.replace(/^###\s+(.+)/gm, '<h3>$1</h3>');
|
||
html = html.replace(/^##\s+(.+)/gm, '<h2>$1</h2>');
|
||
html = html.replace(/^#\s+(.+)/gm, '<h1>$1</h1>');
|
||
|
||
// 处理粗体和行内代码
|
||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
|
||
// 处理列表
|
||
html = html.replace(/^(\d+)\.\s+(.+)/gm, '<li data-index="$1">$2</li>');
|
||
html = html.replace(/^-\s+(.+)/gm, '<li>$1</li>');
|
||
html = html.replace(/(<li.*<\/li>\n)+/g, '<ol>$&</ol>');
|
||
|
||
// 处理段落
|
||
html = html.replace(/^(?!<[hHlLtTpPcCdiI]|<div|<pre|<table|<o).+$/gm, m => {
|
||
if (m.trim()) return `<p>${m.trim()}</p>`;
|
||
return m;
|
||
});
|
||
|
||
// 清理多余空行
|
||
html = html.replace(/\n{3,}/g, '\n\n');
|
||
|
||
function escapeHtml(text) {
|
||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
// 构建评分环
|
||
const scoreRing = totalScore ? `
|
||
<div class="score-section">
|
||
<div class="score-ring">
|
||
<svg viewBox="0 0 120 120" width="140" height="140">
|
||
<circle cx="60" cy="60" r="52" fill="none" stroke="#e2e8f0" stroke-width="8"/>
|
||
<circle cx="60" cy="60" r="52" fill="none" stroke="${getScoreColor(totalScore)}" stroke-width="8"
|
||
stroke-dasharray="${2 * Math.PI * 52}" stroke-dashoffset="${2 * Math.PI * 52 * (1 - totalScore / 10)}"
|
||
transform="rotate(-90, 60, 60)" stroke-linecap="round"
|
||
style="transition: stroke-dashoffset 1.5s ease-in-out;"/>
|
||
<text x="60" y="50" text-anchor="middle" font-size="32" font-weight="700" fill="#1e293b">${totalScore}</text>
|
||
<text x="60" y="72" text-anchor="middle" font-size="13" fill="#64748b">/ 10</text>
|
||
</svg>
|
||
</div>
|
||
<div class="score-label">${scoreLevel}</div>
|
||
</div>` : '';
|
||
|
||
function getScoreColor(score) {
|
||
const s = parseFloat(score);
|
||
if (s >= 8) return '#22c55e';
|
||
if (s >= 6) return '#3b82f6';
|
||
if (s >= 4) return '#f59e0b';
|
||
return '#ef4444';
|
||
}
|
||
|
||
// 构建完整 HTML
|
||
const fullHtml = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${pageTitle}</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: #f1f5f9;
|
||
color: #1e293b;
|
||
line-height: 1.7;
|
||
}
|
||
.page {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 0 20px 60px;
|
||
}
|
||
.header-card {
|
||
background: linear-gradient(135deg, #1e293b, #334155);
|
||
color: white;
|
||
border-radius: 16px;
|
||
padding: 32px 40px;
|
||
margin: 24px 0 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 20px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||
}
|
||
.header-card h1 {
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
line-height: 1.3;
|
||
}
|
||
.header-card .meta {
|
||
font-size: 13px;
|
||
color: #94a3b8;
|
||
margin-top: 6px;
|
||
}
|
||
.card {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 24px 28px;
|
||
margin-bottom: 16px;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
.card h2 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
margin-bottom: 14px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #f1f5f9;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.card h3 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #334155;
|
||
margin: 16px 0 8px;
|
||
}
|
||
.card h4 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
margin: 12px 0 6px;
|
||
}
|
||
.card p {
|
||
font-size: 14px;
|
||
color: #475569;
|
||
margin-bottom: 8px;
|
||
}
|
||
.card p:last-child { margin-bottom: 0; }
|
||
.card code {
|
||
background: #f1f5f9;
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||
color: #0f172a;
|
||
}
|
||
.card strong {
|
||
color: #0f172a;
|
||
}
|
||
.score-section {
|
||
text-align: center;
|
||
padding: 8px 0;
|
||
}
|
||
.score-ring {
|
||
display: inline-block;
|
||
}
|
||
.score-label {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-top: 4px;
|
||
}
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
margin: 10px 0;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th {
|
||
background: #f8fafc;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
padding: 10px 12px;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
color: #475569;
|
||
white-space: nowrap;
|
||
}
|
||
td {
|
||
padding: 9px 12px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
color: #334155;
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: #f8fafc; }
|
||
.cell-ok { color: #16a34a; font-weight: 500; }
|
||
.cell-fail { color: #dc2626; }
|
||
.cell-green { color: #16a34a; }
|
||
.cell-yellow { color: #ca8a04; }
|
||
.cell-red { color: #dc2626; }
|
||
.cell-warn { color: #ea580c; }
|
||
ol {
|
||
padding-left: 20px;
|
||
margin: 8px 0;
|
||
}
|
||
ol li {
|
||
font-size: 14px;
|
||
color: #475569;
|
||
padding: 4px 0;
|
||
list-style-position: outside;
|
||
}
|
||
li::marker {
|
||
font-weight: 600;
|
||
color: #3b82f6;
|
||
}
|
||
.code-block {
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
padding: 16px 20px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
overflow-x: auto;
|
||
margin: 12px 0;
|
||
line-height: 1.5;
|
||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||
}
|
||
.footer {
|
||
text-align: center;
|
||
padding: 24px;
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.page { padding: 0 12px 40px; }
|
||
.header-card { padding: 20px; flex-direction: column; text-align: center; }
|
||
.header-card h1 { font-size: 18px; }
|
||
.card { padding: 16px; }
|
||
}
|
||
@media print {
|
||
body { background: white; }
|
||
.header-card { break-inside: avoid; }
|
||
.card { break-inside: avoid; box-shadow: none; border: 1px solid #e2e8f0; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<div class="header-card">
|
||
<div>
|
||
<h1>${pageTitle}</h1>
|
||
<div class="meta">生成时间:${new Date().toLocaleString('zh-CN')}</div>
|
||
</div>
|
||
${scoreRing}
|
||
</div>
|
||
<div class="card report-content">
|
||
${html.split('\n').filter(l => {
|
||
// 去掉已经被处理过的原始 markdown 元素
|
||
return !l.match(/^\*\*数据来源/) && !l.match(/^\*\*注意/) && !l.match(/^\*自动生/);
|
||
}).join('\n')}
|
||
</div>
|
||
<div class="footer">
|
||
Powered by gitlink-cli · 数据来源于 GitLink 平台
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
fs.writeFileSync(outputFile, fullHtml, 'utf8');
|
||
console.log('✅ HTML 报告已生成: ' + outputFile);
|