diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec8b900a..be013226 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,8 @@ jobs: with: node-version: 20 cache: 'npm' + - run: composer remove laravel/folio --dev --no-update --no-interaction + if: matrix.laravel == 9 - run: composer require laravel/framework:"${{ matrix.laravel }}.*" --no-update --no-interaction - uses: ramsey/composer-install@v2 with: diff --git a/composer.json b/composer.json index 342c2c03..4c8e709c 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "laravel/framework": ">=9.0" }, "require-dev": { + "laravel/folio": "^1.1", "orchestra/testbench": "^7.0 || ^8.0 || ^9.0", "phpunit/phpunit": "^9.5 || ^10.3" }, diff --git a/src/Ziggy.php b/src/Ziggy.php index 9b3be04a..247029e4 100644 --- a/src/Ziggy.php +++ b/src/Ziggy.php @@ -5,9 +5,14 @@ use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Reflector; use Illuminate\Support\Str; use JsonSerializable; +use Laravel\Folio\FolioManager; +use Laravel\Folio\FolioRoutes; +use Laravel\Folio\Pipeline\MatchedView; +use Laravel\Folio\Pipeline\PotentiallyBindablePathSegment; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; @@ -137,19 +142,23 @@ private function nameKeyedRoutes() $routes->put($name, $route); }); - return $routes->map(function ($route) use ($bindings) { - return collect($route)->only(['uri', 'methods', 'wheres']) - ->put('domain', $route->domain()) - ->put('parameters', $route->parameterNames()) - ->put('bindings', $bindings[$route->getName()] ?? []) - ->when($middleware = config('ziggy.middleware'), function ($collection) use ($middleware, $route) { - if (is_array($middleware)) { - return $collection->put('middleware', collect($route->middleware())->intersect($middleware)->values()->all()); - } - - return $collection->put('middleware', $route->middleware()); - })->filter(); - }); + return tap($this->folioRoutes(), fn ($all) => $routes->each( + fn ($route, $name) => $all->put( + $name, + collect($route)->only(['uri', 'methods', 'wheres']) + ->put('domain', $route->domain()) + ->put('parameters', $route->parameterNames()) + ->put('bindings', $bindings[$route->getName()] ?? []) + ->when($middleware = config('ziggy.middleware'), function ($collection) use ($middleware, $route) { + if (is_array($middleware)) { + return $collection->put('middleware', collect($route->middleware())->intersect($middleware)->values()->all()); + } + + return $collection->put('middleware', $route->middleware()); + }) + ->filter() + ) + )); } /** @@ -218,4 +227,80 @@ private function resolveBindings(array $routes): array return $routes; } + + /** + * @see https://github.com/laravel/folio/blob/master/src/Console/ListCommand.php + */ + private function folioRoutes(): Collection + { + if (! app()->has(FolioRoutes::class)) { + return collect(); + } + + // Use existing named Folio routes (instead of searching view files) to respect route caching + return collect(app(FolioRoutes::class)->routes())->map(function (array $route) { + $uri = rtrim($route['baseUri'], '/') . str_replace([$route['mountPath'], '.blade.php'], '', $route['path']); + + $segments = explode('/', $uri); + $parameters = []; + $bindings = []; + + foreach ($segments as $i => $segment) { + // Folio doesn't support sub-segment parameters + if (Str::startsWith($segment, '[')) { + $param = new PotentiallyBindablePathSegment($segment); + + $parameters[] = $name = $param->variable(); + $segments[$i] = "{{$name}}"; + + if ($field = $param->field()) { + $bindings[$name] = $field; + } elseif ($param->bindable()) { + $override = (new ReflectionClass($param->class()))->isInstantiable() && ( + (new ReflectionMethod($param->class(), 'getRouteKeyName'))->class !== Model::class + || (new ReflectionMethod($param->class(), 'getKeyName'))->class !== Model::class + || (new ReflectionProperty($param->class(), 'primaryKey'))->class !== Model::class + ); + + $bindings[$name] = $override ? app($param->class())->getRouteKeyName() : 'id'; + } + } + } + + $uri = implode('/', $segments); + $uri = Str::replaceEnd('/index', '', $uri); + + if ($route['domain'] && str_contains($route['domain'], '{')) { + preg_match_all('/{(.*?)}/', $route['domain'], $matches); + array_unshift($parameters, ...$matches[1]); + } + + $middleware = []; + + if ($ziggyMiddleware = config('ziggy.middleware')) { + $mountPath = Arr::first( + app(FolioManager::class)->mountPaths(), + fn ($mountPath) => $mountPath->path === realpath($route['mountPath']) + ); + $matchedView = new MatchedView(realpath($route['path']), [], $route['mountPath']); + + $middleware = $mountPath->middleware + ->match($matchedView) + ->prepend('web') + ->merge($matchedView->inlineMiddleware()) + ->unique() + ->when(is_array($ziggyMiddleware), fn ($middleware) => $middleware->intersect($ziggyMiddleware)) + ->values()->all(); + } + + return array_filter([ + 'uri' => $uri === '' ? '/' : trim($uri, '/'), + 'methods' => ['GET'], + 'domain' => $route['domain'], + 'parameters' => $parameters, + 'bindings' => $bindings, + 'middleware' => $middleware, + ]); + }); + } } diff --git a/tests/Unit/FolioTest.php b/tests/Unit/FolioTest.php new file mode 100644 index 00000000..664db3aa --- /dev/null +++ b/tests/Unit/FolioTest.php @@ -0,0 +1,440 @@ +version())) < 10) { + $this->markTestSkipped('Folio requires Laravel >=10'); + } + } + + protected function tearDown(): void + { + File::deleteDirectories(resource_path('views')); + + parent::tearDown(); + } + + protected function getPackageProviders($app) + { + return [ + ZiggyServiceProvider::class, + ...((int) head(explode('.', app()->version())) >= 10 ? [FolioServiceProvider::class] : []), + ]; + } + + /** @test */ + public function include_named_folio_routes() + { + File::ensureDirectoryExists(resource_path('views/pages')); + File::put(resource_path('views/pages/about.blade.php'), 'assertSame([ + 'about' => [ + 'uri' => 'about', + 'methods' => ['GET'], + ], + 'users.show' => [ + 'uri' => 'users/{id}', + 'methods' => ['GET'], + 'parameters' => ['id'], + ], + 'laravel-folio' => [ + 'uri' => '{fallbackPlaceholder}', + 'methods' => ['GET', 'HEAD'], + 'wheres' => ['fallbackPlaceholder' => '.*'], + 'parameters' => ['fallbackPlaceholder'], + ], + ], (new Ziggy())->toArray()['routes']); + } + + /** @test */ + public function normal_routes_override_folio_routes() + { + app('router')->get('about', $this->noop())->name('about'); + app('router')->getRoutes()->refreshNameLookups(); + + File::ensureDirectoryExists(resource_path('views/pages')); + File::put(resource_path('views/pages/about.blade.php'), 'assertSame([ + 'about' => [ + 'uri' => 'about', + // Folio routes only respond to 'GET', so this is the web route + 'methods' => ['GET', 'HEAD'], + ], + ], Arr::except((new Ziggy())->toArray()['routes'], 'laravel-folio')); + } + + /** @test */ + public function parameters() + { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[id].blade.php'), 'assertSame([ + 'uri' => 'users/{id}', + 'methods' => ['GET'], + 'parameters' => ['id'], + ], (new Ziggy())->toArray()['routes']['users.show']); + $this->assertSame([ + 'uri' => 'users/{ids}', + 'methods' => ['GET'], + 'parameters' => ['ids'], + ], (new Ziggy())->toArray()['routes']['users.some']); + } + + /** @test */ + public function domains() + { + File::ensureDirectoryExists(resource_path('views/pages/admin')); + File::put(resource_path('views/pages/admin/[...ids].blade.php'), 'path(resource_path('views/pages/admin'))->uri('admin'); + + $this->assertSame([ + 'admins.some' => [ + 'uri' => 'admin/{ids}', + 'methods' => ['GET'], + 'domain' => '{account}.{org}.ziggy.dev', + 'parameters' => ['account', 'org', 'ids'], + ], + ], Arr::except((new Ziggy())->toArray()['routes'], 'laravel-folio')); + } + + /** @test */ + public function paths_and_uris() + { + File::ensureDirectoryExists(resource_path('views/pages/guest')); + File::ensureDirectoryExists(resource_path('views/pages/admin')); + File::put(resource_path('views/pages/guest/[id].blade.php'), 'uri('/'); + Folio::path(resource_path('views/pages/admin'))->uri('/admin'); + + $this->assertSame([ + 'guests.show' => [ + 'uri' => '{id}', + 'methods' => ['GET'], + 'parameters' => ['id'], + ], + 'admins.some' => [ + 'uri' => 'admin/{ids}', + 'methods' => ['GET'], + 'parameters' => ['ids'], + ], + ], Arr::except((new Ziggy())->toArray()['routes'], 'laravel-folio')); + } + + /** @test */ + public function index_pages() + { + File::ensureDirectoryExists(resource_path('views/pages/index/index')); + File::put(resource_path('views/pages/index.blade.php'), 'assertSame([ + 'uri' => '/', + 'methods' => ['GET'], + ], (new Ziggy())->toArray()['routes']['root']); + $this->assertSame([ + 'uri' => 'index/index', + 'methods' => ['GET'], + ], (new Ziggy())->toArray()['routes']['index.index']); + } + + /** @test */ + public function nested_pages() + { + File::ensureDirectoryExists(resource_path('views/pages/[slug]')); + File::put(resource_path('views/pages/[slug]/[id].blade.php'), 'assertSame([ + 'nested' => [ + 'uri' => '{slug}/{id}', + 'methods' => ['GET'], + 'parameters' => ['slug', 'id'], + ], + ], Arr::except((new Ziggy())->toArray()['routes'], 'laravel-folio')); + } + + /** @test */ + public function custom_view_data_variable_names() + { + File::ensureDirectoryExists(resource_path('views/pages/users/[.App.User-$leader]/users')); + File::put(resource_path('views/pages/users/[.App.User-$leader]/users/[.App.User-$follower].blade.php'), 'assertSame([ + 'uri' => 'users/{leader}/users/{follower}', + 'methods' => ['GET'], + 'parameters' => ['leader', 'follower'], + ], (new Ziggy())->toArray()['routes']['follower']); + if (! windows_os()) { + $this->assertSame([ + 'uri' => 'linux-users/{leader}/users/{follower}', + 'methods' => ['GET'], + 'parameters' => ['leader', 'follower'], + ], (new Ziggy())->toArray()['routes']['linux.follower']); + } + } + + /** @test */ + public function middleware() + { + File::ensureDirectoryExists(resource_path('views/pages/admin')); + File::put(resource_path('views/pages/admin/index.blade.php'), 'uri('admin') + ->middleware(['*' => ['auth']]); + + config(['ziggy.middleware' => true]); + + $this->assertSame([ + 'uri' => 'admin', + 'methods' => ['GET'], + 'middleware' => ['web', 'auth'], + ], (new Ziggy())->toArray()['routes']['admin.index']); + $this->assertSame([ + 'uri' => 'admin/special', + 'methods' => ['GET'], + 'middleware' => ['web', 'auth', 'special'], + ], (new Ziggy())->toArray()['routes']['admin.special']); + } + + /** @test */ + public function binding_fields() + { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[User].blade.php'), 'assertSame([ + 'uri' => 'posts/{post}', + 'methods' => ['GET'], + 'parameters' => ['post'], + 'bindings' => [ + 'post' => 'slug', + ], + ], (new Ziggy())->toArray()['routes']['posts.show']); + } + $this->assertSame([ + 'uri' => 'users/{user}', + 'methods' => ['GET'], + 'parameters' => ['user'], + ], (new Ziggy())->toArray()['routes']['users.show']); + $this->assertSame([ + 'uri' => 'teams/{team}', + 'methods' => ['GET'], + 'parameters' => ['team'], + 'bindings' => [ + 'team' => 'uid', + ], + ], (new Ziggy())->toArray()['routes']['teams.show']); + } + + /** @test */ + public function custom_model_paths() + { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[.App.User].blade.php'), 'assertSame([ + 'uri' => 'posts/{post}', + 'methods' => ['GET'], + 'parameters' => ['post'], + 'bindings' => [ + 'post' => 'slug', + ], + ], (new Ziggy())->toArray()['routes']['posts.show']); + } + $this->assertSame([ + 'uri' => 'users/{user}', + 'methods' => ['GET'], + 'parameters' => ['user'], + ], (new Ziggy())->toArray()['routes']['users.show']); + $this->assertSame([ + 'uri' => 'teams/{team}', + 'methods' => ['GET'], + 'parameters' => ['team'], + 'bindings' => [ + 'team' => 'uid', + ], + ], (new Ziggy())->toArray()['routes']['teams.show']); + } + + /** @test */ + public function implicit_route_model_bindings() + { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[.Tests.Unit.FolioUser].blade.php'), 'assertSame([ + 'uri' => 'users/{folioUser}', + 'methods' => ['GET'], + 'parameters' => ['folioUser'], + 'bindings' => [ + 'folioUser' => 'uuid', + ], + ], (new Ziggy)->toArray()['routes']['users.show']); + $this->assertSame([ + 'uri' => 'tags/{folioTag}', + 'methods' => ['GET'], + 'parameters' => ['folioTag'], + 'bindings' => [ + 'folioTag' => 'id', + ], + ], (new Ziggy)->toArray()['routes']['tags.show']); + + $this->assertTrue(FolioUser::$wasBooted); + $this->assertFalse(FolioTag::$wasBooted); + } + + /** @test */ + public function implicit_route_model_bindings_with_custom_variable() + { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[.Tests.Unit.FolioUser-$user].blade.php'), 'assertSame([ + 'uri' => 'users/{user}', + 'methods' => ['GET'], + 'parameters' => ['user'], + 'bindings' => [ + 'user' => 'uuid', + ], + ], (new Ziggy)->toArray()['routes']['users.show']); + if (! windows_os()) { + $this->assertSame([ + 'uri' => 'tags/{tag}', + 'methods' => ['GET'], + 'parameters' => ['tag'], + 'bindings' => [ + 'tag' => 'id', + ], + ], (new Ziggy)->toArray()['routes']['tags.show']); + } + } + + /** @test */ + public function model_bindings_with_both_custom_field_and_custom_variable() + { + if (! windows_os()) { + File::ensureDirectoryExists(resource_path('views/pages/users')); + File::put(resource_path('views/pages/users/[.Tests.Unit.FolioUser:email|user].blade.php'), 'assertSame([ + 'uri' => 'users/{user}', + 'methods' => ['GET'], + 'parameters' => ['user'], + 'bindings' => [ + 'user' => 'email', + ], + ], (new Ziggy)->toArray()['routes']['users.show']); + } + $this->assertSame([ + 'uri' => 'tags/{tag}', + 'methods' => ['GET'], + 'parameters' => ['tag'], + 'bindings' => [ + 'tag' => 'slug', + ], + ], (new Ziggy)->toArray()['routes']['tags.show']); + } +} + +class FolioUser extends Model +{ + public static $wasBooted = false; + + public static function boot() + { + parent::boot(); + static::$wasBooted = true; + } + + public function getRouteKeyName() + { + return 'uuid'; + } +} + +class FolioTag extends Model +{ + public static $wasBooted = false; + + public static function boot() + { + parent::boot(); + static::$wasBooted = true; + } +}