// 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('') 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, }) + '
', '
' + escapeHtml(shortPath(report && report.catalogPath ? report.catalogPath : '', 96)) + '' + (version ? '' + version + '' : '') + panelNote + '
', '', '', ].join('\n') }