// The one visual implementation of the report.
//
// Both consumers render this exact markup: the conversation panel drops it into
// a React wrapper, and scripts/screenshot.mjs wraps the same string in a
// standalone document. That is the only way the screenshot in docs/ can honestly
// claim to show the panel — there is nothing else to render.
//
// Everything interpolated is escaped. The inputs are catalog contents and file
// paths, which routinely contain quotes and angle brackets.
import pluginCss from './client-css.cjs'
import { formatLocation, SEVERITY_LABEL } from './rules.js'
export function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function pad(number) {
return number < 10 ? '0' + number : String(number)
}
function clock(iso) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ''
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
}
function shortPath(value, max) {
const text = String(value || '')
const limit = Number.isInteger(max) ? max : 46
if (text.length <= limit) return text
return '…' + text.slice(text.length - limit + 1)
}
/** Last two segments, with the full path kept in the title attribute. */
function tailPath(value, segments) {
const keep = Number.isInteger(segments) ? segments : 2
const parts = String(value || '').split(/[\\/]+/).filter(function (part) { return part.length > 0 })
if (parts.length === 0) return String(value || '')
if (parts.length <= keep) return parts.join('\\')
return '…\\' + parts.slice(parts.length - keep).join('\\')
}
function verdictOf(report) {
if (report.counts.error > 0) return { cls: 'mc-bad', text: '有错误' }
if (report.counts.warn > 0) return { cls: 'mc-warn', text: '仅警告' }
return { cls: 'mc-ok', text: '全部通过' }
}
function diffLineClass(line) {
if (line.startsWith('@@')) return 'mc-hh'
if (line.startsWith('+++') || line.startsWith('---')) return 'mc-dh'
if (line.startsWith('+')) return 'mc-add'
if (line.startsWith('-')) return 'mc-del'
return 'mc-ctx'
}
function issueRow(issue) {
const parts = []
parts.push('
')
parts.push('
')
parts.push('' + escapeHtml(issue.rule) + ' ')
parts.push('' + escapeHtml(formatLocation(issue)) + ' ')
if (issue.fixable) parts.push('可自动修复 ')
else parts.push('需人工 ')
if (issue.entryId) parts.push('' + escapeHtml(issue.entryId) + ' ')
parts.push('
')
parts.push('
' + escapeHtml(issue.message) + '
')
if (issue.hint) parts.push('
↳ ' + escapeHtml(issue.hint) + '
')
if (issue.snippet) parts.push('
' + escapeHtml(issue.snippet) + '
')
parts.push('
')
return parts.join('')
}
function statCard(value, label, tone) {
return '' +
'' + escapeHtml(String(value)) + ' ' + escapeHtml(label) + '
'
}
function headBlock(report, options) {
const verdict = verdictOf(report)
const parts = []
parts.push('')
parts.push('
')
parts.push('
内容目录 ' +
'' + escapeHtml(verdict.text) + '
')
parts.push('
catalog.json · 金额 / 渲染 / 必填 / 图片
')
parts.push('
')
parts.push('
')
parts.push('
' +
escapeHtml(tailPath(report.projectDir)) + ' ')
parts.push('
')
parts.push('' + (report.fixes.applied ? '已写入' : 'dry-run') + ' ')
parts.push('' + escapeHtml(clock(report.at)) +
(report.durationMs > 0 ? ' · ' + escapeHtml(String(report.durationMs)) + 'ms' : '') + ' ')
parts.push('
')
parts.push('
')
if (options.buttons !== false) {
const canApply = report.fixes.count > 0 && !report.fixes.applied
parts.push('
')
parts.push('重新检查 ')
parts.push('应用修复' +
(report.fixes.count > 0 ? ' · ' + report.fixes.count + ' 处' : '') + ' ')
parts.push('
')
}
parts.push('
')
return parts.join('')
}
function statsBlock(report) {
return '' +
statCard(report.entries, '活动', '') +
statCard(report.counts.error, '错误', 'mc-bad') +
statCard(report.counts.warn, '警告', 'mc-warn') +
statCard(report.counts.info, '信息', '') +
statCard(report.fixes.count, '可自动修复', report.fixes.count > 0 ? 'mc-good' : '') +
'
'
}
function issuesBlock(report) {
if (report.issues.length === 0) return cleanBlock(report)
const parts = []
for (const severity of ['error', 'warn', 'info']) {
const group = report.issues.filter(function (issue) { return issue.severity === severity })
if (group.length === 0) continue
parts.push('')
parts.push('
' + escapeHtml(SEVERITY_LABEL[severity]) +
' ' + group.length + '
')
parts.push('
')
for (const issue of group) parts.push(issueRow(issue))
parts.push('
')
}
return parts.join('')
}
function cleanBlock(report) {
return '' +
'
' +
' ' +
'
没有发现问题 ' + escapeHtml(String(report.entries)) +
' 条活动的金额、必填、重复文案与图片路径都对得上' +
(report.render && report.render.helper && report.render.helper.defined
? ',模板里的价格都走了 money()。'
: '。') +
'
'
}
function diffBlock(report, options) {
const diff = report.fixes.diff
if (typeof diff !== 'string' || diff.length === 0) return ''
const stats = report.fixes.stats
const limit = Number.isInteger(options.maxDiffLines) ? options.maxDiffLines : 400
const lines = diff.replace(/\n$/, '').split('\n')
const shown = lines.slice(0, limit)
const parts = []
parts.push('')
parts.push('
修复 diff ' +
report.fixes.count + ' 处 · ' + stats.hunks + ' 个 hunk · +' + stats.added + '/-' + stats.removed + ' 行
')
parts.push('
')
parts.push('
' + escapeHtml(report.catalog) + ' ' +
'' +
(report.fixes.applied
? '已写回文件'
: (options.buttons === false ? 'dry-run,未写入' : 'dry-run,未写入;点「应用修复」或传 apply: true')) +
'
')
parts.push('
')
for (const line of shown) {
parts.push('
' + escapeHtml(line) + '
')
}
if (lines.length > shown.length) {
parts.push('
…还有 ' + (lines.length - shown.length) + ' 行
')
}
parts.push('
')
return parts.join('')
}
function notesBlock(report) {
const notes = Array.isArray(report.notes) ? report.notes : []
const parts = []
if (notes.length > 0) {
parts.push('')
for (const note of notes) parts.push('
注:' + escapeHtml(note) + '
')
parts.push('
')
}
if (report.applyError) {
parts.push('写入失败:' + escapeHtml(report.applyError) + '
')
}
return parts.join('')
}
function scanBlock(report) {
if (!report.render) return ''
const helper = report.render.helper || { defined: false, where: [] }
return '' +
'扫描 ' + escapeHtml(String(report.render.checkedFiles)) + ' 个模板/脚本 ' +
'· ' +
'money() ' + (helper.defined ? '已定义' : '未找到') + ' ' +
(helper.where.length > 0 ? '' + escapeHtml(helper.where[0]) + ' ' : '') +
'
'
}
function emptyPanel() {
return '' +
'
' +
'
内容目录 ' +
'未检查
' +
'
catalog.json · 金额 / 渲染 / 必填 / 图片
' +
'
重新检查 ' +
'应用修复 ' +
'
' +
'
' +
'
' +
' ' +
'
还没有检查过
' +
'
调用 mp_catalog_guard 工具,或点「重新检查」扫描当前项目目录。
' +
'
'
}
/**
* @param {any} report
* @param {{static?: boolean, maxDiffLines?: number}} [options]
*/
export function renderPanelHtml(report, options) {
const settings = options || {}
if (!report || typeof report !== 'object' || typeof report.counts !== 'object') return emptyPanel()
return '' +
headBlock(report, settings) +
statsBlock(report) +
issuesBlock(report) +
diffBlock(report, settings) +
scanBlock(report) +
notesBlock(report) +
'
'
}
/** Standalone document — no CDN, no scripts, safe to open from disk. */
export const PAGE_CSS = [
'.mc-page{margin:0;padding:32px 20px 40px;min-height:100vh;box-sizing:border-box;',
'background:var(--dsw-alias-bg-base,#f4f5f7);color:var(--dsw-alias-label-primary,#16181d);',
'font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;',
'-webkit-font-smoothing:antialiased}',
'.mc-page .mc-inner{max-width:860px;margin:0 auto;background:var(--dsw-alias-bg-layer-1,#fff);',
'border:1px solid var(--dsw-alias-border-l1,rgba(16,18,24,.1));border-radius:14px;',
'box-shadow:0 1px 2px rgba(16,18,24,.04),0 12px 32px -12px rgba(16,18,24,.12)}',
'.mc-page .mc-inner .mc-root{padding:22px 24px 24px}',
'.mc-page .mc-foot{max-width:860px;margin:12px auto 0;display:flex;gap:8px;flex-wrap:wrap;',
'font-size:11.5px;color:var(--dsw-alias-label-secondary,rgba(22,24,29,.6));padding:0 4px}',
'.mc-page .mc-foot code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
'.mc-page{--dsw-alias-bg-base:#f4f5f7;--dsw-alias-bg-layer-1:#ffffff;--dsw-alias-bg-layer-2:#f1f2f5;',
'--dsw-alias-border-l1:rgba(16,18,24,.10);--dsw-alias-border-l2:rgba(16,18,24,.18);',
'--dsw-alias-label-primary:#16181d;--dsw-alias-label-secondary:rgba(22,24,29,.62);',
'--dsw-alias-brand-primary:#3b5bdb;--dsw-alias-state-error-primary:#c92a2a;',
'--dsw-alias-state-warn-primary:#a15c00;--dsw-alias-state-success-primary:#1a7f37;',
'--mc-radius:10px}',
'.mc-page.mc-theme-dark{--dsw-alias-bg-base:#0e0f13;--dsw-alias-bg-layer-1:#16171d;',
'--dsw-alias-bg-layer-2:#1c1e25;--dsw-alias-border-l1:rgba(255,255,255,.10);',
'--dsw-alias-border-l2:rgba(255,255,255,.18);--dsw-alias-label-primary:rgba(255,255,255,.92);',
'--dsw-alias-label-secondary:rgba(255,255,255,.58);--dsw-alias-brand-primary:#8ea8ff;',
'--dsw-alias-state-error-primary:#ff7b72;--dsw-alias-state-warn-primary:#e3a008;',
'--dsw-alias-state-success-primary:#57d18a}',
'@media (prefers-color-scheme:dark){.mc-page:not(.mc-theme-light){--dsw-alias-bg-base:#0e0f13;',
'--dsw-alias-bg-layer-1:#16171d;--dsw-alias-bg-layer-2:#1c1e25;',
'--dsw-alias-border-l1:rgba(255,255,255,.10);--dsw-alias-border-l2:rgba(255,255,255,.18);',
'--dsw-alias-label-primary:rgba(255,255,255,.92);--dsw-alias-label-secondary:rgba(255,255,255,.58);',
'--dsw-alias-brand-primary:#8ea8ff;--dsw-alias-state-error-primary:#ff7b72;',
'--dsw-alias-state-warn-primary:#e3a008;--dsw-alias-state-success-primary:#57d18a}}',
].join('')
/**
* @param {any} report
* @param {{css?: string, theme?: 'auto'|'light'|'dark', version?: string, panel?: boolean,
* maxDiffLines?: number}} [options]
*/
export function renderReportPage(report, options) {
const settings = options || {}
const theme = settings.theme === 'dark' ? 'mc-theme-dark' : (settings.theme === 'light' ? 'mc-theme-light' : '')
const title = '内容目录检查 · ' + (report && report.catalog ? report.catalog : 'catalog.json')
const version = settings.version ? 'dsh-mp-catalog-guard ' + escapeHtml(settings.version) + '' : ''
const panelNote = settings.panel === true
? '这是内容目录面板的静态渲染;「重新检查 / 应用修复」只在 DSH 面板里可点。 '
: ''
return [
'',
' ',
' ',
'' + escapeHtml(title) + ' ',
'',
'',
'' + renderPanelHtml(report, {
buttons: settings.panel === true,
unclipped: true,
maxDiffLines: settings.maxDiffLines,
}) + ' ',
'',
'',
'',
].join('\n')
}