Files
mcn-short-video/project/短视频脚本创作/V1.0/subskills/lieflat-charts/scripts/validate.mjs
T
maogeigei cad356047c 工作台视频列表页新增点赞/评论趋势折线图(lieflat-charts G8 骨架)
- 引入 lieflat-charts 第三方图表技能到 subskills/(SKILL.md+catalog+templates,已删嵌套 .git)
- ECharts 6.1.0 本地化至 mcn-work-shop/public/vendor/(零 CDN 依赖,离线可用)
- 账号详情视频列表 tab 上方新增趋势图:X=发布时间升序,点赞/评论双序列面积折线
  (G8 Rainfall 骨架:双 grid 独立量级、同 X 联动 pointer、tipLight tooltip、quarticOut 入场)
- 全量视频数据单独拉取(pageSize=500,不随分页变化);numOf 解析 'x.x万' 文本
- V1.0/SKILL.md 登记 lieflat-charts 外部技能依赖
2026-09-02 11:09:12 +08:00

323 lines
11 KiB
JavaScript
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.
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const requiredColorTemplates = [
'templates/color/README.md',
'templates/color/basics-palm.html',
'templates/color/basics-porcelain.html',
'templates/color/basics-wire.html',
'templates/color/big-circular-palm.html',
'templates/color/big-circular-porcelain.html',
'templates/color/big-force-palm.html',
'templates/color/big-force-porcelain.html',
'templates/color/big-threads-palm.html',
'templates/color/big-threads-porcelain.html',
'templates/color/glance-palm.html',
'templates/color/glance-porcelain.html',
'templates/color/glance-wire.html',
'templates/color/lupi-palm.html',
'templates/color/lupi-porcelain.html',
'templates/color/lupi-wire.html',
'templates/color/maps-palm.html',
'templates/color/maps-porcelain.html',
'templates/color/maps-wire.html',
];
const required = [
'README.md',
'LICENSE',
'SKILL.md',
'catalog.md',
'mono-tokens.js',
'color-presets.js',
'agents/openai.yaml',
'report-catalog.md',
'templates/reports/index.html',
...requiredColorTemplates,
];
for (let i = 1; i <= 12; i += 1) {
const n = String(i).padStart(2, '0');
required.push(`templates/reports/report-${n}.zh.html`);
required.push(`templates/reports/report-${n}.en.html`);
required.push(`docs/assets/reports/report-${n}.png`);
}
const failures = [];
const htmlFiles = [];
const ignoredDirectories = new Set(['.git', '.playwright-cli', 'node_modules', 'output']);
const paletteSuffixes = ['gallery', 'porcelain', 'palm', 'wire'];
// 每组 gallery 必须带齐本组图型容器与编号区间;四个色板各一份。
const galleryGroups = {
basics: {
label: 'Basics',
range: /F1–F17|17 张/,
ids: ['treemap', 'histo', 'boxplot', 'stream', 'candle'],
},
lupi: {
label: 'Lupi',
// mono gallery 用「20 张」,色板版用「L1–L20」,两种写法都算已更新。
range: /L1–L20|20 张/,
ids: ['matheat', 'calheat', 'beeswarm', 'ridge', 'parallel'],
},
glance: {
label: 'Glance',
range: /22 张|Glance 22/,
ids: ['violin', 'gheat', 'rankstrip', 'sankey'],
},
maps: {
label: 'Maps',
ids: ['mapus', 'mapworld'],
},
};
function galleryPath(group, suffix) {
return suffix === 'gallery'
? `templates/${group}-gallery.html`
: `templates/color/${group}-${suffix}.html`;
}
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirectories.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.html')) htmlFiles.push(full);
}
}
function rel(file) {
return path.relative(root, file);
}
function lineNumber(source, index) {
return source.slice(0, index).split('\n').length;
}
function colorChannels(literal) {
if (literal.startsWith('#')) {
const hex = literal.slice(1);
const full = hex.length === 3
? [...hex].map(channel => channel + channel).join('')
: hex.slice(0, 6);
return [0, 2, 4].map(index => Number.parseInt(full.slice(index, index + 2), 16)).join(',');
}
const channels = literal.match(/\d+/g);
return channels?.slice(0, 3).join(',') ?? null;
}
function collectPresetChannels(value, channels = new Set()) {
if (typeof value === 'string') {
const literalPattern = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/g;
for (const match of value.matchAll(literalPattern)) {
const parsed = colorChannels(match[0]);
if (parsed) channels.add(parsed);
}
} else if (Array.isArray(value)) {
for (const item of value) collectPresetChannels(item, channels);
} else if (value && typeof value === 'object') {
for (const item of Object.values(value)) collectPresetChannels(item, channels);
}
return channels;
}
for (const file of required) {
if (!fs.existsSync(path.join(root, file))) failures.push(`缺少发布文件:${file}`);
}
for (const [group, spec] of Object.entries(galleryGroups)) {
for (const suffix of paletteSuffixes) {
const file = galleryPath(group, suffix);
const full = path.join(root, file);
if (!fs.existsSync(full)) {
failures.push(`缺少 gallery 模板:${file}`);
continue;
}
const source = fs.readFileSync(full, 'utf8');
for (const id of spec.ids) {
if (!source.includes(`id="${id}"`)) failures.push(`${file} 缺少图型容器 id="${id}"`);
}
if (spec.range && !spec.range.test(source)) {
failures.push(`${file} 的 ${spec.label} 编号未更新(应匹配 ${spec.range})`);
}
}
}
const catalogSource = fs.readFileSync(path.join(root, 'catalog.md'), 'utf8');
const catalogRows = [
'| F13 | Nested Treemap |',
'| F14 | Rung Histogram |',
'| F15 | Tick Box |',
'| F16 | Stream Ribbon |',
'| F17 | Candlestick |',
'| L16 | Matrix Heat |',
'| L17 | Calendar Heat |',
'| L18 | Beeswarm |',
'| L19 | Ridgeline |',
'| L20 | Parallel Coordinates |',
'| G19 | Violin |',
'| G20 | Matrix Heat (Glance) |',
'| G21 | Rank Strip |',
'| G22 | Aggregate Sankey |',
'| M1 | US Choropleth |',
'| M2 | World Choropleth |',
];
for (const row of catalogRows) {
if (!catalogSource.includes(row)) failures.push(`catalog.md 缺少条目 ${row.replaceAll('|', '').trim()}`);
}
const skillSource = fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8');
// 主力/后备分档是硬约束,别在后续改动里被顺手删掉。
if (!skillSource.includes('L1–L15 与 F1–F13')) {
failures.push('SKILL.md 缺少主力(L1–L15 / F1–F13)优先规则');
}
for (const backup of ['F17 Candlestick', 'F15 Tick Box', 'L20 Parallel Coordinates', 'L17 Calendar Heat', 'F16 Stream Ribbon']) {
if (!skillSource.includes(backup)) failures.push(`SKILL.md 缺少后备图例外说明:${backup}`);
}
if (!catalogSource.includes('主力与后备')) failures.push('catalog.md 缺少主力/后备说明');
for (const role of ['`BG`', '`TXT`', '`MUT`', '`GRID`', '`DATA`']) {
if (!skillSource.includes(role)) failures.push(`SKILL.md 的 custom 色板规则缺少角色 ${role}`);
}
const reportCatalogSource = fs.readFileSync(path.join(root, 'report-catalog.md'), 'utf8');
for (let i = 1; i <= 12; i += 1) {
const n = String(i).padStart(2, '0');
if (!reportCatalogSource.includes(`| R${n} |`)) failures.push(`report-catalog.md 缺少 R${n}`);
for (const language of ['zh', 'en']) {
const file = path.join(root, `templates/reports/report-${n}.${language}.html`);
const source = fs.readFileSync(file, 'utf8');
if (!source.includes('<!doctype html>')) failures.push(`${rel(file)} 不是完整 HTML 文档`);
if (!source.match(/<title>[\s\S]+<\/title>/i)) failures.push(`${rel(file)} 缺少 title`);
}
}
const presetChannels = new Map();
const colorPresetsPath = path.join(root, 'color-presets.js');
if (fs.existsSync(colorPresetsPath)) {
try {
const context = vm.createContext({});
new vm.Script(fs.readFileSync(colorPresetsPath, 'utf8'), {
filename: 'color-presets.js',
}).runInContext(context);
const presets = context.PRESETS;
const expected = [
['porcelain', 'PORCELAIN'],
['palm', 'PALM'],
['wire', 'WIRE'],
];
if (!presets || typeof presets.get !== 'function') {
failures.push('color-presets.js 未正确暴露 PRESETS.get()');
} else {
for (const [name, key] of expected) {
const preset = presets[key];
if (!preset || presets.get(name) !== preset) {
failures.push(`color-presets.js 缺少可用预设:${name}`);
continue;
}
presetChannels.set(name, collectPresetChannels(preset));
}
}
} catch (error) {
failures.push(error.message);
}
}
walk(root);
for (const file of htmlFiles) {
const source = fs.readFileSync(file, 'utf8');
const scriptPattern = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
let match;
let scriptIndex = 0;
while ((match = scriptPattern.exec(source))) {
scriptIndex += 1;
if (!match[1].trim()) continue;
try {
new vm.Script(match[1], { filename: `${rel(file)}#script-${scriptIndex}` });
} catch (error) {
failures.push(error.message);
}
}
const seenIds = new Map();
const idPattern = /\sid=(["'])([^"']+)\1/g;
while ((match = idPattern.exec(source))) {
const id = match[2];
const line = lineNumber(source, match.index);
if (seenIds.has(id)) {
failures.push(`${rel(file)}:${line} 重复 id="${id}"(首次出现于第 ${seenIds.get(id)} 行)`);
} else {
seenIds.set(id, line);
}
}
}
const textFiles = [];
function collectText(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirectories.has(entry.name) || entry.name === 'LICENSE') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) collectText(full);
else if (/\.(?:html|js|mjs)$/.test(entry.name)) textFiles.push(full);
}
}
collectText(root);
for (const file of textFiles) {
const source = fs.readFileSync(file, 'utf8');
const relative = rel(file);
const executableSource = relative === 'scripts/validate.mjs'
? ''
: source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const match of executableSource.matchAll(/Math\.random\s*\(/g)) {
failures.push(`${rel(file)} 检测到 Math.random(),请改用确定性 rnd()`);
}
const colorTemplate = relative.match(/^templates\/color\/.+-(porcelain|palm|wire)\.html$/);
if (colorTemplate) {
const allowed = presetChannels.get(colorTemplate[1]);
const literalPattern = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/g;
for (const match of source.matchAll(literalPattern)) {
const channels = colorChannels(match[0]);
const transparentHitTarget = channels === '0,0,0';
if (allowed && channels && !allowed.has(channels) && !transparentHitTarget) {
failures.push(`${relative}:${lineNumber(source, match.index)} 颜色 ${match[0]} 不属于 ${colorTemplate[1]} 预设`);
}
}
} else if (relative !== 'color-presets.js' && !relative.startsWith('templates/reports/')) {
for (const match of source.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
const hex = match[0];
const channels = [
Number.parseInt(hex.slice(1, 3), 16),
Number.parseInt(hex.slice(3, 5), 16),
Number.parseInt(hex.slice(5, 7), 16),
];
if (Math.max(...channels) - Math.min(...channels) > 18) {
failures.push(`${relative}:${lineNumber(source, match.index)} 检测到明显彩色值 ${hex}`);
}
}
}
}
try {
new vm.Script(fs.readFileSync(path.join(root, 'mono-tokens.js'), 'utf8'), {
filename: 'mono-tokens.js',
});
} catch (error) {
failures.push(error.message);
}
if (failures.length) {
console.error(`检查失败(${failures.length} 项):`);
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}
console.log(`检查通过:${htmlFiles.length} 个 HTML 文件,${textFiles.length} 个文本文件。`);