New Branch Structure

This commit is contained in:
Mark Gregory
2026-07-23 17:23:57 +01:00
commit 55bf5ea995
206 changed files with 23782 additions and 0 deletions
@@ -0,0 +1,42 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Support\Facades\Auth;
final class TenantThemeResolver
{
public function resolve(): string
{
$column = config('stylist.tenant_theme_column', 'theme_key');
$user = Auth::user();
$company = $user?->company ?? null;
if ($company && filled($company->{$column} ?? null)) {
return (string) $company->{$column};
}
if (function_exists('tenant')) {
$tenant = tenant();
if ($tenant && filled($tenant->{$column} ?? null)) {
return (string) $tenant->{$column};
}
}
return config('stylist.default_theme', 'default');
}
public function variables(): array
{
$variables = config('stylist.fallback_variables', []);
$company = Auth::user()?->company ?? null;
foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) {
if ($company && filled($company->{$column} ?? null)) {
$variables[$variable] = (string) $company->{$column};
}
}
return $variables;
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeAccessibilityValidator
{
public function __construct(private readonly Filesystem $files) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
foreach ($this->files->allFiles($theme->path().'/views') as $file) {
$content = $file->getContents();
$relative = $file->getRelativePathname();
if (preg_match_all('/<img\b(?![^>]*\balt=)[^>]*>/i', $content, $matches)) {
$issues[] = ['severity' => 'error', 'message' => count($matches[0]).' image(s) missing alt text.', 'file' => $relative];
}
if (preg_match('/<html\b(?![^>]*\blang=)/i', $content)) {
$issues[] = ['severity' => 'warning', 'message' => 'HTML element is missing a lang attribute.', 'file' => $relative];
}
if (preg_match_all('/<button\b[^>]*>\s*(?:<[^>]+>\s*)*<\/button>/is', $content, $matches)) {
$issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative];
}
}
return $issues;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Theme;
final class ThemeOptimizer
{
public function __construct(private readonly Filesystem $files) {}
public function optimize(Theme $theme): array
{
$result = ['css' => 0, 'js' => 0, 'images' => 0];
foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) {
$directory = $theme->path().'/'.$folder;
if (! $this->files->isDirectory($directory)) continue;
foreach ($this->files->files($directory) as $file) {
if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) continue;
$content = $file->getContents();
$content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content;
$content = preg_replace('/\s+/', ' ', $content) ?? $content;
$content = preg_replace('/\s*([{}:;,>])\s*/', '$1', $content) ?? $content;
$target = $file->getPath().'/'.$file->getBasename('.'.$type).'.min.'.$type;
$this->files->put($target, trim($content));
$result[$type]++;
}
}
if (function_exists('imagewebp')) {
foreach (['images', 'icons'] as $folder) {
$directory = $theme->path().'/'.$folder;
if (! $this->files->isDirectory($directory)) continue;
foreach ($this->files->files($directory) as $file) {
$ext = strtolower($file->getExtension());
if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) continue;
$image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname());
if ($image) { imagepalettetotruecolor($image); imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82); imagedestroy($image); $result['images']++; }
}
}
}
return $result;
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
use RuntimeException;
use ZipArchive;
final class ThemePackageManager
{
public function __construct(private readonly Filesystem $files, private readonly Loader $loader, private readonly ThemeValidator $validator) {}
public function install(UploadedFile $upload): Theme
{
if (strtolower($upload->getClientOriginalExtension()) !== 'zip') throw new RuntimeException('Only ZIP theme packages may be installed.');
$temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8)));
$this->files->ensureDirectoryExists($temporary);
$zip = new ZipArchive();
if ($zip->open($upload->getRealPath()) !== true) throw new RuntimeException('The uploaded ZIP could not be opened.');
for ($i=0; $i<$zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) { $zip->close(); throw new RuntimeException('The ZIP contains an unsafe path.'); }
}
$zip->extractTo($temporary); $zip->close();
$manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json');
if (! $manifest) { $this->files->deleteDirectory($temporary); throw new RuntimeException('The package does not contain theme.json.'); }
$source = $manifest->getPath();
$theme = $this->loader->fromPath($source);
if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
$issues = $this->validator->validate($theme);
if (collect($issues)->contains('severity', 'error')) throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
$target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
if ($this->files->isDirectory($target)) $this->files->deleteDirectory($target);
$this->files->copyDirectory($source, $target);
$this->files->deleteDirectory($temporary);
$this->loader->flush();
return $this->loader->find($theme->key());
}
public function uninstall(Theme $theme): void
{
$projectRoot = realpath((string) config('stylist.themes_path'));
$themePath = realpath($theme->path());
if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) throw new RuntimeException('Bundled themes cannot be uninstalled.');
if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) throw new RuntimeException('This theme has installed child themes.');
$this->files->deleteDirectory($themePath); $this->loader->flush();
}
public function export(Theme $theme): string
{
$directory = storage_path('app/stylist/exports'); $this->files->ensureDirectoryExists($directory);
$path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip';
$zip = new ZipArchive(); if ($zip->open($path, ZipArchive::CREATE|ZipArchive::OVERWRITE) !== true) throw new RuntimeException('Unable to create theme archive.');
foreach ($this->files->allFiles($theme->path()) as $file) $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
$zip->close(); return $path;
}
}
@@ -0,0 +1,80 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Database\QueryException;
use Modules\Stylist\Models\ThemeSetting;
use Modules\Stylist\Theme\Theme;
final class ThemeSettings
{
private const APPEARANCE_KEY = '__stylist_appearance';
private const ACCENT_KEY = '__stylist_accent';
public function get(Theme $theme): array
{
$stored = $this->stored($theme);
$defaults = collect($theme->settingsSchema())
->mapWithKeys(fn ($definition, $key) => [$key => is_array($definition) ? ($definition['default'] ?? null) : $definition])
->all();
return array_replace($defaults, array_diff_key((array) ($stored?->settings ?? []), array_flip([self::APPEARANCE_KEY, self::ACCENT_KEY])));
}
public function variant(Theme $theme): string
{
$variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light');
return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light');
}
public function appearance(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->appearances());
if ($allowed === []) return '';
$appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
}
public function accent(Theme $theme): string
{
$stored = (array) ($this->stored($theme)?->settings ?? []);
$allowed = array_keys($theme->accents());
if ($allowed === []) return '';
$accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
}
public function save(Theme $theme, array $settings, string $variant, ?string $appearance = null, ?string $accent = null): ThemeSetting
{
$settings = array_intersect_key($settings, array_flip(array_keys($theme->settingsSchema())));
$existing = (array) ($this->stored($theme)?->settings ?? []);
$allowedAppearances = array_keys($theme->appearances());
if ($allowedAppearances !== []) {
$appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
if (! in_array($appearance, $allowedAppearances, true)) $appearance = (string) array_key_first($theme->appearances());
$settings[self::APPEARANCE_KEY] = $appearance;
}
$allowedAccents = array_keys($theme->accents());
if ($allowedAccents !== []) {
$accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent());
if (! in_array($accent, $allowedAccents, true)) $accent = (string) array_key_first($theme->accents());
$settings[self::ACCENT_KEY] = $accent;
}
if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) $variant = array_key_first($theme->variants());
return ThemeSetting::query()->updateOrCreate(
['theme_key' => $theme->key()],
['settings' => $settings, 'variant' => $variant]
);
}
private function stored(Theme $theme): ?ThemeSetting
{
try { return ThemeSetting::query()->where('theme_key', $theme->key())->first(); }
catch (QueryException) { return null; }
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
final class ThemeValidator
{
public function __construct(
private readonly Filesystem $files,
private readonly Loader $loader,
private readonly ThemeAccessibilityValidator $accessibility,
) {}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validatePath(string $path): array
{
$theme = $this->loader->fromPath($path);
return $this->validate($theme);
}
/** @return array<int, array{severity:string,message:string,file:string}> */
public function validate(Theme $theme): array
{
$issues = [];
$entryView = 'views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
if (! $theme->has($entryView)) {
$issues[] = [
'severity' => 'error',
'message' => "Entry view [{$theme->entryView()}] was not found.",
'file' => $entryView,
];
}
foreach (['stylesheet' => $theme->stylesheet(), 'script' => $theme->script()] as $label => $relativePath) {
if ($relativePath !== '' && ! $theme->has($relativePath)) {
$issues[] = [
'severity' => 'error',
'message' => ucfirst($label)." file [{$relativePath}] was not found.",
'file' => $relativePath,
];
}
}
$preview = $theme->preview();
if ($preview !== null && ! $theme->has($preview)) {
$issues[] = [
'severity' => 'warning',
'message' => "Preview file [{$preview}] was not found.",
'file' => $preview,
];
}
foreach ((array) ($theme->meta()['assets'] ?? []) as $asset) {
if (! is_string($asset) || $asset === '') {
continue;
}
if (! $theme->has($asset)) {
$issues[] = [
'severity' => 'warning',
'message' => "Declared asset [{$asset}] was not found.",
'file' => $asset,
];
}
}
return array_merge($issues, $this->accessibility->validate($theme));
}
}