Files
cms-website/apps/api/src/modules/build/report-html.ts
T
浅念 8f2b9825a6 feat(build): 最小门禁与状态机闭环
Task 6(Plan 4):validating 接真实门禁——五项产物检查(①内部死链 ②sitemap
一致 ③SEO 基线 ④大小写冲突 ⑤产物完整性),P0 阻断/P2 报告分级;validation_
runs/validation_issues 落库(severity/rule/url/evidence),任一 P0 → job failed
(VALIDATION_FAILED + 产物清场),全过 → succeeded。HTML 解析用 htmlparser2
(sanitize-html 既有传递依赖,lock 已解析 12.0.0——apps/api 显式声明复用,零新
包版本),禁正则提标签。报告 json/html 双落盘且 HTML 先写(Plan 3 窄窗口修,
html 转义模板)。顺手:build-snapshot 测试 spawn 缩减 7→2(六文件物化/bodyHtml
重写/ADR-010 改 materializeRenderSnapshot 直调;复用两场景并谱共享单次成功构建),
根套件 97.3s→75.8s;两测试文件清场补 validation 域反序(FK 链先行)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 12:29:48 +08:00

96 lines
3.8 KiB
TypeScript
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.
// 构建报告 HTML 渲染(Plan 4 Task 6——"报告 HTML 先写"窄窗口修的渲染面):
// 沿用 Plan 2/3 模板纪律(import 域 report-html.ts 同款):
// - 对象层(build.json)保留原文——JSON 归档/机器消费的语义层;
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告动态文本含站点键/
// 幂等键/路径等操作者可控串,未转义渲染会使报告本身成为注入载体;
// - 静态骨架无 script/事件属性;计数与清单按确定序渲染。
import type { BuildReport } from './build.service.js'
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
default:
return '&#39;'
}
})
}
const esc = escapeHtml
/** 构建报告 → HTML 片段(结构:概览/门禁/环境/快照计数/产物/耗时/问题清单) */
export function renderBuildReportHtml(report: BuildReport): string {
const lines: string[] = []
lines.push('<h2>构建报告</h2>')
lines.push('<h3>概览</h3>')
lines.push(
`<ul><li>job: ${esc(report.jobId)}</li><li>状态: ${esc(report.status)}</li>` +
`<li>站点: ${esc(report.siteKey)}(${esc(report.siteId)})</li>` +
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>主题: ${esc(report.theme.key)}@${esc(report.theme.version)}</li>` +
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
)
lines.push('<h3>门禁</h3>')
lines.push(
`<ul><li>规则版本: ${esc(report.validation.ruleVersion)}</li>` +
`<li>状态: ${esc(report.validation.status)}</li>` +
`<li>run: ${esc(report.validation.runId)}</li>` +
`<li>P0(阻断): ${report.validation.p0Count}</li>` +
`<li>P2(报告): ${report.validation.p2Count}</li></ul>`,
)
lines.push('<h3>构建环境</h3>')
lines.push(
`<ul><li>node: ${esc(report.buildEnv.node)}</li><li>pnpm: ${esc(report.buildEnv.pnpm)}</li>` +
`<li>sourceCommit: ${esc(report.buildEnv.sourceCommit)}</li>` +
`<li>locale: ${esc(report.buildEnv.locale)}</li><li>timezone: ${esc(report.buildEnv.timezone)}</li></ul>`,
)
lines.push('<h3>快照</h3>')
lines.push(
`<ul><li>config 修订: ${esc(report.snapshot.siteConfigRevisionId)}</li>` +
`<li>manifest: ${esc(report.snapshot.manifestPath)}</li>` +
`<li>contents: ${report.snapshot.counts.contents}</li>` +
`<li>taxonomies: ${report.snapshot.counts.taxonomies}</li>` +
`<li>routes: ${report.snapshot.counts.routes}</li>` +
`<li>assets: ${report.snapshot.counts.assets}</li>` +
`<li>URL 重写: rewritten=${report.urlRewrite.rewritten} unresolved=${report.urlRewrite.unresolved}</li></ul>`,
)
lines.push('<h3>产物</h3>')
lines.push(
`<ul><li>dist: ${esc(report.artifacts.distPath)}</li>` +
`<li>dist sha256: ${esc(report.artifacts.distSha256)}</li>` +
`<li>dist 文件数: ${report.artifacts.distFileCount}</li>` +
`<li>route-manifest: ${esc(report.artifacts.routeManifestPath)}</li>` +
`<li>route-manifest sha256: ${esc(report.artifacts.routeManifestSha256)}</li>` +
`<li>sitemap 条目: ${report.artifacts.sitemapEntries}</li>` +
`<li>媒体复制: ${report.artifacts.mediaCopied}</li></ul>`,
)
lines.push('<h3>问题清单</h3>')
if (report.issues.length === 0) {
lines.push('<p>(无)</p>')
} else {
lines.push('<ul>')
for (const issue of report.issues) {
lines.push(`<li>${esc(issue)}</li>`)
}
lines.push('</ul>')
}
lines.push('<h3>耗时</h3>')
lines.push(`<ul><li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
return lines.join('\n')
}