138 lines
4.8 KiB
JavaScript
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.'
|
|
);
|