From cc0dfb47717383b95918e3a5c1cbea426eaac71b Mon Sep 17 00:00:00 2001 From: roble Date: Tue, 15 Sep 2026 16:35:59 +0100 Subject: [PATCH] feat: implement SEO settings with sitemap and robots.txt functionality --- composer.json | 21 ++--- .../0001_01_01_000013_create_seo_settings.php | 13 +++ src/CoreServiceProvider.php | 3 + src/Filament/Admin/Pages/SeoSettings.php | 57 +++++++++++++ src/Http/Controllers/RobotsController.php | 26 ++++++ src/Http/Controllers/SitemapController.php | 18 ++++ src/Settings/SeoSettings.php | 24 ++++++ src/Sitemap/SitemapRegistry.php | 38 +++++++++ tests/Feature/SitemapTest.php | 82 +++++++++++++++++++ 9 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 database/settings/0001_01_01_000013_create_seo_settings.php create mode 100644 src/Filament/Admin/Pages/SeoSettings.php create mode 100644 src/Http/Controllers/RobotsController.php create mode 100644 src/Http/Controllers/SitemapController.php create mode 100644 src/Settings/SeoSettings.php create mode 100644 src/Sitemap/SitemapRegistry.php create mode 100644 tests/Feature/SitemapTest.php diff --git a/composer.json b/composer.json index b7a6609..305ff7b 100644 --- a/composer.json +++ b/composer.json @@ -22,13 +22,13 @@ "require": { "php": "^8.4", "cviebrock/eloquent-sluggable": "^13.0.1", - "filament/filament": "^5.7.8", - "filament/spatie-laravel-media-library-plugin": "^5.7.8", - "filament/spatie-laravel-settings-plugin": "^5.7.8", - "inertiajs/inertia-laravel": "^3.3.3", + "filament/filament": "^5.8.1", + "filament/spatie-laravel-media-library-plugin": "^5.8.1", + "filament/spatie-laravel-settings-plugin": "^5.8.1", + "inertiajs/inertia-laravel": "^3.3.4", "inertiaui/modal": "^3.1.2", "internachi/modular": "^3.0.2", - "laravel/framework": "^13.0", + "laravel/framework": "^13.32", "laravel/sanctum": "^4.3.3", "openplain/filament-shadcn-theme": "^1.1", "saucebase/breadcrumbs": "^1.1", @@ -36,16 +36,17 @@ "spatie/laravel-data": "^4.23", "spatie/laravel-navigation": "^1.3", "spatie/laravel-permission": "^6.25", - "spatie/laravel-settings": "^3.4", + "spatie/laravel-settings": "^3.9", + "spatie/laravel-sitemap": "^8.2", "spatie/laravel-typescript-transformer": "^2.6", "symfony/filesystem": "^7.4.18", "tightenco/ziggy": "^2.6.4" }, "require-dev": { - "larastan/larastan": "^3.11", - "laravel/pint": "^1.30.5", - "orchestra/testbench": "^11.0", - "phpunit/phpunit": "^12.5.34" + "larastan/larastan": "^3.12.1", + "laravel/pint": "^1.32.1", + "orchestra/testbench": "^11.2", + "phpunit/phpunit": "^12.5.35" }, "autoload": { "psr-4": { diff --git a/database/settings/0001_01_01_000013_create_seo_settings.php b/database/settings/0001_01_01_000013_create_seo_settings.php new file mode 100644 index 0000000..570622b --- /dev/null +++ b/database/settings/0001_01_01_000013_create_seo_settings.php @@ -0,0 +1,13 @@ +migrator->add('seo.sitemap_enabled', true); + $this->migrator->add('seo.robots_enabled', true); + $this->migrator->add('seo.robots_content', "User-agent: *\nDisallow: /admin"); + } +}; diff --git a/src/CoreServiceProvider.php b/src/CoreServiceProvider.php index 4431665..e4ae087 100644 --- a/src/CoreServiceProvider.php +++ b/src/CoreServiceProvider.php @@ -19,6 +19,7 @@ use Saucebase\Core\Providers\NavigationServiceProvider; use Saucebase\Core\Providers\SecurityServiceProvider; use Saucebase\Core\Providers\SettingsServiceProvider; +use Saucebase\Core\Sitemap\SitemapRegistry; /** * The single provider Laravel discovers for this package. @@ -55,6 +56,8 @@ public function register(): void foreach (self::PROVIDERS as $provider) { $this->app->register($provider); } + + $this->app->singleton(SitemapRegistry::class); } /** diff --git a/src/Filament/Admin/Pages/SeoSettings.php b/src/Filament/Admin/Pages/SeoSettings.php new file mode 100644 index 0000000..5cee890 --- /dev/null +++ b/src/Filament/Admin/Pages/SeoSettings.php @@ -0,0 +1,57 @@ +columns(1)->components([ + Section::make(__('Search engines')) + ->description(__('Control what search engines can discover on your site.')) + ->icon(Heroicon::OutlinedMagnifyingGlass) + ->iconColor('info') + ->schema([ + Toggle::make('sitemap_enabled') + ->label(__('Publish sitemap.xml')) + ->helperText(__('Lists your public pages and posts for search engines.')), + Toggle::make('robots_enabled') + ->label(__('Publish robots.txt')) + ->live(), + Textarea::make('robots_content') + ->label(__('robots.txt rules')) + ->helperText(__('The Sitemap line is added automatically when the sitemap is published.')) + ->rows(8) + ->required() + ->visible(fn (Get $get): bool => (bool) $get('robots_enabled')) + ->extraInputAttributes(['class' => 'font-mono']), + ]), + ]); + } +} diff --git a/src/Http/Controllers/RobotsController.php b/src/Http/Controllers/RobotsController.php new file mode 100644 index 0000000..59e0d3f --- /dev/null +++ b/src/Http/Controllers/RobotsController.php @@ -0,0 +1,26 @@ +robots_enabled, 404); + + $content = trim($settings->robots_content); + + if ($settings->sitemap_enabled && app('router')->has('sitemap')) { + $content .= "\n\nSitemap: ".route('sitemap'); + } + + return response($content."\n", 200, ['Content-Type' => 'text/plain; charset=UTF-8']); + } +} diff --git a/src/Http/Controllers/SitemapController.php b/src/Http/Controllers/SitemapController.php new file mode 100644 index 0000000..8890d62 --- /dev/null +++ b/src/Http/Controllers/SitemapController.php @@ -0,0 +1,18 @@ +sitemap_enabled, 404); + + return $registry->build(); + } +} diff --git a/src/Settings/SeoSettings.php b/src/Settings/SeoSettings.php new file mode 100644 index 0000000..bc210c2 --- /dev/null +++ b/src/Settings/SeoSettings.php @@ -0,0 +1,24 @@ + */ + private array $contributors = []; + + /** + * @param Closure(Sitemap): mixed $contributor + */ + public function add(Closure $contributor): void + { + $this->contributors[] = $contributor; + } + + public function build(): Sitemap + { + $sitemap = Sitemap::create(); + + foreach ($this->contributors as $contributor) { + $contributor($sitemap); + } + + return $sitemap; + } +} diff --git a/tests/Feature/SitemapTest.php b/tests/Feature/SitemapTest.php new file mode 100644 index 0000000..5b727ad --- /dev/null +++ b/tests/Feature/SitemapTest.php @@ -0,0 +1,82 @@ +name('sitemap'); + Route::get('/robots.txt', RobotsController::class); + Route::getRoutes()->refreshNameLookups(); + } + + public function test_sitemap_lists_urls_from_every_contributor(): void + { + $registry = $this->app->make(SitemapRegistry::class); + $registry->add(fn (Sitemap $sitemap) => $sitemap->add(Url::create('https://example.test/'))); + $registry->add(fn (Sitemap $sitemap) => $sitemap->add(Url::create('https://example.test/blog'))); + + $this->get('/sitemap.xml') + ->assertOk() + ->assertSee('https://example.test/', false) + ->assertSee('https://example.test/blog', false); + } + + public function test_disabled_sitemap_is_not_found(): void + { + $this->updateSettings(sitemap_enabled: false); + + $this->get('/sitemap.xml')->assertNotFound(); + } + + public function test_robots_serves_the_saved_rules_and_the_absolute_sitemap_url(): void + { + $this->updateSettings(robots_content: "User-agent: *\nDisallow: /private"); + + $response = $this->get('/robots.txt')->assertOk(); + + $this->assertStringStartsWith('text/plain', $response->headers->get('Content-Type')); + $this->assertSame( + "User-agent: *\nDisallow: /private\n\nSitemap: ".url('/sitemap.xml')."\n", + $response->getContent(), + ); + } + + public function test_robots_omits_the_sitemap_line_when_the_sitemap_is_disabled(): void + { + $this->updateSettings(sitemap_enabled: false); + + $this->get('/robots.txt')->assertOk()->assertDontSee('Sitemap:'); + } + + public function test_disabled_robots_is_not_found(): void + { + $this->updateSettings(robots_enabled: false); + + $this->get('/robots.txt')->assertNotFound(); + } + + private function updateSettings(?bool $sitemap_enabled = null, ?bool $robots_enabled = null, ?string $robots_content = null): void + { + $settings = app(SeoSettings::class); + $settings->sitemap_enabled = $sitemap_enabled ?? $settings->sitemap_enabled; + $settings->robots_enabled = $robots_enabled ?? $settings->robots_enabled; + $settings->robots_content = $robots_content ?? $settings->robots_content; + $settings->save(); + } +}