Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: tests

on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
ci:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug

- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader

- name: Run Tests
run: composer test
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
`src/` contains the package code, organized by Laravel-style domains such as `Commands/`, `Http/`, `Jobs/`, `Models/`, `Rules/`, and `Traits/`. In-repo subpackages live under `src/packages/` (`auth`, `datatables`, `lang-extractor`, `sidebar`, `toastify`) and are PSR-4 autoloaded from `composer.json`. Package configuration lives in `config/redot.php`, and generator templates in `stubs/`. Treat `storage/` as runtime output, not source.

## Build, Test, and Development Commands
Run `composer install` to install dependencies for PHP 8.3+ and Laravel 13+. Use `composer lint` or `vendor/bin/pint` to apply the repository formatting rules before pushing. This repository is a library, not a standalone app, so there is no local web server command here; validate behavior through the consuming Laravel application when changing service providers, middleware, or stubs.
Run `composer install` to install dependencies for PHP 8.3+ and Laravel 13+. Use `composer test` or `vendor/bin/pest` to run the Pest/Orchestra Testbench suite, and `composer lint` or `vendor/bin/pint` to apply the repository formatting rules before pushing. This repository is a library, not a standalone app, so there is no local web server command here; validate behavior through the consuming Laravel application when changing service providers, middleware, or stubs that are not covered by package tests.

## Coding Style & Naming Conventions
Follow Laravel conventions and PSR-4 namespaces: classes use `StudlyCase`, methods and properties use `camelCase`, and config, view, and stub files use `snake_case` or dot-oriented Laravel naming where appropriate. Use 4-space indentation in PHP. Format with Laravel Pint using the preset from `pint.json`; keep concatenation spacing consistent and avoid manual style deviations that Pint will rewrite.

## Testing Guidelines
There is currently no committed `tests/` suite or PHPUnit/Pest configuration in this repository. At minimum, run `composer lint` and verify changes in a host Laravel app. If you introduce automated tests, place them under `tests/Feature/*Test.php` or `tests/Unit/*Test.php` and keep coverage focused on package behavior, especially commands, middleware, and service-provider bootstrapping.
The repository has a Pest suite backed by Orchestra Testbench. Keep root package tests under `tests/Feature/Core` or `tests/Unit/Core`, and bundled package tests under `tests/Unit/Packages/<PackageName>` or `tests/Feature/Packages/<PackageName>` when feature-level package tests are needed. Prefer package-level fixtures and Laravel fakes over host-app assumptions; compiled views and temp artifacts should stay outside repository `storage/`. Run `composer test` and `composer lint` before pushing, and still verify behavior in a host Laravel app when changing service providers, middleware, stubs, or workflows not covered by automated tests.

## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, `refactor:`, and `chore:`. Keep commit subjects short and imperative, for example `fix: handle missing upload path`. Open PRs against `master`, include a concise description, note any config or migration impact, and list manual verification steps. Include screenshots only when changing rendered views or generated stubs. CI runs Pint on pull requests and may auto-commit formatting fixes, so lint locally first.
Expand Down
534 changes: 534 additions & 0 deletions PLAN.md

Large diffs are not rendered by default.

23 changes: 21 additions & 2 deletions src/Models/Setting.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Redot\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Redot\Casts\Union;

class Setting extends Model
Expand Down Expand Up @@ -93,14 +94,32 @@ public static function default(string $key): mixed
protected static function booted()
{
static::created(function ($setting) {
cache()->forget('settings.' . $setting->key);
static::forgetCachedValue($setting);
});

static::updated(function ($setting) {
cache()->forget('settings.' . $setting->key);
static::forgetCachedValue($setting);
});
}

/**
* Forget cached setting values, including nested keys cached separately.
*/
protected static function forgetCachedValue(self $setting): void
{
cache()->forget('settings.' . $setting->key);

foreach (array_keys(Arr::dot(Arr::wrap(static::default($setting->key)))) as $key) {
cache()->forget('settings.' . $setting->key . '.' . $key);
}

if (is_array($setting->value)) {
foreach (array_keys(Arr::dot($setting->value)) as $key) {
cache()->forget('settings.' . $setting->key . '.' . $key);
}
}
}

/**
* Get the specified setting value.
*/
Expand Down
2 changes: 0 additions & 2 deletions src/RedotServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@
use Redot\Commands\SyncPermissionsCommand;
use Redot\Commands\ViewMakeCommand;
use Redot\Datatables\DatatablesServiceProvider;
use Redot\LangExtractor\LaravelLangExtractorServiceProvider;
use Redot\Models\Language;
use Redot\Rules\Captcha;
use Redot\Rules\Phone;
Expand Down Expand Up @@ -67,7 +66,6 @@ public function register(): void

$this->app->register(RedotAuthServiceProvider::class);
$this->app->register(DatatablesServiceProvider::class);
$this->app->register(LaravelLangExtractorServiceProvider::class);
$this->app->register(LaravelToastifyServiceProvider::class);
}

Expand Down
2 changes: 1 addition & 1 deletion src/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -437,5 +437,5 @@ function parse_csv(string|array $csv, ?string $separator = ',', ?callable $callb
$csv = explode($separator, $csv);
}

return array_filter(array_map($callback ?: 'trim', $csv));
return array_values(array_filter(array_map($callback ?: 'trim', $csv)));
}
37 changes: 0 additions & 37 deletions src/packages/lang-extractor/src/Console/LangExtractCommand.php

This file was deleted.

6 changes: 4 additions & 2 deletions src/packages/lang-extractor/src/LangExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function __construct($directories = [], $extensions = [])
}

if (count($extensions) > 0) {
$this->withExtensions('php');
$this->withExtensions(...$extensions);
}

$this->pattern = $this->generatePatternUsing('__', 'trans', '@lang');
Expand Down Expand Up @@ -83,7 +83,9 @@ protected function generatePatternUsing(string ...$functions): string
$ignore = glob(lang_path(config('app.fallback_locale')) . '/*.php');
$ignore = array_map(fn ($file) => basename($file, '.php') . '\.[^\s]', $ignore);

return '/(?:' . implode('|', $functions) . ")\((['\"])(?<translation>(?!" . implode('|', $ignore) . ")(?:[^']|\\\')+?)(?<!\\\\)\\1/s";
$ignore = count($ignore) > 0 ? '(?!' . implode('|', $ignore) . ')' : '';

return '/(?:' . implode('|', $functions) . ")\((['\"])(?<translation>{$ignore}(?:[^']|\\\')+?)(?<!\\\\)\\1/s";
}

/**
Expand Down

This file was deleted.

28 changes: 28 additions & 0 deletions tests/Feature/Core/Middleware/EnsureDependenciesBuiltTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Redot\Http\Middleware\EnsureDependenciesBuilt;
use Symfony\Component\HttpFoundation\Response;

it('continues without rebuilding when the dependency lock is current', function () {
$directory = public_path('assets/dist');
$trackedFile = 'composer.json';

File::ensureDirectoryExists($directory);
File::put($directory . '/lock.json', json_encode([
'files' => [
$trackedFile => filemtime(base_path($trackedFile)),
],
'directories' => [],
]));

$response = (new EnsureDependenciesBuilt)->handle(
Request::create('/'),
fn () => new Response('next')
);

expect($response->getContent())->toBe('next');

File::delete($directory . '/lock.json');
});
37 changes: 37 additions & 0 deletions tests/Feature/Core/Middleware/LocalizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use Illuminate\Support\Facades\Route;
use Redot\Http\Middleware\Localization;

it('sets the application locale from the route parameter', function () {
Route::middleware(Localization::class)
->get('/{locale}/localized-probe', fn () => response(app()->getLocale() . '|' . request()->route('locale', 'missing')))
->name('website.localized-probe');

$this->get('/ar/localized-probe')
->assertOk()
->assertSee('ar|missing');

expect(session('website_locale'))->toBe('ar')
->and(app()->getLocale())->toBe('ar');
});

it('redirects unsupported route locales to the fallback locale', function () {
Route::middleware(Localization::class)
->get('/{locale}/fallback-probe', fn () => response('ok'))
->name('website.fallback-probe');

$this->get('/fr/fallback-probe?foo=bar')
->assertRedirect('/en/fallback-probe?foo=bar')
->assertStatus(301);
});

it('lets the locale query string override the route locale', function () {
Route::middleware(Localization::class)
->get('/{locale}/query-locale-probe', fn () => response('ok'))
->name('website.query-locale-probe');

$this->get('/en/query-locale-probe?locale=ar')
->assertRedirect('/ar/query-locale-probe?locale=ar')
->assertStatus(301);
});
53 changes: 53 additions & 0 deletions tests/Feature/Core/ServiceProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
use Redot\Sidebar\Sidebar;
use Redot\Toastify\Toastify;

it('boots the package testbench application', function () {
expect(app()->bound(Sidebar::class))->toBeTrue()
->and(app()->make(Sidebar::class))->toBeInstanceOf(Sidebar::class)
->and(app('sidebar'))->toBe(app()->make(Sidebar::class))
->and(app()->bound(Toastify::class))->toBeTrue()
->and(app('toastify'))->toBeInstanceOf(Toastify::class);
});

it('merges package configuration', function () {
expect(config('redot.features.dashboard.enabled'))->toBeTrue()
->and(config('redot.features.dashboard.prefix'))->toBe('dashboard')
->and(config('redot.locales'))->toHaveCount(2)
->and(config('datatables.assets'))->toBeArray()
->and(config('toastify.defaults'))->toBeArray()
->and(config('toastify.toastifiers.success'))->toBeArray();
});

it('runs package migrations in the testbench database', function () {
foreach ([
'settings',
'languages',
'language_tokens',
'login_tokens',
'permissions',
'roles',
'model_has_permissions',
'model_has_roles',
'role_has_permissions',
] as $table) {
expect(Schema::hasTable($table))->toBeTrue("Expected table [$table] to exist.");
}
});

it('registers package artisan commands', function () {
$commands = array_keys(Artisan::all());

expect($commands)->toContain(
'uploads:clear',
'permissions:sync',
'lang:extract',
'lang:sync',
'lang:publish',
'lang:revert',
'make:datatable',
);
});
78 changes: 78 additions & 0 deletions tests/Feature/Core/StaticIntegrityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;

function package_root(): string
{
return dirname(__DIR__, 3);
}

it('autoloads all package classes declared under composer psr-4 roots', function () {
$root = package_root();
$composer = json_decode(File::get($root . '/composer.json'), true, flags: JSON_THROW_ON_ERROR);
$prefixes = $composer['autoload']['psr-4'];
$autoloadFiles = collect($composer['autoload']['files'] ?? [])
->map(fn (string $path): string => realpath($root . '/' . $path))
->filter()
->all();
$packagePaths = collect($prefixes)
->reject(fn (string $path, string $namespace): bool => $namespace === 'Redot\\')
->map(fn (string $path): string => realpath($root . '/' . $path))
->filter()
->all();
Comment on lines +21 to +25
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$packagePaths is computed but never used. Dropping it (or using it to constrain which paths are scanned) would reduce noise and keep the test intent clearer.

Suggested change
$packagePaths = collect($prefixes)
->reject(fn (string $path, string $namespace): bool => $namespace === 'Redot\\')
->map(fn (string $path): string => realpath($root . '/' . $path))
->filter()
->all();

Copilot uses AI. Check for mistakes.

foreach ($prefixes as $namespace => $path) {
foreach (File::allFiles($root . '/' . $path) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}

if (in_array(realpath($file->getPathname()), $autoloadFiles, true)) {
continue;
}

if ($namespace === 'Redot\\' && Str::startsWith($file->getPathname(), $root . '/src/packages')) {
continue;
}

$relative = Str::of($file->getRelativePathname())
->replace(DIRECTORY_SEPARATOR, '\\')
->replaceEnd('.php', '');

$class = $namespace . $relative;

expect(class_exists($class) || interface_exists($class) || trait_exists($class))
->toBeTrue("Expected [$class] to autoload.");
}
}
});

it('keeps command classes concrete and named', function () {
foreach (File::allFiles(package_root() . '/src/Commands') as $file) {
$class = 'Redot\\Commands\\' . $file->getBasename('.php');
$reflection = new ReflectionClass($class);

expect($reflection->isSubclassOf(Command::class))->toBeTrue("$class must extend Command.");

$instance = app($class);
$name = method_exists($instance, 'getName') ? $instance->getName() : null;

expect($name)->not->toBeEmpty("$class must define a command name.");
}
});

it('can resolve package views referenced by service providers', function () {
foreach ([
'toastify::css',
'toastify::js',
'datatables::datatable',
'datatables::filters.string',
'datatables::partials.table',
'datatables::pagination.default',
] as $view) {
expect(View::exists($view))->toBeTrue("Expected view [$view] to exist.");
}
});
Loading
Loading