Files
ABSDEV/Modules/Stylist/Services/ThemePackageManager.php
T
Mark Gregory a433897538 Implemented the dynamic theme manager and merged the supplied themes.
Included

* Standardised all six theme.json files to manifest version 2:
    * admin
    * common
    * corporate
    * saasland
    * swiss
    * welcome
* Every manifest now uses the same fields and structure.
* Separate active frontend and backend themes.
* One-click theme activation.
* Theme preview in a new browser tab without activating it.
* Theme cards showing:
    * Preview
    * Name
    * Version
    * Author
    * Description
    * Theme type
    * Active status
* Dynamic theme-specific settings.
* Support for:
    * Text
    * URL
    * Colour
    * Textarea
    * Checkbox fields
    * Variants
    * Appearance
    * Accent palettes
* Theme ZIP installation.
* Theme ZIP export.
* Asset optimisation.
* Accessibility validation.
* Safe ZIP path validation.
* Theme inheritance.
* Active themes cannot be uninstalled.
* Parent themes cannot be uninstalled while child themes depend on them.
* Stylist administration routes now require an authenticated, verified user.
* Manifest validation now restricts theme types to:
    * frontend
    * backend
    * common
* Added activation tests for independent frontend and backend theme selection.

The supplied corporate theme was incomplete, so I added the required backend entry view and placeholder asset files while preserving its supplied layout.
2026-07-28 12:54:10 +01:00

99 lines
4.4 KiB
PHP

<?php
namespace Modules\Stylist\Services;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\UploadedFile;
use Modules\Stylist\Theme\Loader;
use Modules\Stylist\Theme\Theme;
use Modules\Settings\Services\SettingsRepository;
use RuntimeException;
use ZipArchive;
final class ThemePackageManager
{
public function __construct(private readonly Filesystem $files, private readonly Loader $loader, private readonly ThemeValidator $validator, private readonly SettingsRepository $settings) {}
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
{
$active = $theme->type() === 'frontend'
? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site')
: $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site');
if ($active === $theme->key()) {
throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.');
}
$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;
}
}