156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
|
|
// 导入报告 HTML 渲染(Plan 3 Task 8——Task 6 推迟项兑现):沿用 Plan 2 Task 6 模板纪律
|
|||
|
|
// (@sf/adapter-wp 的 renderReportHtml 同款):
|
|||
|
|
// - 对象层(import.json)保留**原文**(issue 字符串未转义)——JSON 归档/机器消费的语义层;
|
|||
|
|
// - 渲染层对**所有**动态文本经 escapeHtml(<>&"' 全转义)——报告的动态文本来自不可信
|
|||
|
|
// WXR 内容(行级问题携带清洗前 excerpt 形态,可含 `<script src="x">`),未转义渲染会使
|
|||
|
|
// 报告本身成为存储型 XSS 载体(Plan 2 Task 5 审查移交纪律的导入侧续录);
|
|||
|
|
// - 静态骨架无 script/事件属性;计数表键按字典序渲染(对象键序不保证,渲染须确定)。
|
|||
|
|
//
|
|||
|
|
// escapeHtml 与 adapter-wp/report.ts 的私有实现同形独立维护(17 行纯函数)——不为此扩
|
|||
|
|
// @sf/adapter-wp 公共导出面;两处若任一改判集,由对抗测试各自护栏(本文件测试钉恶意
|
|||
|
|
// payload 转义)。
|
|||
|
|
import type { ImportReport, SnapshotRefSlim } from './import.service.js'
|
|||
|
|
|
|||
|
|
/** HTML 转义(渲染层唯一动态文本通道):<>&"' 全转义,& 最先避免二次转义陷阱 */
|
|||
|
|
function escapeHtml(text: string): string {
|
|||
|
|
return text.replace(/[&<>"']/g, (ch) => {
|
|||
|
|
switch (ch) {
|
|||
|
|
case '&':
|
|||
|
|
return '&'
|
|||
|
|
case '<':
|
|||
|
|
return '<'
|
|||
|
|
case '>':
|
|||
|
|
return '>'
|
|||
|
|
case '"':
|
|||
|
|
return '"'
|
|||
|
|
default:
|
|||
|
|
return '''
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const esc = escapeHtml
|
|||
|
|
|
|||
|
|
/** 分组渲染顺序(固定——对象键序不保证,人类视图须确定) */
|
|||
|
|
const GROUP_ORDER = ['content', 'term', 'media', 'route', 'adapter', 'orchestrator'] as const
|
|||
|
|
|
|||
|
|
const GROUP_LABELS: Record<(typeof GROUP_ORDER)[number], string> = {
|
|||
|
|
content: '内容(content-writer)',
|
|||
|
|
term: '分类(term-writer)',
|
|||
|
|
media: '媒体(media-writer)',
|
|||
|
|
route: '路由(route-writer)',
|
|||
|
|
adapter: '适配器(行级/unsupported/error)',
|
|||
|
|
orchestrator: '编排器(orchestrator)',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function snapshotLine(label: string, ref: SnapshotRefSlim | null): string {
|
|||
|
|
if (ref === null) return `<li>${label}:(无)</li>`
|
|||
|
|
return `<li>${label}:${esc(ref.id)}(${esc(ref.kind)},sha256=${esc(ref.sha256.slice(0, 12))}…,${ref.byteSize} bytes)</li>`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 导入报告 → HTML 片段(结构:概览/快照/版本/路径/计数表/清洗统计/问题清单/错误/耗时)。
|
|||
|
|
* 问题清单优先渲染分组视图(编排管道恒产 issueGroups——六组按固定序);分组缺席
|
|||
|
|
* (注入管道的 Task 1 形态)回退 flat 清单。封顶溢出以 issuesTruncated 注记呈现。
|
|||
|
|
*/
|
|||
|
|
export function renderImportReportHtml(report: ImportReport): 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>connection: ${esc(report.connectionId)}</li>` +
|
|||
|
|
`<li>幂等键: ${esc(report.idempotencyKey)}</li><li>attempt: ${report.attempt}</li>` +
|
|||
|
|
`<li>生成时间: ${esc(report.generatedAt)}</li></ul>`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.push('<h3>快照</h3>')
|
|||
|
|
lines.push(
|
|||
|
|
`<ul>${snapshotLine('wxr(主输入)', report.snapshots.wxr)}` +
|
|||
|
|
`${snapshotLine('media_bundle', report.snapshots.media_bundle)}` +
|
|||
|
|
`${snapshotLine('manual_url_list', report.snapshots.manual_url_list)}</ul>`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.push('<h3>版本</h3>')
|
|||
|
|
lines.push(
|
|||
|
|
`<ul><li>adapter: ${esc(report.versions.adapter)}</li>` +
|
|||
|
|
`<li>mapping: ${esc(report.versions.mapping)}</li>` +
|
|||
|
|
`<li>sanitizer: ${esc(report.versions.sanitizer)}</li>` +
|
|||
|
|
`<li>parser: ${esc(report.versions.parser)}</li></ul>`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.push('<h3>路径</h3>')
|
|||
|
|
lines.push(
|
|||
|
|
`<ul><li>productsPath: ${esc(report.paths.productsPath)}</li>` +
|
|||
|
|
`<li>newsPath: ${esc(report.paths.newsPath)}</li></ul>`,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines.push('<h3>计数</h3>')
|
|||
|
|
lines.push('<table><thead><tr><th>键</th><th>值</th></tr></thead><tbody>')
|
|||
|
|
const keys = Object.keys(report.counts).sort()
|
|||
|
|
if (keys.length === 0) {
|
|||
|
|
lines.push('<tr><td colspan="2">(无)</td></tr>')
|
|||
|
|
}
|
|||
|
|
for (const key of keys) {
|
|||
|
|
lines.push(`<tr><td>${esc(key)}</td><td>${report.counts[key]}</td></tr>`)
|
|||
|
|
}
|
|||
|
|
lines.push('</tbody></table>')
|
|||
|
|
|
|||
|
|
if (report.sanitize !== undefined) {
|
|||
|
|
lines.push('<h3>清洗统计</h3>')
|
|||
|
|
const diffTypes = Object.keys(report.sanitize.byDiffType).sort()
|
|||
|
|
const diffs =
|
|||
|
|
diffTypes.length === 0
|
|||
|
|
? ''
|
|||
|
|
: `<li>byDiffType: ${diffTypes.map((t) => `${esc(t)}=${report.sanitize!.byDiffType[t]}`).join('、')}</li>`
|
|||
|
|
lines.push(`<ul><li>changed rows: ${report.sanitize.changedRows}</li>${diffs}</ul>`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lines.push('<h3>问题清单</h3>')
|
|||
|
|
const groups = report.issueGroups
|
|||
|
|
if (groups !== undefined) {
|
|||
|
|
for (const key of GROUP_ORDER) {
|
|||
|
|
const groupIssues = groups[key]
|
|||
|
|
if (groupIssues === undefined) continue
|
|||
|
|
lines.push(`<h4>${esc(GROUP_LABELS[key])}(${groupIssues.length} 条)</h4>`)
|
|||
|
|
if (groupIssues.length === 0) {
|
|||
|
|
lines.push('<p>(无)</p>')
|
|||
|
|
} else {
|
|||
|
|
lines.push('<ul>')
|
|||
|
|
for (const issue of groupIssues) {
|
|||
|
|
lines.push(`<li>${esc(issue)}</li>`)
|
|||
|
|
}
|
|||
|
|
lines.push('</ul>')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if (report.issues.length === 0 && report.issuesTruncated === 0) {
|
|||
|
|
lines.push('<p>(无)</p>')
|
|||
|
|
} else {
|
|||
|
|
lines.push('<ul>')
|
|||
|
|
for (const issue of report.issues) {
|
|||
|
|
lines.push(`<li>${esc(issue)}</li>`)
|
|||
|
|
}
|
|||
|
|
lines.push('</ul>')
|
|||
|
|
}
|
|||
|
|
if (report.issuesTruncated > 0) {
|
|||
|
|
lines.push(`<p>(另有 ${report.issuesTruncated} 条超出固化上限,仅计数)</p>`)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (report.error !== null) {
|
|||
|
|
lines.push(
|
|||
|
|
`<h3>错误</h3><ul><li>code: ${esc(report.error.code)}</li><li>summary: ${esc(report.error.summary)}</li></ul>`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lines.push('<h3>耗时</h3>')
|
|||
|
|
const adapterTiming =
|
|||
|
|
report.adapterTiming === undefined
|
|||
|
|
? ''
|
|||
|
|
: `<li>adapter parse: ${report.adapterTiming.parseMs.toFixed(3)}ms</li>` +
|
|||
|
|
`<li>adapter sanitize: ${report.adapterTiming.sanitizeMs.toFixed(3)}ms</li>`
|
|||
|
|
lines.push(`<ul>${adapterTiming}<li>total: ${report.durationMs.toFixed(1)}ms</li></ul>`)
|
|||
|
|
|
|||
|
|
return lines.join('\n')
|
|||
|
|
}
|