const schema = require('./schema'); function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); } function isFiniteNumber(v) { return typeof v === 'number' && Number.isFinite(v); } function validateItemFields(item, itemFields, pathLabel, errors) { if (!isPlainObject(item)) { errors.push(`${pathLabel}: Eintrag muss ein Objekt sein.`); return; } for (const field of itemFields) { validateField(item[field.key], field, `${pathLabel}.${field.key}`, errors); } } function validateField(value, field, pathLabel, errors) { switch (field.type) { case 'text': case 'textarea': case 'richtext': case 'email': case 'image': if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`); break; case 'select': if (typeof value !== 'string') errors.push(`${pathLabel}: erwartet Text.`); else if (field.options && !field.options.includes(value)) { errors.push(`${pathLabel}: unbekannter Wert "${value}".`); } break; case 'size': if (typeof value !== 'string' || !/^-?\d+(\.\d+)?(rem|px|em)$/.test(value.trim())) { errors.push(`${pathLabel}: erwartet eine CSS-Größe (z. B. "1.25rem").`); } break; case 'color': if (!isPlainObject(value)) { errors.push(`${pathLabel}: erwartet eine Farbe.`); break; } for (const channel of ['r', 'g', 'b']) { if (!isFiniteNumber(value[channel]) || value[channel] < 0 || value[channel] > 255) { errors.push(`${pathLabel}.${channel}: erwartet eine Zahl von 0 bis 255.`); } } if (!isFiniteNumber(value.a) || value.a < 0 || value.a > 1) { errors.push(`${pathLabel}.a: erwartet eine Zahl von 0 bis 1.`); } break; case 'boolean': if (typeof value !== 'boolean') errors.push(`${pathLabel}: erwartet Ja/Nein.`); break; case 'list-string': case 'list-richtext': if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) { errors.push(`${pathLabel}: erwartet eine Liste von Texten.`); } break; case 'list-object': if (!Array.isArray(value)) { errors.push(`${pathLabel}: erwartet eine Liste.`); break; } value.forEach((item, i) => validateItemFields(item, field.itemFields, `${pathLabel}[${i}]`, errors)); break; case 'group': validateItemFields(value, field.itemFields, pathLabel, errors); break; default: break; } } /** Validates a candidate content.json or theme.json payload against the * admin schema's sections for the given file ('content' or 'theme'). * Returns an array of human-readable error strings (empty = valid). */ function validateAgainstSchema(payload, fileKind) { const errors = []; if (!isPlainObject(payload)) { return ['Inhalt muss ein Objekt sein.']; } const sections = schema.filter((s) => (s.file || 'content') === fileKind); for (const section of sections) { const value = payload[section.key]; if (section.type === 'array-root') { if (!Array.isArray(value)) { errors.push(`${section.key}: erwartet eine Liste.`); continue; } value.forEach((item, i) => validateItemFields(item, section.itemFields, `${section.key}[${i}]`, errors)); } else { if (!isPlainObject(value)) { errors.push(`${section.key}: erwartet ein Objekt.`); continue; } for (const field of section.fields) { validateField(value[field.key], field, `${section.key}.${field.key}`, errors); } } } return errors; } module.exports = { validateContent: (payload) => validateAgainstSchema(payload, 'content'), validateTheme: (payload) => validateAgainstSchema(payload, 'theme'), };