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

273 lines
9.0 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 scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(scriptDirectory, '..');
const centersRoot = path.join(root, 'guild', 'fami');
const sharedRoot = path.join(root, 'guild', 'products', 'fami', 'centers');
const assetDirectory = path.join(sharedRoot, 'assets', 'fami');
const manifestPath = path.join(assetDirectory, 'manifest.json');
const pageNames = ['host-center', 'agency-center', 'bd-center', 'wallet'];
const languages = ['en', 'ar', 'tr', 'es'];
const keyPattern =
/["'`]((?:fami|contact_card|host_center|agency_center|bd_center|withdraw_exchange)\.[A-Za-z0-9_.-]+)["'`]/g;
function invariant(condition, message) {
if (!condition) throw new Error(message);
}
function dimensions(file, bytes) {
if (file.endsWith('.png')) {
invariant(
bytes.subarray(1, 4).toString('ascii') === 'PNG',
`${file}: invalid PNG signature`
);
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20),
};
}
const source = bytes.toString('utf8');
const viewBox = source.match(/viewBox=["']([^"']+)["']/i)?.[1];
invariant(viewBox, `${file}: SVG viewBox is required`);
const values = viewBox.trim().split(/\s+/).map(Number);
return { width: values[2], height: values[3] };
}
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
const entryFrames = new Set([
'3843:5032',
'3843:5157',
'3843:5291',
'3843:5388',
'3843:5462',
'3843:5586',
]);
invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema');
invariant(
manifest.figma?.fileKey === 'vaDp5wwn6AOQVlt9zk0f9r',
'Unexpected Figma file key'
);
invariant(manifest.figma?.page?.id === '1183:578', 'Unexpected Figma page');
invariant(
new Set(Object.values(manifest.figma?.entryFrames || {})).size === 6 &&
Object.values(manifest.figma.entryFrames).every((id) =>
entryFrames.has(id)
),
'Center entry-frame mapping changed'
);
invariant(
Array.isArray(manifest.assets) && manifest.assets.length === 22,
'Manifest must describe all 22 Center assets'
);
const listedFiles = new Set();
for (const asset of manifest.assets) {
invariant(
asset.file && !listedFiles.has(asset.file),
`Duplicate asset ${asset.file}`
);
listedFiles.add(asset.file);
invariant(asset.sourceNode, `${asset.file}: sourceNode is required`);
invariant(
!entryFrames.has(asset.sourceNode),
`${asset.file}: root-frame export is forbidden`
);
invariant(
asset.sourceName && asset.sourceType && asset.purpose,
`${asset.file}: source metadata is required`
);
invariant(
asset.bounds?.width > 0 && asset.bounds?.height > 0,
`${asset.file}: dimensions are required`
);
invariant(
!(asset.bounds.width >= 375 && asset.bounds.height >= 812),
`${asset.file}: full-screen aggregate assets are forbidden`
);
invariant(
Number.isInteger(asset.bytes) && asset.bytes > 0,
`${asset.file}: byte size is required`
);
invariant(
/^[a-f0-9]{64}$/.test(asset.sha256 || ''),
`${asset.file}: SHA-256 is required`
);
invariant(!asset.containsText, `${asset.file}: rendered text is forbidden`);
invariant(
!asset.containsControls,
`${asset.file}: rendered controls are forbidden`
);
invariant(
!asset.containsDynamicMedia || asset.testOnly === true,
`${asset.file}: dynamic media must be an explicit test-only fixture`
);
const assetPath = path.join(assetDirectory, asset.file);
const [bytes, info] = await Promise.all([
readFile(assetPath),
stat(assetPath),
]);
const digest = createHash('sha256').update(bytes).digest('hex');
const actualBounds = dimensions(asset.file, bytes);
invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`);
invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`);
invariant(
actualBounds.width === asset.bounds.width &&
actualBounds.height === asset.bounds.height,
`${asset.file}: dimensions mismatch`
);
}
const diskAssets = (await readdir(assetDirectory))
.filter((file) => file !== 'manifest.json')
.sort();
invariant(
JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()),
'Every Center asset must have exactly one manifest entry'
);
const pageSources = await Promise.all(
pageNames.map(async (pageName) => {
const pageRoot = path.join(centersRoot, pageName);
const [html, script, style] = await Promise.all([
readFile(path.join(pageRoot, 'index.html'), 'utf8'),
readFile(path.join(pageRoot, 'script.js'), 'utf8'),
readFile(path.join(pageRoot, 'style.css'), 'utf8'),
]);
invariant(
html.includes('data-hy-app-code="fami"'),
`${pageName}: Fami app identity is missing`
);
const contextIndex = html.indexOf(
'../../packages/core/product-context.js'
);
const productIndex = html.indexOf('../../products/fami/product.js');
const apiIndex = html.indexOf('../../../common/api.js');
invariant(
contextIndex >= 0 &&
productIndex > contextIndex &&
apiIndex > productIndex,
`${pageName}: product context must run before common/api.js`
);
invariant(
html.includes('../../products/fami/centers/fami.css') &&
html.includes('../../products/fami/centers/fami.js'),
`${pageName}: shared Fami Center runtime is missing`
);
invariant(
!/<img[^>]+src=["'][^"']*(?:host|agency|bd)-avatar\.png/i.test(
html
),
`${pageName}: a Figma sample avatar is wired into production DOM`
);
return { pageName, html, script, style };
})
);
const sharedSource = await readFile(path.join(sharedRoot, 'fami.js'), 'utf8');
const commonParamsSource = await readFile(
path.join(root, 'common', 'params.js'),
'utf8'
);
const runtimeSource = [
sharedSource,
...(await Promise.all(
['fami.css'].map((file) =>
readFile(path.join(sharedRoot, file), 'utf8')
)
)),
...pageSources.flatMap(({ html, script, style }) => [html, script, style]),
].join('\n');
for (const forbidden of [
'/gonghui/',
'/guide/fami/',
'withdraw-exchange',
'unhost-center',
'superadmin-center',
]) {
invariant(
!runtimeSource.includes(forbidden),
`Forbidden Center runtime path/reference: ${forbidden}`
);
}
invariant(
sharedSource.includes("(isMock() ? String(fallback || '').trim() : '')"),
'Figma avatar fallback must be gated by explicit mock mode'
);
invariant(
sharedSource.includes("params.get('mock') === '1'"),
'Mock mode must require an explicit query flag'
);
invariant(
commonParamsSource.includes("String(window.HyAppDefaultAppCode || '')"),
'Common params must preserve the page-level Fami app code default'
);
const pageScripts = pageSources.map(({ script }) => script).join('\n');
const statsCalls = [...pageScripts.matchAll(/\.stats\s*\(\s*/g)];
invariant(statsCalls.length === 7, 'Expected seven Center stats calls');
for (const call of statsCalls) {
invariant(
pageScripts
.slice(call.index + call[0].length)
.trimStart()
.startsWith('{'),
'Every Center stats call must receive an object query'
);
}
const walletScript = pageSources.find(
({ pageName }) => pageName === 'wallet'
).script;
invariant(
walletScript.includes('.withdrawUSDT({'),
'Wallet must use pointWallet.withdrawUSDT'
);
invariant(
!/\.withdraw\s*\(/.test(walletScript),
'Legacy pointWallet.withdraw is forbidden'
);
invariant(
walletScript.includes('saveWithdrawAddress({ usdt_trc20_address: value })'),
'Saved TRC20 address must use the current wallet contract field'
);
const usedKeys = new Set();
for (const match of runtimeSource.matchAll(keyPattern)) usedKeys.add(match[1]);
invariant(
usedKeys.size === 100,
`Expected 100 Center i18n keys, found ${usedKeys.size}`
);
for (const language of languages) {
const locale = JSON.parse(
await readFile(
path.join(
root,
'guild',
'products',
'fami',
'locales',
`${language}.json`
),
'utf8'
)
);
const missing = [...usedKeys].filter((key) => !(key in locale));
invariant(
missing.length === 0,
`${language}: missing Center locale keys: ${missing.join(', ')}`
);
}
console.log(
`guild/fami Center audit passed (${pageNames.length} entries, ${manifest.assets.length} traced layers, ${usedKeys.size} locale keys)`
);