import { readFile } from 'node:fs/promises' import path from 'node:path' const allowedAssetClasses = new Set([ 'code-native', 'fixed-bitmap', 'transparent-overlay', 'nine-slice', 'tile-texture' ]) const requireObject = (value, label) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must be an object`) } return value } const requireString = (value, label) => { if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`) return value } const requirePositiveNumber = (value, label) => { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw new Error(`${label} must be a positive number`) } return value } const resolveWorkspacePath = (workspace, relativePath, label) => { requireString(relativePath, label) const absolutePath = path.resolve(workspace, relativePath) const relative = path.relative(workspace, absolutePath) if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`${label} escapes workspace: ${relativePath}`) } return absolutePath } const requireBoolean = (value, label) => { if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`) return value } export const validateManifest = (manifest, workspace) => { requireObject(manifest, 'manifest') if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2') requireString(manifest.page, 'page') requireObject(manifest.runtime, 'runtime') requireString(manifest.runtime.url, 'runtime.url') requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort') if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array') const ids = new Set() for (const asset of manifest.assets) { requireObject(asset, 'asset') const id = requireString(asset.id, 'asset.id') if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`) ids.add(id) if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`) resolveWorkspacePath(workspace, asset.source, `${id}.source`) resolveWorkspacePath(workspace, asset.output, `${id}.output`) const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`) const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`) const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`) const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`) const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`) const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`) const render = requireObject(asset.render, `${id}.render`) requireString(render.scalePolicy, `${id}.render.scalePolicy`) requireString(render.uniMode, `${id}.render.uniMode`) requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`) if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') { throw new Error(`${id} uniform-only asset cannot use scaleToFill`) } const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1) if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) { throw new Error(`${id} ratio drift exceeds 0.5%`) } const alpha = requireObject(asset.alpha, `${id}.alpha`) requireBoolean(alpha.required, `${id}.alpha.required`) requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`) if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) { throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`) } const edge = requireObject(asset.edge, `${id}.edge`) requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`) requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`) requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`) const quality = requireObject(asset.quality, `${id}.quality`) requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`) requireString(quality.colorSpace, `${id}.quality.colorSpace`) const processing = requireObject(asset.processing, `${id}.processing`) requirePositiveNumber(processing.trim, `${id}.processing.trim`) requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`) const runtime = requireObject(asset.runtime, `${id}.runtime`) requireString(runtime.selector, `${id}.runtime.selector`) requireString(runtime.imageSelector, `${id}.runtime.imageSelector`) if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) { throw new Error(`${id}.consumers must be a non-empty array`) } for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`) } return manifest } export const loadAndValidateManifest = async (filePath, workspace) => { const manifest = JSON.parse(await readFile(filePath, 'utf8')) return validateManifest(manifest, workspace) }