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 pageDirectory = path.join(root, 'coin-seller'); const assetDirectory = path.join(pageDirectory, 'assets', 'fami', 'layers'); const manifestPath = path.join(assetDirectory, 'manifest.json'); const forbiddenAggregateNodes = new Set([ '827:3011', '827:3012', '827:3027', '827:3042', '827:3057', '827:3072', '827:3087', '827:3014', '827:3022', ]); function invariant(condition, message) { if (!condition) throw new Error(message); } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); invariant(manifest.schemaVersion === 1, 'Unsupported manifest schema'); invariant( manifest.figma?.fileKey === 'OTOg5cwbhVNWxkImGmzTJl', 'Unexpected Figma file key' ); invariant( manifest.figma?.rootFrame?.id === '827:3011', 'Unexpected Figma root frame' ); invariant( manifest.figma?.designSize?.width === 1080 && manifest.figma?.designSize?.height === 2626 && manifest.figma?.designSize?.cssWidth === 360, 'Unexpected design dimensions' ); invariant( manifest.runtimeRules?.referenceScreenshotRuntimeUse === false, 'The whole-page reference screenshot must remain QA-only' ); invariant( manifest.runtimeRules?.referenceScreenshot === 'coin-seller/.audit/figma-827-3011.png', 'The QA screenshot path must remain repo-relative and outside runtime assets' ); invariant( manifest.runtimeRules?.heroMask?.sourceNode === '827:3089' && JSON.stringify(manifest.runtimeRules.heroMask.sourceStops) === JSON.stringify([65, 100]) && JSON.stringify(manifest.runtimeRules.heroMask.renderedStops) === JSON.stringify([83.8, 94.7]), 'The transformed Figma hero mask trace is required' ); invariant(Array.isArray(manifest.assets), 'Manifest assets must be an array'); invariant( manifest.assets.length === 4, 'Exactly four traced layers are allowed' ); 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( !forbiddenAggregateNodes.has(asset.sourceNode), `${asset.file}: aggregate Figma export is forbidden` ); invariant( asset.sourceName && asset.sourceType && asset.purpose, `${asset.file}: source trace and purpose are required` ); invariant( asset.bounds?.width > 0 && asset.bounds?.height > 0, `${asset.file}: design bounds are required` ); invariant( asset.original?.width > 0 && asset.original?.height > 0, `${asset.file}: original dimensions are required` ); invariant(asset.exportFormat, `${asset.file}: export format is required`); 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.containsDynamicText === false, `${asset.file}: dynamic text is forbidden in assets` ); invariant( asset.containsControls === false, `${asset.file}: rendered controls are forbidden in assets` ); invariant( asset.containsBusinessMedia === false, `${asset.file}: business media is forbidden in assets` ); invariant( asset.containsDecorativeWordmark === false || asset.sourceNode === '827:3090', `${asset.file}: only the single Figma hero image may contain its decorative wordmark` ); 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'); invariant(info.size === asset.bytes, `${asset.file}: byte size mismatch`); invariant(digest === asset.sha256, `${asset.file}: SHA-256 mismatch`); } const diskAssets = (await readdir(assetDirectory)) .filter((file) => file !== 'manifest.json') .sort(); invariant( JSON.stringify(diskAssets) === JSON.stringify([...listedFiles].sort()), 'Every runtime visual asset must have exactly one manifest entry' ); const [html, css, js, api, theme, locales] = await Promise.all([ readFile(path.join(pageDirectory, 'fami.html'), 'utf8'), readFile(path.join(pageDirectory, 'fami.css'), 'utf8'), readFile(path.join(pageDirectory, 'fami.js'), 'utf8'), readFile(path.join(root, 'common', 'api.js'), 'utf8'), readFile(path.join(root, 'common', 'theme.css'), 'utf8'), Promise.all( ['en', 'ar', 'tr', 'es', 'zh', 'id'].map((locale) => readFile( path.join(root, 'common', 'locales', `${locale}.json`), 'utf8' ) ) ), ]); const runtimeSource = [html, css, js].join('\n'); const runtimeLower = runtimeSource.toLowerCase(); for (const nodeID of forbiddenAggregateNodes) { invariant( !runtimeSource.includes(nodeID), `Runtime must not reference aggregate Figma node ${nodeID}` ); } for (const forbidden of [ 'figma-827-3011.png', '.audit/figma', 'whole-page', 'page-screenshot', 'root-frame.png', ]) { invariant( !runtimeLower.includes(forbidden), `Forbidden whole-page reference: ${forbidden}` ); } for (const asset of manifest.assets) { invariant( runtimeSource.includes(asset.file), `${asset.file}: traced asset is not consumed by the page` ); } invariant(theme.includes('--hy-theme-primary'), 'Common theme is invalid'); invariant(html.includes('../common/theme.css'), 'Common theme is required'); invariant(html.includes('../common/i18n.js'), 'Common i18n is required'); invariant(html.includes('../common/api.js'), 'Common API is required'); invariant( html.includes('data-hy-app-code="fami"'), 'Fami app identity is required' ); invariant( html.indexOf("window.HyAppDefaultAppCode = 'fami'") < html.indexOf('../common/api.js'), 'Fami app code must be configured before common API initialization' ); invariant( html.includes('