*/ 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 $value * @param array $errors * @return array> */ 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 $value * @param array $errors * @return array */ 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"); } }