180 lines
7.9 KiB
PHP
Executable File
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");
|
|
}
|
|
}
|