diff --git a/composer.json b/composer.json index 69252318..558a5903 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "require": { "php": "^8.2", "laravel/framework": "^10.0 || ^11.0", + "laravel/prompts": "^0.1.24", "spatie/browsershot": "^4.0", "spatie/image": "^3.4", "spatie/laravel-ray": "^1.32", diff --git a/config/advanced-seo.php b/config/advanced-seo.php index 639e2a99..28a1297d 100644 --- a/config/advanced-seo.php +++ b/config/advanced-seo.php @@ -226,14 +226,25 @@ /* |-------------------------------------------------------------------------- - | Cache Expiry + | Storage Path |-------------------------------------------------------------------------- | - | The sitemap cache expiry in minutes. + | The directory where your sitemap files will be located. | */ - 'expiry' => 60, + 'path' => storage_path('statamic/sitemaps'), + + /* + |-------------------------------------------------------------------------- + | Queue + |-------------------------------------------------------------------------- + | + | The queue that is used when generating the sitemaps. + | + */ + + 'queue' => 'default', ], diff --git a/routes/web.php b/routes/web.php index 107c754d..015e6921 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,6 @@ Route::name('advanced-seo.')->group(function () { Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('sitemap.index'); - Route::get('/sitemap-{type}-{handle}.xml', [SitemapController::class, 'show'])->name('sitemap.show'); + Route::get('/sitemaps/{id}.xml', [SitemapController::class, 'show'])->name('sitemap.show'); Route::get('/sitemap.xsl', [SitemapController::class, 'xsl'])->name('sitemap.xsl'); }); diff --git a/src/Actions/IncludeInSitemap.php b/src/Actions/IncludeInSitemap.php index 7957aea3..1a58c8b0 100644 --- a/src/Actions/IncludeInSitemap.php +++ b/src/Actions/IncludeInSitemap.php @@ -4,7 +4,9 @@ use Aerni\AdvancedSeo\Concerns\AsAction; use Aerni\AdvancedSeo\Concerns\EvaluatesIndexability; -use Aerni\AdvancedSeo\Facades\Seo; +use Aerni\AdvancedSeo\Data\DefaultsData; +use Aerni\AdvancedSeo\Features\Sitemap; +use Statamic\Contracts\Entries\Collection; use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Taxonomies\Taxonomy; use Statamic\Contracts\Taxonomies\Term; @@ -15,37 +17,50 @@ class IncludeInSitemap use AsAction; use EvaluatesIndexability; - public function handle(Entry|Term|Taxonomy $model, ?string $locale = null): bool + protected DefaultsData $data; + + public function handle(Entry|Term|Collection|Taxonomy $model, ?string $locale = null): bool { - $locale ??= $model->locale(); + if ($this->localeIsRequired($model) && is_null($locale)) { + throw new \Exception('A locale is required if the model is a Collection or Taxonomy.'); + } + + $this->data = GetDefaultsData::handle($model); + + if ($locale) { + $this->data->locale = $locale; + } - return Blink::once("{$model->id()}::{$locale}", fn () => match (true) { + return Blink::once("include-in-sitemap-{$this->data->id()}::{$model->id()}", fn () => match (true) { $model instanceof Entry => $this->includeEntryOrTermInSitemap($model), $model instanceof Term => $this->includeEntryOrTermInSitemap($model), - $model instanceof Taxonomy => $this->includeTaxonomyInSitemap($model, $locale) + $model instanceof Collection => $this->includeCollectionOrTaxonomyInSitemap($model), + $model instanceof Taxonomy => $this->includeCollectionOrTaxonomyInSitemap($model) }); } protected function includeEntryOrTermInSitemap(Entry|Term $model): bool { - return ! $this->isExcludedFromSitemap($model, $model->locale) + return Sitemap::enabled($this->data) && $this->isIndexableEntryOrTerm($model) && $model->seo_sitemap_enabled && $model->seo_canonical_type == 'current'; } - protected function includeTaxonomyInSitemap(Taxonomy $taxonomy, string $locale): bool + protected function includeCollectionOrTaxonomyInSitemap(Collection|Taxonomy $model): bool { - return ! $this->isExcludedFromSitemap($taxonomy, $locale) - && $this->isIndexableSite($locale); + // TODO: Currently, taxonomies don't have a routes method. But they should once PR https://github.com/statamic/cms/pull/8627 is merged. + $hasRouteForSite = $model instanceof Collection + ? $model->routes()->filter()->has($this->data->locale) + : true; + + return Sitemap::enabled($this->data) + && $this->isIndexableSite($this->data->locale) + && $hasRouteForSite; } - protected function isExcludedFromSitemap(Entry|Term|Taxonomy $model, string $locale): bool + protected function localeIsRequired($model): bool { - $excluded = Seo::find('site', 'indexing') - ?->in($locale) - ?->value('excluded_'.EvaluateModelType::handle($model)) ?? []; - - return in_array(EvaluateModelHandle::handle($model), $excluded); + return $model instanceof Taxonomy || $model instanceof Collection; } } diff --git a/src/Blueprints/BaseBlueprint.php b/src/Blueprints/BaseBlueprint.php index c1bda54d..997c8b66 100644 --- a/src/Blueprints/BaseBlueprint.php +++ b/src/Blueprints/BaseBlueprint.php @@ -15,7 +15,7 @@ abstract class BaseBlueprint implements Contract public static function make(): self { - return new static(); + return new static; } public function data(DefaultsData $data): self diff --git a/src/Commands/GenerateSitemaps.php b/src/Commands/GenerateSitemaps.php new file mode 100644 index 00000000..01cdd764 --- /dev/null +++ b/src/Commands/GenerateSitemaps.php @@ -0,0 +1,48 @@ +shouldQueue = $this->option('queue'); + + if ($this->shouldQueue && config('queue.default') === 'sync') { + warning('The queue connection is set to "sync". Queueing will be disabled.'); + $this->shouldQueue = false; + } + + $sitemaps = collect([Sitemap::index()])->merge(Sitemap::all()); + + $this->shouldQueue + ? GenerateSitemapsJob::dispatch($sitemaps) + : spin(fn () => GenerateSitemapsJob::dispatchSync($sitemaps), 'Generating sitemaps ...'); + + $this->shouldQueue + ? info('All requests to generate the sitemaps have been added to the queue.') + : info('The sitemaps have been succesfully generated.'); + } +} diff --git a/src/Contracts/Sitemap.php b/src/Contracts/Sitemap.php index b6126304..11180b5a 100644 --- a/src/Contracts/Sitemap.php +++ b/src/Contracts/Sitemap.php @@ -17,6 +17,4 @@ public function id(): string; public function url(): string; public function lastmod(): ?string; - - public function clearCache(): void; } diff --git a/src/Contracts/SitemapFile.php b/src/Contracts/SitemapFile.php new file mode 100644 index 00000000..9cc7ff15 --- /dev/null +++ b/src/Contracts/SitemapFile.php @@ -0,0 +1,14 @@ +fieldName}Sitemaps"}(); - - if ($baseUrl = $args['baseUrl'] ?? null) { - $sitemaps = $sitemaps->each(fn ($sitemap) => $sitemap->baseUrl($baseUrl)); - } - - if ($handle = $args['handle'] ?? null) { - $sitemaps = $sitemaps->filter(fn ($sitemap) => $handle === $sitemap->handle()); - } - - $sitemapUrls = $sitemaps->flatMap->urls(); - - if ($site = $args['site'] ?? null) { - $sitemapUrls = $sitemapUrls->filter(fn ($url) => $url->site() === $site); - } - - return $sitemapUrls->isNotEmpty() ? $sitemapUrls->toArray() : null; + $sitemaps = Sitemap::all() + ->filter(fn ($sitemap) => $sitemap->type() === $info->fieldName) + ->when( + $handle = data_get($args, 'handle'), + fn ($sitemaps) => $sitemaps->filter(fn ($sitemap) => $sitemap->handle() === $handle) + ); + + $urls = $sitemaps->flatMap->urls() + ->when( + $site = data_get($args, 'site'), + fn ($urls) => $urls->filter(fn ($url) => $url->site() === $site) + ) + ->when( + $baseUrl = data_get($args, 'baseUrl'), + fn ($urls) => $urls->each(fn ($url) => $url->baseUrl($baseUrl)) + ); + + return $urls->isNotEmpty() ? $urls->toArray() : null; } } diff --git a/src/Http/Controllers/Cp/SeoDefaultsController.php b/src/Http/Controllers/Cp/SeoDefaultsController.php index 46c01f86..aae390cd 100644 --- a/src/Http/Controllers/Cp/SeoDefaultsController.php +++ b/src/Http/Controllers/Cp/SeoDefaultsController.php @@ -177,7 +177,7 @@ protected function flashDefaultsUnavailable(): void 'type' => Str::singular($this->type()), ])); - throw new NotFoundHttpException(); + throw new NotFoundHttpException; } protected function redirectToIndex(SeoDefaultSet $set, string $site): RedirectResponse diff --git a/src/Http/Controllers/Web/SitemapController.php b/src/Http/Controllers/Web/SitemapController.php index 0a051e1e..72ccdc8d 100644 --- a/src/Http/Controllers/Web/SitemapController.php +++ b/src/Http/Controllers/Web/SitemapController.php @@ -2,67 +2,42 @@ namespace Aerni\AdvancedSeo\Http\Controllers\Web; -use Aerni\AdvancedSeo\Facades\Sitemap; +use Aerni\AdvancedSeo\Concerns\EvaluatesIndexability; +use Aerni\AdvancedSeo\Contracts\Sitemap; +use Aerni\AdvancedSeo\Contracts\SitemapIndex; +use Aerni\AdvancedSeo\Facades\Sitemap as SitemapRepository; use Illuminate\Http\Response; use Illuminate\Routing\Controller; -use Illuminate\Support\Facades\Cache; use Statamic\Exceptions\NotFoundHttpException; -use Statamic\Facades\Addon; class SitemapController extends Controller { - public function index(): Response + use EvaluatesIndexability; + + public function __construct() { throw_unless(config('advanced-seo.sitemap.enabled'), new NotFoundHttpException); - - $sitemaps = Cache::remember('advanced-seo::sitemaps::index', Sitemap::cacheExpiry(), function () { - return Sitemap::all() - ->filter(fn ($sitemap) => $sitemap->urls()->isNotEmpty()) - ->toArray(); - }); - - throw_unless($sitemaps, new NotFoundHttpException); - - return response() - ->view('advanced-seo::sitemaps.index', [ - 'sitemaps' => $sitemaps, - 'version' => Addon::get('aerni/advanced-seo')->version(), - ]) - ->header('Content-Type', 'text/xml') - ->header('X-Robots-Tag', 'noindex, nofollow'); + throw_unless($this->crawlingIsEnabled(), new NotFoundHttpException); } - public function show(string $type, string $handle): Response + public function index(): SitemapIndex { - throw_unless(config('advanced-seo.sitemap.enabled'), new NotFoundHttpException); - - $id = "{$type}::{$handle}"; - - $urls = Cache::remember( - "advanced-seo::sitemaps::{$id}", - Sitemap::cacheExpiry(), - fn () => Sitemap::find($id)?->urls()->toArray() - ); - - throw_unless($urls, new NotFoundHttpException); + return SitemapRepository::index(); + } - return response() - ->view('advanced-seo::sitemaps.show', [ - 'urls' => $urls, - 'version' => Addon::get('aerni/advanced-seo')->version(), - ]) - ->header('Content-Type', 'text/xml') - ->header('X-Robots-Tag', 'noindex, nofollow'); + public function show(string $id): Sitemap + { + return throw_unless(SitemapRepository::find($id), NotFoundHttpException::class); } public function xsl(): Response { - throw_unless(config('advanced-seo.sitemap.enabled'), new NotFoundHttpException); - - $path = __DIR__.'/../../../../resources/views/sitemaps/sitemap.xsl'; - - return response(file_get_contents($path)) - ->header('Content-Type', 'text/xsl') - ->header('X-Robots-Tag', 'noindex, nofollow'); + return response( + content: SitemapRepository::xsl(), + headers: [ + 'Content-Type' => 'text/xsl', + 'X-Robots-Tag' => 'noindex, nofollow', + ], + ); } } diff --git a/src/Http/Controllers/Web/SocialImagesController.php b/src/Http/Controllers/Web/SocialImagesController.php index 993140ec..e0af29f7 100644 --- a/src/Http/Controllers/Web/SocialImagesController.php +++ b/src/Http/Controllers/Web/SocialImagesController.php @@ -25,7 +25,7 @@ public function show(string $theme, string $type, string $id): Response throw_unless($data = $this->getData($id), new NotFoundHttpException); // Throw if the data is not an entry or term. - throw_unless($data instanceof Entry || $data instanceof LocalizedTerm, new NotFoundHttpException()); + throw_unless($data instanceof Entry || $data instanceof LocalizedTerm, new NotFoundHttpException); // Throw if the social image type is not supported. throw_unless($model = SocialImage::findModel(Str::replace('-', '_', $type)), new NotFoundHttpException); diff --git a/src/Jobs/GenerateSitemapsJob.php b/src/Jobs/GenerateSitemapsJob.php new file mode 100644 index 00000000..d1de63cb --- /dev/null +++ b/src/Jobs/GenerateSitemapsJob.php @@ -0,0 +1,30 @@ +queue = config('advanced-seo.sitemap.queue', 'default'); + } + + public function handle(): void + { + File::deleteDirectory(Sitemap::path()); + + $this->sitemaps->each->save(); + } +} diff --git a/src/Listeners/GenerateFavicons.php b/src/Listeners/GenerateFavicons.php index aac9fcff..a299a1e5 100644 --- a/src/Listeners/GenerateFavicons.php +++ b/src/Listeners/GenerateFavicons.php @@ -51,7 +51,7 @@ private function createThumbnail($svg, $exportPath, $width, $height, $background $ratio = $height / $viewBoxHeight; } - $imagick = new \Imagick(); + $imagick = new \Imagick; $imagick->setResolution($viewBoxWidth * $ratio * 2, $viewBoxHeight * $ratio * 2); $imagick->setBackgroundColor(new \ImagickPixel($background)); diff --git a/src/Migrators/BaseMigrator.php b/src/Migrators/BaseMigrator.php index 91e11481..7cd5151c 100644 --- a/src/Migrators/BaseMigrator.php +++ b/src/Migrators/BaseMigrator.php @@ -12,7 +12,7 @@ abstract class BaseMigrator { public static function run(): void { - (new static())->process(); + (new static)->process(); } protected function process(): void diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b7d17593..4a08507a 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -49,6 +49,7 @@ class ServiceProvider extends AddonServiceProvider ]; protected $commands = [ + Commands\GenerateSitemaps::class, Commands\GenerateSocialImages::class, Commands\MakeTheme::class, Commands\Migrate::class, @@ -70,7 +71,6 @@ class ServiceProvider extends AddonServiceProvider protected $subscribe = [ \Aerni\AdvancedSeo\Subscribers\ContentDefaultsSubscriber::class, \Aerni\AdvancedSeo\Subscribers\OnPageSeoBlueprintSubscriber::class, - \Aerni\AdvancedSeo\Subscribers\SitemapCacheSubscriber::class, \Aerni\AdvancedSeo\Subscribers\SocialImagesGeneratorSubscriber::class, ]; @@ -241,8 +241,8 @@ protected function bootGraphQL(): self GraphQL::addType(SocialImagePresetType::class); GraphQL::addType(SocialMediaDefaultsType::class); - GraphQL::addField(EntryInterface::NAME, 'seo', fn () => (new SeoField())->toArray()); - GraphQL::addField(TermInterface::NAME, 'seo', fn () => (new SeoField())->toArray()); + GraphQL::addField(EntryInterface::NAME, 'seo', fn () => (new SeoField)->toArray()); + GraphQL::addField(TermInterface::NAME, 'seo', fn () => (new SeoField)->toArray()); } return $this; diff --git a/src/Sitemaps/BaseSitemap.php b/src/Sitemaps/BaseSitemap.php index 095aab4a..c653550a 100644 --- a/src/Sitemaps/BaseSitemap.php +++ b/src/Sitemaps/BaseSitemap.php @@ -2,47 +2,56 @@ namespace Aerni\AdvancedSeo\Sitemaps; -use Aerni\AdvancedSeo\Concerns\HasBaseUrl; -use Aerni\AdvancedSeo\Contracts\Sitemap; +use Aerni\AdvancedSeo\Contracts\Sitemap as Contract; +use Aerni\AdvancedSeo\Contracts\SitemapFile; +use Aerni\AdvancedSeo\Facades\Sitemap; +use Aerni\AdvancedSeo\Sitemaps\Collections\CollectionSitemap; +use Aerni\AdvancedSeo\Sitemaps\Custom\CustomSitemap; +use Aerni\AdvancedSeo\Sitemaps\Taxonomies\TaxonomySitemap; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Contracts\Support\Renderable; +use Illuminate\Contracts\Support\Responsable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Statamic\Contracts\Query\Builder; +use Statamic\Facades\Addon; use Statamic\Facades\URL; use Statamic\Support\Traits\FluentlyGetsAndSets; -abstract class BaseSitemap implements Arrayable, Sitemap +abstract class BaseSitemap implements Arrayable, Contract, Renderable, Responsable, SitemapFile { - use FluentlyGetsAndSets, HasBaseUrl; - - protected Collection $urls; + use FluentlyGetsAndSets; abstract public function urls(): Collection; public function handle(): string { - return $this->type() === 'custom' - ? $this->handle - : $this->model->handle(); + return match (static::class) { + CollectionSitemap::class => $this->model->handle(), + TaxonomySitemap::class => $this->model->handle(), + CustomSitemap::class => $this->handle, + default => Str::of(static::class)->afterLast('\\')->remove('Sitemap')->snake(), + }; } public function type(): string { - return Str::of(static::class)->afterLast('\\')->remove('Sitemap')->lower(); + return match (static::class) { + CollectionSitemap::class => 'collection', + TaxonomySitemap::class => 'taxonomy', + default => 'custom', + }; } public function id(): string { - return "{$this->type()}::{$this->handle()}"; + return Str::slug("{$this->type()}-{$this->handle()}"); } public function url(): string { - $baseUrl = config('app.url'); - $filename = "sitemap-{$this->type()}-{$this->handle()}.xml"; - - return URL::tidy("{$baseUrl}/{$filename}"); + return URL::tidy(config('app.url')."/sitemaps/{$this->filename()}"); } public function lastmod(): ?string @@ -50,20 +59,28 @@ public function lastmod(): ?string return $this->urls()->sortByDesc('lastmod')->first()?->lastmod(); } - public function clearCache(): void - { - Cache::forget('advanced-seo::sitemaps::index'); - Cache::forget("advanced-seo::sitemaps::{$this->id()}"); - } - protected function includeInSitemapQuery(Builder $query): Builder { return $query ->where('published', true) - ->whereNotNull('url') - ->where('seo_noindex', false) - ->where('seo_sitemap_enabled', true) - ->where('seo_canonical_type', 'current'); + ->whereNotNull('url'); + + /** + * A reminder for my later self. We used to also include the following queries here: + * + * $query + * ->where('seo_noindex', false) + * ->where('seo_sitemap_enabled', true) + * ->where('seo_canonical_type', 'current') + * + * But we removed them as they lead to unexpected results due to the following reasons: + * + * Features like the sitemap are evaluated and enabled based the locale of an entry, + * which determines if certain fields are added when extending the blueprint. + * But the query lead to unexpected results because it looks in the Stache which could + * have fields in there or not based on which entry built the Stache. It's complicated. + * So we just removed the queries as we are filtering those specific fields later on anyways. + */ } public function toArray(): array @@ -71,9 +88,53 @@ public function toArray(): array return [ 'url' => $this->url(), 'lastmod' => $this->lastmod(), + 'urls' => $this->urls()->toArray(), ]; } + public function render(): string + { + return view('advanced-seo::sitemaps.show', [ + 'urls' => $this->toArray()['urls'], + 'version' => Addon::get('aerni/advanced-seo')->version(), + ])->render(); + } + + public function toResponse($request) + { + return response( + content: $this->file() ?? $this->render(), + headers: [ + 'Content-Type' => 'text/xml', + 'X-Robots-Tag' => 'noindex, nofollow', + ] + ); + } + + public function filename(): string + { + return "{$this->id()}.xml"; + } + + public function file(): ?string + { + return File::exists($this->path()) ? File::get($this->path()) : null; + } + + public function path(): string + { + return Sitemap::path($this->filename()); + } + + public function save(): self + { + File::ensureDirectoryExists(Sitemap::path()); + + File::put($this->path(), $this->render()); + + return $this; + } + public function __call(string $name, array $arguments): mixed { return $this->fluentlyGetOrSet($name)->args($arguments); diff --git a/src/Sitemaps/BaseSitemapUrl.php b/src/Sitemaps/BaseSitemapUrl.php index 625e3439..71acc972 100644 --- a/src/Sitemaps/BaseSitemapUrl.php +++ b/src/Sitemaps/BaseSitemapUrl.php @@ -2,8 +2,10 @@ namespace Aerni\AdvancedSeo\Sitemaps; +use Aerni\AdvancedSeo\Concerns\HasBaseUrl; use Aerni\AdvancedSeo\Contracts\SitemapUrl; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Str; use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Taxonomies\Taxonomy; use Statamic\Contracts\Taxonomies\Term; @@ -12,6 +14,8 @@ abstract class BaseSitemapUrl implements Arrayable, SitemapUrl { + use HasBaseUrl; + abstract public function loc(): string|self; abstract public function alternates(): array|self|null; @@ -41,10 +45,13 @@ public function toArray(): array ]; } - protected function absoluteUrl(Entry|Taxonomy|Term|Site $model): string + protected function absoluteUrl(Entry|Taxonomy|Term|Site|string $model): string { - return $this->sitemap->baseUrl() - ? URL::assemble($this->sitemap->baseUrl(), $model->url()) - : $model->absoluteUrl(); + return match (true) { + $this->baseUrl() && is_string($model) => URL::assemble($this->baseUrl(), Str::after($model, parse_url($model, PHP_URL_HOST))), + $this->baseUrl() => URL::assemble($this->baseUrl(), $model->url()), + is_string($model) => $model, + default => $model->absoluteUrl(), + }; } } diff --git a/src/Sitemaps/Collections/CollectionSitemap.php b/src/Sitemaps/Collections/CollectionSitemap.php index 269cc70a..826386f0 100644 --- a/src/Sitemaps/Collections/CollectionSitemap.php +++ b/src/Sitemaps/Collections/CollectionSitemap.php @@ -6,6 +6,7 @@ use Aerni\AdvancedSeo\Sitemaps\BaseSitemap; use Illuminate\Support\Collection; use Statamic\Contracts\Entries\Collection as EntriesCollection; +use Statamic\Facades\Blink; class CollectionSitemap extends BaseSitemap { @@ -13,13 +14,11 @@ public function __construct(protected EntriesCollection $model) {} public function urls(): Collection { - if (isset($this->urls)) { - return $this->urls; - } - - return $this->urls = $this->entries() - ->map(fn ($entry) => new EntrySitemapUrl($entry, $this)) - ->filter(fn ($url) => $url->canonicalTypeIsCurrent()); + return Blink::once($this->filename(), function () { + return $this->entries() + ->map(fn ($entry) => new EntrySitemapUrl($entry)) + ->filter(fn ($url) => $url->canonicalTypeIsCurrent()); + }); } protected function entries(): Collection diff --git a/src/Sitemaps/Collections/EntrySitemapUrl.php b/src/Sitemaps/Collections/EntrySitemapUrl.php index 717e1d18..1423652d 100644 --- a/src/Sitemaps/Collections/EntrySitemapUrl.php +++ b/src/Sitemaps/Collections/EntrySitemapUrl.php @@ -10,7 +10,7 @@ class EntrySitemapUrl extends BaseSitemapUrl { - public function __construct(protected Entry $entry, protected CollectionSitemap $sitemap) {} + public function __construct(protected Entry $entry) {} public function loc(): string { diff --git a/src/Sitemaps/Custom/CustomSitemap.php b/src/Sitemaps/Custom/CustomSitemap.php index 8eeddcfe..443f546c 100644 --- a/src/Sitemaps/Custom/CustomSitemap.php +++ b/src/Sitemaps/Custom/CustomSitemap.php @@ -7,6 +7,8 @@ class CustomSitemap extends BaseSitemap { + protected Collection $urls; + public function __construct(protected string $handle) { $this->urls = collect(); diff --git a/src/Sitemaps/Custom/CustomSitemapUrl.php b/src/Sitemaps/Custom/CustomSitemapUrl.php index 3a9fde07..fdc313e2 100644 --- a/src/Sitemaps/Custom/CustomSitemapUrl.php +++ b/src/Sitemaps/Custom/CustomSitemapUrl.php @@ -14,7 +14,7 @@ class CustomSitemapUrl extends BaseSitemapUrl public function __construct( protected string $loc, - protected ?string $alternates = null, + protected ?array $alternates = null, protected ?string $lastmod = null, protected ?string $changefreq = null, protected ?string $priority = null, @@ -23,12 +23,21 @@ public function __construct( public function loc(?string $loc = null): string|self { - return $this->fluentlyGetOrSet('loc')->args(func_get_args()); + return $this->fluentlyGetOrSet('loc') + ->getter(fn ($loc) => $this->absoluteUrl($loc)) + ->args(func_get_args()); } public function alternates(?array $alternates = null): array|self|null { return $this->fluentlyGetOrSet('alternates') + ->getter(function ($alternates) { + return collect($alternates)->map(function ($alternate) { + $alternate['href'] = $this->absoluteUrl($alternate['href']); + + return $alternate; + })->all(); + }) ->setter(function ($alternates) { foreach ($alternates as $alternate) { throw_unless(array_key_exists('href', $alternate), new \Exception("One of your alternate links is missing the 'href' attribute.")); diff --git a/src/Sitemaps/SitemapIndex.php b/src/Sitemaps/SitemapIndex.php new file mode 100644 index 00000000..32d967bf --- /dev/null +++ b/src/Sitemaps/SitemapIndex.php @@ -0,0 +1,117 @@ +sitemaps = collect($this->sitemaps) + ->push($sitemap) + ->unique(fn ($sitemap) => $sitemap->handle()) + ->all(); + + return $this; + } + + public function sitemaps(): Collection + { + return Blink::once($this->filename(), function () { + return collect($this->sitemaps) + ->merge($this->collectionSitemaps()) + ->merge($this->taxonomySitemaps()); + }); + } + + protected function collectionSitemaps(): Collection + { + return CollectionFacade::all() + ->filter($this->shouldProcessSitemap(...)) + ->mapInto(CollectionSitemap::class) + ->values(); + } + + protected function taxonomySitemaps(): Collection + { + return Taxonomy::all() + ->filter($this->shouldProcessSitemap(...)) + ->mapInto(TaxonomySitemap::class) + ->values(); + } + + public function toArray(): array + { + return $this->sitemaps()->toArray(); + } + + public function render(): string + { + return view('advanced-seo::sitemaps.index', [ + 'sitemaps' => $this->toArray(), + 'version' => Addon::get('aerni/advanced-seo')->version(), + ])->render(); + } + + public function toResponse($request) + { + return response( + content: $this->file() ?? $this->render(), + headers: [ + 'Content-Type' => 'text/xml', + 'X-Robots-Tag' => 'noindex, nofollow', + ] + ); + } + + public function filename(): string + { + return 'sitemap.xml'; + } + + public function file(): ?string + { + return File::exists($this->path()) ? File::get($this->path()) : null; + } + + public function path(): string + { + return SitemapRepository::path($this->filename()); + } + + public function save(): self + { + File::ensureDirectoryExists(SitemapRepository::path()); + + File::put($this->path(), $this->render()); + + return $this; + } + + protected function shouldProcessSitemap(CollectionContract|TaxonomyContract $model): bool + { + return $model->sites() + ->map(fn ($site) => IncludeInSitemap::run($model, $site)) + ->contains('true'); + } +} diff --git a/src/Sitemaps/SitemapRepository.php b/src/Sitemaps/SitemapRepository.php index 724b103c..b0653a6a 100644 --- a/src/Sitemaps/SitemapRepository.php +++ b/src/Sitemaps/SitemapRepository.php @@ -2,20 +2,23 @@ namespace Aerni\AdvancedSeo\Sitemaps; -use Aerni\AdvancedSeo\Actions\IsEnabledModel; use Aerni\AdvancedSeo\Contracts\Sitemap; -use Aerni\AdvancedSeo\Sitemaps\Collections\CollectionSitemap; use Aerni\AdvancedSeo\Sitemaps\Custom\CustomSitemap; use Aerni\AdvancedSeo\Sitemaps\Custom\CustomSitemapUrl; -use Aerni\AdvancedSeo\Sitemaps\Taxonomies\TaxonomySitemap; +use Closure; use Illuminate\Support\Collection; -use Illuminate\Support\Str; -use Statamic\Facades\Collection as CollectionFacade; -use Statamic\Facades\Taxonomy; +use Statamic\Facades\Path; class SitemapRepository { - protected array $customSitemaps = []; + protected array $extensions = []; + + public function __construct(protected SitemapIndex $sitemapIndex) {} + + public function register(Closure|array|string $extensions): void + { + $this->extensions[] = $extensions; + } public function make(string $handle): CustomSitemap { @@ -27,60 +30,56 @@ public function makeUrl(string $loc): CustomSitemapUrl return new CustomSitemapUrl($loc); } - public function add(CustomSitemap $sitemap): void + public function add(Sitemap $sitemap): void { - $this->customSitemaps = $this->customSitemaps() - ->push($sitemap) - ->unique(fn ($sitemap) => $sitemap->handle()) - ->all(); + $this->sitemapIndex->add($sitemap); } - public function all(): Collection + public function index(): SitemapIndex { - return $this->collectionSitemaps() - ->merge($this->taxonomySitemaps()) - ->merge($this->customSitemaps()); + $this->boot(); + + return $this->sitemapIndex; } - public function find(string $id): ?Sitemap + public function all(): Collection { - $method = Str::before($id, '::').'Sitemaps'; - - if (! method_exists($this, $method)) { - return null; - } + $this->boot(); - return $this->$method()->first(fn ($sitemap) => $id === $sitemap->id()); + return $this->sitemapIndex->sitemaps(); } - public function collectionSitemaps(): Collection + public function find(string $id): ?Sitemap { - return CollectionFacade::all() - ->filter(IsEnabledModel::handle(...)) - ->mapInto(CollectionSitemap::class) - ->values(); + return $this->all()->firstWhere(fn ($sitemap) => $sitemap->id() === $id); } - public function taxonomySitemaps(): Collection + public function xsl(): string { - return Taxonomy::all() - ->filter(IsEnabledModel::handle(...)) - ->mapInto(TaxonomySitemap::class) - ->values(); + return file_get_contents(__DIR__.'/../../resources/views/sitemaps/sitemap.xsl'); } - public function customSitemaps(): Collection + public function path(string $path = ''): string { - return collect($this->customSitemaps); + return Path::assemble( + config('advanced-seo.sitemap.path', storage_path('statamic/sitemaps')), + $path + ); } - public function clearCache(): void + protected function boot(): void { - $this->all()->each->clearCache(); - } + collect($this->extensions) + ->map(fn ($extension) => $extension instanceof Closure ? $extension() : $extension) + ->flatten() + ->map(fn ($sitemap) => $sitemap instanceof Sitemap ? $sitemap : app($sitemap)) + ->each(fn ($sitemap) => $this->add($sitemap)); - public function cacheExpiry(): int - { - return config('advanced-seo.sitemap.expiry', 60) * 60; + /** + * TODO: Once we drop support for Laravel 10, we could use Laravel's new once() helper instead. + * Ensure we don't boot extensions multiple times during the same request, + * which could happen if the `index()` and `all()` methods are called in the same request. + */ + $this->extensions = []; } } diff --git a/src/Sitemaps/Taxonomies/CollectionTaxonomySitemapUrl.php b/src/Sitemaps/Taxonomies/CollectionTaxonomySitemapUrl.php index 34e27744..ad1fa78f 100644 --- a/src/Sitemaps/Taxonomies/CollectionTaxonomySitemapUrl.php +++ b/src/Sitemaps/Taxonomies/CollectionTaxonomySitemapUrl.php @@ -51,11 +51,14 @@ public function alternates(): ?array public function lastmod(): string { if ($term = $this->lastModifiedTaxonomyTerm()) { + /* Ensure we are getting a fresh last modified date in case that there is no last modified taxonomy term. */ + Cache::forget("advanced-seo::sitemaps::collection-taxonomy::{$this->taxonomy}::lastmod"); + return $term->lastModified()->format('Y-m-d\TH:i:sP'); } return Cache::rememberForever( - "advanced-seo::sitemaps::taxonomy::{$this->taxonomy}::lastmod", + "advanced-seo::sitemaps::collection-taxonomy::{$this->taxonomy}::lastmod", fn () => now()->format('Y-m-d\TH:i:sP') ); } diff --git a/src/Sitemaps/Taxonomies/TaxonomySitemap.php b/src/Sitemaps/Taxonomies/TaxonomySitemap.php index 449bb290..5f315d21 100644 --- a/src/Sitemaps/Taxonomies/TaxonomySitemap.php +++ b/src/Sitemaps/Taxonomies/TaxonomySitemap.php @@ -6,6 +6,7 @@ use Aerni\AdvancedSeo\Sitemaps\BaseSitemap; use Illuminate\Support\Collection; use Statamic\Contracts\Taxonomies\Taxonomy; +use Statamic\Facades\Blink; class TaxonomySitemap extends BaseSitemap { @@ -13,15 +14,13 @@ public function __construct(protected Taxonomy $model) {} public function urls(): Collection { - if (isset($this->urls)) { - return $this->urls; - } - - return $this->urls = $this->taxonomyUrls() - ->merge($this->termUrls()) - ->merge($this->collectionTaxonomyUrls()) - ->merge($this->collectionTermUrls()) - ->filter(fn ($url) => $url->canonicalTypeIsCurrent()); + return Blink::once($this->filename(), function () { + return $this->taxonomyUrls() + ->merge($this->termUrls()) + ->merge($this->collectionTaxonomyUrls()) + ->merge($this->collectionTermUrls()) + ->filter(fn ($url) => $url->canonicalTypeIsCurrent()); + }); } protected function taxonomyUrls(): Collection @@ -34,7 +33,7 @@ protected function taxonomyUrls(): Collection protected function termUrls(): Collection { return $this->terms() - ->map(fn ($term) => new TermSitemapUrl($term, $this)); + ->map(fn ($term) => new TermSitemapUrl($term)); } protected function collectionTaxonomyUrls(): Collection diff --git a/src/Sitemaps/Taxonomies/TaxonomySitemapUrl.php b/src/Sitemaps/Taxonomies/TaxonomySitemapUrl.php index c0368bd1..85b1826f 100644 --- a/src/Sitemaps/Taxonomies/TaxonomySitemapUrl.php +++ b/src/Sitemaps/Taxonomies/TaxonomySitemapUrl.php @@ -59,6 +59,9 @@ public function alternates(): ?array public function lastmod(): string { if ($term = $this->lastModifiedTaxonomyTerm()) { + /* Ensure we are getting a fresh last modified date in case that there is no last modified taxonomy term. */ + Cache::forget("advanced-seo::sitemaps::taxonomy::{$this->taxonomy}::lastmod"); + return $term->lastModified()->format('Y-m-d\TH:i:sP'); } diff --git a/src/Sitemaps/Taxonomies/TermSitemapUrl.php b/src/Sitemaps/Taxonomies/TermSitemapUrl.php index 40c1fde9..3f4a0047 100644 --- a/src/Sitemaps/Taxonomies/TermSitemapUrl.php +++ b/src/Sitemaps/Taxonomies/TermSitemapUrl.php @@ -10,7 +10,7 @@ class TermSitemapUrl extends BaseSitemapUrl { - public function __construct(protected Term $term, protected TaxonomySitemap $sitemap) {} + public function __construct(protected Term $term) {} public function loc(): string { diff --git a/src/Subscribers/SitemapCacheSubscriber.php b/src/Subscribers/SitemapCacheSubscriber.php deleted file mode 100644 index 83bd9064..00000000 --- a/src/Subscribers/SitemapCacheSubscriber.php +++ /dev/null @@ -1,28 +0,0 @@ - 'clearCache', - \Statamic\Events\EntrySaved::class => 'clearCache', - \Statamic\Events\EntryDeleted::class => 'clearCache', - \Statamic\Events\TaxonomySaved::class => 'clearCache', - \Statamic\Events\TermSaved::class => 'clearCache', - \Statamic\Events\TermDeleted::class => 'clearCache', - \Aerni\AdvancedSeo\Events\SeoDefaultSetSaved::class => 'clearCache', - ]; - } - - public function clearCache(Event $event): void - { - Sitemap::clearCache(); - } -} diff --git a/src/View/GraphQlCascade.php b/src/View/GraphQlCascade.php index 10183cfe..973ba26a 100644 --- a/src/View/GraphQlCascade.php +++ b/src/View/GraphQlCascade.php @@ -141,6 +141,7 @@ public function twitterHandle(): ?string public function indexing(): ?string { + // TODO: Could use crawlingIsEnabled() method instead. if (! in_array(app()->environment(), config('advanced-seo.crawling.environments', []))) { $this->merge(['noindex' => true, 'nofollow' => true]); } diff --git a/src/View/ViewCascade.php b/src/View/ViewCascade.php index 89aff2cc..e2e68edc 100644 --- a/src/View/ViewCascade.php +++ b/src/View/ViewCascade.php @@ -167,6 +167,7 @@ public function twitterHandle(): ?string public function indexing(): ?string { + // TODO: Could use crawlingIsEnabled() method instead. if (! in_array(app()->environment(), config('advanced-seo.crawling.environments', []))) { $this->merge(['noindex' => true, 'nofollow' => true]); } diff --git a/tests/Tags/AdvancedSeoTagsTest.php b/tests/Tags/AdvancedSeoTagsTest.php index 007c5f41..833a859c 100644 --- a/tests/Tags/AdvancedSeoTagsTest.php +++ b/tests/Tags/AdvancedSeoTagsTest.php @@ -15,6 +15,6 @@ public function setUp(): void { parent::setUp(); - $this->tag = (new AdvancedSeoTags())->setContext($this->context); + $this->tag = (new AdvancedSeoTags)->setContext($this->context); } }