Initial Commit
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Blog\Models\Blog;
|
||||
use Modules\Faq\Models\Faq;
|
||||
use Modules\News\Models\News;
|
||||
use Modules\Products\Models\Product;
|
||||
use Modules\Team\Models\TeamMember;
|
||||
use Modules\Testimonials\Models\Testimonial;
|
||||
use Throwable;
|
||||
|
||||
final class FrontendContent
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function homepage(array $settings): array
|
||||
{
|
||||
// Always provide source collections. Visibility is owned by page placements
|
||||
// in the composable content system; the legacy theme still applies its
|
||||
// existing show_* settings while no composition has been installed.
|
||||
return [
|
||||
'pricingPlans' => $this->pricingPlans(),
|
||||
'testimonials' => $this->testimonials(),
|
||||
'faqs' => $this->faqs(),
|
||||
'teamMembers' => $this->team(),
|
||||
'latestBlogPosts' => $this->blogPosts(),
|
||||
'latestNewsArticles' => $this->newsArticles(),
|
||||
];
|
||||
}
|
||||
|
||||
private function pricingPlans(): Collection
|
||||
{
|
||||
return $this->query('products', fn () => Product::query()
|
||||
->where('is_active', true)
|
||||
->where('product_type', 'plan')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->limit(6)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function testimonials(): Collection
|
||||
{
|
||||
return $this->query('testimonials', fn () => Testimonial::query()
|
||||
->where('enabled', true)
|
||||
->latest('id')
|
||||
->limit(6)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function faqs(): Collection
|
||||
{
|
||||
return $this->query('faqs', fn () => Faq::query()
|
||||
->where('enabled', true)
|
||||
->orderBy('sort_order')
|
||||
->limit(12)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function team(): Collection
|
||||
{
|
||||
return $this->query('team_members', fn () => TeamMember::query()
|
||||
->where('enabled', true)
|
||||
->orderBy('sort_order')
|
||||
->limit(8)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function blogPosts(): Collection
|
||||
{
|
||||
return $this->query('blog_posts', fn () => Blog::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit(3)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function newsArticles(): Collection
|
||||
{
|
||||
return $this->query('news_articles', fn () => News::query()
|
||||
->published()
|
||||
->latest('published_at')
|
||||
->limit(3)
|
||||
->get());
|
||||
}
|
||||
|
||||
private function query(string $table, callable $callback): Collection
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable($table) ? $callback() : collect();
|
||||
} catch (Throwable) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
private function enabled(array $settings, string $key, bool $default): bool
|
||||
{
|
||||
return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Services;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class TenantThemeResolver
|
||||
{
|
||||
public function resolve(?string $context = null): string
|
||||
{
|
||||
$context ??= $this->context();
|
||||
$column = config('stylist.tenant_theme_column', 'theme_key');
|
||||
$user = Auth::user();
|
||||
$company = $user?->company ?? null;
|
||||
|
||||
/*
|
||||
* The legacy company theme_key is a public-facing tenant theme.
|
||||
* It must never replace the application's backend/admin theme.
|
||||
*/
|
||||
if ($context === 'frontend' && $company && filled($company->{$column} ?? null)) {
|
||||
return (string) $company->{$column};
|
||||
}
|
||||
|
||||
$settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme';
|
||||
$default = $context === 'frontend'
|
||||
? config('stylist.default_frontend_theme', 'frontend')
|
||||
: config('stylist.default_backend_theme', config('stylist.default_theme', 'backend'));
|
||||
|
||||
try {
|
||||
return (string) SiteSetting::valueOf($settingKey, $default);
|
||||
} catch (\Throwable) {
|
||||
return (string) $default;
|
||||
}
|
||||
}
|
||||
|
||||
public function context(): string
|
||||
{
|
||||
if (app()->runningInConsole()) {
|
||||
return 'backend';
|
||||
}
|
||||
|
||||
$request = request();
|
||||
|
||||
return $request->is(
|
||||
'dashboard',
|
||||
'dashboard/*',
|
||||
'settings',
|
||||
'settings/*',
|
||||
'admin',
|
||||
'admin/*',
|
||||
'helpdesk',
|
||||
'helpdesk/*',
|
||||
) || $request->routeIs(
|
||||
'dashboard',
|
||||
'profile.edit',
|
||||
'appearance.edit',
|
||||
'security.edit',
|
||||
'admin.*',
|
||||
'helpdesk.*',
|
||||
)
|
||||
? 'backend'
|
||||
: 'frontend';
|
||||
}
|
||||
|
||||
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,72 @@
|
||||
<?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();
|
||||
|
||||
$missingAlt = 0;
|
||||
if (preg_match_all('/<img\\b(?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*>/is', $content, $images)) {
|
||||
foreach ($images[0] as $image) {
|
||||
if (! preg_match('/\\balt\\s*=\\s*(?:"[^"]*"|\'[^\']*\')/is', $image)) {
|
||||
$missingAlt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($missingAlt > 0) {
|
||||
$issues[] = [
|
||||
'severity' => 'error',
|
||||
'message' => $missingAlt.' 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,
|
||||
];
|
||||
}
|
||||
|
||||
$unnamedButtons = 0;
|
||||
if (preg_match_all('/<button\\b((?:(?:"[^"]*")|(?:\'[^\']*\')|[^\'">])*)>(.*?)<\\/button>/is', $content, $buttons, PREG_SET_ORDER)) {
|
||||
foreach ($buttons as $button) {
|
||||
$attributes = $button[1];
|
||||
$body = $button[2];
|
||||
|
||||
$hasAccessibleAttribute = preg_match('/\\b(?:aria-label|aria-labelledby|title)\\s*=\\s*(?:"[^"]+"|\'[^\']+\')/is', $attributes) === 1;
|
||||
$visibleText = trim(html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5));
|
||||
|
||||
if (! $hasAccessibleAttribute && $visibleText === '') {
|
||||
$unnamedButtons++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($unnamedButtons > 0) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'message' => $unnamedButtons.' button(s) may lack an accessible name.',
|
||||
'file' => $relative,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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,98 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Services;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Modules\Settings\Services\SettingsRepository;
|
||||
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, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user