hyapp-h5/scripts/check-activity-gift-challenge-aslan-assets.mjs
2026-07-18 11:13:12 +08:00

398 lines
14 KiB
JavaScript

import { createHash } from 'node:crypto';
import { readFile, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const auditLabel = 'gift-challenge-aslan-asset-audit';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDirectory, '..');
const pageDirectory = path.join(repoRoot, 'activity', 'gift-challenge');
const aslanDirectory = path.join(pageDirectory, 'aslan');
const layerDirectory = path.join(aslanDirectory, 'assets', 'layers');
const manifestPath = path.join(layerDirectory, 'manifest.json');
const localeCodes = ['en', 'ar', 'tr', 'es'];
// Keep the permanent red-line list in code as well as the manifest. Otherwise a
// later manifest edit could silently remove a root frame from its own denylist.
const canonicalForbiddenNodes = new Set([
'646:1997',
'918:6917',
'918:7827',
'918:8941',
'918:9851',
'918:10385',
]);
const forbiddenAssetName =
/(?:whole[-_ ]?page|full[-_ ]?page|page[-_ ]?shot|page[-_ ]?screenshot|root[-_ ]?frame|screenshot)/i;
function invariant(condition, message) {
if (!condition) throw new Error(`[${auditLabel}] ${message}`);
}
function pngDimensions(bytes, filename) {
invariant(
bytes.length >= 24 &&
bytes[0] === 0x89 &&
bytes.toString('ascii', 1, 4) === 'PNG',
`${filename}: expected PNG signature`
);
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20),
};
}
function jpegDimensions(bytes, filename) {
invariant(
bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8,
`${filename}: expected JPEG signature`
);
const sizeMarkers = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce,
0xcf,
]);
for (let offset = 2; offset + 8 < bytes.length; ) {
if (bytes[offset] !== 0xff) {
offset += 1;
continue;
}
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
const marker = bytes[offset];
offset += 1;
if (
marker === 0xd8 ||
marker === 0xd9 ||
marker === 0x01 ||
(marker >= 0xd0 && marker <= 0xd7)
) {
continue;
}
invariant(offset + 2 <= bytes.length, `${filename}: truncated JPEG`);
const segmentLength = bytes.readUInt16BE(offset);
invariant(segmentLength >= 2, `${filename}: invalid JPEG segment`);
if (sizeMarkers.has(marker)) {
invariant(
segmentLength >= 7 && offset + segmentLength <= bytes.length,
`${filename}: invalid JPEG size segment`
);
return {
height: bytes.readUInt16BE(offset + 3),
width: bytes.readUInt16BE(offset + 5),
};
}
offset += segmentLength;
}
throw new Error(`[${auditLabel}] ${filename}: JPEG dimensions not found`);
}
function svgDimensions(bytes, asset) {
const source = bytes.toString('utf8');
invariant(/<svg\b/i.test(source), `${asset.filename}: expected SVG root`);
const match = source.match(/viewBox=["']([^"']+)["']/i);
invariant(match, `${asset.filename}: SVG viewBox is required`);
const values = match[1]
.trim()
.split(/[\s,]+/)
.map(Number);
invariant(
values.length === 4 && values.every(Number.isFinite),
`${asset.filename}: SVG viewBox must contain four finite numbers`
);
const expected = asset.exportViewBox;
invariant(
expected,
`${asset.filename}: manifest exportViewBox is required`
);
const expectedValues = [
expected.minX,
expected.minY,
expected.width,
expected.height,
].map(Number);
invariant(
expectedValues.every(Number.isFinite) &&
values.every(
(value, index) =>
Math.abs(value - expectedValues[index]) <= 0.001
),
`${asset.filename}: SVG viewBox does not match the manifest`
);
invariant(
values[2] > 0 && values[3] > 0,
`${asset.filename}: SVG viewBox dimensions must be positive`
);
return { width: values[2], height: values[3] };
}
function fileDimensions(bytes, asset) {
if (asset.file.format === 'png') {
return pngDimensions(bytes, asset.filename);
}
if (asset.file.format === 'jpeg') {
return jpegDimensions(bytes, asset.filename);
}
if (asset.file.format === 'svg') {
return svgDimensions(bytes, asset);
}
throw new Error(
`[${auditLabel}] ${asset.filename}: unsupported file format`
);
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
invariant(manifest.schemaVersion === 1, 'unsupported manifest schema');
invariant(
manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl',
'unexpected Figma file key'
);
const manifestForbiddenNodes = new Set(
manifest.policy?.forbiddenSourceNodes || []
);
for (const node of canonicalForbiddenNodes) {
invariant(
manifestForbiddenNodes.has(node),
`${node}: permanent forbidden node is missing from manifest policy`
);
}
const maxLayerPixels = Number(manifest.policy?.maxDecorativeLayerPixels);
invariant(
Number.isFinite(maxLayerPixels) && maxLayerPixels > 0,
'maxDecorativeLayerPixels must be a positive number'
);
const assets = manifest.assets || [];
invariant(assets.length > 0, 'manifest must list exported layers');
const assetsByFile = new Map();
for (const asset of assets) {
// basename-only paths prevent a manifest entry from escaping the audited
// directory while still appearing to represent a local layer.
invariant(
typeof asset.filename === 'string' &&
path.basename(asset.filename) === asset.filename,
'every layer filename must be a basename'
);
invariant(
!assetsByFile.has(asset.filename),
`${asset.filename}: duplicate manifest entry`
);
assetsByFile.set(asset.filename, asset);
invariant(
!forbiddenAssetName.test(asset.filename),
`${asset.filename}: filename indicates a forbidden whole-page asset`
);
invariant(
/^\d+:\d+$/.test(asset.sourceNode || ''),
`${asset.filename}: source Figma node is required`
);
invariant(
!canonicalForbiddenNodes.has(asset.sourceNode) &&
!manifestForbiddenNodes.has(asset.sourceNode),
`${asset.filename}: root canvas/frame exports are forbidden`
);
invariant(
typeof asset.use === 'string' && asset.use.trim(),
`${asset.filename}: usage description is required`
);
invariant(
asset.scope === 'runtime',
`${asset.filename}: Gift Challenge layers must be runtime decoration`
);
invariant(
typeof asset.derived === 'boolean',
`${asset.filename}: derived must be explicit`
);
if (asset.derived) {
invariant(
typeof asset.derivation === 'string' && asset.derivation.trim(),
`${asset.filename}: derived assets must document their derivation`
);
}
invariant(
Number(asset.renderedDesignSize?.width) > 0 &&
Number(asset.renderedDesignSize?.height) > 0,
`${asset.filename}: positive Figma design size is required`
);
for (const field of [
'containsText',
'containsControls',
'containsDynamicBusinessMedia',
]) {
invariant(
typeof asset[field] === 'boolean',
`${asset.filename}: ${field} must be boolean`
);
invariant(
asset[field] === false,
`${asset.filename}: text, controls and dynamic business media must remain DOM/API content`
);
}
invariant(
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
`${asset.filename}: SHA-256 is required`
);
invariant(
Number.isInteger(asset.file?.bytes) && asset.file.bytes > 0,
`${asset.filename}: byte size must be a positive integer`
);
invariant(
Number(asset.file?.pixelWidth) > 0 &&
Number(asset.file?.pixelHeight) > 0,
`${asset.filename}: positive exported dimensions are required`
);
const extension = path.extname(asset.filename).toLowerCase();
invariant(
(asset.file.format === 'png' && extension === '.png') ||
(asset.file.format === 'jpeg' &&
['.jpg', '.jpeg'].includes(extension)) ||
(asset.file.format === 'svg' && extension === '.svg'),
`${asset.filename}: extension does not match manifest format`
);
const assetPath = path.join(layerDirectory, asset.filename);
const [bytes, fileInfo] = await Promise.all([
readFile(assetPath),
stat(assetPath),
]);
const dimensions = fileDimensions(bytes, asset);
invariant(
fileInfo.isFile(),
`${asset.filename}: manifest entry must point to a file`
);
invariant(
fileInfo.size === asset.file.bytes,
`${asset.filename}: byte size mismatch`
);
invariant(
Math.abs(dimensions.width - Number(asset.file.pixelWidth)) <= 0.001 &&
Math.abs(dimensions.height - Number(asset.file.pixelHeight)) <=
0.001,
`${asset.filename}: exported dimensions mismatch`
);
invariant(
dimensions.width * dimensions.height <= maxLayerPixels,
`${asset.filename}: layer exceeds the decorative pixel limit`
);
invariant(
createHash('sha256').update(bytes).digest('hex') === asset.sha256,
`${asset.filename}: SHA-256 mismatch`
);
}
const diskFiles = new Set(
(await readdir(layerDirectory)).filter(
(filename) => filename !== 'manifest.json'
)
);
invariant(
diskFiles.size === assetsByFile.size &&
[...diskFiles].every((filename) => assetsByFile.has(filename)),
'every layer file must have exactly one manifest record'
);
const [html, css, js, ...localeTexts] = await Promise.all([
readFile(path.join(pageDirectory, 'aslan.html'), 'utf8'),
readFile(path.join(aslanDirectory, 'aslan.css'), 'utf8'),
readFile(path.join(aslanDirectory, 'aslan.js'), 'utf8'),
...localeCodes.map((locale) =>
readFile(
path.join(repoRoot, 'common', 'locales', `${locale}.json`),
'utf8'
)
),
]);
const runtimeSource = `${html}\n${css}\n${js}`;
// Every runtime image reference must resolve back to this manifest. This checks
// the actual page path, not merely whether an approved collection exists on disk.
const runtimeLayerReferences = [
...runtimeSource.matchAll(
/(?:\.\/)?(?:aslan\/)?assets\/layers\/([^"')\s?#]+)/g
),
].map((match) => match[1]);
invariant(
runtimeLayerReferences.length > 0,
'page must reference audited layers'
);
for (const filename of runtimeLayerReferences) {
invariant(
assetsByFile.has(filename),
`${filename}: runtime layer reference is not present in the manifest`
);
invariant(
!forbiddenAssetName.test(filename),
`${filename}: aslan.html/runtime references a forbidden whole-page asset`
);
}
for (const asset of assets) {
invariant(
runtimeLayerReferences.includes(asset.filename),
`${asset.filename}: manifested layer is not used by the page`
);
}
const htmlImageSources = [
...html.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi),
].map((match) => match[1]);
for (const source of htmlImageSources) {
invariant(
!forbiddenAssetName.test(source),
`${source}: aslan.html references a forbidden whole-page image`
);
const layerMatch = source.match(/assets\/layers\/([^"')\s?#]+)/);
invariant(
layerMatch && assetsByFile.has(layerMatch[1]),
`${source}: every aslan.html image must be an audited layer`
);
invariant(
!canonicalForbiddenNodes.has(
assetsByFile.get(layerMatch[1]).sourceNode
),
`${source}: aslan.html image resolves to a forbidden root frame`
);
}
invariant(
html.includes('../../common/theme.css') &&
html.includes('../../common/i18n.js'),
'shared theme and i18n runtime are required'
);
const locales = localeTexts.map((source) => JSON.parse(source));
const baseKeys = Object.keys(locales[0])
.filter((key) => key.startsWith('giftChallenge.'))
.sort();
invariant(baseKeys.length >= 40, 'English Gift Challenge locale is incomplete');
const baseKeySet = new Set(baseKeys);
for (let index = 0; index < locales.length; index += 1) {
const localeCode = localeCodes[index];
const locale = locales[index];
const keys = Object.keys(locale)
.filter((key) => key.startsWith('giftChallenge.'))
.sort();
const missing = baseKeys.filter((key) => !(key in locale));
const extra = keys.filter((key) => !baseKeySet.has(key));
invariant(
missing.length === 0 && extra.length === 0,
`${localeCode}: Gift Challenge key set differs (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})`
);
for (const key of keys) {
invariant(
typeof locale[key] === 'string' && locale[key].trim(),
`${localeCode}:${key}: translation must be a non-empty string`
);
}
}
const runtimeI18nKeys = new Set(
[...runtimeSource.matchAll(/giftChallenge\.[A-Za-z0-9_.-]+/g)].map(
(match) => match[0]
)
);
for (const key of runtimeI18nKeys) {
invariant(baseKeySet.has(key), `${key}: runtime i18n key is missing`);
}
console.log(
`[${auditLabel}] passed: ${assets.length} traceable layers, ${baseKeys.length} aligned keys across ${localeCodes.length} locales`
);