Files
Helpddesk/Modules/Stylist/Theme/ManifestValidator.php
T
2026-09-02 17:08:57 +00:00

180 lines
7.9 KiB
PHP
Executable File

<?php
namespace Modules\Stylist\Theme;
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
final class ManifestValidator
{
/** @return array<string, mixed> */
public function validate(array $manifest, string $path): array
{
$errors = [];
$manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1;
if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) {
$manifestVersion = (int) $manifestVersion;
}
if (! is_int($manifestVersion) || $manifestVersion < 1) {
$errors[] = 'The manifest_version must be a positive integer.';
}
$key = $manifest['key'] ?? $manifest['slug'] ?? basename($path);
if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
$errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.';
}
if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) {
$errors[] = 'The name must be a non-empty string.';
}
if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) {
$errors[] = 'The version must use semantic versioning, for example 1.2.0.';
}
if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) {
$errors[] = 'The type must be frontend, backend, or common.';
}
$parent = $manifest['extends'] ?? $manifest['parent'] ?? null;
if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) {
$errors[] = 'The extends/parent value must be a valid theme key.';
}
foreach (['stylesheet', 'script', 'preview'] as $field) {
if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) {
$errors[] = "The {$field} value must be a safe relative path.";
}
}
foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) {
if (isset($manifest[$field]) && ! is_array($manifest[$field])) {
$errors[] = "The {$field} value must be an object or array.";
}
}
$variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false);
$appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors);
$accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors);
$capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors);
$capabilities['appearances'] = $appearances !== [];
$capabilities['accents'] = $accents !== [];
$capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp');
$defaultAppearance = $manifest['default_appearance'] ?? null;
if ($defaultAppearance !== null) {
if (! is_string($defaultAppearance) || $defaultAppearance === '') {
$errors[] = 'The default_appearance value must be a non-empty string.';
} elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) {
$errors[] = 'The default_appearance value must be declared in appearances.';
}
}
$defaultAccent = $manifest['default_accent'] ?? null;
if ($defaultAccent !== null) {
if (! is_string($defaultAccent) || $defaultAccent === '') {
$errors[] = 'The default_accent value must be a non-empty string.';
} elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) {
$errors[] = 'The default_accent value must be declared in accents.';
}
}
if ($errors !== []) {
throw InvalidThemeManifestException::forPath($path, $errors);
}
$manifest['manifest_version'] = $manifestVersion;
unset($manifest['schema_version']);
$manifest['key'] = $key;
$manifest['slug'] = $key;
$manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString();
$manifest['extends'] = $parent;
$manifest['version'] = (string) ($manifest['version'] ?? '1.0.0');
$manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home');
$manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css';
$manifest['script'] = $manifest['script'] ?? 'js/app.js';
$manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [];
$manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : [];
$manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : [];
$manifest['capabilities'] = $capabilities;
$manifest['variants'] = $variants;
$manifest['appearances'] = $appearances;
$manifest['accents'] = $accents;
$manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : [];
$manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : [];
$manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null);
return $manifest;
}
/**
* Compact lists such as ["light", "dark"] are accepted and converted to
* keyed empty definitions. Object definitions remain unchanged.
*
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, array<string, mixed>>
*/
private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
{
if ($value === []) {
return [];
}
if (array_is_list($value)) {
if (! $allowCompact && $field === 'variants') {
// Older Theme Builder Phase 1 builds incorrectly wrote accent names
// to variants. Ignore that list while preserving object-style variants.
return [];
}
$normalised = [];
foreach ($value as $name) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
$errors[] = "Every {$field} entry must be a valid lowercase key.";
continue;
}
$normalised[$name] = [];
}
return $normalised;
}
$normalised = [];
foreach ($value as $name => $definition) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
$errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
continue;
}
$normalised[$name] = $definition;
}
return $normalised;
}
/**
* @param array<int|string, mixed> $value
* @param array<int, string> $errors
* @return array<string, bool>
*/
private function normaliseCapabilities(array $value, array &$errors): array
{
$normalised = [];
foreach ($value as $name => $enabled) {
if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
$errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
continue;
}
$normalised[$name] = $enabled;
}
return $normalised;
}
private function isSafeRelativePath(mixed $path): bool
{
return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0");
}
}