import path from 'node:path' const assertOnlyFields = (value, fields, label) => { for (const field of Object.keys(value)) { if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`) } } 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 requirePositiveInteger = (value, label) => { if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`) return value } const requireIntegerInRange = (value, minimum, maximum, label) => { if (!Number.isInteger(value) || value < minimum || value > maximum) { throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`) } return value } const requireBoolean = (value, label) => { if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`) return value } const requireExact = (value, expected, label) => { if (value !== expected) throw new Error(`${label} must be ${expected}`) return value } const resolveInsideWorkspace = (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 validatePixels = (value, label) => { const pixels = requireObject(value, label) assertOnlyFields(pixels, new Set(['width', 'height']), label) requirePositiveInteger(pixels.width, `${label}.width`) requirePositiveInteger(pixels.height, `${label}.height`) } const validateAlphaAndEdge = (asset, id) => { const alpha = requireObject(asset.alpha, `${id}.alpha`) assertOnlyFields(alpha, new Set(['required', 'transparentOuterPadding', 'cornerMaxAlpha']), `${id}.alpha`) requireExact(alpha.required, true, `${id}.alpha.required`) requireIntegerInRange(alpha.transparentOuterPadding, 0, 4096, `${id}.alpha.transparentOuterPadding`) requireIntegerInRange(alpha.cornerMaxAlpha, 0, 255, `${id}.alpha.cornerMaxAlpha`) const edge = requireObject(asset.edge, `${id}.edge`) assertOnlyFields( edge, new Set(['forbidChromaResidue', 'forbidLightFringe', 'premultipliedAlphaCheck']), `${id}.edge`, ) requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`) requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`) requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`) } const validateProcessing = (processing, id) => { requireObject(processing, `${id}.processing`) const mode = requireString(processing.mode, `${id}.processing.mode`) if (mode === 'chroma-stretch') { assertOnlyFields( processing, new Set(['mode', 'capWidth', 'keyColor', 'keyTolerance', 'paletteColors', 'indexedPng']), `${id}.processing`, ) requirePositiveInteger(processing.capWidth, `${id}.processing.capWidth`) if (!/^#[A-Fa-f0-9]{6}$/.test(processing.keyColor)) { throw new Error(`${id}.processing.keyColor must be a six-digit RGB color`) } requireIntegerInRange(processing.keyTolerance, 0, 441, `${id}.processing.keyTolerance`) if (processing.paletteColors !== undefined) { requireIntegerInRange(processing.paletteColors, 2, 256, `${id}.processing.paletteColors`) } if (processing.indexedPng !== undefined) { requireBoolean(processing.indexedPng, `${id}.processing.indexedPng`) } return true } if (mode === 'opaque-resize') { assertOnlyFields(processing, new Set(['mode', 'resample', 'outputMode']), `${id}.processing`) requireExact(processing.resample, 'lanczos', `${id}.processing.resample`) requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`) return false } if (mode === 'opaque-cover-crop') { assertOnlyFields(processing, new Set(['mode', 'resample', 'anchor', 'outputMode']), `${id}.processing`) requireExact(processing.resample, 'lanczos', `${id}.processing.resample`) requireExact(processing.anchor, 'center', `${id}.processing.anchor`) requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`) return false } if (mode === 'warm-gold-frame-extract') { assertOnlyFields( processing, new Set([ 'mode', 'borderBand', 'redGreenMin', 'greenBlueMin', 'redBlueMin', 'redMaxExclusive', 'blueMaxExclusive', 'alphaOffset', 'alphaScale', 'outputMode', ]), `${id}.processing`, ) requirePositiveInteger(processing.borderBand, `${id}.processing.borderBand`) for (const field of ['redGreenMin', 'greenBlueMin', 'redBlueMin', 'alphaOffset']) { requireIntegerInRange(processing[field], 0, 255, `${id}.processing.${field}`) } for (const field of ['redMaxExclusive', 'blueMaxExclusive']) { requireIntegerInRange(processing[field], 1, 256, `${id}.processing.${field}`) } requirePositiveInteger(processing.alphaScale, `${id}.processing.alphaScale`) requireExact(processing.outputMode, 'RGBA', `${id}.processing.outputMode`) return true } throw new Error(`${id} has unsupported processing.mode: ${mode}`) } export const validateAssetBuildManifest = (manifest, workspace) => { requireObject(manifest, 'manifest') assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'family', 'assets']), 'manifest') if (manifest.schemaVersion !== 3) throw new Error('schemaVersion must be 3') if (manifest.kind !== 'asset-build-manifest') throw new Error('kind must be asset-build-manifest') requireString(manifest.family, 'family') if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) { throw new Error('assets must be a non-empty array') } const ids = new Set() const outputs = new Set() for (const asset of manifest.assets) { requireObject(asset, 'asset') const processing = requireObject(asset.processing, 'asset.processing') const mode = requireString(processing.mode, 'asset.processing.mode') const transparent = ['chroma-stretch', 'warm-gold-frame-extract'].includes(mode) const fields = new Set(['id', 'source', 'output', 'sourcePixels', 'outputPixels', 'quality', 'processing']) if (transparent) { fields.add('alpha') fields.add('edge') } assertOnlyFields(asset, fields, 'asset') const id = requireString(asset.id, 'asset.id') if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`) ids.add(id) resolveInsideWorkspace(workspace, asset.source, `${id}.source`) resolveInsideWorkspace(workspace, asset.output, `${id}.output`) if (outputs.has(asset.output)) throw new Error(`duplicate output: ${asset.output}`) outputs.add(asset.output) validatePixels(asset.sourcePixels, `${id}.sourcePixels`) validatePixels(asset.outputPixels, `${id}.outputPixels`) const processingNeedsAlpha = validateProcessing(processing, id) if (processingNeedsAlpha) validateAlphaAndEdge(asset, id) const quality = requireObject(asset.quality, `${id}.quality`) assertOnlyFields(quality, new Set(['maxBytes', 'colorSpace']), `${id}.quality`) requirePositiveInteger(quality.maxBytes, `${id}.quality.maxBytes`) requireExact(quality.colorSpace, 'sRGB', `${id}.quality.colorSpace`) } return manifest }