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
21 changes: 11 additions & 10 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,31 @@
"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",
"saucebase/module-installer": "^2.8.0",
"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": {
Expand Down
13 changes: 13 additions & 0 deletions database/settings/0001_01_01_000013_create_seo_settings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

use Spatie\LaravelSettings\Migrations\SettingsMigration;

return new class extends SettingsMigration
{
public function up(): void
{
$this->migrator->add('seo.sitemap_enabled', true);
$this->migrator->add('seo.robots_enabled', true);
$this->migrator->add('seo.robots_content', "User-agent: *\nDisallow: /admin");
}
};
3 changes: 3 additions & 0 deletions src/CoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,6 +56,8 @@ public function register(): void
foreach (self::PROVIDERS as $provider) {
$this->app->register($provider);
}

$this->app->singleton(SitemapRegistry::class);
}

/**
Expand Down
57 changes: 57 additions & 0 deletions src/Filament/Admin/Pages/SeoSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Saucebase\Core\Filament\Admin\Pages;

use BackedEnum;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Saucebase\Core\Filament\Pages\SettingsPage;
use Saucebase\Core\Settings\SeoSettings as SeoSettingsData;

class SeoSettings extends SettingsPage
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlass;

protected static ?int $navigationSort = 3;

protected static string $settings = SeoSettingsData::class;

public static function getNavigationLabel(): string
{
return __('SEO');
}

public function getTitle(): string
{
return __('SEO Settings');
}

public function form(Schema $schema): Schema
{
return $schema->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']),
]),
]);
}
}
26 changes: 26 additions & 0 deletions src/Http/Controllers/RobotsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Saucebase\Core\Http\Controllers;

use Illuminate\Http\Response;
use Saucebase\Core\Settings\SeoSettings;

class RobotsController
{
/**
* The `Sitemap:` line has to be an absolute URL, which a static public/robots.txt
* cannot know, so it is added here from the current host.
*/
public function __invoke(SeoSettings $settings): Response
{
abort_unless($settings->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']);
}
}
18 changes: 18 additions & 0 deletions src/Http/Controllers/SitemapController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Saucebase\Core\Http\Controllers;

use Saucebase\Core\Settings\SeoSettings;
use Saucebase\Core\Sitemap\SitemapRegistry;
use Spatie\Sitemap\Sitemap;

class SitemapController
{
// ponytail: built per request; cache the rendered XML if contributors get slow.
public function __invoke(SitemapRegistry $registry, SeoSettings $settings): Sitemap
{
abort_unless($settings->sitemap_enabled, 404);

return $registry->build();
}
}
24 changes: 24 additions & 0 deletions src/Settings/SeoSettings.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Saucebase\Core\Settings;

use Spatie\LaravelSettings\Settings;

/**
* What the application tells search engines: whether it publishes a sitemap and a
* robots.txt, and which crawl rules the robots.txt carries.
*/
class SeoSettings extends Settings
{
public bool $sitemap_enabled;

public bool $robots_enabled;

/** The crawl rules. The `Sitemap:` line is not stored here; it is appended when served. */
public string $robots_content;

public static function group(): string
{
return 'seo';
}
}
38 changes: 38 additions & 0 deletions src/Sitemap/SitemapRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Saucebase\Core\Sitemap;

use Closure;
use Spatie\Sitemap\Sitemap;

/**
* Collects the URLs the application and each module want in `/sitemap.xml`.
*
* Contributors register a callback from their service provider's boot(). Callbacks run
* when the sitemap is requested, not at boot, so a module's queries and route() calls
* only happen for that one request.
*/
class SitemapRegistry
{
/** @var list<Closure(Sitemap): mixed> */
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;
}
}
82 changes: 82 additions & 0 deletions tests/Feature/SitemapTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace Saucebase\Core\Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Saucebase\Core\Http\Controllers\RobotsController;
use Saucebase\Core\Http\Controllers\SitemapController;
use Saucebase\Core\Settings\SeoSettings;
use Saucebase\Core\Sitemap\SitemapRegistry;
use Saucebase\Core\Tests\TestCase;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;

class SitemapTest extends TestCase
{
use RefreshDatabase;

protected function setUp(): void
{
parent::setUp();

Route::get('/sitemap.xml', SitemapController::class)->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('<loc>https://example.test/</loc>', false)
->assertSee('<loc>https://example.test/blog</loc>', 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

Check warning on line 74 in tests/Feature/SitemapTest.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Split this 134 characters long line (which is greater than 120 authorized).

See more on https://sonarcloud.io/project/issues?id=saucebase-dev_core&issues=AaCmdMFV8z7kZVYZ_8dT&open=AaCmdMFV8z7kZVYZ_8dT&pullRequest=2
{
$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();
}
}
Loading