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