// A small JSON Schema (draft 2020-12) validator for the keywords theme.schema.json uses: type, // required, properties, additionalProperties, enum, const, pattern, min/maxLength, minimum, // maximum, items, minItems, maxItems, uniqueItems, allOf, oneOf, anyOf and local $ref (theme and // store catalog schemas). Returns a list of error paths. const typeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value); function matchesType(value, type) { if (type === 'integer') return Number.isInteger(value); if (type === 'number') return typeof value === 'number' && Number.isFinite(value); return typeOf(value) === type; } export function validate(schema, value, root = schema, path = '$') { if (schema.$ref) { const target = schema.$ref.replace(/^#\//, '').split('/').reduce((node, key) => node?.[key], root); if (!target) return [`${path}: unresolved ${schema.$ref}`]; return validate(target, value, root, path); } const errors = []; if (schema.type && ![].concat(schema.type).some(type => matchesType(value, type))) return [`${path}: expected ${schema.type}, got ${typeOf(value)}`]; if ('const' in schema && JSON.stringify(schema.const) !== JSON.stringify(value)) errors.push(`${path}: expected ${JSON.stringify(schema.const)}`); if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) errors.push(`${path}: not one of ${schema.enum.join(', ')}`); if (typeof value === 'string') { if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${path}: does not match ${schema.pattern}`); if (schema.minLength != null && [...value].length < schema.minLength) errors.push(`${path}: shorter than ${schema.minLength}`); if (schema.maxLength != null && [...value].length > schema.maxLength) errors.push(`${path}: longer than ${schema.maxLength}`); } if (typeof value === 'number') { if (schema.minimum != null && value < schema.minimum) errors.push(`${path}: below ${schema.minimum}`); if (schema.maximum != null && value > schema.maximum) errors.push(`${path}: above ${schema.maximum}`); } if (Array.isArray(value)) { if (schema.minItems != null && value.length < schema.minItems) errors.push(`${path}: fewer than ${schema.minItems} items`); if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} items`); if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) errors.push(`${path}: repeated items`); if (schema.items) value.forEach((item, index) => errors.push(...validate(schema.items, item, root, `${path}[${index}]`))); } if (typeOf(value) === 'object') { for (const key of schema.required ?? []) if (!(key in value)) errors.push(`${path}: missing ${key}`); for (const [key, item] of Object.entries(value)) { const child = schema.properties?.[key]; if (child) errors.push(...validate(child, item, root, `${path}.${key}`)); else if (schema.additionalProperties === false) errors.push(`${path}: unknown property ${key}`); } } for (const option of schema.allOf ?? []) errors.push(...validate(option, value, root, path)); if (schema.oneOf) { const passing = schema.oneOf.filter(option => validate(option, value, root, path).length === 0).length; if (passing !== 1) errors.push(`${path}: matches ${passing} of oneOf (needs exactly 1)`); } if (schema.anyOf && !schema.anyOf.some(option => validate(option, value, root, path).length === 0)) errors.push(`${path}: matches none of anyOf`); return errors; }