122 lines
5.1 KiB
JavaScript
122 lines
5.1 KiB
JavaScript
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)
|
|
}
|
|
|