New Branch Structure
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,66 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
STYLIST_DEFAULT_THEME=admin
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default_theme' => env('STYLIST_DEFAULT_THEME', 'admin'),
|
||||
|
||||
/*
|
||||
| Themes are application resources, never module resources.
|
||||
| Every theme is self-contained and owns its views and assets.
|
||||
*/
|
||||
'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')),
|
||||
|
||||
'tenant_theme_column' => 'theme_key',
|
||||
'tenant_brand_columns' => [
|
||||
'brand_primary' => 'brand-primary',
|
||||
'brand_secondary' => 'brand-secondary',
|
||||
'brand_accent' => 'brand-accent',
|
||||
],
|
||||
|
||||
'fallback_variables' => [
|
||||
'brand-primary' => '#2563eb',
|
||||
'brand-secondary' => '#0f172a',
|
||||
'brand-accent' => '#22c55e',
|
||||
'surface' => '#ffffff',
|
||||
'surface-muted' => '#f8fafc',
|
||||
'text-primary' => '#0f172a',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
|
||||
final class CreateThemeCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:create-theme {key : Lowercase theme key} {--parent=common : Parent theme key} {--name= : Display name}';
|
||||
protected $description = 'Create a minimal child theme without copying shared views.';
|
||||
|
||||
public function __construct(private readonly Filesystem $files)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$key = strtolower(trim((string) $this->argument('key')));
|
||||
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
|
||||
$this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
|
||||
if ($this->files->exists($path)) {
|
||||
$this->error("Theme [{$key}] already exists.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$parent = trim((string) $this->option('parent'));
|
||||
$name = trim((string) ($this->option('name') ?: str($key)->headline()));
|
||||
$this->files->ensureDirectoryExists($path.'/views');
|
||||
|
||||
$manifest = [
|
||||
'manifest_version' => 1,
|
||||
'name' => $name,
|
||||
'slug' => $key,
|
||||
'version' => '1.0.0',
|
||||
'description' => "Child theme for {$name}.",
|
||||
'extends' => $parent !== '' ? $parent : null,
|
||||
'entry_view' => 'home',
|
||||
'views' => true,
|
||||
'variables' => [],
|
||||
'settings' => [],
|
||||
];
|
||||
|
||||
if ($manifest['extends'] === null) {
|
||||
unset($manifest['extends']);
|
||||
}
|
||||
|
||||
$this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
|
||||
$this->files->put($path.'/views/.gitkeep', '');
|
||||
|
||||
$this->info("Theme [{$key}] created at {$path}.");
|
||||
$this->line('Shared views will resolve through its parent; only add genuine overrides.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class FindDuplicateViewsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:duplicates';
|
||||
protected $description = 'Find Blade task paths owned by more than one theme.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$duplicates = $this->inspector->duplicateViews();
|
||||
if ($duplicates === []) {
|
||||
$this->info('No duplicate theme views found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($duplicates as $view => $owners) {
|
||||
foreach ($owners as $owner) {
|
||||
$rows[] = [$view, $owner['theme'], $owner['path']];
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['View', 'Theme', 'Path'], $rows);
|
||||
$this->error(count($duplicates).' duplicate view task(s) found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class InspectThemesCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:inspect';
|
||||
protected $description = 'Display installed themes, inheritance, entry views, and view counts.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$themes = $this->inspector->themes();
|
||||
$this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [
|
||||
$theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'],
|
||||
], $themes));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class PublishAssetsCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:publish-assets {--force : Overwrite the published configuration}';
|
||||
protected $description = 'Publish the Stylist configuration and ensure resources/themes exists.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
$this->call('vendor:publish', [
|
||||
'--tag' => 'stylist-config',
|
||||
'--force' => $force,
|
||||
]);
|
||||
|
||||
$themesPath = (string) config('stylist.themes_path', resource_path('themes'));
|
||||
|
||||
if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) {
|
||||
$this->components->error("Unable to create theme directory: {$themesPath}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->components->info("Stylist config published. Themes remain in: {$themesPath}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
|
||||
final class ThemeDoctorCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:doctor';
|
||||
protected $description = 'Diagnose manifests, inheritance, duplicate views, and theme configuration.';
|
||||
|
||||
public function __construct(private readonly ThemeInspector $inspector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$issues = $this->inspector->issues();
|
||||
if ($issues === []) {
|
||||
$this->info('Stylist is healthy. No theme issues found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [
|
||||
strtoupper($issue['severity']), $issue['theme'], $issue['message'],
|
||||
], $issues));
|
||||
|
||||
$errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error'));
|
||||
$warnings = count($issues) - $errors;
|
||||
$this->line("{$errors} error(s), {$warnings} warning(s).");
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Modules\Stylist\Services\ThemeValidator;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
final class ValidateThemeCommand extends Command
|
||||
{
|
||||
protected $signature = 'stylist:validate {theme : Installed theme key, theme directory, or theme ZIP path}';
|
||||
|
||||
protected $description = 'Validate a Stylist theme manifest, files, references, and accessibility.';
|
||||
|
||||
public function __construct(
|
||||
private readonly Filesystem $files,
|
||||
private readonly Loader $loader,
|
||||
private readonly ThemeValidator $validator,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$temporary = null;
|
||||
|
||||
try {
|
||||
[$path, $temporary] = $this->resolveThemePath((string) $this->argument('theme'));
|
||||
$theme = $this->loader->fromPath($path);
|
||||
$issues = $this->validator->validate($theme);
|
||||
|
||||
$this->info("Theme: {$theme->name()} ({$theme->key()})");
|
||||
$this->line('Manifest schema: '.$theme->manifestVersion());
|
||||
|
||||
if ($issues === []) {
|
||||
$this->info('Theme validation passed with no issues.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Severity', 'File', 'Message'],
|
||||
array_map(fn (array $issue) => [
|
||||
strtoupper($issue['severity']),
|
||||
$issue['file'],
|
||||
$issue['message'],
|
||||
], $issues),
|
||||
);
|
||||
|
||||
$errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error'));
|
||||
$warnings = count($issues) - $errors;
|
||||
$this->line("{$errors} error(s), {$warnings} warning(s).");
|
||||
|
||||
return $errors > 0 ? self::FAILURE : self::SUCCESS;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
if ($temporary !== null) {
|
||||
$this->files->deleteDirectory($temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:?string} */
|
||||
private function resolveThemePath(string $input): array
|
||||
{
|
||||
if ($this->files->isDirectory($input)) {
|
||||
return [realpath($input) ?: $input, null];
|
||||
}
|
||||
|
||||
if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') {
|
||||
$temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
|
||||
$this->files->ensureDirectoryExists($temporary);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($input) !== true) {
|
||||
throw new RuntimeException('The theme ZIP could not be opened.');
|
||||
}
|
||||
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$name = (string) $zip->getNameIndex($index);
|
||||
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) {
|
||||
throw new RuntimeException('The package does not contain theme.json.');
|
||||
}
|
||||
|
||||
return [$manifest->getPath(), $temporary];
|
||||
}
|
||||
|
||||
return [$this->loader->find($input)->path(), null];
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('companies')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('companies', 'theme_key')) {
|
||||
$table->string('theme_key')->default('default')->after('timezone');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('companies', 'brand_primary')) {
|
||||
$table->string('brand_primary')->nullable()->after('theme_key');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('companies', 'brand_secondary')) {
|
||||
$table->string('brand_secondary')->nullable()->after('brand_primary');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('companies', 'brand_accent')) {
|
||||
$table->string('brand_accent')->nullable()->after('brand_secondary');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('companies', 'logo_path')) {
|
||||
$table->string('logo_path')->nullable()->after('brand_accent');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('companies', 'theme_settings')) {
|
||||
$table->json('theme_settings')->nullable()->after('logo_path');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('companies')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) {
|
||||
if (Schema::hasColumn('companies', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void { Schema::create('stylist_theme_settings', function (Blueprint $table) { $table->id(); $table->string('theme_key')->unique(); $table->string('variant')->default('light'); $table->json('settings')->nullable(); $table->timestamps(); }); }
|
||||
public function down(): void { Schema::dropIfExists('stylist_theme_settings'); }
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Diagnostics;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Modules\Stylist\Theme\Theme;
|
||||
|
||||
final class ThemeInspector
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Filesystem $files,
|
||||
private readonly Loader $loader,
|
||||
) {}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function themes(): array
|
||||
{
|
||||
return $this->loader->all()->map(function (Theme $theme): array {
|
||||
return [
|
||||
'key' => $theme->key(),
|
||||
'name' => $theme->name(),
|
||||
'parent' => $theme->parent(),
|
||||
'path' => $theme->path(),
|
||||
'views' => count($this->viewFiles($theme)),
|
||||
'entry_view' => $theme->entryView(),
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, array{theme:string,path:string}>> */
|
||||
public function duplicateViews(): array
|
||||
{
|
||||
$ownership = [];
|
||||
|
||||
foreach ($this->loader->all() as $theme) {
|
||||
foreach ($this->viewFiles($theme) as $relative => $absolute) {
|
||||
$ownership[$relative][] = [
|
||||
'theme' => $theme->key(),
|
||||
'path' => $absolute,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($ownership, fn (array $owners): bool => count($owners) > 1);
|
||||
}
|
||||
|
||||
/** @return array<int, array{severity:string,theme:string,message:string}> */
|
||||
public function issues(): array
|
||||
{
|
||||
$issues = [];
|
||||
$themes = $this->loader->all();
|
||||
|
||||
foreach ($themes as $theme) {
|
||||
$parent = $theme->parent();
|
||||
|
||||
if ($parent !== null && ! $themes->has($parent)) {
|
||||
$issues[] = [
|
||||
'severity' => 'error',
|
||||
'theme' => $theme->key(),
|
||||
'message' => "Parent theme [{$parent}] is not installed.",
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->hasInheritanceCycle($theme, $themes->all())) {
|
||||
$issues[] = [
|
||||
'severity' => 'error',
|
||||
'theme' => $theme->key(),
|
||||
'message' => 'Theme inheritance contains a cycle.',
|
||||
];
|
||||
}
|
||||
|
||||
$entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
|
||||
if (! $this->files->exists($entry) && $parent === null) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'theme' => $theme->key(),
|
||||
'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->duplicateViews() as $view => $owners) {
|
||||
$issues[] = [
|
||||
'severity' => 'error',
|
||||
'theme' => implode(', ', array_column($owners, 'theme')),
|
||||
'message' => "View [{$view}] has multiple owners.",
|
||||
];
|
||||
}
|
||||
|
||||
$default = (string) config('stylist.default_theme');
|
||||
if ($default !== '' && ! $themes->has($default)) {
|
||||
$issues[] = [
|
||||
'severity' => 'warning',
|
||||
'theme' => $default,
|
||||
'message' => 'Configured default theme is not installed.',
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function viewFiles(Theme $theme): array
|
||||
{
|
||||
$root = $theme->path().'/views';
|
||||
if (! $this->files->isDirectory($root)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$views = [];
|
||||
foreach ($this->files->allFiles($root) as $file) {
|
||||
if (! str_ends_with($file->getFilename(), '.blade.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$absolute = str_replace('\\', '/', $file->getPathname());
|
||||
$relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/');
|
||||
$views[$relative] = $absolute;
|
||||
}
|
||||
|
||||
ksort($views);
|
||||
return $views;
|
||||
}
|
||||
|
||||
/** @param array<string, Theme> $themes */
|
||||
private function hasInheritanceCycle(Theme $theme, array $themes): bool
|
||||
{
|
||||
$seen = [];
|
||||
$key = $theme->key();
|
||||
|
||||
while (isset($themes[$key])) {
|
||||
if (isset($seen[$key])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$key = $themes[$key]->parent() ?? '';
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
final class StylistFacade extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'stylist';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
final class ThemeFacade extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'stylist.html';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Modules\Stylist\Html\ThemeHtmlBuilder;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Modules\Stylist\Theme\UrlGenerator;
|
||||
use Modules\Stylist\View\ThemeViewResolver;
|
||||
|
||||
if (! function_exists('stylist')) {
|
||||
function stylist(): Stylist
|
||||
{
|
||||
return app(Stylist::class);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('stylist_html')) {
|
||||
function stylist_html(): ThemeHtmlBuilder
|
||||
{
|
||||
return app(ThemeHtmlBuilder::class);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('stylist_view')) {
|
||||
function stylist_view(string $view, array $data = [], array $mergeData = []): View
|
||||
{
|
||||
return app(ThemeViewResolver::class)->make($view, $data, $mergeData);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('stylist_theme_view')) {
|
||||
function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View
|
||||
{
|
||||
return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('theme_asset')) {
|
||||
function theme_asset(string $path): string
|
||||
{
|
||||
return app(UrlGenerator::class)->asset($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('theme_switcher')) {
|
||||
function theme_switcher(): \Illuminate\Support\HtmlString
|
||||
{
|
||||
return stylist_html()->switcher();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Html;
|
||||
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Modules\Stylist\Theme\UrlGenerator;
|
||||
|
||||
final class ThemeHtmlBuilder
|
||||
{
|
||||
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $url) {}
|
||||
|
||||
public function stylesheet(?string $path = null): HtmlString
|
||||
{
|
||||
$path ??= $this->stylist->current()->stylesheet();
|
||||
if (! $this->stylist->current()->has($path)) return new HtmlString('');
|
||||
return new HtmlString('<link rel="stylesheet" href="'.e($this->url->asset($path)).'">');
|
||||
}
|
||||
|
||||
public function script(?string $path = null): HtmlString
|
||||
{
|
||||
$path ??= $this->stylist->current()->script();
|
||||
if (! $this->stylist->current()->has($path)) return new HtmlString('');
|
||||
return new HtmlString('<script src="'.e($this->url->asset($path)).'" defer></script>');
|
||||
}
|
||||
|
||||
public function variables(): HtmlString
|
||||
{
|
||||
$theme = $this->stylist->current();
|
||||
$default = $this->declarations($this->stylist->variables());
|
||||
$rules = [':root{'.$default.'}'];
|
||||
|
||||
foreach ($theme->appearances() as $name => $definition) {
|
||||
$vars = (array) data_get($definition, 'variables', []);
|
||||
if ($vars) $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
}
|
||||
foreach ($theme->accents() as $name => $definition) {
|
||||
$vars = (array) data_get($definition, 'variables', []);
|
||||
if ($vars) $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
|
||||
}
|
||||
|
||||
if ($theme->appearances() === [] && $theme->accents() === []) {
|
||||
return new HtmlString('<style>'.implode('', $rules).'</style>');
|
||||
}
|
||||
|
||||
$appearance = json_encode($this->stylist->appearance(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
$accent = json_encode($this->stylist->accent(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
$themeKey = json_encode($theme->key(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
$hasAppearances = $theme->appearances() !== [] ? 'true' : 'false';
|
||||
$hasAccents = $theme->accents() !== [] ? 'true' : 'false';
|
||||
|
||||
$script = <<<JS
|
||||
<script>(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.theme=dark?'dark':'light';d.dataset.appearance=a;d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})();</script>
|
||||
JS;
|
||||
|
||||
return new HtmlString('<style>'.implode('', $rules).'</style>'.$script);
|
||||
}
|
||||
|
||||
public function switcher(): HtmlString
|
||||
{
|
||||
$theme = $this->stylist->current();
|
||||
$appearances = array_keys($theme->appearances());
|
||||
$accents = array_keys($theme->accents());
|
||||
|
||||
if ($appearances === [] && $accents === []) {
|
||||
return new HtmlString('');
|
||||
}
|
||||
|
||||
$appearanceOptions = collect($appearances)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
|
||||
$accentOptions = collect($accents)->map(fn ($name) => '<option value="'.e($name).'">'.e(ucfirst($name)).'</option>')->implode('');
|
||||
$appearanceSelect = $appearances ? '<label>Appearance <select data-stylist-appearance>'.$appearanceOptions.'</select></label>' : '';
|
||||
$accentSelect = $accents ? '<label>Accent <select data-stylist-accent>'.$accentOptions.'</select></label>' : '';
|
||||
|
||||
return new HtmlString('<div data-stylist-switcher class="stylist-theme-switcher">'.$appearanceSelect.$accentSelect.'</div><script>document.addEventListener("stylist:ready",function(){document.querySelectorAll("[data-stylist-switcher]").forEach(function(root){var a=root.querySelector("[data-stylist-appearance]"),c=root.querySelector("[data-stylist-accent]");if(a){a.value=StylistTheme.appearance();a.addEventListener("change",function(){StylistTheme.setAppearance(a.value)})}if(c){c.value=StylistTheme.accent();c.addEventListener("change",function(){StylistTheme.setAccent(c.value)})}})});</script>');
|
||||
}
|
||||
|
||||
private function declarations(array $variables): string
|
||||
{
|
||||
return collect($variables)
|
||||
->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value)))
|
||||
->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';')
|
||||
->implode('');
|
||||
}
|
||||
|
||||
private function selector(string $value): string
|
||||
{
|
||||
return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Html;
|
||||
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Modules\Stylist\Theme\UrlGenerator;
|
||||
|
||||
final class ThemeSeoBuilder
|
||||
{
|
||||
public function __construct(private readonly Stylist $stylist, private readonly UrlGenerator $urls) {}
|
||||
public function render(array $overrides=[]): HtmlString
|
||||
{
|
||||
$theme=$this->stylist->current(); $seo=array_replace($theme->seo(),$overrides); $settings=$this->stylist->settings();
|
||||
$title=(string)($seo['title']??$settings['site_title']??$theme->name()); $description=(string)($seo['description']??$settings['site_description']??$theme->description());
|
||||
$image=$seo['image']??$theme->preview(); if($image && $theme->has($image))$image=$this->urls->asset($image);
|
||||
$tags=['<title>'.e($title).'</title>']; if($description!=='')$tags[]='<meta name="description" content="'.e($description).'">';
|
||||
foreach(['og:title'=>$title,'og:description'=>$description,'twitter:card'=>'summary_large_image','twitter:title'=>$title,'twitter:description'=>$description] as $property=>$content){if($content==='')continue;$attribute=str_starts_with($property,'og:')?'property':'name';$tags[]='<meta '.$attribute.'="'.e($property).'" content="'.e($content).'">';}
|
||||
if($image){$tags[]='<meta property="og:image" content="'.e($image).'">';$tags[]='<meta name="twitter:image" content="'.e($image).'">';}
|
||||
if(isset($seo['json_ld'])&&is_array($seo['json_ld']))$tags[]='<script type="application/ld+json">'.json_encode($seo['json_ld'],JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).'</script>';
|
||||
return new HtmlString(implode("\n",$tags));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Http\Controllers;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Stylist\Services\ThemeAccessibilityValidator;
|
||||
use Modules\Stylist\Services\ThemeSettings;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Modules\Stylist\Theme\UrlGenerator;
|
||||
|
||||
final class StylistController extends Controller
|
||||
{
|
||||
public function __invoke(Stylist $stylist, ThemeSettings $settings, ThemeAccessibilityValidator $accessibility, UrlGenerator $urls)
|
||||
{
|
||||
return view('stylist::index', [
|
||||
'theme' => $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(),
|
||||
'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class ThemeAssetController extends Controller
|
||||
{
|
||||
private const ALLOWED_ROOTS = ['css', 'js', 'images', 'fonts', 'icons', 'assets'];
|
||||
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'css', 'js', 'mjs', 'map', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif',
|
||||
'ico', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'json', 'xml', 'txt', 'webmanifest',
|
||||
];
|
||||
|
||||
public function __invoke(string $theme, string $path, Loader $loader): BinaryFileResponse|Response
|
||||
{
|
||||
abort_if(str_contains($path, "\0"), 404);
|
||||
|
||||
$normalised = str_replace('\\', '/', ltrim($path, '/'));
|
||||
$root = explode('/', $normalised, 2)[0] ?? '';
|
||||
$extension = strtolower(pathinfo($normalised, PATHINFO_EXTENSION));
|
||||
|
||||
abort_unless(in_array($root, self::ALLOWED_ROOTS, true), 404);
|
||||
abort_unless(in_array($extension, self::ALLOWED_EXTENSIONS, true), 404);
|
||||
|
||||
$loadedTheme = $loader->find($theme);
|
||||
$themeRoot = realpath($loadedTheme->path());
|
||||
abort_unless($themeRoot !== false, 404);
|
||||
|
||||
$asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised);
|
||||
|
||||
abort_unless(
|
||||
$asset !== false
|
||||
&& str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR)
|
||||
&& is_file($asset),
|
||||
404
|
||||
);
|
||||
|
||||
$mime = match ($extension) {
|
||||
'css' => 'text/css; charset=UTF-8',
|
||||
'js', 'mjs' => 'application/javascript; charset=UTF-8',
|
||||
'svg' => 'image/svg+xml',
|
||||
'json', 'map', 'webmanifest' => 'application/json',
|
||||
'woff' => 'font/woff',
|
||||
'woff2' => 'font/woff2',
|
||||
'ttf' => 'font/ttf',
|
||||
'otf' => 'font/otf',
|
||||
default => mime_content_type($asset) ?: 'application/octet-stream',
|
||||
};
|
||||
|
||||
return response()->file($asset, [
|
||||
'Content-Type' => $mime,
|
||||
'Cache-Control' => app()->isProduction()
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache, private',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Stylist\Services\ThemeAccessibilityValidator;
|
||||
use Modules\Stylist\Services\ThemeOptimizer;
|
||||
use Modules\Stylist\Services\ThemePackageManager;
|
||||
use Modules\Stylist\Services\ThemeSettings;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
|
||||
final class ThemeManagementController extends Controller
|
||||
{
|
||||
public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings)
|
||||
{
|
||||
$item = $loader->find($theme);
|
||||
$settings->save(
|
||||
$item,
|
||||
(array) $request->input('settings', []),
|
||||
(string) $request->input('variant', 'light'),
|
||||
$request->string('appearance')->toString() ?: null,
|
||||
$request->string('accent')->toString() ?: null,
|
||||
);
|
||||
return back()->with('status', 'Theme settings saved.');
|
||||
}
|
||||
public function install(Request $request, ThemePackageManager $packages)
|
||||
{
|
||||
$request->validate(['theme' => ['required','file','mimes:zip','max:51200']]);
|
||||
$theme = $packages->install($request->file('theme'));
|
||||
return back()->with('status', $theme->name().' installed.');
|
||||
}
|
||||
public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
|
||||
{
|
||||
$item = $loader->find($theme); $packages->uninstall($item);
|
||||
return back()->with('status', $item->name().' uninstalled.');
|
||||
}
|
||||
public function export(string $theme, Loader $loader, ThemePackageManager $packages)
|
||||
{
|
||||
$item = $loader->find($theme); $path = $packages->export($item);
|
||||
return response()->download($path)->deleteFileAfterSend();
|
||||
}
|
||||
public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
|
||||
{
|
||||
$item = $loader->find($theme); $result = $optimizer->optimize($item);
|
||||
return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
|
||||
}
|
||||
public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
|
||||
{
|
||||
return response()->json(['issues' => $validator->validate($loader->find($theme))]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
final class ThemePreview extends Component
|
||||
{
|
||||
public array $stats = [
|
||||
'companies' => 12,
|
||||
'warehouses' => 34,
|
||||
'users' => 156,
|
||||
];
|
||||
|
||||
public function render()
|
||||
{
|
||||
return stylist_view('livewire.theme-preview', [
|
||||
'stats' => $this->stats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ThemeSetting extends Model
|
||||
{
|
||||
protected $table = 'stylist_theme_settings';
|
||||
protected $fillable = ['theme_key', 'variant', 'settings'];
|
||||
protected $casts = ['settings' => 'array'];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
Route::middleware('web')
|
||||
->group(module_path('Stylist', 'Routes/assets.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
->prefix('stylist')
|
||||
->name('stylist.')
|
||||
->group(module_path('Stylist', 'Routes/web.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Stylist\Console\CreateThemeCommand;
|
||||
use Modules\Stylist\Console\FindDuplicateViewsCommand;
|
||||
use Modules\Stylist\Console\InspectThemesCommand;
|
||||
use Modules\Stylist\Console\PublishAssetsCommand;
|
||||
use Modules\Stylist\Console\ThemeDoctorCommand;
|
||||
use Modules\Stylist\Console\ValidateThemeCommand;
|
||||
use Modules\Stylist\Html\ThemeHtmlBuilder;
|
||||
use Modules\Stylist\Html\ThemeSeoBuilder;
|
||||
use Modules\Stylist\Support\ModuleInfo;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
|
||||
final class StylistServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected string $moduleName = 'Stylist';
|
||||
protected string $moduleNameLower = 'stylist';
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
|
||||
$this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
|
||||
|
||||
$this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class));
|
||||
$this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class));
|
||||
$this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class));
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerViews();
|
||||
$this->registerThemeViews();
|
||||
$this->registerTranslations();
|
||||
$this->registerPublishing();
|
||||
$this->registerHelpers();
|
||||
$this->registerBlade();
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
PublishAssetsCommand::class,
|
||||
ValidateThemeCommand::class,
|
||||
InspectThemesCommand::class,
|
||||
FindDuplicateViewsCommand::class,
|
||||
ThemeDoctorCommand::class,
|
||||
CreateThemeCommand::class,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
|
||||
|
||||
View::composer('*', function ($view): void {
|
||||
if (! $this->app->bound(Stylist::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$view->with('activeTheme', $this->app->make(Stylist::class)->current());
|
||||
});
|
||||
}
|
||||
|
||||
private function registerViews(): void
|
||||
{
|
||||
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
|
||||
$viewPath = resource_path('views/modules/'.ModuleInfo::nameLower());
|
||||
|
||||
$this->publishes([$sourcePath => $viewPath], ['views', 'stylist-module-views']);
|
||||
$this->loadViewsFrom([$viewPath, $sourcePath], ModuleInfo::nameLower());
|
||||
}
|
||||
|
||||
private function registerThemeViews(): void
|
||||
{
|
||||
foreach ($this->themeRoots() as $root) {
|
||||
if (! is_dir($root)) {
|
||||
logger()->warning('Stylist theme root does not exist', [
|
||||
'root' => $root,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: [];
|
||||
|
||||
foreach ($themeDirectories as $themeDirectory) {
|
||||
$themeViewsPath = $themeDirectory.'/views';
|
||||
|
||||
if (! is_dir($themeViewsPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$themeKey = strtolower(basename($themeDirectory));
|
||||
|
||||
// Themes always live outside the module, normally in resources/themes.
|
||||
// Register the canonical namespace and the legacy namespace as an alias
|
||||
// to the same application-owned directory. The alias keeps existing
|
||||
// routes and Blade templates working without reintroducing module themes.
|
||||
$namespaces = [
|
||||
"stylist-theme-{$themeKey}",
|
||||
"stylist-module-theme-{$themeKey}",
|
||||
];
|
||||
|
||||
$componentsPath = $themeViewsPath.'/components';
|
||||
|
||||
// The common theme is the application-wide owner of layouts,
|
||||
// authentication, settings and shared components. Livewire 4
|
||||
// resolves its default full-page layout through layouts::app,
|
||||
// so point that compatibility namespace at the common theme.
|
||||
if ($themeKey === 'common') {
|
||||
$layoutsPath = $componentsPath.'/layouts';
|
||||
|
||||
if (is_dir($layoutsPath)) {
|
||||
View::addNamespace('layouts', $layoutsPath);
|
||||
Blade::anonymousComponentPath($layoutsPath, 'layouts');
|
||||
}
|
||||
|
||||
View::addNamespace('common', $themeViewsPath);
|
||||
|
||||
if (is_dir($componentsPath)) {
|
||||
Blade::anonymousComponentPath($componentsPath, 'common');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($namespaces as $namespace) {
|
||||
View::addNamespace($namespace, $themeViewsPath);
|
||||
|
||||
if (is_dir($componentsPath)) {
|
||||
Blade::anonymousComponentPath($componentsPath, $namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function normalisePath(string $path): string
|
||||
{
|
||||
return rtrim(
|
||||
str_replace('\\', '/', realpath($path) ?: $path),
|
||||
'/'
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function themeRoots(): array
|
||||
{
|
||||
return array_values(array_unique(array_filter([
|
||||
(string) config('stylist.themes_path'),
|
||||
])));
|
||||
}
|
||||
|
||||
private function registerTranslations(): void
|
||||
{
|
||||
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
|
||||
$sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
|
||||
|
||||
$this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
|
||||
}
|
||||
|
||||
private function registerPublishing(): void
|
||||
{
|
||||
$this->publishes([
|
||||
module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
|
||||
], ['config', 'stylist-config']);
|
||||
|
||||
}
|
||||
|
||||
private function registerHelpers(): void
|
||||
{
|
||||
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
|
||||
require_once $filename;
|
||||
}
|
||||
}
|
||||
|
||||
private function registerBlade(): void
|
||||
{
|
||||
Blade::directive('themeVariables', fn () => '<?php echo stylist_html()->variables(); ?>');
|
||||
Blade::directive('themeStylesheet', fn ($expression) => '<?php echo stylist_html()->stylesheet('.($expression ?: 'null').'); ?>');
|
||||
Blade::directive('themeScript', fn ($expression) => '<?php echo stylist_html()->script('.($expression ?: 'null').'); ?>');
|
||||
Blade::directive('themeSwitcher', fn () => '<?php echo stylist_html()->switcher(); ?>');
|
||||
Blade::directive('themeSeo', fn ($expression) => '<?php echo app(\Modules\Stylist\Html\ThemeSeoBuilder::class)->render('.($expression ?: '[]').'); ?>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
# Stylist Module
|
||||
|
||||
Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules.
|
||||
|
||||
## Theme layout
|
||||
|
||||
Each theme is self-contained:
|
||||
|
||||
```text
|
||||
resources/themes/{theme}/
|
||||
├── theme.json
|
||||
├── css/theme.css
|
||||
└── views/
|
||||
├── layouts/app.blade.php
|
||||
├── components/page-hero.blade.php
|
||||
└── livewire/theme-preview.blade.php
|
||||
```
|
||||
|
||||
The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`.
|
||||
|
||||
## Rendering themed views
|
||||
|
||||
Use the helper from controllers, Blade components, and Livewire components:
|
||||
|
||||
```php
|
||||
return stylist_view('layouts.app');
|
||||
```
|
||||
|
||||
It checks views in this order:
|
||||
|
||||
```text
|
||||
stylist-theme-{active-theme}::{view}
|
||||
stylist-theme-{default-theme}::{view}
|
||||
stylist::{view}
|
||||
```
|
||||
|
||||
## Livewire
|
||||
|
||||
Keep the PHP component class shared and put the template in each theme:
|
||||
|
||||
```text
|
||||
Livewire/ThemePreview.php
|
||||
resources/themes/default/views/livewire/theme-preview.blade.php
|
||||
resources/themes/mpba/views/livewire/theme-preview.blade.php
|
||||
```
|
||||
|
||||
The component renders with:
|
||||
|
||||
```php
|
||||
return stylist_view('livewire.theme-preview', ['stats' => $this->stats]);
|
||||
```
|
||||
|
||||
## Tenant assignment
|
||||
|
||||
Add or use these fields on `companies`:
|
||||
|
||||
```text
|
||||
theme_key
|
||||
brand_primary
|
||||
brand_secondary
|
||||
brand_accent
|
||||
logo_path
|
||||
favicon_path
|
||||
theme_settings
|
||||
```
|
||||
|
||||
Then assign:
|
||||
|
||||
```php
|
||||
$company->update(['theme_key' => 'mpba']);
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag=stylist-config
|
||||
# or
|
||||
php artisan stylist:publish-assets
|
||||
|
||||
Themes are not published from or stored inside the module. Install them directly under `resources/themes`.
|
||||
```
|
||||
|
||||
## Blade helpers
|
||||
|
||||
```blade
|
||||
<html data-theme="{{ stylist()->current()->key() }}">
|
||||
@themeVariables
|
||||
@themeStylesheet
|
||||
</html>
|
||||
```
|
||||
|
||||
## Self-contained application themes
|
||||
|
||||
Application themes live in `resources/themes/{theme}` and own their runtime assets:
|
||||
|
||||
```text
|
||||
resources/themes/example/
|
||||
├── theme.json
|
||||
├── css/theme.css
|
||||
├── js/app.js
|
||||
└── views/home.blade.php
|
||||
```
|
||||
|
||||
Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs.
|
||||
|
||||
|
||||
## Appearance and colour variants
|
||||
|
||||
Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `<head>` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page.
|
||||
|
||||
## Theme manifest compatibility
|
||||
|
||||
Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"key": "design-gallery",
|
||||
"name": "Design Gallery"
|
||||
}
|
||||
```
|
||||
|
||||
`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery.
|
||||
|
||||
Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist:
|
||||
|
||||
```json
|
||||
{
|
||||
"appearances": ["light", "dark", "system"],
|
||||
"accents": ["blue", "neutral"],
|
||||
"default_appearance": "system",
|
||||
"default_accent": "neutral"
|
||||
}
|
||||
```
|
||||
|
||||
Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls.
|
||||
|
||||
Additional boolean capabilities may be declared explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"capabilities": {
|
||||
"marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validate a theme
|
||||
|
||||
Validate an installed theme key, unpacked theme directory, or ZIP package:
|
||||
|
||||
```bash
|
||||
php artisan stylist:validate design-gallery
|
||||
php artisan stylist:validate resources/themes/design-gallery
|
||||
php artisan stylist:validate ~/Downloads/design-gallery.zip
|
||||
```
|
||||
|
||||
The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found.
|
||||
|
||||
## Theme inheritance and diagnostics
|
||||
|
||||
Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"slug": "admin",
|
||||
"extends": "common"
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views.
|
||||
|
||||
Use these maintenance commands:
|
||||
|
||||
```bash
|
||||
php artisan stylist:inspect
|
||||
php artisan stylist:duplicates
|
||||
php artisan stylist:doctor
|
||||
php artisan stylist:create-theme customer --parent=common --name="Customer Portal"
|
||||
```
|
||||
|
||||
`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task.
|
||||
@@ -0,0 +1,5 @@
|
||||
[data-theme] {
|
||||
--brand-primary: var(--brand-primary, #2563eb);
|
||||
--brand-secondary: var(--brand-secondary, #0f172a);
|
||||
--brand-accent: var(--brand-accent, #22c55e);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '../css/app.css';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Stylist',
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
<x-app-layout>
|
||||
<x-wms.page-hero eyebrow="Administration" title="Stylist" subtitle="Install, configure, validate and export self-contained themes." />
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-red-800">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('stylist.install') }}" enctype="multipart/form-data" class="mb-8 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
@csrf
|
||||
<label class="min-w-64 flex-1"><span class="mb-2 block text-sm font-medium text-slate-700">Install theme ZIP</span><input type="file" name="theme" accept=".zip" required class="block w-full rounded-lg border border-slate-300 p-2"></label>
|
||||
<button class="rounded-lg bg-slate-900 px-4 py-2.5 font-semibold text-white">Install theme</button>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
@foreach ($themes as $item)
|
||||
@php($saved = $settingsService->get($item))
|
||||
@php($variant = $settingsService->variant($item))
|
||||
@php($issues = $accessibility->validate($item))
|
||||
@php($appearance = $settingsService->appearance($item))
|
||||
@php($accent = $settingsService->accent($item))
|
||||
<article class="overflow-hidden rounded-2xl border {{ $item->key() === $theme->key() ? 'border-blue-400 ring-2 ring-blue-100' : 'border-slate-200' }} bg-white shadow-sm">
|
||||
<div class="aspect-[16/7] bg-slate-100">
|
||||
@if ($item->preview() && $item->has($item->preview()))
|
||||
<img src="{{ $urls->forTheme($item, $item->preview()) }}" alt="{{ $item->name() }} theme preview" class="h-full w-full object-cover">
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-sm text-slate-500">Add preview.webp to this theme</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-start justify-between gap-4"><div><h2 class="text-xl font-bold text-slate-950">{{ $item->name() }}</h2><p class="text-sm text-slate-500">v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif</p></div>@if($item->key() === $theme->key())<span class="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">Active</span>@endif</div>
|
||||
@if($item->description())<p class="mt-3 text-sm text-slate-600">{{ $item->description() }}</p>@endif
|
||||
@if($item->parent())<p class="mt-2 text-xs font-medium text-violet-700">Child of {{ $item->parent() }}</p>@endif
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a href="{{ route('stylist.export', $item->key()) }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Export ZIP</a>
|
||||
<form method="POST" action="{{ route('stylist.optimize', $item->key()) }}">@csrf<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Optimise assets</button></form>
|
||||
@if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__'))
|
||||
<form method="POST" action="{{ route('stylist.uninstall', $item->key()) }}" onsubmit="return confirm('Uninstall this theme?')">@csrf @method('DELETE')<button class="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700">Uninstall</button></form>
|
||||
@endif
|
||||
</div>
|
||||
<details class="mt-5 rounded-xl border border-slate-200 p-4">
|
||||
<summary class="cursor-pointer font-semibold">Theme settings</summary>
|
||||
<form method="POST" action="{{ route('stylist.settings', $item->key()) }}" class="mt-4 space-y-4">@csrf @method('PUT')
|
||||
@if($item->variants())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Variant</span><select name="variant" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->variants()) as $name)<option value="{{ $name }}" @selected($variant === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
@endif
|
||||
@if($item->supports('dark_mode') || $item->appearances())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Appearance</span><select name="appearance" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)<option value="{{ $name }}" @selected($appearance === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
@endif
|
||||
@if($item->accents())
|
||||
<div><span class="mb-2 block text-sm font-medium">Accent colour</span><div class="flex flex-wrap gap-2">@foreach($item->accents() as $name => $definition)<label class="cursor-pointer"><input type="radio" name="accent" value="{{ $name }}" class="peer sr-only" @checked($accent === $name)><span class="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-100"><span class="h-4 w-4 rounded-full border border-black/10" style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}"></span>{{ data_get($definition, 'label', ucfirst($name)) }}</span></label>@endforeach</div></div>
|
||||
@endif
|
||||
@foreach($item->settingsSchema() as $key => $definition)
|
||||
@php($definition = is_array($definition) ? $definition : ['default' => $definition])
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">{{ $definition['label'] ?? str($key)->headline() }}</span>
|
||||
@if(($definition['type'] ?? 'text') === 'color')<input type="color" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? '#000000' }}" class="h-10 w-full rounded-lg border-slate-300">
|
||||
@else<input type="{{ ($definition['type'] ?? 'text') === 'url' ? 'url' : 'text' }}" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? '' }}" class="w-full rounded-lg border-slate-300">@endif
|
||||
</label>
|
||||
@endforeach
|
||||
<button class="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white">Save settings</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="mt-4 text-sm {{ collect($issues)->contains('severity', 'error') ? 'text-red-700' : 'text-emerald-700' }}">Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Stylist\Http\Controllers\ThemeAssetController;
|
||||
|
||||
/*
|
||||
| Public theme assets
|
||||
| --------------------------------------------------------------------------
|
||||
| Themes are stored beneath resources/themes, which is not web-accessible.
|
||||
| This route safely streams allow-listed assets through Laravel at the URL
|
||||
| expected by generated themes: /themes/{theme}/{path}.
|
||||
*/
|
||||
Route::get('/themes/{theme}/{path}', ThemeAssetController::class)
|
||||
->where('theme', '[A-Za-z0-9_-]+')
|
||||
->where('path', '.*')
|
||||
->name('stylist.theme.asset');
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Stylist\Http\Controllers\StylistController;
|
||||
use Modules\Stylist\Http\Controllers\ThemeManagementController;
|
||||
|
||||
Route::get('/', StylistController::class)->name('index');
|
||||
Route::post('/install', [ThemeManagementController::class, 'install'])->name('install');
|
||||
Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings');
|
||||
Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall');
|
||||
Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export');
|
||||
Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize');
|
||||
Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility');
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Support;
|
||||
|
||||
final class ModuleInfo
|
||||
{
|
||||
public static function name(): string
|
||||
{
|
||||
return 'Stylist';
|
||||
}
|
||||
|
||||
public static function nameLower(): string
|
||||
{
|
||||
return 'stylist';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class CommonApplicationViewsTest extends TestCase
|
||||
{
|
||||
public function test_common_theme_owns_application_authentication_and_settings_views(): void
|
||||
{
|
||||
foreach ([
|
||||
'stylist-theme-common::components.layouts.app',
|
||||
'stylist-theme-common::components.settings.layout',
|
||||
'stylist-theme-common::partials.settings-heading',
|
||||
'stylist-theme-common::livewire.settings.profile',
|
||||
'stylist-theme-common::livewire.settings.appearance',
|
||||
'stylist-theme-common::livewire.settings.security',
|
||||
'stylist-theme-common::livewire.auth.login',
|
||||
'stylist-theme-common::livewire.auth.register',
|
||||
] as $view) {
|
||||
$this->assertTrue(view()->exists($view), "Expected common view [{$view}] to exist.");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_authentication_and_settings_views_are_not_duplicated(): void
|
||||
{
|
||||
$this->assertDirectoryDoesNotExist(resource_path('views'));
|
||||
$this->assertDirectoryDoesNotExist(resource_path('themes/admin/views/livewire'));
|
||||
$this->assertDirectoryDoesNotExist(resource_path('themes/welcome/views/livewire'));
|
||||
|
||||
$commonViews = resource_path('themes/common/views');
|
||||
|
||||
foreach ([
|
||||
'livewire/auth/login.blade.php',
|
||||
'livewire/auth/register.blade.php',
|
||||
'livewire/auth/forgot-password.blade.php',
|
||||
'livewire/auth/reset-password.blade.php',
|
||||
'livewire/settings/profile.blade.php',
|
||||
'livewire/settings/appearance.blade.php',
|
||||
'livewire/settings/security.blade.php',
|
||||
] as $relativePath) {
|
||||
$matches = collect(File::allFiles(resource_path('themes')))
|
||||
->filter(fn ($file) => str_ends_with(
|
||||
str_replace('\\', '/', $file->getPathname()),
|
||||
'/views/'.$relativePath
|
||||
));
|
||||
|
||||
$this->assertCount(1, $matches, "Expected one authoritative view for [{$relativePath}].");
|
||||
$this->assertStringStartsWith(
|
||||
str_replace('\\', '/', $commonViews),
|
||||
str_replace('\\', '/', $matches->first()->getPathname())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
final class LegacyThemeNamespaceAliasTest extends TestCase
|
||||
{
|
||||
public function test_legacy_theme_namespace_resolves_to_application_theme_directory(): void
|
||||
{
|
||||
$this->assertTrue(view()->exists('stylist-module-theme-admin::dashboard'));
|
||||
$this->assertTrue(view()->exists('stylist-theme-admin::dashboard'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class StylistModuleTest extends TestCase
|
||||
{
|
||||
public function test_stylist_service_resolves(): void
|
||||
{
|
||||
$this->assertInstanceOf(Stylist::class, app(Stylist::class));
|
||||
}
|
||||
|
||||
public function test_default_theme_resolves(): void
|
||||
{
|
||||
config(['stylist.default_theme' => 'default']);
|
||||
|
||||
$this->assertSame('default', app(Stylist::class)->current()->key());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ThemeAssetControllerTest extends TestCase
|
||||
{
|
||||
public function test_active_theme_css_is_served(): void
|
||||
{
|
||||
config(['stylist.themes_path' => resource_path('themes')]);
|
||||
app(Loader::class)->flush();
|
||||
|
||||
$this->get(route('stylist.theme.asset', [
|
||||
'theme' => 'saasland',
|
||||
'path' => 'css/theme.css',
|
||||
]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
public function test_asset_url_uses_public_themes_path(): void
|
||||
{
|
||||
$url = route('stylist.theme.asset', [
|
||||
'theme' => 'saasland',
|
||||
'path' => 'images/example.jpg',
|
||||
], false);
|
||||
|
||||
$this->assertSame('/themes/saasland/images/example.jpg', $url);
|
||||
}
|
||||
|
||||
public function test_non_asset_theme_files_are_not_served(): void
|
||||
{
|
||||
config(['stylist.themes_path' => resource_path('themes')]);
|
||||
app(Loader::class)->flush();
|
||||
|
||||
$this->get(route('stylist.theme.asset', [
|
||||
'theme' => 'saasland',
|
||||
'path' => 'views/home.blade.php',
|
||||
]))->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Modules\Stylist\Html\ThemeHtmlBuilder;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ThemeHtmlBuilderTest extends TestCase
|
||||
{
|
||||
public function test_css_variables_are_rendered(): void
|
||||
{
|
||||
config(['stylist.fallback_variables.brand-primary' => '#111827']);
|
||||
|
||||
$html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
|
||||
|
||||
$this->assertStringContainsString('--brand-primary: #111827;', $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Modules\Stylist\Diagnostics\ThemeInspector;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Modules\Stylist\View\ThemeViewResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ThemeInheritanceTest extends TestCase
|
||||
{
|
||||
private string $themesPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->themesPath = storage_path('framework/testing/stylist-inheritance-'.uniqid());
|
||||
mkdir($this->themesPath, 0777, true);
|
||||
config([
|
||||
'stylist.themes_path' => $this->themesPath,
|
||||
'stylist.default_theme' => 'common',
|
||||
]);
|
||||
app(Loader::class)->flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
app(Filesystem::class)->deleteDirectory($this->themesPath);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_child_theme_resolves_views_through_parent_without_copying_them(): void
|
||||
{
|
||||
$this->theme('common', null, ['components/layouts/app.blade.php' => '<main>{{ $slot }}</main>']);
|
||||
$this->theme('admin', 'common', ['dashboard.blade.php' => '<h1>Dashboard</h1>']);
|
||||
app(Loader::class)->flush();
|
||||
|
||||
$candidates = app(ThemeViewResolver::class)->candidatesForTheme('admin', 'components.layouts.app');
|
||||
|
||||
$this->assertSame('stylist-theme-admin::components.layouts.app', $candidates[0]);
|
||||
$this->assertContains('stylist-theme-common::components.layouts.app', $candidates);
|
||||
$this->assertLessThan(
|
||||
array_search('stylist-theme-common::components.layouts.app', $candidates, true),
|
||||
array_search('stylist-theme-admin::components.layouts.app', $candidates, true),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_parent_alias_is_normalised_to_extends(): void
|
||||
{
|
||||
$path = $this->themesPath.'/child';
|
||||
mkdir($path, 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'name' => 'Child',
|
||||
'parent' => 'common',
|
||||
]));
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame('common', $theme->parent());
|
||||
}
|
||||
|
||||
public function test_duplicate_view_ownership_is_reported(): void
|
||||
{
|
||||
$this->theme('common', null, ['livewire/auth/login.blade.php' => 'common']);
|
||||
$this->theme('admin', 'common', ['livewire/auth/login.blade.php' => 'admin']);
|
||||
app(Loader::class)->flush();
|
||||
|
||||
$duplicates = app(ThemeInspector::class)->duplicateViews();
|
||||
|
||||
$this->assertArrayHasKey('livewire/auth/login.blade.php', $duplicates);
|
||||
$this->assertEqualsCanonicalizing(['common', 'admin'], array_column($duplicates['livewire/auth/login.blade.php'], 'theme'));
|
||||
}
|
||||
|
||||
/** @param array<string, string> $views */
|
||||
private function theme(string $key, ?string $parent, array $views): void
|
||||
{
|
||||
$path = $this->themesPath.'/'.$key;
|
||||
mkdir($path.'/views', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode(array_filter([
|
||||
'name' => ucfirst($key),
|
||||
'slug' => $key,
|
||||
'extends' => $parent,
|
||||
], fn ($value) => $value !== null)));
|
||||
|
||||
foreach ($views as $relative => $contents) {
|
||||
$file = $path.'/views/'.$relative;
|
||||
mkdir(dirname($file), 0777, true);
|
||||
file_put_contents($file, $contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Modules\Stylist\Theme\Exceptions\InvalidThemeManifestException;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ThemeManifestTest extends TestCase
|
||||
{
|
||||
private string $themesPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->themesPath = storage_path('framework/testing/stylist-themes-'.uniqid());
|
||||
mkdir($this->themesPath, 0777, true);
|
||||
config(['stylist.themes_path' => $this->themesPath]);
|
||||
app(Loader::class)->flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
app(Filesystem::class)->deleteDirectory($this->themesPath);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_manifest_is_normalised(): void
|
||||
{
|
||||
$path = $this->themesPath.'/sample';
|
||||
mkdir($path.'/css', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample']));
|
||||
file_put_contents($path.'/css/theme.css', 'body{}');
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame('sample', $theme->key());
|
||||
$this->assertSame('css/theme.css', $theme->stylesheet());
|
||||
$this->assertSame('js/app.js', $theme->script());
|
||||
$this->assertSame('home', $theme->entryView());
|
||||
}
|
||||
|
||||
public function test_unsafe_manifest_path_is_rejected(): void
|
||||
{
|
||||
$path = $this->themesPath.'/unsafe';
|
||||
mkdir($path, 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'name' => 'Unsafe',
|
||||
'stylesheet' => '../secret.css',
|
||||
]));
|
||||
|
||||
$this->expectException(InvalidThemeManifestException::class);
|
||||
app(Loader::class)->fromPath($path);
|
||||
}
|
||||
public function test_phase_one_manifest_optional_capabilities_are_normalised(): void
|
||||
{
|
||||
$path = $this->themesPath.'/design-gallery';
|
||||
mkdir($path.'/css', 0777, true);
|
||||
mkdir($path.'/js', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'key' => 'design-gallery',
|
||||
'name' => 'Design Gallery',
|
||||
'stylesheet' => 'css/app.css',
|
||||
'script' => 'js/app.js',
|
||||
'appearances' => ['light', 'dark', 'system'],
|
||||
'accents' => ['blue', 'emerald', 'neutral'],
|
||||
'default_appearance' => 'system',
|
||||
'default_accent' => 'neutral',
|
||||
// Phase 1 incorrectly duplicated accents here; Stylist must not
|
||||
// reject the whole theme because of that compact legacy list.
|
||||
'variants' => ['blue', 'emerald', 'neutral'],
|
||||
]));
|
||||
file_put_contents($path.'/css/app.css', 'body{}');
|
||||
file_put_contents($path.'/js/app.js', '');
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances()));
|
||||
$this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents()));
|
||||
$this->assertSame([], $theme->variants());
|
||||
$this->assertSame('system', $theme->defaultAppearance());
|
||||
$this->assertSame('neutral', $theme->defaultAccent());
|
||||
}
|
||||
|
||||
public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void
|
||||
{
|
||||
$path = $this->themesPath.'/legacy';
|
||||
mkdir($path.'/css', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy']));
|
||||
file_put_contents($path.'/css/theme.css', 'body{}');
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame([], $theme->appearances());
|
||||
$this->assertSame([], $theme->accents());
|
||||
$this->assertSame('', $theme->defaultAppearance());
|
||||
$this->assertSame('', $theme->defaultAccent());
|
||||
}
|
||||
|
||||
public function test_defaults_must_reference_declared_optional_capabilities(): void
|
||||
{
|
||||
$path = $this->themesPath.'/invalid-default';
|
||||
mkdir($path, 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'name' => 'Invalid Default',
|
||||
'appearances' => ['light', 'dark'],
|
||||
'default_appearance' => 'system',
|
||||
]));
|
||||
|
||||
$this->expectException(InvalidThemeManifestException::class);
|
||||
app(Loader::class)->fromPath($path);
|
||||
}
|
||||
|
||||
public function test_manifest_version_and_capabilities_are_normalised(): void
|
||||
{
|
||||
$path = $this->themesPath.'/capable';
|
||||
mkdir($path.'/css', 0777, true);
|
||||
mkdir($path.'/js', 0777, true);
|
||||
mkdir($path.'/views', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'manifest_version' => 1,
|
||||
'name' => 'Capable',
|
||||
'appearances' => ['light', 'dark'],
|
||||
'accents' => ['blue'],
|
||||
'capabilities' => ['marketplace' => true],
|
||||
]));
|
||||
file_put_contents($path.'/css/theme.css', 'body{}');
|
||||
file_put_contents($path.'/js/app.js', '');
|
||||
file_put_contents($path.'/views/home.blade.php', '<div>Capable</div>');
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame(1, $theme->manifestVersion());
|
||||
$this->assertTrue($theme->hasCapability('appearances'));
|
||||
$this->assertTrue($theme->hasCapability('accents'));
|
||||
$this->assertTrue($theme->hasCapability('marketplace'));
|
||||
$this->assertFalse($theme->hasCapability('preview'));
|
||||
}
|
||||
|
||||
public function test_unknown_manifest_fields_are_preserved_without_preventing_loading(): void
|
||||
{
|
||||
$path = $this->themesPath.'/future';
|
||||
mkdir($path.'/css', 0777, true);
|
||||
file_put_contents($path.'/theme.json', json_encode([
|
||||
'name' => 'Future',
|
||||
'future_builder_metadata' => ['anything' => true],
|
||||
]));
|
||||
file_put_contents($path.'/css/theme.css', 'body{}');
|
||||
|
||||
$theme = app(Loader::class)->fromPath($path);
|
||||
|
||||
$this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Modules\Stylist\Services\ThemePackageManager;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
final class ThemePackageRoundTripTest extends TestCase
|
||||
{
|
||||
private string $themesPath;
|
||||
private string $workspace;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->themesPath = storage_path('framework/testing/stylist-installed-'.uniqid());
|
||||
$this->workspace = storage_path('framework/testing/stylist-package-'.uniqid());
|
||||
mkdir($this->themesPath, 0777, true);
|
||||
mkdir($this->workspace, 0777, true);
|
||||
config(['stylist.themes_path' => $this->themesPath]);
|
||||
app(Loader::class)->flush();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
app(Filesystem::class)->deleteDirectory($this->themesPath);
|
||||
app(Filesystem::class)->deleteDirectory($this->workspace);
|
||||
app(Loader::class)->flush();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_generated_theme_zip_installs_discovers_and_renders(): void
|
||||
{
|
||||
$source = $this->workspace.'/design-gallery';
|
||||
mkdir($source.'/css', 0777, true);
|
||||
mkdir($source.'/js', 0777, true);
|
||||
mkdir($source.'/views', 0777, true);
|
||||
|
||||
file_put_contents($source.'/theme.json', json_encode([
|
||||
'manifest_version' => 1,
|
||||
'key' => 'design-gallery',
|
||||
'name' => 'Design Gallery',
|
||||
'stylesheet' => 'css/app.css',
|
||||
'script' => 'js/app.js',
|
||||
'entry_view' => 'home',
|
||||
'appearances' => ['light', 'dark', 'system'],
|
||||
'accents' => ['blue', 'neutral'],
|
||||
'default_appearance' => 'system',
|
||||
'default_accent' => 'neutral',
|
||||
]));
|
||||
file_put_contents($source.'/css/app.css', 'body{}');
|
||||
file_put_contents($source.'/js/app.js', '');
|
||||
file_put_contents($source.'/views/home.blade.php', '<main>Design Gallery works</main>');
|
||||
|
||||
$zipPath = $this->workspace.'/design-gallery.zip';
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE));
|
||||
foreach (app(Filesystem::class)->allFiles($source) as $file) {
|
||||
$zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname());
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
$upload = new UploadedFile($zipPath, 'design-gallery.zip', 'application/zip', null, true);
|
||||
$theme = app(ThemePackageManager::class)->install($upload);
|
||||
|
||||
$this->assertSame('design-gallery', $theme->key());
|
||||
$this->assertTrue(app(Loader::class)->all()->has('design-gallery'));
|
||||
$this->assertStringContainsString(
|
||||
'Design Gallery works',
|
||||
stylist_theme_view('design-gallery', 'home')->render(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Modules\Stylist\View\ThemeViewResolver;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ThemeViewResolverTest extends TestCase
|
||||
{
|
||||
public function test_theme_view_candidates_prefer_active_theme_then_default(): void
|
||||
{
|
||||
config([
|
||||
'stylist.default_theme' => 'default',
|
||||
'stylist.themes_path' => resource_path('themes'),
|
||||
]);
|
||||
|
||||
$candidates = app(ThemeViewResolver::class)->candidates('components.page-hero');
|
||||
|
||||
$this->assertContains('stylist-theme-default::components.page-hero', $candidates);
|
||||
$this->assertContains('stylist::components.page-hero', $candidates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
final class WelcomeThemeViewTest extends TestCase
|
||||
{
|
||||
public function test_bundled_welcome_theme_view_namespace_is_registered(): void
|
||||
{
|
||||
$this->assertTrue(view()->exists('stylist-theme-welcome::welcome'));
|
||||
}
|
||||
|
||||
public function test_home_route_renders_the_welcome_theme(): void
|
||||
{
|
||||
$this->get('/')
|
||||
->assertOk()
|
||||
->assertViewIs('stylist-theme-welcome::welcome');
|
||||
}
|
||||
}
|
||||
@@ -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, '/')]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Stylist\View;
|
||||
|
||||
use Illuminate\Contracts\View\Factory as ViewFactory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Modules\Stylist\Theme\Exceptions\ThemeNotFoundException;
|
||||
use Modules\Stylist\Theme\Loader;
|
||||
use Modules\Stylist\Theme\Stylist;
|
||||
use RuntimeException;
|
||||
|
||||
final class ThemeViewResolver
|
||||
{
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $candidateCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly Stylist $stylist,
|
||||
private readonly ViewFactory $views,
|
||||
private readonly Loader $loader,
|
||||
) {}
|
||||
|
||||
public function make(string $view, array $data = [], array $mergeData = []): View
|
||||
{
|
||||
return $this->views->first(
|
||||
$this->candidates($view),
|
||||
array_replace([
|
||||
'themeSettings' => $this->stylist->settings(),
|
||||
'themeVariant' => $this->stylist->variant(),
|
||||
], $data),
|
||||
$mergeData,
|
||||
);
|
||||
}
|
||||
|
||||
public function makeForTheme(string $theme, string $view, array $data = [], array $mergeData = []): View
|
||||
{
|
||||
return $this->views->first($this->candidatesForTheme($theme, $view), $data, $mergeData);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function candidates(string $view): array
|
||||
{
|
||||
return $this->candidatesForTheme($this->stylist->current()->key(), $view);
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function candidatesForTheme(string $theme, string $view): array
|
||||
{
|
||||
$view = ltrim($view, '.');
|
||||
$cacheKey = $theme.'|'.$view;
|
||||
|
||||
if (isset($this->candidateCache[$cacheKey])) {
|
||||
return $this->candidateCache[$cacheKey];
|
||||
}
|
||||
|
||||
$keys = $this->inheritanceChain($theme);
|
||||
$fallback = (string) config('stylist.default_theme', 'common');
|
||||
|
||||
if ($fallback !== '' && ! in_array($fallback, $keys, true)) {
|
||||
try {
|
||||
$keys = array_merge($keys, $this->inheritanceChain($fallback));
|
||||
} catch (ThemeNotFoundException) {
|
||||
// A missing configured fallback is diagnosed by stylist:doctor.
|
||||
}
|
||||
}
|
||||
|
||||
if (! in_array('common', $keys, true)) {
|
||||
try {
|
||||
$keys = array_merge($keys, $this->inheritanceChain('common'));
|
||||
} catch (ThemeNotFoundException) {
|
||||
// The module fallback below remains available.
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
foreach (array_values(array_unique($keys)) as $key) {
|
||||
$this->registerNamespace($key);
|
||||
$candidates[] = "stylist-theme-{$key}::{$view}";
|
||||
$candidates[] = "stylist-module-theme-{$key}::{$view}";
|
||||
}
|
||||
|
||||
$candidates[] = "stylist::{$view}";
|
||||
$candidates[] = $view;
|
||||
|
||||
return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates));
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->candidateCache = [];
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function inheritanceChain(string $theme): array
|
||||
{
|
||||
$keys = [];
|
||||
$seen = [];
|
||||
$key = $theme;
|
||||
|
||||
while ($key !== '') {
|
||||
if (isset($seen[$key])) {
|
||||
throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].');
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$loaded = $this->loader->find($key);
|
||||
$keys[] = $loaded->key();
|
||||
$key = $loaded->parent() ?? '';
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function registerNamespace(string $theme): void
|
||||
{
|
||||
try {
|
||||
$loaded = $this->loader->find($theme);
|
||||
} catch (ThemeNotFoundException) {
|
||||
return;
|
||||
}
|
||||
|
||||
$viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views';
|
||||
if (! is_dir($viewsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->views->addNamespace("stylist-theme-{$theme}", $viewsPath);
|
||||
$this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
latest: 1.1.0
|
||||
releases:
|
||||
- version: 1.1.0
|
||||
date: 2026-07-08
|
||||
changes:
|
||||
- Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views.
|
||||
- Added ThemeViewResolver and stylist_view() fallback helper.
|
||||
- Added default and MPBA example themes.
|
||||
- Added theme publishing support.
|
||||
- Updated Vite inputs to include theme CSS files.
|
||||
- version: 1.0.0
|
||||
date: 2026-07-08
|
||||
changes:
|
||||
- Initial Laravel 13 Stylist module.
|
||||
|
||||
- version: 1.1.0
|
||||
date: 2026-07-23
|
||||
changes:
|
||||
- Added optional manifest_version/schema_version normalisation.
|
||||
- Added capability normalisation for appearances, accents, previews and future features.
|
||||
- Added stylist:validate for installed themes, directories and ZIP packages.
|
||||
- Added complete package validation during installation.
|
||||
- Added dynamic view namespace registration after installing a theme.
|
||||
- Added manifest compatibility and install-render regression coverage.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "mpba/stylist-module",
|
||||
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
|
||||
"type": "laravel-module",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"illuminate/support": "^13.0",
|
||||
"illuminate/view": "^13.0",
|
||||
"illuminate/filesystem": "^13.0",
|
||||
"illuminate/console": "^13.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Stylist\\": ""
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Modules\\Stylist\\Tests\\": "Tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Stylist",
|
||||
"alias": "stylist",
|
||||
"version": "1.1.0",
|
||||
"description": "Tenant-aware theme and asset switching module for Laravel 13.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"modules",
|
||||
"themes",
|
||||
"tenancy",
|
||||
"styling"
|
||||
],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\Stylist\\Providers\\StylistServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": [],
|
||||
"requires": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
||||
"stylist:dev": "vite --config Modules/Stylist/vite.config.js",
|
||||
"stylist:build": "vite build --config Modules/Stylist/vite.config.js",
|
||||
|
||||
"matches:dev": "vite --config Modules/Matches/vite.config.js",
|
||||
"matches:build": "vite build --config Modules/Matches/vite.config.js",
|
||||
|
||||
"modules:build": "npm run stylist:build && npm run matches:build",
|
||||
"build:all": "npm run build && npm run modules:build"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: [
|
||||
'Modules/Stylist/Resources/assets/css/app.css',
|
||||
'Modules/Stylist/Resources/assets/js/app.js',
|
||||
],
|
||||
|
||||
refresh: [
|
||||
'Modules/Stylist/Resources/views/**',
|
||||
'resources/themes/**/views/**',
|
||||
'Modules/Stylist/App/**',
|
||||
'Modules/Stylist/routes/**',
|
||||
],
|
||||
|
||||
buildDirectory: 'build/stylist',
|
||||
}),
|
||||
|
||||
tailwindcss(),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Actions;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class Logout
|
||||
{
|
||||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke(): Redirector|RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
Session::invalidate();
|
||||
Session::regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Appearance settings')]
|
||||
class Appearance extends Component
|
||||
{
|
||||
//
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('stylist-theme-common::livewire.settings.appearance')
|
||||
->layout('stylist-theme-common::components.layouts.app');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Livewire\Actions\Logout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class DeleteUserForm extends Component
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public string $password = '';
|
||||
|
||||
/**
|
||||
* Delete the currently authenticated user.
|
||||
*/
|
||||
public function deleteUser(Logout $logout): void
|
||||
{
|
||||
$this->validate([
|
||||
'password' => $this->currentPasswordRules(),
|
||||
]);
|
||||
|
||||
tap(Auth::user(), $logout(...))->delete();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('stylist-theme-common::livewire.settings.delete-user-form');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Profile settings')]
|
||||
class Profile extends Component
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile information for the currently authenticated user.
|
||||
*/
|
||||
public function updateProfileInformation(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $this->validate($this->profileRules($user->id));
|
||||
|
||||
$user->fill($validated);
|
||||
|
||||
if ($user->isDirty('email')) {
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
Flux::toast(variant: 'success', text: __('Profile updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email verification notification to the current user.
|
||||
*/
|
||||
public function resendVerificationNotification(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
|
||||
Flux::toast(text: __('A new verification link has been sent to your email address.'));
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function hasUnverifiedEmail(): bool
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return $user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function showDeleteUser(): bool
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return ! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('stylist-theme-common::livewire.settings.profile')
|
||||
->layout('stylist-theme-common::components.layouts.app');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Exception;
|
||||
use Flux\Flux;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Passkeys\Actions\DeletePasskey;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Title('Security settings')]
|
||||
class Security extends Component
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $password_confirmation = '';
|
||||
|
||||
#[Locked]
|
||||
public bool $canManageTwoFactor;
|
||||
|
||||
#[Locked]
|
||||
public bool $twoFactorEnabled;
|
||||
|
||||
#[Locked]
|
||||
public bool $requiresConfirmation;
|
||||
|
||||
#[Locked]
|
||||
public string $qrCodeSvg = '';
|
||||
|
||||
#[Locked]
|
||||
public string $manualSetupKey = '';
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public bool $showVerificationStep = false;
|
||||
|
||||
#[Validate('required|string|size:6', onUpdate: false)]
|
||||
public string $code = '';
|
||||
|
||||
#[Locked]
|
||||
public bool $canManagePasskeys;
|
||||
|
||||
/**
|
||||
* @var array<int, array{id: int, name: string, authenticator: string|null, created_at_diff: string, last_used_at_diff: string|null}>
|
||||
*/
|
||||
#[Locked]
|
||||
public array $passkeys = [];
|
||||
|
||||
public bool $showDeleteModal = false;
|
||||
|
||||
#[Locked]
|
||||
public ?int $deletingPasskeyId = null;
|
||||
|
||||
#[Locked]
|
||||
public string $deletingPasskeyName = '';
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
|
||||
{
|
||||
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
|
||||
|
||||
if ($this->canManageTwoFactor) {
|
||||
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
|
||||
$disableTwoFactorAuthentication(auth()->user());
|
||||
}
|
||||
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
|
||||
}
|
||||
|
||||
$this->canManagePasskeys = Features::canManagePasskeys();
|
||||
|
||||
if ($this->canManagePasskeys) {
|
||||
$this->loadPasskeys();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the password for the currently authenticated user.
|
||||
*/
|
||||
public function updatePassword(): void
|
||||
{
|
||||
try {
|
||||
$validated = $this->validate([
|
||||
'current_password' => $this->currentPasswordRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
]);
|
||||
} catch (ValidationException $e) {
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->update([
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
Flux::toast(variant: 'success', text: __('Password updated.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user's passkeys.
|
||||
*/
|
||||
public function loadPasskeys(): void
|
||||
{
|
||||
$this->passkeys = Auth::user()->passkeys()
|
||||
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($passkey) => [
|
||||
'id' => $passkey->id,
|
||||
'name' => $passkey->name,
|
||||
'authenticator' => $passkey->authenticator,
|
||||
'created_at_diff' => $passkey->created_at->diffForHumans(),
|
||||
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete confirmation modal.
|
||||
*/
|
||||
public function confirmDelete(int $passkeyId): void
|
||||
{
|
||||
$passkey = Auth::user()->passkeys()->findOrFail($passkeyId);
|
||||
|
||||
$this->deletingPasskeyId = $passkey->id;
|
||||
$this->deletingPasskeyName = $passkey->name;
|
||||
$this->showDeleteModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the passkey.
|
||||
*/
|
||||
public function deletePasskey(DeletePasskey $deletePasskey): void
|
||||
{
|
||||
if (! $this->deletingPasskeyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$passkey = $user->passkeys()->findOrFail($this->deletingPasskeyId);
|
||||
|
||||
$deletePasskey($user, $passkey);
|
||||
|
||||
$this->closeDeleteModal();
|
||||
$this->loadPasskeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the delete confirmation modal.
|
||||
*/
|
||||
public function closeDeleteModal(): void
|
||||
{
|
||||
$this->showDeleteModal = false;
|
||||
$this->deletingPasskeyId = null;
|
||||
$this->deletingPasskeyName = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable two-factor authentication for the user.
|
||||
*/
|
||||
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
|
||||
{
|
||||
$enableTwoFactorAuthentication(auth()->user());
|
||||
|
||||
if (! $this->requiresConfirmation) {
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
|
||||
$this->loadSetupData();
|
||||
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the two-factor authentication setup data for the user.
|
||||
*/
|
||||
private function loadSetupData(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
|
||||
$this->manualSetupKey = decrypt($user->two_factor_secret);
|
||||
} catch (Exception) {
|
||||
$this->addError('setupData', 'Failed to fetch setup data.');
|
||||
|
||||
$this->reset('qrCodeSvg', 'manualSetupKey');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the two-factor verification step if necessary.
|
||||
*/
|
||||
public function showVerificationIfNecessary(): void
|
||||
{
|
||||
if ($this->requiresConfirmation) {
|
||||
$this->showVerificationStep = true;
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm two-factor authentication for the user.
|
||||
*/
|
||||
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
$this->twoFactorEnabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset two-factor verification state.
|
||||
*/
|
||||
public function resetVerification(): void
|
||||
{
|
||||
$this->reset('code', 'showVerificationStep');
|
||||
|
||||
$this->resetErrorBag();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable two-factor authentication for the user.
|
||||
*/
|
||||
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
|
||||
{
|
||||
$disableTwoFactorAuthentication(auth()->user());
|
||||
|
||||
$this->twoFactorEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the two-factor authentication modal.
|
||||
*/
|
||||
public function closeModal(): void
|
||||
{
|
||||
$this->reset(
|
||||
'code',
|
||||
'manualSetupKey',
|
||||
'qrCodeSvg',
|
||||
'showModal',
|
||||
'showVerificationStep',
|
||||
);
|
||||
|
||||
$this->resetErrorBag();
|
||||
|
||||
if (! $this->requiresConfirmation) {
|
||||
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current modal configuration state.
|
||||
*
|
||||
* @return array{title: string, description: string, buttonText: string}
|
||||
*/
|
||||
#[Computed]
|
||||
public function modalConfig(): array
|
||||
{
|
||||
if ($this->twoFactorEnabled) {
|
||||
return [
|
||||
'title' => __('Two-factor authentication enabled'),
|
||||
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
|
||||
'buttonText' => __('Close'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->showVerificationStep) {
|
||||
return [
|
||||
'title' => __('Verify authentication code'),
|
||||
'description' => __('Enter the 6-digit code from your authenticator app.'),
|
||||
'buttonText' => __('Continue'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => __('Enable two-factor authentication'),
|
||||
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
|
||||
'buttonText' => __('Continue'),
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('stylist-theme-common::livewire.settings.security')
|
||||
->layout('stylist-theme-common::components.layouts.app');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings\TwoFactor;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Exception;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class RecoveryCodes extends Component
|
||||
{
|
||||
/** @var list<string> */
|
||||
#[Locked]
|
||||
public array $recoveryCodes = [];
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadRecoveryCodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new recovery codes for the user.
|
||||
*/
|
||||
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
|
||||
{
|
||||
$generateNewRecoveryCodes(auth()->user());
|
||||
|
||||
$this->loadRecoveryCodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the recovery codes for the user.
|
||||
*/
|
||||
private function loadRecoveryCodes(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
|
||||
try {
|
||||
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
|
||||
} catch (Exception) {
|
||||
$this->addError('recoveryCodes', 'Failed to load recovery codes');
|
||||
|
||||
$this->recoveryCodes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('stylist-theme-common::livewire.settings.two-factor.recovery-codes');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
* @property Carbon|null $two_factor_confirmed_at
|
||||
* @property string|null $remember_token
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
public function initials(): string
|
||||
{
|
||||
$initials = Str::initials($this->name, true);
|
||||
|
||||
return Str::length($initials) > 1
|
||||
? Str::substr($initials, 0, 1).Str::substr($initials, -1)
|
||||
: $initials;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure default behaviors for production-ready applications.
|
||||
*/
|
||||
protected function configureDefaults(): void
|
||||
{
|
||||
Date::use(CarbonImmutable::class);
|
||||
|
||||
DB::prohibitDestructiveCommands(
|
||||
app()->isProduction(),
|
||||
);
|
||||
|
||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||
? Password::min(12)
|
||||
->mixedCase()
|
||||
->letters()
|
||||
->numbers()
|
||||
->symbols()
|
||||
->uncompromised()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('stylist-theme-common::livewire.auth.login'));
|
||||
Fortify::verifyEmailView(fn () => view('stylist-theme-common::livewire.auth.verify-email'));
|
||||
Fortify::twoFactorChallengeView(fn () => view('stylist-theme-common::livewire.auth.two-factor-challenge'));
|
||||
Fortify::confirmPasswordView(fn () => view('stylist-theme-common::livewire.auth.confirm-password'));
|
||||
Fortify::registerView(fn () => view('stylist-theme-common::livewire.auth.register'));
|
||||
Fortify::resetPasswordView(fn () => view('stylist-theme-common::livewire.auth.reset-password'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('stylist-theme-common::livewire.auth.forgot-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()).'|'.$request->ip(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/livewire-starter-kit",
|
||||
"type": "project",
|
||||
"description": "The official Laravel starter kit for Livewire.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/chisel": "^0.1.0",
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/blaze": "^1.0",
|
||||
"livewire/flux": "^2.13.1",
|
||||
"livewire/livewire": "^4.1",
|
||||
"mpba/laravel-modules": "dev-main",
|
||||
"predis/predis": "^3.5",
|
||||
"ramsey/uuid": "^4.9",
|
||||
"spatie/laravel-permission": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"laravel/sail": "^1.53",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.9.3",
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-laravel": "^4.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/",
|
||||
"Modules\\": "Modules/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:MPBA-Ltd/laravel_modules.git"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"lint": [
|
||||
"pint --parallel"
|
||||
],
|
||||
"lint:check": [
|
||||
"pint --parallel --test"
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@test"
|
||||
],
|
||||
"types:check": [
|
||||
"phpstan analyse"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@lint:check",
|
||||
"@types:check",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [],
|
||||
"installer": {
|
||||
"post-create-project": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
Generated
+11572
File diff suppressed because it is too large
Load Diff
+126
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', '')),
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim((string) env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings configure Fortify's passkey (WebAuthn) support.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'allowed_origins' => [config('app.url')],
|
||||
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features, or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0
|
||||
]),
|
||||
Features::passkeys([
|
||||
'confirmPassword' => true,
|
||||
]),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
use mpba\Modules\Activators\DatabaseActivator;
|
||||
use mpba\Modules\Activators\FileActivator;
|
||||
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Module Namespace
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Default module namespace.
|
||||
|
|
||||
*/
|
||||
|
||||
'namespace' => 'Modules',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Module Stubs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Default module stubs.
|
||||
|
|
||||
*/
|
||||
|
||||
'stubs' => [
|
||||
'enabled' => false,
|
||||
'path' => base_path() . '/Vendors/mpba/laravel-modules/src/Commands/stubs',
|
||||
'files' => [
|
||||
'routes/web' => 'Routes/web.php',
|
||||
'routes/api' => 'Routes/api.php',
|
||||
'views/index' => 'Resources/views/index.blade.php',
|
||||
'views/master' => 'Resources/views/layouts/master.blade.php',
|
||||
'scaffold/config' => 'Config/config.php',
|
||||
'composer' => 'composer.json',
|
||||
'assets/js/app' => 'Resources/assets/js/app.js',
|
||||
'assets/sass/app' => 'Resources/assets/sass/app.scss',
|
||||
'webpack' => 'webpack.mix.js',
|
||||
'package' => 'package.json',
|
||||
],
|
||||
'replacements' => [
|
||||
'routes/web' => ['LOWER_NAME', 'STUDLY_NAME'],
|
||||
'routes/api' => ['LOWER_NAME'],
|
||||
'webpack' => ['LOWER_NAME'],
|
||||
'json' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'PROVIDER_NAMESPACE'],
|
||||
'views/index' => ['LOWER_NAME'],
|
||||
'views/master' => ['LOWER_NAME', 'STUDLY_NAME'],
|
||||
'scaffold/config' => ['STUDLY_NAME'],
|
||||
'composer' => [
|
||||
'LOWER_NAME',
|
||||
'STUDLY_NAME',
|
||||
'VENDOR',
|
||||
'AUTHOR_NAME',
|
||||
'AUTHOR_EMAIL',
|
||||
'MODULE_NAMESPACE',
|
||||
'PROVIDER_NAMESPACE',
|
||||
],
|
||||
],
|
||||
'gitkeep' => true,
|
||||
],
|
||||
'paths' => [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modules path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This path used for save the generated module. This path also will be added
|
||||
| automatically to list of scanned folders.
|
||||
|
|
||||
*/
|
||||
|
||||
'modules' => base_path('Modules'),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modules assets path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may update the modules assets path.
|
||||
|
|
||||
*/
|
||||
|
||||
'assets' => public_path('modules'),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The migrations path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Where you run 'module:publish-migration' command, where do you publish the
|
||||
| the migration files?
|
||||
|
|
||||
*/
|
||||
|
||||
'migration' => base_path('database/migrations'),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Generator path
|
||||
|--------------------------------------------------------------------------
|
||||
| Customise the paths where the folders will be generated.
|
||||
| Set the generate key to false to not generate that folder
|
||||
*/
|
||||
'generator' => [
|
||||
'config' => ['path' => 'Config', 'generate' => true],
|
||||
'trait' => ['path' => 'Traits', 'generate' => true],
|
||||
'helper' => ['path' => 'Helpers', 'generate' => true],
|
||||
'command' => ['path' => 'Console', 'generate' => true],
|
||||
'support' => ['path' => 'Support', 'generate' => true],
|
||||
'migration' => ['path' => 'Database/Migrations', 'generate' => true],
|
||||
'seeder' => ['path' => 'Database/Seeders', 'generate' => true],
|
||||
'factory' => ['path' => 'Database/factories', 'generate' => true],
|
||||
'model' => ['path' => 'Models', 'generate' => true],
|
||||
'routes' => ['path' => 'Routes', 'generate' => true],
|
||||
'controller' => ['path' => 'Http/Controllers', 'generate' => true],
|
||||
'filter' => ['path' => 'Http/Middleware', 'generate' => true],
|
||||
'request' => ['path' => 'Http/Requests', 'generate' => true],
|
||||
'provider' => ['path' => 'Providers', 'generate' => true],
|
||||
'assets' => ['path' => 'Resources/assets', 'generate' => true],
|
||||
'lang' => ['path' => 'Resources/lang', 'generate' => true],
|
||||
'views' => ['path' => 'Resources/views', 'generate' => true],
|
||||
'test' => ['path' => 'Tests/Unit', 'generate' => true],
|
||||
'test-feature' => ['path' => 'Tests/Feature', 'generate' => true],
|
||||
'test-browser' => ['path' => 'Tests/Browser', 'generate' => true,],
|
||||
'repository' => ['path' => 'Repositories', 'generate' => false],
|
||||
'event' => ['path' => 'Events', 'generate' => false],
|
||||
'listener' => ['path' => 'Listeners', 'generate' => false],
|
||||
'policies' => ['path' => 'Policies', 'generate' => false],
|
||||
'rules' => ['path' => 'Rules', 'generate' => false],
|
||||
'jobs' => ['path' => 'Jobs', 'generate' => false],
|
||||
'emails' => ['path' => 'Emails', 'generate' => false],
|
||||
'notifications' => ['path' => 'Notifications', 'generate' => false],
|
||||
'resource' => ['path' => 'Transformers', 'generate' => false],
|
||||
'component' => ['path' => 'View', 'generate' => true],
|
||||
],
|
||||
],
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scan Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you define which folder will be scanned. By default will scan Vendors
|
||||
| directory. This is useful if you host the package in packagist website.
|
||||
|
|
||||
*/
|
||||
|
||||
'scan' => [
|
||||
'enabled' => false,
|
||||
'paths' => [
|
||||
base_path('Vendors/*/*'),
|
||||
],
|
||||
],
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Composer File Template
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here is the config for composer.json file, generated by this package
|
||||
|
|
||||
*/
|
||||
|
||||
'composer' => [
|
||||
'Vendors' => 'mpba',
|
||||
'author' => [
|
||||
'name' => 'Mark Gregory',
|
||||
'email' => 'mark.gregory@mpba.co.uk',
|
||||
],
|
||||
],
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Caching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here is the config for setting up caching feature.
|
||||
|
|
||||
*/
|
||||
'cache' => [
|
||||
'enabled' => false,
|
||||
'key' => 'laravel-modules',
|
||||
'lifetime' => 60,
|
||||
],
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Choose what laravel-modules will register as custom namespaces.
|
||||
| Setting one to false will require you to register that part
|
||||
| in your own Service Provider class.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'register' => [
|
||||
'translations' => true,
|
||||
/**
|
||||
* load files on boot or register method
|
||||
*
|
||||
* @example boot|register
|
||||
*/
|
||||
'files' => 'register',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Activators
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You can define new types of activators here, file, database etc. The only
|
||||
| required parameter is 'class'.
|
||||
| The file activator will store the activation status in storage/installed_modules
|
||||
*/
|
||||
'activators' => [
|
||||
'file' => [
|
||||
'class' => FileActivator::class,
|
||||
'statuses-file' => base_path('modules_statuses.json'),
|
||||
'cache-key' => 'activator.installed',
|
||||
'cache-lifetime' => 604800,
|
||||
],
|
||||
'database' => [
|
||||
'class' => DatabaseActivator::class,
|
||||
'cache-key' => 'activator.installed',
|
||||
'cache-lifetime' => 604800,
|
||||
],
|
||||
],
|
||||
|
||||
'activator' => 'file',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Module control admin UI
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings control the package-provided module management screens.
|
||||
| The UI uses the database activator and the module_statuses table.
|
||||
|
|
||||
*/
|
||||
'admin' => [
|
||||
'enabled' => true,
|
||||
'prefix' => 'admin/modules',
|
||||
'middleware' => ['web', 'auth'],
|
||||
'layout_component' => 'layouts.app',
|
||||
'hero_component' => 'wms.page-hero',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Resend, Postmark, AWS, and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session',
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user