hyapp-h5/scripts/check-fami-layer-assets.mjs
2026-07-17 11:54:43 +08:00

138 lines
4.8 KiB
JavaScript

import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { extname, join, relative, resolve } from 'node:path';
const projectRoot = resolve(import.meta.dirname, '..');
const pageFiles = ['vip/fami.html', 'vip/fami.css', 'vip/fami.js'];
const assetRoot = join(projectRoot, 'vip/assets/fami');
const manifestPath = join(assetRoot, 'manifest.json');
const forbiddenSourceNodes = new Set([
'3639:15063', // unactivated root frame
'3639:15467', // activated root frame
'3639:15387', // complete carousel
'3639:15791', // complete carousel
'3639:15451', // complete bottom bar
'3639:15855', // complete bottom bar
]);
const forbiddenAssetName =
/(?:full[-_ ]?page|whole[-_ ]?page|page[-_ ]?shot|screenshot|figma-vip-(?:full|no-status|content-no-status))/i;
const exactSourceNodeSizes = new Map([
['3639:15349', [30.33, 27.854637]],
['3639:15355', [30.33, 27.030392]],
['3639:15376', [33, 33]],
]);
const failures = [];
const fail = (message) => failures.push(message);
const sha256 = (filePath) =>
createHash('sha256').update(readFileSync(filePath)).digest('hex');
for (const file of pageFiles) {
const absolute = join(projectRoot, file);
if (!existsSync(absolute)) {
fail(`${file}: page source is missing`);
continue;
}
const source = readFileSync(absolute, 'utf8');
if (forbiddenAssetName.test(source)) {
fail(`${file}: references a forbidden whole-page screenshot asset`);
}
}
if (!existsSync(manifestPath)) {
fail('vip/assets/fami/manifest.json: layer manifest is missing');
} else {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const files = new Set();
for (const asset of manifest.assets ?? []) {
const label = asset.file || '<missing file>';
const absolute = join(assetRoot, label);
if (!asset.file || files.has(asset.file)) {
fail(`${label}: manifest file path is missing or duplicated`);
}
files.add(asset.file);
if (forbiddenSourceNodes.has(asset.sourceNode)) {
fail(
`${label}: exported from forbidden aggregate node ${asset.sourceNode}`
);
}
if (forbiddenAssetName.test(label)) {
fail(`${label}: filename indicates a whole-page screenshot`);
}
if (
asset.containsText !== false ||
asset.containsControls !== false ||
asset.containsDynamicMedia !== false
) {
fail(
`${label}: asset embeds text, controls, or dynamic business media`
);
}
const exactSourceNodeSize = exactSourceNodeSizes.get(asset.sourceNode);
if (
exactSourceNodeSize &&
(Math.abs(
Number(asset.intrinsicSize?.width) - exactSourceNodeSize[0]
) > 0.001 ||
Math.abs(
Number(asset.intrinsicSize?.height) - exactSourceNodeSize[1]
) > 0.001)
) {
fail(
`${label}: intrinsicSize does not match the Figma source node`
);
}
if (!existsSync(absolute)) {
fail(`${label}: manifest asset is missing`);
} else if (sha256(absolute) !== asset.sha256) {
fail(`${label}: sha256 does not match the audited export`);
} else if (asset.format === 'svg') {
const source = readFileSync(absolute, 'utf8');
const viewBox = source.match(
/viewBox=["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i
);
const width = Number(viewBox?.[1]);
const height = Number(viewBox?.[2]);
if (
!viewBox ||
Math.abs(width - Number(asset.exportViewBox?.width)) > 0.001 ||
Math.abs(height - Number(asset.exportViewBox?.height)) > 0.001
) {
fail(`${label}: exportViewBox does not match the SVG file`);
}
}
}
const exportedFiles = readdirSync(assetRoot, { recursive: true })
.filter((entry) =>
['.png', '.svg', '.webp', '.jpg', '.jpeg'].includes(extname(entry))
)
.map((entry) => entry.replaceAll('\\', '/'));
for (const file of exportedFiles) {
if (!files.has(file)) {
fail(
`${relative(projectRoot, join(assetRoot, file))}: exported asset is not audited in manifest`
);
}
if (forbiddenAssetName.test(file)) {
fail(
`${file}: whole-page screenshot naming is permanently forbidden`
);
}
}
}
if (failures.length > 0) {
console.error('Fami layer audit failed:\n- ' + failures.join('\n- '));
process.exit(1);
}
console.log(
'Fami layer audit passed: only audited, independent visual layers are used.'
);