New Branch Structure
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidThemeManifestException extends RuntimeException
|
||||
{
|
||||
/** @param array<int, string> $errors */
|
||||
public static function forPath(string $path, array $errors): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Invalid theme manifest at [%s]: %s',
|
||||
rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json',
|
||||
implode(' ', $errors),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ThemeJsonNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function forPath(string $path): self
|
||||
{
|
||||
return new self("Theme manifest [{$path}/theme.json] was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ThemeNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function forKey(string $key): self
|
||||
{
|
||||
return new self("Theme [{$key}] was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Modules\Stylist\Theme\Exceptions\ThemeJsonNotFoundException;
|
||||
|
||||
final class Json
|
||||
{
|
||||
public function __construct(private readonly Filesystem $files) {}
|
||||
|
||||
public function read(string $path): array
|
||||
{
|
||||
$manifest = rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json';
|
||||
|
||||
if (! $this->files->exists($manifest)) {
|
||||
throw ThemeJsonNotFoundException::forPath($path);
|
||||
}
|
||||
|
||||
return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
|
||||
|
||||
final class Loader
|
||||
{
|
||||
private ?Collection $themes = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Filesystem $files,
|
||||
private readonly Json $json,
|
||||
private readonly ManifestValidator $validator,
|
||||
) {}
|
||||
|
||||
public function all(): Collection
|
||||
{
|
||||
if ($this->themes !== null) {
|
||||
return $this->themes;
|
||||
}
|
||||
|
||||
$themes = collect();
|
||||
|
||||
foreach ($this->roots() as $root) {
|
||||
if (! $this->files->isDirectory($root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->files->directories($root) as $themePath) {
|
||||
try {
|
||||
$theme = $this->fromPath($themePath);
|
||||
$themes->put($theme->key(), $theme);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->themes = $themes->sortKeys();
|
||||
}
|
||||
|
||||
public function find(string $key): Theme
|
||||
{
|
||||
$theme = $this->all()->get($key);
|
||||
|
||||
if (! $theme) {
|
||||
throw ThemeNotFoundException::forKey($key);
|
||||
}
|
||||
|
||||
return $theme;
|
||||
}
|
||||
|
||||
public function fromPath(string $path): Theme
|
||||
{
|
||||
$data = $this->validator->validate($this->json->read($path), $path);
|
||||
|
||||
return new Theme(
|
||||
key: $data['key'],
|
||||
name: $data['name'],
|
||||
path: rtrim($path, DIRECTORY_SEPARATOR),
|
||||
stylesheet: $data['stylesheet'],
|
||||
script: $data['script'],
|
||||
variables: $data['variables'],
|
||||
meta: $data,
|
||||
);
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->themes = null;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function roots(): array
|
||||
{
|
||||
return array_values(array_unique(array_filter([
|
||||
(string) config('stylist.themes_path'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?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.';
|
||||
}
|
||||
$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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme;
|
||||
|
||||
use Modules\Stylist\Services\TenantThemeResolver;
|
||||
use Modules\Stylist\Services\ThemeSettings;
|
||||
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
|
||||
|
||||
final class Stylist
|
||||
{
|
||||
public function __construct(private readonly Loader $loader, private readonly TenantThemeResolver $resolver, private readonly ThemeSettings $settings) {}
|
||||
|
||||
public function current(): Theme
|
||||
{
|
||||
$requested = $this->resolver->resolve();
|
||||
try { return $this->loader->find($requested); }
|
||||
catch (ThemeNotFoundException) {
|
||||
$fallback = (string) config('stylist.default_theme', 'default');
|
||||
if ($requested !== $fallback) return $this->loader->find($fallback);
|
||||
throw ThemeNotFoundException::forKey($requested);
|
||||
}
|
||||
}
|
||||
|
||||
public function variables(): array
|
||||
{
|
||||
$theme = $this->current();
|
||||
$variant = $this->settings->variant($theme);
|
||||
$variantVars = (array) data_get($theme->variants(), $variant.'.variables', []);
|
||||
$appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []);
|
||||
$accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []);
|
||||
$settingVars = collect($this->settings->get($theme))
|
||||
->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--'))
|
||||
->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value])
|
||||
->all();
|
||||
|
||||
return array_replace(
|
||||
(array) config('stylist.fallback_variables', []),
|
||||
$this->inheritedVariables($theme),
|
||||
$variantVars,
|
||||
$appearanceVars,
|
||||
$accentVars,
|
||||
$settingVars,
|
||||
$this->resolver->variables(),
|
||||
);
|
||||
}
|
||||
|
||||
public function settings(): array { return $this->settings->get($this->current()); }
|
||||
public function variant(): string { return $this->settings->variant($this->current()); }
|
||||
public function appearance(): string { return $this->settings->appearance($this->current()); }
|
||||
public function accent(): string { return $this->settings->accent($this->current()); }
|
||||
public function all() { return $this->loader->all(); }
|
||||
|
||||
private function inheritedVariables(Theme $theme, array $seen = []): array
|
||||
{
|
||||
if (in_array($theme->key(), $seen, true)) return [];
|
||||
$seen[] = $theme->key();
|
||||
$parent = [];
|
||||
if ($theme->parent()) {
|
||||
try { $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen); }
|
||||
catch (ThemeNotFoundException) {}
|
||||
}
|
||||
return array_replace($parent, $theme->variables());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme;
|
||||
|
||||
final class Theme
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $key,
|
||||
private readonly string $name,
|
||||
private readonly string $path,
|
||||
private readonly string $stylesheet = 'css/theme.css',
|
||||
private readonly string $script = 'js/app.js',
|
||||
private readonly array $variables = [],
|
||||
private readonly array $meta = [],
|
||||
) {}
|
||||
|
||||
public function key(): string { return $this->key; }
|
||||
public function name(): string { return $this->name; }
|
||||
public function path(): string { return $this->path; }
|
||||
public function stylesheet(): string { return $this->stylesheet; }
|
||||
public function script(): string { return $this->script; }
|
||||
public function variables(): array { return $this->variables; }
|
||||
public function meta(): array { return $this->meta; }
|
||||
public function manifestVersion(): int { return (int) ($this->meta['manifest_version'] ?? 1); }
|
||||
public function version(): string { return (string) ($this->meta['version'] ?? '1.0.0'); }
|
||||
public function description(): string { return (string) ($this->meta['description'] ?? ''); }
|
||||
public function author(): string { return (string) ($this->meta['author'] ?? ''); }
|
||||
public function parent(): ?string { $parent = $this->meta['extends'] ?? null; return is_string($parent) && $parent !== '' ? $parent : null; }
|
||||
public function preview(): ?string { $preview = $this->meta['preview'] ?? null; return is_string($preview) && $preview !== '' ? $preview : null; }
|
||||
public function variants(): array { return (array) ($this->meta['variants'] ?? []); }
|
||||
public function appearances(): array { return (array) ($this->meta['appearances'] ?? []); }
|
||||
public function accents(): array { return (array) ($this->meta['accents'] ?? []); }
|
||||
public function defaultAppearance(): string { return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? ''); }
|
||||
public function defaultAccent(): string { return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? ''); }
|
||||
public function settingsSchema(): array { return (array) ($this->meta['settings'] ?? []); }
|
||||
public function seo(): array { return (array) ($this->meta['seo'] ?? []); }
|
||||
public function entryView(): string { return (string) ($this->meta['entry_view'] ?? 'home'); }
|
||||
public function capabilities(): array { return (array) ($this->meta['capabilities'] ?? []); }
|
||||
public function hasCapability(string $feature): bool { return (bool) ($this->capabilities()[$feature] ?? false); }
|
||||
public function supports(string $feature): bool { return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature)); }
|
||||
public function has(string $relativePath): bool { return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/')); }
|
||||
public function absolutePath(string $relativePath): string { return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'); }
|
||||
|
||||
/** @deprecated Theme assets are served through UrlGenerator. */
|
||||
public function assetsPath(): string { return 'stylist/theme-assets/'.$this->key; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Theme;
|
||||
|
||||
final class UrlGenerator
|
||||
{
|
||||
public function __construct(private readonly Stylist $stylist) {}
|
||||
public function asset(string $path): string { return $this->forTheme($this->stylist->current(), $path); }
|
||||
public function forTheme(Theme|string $theme, string $path): string
|
||||
{
|
||||
$key = $theme instanceof Theme ? $theme->key() : $theme;
|
||||
return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user