├── LICENSE.md ├── composer.json ├── functions.php ├── src ├── Console │ ├── InstallCommand.php │ ├── ListCommand.php │ └── MakeCommand.php ├── Events │ └── ViewMatched.php ├── Exceptions │ ├── PossibleDirectoryTraversal.php │ └── UrlGenerationException.php ├── Folio.php ├── FolioManager.php ├── FolioRoutes.php ├── FolioServiceProvider.php ├── InlineMetadataInterceptor.php ├── Metadata.php ├── MountPath.php ├── Options │ └── PageOptions.php ├── PathBasedMiddlewareList.php ├── PendingRoute.php ├── Pipeline │ ├── ContinueIterating.php │ ├── EnsureMatchesDomain.php │ ├── EnsureNoDirectoryTraversal.php │ ├── FindsWildcardViews.php │ ├── MatchDirectoryIndexViews.php │ ├── MatchLiteralDirectories.php │ ├── MatchLiteralViews.php │ ├── MatchRootIndex.php │ ├── MatchWildcardDirectories.php │ ├── MatchWildcardViews.php │ ├── MatchWildcardViewsThatCaptureMultipleSegments.php │ ├── MatchedView.php │ ├── PotentiallyBindablePathSegment.php │ ├── SetMountPathOnMatchedView.php │ ├── State.php │ ├── StopIterating.php │ └── TransformModelBindings.php ├── RequestHandler.php ├── Router.php └── Support │ └── Project.php ├── stubs ├── FolioServiceProvider.stub └── folio-page.stub └── testbench.yaml /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/folio", 3 | "description": "Page based routing for Laravel.", 4 | "keywords": ["laravel", "routing"], 5 | "homepage": "https://github.com/laravel/folio", 6 | "license": "MIT", 7 | "support": { 8 | "issues": "https://github.com/laravel/folio/issues", 9 | "source": "https://github.com/laravel/folio" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Taylor Otwell", 14 | "email": "taylor@laravel.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.1", 19 | "illuminate/container": "^10.19|^11.0|^12.0", 20 | "illuminate/console": "^10.19|^11.0|^12.0", 21 | "illuminate/contracts": "^10.19|^11.0|^12.0", 22 | "illuminate/filesystem": "^10.19|^11.0|^12.0", 23 | "illuminate/pipeline": "^10.19|^11.0|^12.0", 24 | "illuminate/routing": "^10.19|^11.0|^12.0", 25 | "illuminate/support": "^10.19|^11.0|^12.0", 26 | "illuminate/view": "^10.19|^11.0|^12.0", 27 | "spatie/once": "^3.1", 28 | "symfony/console": "^6.0|^7.0" 29 | }, 30 | "require-dev": { 31 | "orchestra/testbench": "^8.6.0|^9.0|^10.0", 32 | "pestphp/pest": "^2.5|^3.0", 33 | "phpstan/phpstan": "^1.10" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Laravel\\Folio\\": "src/" 38 | }, 39 | "files": [ 40 | "functions.php" 41 | ] 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Tests\\": "tests/", 46 | "Workbench\\App\\": "workbench/app", 47 | "Workbench\\Database\\": "workbench/database" 48 | } 49 | }, 50 | "extra": { 51 | "branch-alias": { 52 | "dev-master": "1.x-dev" 53 | }, 54 | "laravel": { 55 | "providers": [ 56 | "Laravel\\Folio\\FolioServiceProvider" 57 | ] 58 | } 59 | }, 60 | "config": { 61 | "sort-packages": true, 62 | "allow-plugins": { 63 | "pestphp/pest-plugin": true 64 | } 65 | }, 66 | "scripts": { 67 | "post-autoload-dump": "@composer run prepare", 68 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 69 | "build": [ 70 | "@composer run prepare", 71 | "@php ./vendor/bin/testbench package:create-sqlite-db", 72 | "@php ./vendor/bin/testbench migrate:refresh" 73 | ], 74 | "start": [ 75 | "@composer run prepare", 76 | "@php ./vendor/bin/testbench serve" 77 | ] 78 | }, 79 | "minimum-stability": "dev", 80 | "prefer-stable": true 81 | } 82 | -------------------------------------------------------------------------------- /functions.php: -------------------------------------------------------------------------------- 1 | make(InlineMetadataInterceptor::class)->whenListening( 16 | fn () => Metadata::instance()->renderUsing = $callback, 17 | ); 18 | 19 | return new PageOptions; 20 | } 21 | 22 | /** 23 | * Specify the name of the current page. 24 | */ 25 | function name(string $name): PageOptions 26 | { 27 | Container::getInstance()->make(InlineMetadataInterceptor::class)->whenListening( 28 | fn () => Metadata::instance()->name = $name, 29 | ); 30 | 31 | return new PageOptions; 32 | } 33 | 34 | /** 35 | * Add one or more middleware to the current page. 36 | */ 37 | function middleware(Closure|string|array $middleware = []): PageOptions 38 | { 39 | Container::getInstance()->make(InlineMetadataInterceptor::class)->whenListening( 40 | fn () => Metadata::instance()->middleware = Metadata::instance()->middleware->merge(Arr::wrap($middleware)), 41 | ); 42 | 43 | return new PageOptions; 44 | } 45 | 46 | /** 47 | * Indicate that the current page should include trashed models. 48 | */ 49 | function withTrashed(bool $withTrashed = true): PageOptions 50 | { 51 | Container::getInstance()->make(InlineMetadataInterceptor::class)->whenListening( 52 | fn () => Metadata::instance()->withTrashed = $withTrashed, 53 | ); 54 | 55 | return new PageOptions; 56 | } 57 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | components->info('Publishing Folio Service Provider.'); 34 | 35 | $this->callSilent('vendor:publish', ['--tag' => 'folio-provider']); 36 | 37 | $this->registerFolioServiceProvider(); 38 | 39 | $this->ensurePagesDirectoryExists(); 40 | 41 | $this->components->info('Folio scaffolding installed successfully.'); 42 | } 43 | 44 | /** 45 | * Ensure the pages directory exists. 46 | */ 47 | protected function ensurePagesDirectoryExists(): void 48 | { 49 | if (! is_dir($directory = resource_path('views/pages'))) { 50 | File::ensureDirectoryExists($directory); 51 | 52 | File::put($directory.'/.gitkeep', ''); 53 | } 54 | } 55 | 56 | /** 57 | * Register the Folio service provider in the application configuration file. 58 | */ 59 | protected function registerFolioServiceProvider(): void 60 | { 61 | if (method_exists(ServiceProvider::class, 'addProviderToBootstrapFile') && 62 | ServiceProvider::addProviderToBootstrapFile(\App\Providers\FolioServiceProvider::class)) { // @phpstan-ignore-line 63 | return; 64 | } 65 | 66 | $namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace()); 67 | 68 | $appConfig = file_get_contents(config_path('app.php')); 69 | 70 | if (Str::contains($appConfig, $namespace.'\\Providers\\FolioServiceProvider::class')) { 71 | return; 72 | } 73 | 74 | $lineEndingCount = [ 75 | "\r\n" => substr_count($appConfig, "\r\n"), 76 | "\r" => substr_count($appConfig, "\r"), 77 | "\n" => substr_count($appConfig, "\n"), 78 | ]; 79 | 80 | $eol = array_keys($lineEndingCount, max($lineEndingCount))[0]; 81 | 82 | file_put_contents(config_path('app.php'), str_replace( 83 | "{$namespace}\\Providers\RouteServiceProvider::class,".$eol, 84 | "{$namespace}\\Providers\RouteServiceProvider::class,".$eol." {$namespace}\Providers\FolioServiceProvider::class,".$eol, 85 | $appConfig 86 | )); 87 | 88 | file_put_contents(app_path('Providers/FolioServiceProvider.php'), str_replace( 89 | "namespace App\Providers;", 90 | "namespace {$namespace}\Providers;", 91 | file_get_contents(app_path('Providers/FolioServiceProvider.php')) 92 | )); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Console/ListCommand.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | protected $headers = ['Domain', 'Method', 'URI', 'Name', 'View']; 34 | 35 | /** 36 | * Execute the console command. 37 | */ 38 | public function handle(): void 39 | { 40 | $mountPaths = $this->laravel->make(FolioManager::class)->mountPaths(); 41 | 42 | $routes = $this->routesFromMountPaths($mountPaths); 43 | 44 | if ($routes->isEmpty()) { 45 | $this->components->error("Your application doesn't have any routes."); 46 | } else { 47 | $this->displayRoutes( 48 | $this->toDisplayableFormat($routes->all()), 49 | ); 50 | } 51 | } 52 | 53 | /** 54 | * Get the formatted action for display on the CLI. 55 | * 56 | * @param array $route 57 | */ 58 | protected function formatActionForCli($route): string 59 | { 60 | $action = $route['view']; 61 | 62 | if (is_string($route['name'])) { 63 | $action = $route['name'].' › '.$action; 64 | } 65 | 66 | return $action; 67 | } 68 | 69 | /** 70 | * Compute the routes from the given mounted paths. 71 | * 72 | * @return \Illuminate\Support\Collection 73 | */ 74 | protected function routesFromMountPaths(array $mountPaths): Collection 75 | { 76 | return collect($mountPaths)->map(function (MountPath $mountPath) { 77 | $views = Finder::create()->in($mountPath->path)->name('*.blade.php')->files()->getIterator(); 78 | 79 | $baseUri = rtrim($mountPath->baseUri, '/'); 80 | $domain = $mountPath->domain; 81 | $mountPath = str_replace(DIRECTORY_SEPARATOR, '/', $mountPath->path); 82 | 83 | $path = '/'.ltrim($mountPath, '/'); 84 | 85 | return collect($views) 86 | ->map(function (SplFileInfo $view) use ($baseUri, $domain, $mountPath) { 87 | $viewPath = str_replace(DIRECTORY_SEPARATOR, '/', $view->getRealPath()); 88 | $uri = $baseUri.str_replace($mountPath, '', $viewPath); 89 | 90 | if (count($this->laravel->make(FolioManager::class)->mountPaths()) === 1) { 91 | $action = str_replace($mountPath.'/', '', $viewPath); 92 | } else { 93 | $basePath = str_replace(DIRECTORY_SEPARATOR, '/', base_path(DIRECTORY_SEPARATOR)); 94 | 95 | if (str_contains($basePath, '/vendor/orchestra/')) { 96 | $basePath = Str::before($basePath, '/vendor/orchestra/').'/'; 97 | } 98 | 99 | $action = str_replace($basePath, '', $viewPath); 100 | } 101 | 102 | $uri = str_replace('.blade.php', '', $uri); 103 | 104 | $uri = collect(explode('/', $uri)) 105 | ->map(function (string $currentSegment) { 106 | if (Str::startsWith($currentSegment, '[...')) { 107 | $formattedSegment = '[...'; 108 | } elseif (Str::startsWith($currentSegment, '[')) { 109 | $formattedSegment = '['; 110 | } else { 111 | return $currentSegment; 112 | } 113 | 114 | $lastPartOfSegment = str($currentSegment)->whenContains( 115 | '.', 116 | fn (Stringable $string) => $string->afterLast('.'), 117 | fn (Stringable $string) => $string->afterLast('['), 118 | ); 119 | 120 | return $formattedSegment.match (true) { 121 | $lastPartOfSegment->contains(':') => $lastPartOfSegment->beforeLast(':')->camel() 122 | .':'.$lastPartOfSegment->afterLast(':'), 123 | $lastPartOfSegment->contains('-') => $lastPartOfSegment->beforeLast('-')->camel() 124 | .':'.$lastPartOfSegment->afterLast('-'), 125 | default => $lastPartOfSegment->camel(), 126 | }; 127 | }) 128 | ->implode('/'); 129 | 130 | $uri = preg_replace_callback('/\[(.*?)\]/', function (array $matches) { 131 | return '{'.Str::camel($matches[1]).'}'; 132 | }, $uri); 133 | 134 | $uri = str_replace(['/index', '/index/'], ['', '/'], $uri); 135 | 136 | return [ 137 | 'method' => 'GET', 138 | 'domain' => $domain, 139 | 'uri' => $uri === '' ? '/' : $uri, 140 | 'name' => $this->routeName($mountPath, $viewPath), 141 | 'action' => $action, 142 | 'view' => $action, 143 | ]; 144 | }); 145 | })->flatten(1) 146 | ->unique(fn (array $route) => $route['uri']) 147 | ->values(); 148 | } 149 | 150 | /** 151 | * Get the route name for the given mount path and view path. 152 | */ 153 | protected function routeName(string $mountPath, string $viewPath): ?string 154 | { 155 | return collect($this->laravel->make(FolioRoutes::class)->routes())->search(function (array $route) use ($mountPath, $viewPath) { 156 | ['mountPath' => $routeRelativeMountPath, 'path' => $routeRelativeViewPath] = $route; 157 | 158 | return $routeRelativeMountPath === Project::relativePathOf($mountPath) 159 | && $routeRelativeViewPath === Project::relativePathOf($viewPath); 160 | }) ?: null; 161 | } 162 | 163 | /** 164 | * Convert the given routes to JSON. 165 | * 166 | * @param \Illuminate\Support\Collection $routes 167 | */ 168 | protected function asJson($routes): string 169 | { 170 | return $routes->values()->toJson(); 171 | } 172 | 173 | /** 174 | * Convert the given routes to regular CLI output. 175 | * 176 | * @param \Illuminate\Support\Collection $routes 177 | * @return array 178 | */ 179 | protected function forCli($routes): array 180 | { 181 | return parent::forCli(collect($routes)->map(fn ($route) => array_merge([ 182 | 'middleware' => '', 183 | ], $route))); 184 | } 185 | 186 | /** 187 | * Compile the routes into a displayable format. 188 | * 189 | * @param array $routes 190 | * @return array 191 | */ 192 | protected function toDisplayableFormat(array $routes): array 193 | { 194 | $routes = collect($routes)->filter($this->filterRoute(...))->values()->all(); 195 | 196 | if (($sort = $this->option('sort')) !== null) { 197 | $routes = $this->sortRoutes($sort, $routes); 198 | } else { 199 | $routes = $this->sortRoutes('uri', $routes); 200 | } 201 | 202 | if ($this->option('reverse')) { 203 | $routes = array_reverse($routes); 204 | } 205 | 206 | return $this->pluckColumns($routes); 207 | } 208 | 209 | /** 210 | * Filter the route by URI and / or name. 211 | * 212 | * @param array $route 213 | * @return array|null 214 | */ 215 | protected function filterRoute(array $route): ?array 216 | { 217 | if ($this->option('name') && ! Str::contains((string) $route['name'], $this->option('name'))) { 218 | return null; 219 | } 220 | 221 | if (($this->option('path') && ! Str::contains($route['uri'], $this->option('path')))) { 222 | return null; 223 | } 224 | 225 | if (($this->option('domain') && ! Str::contains((string) $route['domain'], $this->option('domain')))) { 226 | return null; 227 | } 228 | 229 | if ($this->option('except-path')) { 230 | foreach (explode(',', $this->option('except-path')) as $path) { 231 | if (str_contains($route['uri'], $path)) { 232 | return null; 233 | } 234 | } 235 | } 236 | 237 | return $route; 238 | } 239 | 240 | /** 241 | * Sort the routes by a given element. 242 | * 243 | * @param string $sort 244 | * @return array 245 | */ 246 | protected function sortRoutes($sort, array $routes): array 247 | { 248 | if ($sort !== 'uri') { 249 | return parent::sortRoutes($sort, $routes); 250 | } 251 | 252 | usort($routes, function (array $first, array $second) use ($sort) { 253 | $first = Str::of($first[$sort]); 254 | $second = Str::of($second[$sort]); 255 | 256 | if ( 257 | $first->beforeLast('/') === $second->beforeLast('/') 258 | && $first->afterLast('/')->startsWith('{') && ! $second->afterLast('/')->startsWith('{') 259 | ) { 260 | return -1; 261 | } 262 | 263 | if ($second->startsWith($first)) { 264 | return $first->explode('/')->count() > $second->explode('/')->count() ? 1 : -1; 265 | } 266 | 267 | return $first->value() <=> $second->value(); 268 | }); 269 | 270 | return $routes; 271 | } 272 | 273 | /** 274 | * Get the console command options. 275 | * 276 | * @return array> 277 | */ 278 | protected function getOptions(): array 279 | { 280 | return [ 281 | ['json', null, InputOption::VALUE_NONE, 'Output the route list as JSON'], 282 | ['name', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by name'], 283 | ['domain', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by domain'], 284 | ['path', null, InputOption::VALUE_OPTIONAL, 'Only show routes matching the given path pattern'], 285 | ['except-path', null, InputOption::VALUE_OPTIONAL, 'Do not display the routes matching the given path pattern'], 286 | ['reverse', 'r', InputOption::VALUE_NONE, 'Reverse the ordering of the routes'], 287 | ['sort', null, InputOption::VALUE_OPTIONAL, 'The column (domain, name, uri, view) to sort by', 'uri'], 288 | ]; 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /src/Console/MakeCommand.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | protected $aliases = ['make:folio']; 42 | 43 | /** 44 | * Get the destination view path. 45 | * 46 | * @param string $name 47 | */ 48 | protected function getPath($name): string 49 | { 50 | $mountPath = Folio::paths()[0] ?? resource_path('views/pages'); 51 | 52 | return $mountPath.'/'.preg_replace_callback( 53 | '/(?:\[.*?\])|(\w+)/', 54 | fn (array $matches) => empty($matches[1]) ? $matches[0] : Str::lower($matches[1]), 55 | Str::finish($this->argument('name'), '.blade.php') 56 | ); 57 | } 58 | 59 | /** 60 | * Get the stub file for the generator. 61 | */ 62 | protected function getStub(): string 63 | { 64 | return file_exists($customPath = $this->laravel->basePath('stubs/folio-page.stub')) 65 | ? $customPath 66 | : __DIR__.'/../../stubs/folio-page.stub'; 67 | } 68 | 69 | /** 70 | * Get the console command arguments. 71 | */ 72 | protected function getOptions(): array 73 | { 74 | return [ 75 | ['force', 'f', InputOption::VALUE_NONE, 'Create the Folio page even if the page already exists'], 76 | ]; 77 | } 78 | 79 | /** 80 | * Prompt for missing input arguments using the returned questions. 81 | * 82 | * @return array 83 | */ 84 | protected function promptForMissingArgumentsUsing() 85 | { 86 | return [ 87 | 'name' => class_exists(Application::class) && version_compare(Application::VERSION, '10.17.0', '>=') 88 | ? ['What should the page be named?', 'E.g. users/index, users/[User]'] 89 | : 'What should the page be named?', 90 | ]; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Events/ViewMatched.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $mountPaths = []; 20 | 21 | /** 22 | * The callback that should be used to render matched views. 23 | */ 24 | protected ?Closure $renderUsing = null; 25 | 26 | /** 27 | * The callback that should be used when terminating the manager. 28 | */ 29 | protected ?Closure $terminateUsing = null; 30 | 31 | /** 32 | * The view that was last matched by Folio. 33 | */ 34 | protected ?MatchedView $lastMatchedView = null; 35 | 36 | /** 37 | * Register a route to handle page based routing at the given paths. 38 | * 39 | * @param array> $middleware 40 | * 41 | * @throws \InvalidArgumentException 42 | */ 43 | public function route(?string $path = null, ?string $uri = '/', array $middleware = []): PendingRoute 44 | { 45 | return new PendingRoute( 46 | $this, 47 | $path ? $path : config('view.paths')[0].'/pages', 48 | $uri, 49 | $middleware 50 | ); 51 | } 52 | 53 | /** 54 | * Registers the given route. 55 | * 56 | * @param array> $middleware 57 | */ 58 | public function registerRoute(string $path, string $uri, array $middleware, ?string $domain): void 59 | { 60 | $path = realpath($path); 61 | $uri = '/'.ltrim($uri, '/'); 62 | 63 | if (! is_dir($path)) { 64 | throw new InvalidArgumentException("The given path [{$path}] is not a directory."); 65 | } 66 | 67 | $this->mountPaths[] = $mountPath = new MountPath( 68 | $path, 69 | $uri, 70 | $middleware, 71 | $domain, 72 | ); 73 | 74 | Route::fallback($this->handler())->name('laravel-folio'); 75 | } 76 | 77 | /** 78 | * Get the Folio request handler function. 79 | */ 80 | protected function handler(): Closure 81 | { 82 | return function (Request $request) { 83 | $this->terminateUsing = null; 84 | 85 | $mountPaths = collect($this->mountPaths)->filter( 86 | fn (MountPath $mountPath) => str_starts_with(mb_strtolower('/'.$request->path()), $mountPath->baseUri) 87 | )->all(); 88 | 89 | return (new RequestHandler( 90 | $mountPaths, 91 | $this->renderUsing, 92 | fn (MatchedView $matchedView) => $this->lastMatchedView = $matchedView, 93 | ))($request); 94 | }; 95 | } 96 | 97 | /** 98 | * Get the middleware that should be applied to the Folio handled URI. 99 | * 100 | * @return array 101 | */ 102 | public function middlewareFor(string $uri): array 103 | { 104 | foreach ($this->mountPaths as $mountPath) { 105 | if (! $matchedView = (new Router($mountPath))->match(new Request, $uri)) { 106 | continue; 107 | } 108 | 109 | return $mountPath->middleware->match($matchedView)->merge( 110 | $matchedView->inlineMiddleware() 111 | )->unique()->values()->all(); 112 | } 113 | 114 | return []; 115 | } 116 | 117 | /** 118 | * Get a piece of data from the route / view that was last matched by Folio. 119 | */ 120 | public function data(?string $key = null, mixed $default = null): mixed 121 | { 122 | return Arr::get($this->lastMatchedView?->data ?: [], $key, $default); 123 | } 124 | 125 | /** 126 | * Specify the callback that should be used to render matched views. 127 | */ 128 | public function renderUsing(?Closure $callback = null): static 129 | { 130 | $this->renderUsing = $callback; 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Execute the pending termination callback. 137 | */ 138 | public function terminate(): void 139 | { 140 | if ($this->terminateUsing) { 141 | try { 142 | ($this->terminateUsing)(); 143 | } finally { 144 | $this->terminateUsing = null; 145 | } 146 | } 147 | } 148 | 149 | /** 150 | * Specify the callback that should be used when terminating the application. 151 | */ 152 | public function terminateUsing(?Closure $callback = null): static 153 | { 154 | $this->terminateUsing = $callback; 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Get the array of mounted paths that have been registered. 161 | * 162 | * @return array 163 | */ 164 | public function mountPaths(): array 165 | { 166 | return $this->mountPaths; 167 | } 168 | 169 | /** 170 | * Get the mounted directory paths as strings. 171 | * 172 | * @return array 173 | */ 174 | public function paths(): array 175 | { 176 | return collect($this->mountPaths)->map->path->all(); 177 | } 178 | 179 | /** 180 | * Dynamically pass methods to a new pending route registration. 181 | * 182 | * @param array $parameters 183 | */ 184 | public function __call(string $method, array $parameters): PendingRoute 185 | { 186 | return $this->route()->$method(...$parameters); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/FolioRoutes.php: -------------------------------------------------------------------------------- 1 | $routes 28 | */ 29 | public function __construct( 30 | protected FolioManager $manager, 31 | protected string $cachedFolioRoutesPath, 32 | protected array $routes = [], 33 | protected bool $loaded = false, 34 | ) { 35 | $this->cachedFolioRoutesPath = str_replace(DIRECTORY_SEPARATOR, '/', $this->cachedFolioRoutesPath); 36 | } 37 | 38 | /** 39 | * Persist the loaded routes into the cache. 40 | */ 41 | public function persist(): void 42 | { 43 | $this->flush(); 44 | 45 | $this->ensureLoaded(); 46 | 47 | File::put( 48 | $this->cachedFolioRoutesPath, 49 | ' static::$version, 51 | 'routes' => $this->routes, 52 | ], true).';', 53 | ); 54 | } 55 | 56 | /** 57 | * Ensure the routes have been loaded into memory. 58 | */ 59 | protected function ensureLoaded(): void 60 | { 61 | if (! $this->loaded) { 62 | $this->load(); 63 | } 64 | 65 | $this->loaded = true; 66 | } 67 | 68 | /** 69 | * Load the routes into memory. 70 | */ 71 | protected function load(): void 72 | { 73 | if ($this->loaded) { 74 | return; 75 | } 76 | 77 | if (File::exists($this->cachedFolioRoutesPath)) { 78 | $cache = File::getRequire($this->cachedFolioRoutesPath); 79 | 80 | if (isset($cache['version']) && (int) $cache['version'] === static::$version) { 81 | $this->routes = $cache['routes']; 82 | 83 | $this->loaded = true; 84 | 85 | return; 86 | } 87 | } 88 | 89 | foreach ($this->manager->mountPaths() as $mountPath) { 90 | $views = Finder::create()->in($mountPath->path)->name('*.blade.php')->files()->getIterator(); 91 | 92 | foreach ($views as $view) { 93 | $matchedView = new MatchedView($view->getRealPath(), [], $mountPath->path); 94 | 95 | if ($name = $matchedView->name()) { 96 | $this->routes[$name] = [ 97 | 'mountPath' => Project::relativePathOf($matchedView->mountPath), 98 | 'path' => Project::relativePathOf($matchedView->path), 99 | 'baseUri' => $mountPath->baseUri, 100 | 'domain' => $mountPath->domain, 101 | ]; 102 | } 103 | } 104 | } 105 | 106 | $this->loaded = true; 107 | } 108 | 109 | /** 110 | * Determine if a route with the given name exists. 111 | */ 112 | public function has(string $name): bool 113 | { 114 | $this->ensureLoaded(); 115 | 116 | return isset($this->routes[$name]); 117 | } 118 | 119 | /** 120 | * Get the route URL for the given route name and arguments. 121 | * 122 | * @thows \Laravel\Folio\Exceptions\UrlGenerationException 123 | */ 124 | public function get(string $name, array $arguments, bool $absolute): string 125 | { 126 | $this->ensureLoaded(); 127 | 128 | if (! isset($this->routes[$name])) { 129 | throw new RouteNotFoundException("Route [{$name}] not found."); 130 | } 131 | 132 | [ 133 | 'mountPath' => $mountPath, 134 | 'path' => $path, 135 | 'baseUri' => $baseUri, 136 | 'domain' => $domain, 137 | ] = $this->routes[$name]; 138 | 139 | [$path, $remainingArguments] = $this->path($mountPath, $path, $arguments); 140 | 141 | $route = new Route(['GET'], '{__folio_path}', fn () => null); 142 | 143 | $route->name($name)->domain($domain); 144 | 145 | $uri = $baseUri === '/' ? $path : $baseUri.$path; 146 | 147 | try { 148 | return url()->toRoute($route, [...$remainingArguments, '__folio_path' => $uri], $absolute); 149 | } catch (\Illuminate\Routing\Exceptions\UrlGenerationException $e) { 150 | throw new UrlGenerationException(str_replace('{__folio_path}', $uri, $e->getMessage()), $e->getCode(), $e); 151 | } 152 | } 153 | 154 | /** 155 | * Get the relative route URL for the given route name and arguments. 156 | * 157 | * @param array $parameters 158 | * @return array{string, array} 159 | */ 160 | protected function path(string $mountPath, string $path, array $parameters): array 161 | { 162 | $uri = str_replace('.blade.php', '', $path); 163 | 164 | [$parameters, $usedParameters] = [collect($parameters), collect()]; 165 | 166 | $uri = collect(explode('/', $uri)) 167 | ->map(function (string $segment) use ($parameters, $uri, $usedParameters) { 168 | if (! Str::startsWith($segment, '[')) { 169 | return $segment; 170 | } 171 | 172 | $segment = new PotentiallyBindablePathSegment($segment); 173 | 174 | $name = $segment->variable(); 175 | 176 | $key = $parameters->search(function (mixed $value, string $key) use ($name) { 177 | return Str::camel($key) === $name && $value !== null; 178 | }); 179 | 180 | if ($key === false) { 181 | throw UrlGenerationException::forMissingParameter($uri, $name); 182 | } 183 | 184 | $usedParameters->add($key); 185 | 186 | return $this->formatParameter( 187 | $uri, 188 | Str::camel($key), 189 | $parameters->get($key), 190 | $segment->field(), 191 | $segment->capturesMultipleSegments() 192 | ); 193 | })->implode('/'); 194 | 195 | $uri = match (true) { 196 | str_ends_with($uri, '/index') => substr($uri, 0, -6), 197 | str_ends_with($uri, '/index/') => substr($uri, 0, -7), 198 | default => $uri, 199 | }; 200 | 201 | return [ 202 | '/'.ltrim(substr($uri, strlen($mountPath)), '/'), 203 | $parameters->except($usedParameters->all())->all(), 204 | ]; 205 | } 206 | 207 | /** 208 | * Format the given parameter for placement in the route URL. 209 | * 210 | * @throws \Laravel\Folio\Exceptions\UrlGenerationException 211 | */ 212 | protected function formatParameter(string $uri, string $name, mixed $parameter, string|bool $field, bool $variadic): mixed 213 | { 214 | $value = match (true) { 215 | $parameter instanceof UrlRoutable && $field !== false => $parameter->{$field}, 216 | $parameter instanceof UrlRoutable => $parameter->getRouteKey(), 217 | $parameter instanceof BackedEnum => $parameter->value, 218 | $variadic => implode( 219 | '/', 220 | collect($parameter) 221 | ->map(fn (mixed $value) => $this->formatParameter($uri, $name, $value, $field, false)) 222 | ->all() 223 | ), 224 | default => $parameter, 225 | }; 226 | 227 | if (is_null($value)) { 228 | throw UrlGenerationException::forMissingParameter($uri, $name); 229 | } 230 | 231 | return $value; 232 | } 233 | 234 | /** 235 | * Get all of the registered routes. 236 | */ 237 | public function routes(): array 238 | { 239 | $this->ensureLoaded(); 240 | 241 | return $this->routes; 242 | } 243 | 244 | /** 245 | * Flush the cached routes. 246 | */ 247 | public function flush(): void 248 | { 249 | File::delete($this->cachedFolioRoutesPath); 250 | 251 | $this->loaded = false; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/FolioServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(FolioManager::class); 19 | $this->app->singleton(InlineMetadataInterceptor::class); 20 | $this->app->singleton(FolioRoutes::class); 21 | 22 | $this->app->when(FolioRoutes::class) 23 | ->needs('$cachedFolioRoutesPath') 24 | ->give(fn () => dirname($this->app->getCachedRoutesPath()).DIRECTORY_SEPARATOR.'folio-routes.php'); 25 | } 26 | 27 | /** 28 | * Bootstrap the package's services. 29 | */ 30 | public function boot(): void 31 | { 32 | $this->registerCommands(); 33 | $this->registerPublishing(); 34 | $this->registerUrlGenerator(); 35 | $this->registerTerminationCallback(); 36 | $this->cacheFolioRoutesOnRouteCache(); 37 | } 38 | 39 | /** 40 | * Register the package's commands. 41 | */ 42 | protected function registerCommands(): void 43 | { 44 | if ($this->app->runningInConsole()) { 45 | $this->commands([ 46 | Console\InstallCommand::class, 47 | Console\ListCommand::class, 48 | Console\MakeCommand::class, 49 | ]); 50 | } 51 | } 52 | 53 | /** 54 | * Register the package's publishable resources. 55 | */ 56 | protected function registerPublishing(): void 57 | { 58 | if ($this->app->runningInConsole()) { 59 | $this->publishes([ 60 | __DIR__.'/../stubs/FolioServiceProvider.stub' => app_path('Providers/FolioServiceProvider.php'), 61 | ], 'folio-provider'); 62 | } 63 | } 64 | 65 | /** 66 | * Register the URL generator route resolver. 67 | */ 68 | protected function registerUrlGenerator(): void 69 | { 70 | $this->callAfterResolving(UrlGenerator::class, function ($url) { 71 | $url->resolveMissingNamedRoutesUsing(function ($name, $parameters, $absolute) { 72 | $routes = app(FolioRoutes::class); 73 | 74 | if ($routes->has($name)) { 75 | return $routes->get($name, $parameters, $absolute); 76 | } 77 | }); 78 | }); 79 | } 80 | 81 | /** 82 | * Register the package's terminating callback. 83 | */ 84 | protected function registerTerminationCallback(): void 85 | { 86 | $this->app->terminating(fn (FolioManager $manager) => $manager->terminate()); 87 | } 88 | 89 | /** 90 | * Cache Folio's routes when the route:cache and route:clear commands are run. 91 | */ 92 | protected function cacheFolioRoutesOnRouteCache(): void 93 | { 94 | Event::listen(CommandFinished::class, function (CommandFinished $event) { 95 | if ($event->command === 'route:cache') { 96 | $this->app->make(FolioRoutes::class)->persist(); 97 | } 98 | }); 99 | 100 | Event::listen(CommandStarting::class, function (CommandStarting $event) { 101 | if ($event->command === 'route:clear') { 102 | $this->app->make(FolioRoutes::class)->flush(); 103 | } 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/InlineMetadataInterceptor.php: -------------------------------------------------------------------------------- 1 | path, $this->cache)) { 25 | return $this->cache[$matchedView->path]; 26 | } 27 | 28 | try { 29 | $this->listen(function () use ($matchedView) { 30 | ob_start(); 31 | 32 | [$__path, $__variables] = [ 33 | $matchedView->path, 34 | $matchedView->data, 35 | ]; 36 | 37 | (static function () use ($__path, $__variables) { 38 | extract($__variables); 39 | 40 | require $__path; 41 | })(); 42 | }); 43 | } finally { 44 | ob_get_clean(); 45 | 46 | $metadata = tap(Metadata::instance(), fn () => Metadata::flush()); 47 | } 48 | 49 | return $this->cache[$matchedView->path] = $metadata; 50 | } 51 | 52 | /** 53 | * Execute the callback while listening for metadata. 54 | */ 55 | public function listen(callable $callback): void 56 | { 57 | $this->listening = true; 58 | 59 | try { 60 | $callback(); 61 | } finally { 62 | $this->listening = false; 63 | } 64 | } 65 | 66 | /** 67 | * Execute the callback if the interceptor is listening for metadata. 68 | */ 69 | public function whenListening(callable $callback): void 70 | { 71 | if ($this->listening) { 72 | $callback(); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Metadata.php: -------------------------------------------------------------------------------- 1 | path = str_replace('/', DIRECTORY_SEPARATOR, $path); 22 | 23 | $this->middleware = new PathBasedMiddlewareList($middleware); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Options/PageOptions.php: -------------------------------------------------------------------------------- 1 | relativePath(), '/'); 25 | 26 | foreach ($this->middleware as $pattern => $middleware) { 27 | if (Str::is(trim($pattern, '/'), $relativePath)) { 28 | $matched = array_merge($matched, Arr::wrap($middleware)); 29 | } 30 | } 31 | 32 | return collect($matched); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/PendingRoute.php: -------------------------------------------------------------------------------- 1 | > $middleware 11 | */ 12 | public function __construct( 13 | protected FolioManager $manager, 14 | protected string $path, 15 | protected string $uri, 16 | protected array $middleware, 17 | protected ?string $domain = null, 18 | ) {} 19 | 20 | /** 21 | * Set the domain for the route. 22 | */ 23 | public function domain(string $domain): static 24 | { 25 | $this->domain = $domain; 26 | 27 | return $this; 28 | } 29 | 30 | /** 31 | * Set the path for the route. 32 | */ 33 | public function path(string $path): static 34 | { 35 | $this->path = $path; 36 | 37 | return $this; 38 | } 39 | 40 | /** 41 | * Set the middleware for the route. 42 | */ 43 | public function middleware(array $middleware): static 44 | { 45 | $this->middleware = $middleware; 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * Set the URI for the route. 52 | */ 53 | public function uri(string $uri): static 54 | { 55 | $this->uri = $uri; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * Register the route upon instance destruction. 62 | */ 63 | public function __destruct() 64 | { 65 | $this->manager->registerRoute( 66 | $this->path, 67 | $this->uri, 68 | $this->middleware, 69 | $this->domain, 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Pipeline/ContinueIterating.php: -------------------------------------------------------------------------------- 1 | mountPath->domain === null) { 24 | return $next($state); 25 | } 26 | 27 | $route = $this->route(); 28 | 29 | if ($this->matchesDomain($route) === false) { 30 | return new StopIterating; 31 | } 32 | 33 | $state->data = array_merge($route->parameters(), $state->data); 34 | 35 | return $next($state); 36 | } 37 | 38 | /** 39 | * Get the route that should be used to match the request. 40 | */ 41 | protected function route(): Route 42 | { 43 | return (new Route(['GET'], $this->mountPath->baseUri, fn () => null)) 44 | ->domain($this->mountPath->domain) 45 | ->bind($this->request); 46 | } 47 | 48 | /** 49 | * Determine if the request matches the domain. 50 | */ 51 | protected function matchesDomain(Route $route): bool 52 | { 53 | return (bool) (new HostValidator)->matches($route, $this->request); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Pipeline/EnsureNoDirectoryTraversal.php: -------------------------------------------------------------------------------- 1 | path))->startsWith($state->mountPath.DIRECTORY_SEPARATOR)) { 23 | throw new PossibleDirectoryTraversal; 24 | } 25 | 26 | return $view; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Pipeline/FindsWildcardViews.php: -------------------------------------------------------------------------------- 1 | findViewWith($directory, '[...', ']'); 16 | } 17 | 18 | /** 19 | * Attempt to find a wildcard view at the given directory. 20 | */ 21 | protected function findWildcardView(string $directory): ?string 22 | { 23 | return $this->findViewWith($directory, '[', ']'); 24 | } 25 | 26 | /** 27 | * Attempt to find a wildcard view at the given directory with the given beginning and ending strings. 28 | */ 29 | protected function findViewWith(string $directory, $startsWith, $endsWith): ?string 30 | { 31 | $files = (new Filesystem)->files($directory); 32 | 33 | return collect($files)->first(function ($file) use ($startsWith, $endsWith) { 34 | $filename = Str::of($file->getFilename()); 35 | 36 | if (! $filename->endsWith('.blade.php')) { 37 | return; 38 | } 39 | 40 | $filename = $filename->before('.blade.php'); 41 | 42 | return $filename->startsWith($startsWith) && 43 | $filename->endsWith($endsWith); 44 | })?->getFilename(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Pipeline/MatchDirectoryIndexViews.php: -------------------------------------------------------------------------------- 1 | onLastUriSegment() && 15 | $state->currentUriSegmentIsDirectory() && 16 | file_exists($path = $state->currentUriSegmentDirectory().'/index.blade.php') 17 | ? new MatchedView($path, $state->data) 18 | : $next($state); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Pipeline/MatchLiteralDirectories.php: -------------------------------------------------------------------------------- 1 | onLastUriSegment() && $state->currentUriSegmentIsDirectory() 15 | ? new ContinueIterating($state) 16 | : $next($state); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Pipeline/MatchLiteralViews.php: -------------------------------------------------------------------------------- 1 | onLastUriSegment() && 15 | file_exists($path = $state->currentDirectory().'/'.$state->currentUriSegment().'.blade.php') 16 | ? new MatchedView($path, $state->data) 17 | : $next($state); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Pipeline/MatchRootIndex.php: -------------------------------------------------------------------------------- 1 | uri) === '/') { 15 | return file_exists($path = $state->mountPath.'/index.blade.php') 16 | ? new MatchedView($path, $state->data) 17 | : new StopIterating; 18 | } 19 | 20 | return $next($state); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Pipeline/MatchWildcardDirectories.php: -------------------------------------------------------------------------------- 1 | findWildcardDirectory($state->currentDirectory())) { 17 | $currentState = $state->withData( 18 | Str::of($directory) 19 | ->basename() 20 | ->match('/\[(.*)\]/')->value(), 21 | $state->currentUriSegment(), 22 | )->replaceCurrentUriSegmentWith( 23 | Str::of($directory)->basename() 24 | ); 25 | 26 | if (! $currentState->onLastUriSegment()) { 27 | return new ContinueIterating($currentState); 28 | } 29 | 30 | if (file_exists($path = $currentState->currentUriSegmentDirectory().'/index.blade.php')) { 31 | return new MatchedView($path, $currentState->data); 32 | } 33 | } 34 | 35 | return $next($state); 36 | } 37 | 38 | /** 39 | * Attempt to find a wildcard directory within the given directory. 40 | */ 41 | public function findWildcardDirectory(string $directory): ?string 42 | { 43 | return collect((new Filesystem)->directories($directory)) 44 | ->first(function (string $directory) { 45 | $directory = Str::of($directory)->basename(); 46 | 47 | return $directory->startsWith('[') && 48 | $directory->endsWith(']'); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Pipeline/MatchWildcardViews.php: -------------------------------------------------------------------------------- 1 | onLastUriSegment() && 18 | $path = $this->findWildcardView($state->currentDirectory())) { 19 | return new MatchedView($state->currentDirectory().'/'.$path, $state->withData( 20 | Str::of($path) 21 | ->before('.blade.php') 22 | ->match('/\[(.*)\]/')->value(), 23 | $state->currentUriSegment(), 24 | )->data); 25 | } 26 | 27 | return $next($state); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Pipeline/MatchWildcardViewsThatCaptureMultipleSegments.php: -------------------------------------------------------------------------------- 1 | findWildcardMultiSegmentView($state->currentDirectory())) { 18 | return new MatchedView($state->currentDirectory().'/'.$path, $state->withData( 19 | Str::of($path) 20 | ->before('.blade.php') 21 | ->match('/\[\.\.\.(.*)\]/')->value(), 22 | array_slice( 23 | $state->segments, 24 | $state->currentIndex, 25 | $state->uriSegmentCount() 26 | ) 27 | )->data); 28 | } 29 | 30 | return $next($state); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Pipeline/MatchedView.php: -------------------------------------------------------------------------------- 1 | path = realpath($path) ?: $path; 19 | } 20 | 21 | /** 22 | * Set the mount path on the matched view, returning a new instance. 23 | */ 24 | public function withMountPath(string $mountPath): MatchedView 25 | { 26 | return new static(mountPath: $mountPath, path: $this->path, data: $this->data); 27 | } 28 | 29 | /** 30 | * Get the matched view's name, if any. 31 | */ 32 | public function name(): ?string 33 | { 34 | return app(InlineMetadataInterceptor::class)->intercept($this)->name; 35 | } 36 | 37 | /** 38 | * Get the matched view's inline middleware. 39 | */ 40 | public function inlineMiddleware(): Collection 41 | { 42 | return app(InlineMetadataInterceptor::class)->intercept($this)->middleware; 43 | } 44 | 45 | /** 46 | * Determine if the matched view resolves soft deleted model bindings. 47 | */ 48 | public function allowsTrashedBindings(): bool 49 | { 50 | return app(InlineMetadataInterceptor::class)->intercept($this)->withTrashed; 51 | } 52 | 53 | /** 54 | * Get the matched view's render callback. 55 | */ 56 | public function renderUsing(): callable 57 | { 58 | return app(InlineMetadataInterceptor::class)->intercept($this)->renderUsing ?? fn ($view) => $view; 59 | } 60 | 61 | /** 62 | * Get the path to the matched view relative to the mount path. 63 | */ 64 | public function relativePath(): string 65 | { 66 | $path = str_replace($this->mountPath, '', $this->path); 67 | 68 | return '/'.trim(str_replace(DIRECTORY_SEPARATOR, '/', $path), '/'); 69 | } 70 | 71 | /** 72 | * Replace the given piece of a data with a new piece of data. 73 | */ 74 | public function replace(string $keyBeingReplaced, string $newKey, mixed $value): MatchedView 75 | { 76 | $data = $this->data; 77 | 78 | unset($data[$keyBeingReplaced]); 79 | 80 | $data[$newKey] = $value; 81 | 82 | return $this->withData($data); 83 | } 84 | 85 | /** 86 | * Create a new matched view instance with the given data. 87 | */ 88 | public function withData(array $data): MatchedView 89 | { 90 | return new static($this->path, $data, $this->mountPath); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Pipeline/PotentiallyBindablePathSegment.php: -------------------------------------------------------------------------------- 1 | )|null 26 | */ 27 | protected static ?Closure $resolveUrlRoutableNamespacesUsing = null; 28 | 29 | /** 30 | * Create a new potentially bindable path segment instance. 31 | */ 32 | public function __construct(public string $value) {} 33 | 34 | /** 35 | * Determine if the segment is bindable. 36 | */ 37 | public function bindable(): bool 38 | { 39 | if (! str_starts_with($this->value, '[') || 40 | ! str_ends_with($this->value, ']') || 41 | ! class_exists($this->class())) { 42 | return false; 43 | } 44 | 45 | if (enum_exists($this->class())) { 46 | return true; 47 | } 48 | 49 | if (! is_a($this->class(), UrlRoutable::class, true)) { 50 | throw new Exception('Folio route attempting to bind to class ['.$this->class().'], but it does not implement the UrlRoutable interface.'); 51 | } 52 | 53 | return true; 54 | } 55 | 56 | /** 57 | * Determine if the binding segment captures multiple segments. 58 | */ 59 | public function capturesMultipleSegments(): bool 60 | { 61 | return str_starts_with($this->value, '[...'); 62 | } 63 | 64 | /** 65 | * Resolve the binding or throw a ModelNotFoundException. 66 | */ 67 | public function resolveOrFail(mixed $value, 68 | ?UrlRoutable $parent = null, 69 | bool $withTrashed = false): UrlRoutable|BackedEnum 70 | { 71 | if (is_null($resolved = $this->resolve($value, $parent, $withTrashed))) { 72 | throw (new ModelNotFoundException) 73 | ->setModel(get_class($this->newClassInstance()), [$value]); 74 | } 75 | 76 | return $resolved; 77 | } 78 | 79 | /** 80 | * Attempt to resolve the binding. 81 | */ 82 | protected function resolve(mixed $value, ?UrlRoutable $parent, bool $withTrashed): mixed 83 | { 84 | if ($explicitBindingCallback = Route::getBindingCallback($this->variable())) { 85 | return $explicitBindingCallback($value); 86 | } 87 | 88 | if (enum_exists($this->class())) { 89 | return $this->resolveEnum($value); 90 | } elseif ($parent && $this->field()) { 91 | return $this->resolveViaParent($value, $parent, $withTrashed); 92 | } 93 | 94 | $classInstance = $this->newClassInstance(); 95 | 96 | $method = $withTrashed ? 'resolveSoftDeletableRouteBinding' : 'resolveRouteBinding'; 97 | 98 | return $classInstance->{$method}( 99 | $value, $this->field() ?: $classInstance->getRouteKeyName() 100 | ); 101 | } 102 | 103 | /** 104 | * Attempt to resolve the binding via the given parent. 105 | */ 106 | protected function resolveViaParent(mixed $value, UrlRoutable $parent, bool $withTrashed): ?UrlRoutable 107 | { 108 | $method = $withTrashed 109 | ? 'resolveSoftDeletableChildRouteBinding' 110 | : 'resolveChildRouteBinding'; 111 | 112 | return $parent->{$method}( 113 | $this->variable(), 114 | $value, 115 | $this->field() ?: $this->newClassInstance()->getRouteKeyName() 116 | ); 117 | } 118 | 119 | /** 120 | * Resolve the binding as an Enum. 121 | */ 122 | protected function resolveEnum(mixed $value): BackedEnum 123 | { 124 | $backedEnumClass = $this->class(); 125 | 126 | if (is_null($backedEnum = $backedEnumClass::tryFrom((string) $value))) { 127 | throw new BackedEnumCaseNotFoundException($backedEnumClass, $value); 128 | } 129 | 130 | return $backedEnum; 131 | } 132 | 133 | /** 134 | * Get the class name contained by the bindable segment. 135 | * 136 | * @throws \Exception 137 | */ 138 | public function class(): string 139 | { 140 | if ($this->class) { 141 | return $this->class; 142 | } 143 | 144 | $class = Str::of($this->value) 145 | ->trim('[]') 146 | ->after('...') 147 | ->before('-') 148 | ->before('|') 149 | ->before(':') 150 | ->replace('.', '\\'); 151 | 152 | if (! $class->contains('\\')) { 153 | $namespaces = value(static::$resolveUrlRoutableNamespacesUsing) ?? ['\\App\\Models', '\\App', '']; 154 | 155 | $namespace = collect($namespaces)->first(fn (string $namespace) => class_exists("$namespace\\$class"), $namespaces[0]); 156 | 157 | $class = $class->prepend($namespace.'\\'); 158 | } 159 | 160 | return $this->class = $class->trim('\\')->value(); 161 | } 162 | 163 | /** 164 | * Get the basename of the class being bound. 165 | */ 166 | public function classBasename(): string 167 | { 168 | return class_basename($this->class()); 169 | } 170 | 171 | /** 172 | * Get a new class instance for the binding class. 173 | */ 174 | public function newClassInstance(): mixed 175 | { 176 | return Container::getInstance()->make($this->class()); 177 | } 178 | 179 | /** 180 | * Get the custom binding field (if any) that is specified in the segment. 181 | */ 182 | public function field(): string|bool 183 | { 184 | if (str_contains($this->value, ':')) { 185 | return Str::of($this->trimmed())->after(':')->before('|')->before('$')->value(); 186 | } elseif (str_contains($this->value, '-')) { 187 | return with( 188 | explode('-', $this->trimmed()), 189 | fn (array $segments) => str_contains($segments[1] ?? '', '$') ? false : $segments[1] 190 | ); 191 | } 192 | 193 | return false; 194 | } 195 | 196 | /** 197 | * Get the view injectable variable name for the class being bound. 198 | */ 199 | public function variable(): string 200 | { 201 | if (str_contains($this->value, '|')) { 202 | return Str::of($this->trimmed())->afterLast('|')->trim('$')->value(); 203 | } elseif (str_contains($this->value, '$')) { 204 | return Str::of($this->trimmed())->afterLast('$')->value(); 205 | } 206 | 207 | return $this->capturesMultipleSegments() 208 | ? Str::camel(Str::plural($this->classBasename())) 209 | : Str::camel($this->classBasename()); 210 | } 211 | 212 | /** 213 | * Get the segment value with the "[" and "]" and "..." characters trimmed. 214 | */ 215 | public function trimmed(): string 216 | { 217 | return Str::of($this->value)->trim('[]')->after('...')->value(); 218 | } 219 | 220 | /** 221 | * Set the callback to be used to resolve URL routable namespaces. 222 | * 223 | * @param (\Closure(): array)|null $callback 224 | */ 225 | public static function resolveUrlRoutableNamespacesUsing(?Closure $callback): void 226 | { 227 | static::$resolveUrlRoutableNamespacesUsing = $callback; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/Pipeline/SetMountPathOnMatchedView.php: -------------------------------------------------------------------------------- 1 | withMountPath($state->mountPath); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Pipeline/State.php: -------------------------------------------------------------------------------- 1 | mountPath = str_replace('/', DIRECTORY_SEPARATOR, $mountPath); 18 | } 19 | 20 | /** 21 | * Create a new state instance for the given iteration. 22 | */ 23 | public function forIteration(int $iteration): State 24 | { 25 | return new static( 26 | $this->uri, 27 | $this->mountPath, 28 | $this->segments, 29 | $this->data, 30 | $iteration, 31 | ); 32 | } 33 | 34 | /** 35 | * Create a new state instance with the given data added. 36 | */ 37 | public function withData(string $key, mixed $value): State 38 | { 39 | return new static( 40 | $this->uri, 41 | $this->mountPath, 42 | $this->segments, 43 | array_merge($this->data, [$key => $value]), 44 | $this->currentIndex, 45 | ); 46 | } 47 | 48 | /** 49 | * Get the number of URI segments that are present. 50 | */ 51 | public function uriSegmentCount(): int 52 | { 53 | return once(fn () => count($this->segments)); 54 | } 55 | 56 | /** 57 | * Get the current URI segment for the given iteration. 58 | */ 59 | public function currentUriSegment(): string 60 | { 61 | return $this->segments[$this->currentIndex]; 62 | } 63 | 64 | /** 65 | * Replace the segment value for the current iteration. 66 | */ 67 | public function replaceCurrentUriSegmentWith(string $value): State 68 | { 69 | $segments = $this->segments; 70 | 71 | $segments[$this->currentIndex] = $value; 72 | 73 | return new static( 74 | $this->uri, 75 | $this->mountPath, 76 | $segments, 77 | $this->data, 78 | $this->currentIndex, 79 | ); 80 | } 81 | 82 | /** 83 | * Determine if the current iteration is for the last segment. 84 | */ 85 | public function onLastUriSegment(): bool 86 | { 87 | return once(fn () => $this->currentIndex === ($this->uriSegmentCount() - 1)); 88 | } 89 | 90 | /** 91 | * Get the absolute path to the current directory for the given iteration. 92 | */ 93 | public function currentDirectory(): string 94 | { 95 | return once(fn () => $this->mountPath.'/'.implode('/', array_slice($this->segments, 0, $this->currentIndex))); 96 | } 97 | 98 | /** 99 | * Get the absolute path to the current directory (including the current URI segment) for the given iteration. 100 | */ 101 | public function currentUriSegmentDirectory(): string 102 | { 103 | return once(fn () => $this->currentDirectory().'/'.$this->currentUriSegment()); 104 | } 105 | 106 | /** 107 | * Determine if the path to the current directory (including the current URI segment) is a directory. 108 | */ 109 | public function currentUriSegmentIsDirectory(): bool 110 | { 111 | return once(fn () => is_dir($this->currentUriSegmentDirectory())); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Pipeline/StopIterating.php: -------------------------------------------------------------------------------- 1 | uri), 27 | $this->bindablePathSegments($view), 28 | ]; 29 | 30 | foreach ($pathSegments as $index => $segment) { 31 | if (! ($segment = new PotentiallyBindablePathSegment($segment))->bindable()) { 32 | continue; 33 | } 34 | 35 | if ($segment->capturesMultipleSegments()) { 36 | $view = $this->initializeVariable( 37 | $view, $segment, array_slice($uriSegments, $index) 38 | ); 39 | 40 | return $view->replace( 41 | $segment->trimmed(), 42 | $segment->variable(), 43 | collect(array_slice($uriSegments, $index)) 44 | ->map(fn (string $value) => $segment->resolveOrFail( 45 | $value, $parent ?? null, $view->allowsTrashedBindings() 46 | )) 47 | ->all(), 48 | ); 49 | } 50 | 51 | $view = $this->initializeVariable($view, $segment, $uriSegments[$index]); 52 | 53 | $view = $view->replace( 54 | $segment->trimmed(), 55 | $segment->variable(), 56 | $resolved = $segment->resolveOrFail( 57 | $uriSegments[$index], 58 | $parent ?? null, 59 | $view->allowsTrashedBindings() 60 | ), 61 | ); 62 | 63 | $parent = $resolved; 64 | } 65 | 66 | if ($this->request->route()) { 67 | foreach ($view->data as $key => $value) { 68 | $this->request->route()->setParameter($key, $value); 69 | } 70 | } 71 | 72 | return $view; 73 | } 74 | 75 | /** 76 | * Get the bindable path segments for the matched view. 77 | */ 78 | protected function bindablePathSegments(MatchedView $view): array 79 | { 80 | return explode(DIRECTORY_SEPARATOR, (string) Str::of($view->path) 81 | ->replace($view->mountPath, '') 82 | ->beforeLast('.blade.php') 83 | ->trim(DIRECTORY_SEPARATOR)); 84 | } 85 | 86 | /** 87 | * Initialize a given variable on the matched view so we can intercept the page metadata without errors. 88 | */ 89 | protected function initializeVariable(MatchedView $view, PotentiallyBindablePathSegment $segment, mixed $value): MatchedView 90 | { 91 | return $view->replace( 92 | $segment->trimmed(), 93 | $segment->variable(), 94 | $value, 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/RequestHandler.php: -------------------------------------------------------------------------------- 1 | $mountPaths 20 | */ 21 | public function __construct( 22 | protected array $mountPaths, 23 | protected ?Closure $renderUsing = null, 24 | protected ?Closure $onViewMatch = null, 25 | ) {} 26 | 27 | /** 28 | * Handle the incoming request using Folio. 29 | */ 30 | public function __invoke(Request $request): mixed 31 | { 32 | foreach ($this->mountPaths as $mountPath) { 33 | $requestPath = '/'.ltrim($request->path(), '/'); 34 | 35 | $uri = '/'.ltrim(substr($requestPath, strlen($mountPath->baseUri)), '/'); 36 | 37 | if ($matchedView = app()->make(Router::class, ['mountPath' => $mountPath])->match($request, $uri)) { 38 | break; 39 | } 40 | } 41 | 42 | abort_unless($matchedView ?? null, 404); 43 | 44 | if ($name = $matchedView->name()) { 45 | $request->route()->action['as'] = $name; 46 | } 47 | 48 | app(Dispatcher::class)->dispatch(new Events\ViewMatched($matchedView, $mountPath)); 49 | 50 | $middleware = collect($this->middleware($mountPath, $matchedView)); 51 | 52 | return (new Pipeline(app())) 53 | ->send($request) 54 | ->through($middleware->all()) 55 | ->then(function (Request $request) use ($matchedView, $middleware) { 56 | if ($this->onViewMatch) { 57 | ($this->onViewMatch)($matchedView); 58 | } 59 | 60 | $response = $this->renderUsing 61 | ? ($this->renderUsing)($request, $matchedView) 62 | : $this->toResponse($request, $matchedView); 63 | 64 | $app = app(); 65 | 66 | $app->make(FolioManager::class)->terminateUsing(function () use ($middleware, $app, $request, $response) { 67 | $middleware->filter(fn ($m) => is_string($m) && class_exists($m) && method_exists($m, 'terminate')) 68 | ->map(fn (string $m) => $app->make($m)) 69 | ->each(fn (object $m) => $app->call([$m, 'terminate'], ['request' => $request, 'response' => $response])); 70 | 71 | $request->route()->action['as'] = 'laravel-folio'; 72 | }); 73 | 74 | return $response; 75 | }); 76 | } 77 | 78 | /** 79 | * Get the middleware that should be applied to the matched view. 80 | */ 81 | protected function middleware(MountPath $mountPath, MatchedView $matchedView): array 82 | { 83 | return Route::resolveMiddleware( 84 | $mountPath 85 | ->middleware 86 | ->match($matchedView) 87 | ->prepend('web') 88 | ->merge($matchedView->inlineMiddleware()) 89 | ->unique() 90 | ->values() 91 | ->all() 92 | ); 93 | } 94 | 95 | /** 96 | * Create a response instance for the given matched view. 97 | */ 98 | protected function toResponse(Request $request, MatchedView $matchedView): Response 99 | { 100 | $view = View::file($matchedView->path, $matchedView->data); 101 | 102 | return Route::toResponse($request, app()->call( 103 | $matchedView->renderUsing(), 104 | ['view' => $view, ...$view->getData()] 105 | ) ?? $view); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | 1 ? trim($uri, '/') : $uri; 36 | 37 | if ($view = $this->matchAtPath($request, $uri)) { 38 | return $view; 39 | } 40 | 41 | return null; 42 | } 43 | 44 | /** 45 | * Resolve the given URI via page based routing at the given mount path. 46 | */ 47 | protected function matchAtPath(Request $request, string $uri): ?MatchedView 48 | { 49 | $state = new State( 50 | uri: $uri, 51 | mountPath: $this->mountPath->path, 52 | segments: explode('/', $uri) 53 | ); 54 | 55 | for ($i = 0; $i < $state->uriSegmentCount(); $i++) { 56 | $value = (new Pipeline) 57 | ->send($state->forIteration($i)) 58 | ->through([ 59 | new EnsureMatchesDomain($request, $this->mountPath), 60 | new EnsureNoDirectoryTraversal, 61 | new TransformModelBindings($request), 62 | new SetMountPathOnMatchedView, 63 | new MatchRootIndex, 64 | new MatchDirectoryIndexViews, 65 | new MatchWildcardViewsThatCaptureMultipleSegments, 66 | new MatchLiteralDirectories, 67 | new MatchWildcardDirectories, 68 | new MatchLiteralViews, 69 | new MatchWildcardViews, 70 | ])->then(fn () => new StopIterating); 71 | 72 | if ($value instanceof MatchedView) { 73 | return $value; 74 | } elseif ($value instanceof ContinueIterating) { 75 | $state = $value->state; 76 | 77 | continue; 78 | } elseif ($value instanceof StopIterating) { 79 | break; 80 | } 81 | } 82 | 83 | return null; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Support/Project.php: -------------------------------------------------------------------------------- 1 | middleware([ 24 | '*' => [ 25 | // 26 | ], 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /stubs/folio-page.stub: -------------------------------------------------------------------------------- 1 |
2 | // 3 |
4 | -------------------------------------------------------------------------------- /testbench.yaml: -------------------------------------------------------------------------------- 1 | providers: 2 | - Workbench\App\Providers\FolioServiceProvider 3 | 4 | migrations: true 5 | 6 | seeders: 7 | - Workbench\Database\Seeders\DatabaseSeeder 8 | --------------------------------------------------------------------------------