Implement creator studio and upload updates
This commit is contained in:
82
app/Services/Sitemaps/AbstractSitemapBuilder.php
Normal file
82
app/Services/Sitemaps/AbstractSitemapBuilder.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
abstract class AbstractSitemapBuilder implements SitemapBuilder
|
||||
{
|
||||
protected function contentTypeSlugs(): array
|
||||
{
|
||||
return array_values((array) config('sitemaps.content_type_slugs', []));
|
||||
}
|
||||
|
||||
protected function newest(mixed ...$timestamps): ?DateTimeInterface
|
||||
{
|
||||
$filtered = array_values(array_filter(array_map(fn (mixed $value): ?Carbon => $this->dateTime($value), $timestamps)));
|
||||
|
||||
if ($filtered === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($filtered, static fn (DateTimeInterface $left, DateTimeInterface $right): int => $left < $right ? 1 : -1);
|
||||
|
||||
return $filtered[0];
|
||||
}
|
||||
|
||||
protected function dateTime(mixed $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function absoluteUrl(?string $url): ?string
|
||||
{
|
||||
$value = trim((string) $url);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($value, ['http://', 'https://'])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return url($value);
|
||||
}
|
||||
|
||||
protected function image(?string $url, ?string $title = null): ?SitemapImage
|
||||
{
|
||||
$absolute = $this->absoluteUrl($url);
|
||||
|
||||
if ($absolute === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapImage($absolute, $title !== '' ? $title : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, SitemapImage|null> $images
|
||||
* @return list<SitemapImage>
|
||||
*/
|
||||
protected function images(array $images): array
|
||||
{
|
||||
return array_values(array_filter($images, static fn (mixed $image): bool => $image instanceof SitemapImage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\ShardableSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
abstract class AbstractIdShardableSitemapBuilder extends AbstractSitemapBuilder implements ShardableSitemapBuilder
|
||||
{
|
||||
/**
|
||||
* @return Builder<Model>
|
||||
*/
|
||||
abstract protected function query(): Builder;
|
||||
|
||||
abstract protected function shardConfigKey(): string;
|
||||
|
||||
abstract protected function mapRecord(Model $record): ?SitemapUrl;
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return $this->query()
|
||||
->orderBy($this->idColumn())
|
||||
->cursor()
|
||||
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->items(),
|
||||
));
|
||||
}
|
||||
|
||||
public function totalItems(): int
|
||||
{
|
||||
return (clone $this->query())->count();
|
||||
}
|
||||
|
||||
public function shardSize(): int
|
||||
{
|
||||
return max(1, (int) \data_get(\config('sitemaps.shards', []), $this->shardConfigKey() . '.size', 10000));
|
||||
}
|
||||
|
||||
public function itemsForShard(int $shard): array
|
||||
{
|
||||
$window = $this->shardWindow($shard);
|
||||
|
||||
if ($window === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->applyShardWindow($window['from'], $window['to'])
|
||||
->get()
|
||||
->map(fn (Model $record): ?SitemapUrl => $this->mapRecord($record))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModifiedForShard(int $shard): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(...array_map(
|
||||
fn (SitemapUrl $item): ?DateTimeInterface => $item->lastModified,
|
||||
$this->itemsForShard($shard),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from: int, to: int}|null
|
||||
*/
|
||||
protected function shardWindow(int $shard): ?array
|
||||
{
|
||||
if ($shard < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = $this->shardSize();
|
||||
$current = 0;
|
||||
$from = null;
|
||||
$to = null;
|
||||
|
||||
$windowQuery = (clone $this->query())
|
||||
->setEagerLoads([])
|
||||
->select([$this->idColumn()])
|
||||
->orderBy($this->idColumn());
|
||||
|
||||
foreach ($windowQuery->cursor() as $record) {
|
||||
$current++;
|
||||
|
||||
if ((int) ceil($current / $size) !== $shard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recordId = (int) $record->getAttribute($this->idColumn());
|
||||
$from ??= $recordId;
|
||||
$to = $recordId;
|
||||
}
|
||||
|
||||
if ($from === null || $to === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['from' => $from, 'to' => $to];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<Model>
|
||||
*/
|
||||
protected function applyShardWindow(int $from, int $to): Builder
|
||||
{
|
||||
return (clone $this->query())
|
||||
->whereBetween($this->qualifiedIdColumn(), [$from, $to])
|
||||
->orderBy($this->idColumn());
|
||||
}
|
||||
|
||||
protected function idColumn(): string
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return $this->idColumn();
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/ArtworksSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/ArtworksSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ArtworksSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'artworks';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->artwork($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
->public()
|
||||
->published();
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'artworks.id';
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/CardsSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/CardsSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\NovaCard;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CardsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->card($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return NovaCard::query()
|
||||
->publiclyVisible()
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'nova_cards.id';
|
||||
}
|
||||
}
|
||||
60
app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php
Normal file
60
app/Services/Sitemaps/Builders/CategoriesSitemapBuilder.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class CategoriesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'categories';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [$this->urls->categoryDirectory()];
|
||||
|
||||
$contentTypes = ContentType::query()
|
||||
->whereIn('slug', $this->contentTypeSlugs())
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
foreach ($contentTypes as $contentType) {
|
||||
$items[] = $this->urls->contentType($contentType);
|
||||
}
|
||||
|
||||
$categories = Category::query()
|
||||
->with('contentType')
|
||||
->active()
|
||||
->whereHas('contentType', fn ($query) => $query->whereIn('slug', $this->contentTypeSlugs()))
|
||||
->orderBy('content_type_id')
|
||||
->orderBy('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$items[] = $this->urls->category($category);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Category::query()
|
||||
->active()
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
47
app/Services/Sitemaps/Builders/CollectionsSitemapBuilder.php
Normal file
47
app/Services/Sitemaps/Builders/CollectionsSitemapBuilder.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CollectionsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'collections';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->collection($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Collection::query()
|
||||
->with('user:id,username')
|
||||
->public()
|
||||
->whereNull('canonical_collection_id')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'collections.id';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
|
||||
final class ForumCategoriesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-categories';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach (ForumCategory::query()->active()->ordered()->get() as $category) {
|
||||
$items[] = $this->urls->forumCategory($category);
|
||||
}
|
||||
|
||||
foreach (ForumBoard::query()->active()->ordered()->get() as $board) {
|
||||
$items[] = $this->urls->forumBoard($board);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->newest(
|
||||
ForumCategory::query()->active()->max('updated_at'),
|
||||
ForumBoard::query()->active()->max('updated_at'),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
app/Services/Sitemaps/Builders/ForumIndexSitemapBuilder.php
Normal file
31
app/Services/Sitemaps/Builders/ForumIndexSitemapBuilder.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class ForumIndexSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-index';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return [$this->urls->forumIndex()];
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class ForumThreadsSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'forum-threads';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'forum-threads';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->forumTopic($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return ForumTopic::query()
|
||||
->visible()
|
||||
->whereHas('board', fn ($query) => $query->active())
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'forum_topics.id';
|
||||
}
|
||||
}
|
||||
52
app/Services/Sitemaps/Builders/GoogleNewsSitemapBuilder.php
Normal file
52
app/Services/Sitemaps/Builders/GoogleNewsSitemapBuilder.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\GoogleNewsSitemapUrl;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return (string) \config('sitemaps.news.google_variant_name', 'news-google');
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return NewsArticle::query()
|
||||
->published()
|
||||
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
|
||||
->orderByDesc('published_at')
|
||||
->limit(max(1, (int) \config('sitemaps.news.google_max_items', 1000)))
|
||||
->get()
|
||||
->map(function (NewsArticle $article): ?GoogleNewsSitemapUrl {
|
||||
if (trim((string) $article->slug) === '' || $article->published_at === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GoogleNewsSitemapUrl(
|
||||
route('news.show', ['slug' => $article->slug]),
|
||||
trim((string) $article->title),
|
||||
$article->published_at,
|
||||
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
|
||||
(string) \config('sitemaps.news.google_language', 'en'),
|
||||
);
|
||||
})
|
||||
->filter(fn (?GoogleNewsSitemapUrl $item): bool => $item !== null && $item->title !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(NewsArticle::query()
|
||||
->published()
|
||||
->where('published_at', '>=', now()->subHours(max(1, (int) \config('sitemaps.news.google_lookback_hours', 48))))
|
||||
->max('published_at'));
|
||||
}
|
||||
}
|
||||
42
app/Services/Sitemaps/Builders/NewsSitemapBuilder.php
Normal file
42
app/Services/Sitemaps/Builders/NewsSitemapBuilder.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
|
||||
final class NewsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'news';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return NewsArticle::query()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->cursor()
|
||||
->map(fn (NewsArticle $article): ?SitemapUrl => $this->urls->news($article))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(NewsArticle::query()
|
||||
->published()
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
61
app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php
Normal file
61
app/Services/Sitemaps/Builders/StaticPagesSitemapBuilder.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'static-pages';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
$items = [
|
||||
$this->urls->staticRoute('/'),
|
||||
$this->urls->staticRoute('/faq'),
|
||||
$this->urls->staticRoute('/rules-and-guidelines'),
|
||||
$this->urls->staticRoute('/privacy-policy'),
|
||||
$this->urls->staticRoute('/terms-of-service'),
|
||||
$this->urls->staticRoute('/staff'),
|
||||
];
|
||||
|
||||
$marketingPages = Page::query()
|
||||
->published()
|
||||
->whereIn('slug', ['about', 'help'])
|
||||
->get()
|
||||
->keyBy('slug');
|
||||
|
||||
if ($marketingPages->has('about')) {
|
||||
$items[] = $this->urls->page($marketingPages['about'], '/about');
|
||||
}
|
||||
|
||||
if ($marketingPages->has('help')) {
|
||||
$items[] = $this->urls->page($marketingPages['help'], '/help');
|
||||
}
|
||||
|
||||
$excluded = array_values((array) config('sitemaps.static_page_excluded_slugs', []));
|
||||
|
||||
foreach (Page::query()->published()->whereNotIn('slug', $excluded)->orderBy('slug')->get() as $page) {
|
||||
$items[] = $this->urls->page($page, '/pages/' . $page->slug);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Page::query()->published()->max('updated_at'));
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/StoriesSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/StoriesSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Story;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class StoriesSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'stories';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->story($record);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return Story::query()
|
||||
->published()
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'stories.id';
|
||||
}
|
||||
}
|
||||
45
app/Services/Sitemaps/Builders/TagsSitemapBuilder.php
Normal file
45
app/Services/Sitemaps/Builders/TagsSitemapBuilder.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Tag;
|
||||
use App\Services\Sitemaps\AbstractSitemapBuilder;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class TagsSitemapBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'tags';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return Tag::query()
|
||||
->where('is_active', true)
|
||||
->where('usage_count', '>', 0)
|
||||
->whereHas('artworks', fn ($query) => $query->public()->published())
|
||||
->orderByDesc('usage_count')
|
||||
->orderBy('slug')
|
||||
->get()
|
||||
->map(fn (Tag $tag): SitemapUrl => $this->urls->tag($tag))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function lastModified(): ?DateTimeInterface
|
||||
{
|
||||
return $this->dateTime(Tag::query()
|
||||
->where('is_active', true)
|
||||
->where('usage_count', '>', 0)
|
||||
->max('updated_at'));
|
||||
}
|
||||
}
|
||||
76
app/Services/Sitemaps/Builders/UsersSitemapBuilder.php
Normal file
76
app/Services/Sitemaps/Builders/UsersSitemapBuilder.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps\Builders;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\Sitemaps\SitemapUrlBuilder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class UsersSitemapBuilder extends AbstractIdShardableSitemapBuilder
|
||||
{
|
||||
public function __construct(private readonly SitemapUrlBuilder $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'users';
|
||||
}
|
||||
|
||||
protected function shardConfigKey(): string
|
||||
{
|
||||
return 'users';
|
||||
}
|
||||
|
||||
protected function mapRecord(Model $record): ?SitemapUrl
|
||||
{
|
||||
return $this->urls->profile($record, $record->updated_at);
|
||||
}
|
||||
|
||||
protected function query(): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('username')
|
||||
->where('username', '!=', '')
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereExists(
|
||||
Artwork::query()
|
||||
->selectRaw('1')
|
||||
->public()
|
||||
->published()
|
||||
->whereColumn('artworks.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
Collection::query()
|
||||
->selectRaw('1')
|
||||
->public()
|
||||
->whereNull('canonical_collection_id')
|
||||
->whereColumn('collections.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
NovaCard::query()
|
||||
->selectRaw('1')
|
||||
->publiclyVisible()
|
||||
->whereColumn('nova_cards.user_id', 'users.id')
|
||||
)->orWhereExists(
|
||||
Story::query()
|
||||
->selectRaw('1')
|
||||
->published()
|
||||
->whereColumn('stories.creator_id', 'users.id')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected function qualifiedIdColumn(): string
|
||||
{
|
||||
return 'users.id';
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/GoogleNewsSitemapUrl.php
Normal file
19
app/Services/Sitemaps/GoogleNewsSitemapUrl.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final class GoogleNewsSitemapUrl
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $loc,
|
||||
public readonly string $title,
|
||||
public readonly DateTimeInterface $publicationDate,
|
||||
public readonly string $publicationName,
|
||||
public readonly string $publicationLanguage,
|
||||
) {
|
||||
}
|
||||
}
|
||||
77
app/Services/Sitemaps/PublishedSitemapResolver.php
Normal file
77
app/Services/Sitemaps/PublishedSitemapResolver.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class PublishedSitemapResolver
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, release_id: string, document_name: string}|null
|
||||
*/
|
||||
public function resolveIndex(): ?array
|
||||
{
|
||||
return $this->resolveDocumentName(SitemapCacheService::INDEX_DOCUMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, release_id: string, document_name: string}|null
|
||||
*/
|
||||
public function resolveNamed(string $requestedName): ?array
|
||||
{
|
||||
$manifest = $this->releases->activeManifest();
|
||||
|
||||
if ($manifest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$documentName = $this->canonicalDocumentName($requestedName, $manifest);
|
||||
|
||||
return $documentName !== null ? $this->resolveDocumentName($documentName) : null;
|
||||
}
|
||||
|
||||
private function resolveDocumentName(string $documentName): ?array
|
||||
{
|
||||
$releaseId = $this->releases->activeReleaseId();
|
||||
|
||||
if ($releaseId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $this->releases->getDocument($releaseId, $documentName);
|
||||
|
||||
return is_string($content) && $content !== ''
|
||||
? ['content' => $content, 'release_id' => $releaseId, 'document_name' => $documentName]
|
||||
: null;
|
||||
}
|
||||
|
||||
private function canonicalDocumentName(string $requestedName, array $manifest): ?string
|
||||
{
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
if (isset($documents[$requestedName])) {
|
||||
return $requestedName;
|
||||
}
|
||||
|
||||
foreach ((array) ($manifest['families'] ?? []) as $familyName => $family) {
|
||||
$entryName = (string) ($family['entry_name'] ?? '');
|
||||
if ($requestedName === $familyName && $entryName !== '') {
|
||||
return $entryName;
|
||||
}
|
||||
|
||||
if (preg_match('/^' . preg_quote((string) $familyName, '/') . '-([0-9]+)$/', $requestedName, $matches)) {
|
||||
$number = (int) $matches[1];
|
||||
$candidate = sprintf('%s-%04d', $familyName, $number);
|
||||
if (in_array($candidate, (array) ($family['shards'] ?? []), true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
21
app/Services/Sitemaps/ShardableSitemapBuilder.php
Normal file
21
app/Services/Sitemaps/ShardableSitemapBuilder.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
interface ShardableSitemapBuilder extends SitemapBuilder
|
||||
{
|
||||
public function totalItems(): int;
|
||||
|
||||
public function shardSize(): int;
|
||||
|
||||
/**
|
||||
* @return list<SitemapUrl>
|
||||
*/
|
||||
public function itemsForShard(int $shard): array;
|
||||
|
||||
public function lastModifiedForShard(int $shard): ?DateTimeInterface;
|
||||
}
|
||||
143
app/Services/Sitemaps/SitemapBuildService.php
Normal file
143
app/Services/Sitemaps/SitemapBuildService.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
|
||||
|
||||
final class SitemapBuildService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapCacheService $cache,
|
||||
private readonly SitemapIndexService $index,
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
private readonly SitemapXmlRenderer $renderer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}
|
||||
*/
|
||||
public function buildIndex(bool $force = false, bool $persist = true, ?array $families = null): array
|
||||
{
|
||||
$built = $this->cache->remember(
|
||||
SitemapCacheService::INDEX_DOCUMENT,
|
||||
fn (): string => $this->renderer->renderIndex($this->index->items($families)),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => SitemapTarget::TYPE_INDEX,
|
||||
'url_count' => count($this->index->items($families)),
|
||||
'shard_count' => 0,
|
||||
'name' => SitemapCacheService::INDEX_DOCUMENT,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string, type: string, url_count: int, shard_count: int, name: string}|null
|
||||
*/
|
||||
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
|
||||
{
|
||||
$target = $this->shards->resolve($this->registry, $name);
|
||||
|
||||
if ($target === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$built = $this->cache->remember(
|
||||
$target->documentName,
|
||||
fn (): string => $this->renderTarget($target),
|
||||
$force,
|
||||
$persist,
|
||||
);
|
||||
|
||||
return $built + [
|
||||
'type' => $target->type,
|
||||
'url_count' => $this->urlCount($target),
|
||||
'shard_count' => $target->totalShards,
|
||||
'name' => $target->documentName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function documentNamesForFamily(string $family, bool $includeCompatibilityIndex = true): array
|
||||
{
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = $this->shards->canonicalDocumentNamesForBuilder($builder);
|
||||
|
||||
if ($includeCompatibilityIndex && $builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
|
||||
array_unshift($names, $builder->name());
|
||||
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
|
||||
array_unshift($names, $builder->name() . '-' . $shard);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function canonicalDocumentNamesForFamily(string $family): array
|
||||
{
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->shards->canonicalDocumentNamesForBuilder($builder);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function enabledFamilies(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
(array) config('sitemaps.enabled', []),
|
||||
fn (mixed $name): bool => is_string($name) && $this->registry->get($name) !== null,
|
||||
));
|
||||
}
|
||||
|
||||
private function renderTarget(SitemapTarget $target): string
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
return $this->renderer->renderIndex($this->index->itemsForBuilder($target->builder));
|
||||
}
|
||||
|
||||
if ($target->builder instanceof GoogleNewsSitemapBuilder || $target->type === SitemapTarget::TYPE_GOOGLE_NEWS) {
|
||||
return $this->renderer->renderGoogleNewsUrlset($target->builder->items());
|
||||
}
|
||||
|
||||
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
|
||||
return $this->renderer->renderUrlset($target->builder->itemsForShard($target->shardNumber));
|
||||
}
|
||||
|
||||
return $this->renderer->renderUrlset($target->builder->items());
|
||||
}
|
||||
|
||||
private function urlCount(SitemapTarget $target): int
|
||||
{
|
||||
if ($target->type === SitemapTarget::TYPE_INDEX) {
|
||||
return count($this->index->itemsForBuilder($target->builder));
|
||||
}
|
||||
|
||||
if ($target->builder instanceof ShardableSitemapBuilder && $target->shardNumber !== null) {
|
||||
return count($target->builder->itemsForShard($target->shardNumber));
|
||||
}
|
||||
|
||||
return count($target->builder->items());
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/SitemapBuilder.php
Normal file
19
app/Services/Sitemaps/SitemapBuilder.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
interface SitemapBuilder
|
||||
{
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @return list<SitemapUrl>
|
||||
*/
|
||||
public function items(): array;
|
||||
|
||||
public function lastModified(): ?DateTimeInterface;
|
||||
}
|
||||
145
app/Services/Sitemaps/SitemapCacheService.php
Normal file
145
app/Services/Sitemaps/SitemapCacheService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class SitemapCacheService
|
||||
{
|
||||
public const INDEX_DOCUMENT = 'index';
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}|null
|
||||
*/
|
||||
public function get(string $name): ?array
|
||||
{
|
||||
if ($this->preferPreGenerated()) {
|
||||
$preGenerated = $this->getPreGenerated($name);
|
||||
if ($preGenerated !== null) {
|
||||
return $preGenerated;
|
||||
}
|
||||
}
|
||||
|
||||
$cached = Cache::get($this->cacheKey($name));
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return ['content' => $cached, 'source' => 'cache'];
|
||||
}
|
||||
|
||||
if (! $this->preferPreGenerated()) {
|
||||
return $this->getPreGenerated($name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}
|
||||
*/
|
||||
public function remember(string $name, Closure $builder, bool $force = false, bool $persist = true): array
|
||||
{
|
||||
if (! $force) {
|
||||
$existing = $this->get($name);
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
$content = (string) $builder();
|
||||
|
||||
if ($persist) {
|
||||
$this->store($name, $content);
|
||||
}
|
||||
|
||||
return ['content' => $content, 'source' => 'built'];
|
||||
}
|
||||
|
||||
public function store(string $name, string $content): void
|
||||
{
|
||||
Cache::put(
|
||||
$this->cacheKey($name),
|
||||
$content,
|
||||
now()->addSeconds(max(60, (int) config('sitemaps.cache_ttl_seconds', 900))),
|
||||
);
|
||||
|
||||
if ($this->preGeneratedEnabled()) {
|
||||
Storage::disk($this->disk())->put($this->documentPath($name), $content);
|
||||
}
|
||||
}
|
||||
|
||||
public function clear(array $names): int
|
||||
{
|
||||
$cleared = 0;
|
||||
|
||||
foreach (array_values(array_unique($names)) as $name) {
|
||||
Cache::forget($this->cacheKey($name));
|
||||
|
||||
if ($this->preGeneratedEnabled()) {
|
||||
Storage::disk($this->disk())->delete($this->documentPath($name));
|
||||
}
|
||||
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
return $cleared;
|
||||
}
|
||||
|
||||
public function documentPath(string $name): string
|
||||
{
|
||||
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
|
||||
$segments = $name === self::INDEX_DOCUMENT
|
||||
? [$prefix, 'sitemap.xml']
|
||||
: [$prefix, 'sitemaps', $name . '.xml'];
|
||||
|
||||
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));
|
||||
}
|
||||
|
||||
private function cacheKey(string $name): string
|
||||
{
|
||||
return 'sitemaps:v2:' . $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{content: string, source: string}|null
|
||||
*/
|
||||
private function getPreGenerated(string $name): ?array
|
||||
{
|
||||
if (! $this->preGeneratedEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->documentPath($name);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $disk->get($path);
|
||||
|
||||
if (! is_string($content) || $content === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['content' => $content, 'source' => 'pre-generated'];
|
||||
}
|
||||
|
||||
private function disk(): string
|
||||
{
|
||||
return (string) config('sitemaps.pre_generated.disk', 'local');
|
||||
}
|
||||
|
||||
private function preGeneratedEnabled(): bool
|
||||
{
|
||||
return (bool) config('sitemaps.pre_generated.enabled', true);
|
||||
}
|
||||
|
||||
private function preferPreGenerated(): bool
|
||||
{
|
||||
return $this->preGeneratedEnabled() && (bool) config('sitemaps.pre_generated.prefer', false);
|
||||
}
|
||||
}
|
||||
13
app/Services/Sitemaps/SitemapImage.php
Normal file
13
app/Services/Sitemaps/SitemapImage.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final readonly class SitemapImage
|
||||
{
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?string $title = null,
|
||||
) {}
|
||||
}
|
||||
15
app/Services/Sitemaps/SitemapIndexItem.php
Normal file
15
app/Services/Sitemaps/SitemapIndexItem.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final readonly class SitemapIndexItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?DateTimeInterface $lastModified = null,
|
||||
) {}
|
||||
}
|
||||
61
app/Services/Sitemaps/SitemapIndexService.php
Normal file
61
app/Services/Sitemaps/SitemapIndexService.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapIndexService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function items(?array $families = null): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
|
||||
$builder = $this->registry->get((string) $name);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<SitemapIndexItem>
|
||||
*/
|
||||
public function itemsForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
if ($builder instanceof ShardableSitemapBuilder && $this->shards->shardCount($builder) > 1) {
|
||||
$items = [];
|
||||
|
||||
foreach (range(1, $this->shards->shardCount($builder)) as $shard) {
|
||||
$items[] = new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->canonicalShardName($builder->name(), $shard) . '.xml'),
|
||||
$builder->lastModifiedForShard($shard),
|
||||
);
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
return [new SitemapIndexItem(
|
||||
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
|
||||
$builder->lastModified(),
|
||||
)];
|
||||
}
|
||||
}
|
||||
201
app/Services/Sitemaps/SitemapPublishService.php
Normal file
201
app/Services/Sitemaps/SitemapPublishService.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class SitemapPublishService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapReleaseCleanupService $cleanup,
|
||||
private readonly SitemapReleaseManager $releases,
|
||||
private readonly SitemapReleaseValidator $validator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $families
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildRelease(?array $families = null, ?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($families, $releaseId): array {
|
||||
return $this->buildReleaseUnlocked($families, $releaseId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function publish(?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($releaseId): array {
|
||||
$manifest = $releaseId !== null
|
||||
? $this->releases->readManifest($releaseId)
|
||||
: $this->buildReleaseUnlocked();
|
||||
|
||||
if ($manifest === null) {
|
||||
throw new \RuntimeException('Sitemap release [' . $releaseId . '] does not exist.');
|
||||
}
|
||||
|
||||
$releaseId = (string) $manifest['release_id'];
|
||||
$validation = $this->validator->validate($releaseId);
|
||||
|
||||
if (! ($validation['ok'] ?? false)) {
|
||||
$manifest['status'] = 'failed';
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
|
||||
throw new \RuntimeException('Sitemap release validation failed.');
|
||||
}
|
||||
|
||||
$manifest['status'] = 'published';
|
||||
$manifest['published_at'] = now()->toAtomString();
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
$this->releases->activate($releaseId);
|
||||
$deleted = $this->cleanup->cleanup();
|
||||
|
||||
return $manifest + ['cleanup_deleted' => $deleted];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rollback(?string $releaseId = null): array
|
||||
{
|
||||
return $this->withLock(function () use ($releaseId): array {
|
||||
if ($releaseId === null) {
|
||||
$activeReleaseId = $this->releases->activeReleaseId();
|
||||
foreach ($this->releases->listReleases() as $release) {
|
||||
if ((string) ($release['status'] ?? '') === 'published' && (string) ($release['release_id'] ?? '') !== $activeReleaseId) {
|
||||
$releaseId = (string) $release['release_id'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_string($releaseId) || $releaseId === '') {
|
||||
throw new \RuntimeException('No rollback release is available.');
|
||||
}
|
||||
|
||||
$manifest = $this->releases->readManifest($releaseId);
|
||||
if ($manifest === null) {
|
||||
throw new \RuntimeException('Rollback release [' . $releaseId . '] not found.');
|
||||
}
|
||||
|
||||
$this->releases->activate($releaseId);
|
||||
|
||||
return $manifest + ['rolled_back_at' => now()->toAtomString()];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TReturn
|
||||
* @param callable(): TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
private function withLock(callable $callback): mixed
|
||||
{
|
||||
$lock = Cache::lock('sitemaps:publish-flow', max(30, (int) config('sitemaps.releases.lock_seconds', 900)));
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw new \RuntimeException('Another sitemap build or publish operation is already running.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $families
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildReleaseUnlocked(?array $families = null, ?string $releaseId = null): array
|
||||
{
|
||||
$selectedFamilies = $families ?: $this->build->enabledFamilies();
|
||||
$releaseId ??= $this->releases->generateReleaseId();
|
||||
|
||||
$familyManifest = [];
|
||||
$documents = [
|
||||
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
|
||||
];
|
||||
$totalUrls = 0;
|
||||
|
||||
$rootIndex = $this->build->buildIndex(true, false, $selectedFamilies);
|
||||
$this->releases->putDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT, (string) $rootIndex['content']);
|
||||
|
||||
foreach ($selectedFamilies as $family) {
|
||||
$builder = app(SitemapRegistry::class)->get($family);
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$canonicalNames = $this->build->canonicalDocumentNamesForFamily($family);
|
||||
$shardNames = [];
|
||||
$urlCount = 0;
|
||||
|
||||
foreach ($canonicalNames as $documentName) {
|
||||
$built = $this->build->buildNamed($documentName, true, false);
|
||||
if ($built === null) {
|
||||
throw new \RuntimeException('Failed to build sitemap document [' . $documentName . '].');
|
||||
}
|
||||
|
||||
$this->releases->putDocument($releaseId, $documentName, (string) $built['content']);
|
||||
$documents[$documentName] = $this->releases->documentRelativePath($documentName);
|
||||
|
||||
if ($built['type'] !== SitemapTarget::TYPE_INDEX) {
|
||||
$urlCount += (int) $built['url_count'];
|
||||
}
|
||||
|
||||
if (str_starts_with($documentName, $family . '-') && ! str_ends_with($documentName, '-index')) {
|
||||
$shardNames[] = $documentName;
|
||||
}
|
||||
}
|
||||
|
||||
$totalUrls += $urlCount;
|
||||
$familyManifest[$family] = [
|
||||
'family' => $family,
|
||||
'entry_name' => app(SitemapShardService::class)->rootEntryName($builder),
|
||||
'documents' => $canonicalNames,
|
||||
'shards' => $shardNames,
|
||||
'url_count' => $urlCount,
|
||||
'shard_count' => count($shardNames),
|
||||
'type' => $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
|
||||
? SitemapTarget::TYPE_GOOGLE_NEWS
|
||||
: (count($shardNames) > 0 ? SitemapTarget::TYPE_INDEX : SitemapTarget::TYPE_URLSET),
|
||||
];
|
||||
}
|
||||
|
||||
$manifest = [
|
||||
'release_id' => $releaseId,
|
||||
'status' => 'built',
|
||||
'built_at' => now()->toAtomString(),
|
||||
'published_at' => null,
|
||||
'families' => $familyManifest,
|
||||
'documents' => $documents,
|
||||
'totals' => [
|
||||
'families' => count($familyManifest),
|
||||
'documents' => count($documents),
|
||||
'urls' => $totalUrls,
|
||||
],
|
||||
];
|
||||
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
|
||||
$validation = $this->validator->validate($releaseId);
|
||||
$manifest['status'] = ($validation['ok'] ?? false) ? 'validated' : 'failed';
|
||||
$manifest['validation'] = $validation;
|
||||
$this->releases->writeManifest($releaseId, $manifest);
|
||||
$this->releases->writeBuildReport($releaseId, $validation);
|
||||
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
72
app/Services/Sitemaps/SitemapRegistry.php
Normal file
72
app/Services/Sitemaps/SitemapRegistry.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CategoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\CollectionsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumCategoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumIndexSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\ForumThreadsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\GoogleNewsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\NewsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\StaticPagesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\StoriesSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\TagsSitemapBuilder;
|
||||
use App\Services\Sitemaps\Builders\UsersSitemapBuilder;
|
||||
|
||||
final class SitemapRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, SitemapBuilder>
|
||||
*/
|
||||
private array $builders;
|
||||
|
||||
public function __construct(
|
||||
ArtworksSitemapBuilder $artworks,
|
||||
UsersSitemapBuilder $users,
|
||||
TagsSitemapBuilder $tags,
|
||||
CategoriesSitemapBuilder $categories,
|
||||
CollectionsSitemapBuilder $collections,
|
||||
CardsSitemapBuilder $cards,
|
||||
StoriesSitemapBuilder $stories,
|
||||
NewsSitemapBuilder $news,
|
||||
GoogleNewsSitemapBuilder $googleNews,
|
||||
ForumIndexSitemapBuilder $forumIndex,
|
||||
ForumCategoriesSitemapBuilder $forumCategories,
|
||||
ForumThreadsSitemapBuilder $forumThreads,
|
||||
StaticPagesSitemapBuilder $staticPages,
|
||||
) {
|
||||
$this->builders = [
|
||||
$artworks->name() => $artworks,
|
||||
$users->name() => $users,
|
||||
$tags->name() => $tags,
|
||||
$categories->name() => $categories,
|
||||
$collections->name() => $collections,
|
||||
$cards->name() => $cards,
|
||||
$stories->name() => $stories,
|
||||
$news->name() => $news,
|
||||
$googleNews->name() => $googleNews,
|
||||
$forumIndex->name() => $forumIndex,
|
||||
$forumCategories->name() => $forumCategories,
|
||||
$forumThreads->name() => $forumThreads,
|
||||
$staticPages->name() => $staticPages,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, SitemapBuilder>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->builders;
|
||||
}
|
||||
|
||||
public function get(string $name): ?SitemapBuilder
|
||||
{
|
||||
return $this->builders[$name] ?? null;
|
||||
}
|
||||
}
|
||||
51
app/Services/Sitemaps/SitemapReleaseCleanupService.php
Normal file
51
app/Services/Sitemaps/SitemapReleaseCleanupService.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapReleaseCleanupService
|
||||
{
|
||||
public function __construct(private readonly SitemapReleaseManager $releases)
|
||||
{
|
||||
}
|
||||
|
||||
public function cleanup(): int
|
||||
{
|
||||
$activeReleaseId = $this->releases->activeReleaseId();
|
||||
$successfulKeep = max(1, (int) config('sitemaps.releases.retain_successful', 3));
|
||||
$failedKeep = max(0, (int) config('sitemaps.releases.retain_failed', 2));
|
||||
|
||||
$successfulSeen = 0;
|
||||
$failedSeen = 0;
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($this->releases->listReleases() as $release) {
|
||||
$releaseId = (string) ($release['release_id'] ?? '');
|
||||
|
||||
if ($releaseId === '' || $releaseId === $activeReleaseId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = (string) ($release['status'] ?? 'built');
|
||||
|
||||
if ($status === 'published') {
|
||||
$successfulSeen++;
|
||||
if ($successfulSeen > $successfulKeep) {
|
||||
$this->releases->deleteRelease($releaseId);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$failedSeen++;
|
||||
if ($failedSeen > $failedKeep) {
|
||||
$this->releases->deleteRelease($releaseId);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
186
app/Services/Sitemaps/SitemapReleaseManager.php
Normal file
186
app/Services/Sitemaps/SitemapReleaseManager.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SitemapReleaseManager
|
||||
{
|
||||
public function generateReleaseId(): string
|
||||
{
|
||||
return now()->utc()->format('YmdHis') . '-' . Str::lower((string) Str::ulid());
|
||||
}
|
||||
|
||||
public function releaseExists(string $releaseId): bool
|
||||
{
|
||||
return Storage::disk($this->disk())->exists($this->manifestPath($releaseId));
|
||||
}
|
||||
|
||||
public function putDocument(string $releaseId, string $documentName, string $content): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->releaseDocumentPath($releaseId, $documentName), $content);
|
||||
}
|
||||
|
||||
public function getDocument(string $releaseId, string $documentName): ?string
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->releaseDocumentPath($releaseId, $documentName);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $disk->get($path);
|
||||
|
||||
return is_string($content) && $content !== '' ? $content : null;
|
||||
}
|
||||
|
||||
public function writeManifest(string $releaseId, array $manifest): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->manifestPath($releaseId), json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public function readManifest(string $releaseId): ?array
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->manifestPath($releaseId);
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $disk->get($path), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
public function writeBuildReport(string $releaseId, array $report): void
|
||||
{
|
||||
Storage::disk($this->disk())->put($this->buildReportPath($releaseId), json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public function activate(string $releaseId): void
|
||||
{
|
||||
$payload = [
|
||||
'release_id' => $releaseId,
|
||||
'activated_at' => now()->toAtomString(),
|
||||
];
|
||||
|
||||
$this->atomicJsonWrite($this->activePointerPath(), $payload);
|
||||
}
|
||||
|
||||
public function activeReleaseId(): ?string
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$path = $this->activePointerPath();
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $disk->get($path), true);
|
||||
|
||||
return is_array($decoded) && is_string($decoded['release_id'] ?? null)
|
||||
? $decoded['release_id']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function activeManifest(): ?array
|
||||
{
|
||||
$releaseId = $this->activeReleaseId();
|
||||
|
||||
return $releaseId !== null ? $this->readManifest($releaseId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listReleases(): array
|
||||
{
|
||||
$releases = [];
|
||||
|
||||
foreach (Storage::disk($this->disk())->allDirectories($this->releasesRootPath()) as $directory) {
|
||||
$releaseId = basename($directory);
|
||||
$manifest = $this->readManifest($releaseId);
|
||||
|
||||
if ($manifest !== null) {
|
||||
$releases[] = $manifest;
|
||||
}
|
||||
}
|
||||
|
||||
usort($releases, static fn (array $left, array $right): int => strcmp((string) ($right['release_id'] ?? ''), (string) ($left['release_id'] ?? '')));
|
||||
|
||||
return $releases;
|
||||
}
|
||||
|
||||
public function deleteRelease(string $releaseId): void
|
||||
{
|
||||
Storage::disk($this->disk())->deleteDirectory($this->releaseRootPath($releaseId));
|
||||
}
|
||||
|
||||
public function documentRelativePath(string $documentName): string
|
||||
{
|
||||
return $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? 'sitemap.xml'
|
||||
: 'sitemaps/' . $documentName . '.xml';
|
||||
}
|
||||
|
||||
public function releaseDocumentPath(string $releaseId, string $documentName): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/' . $this->documentRelativePath($documentName);
|
||||
}
|
||||
|
||||
public function manifestPath(string $releaseId): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/manifest.json';
|
||||
}
|
||||
|
||||
public function buildReportPath(string $releaseId): string
|
||||
{
|
||||
return $this->releaseRootPath($releaseId) . '/build-report.json';
|
||||
}
|
||||
|
||||
private function releaseRootPath(string $releaseId): string
|
||||
{
|
||||
return $this->releasesRootPath() . '/' . $releaseId;
|
||||
}
|
||||
|
||||
private function releasesRootPath(): string
|
||||
{
|
||||
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/releases';
|
||||
}
|
||||
|
||||
private function activePointerPath(): string
|
||||
{
|
||||
return trim((string) config('sitemaps.releases.path', 'sitemaps'), '/') . '/active.json';
|
||||
}
|
||||
|
||||
private function disk(): string
|
||||
{
|
||||
return (string) config('sitemaps.releases.disk', 'local');
|
||||
}
|
||||
|
||||
private function atomicJsonWrite(string $relativePath, array $payload): void
|
||||
{
|
||||
$disk = Storage::disk($this->disk());
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
try {
|
||||
$absolutePath = $disk->path($relativePath);
|
||||
$directory = dirname($absolutePath);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
$temporaryPath = $absolutePath . '.tmp';
|
||||
file_put_contents($temporaryPath, $json, LOCK_EX);
|
||||
rename($temporaryPath, $absolutePath);
|
||||
} catch (\Throwable) {
|
||||
$disk->put($relativePath, $json);
|
||||
}
|
||||
}
|
||||
}
|
||||
258
app/Services/Sitemaps/SitemapReleaseValidator.php
Normal file
258
app/Services/Sitemaps/SitemapReleaseValidator.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
final class SitemapReleaseValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapReleaseManager $releases,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(string $releaseId): array
|
||||
{
|
||||
$manifest = $this->releases->readManifest($releaseId);
|
||||
|
||||
if ($manifest === null) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'release_id' => $releaseId,
|
||||
'errors' => ['Release manifest not found.'],
|
||||
];
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$families = (array) ($manifest['families'] ?? []);
|
||||
$documents = (array) ($manifest['documents'] ?? []);
|
||||
|
||||
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
|
||||
$rootXml = is_string($rootContent) ? $this->loadXml($rootContent) : null;
|
||||
|
||||
if ($rootXml === null) {
|
||||
$errors[] = 'Root sitemap.xml is missing or invalid.';
|
||||
} else {
|
||||
$rootLocs = $this->extractLocs($rootXml, 'sitemap');
|
||||
$expectedRootLocs = array_map(
|
||||
fn (string $entryName): string => url('/sitemaps/' . $entryName . '.xml'),
|
||||
array_values(array_map(static fn (array $family): string => (string) ($family['entry_name'] ?? ''), $families)),
|
||||
);
|
||||
|
||||
if ($rootLocs !== $expectedRootLocs) {
|
||||
$errors[] = 'Root sitemap index does not match the manifest family entries.';
|
||||
}
|
||||
}
|
||||
|
||||
$reports = [];
|
||||
|
||||
foreach ($families as $familyName => $family) {
|
||||
$familyErrors = [];
|
||||
$familyWarnings = [];
|
||||
$seenLocs = [];
|
||||
$duplicates = [];
|
||||
|
||||
foreach ((array) ($family['documents'] ?? []) as $documentName) {
|
||||
$artifact = $this->releases->getDocument($releaseId, (string) $documentName);
|
||||
|
||||
if (! is_string($artifact) || $artifact === '') {
|
||||
$familyErrors[] = 'Missing artifact [' . $documentName . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$artifactXml = $this->loadXml($artifact);
|
||||
if ($artifactXml === null) {
|
||||
$familyErrors[] = 'Invalid XML in artifact [' . $documentName . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected = $documentName === SitemapCacheService::INDEX_DOCUMENT
|
||||
? $this->build->buildIndex(true, false, array_keys($families))
|
||||
: $this->build->buildNamed((string) $documentName, true, false);
|
||||
|
||||
if ($expected === null) {
|
||||
$familyErrors[] = 'Unable to rebuild expected document [' . $documentName . '] for validation.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$expectedXml = $this->loadXml((string) $expected['content']);
|
||||
if ($expectedXml === null) {
|
||||
$familyErrors[] = 'Expected document [' . $documentName . '] could not be parsed.';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $expected['type'] === SitemapTarget::TYPE_INDEX) {
|
||||
if ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
|
||||
$familyErrors[] = 'Index artifact [' . $documentName . '] does not match expected sitemap references.';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$artifactLocs = $this->extractLocs($artifactXml, 'url');
|
||||
$expectedLocs = $this->extractLocs($expectedXml, 'url');
|
||||
|
||||
if ($artifactLocs !== $expectedLocs) {
|
||||
$familyErrors[] = 'URL artifact [' . $documentName . '] does not match expected canonical URLs.';
|
||||
}
|
||||
|
||||
foreach ($artifactLocs as $loc) {
|
||||
if (isset($seenLocs[$loc])) {
|
||||
$duplicates[$loc] = true;
|
||||
}
|
||||
|
||||
$seenLocs[$loc] = true;
|
||||
|
||||
$urlError = $this->urlError($loc);
|
||||
if ($urlError !== null) {
|
||||
$familyErrors[] = $urlError . ' [' . $loc . ']';
|
||||
}
|
||||
}
|
||||
|
||||
if ((string) $familyName === (string) config('sitemaps.news.google_variant_name', 'news-google')) {
|
||||
if ($this->extractNewsTitles($artifactXml) === []) {
|
||||
$familyErrors[] = 'Google News sitemap contains no valid news:title elements.';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->extractImageLocs($artifactXml) as $imageLoc) {
|
||||
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
|
||||
$familyWarnings[] = 'Non-absolute image URL [' . $imageLoc . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($duplicates !== []) {
|
||||
$familyErrors[] = 'Duplicate URLs detected across family artifacts.';
|
||||
}
|
||||
|
||||
$reports[] = [
|
||||
'family' => $familyName,
|
||||
'documents' => count((array) ($family['documents'] ?? [])),
|
||||
'url_count' => (int) ($family['url_count'] ?? 0),
|
||||
'shard_count' => (int) ($family['shard_count'] ?? 0),
|
||||
'errors' => $familyErrors,
|
||||
'warnings' => $familyWarnings,
|
||||
];
|
||||
|
||||
foreach ($familyErrors as $familyError) {
|
||||
$errors[] = $familyName . ': ' . $familyError;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $errors === [],
|
||||
'release_id' => $releaseId,
|
||||
'errors' => $errors,
|
||||
'families' => $reports,
|
||||
'totals' => [
|
||||
'families' => count($families),
|
||||
'documents' => count($documents),
|
||||
'urls' => array_sum(array_map(static fn (array $family): int => (int) ($family['url_count'] ?? 0), $families)),
|
||||
'shards' => array_sum(array_map(static fn (array $family): int => (int) ($family['shard_count'] ?? 0), $families)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadXml(string $content): ?DOMDocument
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadXML($content);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $loaded ? $document : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocs(DOMDocument $document, string $nodeName): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractImageLocs(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractNewsTitles(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="title"]');
|
||||
$titles = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$titles[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $titles;
|
||||
}
|
||||
|
||||
private function urlError(string $loc): ?string
|
||||
{
|
||||
$parts = parse_url($loc);
|
||||
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return 'Non-absolute URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['query'] ?? '') !== '') {
|
||||
return 'Query-string URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['fragment'] ?? '') !== '') {
|
||||
return 'Fragment URL emitted';
|
||||
}
|
||||
|
||||
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
|
||||
|
||||
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
|
||||
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
|
||||
return 'Non-public path emitted';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
149
app/Services/Sitemaps/SitemapShardService.php
Normal file
149
app/Services/Sitemaps/SitemapShardService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapShardService
|
||||
{
|
||||
public function rootEntryName(SitemapBuilder $builder): string
|
||||
{
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
|
||||
return $this->familyIndexName($builder->name());
|
||||
}
|
||||
|
||||
return $builder->name();
|
||||
}
|
||||
|
||||
public function shardCount(SitemapBuilder $builder): int
|
||||
{
|
||||
if (! $builder instanceof ShardableSitemapBuilder || ! $this->enabledFor($builder->name())) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$totalItems = $builder->totalItems();
|
||||
if ($totalItems <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(1, (int) ceil($totalItems / max(1, $builder->shardSize())));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function indexNamesForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && $shardCount > 1) {
|
||||
return array_map(fn (int $shard): string => $this->canonicalShardName($builder->name(), $shard), range(1, $shardCount));
|
||||
}
|
||||
|
||||
return [$builder->name()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function canonicalDocumentNamesForBuilder(SitemapBuilder $builder): array
|
||||
{
|
||||
$names = $this->indexNamesForBuilder($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($this->shardCount($builder) > 1 || $this->forceFamilyIndexes())) {
|
||||
array_unshift($names, $this->familyIndexName($builder->name()));
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
public function familyIndexName(string $baseName): string
|
||||
{
|
||||
return $baseName . '-index';
|
||||
}
|
||||
|
||||
public function canonicalShardName(string $baseName, int $shard): string
|
||||
{
|
||||
return sprintf('%s-%s', $baseName, str_pad((string) $shard, $this->padLength(), '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
public function resolve(SitemapRegistry $registry, string $name): ?SitemapTarget
|
||||
{
|
||||
$builder = $registry->get($name);
|
||||
|
||||
if ($builder !== null) {
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($builder instanceof ShardableSitemapBuilder && ($shardCount > 1 || $this->forceFamilyIndexes())) {
|
||||
return new SitemapTarget($name, $this->familyIndexName($builder->name()), $builder->name(), SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $builder->name(), $builder->name(), $this->targetType($builder), $builder);
|
||||
}
|
||||
|
||||
if (preg_match('/^(.+)-index$/', $name, $indexMatches)) {
|
||||
$baseName = (string) $indexMatches[1];
|
||||
$builder = $registry->get($baseName);
|
||||
|
||||
if (! $builder instanceof ShardableSitemapBuilder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($shardCount < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $this->familyIndexName($baseName), $baseName, SitemapTarget::TYPE_INDEX, $builder, null, $shardCount);
|
||||
}
|
||||
|
||||
if (! preg_match('/^(.+)-([0-9]{1,})$/', $name, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseName = (string) $matches[1];
|
||||
$shardNumber = (int) $matches[2];
|
||||
$builder = $registry->get($baseName);
|
||||
|
||||
if (! $builder instanceof ShardableSitemapBuilder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shardCount = $this->shardCount($builder);
|
||||
|
||||
if ($shardCount < 2 || $shardNumber > $shardCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapTarget($name, $this->canonicalShardName($baseName, $shardNumber), $baseName, SitemapTarget::TYPE_URLSET, $builder, $shardNumber, $shardCount);
|
||||
}
|
||||
|
||||
private function forceFamilyIndexes(): bool
|
||||
{
|
||||
return (bool) config('sitemaps.shards.force_family_indexes', false);
|
||||
}
|
||||
|
||||
private function padLength(): int
|
||||
{
|
||||
return max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
|
||||
}
|
||||
|
||||
private function targetType(SitemapBuilder $builder): string
|
||||
{
|
||||
return $builder->name() === (string) config('sitemaps.news.google_variant_name', 'news-google')
|
||||
? SitemapTarget::TYPE_GOOGLE_NEWS
|
||||
: SitemapTarget::TYPE_URLSET;
|
||||
}
|
||||
|
||||
private function enabledFor(string $family): bool
|
||||
{
|
||||
if (! (bool) config('sitemaps.shards.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) data_get(config('sitemaps.shards', []), $family . '.size', 0) > 0;
|
||||
}
|
||||
}
|
||||
23
app/Services/Sitemaps/SitemapTarget.php
Normal file
23
app/Services/Sitemaps/SitemapTarget.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
final class SitemapTarget
|
||||
{
|
||||
public const TYPE_INDEX = 'index';
|
||||
public const TYPE_URLSET = 'urlset';
|
||||
public const TYPE_GOOGLE_NEWS = 'google-news';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $requestedName,
|
||||
public readonly string $documentName,
|
||||
public readonly string $baseName,
|
||||
public readonly string $type,
|
||||
public readonly SitemapBuilder $builder,
|
||||
public readonly ?int $shardNumber = null,
|
||||
public readonly int $totalShards = 1,
|
||||
) {
|
||||
}
|
||||
}
|
||||
19
app/Services/Sitemaps/SitemapUrl.php
Normal file
19
app/Services/Sitemaps/SitemapUrl.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
final readonly class SitemapUrl
|
||||
{
|
||||
/**
|
||||
* @param list<SitemapImage> $images
|
||||
*/
|
||||
public function __construct(
|
||||
public string $loc,
|
||||
public ?DateTimeInterface $lastModified = null,
|
||||
public array $images = [],
|
||||
) {}
|
||||
}
|
||||
208
app/Services/Sitemaps/SitemapUrlBuilder.php
Normal file
208
app/Services/Sitemaps/SitemapUrlBuilder.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use DateTimeInterface;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\Collection;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\NovaCard;
|
||||
use App\Models\Page;
|
||||
use App\Models\Story;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use cPad\Plugins\News\Models\NewsArticle;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SitemapUrlBuilder extends AbstractSitemapBuilder
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'url-builder';
|
||||
}
|
||||
|
||||
public function items(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lastModified(): ?\Carbon\CarbonInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function artwork(Artwork $artwork): ?SitemapUrl
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($slug === '') {
|
||||
$slug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
return new SitemapUrl(
|
||||
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
$this->newest($artwork->updated_at, $artwork->published_at, $artwork->created_at),
|
||||
$this->images([
|
||||
$this->image($preview['url'] ?? null, (string) $artwork->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function profile(User $user, ?DateTimeInterface $lastModified = null): ?SitemapUrl
|
||||
{
|
||||
$username = strtolower(trim((string) $user->username));
|
||||
|
||||
if ($username === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('profile.show', ['username' => $username]),
|
||||
$this->newest($lastModified, $user->updated_at, $user->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function tag(Tag $tag): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('tags.show', ['tag' => $tag->slug]),
|
||||
$this->newest($tag->updated_at, $tag->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function categoryDirectory(): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(route('categories.index'));
|
||||
}
|
||||
|
||||
public function contentType(ContentType $contentType): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url('/' . strtolower((string) $contentType->slug)),
|
||||
$this->newest($contentType->updated_at, $contentType->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function category(Category $category): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
$this->absoluteUrl($category->url) ?? url('/'),
|
||||
$this->newest($category->updated_at, $category->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function collection(Collection $collection): ?SitemapUrl
|
||||
{
|
||||
$username = strtolower(trim((string) $collection->user?->username));
|
||||
|
||||
if ($username === '' || trim((string) $collection->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('profile.collections.show', [
|
||||
'username' => $username,
|
||||
'slug' => $collection->slug,
|
||||
]),
|
||||
$this->newest($collection->updated_at, $collection->published_at, $collection->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function card(NovaCard $card): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $card->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
$card->publicUrl(),
|
||||
$this->newest($card->updated_at, $card->published_at, $card->created_at),
|
||||
$this->images([
|
||||
$this->image($card->ogPreviewUrl() ?: $card->previewUrl(), (string) $card->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function story(Story $story): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $story->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
$story->url,
|
||||
$this->newest($story->updated_at, $story->published_at, $story->created_at),
|
||||
$this->images([
|
||||
$this->image($story->cover_url ?: $story->og_image, (string) $story->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function news(NewsArticle $article): ?SitemapUrl
|
||||
{
|
||||
if (trim((string) $article->slug) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SitemapUrl(
|
||||
route('news.show', ['slug' => $article->slug]),
|
||||
$this->newest($article->updated_at, $article->published_at, $article->created_at),
|
||||
$this->images([
|
||||
$this->image($article->cover_url ?: $article->effectiveOgImage, (string) $article->title),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumIndex(): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(route('forum.index'));
|
||||
}
|
||||
|
||||
public function forumCategory(ForumCategory $category): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.category.show', ['categorySlug' => $category->slug]),
|
||||
$this->newest($category->updated_at, $category->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumBoard(ForumBoard $board): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.board.show', ['boardSlug' => $board->slug]),
|
||||
$this->newest($board->updated_at, $board->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function forumTopic(ForumTopic $topic): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
route('forum.topic.show', ['topic' => $topic->slug]),
|
||||
$this->newest($topic->last_post_at, $topic->updated_at, $topic->created_at),
|
||||
);
|
||||
}
|
||||
|
||||
public function staticRoute(string $path, ?\Carbon\CarbonInterface $lastModified = null): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url($path),
|
||||
$lastModified,
|
||||
);
|
||||
}
|
||||
|
||||
public function page(Page $page, string $path): SitemapUrl
|
||||
{
|
||||
return new SitemapUrl(
|
||||
url($path),
|
||||
$this->newest($page->updated_at, $page->published_at, $page->created_at),
|
||||
);
|
||||
}
|
||||
}
|
||||
286
app/Services/Sitemaps/SitemapValidationService.php
Normal file
286
app/Services/Sitemaps/SitemapValidationService.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
final class SitemapValidationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SitemapBuildService $build,
|
||||
private readonly SitemapIndexService $index,
|
||||
private readonly SitemapRegistry $registry,
|
||||
private readonly SitemapShardService $shards,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $onlyFamilies
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(array $onlyFamilies = []): array
|
||||
{
|
||||
$families = $onlyFamilies !== []
|
||||
? array_values(array_filter($onlyFamilies, fn (string $family): bool => $this->registry->get($family) !== null))
|
||||
: $this->build->enabledFamilies();
|
||||
|
||||
$expectedIndexLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
array_values(array_filter(
|
||||
$this->index->items(),
|
||||
fn (SitemapIndexItem $item): bool => $this->isFamilySelected($families, $item->loc),
|
||||
)),
|
||||
);
|
||||
|
||||
$indexBuild = $this->build->buildIndex(true, false);
|
||||
$indexErrors = [];
|
||||
$indexXml = $this->loadXml($indexBuild['content']);
|
||||
|
||||
if ($indexXml === null) {
|
||||
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
|
||||
}
|
||||
|
||||
$actualIndexLocs = $indexXml ? $this->extractLocs($indexXml, 'sitemap') : [];
|
||||
if ($indexXml !== null && $actualIndexLocs !== $expectedIndexLocs) {
|
||||
$indexErrors[] = 'Main sitemap index child references do not match the expected shard-aware manifest.';
|
||||
}
|
||||
|
||||
$familyReports = [];
|
||||
$duplicates = [];
|
||||
$seenUrls = [];
|
||||
$totalUrlCount = 0;
|
||||
$totalShardCount = 0;
|
||||
|
||||
foreach ($families as $family) {
|
||||
$builder = $this->registry->get($family);
|
||||
|
||||
if ($builder === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$report = [
|
||||
'family' => $family,
|
||||
'documents' => 0,
|
||||
'url_count' => 0,
|
||||
'shard_count' => max(1, $this->shards->shardCount($builder)),
|
||||
'errors' => [],
|
||||
'warnings' => [],
|
||||
];
|
||||
|
||||
$totalShardCount += $report['shard_count'];
|
||||
|
||||
foreach ($this->build->documentNamesForFamily($family, true) as $name) {
|
||||
$built = $this->build->buildNamed($name, true, false);
|
||||
|
||||
if ($built === null) {
|
||||
$report['errors'][] = 'Unable to resolve sitemap [' . $name . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$document = $this->loadXml($built['content']);
|
||||
if ($document === null) {
|
||||
$report['errors'][] = 'Invalid XML emitted for [' . $name . '].';
|
||||
continue;
|
||||
}
|
||||
|
||||
$report['documents']++;
|
||||
|
||||
if ($built['type'] === SitemapTarget::TYPE_INDEX) {
|
||||
$expectedFamilyLocs = array_map(
|
||||
static fn (SitemapIndexItem $item): string => $item->loc,
|
||||
$this->index->itemsForBuilder($builder),
|
||||
);
|
||||
|
||||
$actualFamilyLocs = $this->extractLocs($document, 'sitemap');
|
||||
if ($actualFamilyLocs !== $expectedFamilyLocs) {
|
||||
$report['errors'][] = 'Shard compatibility index [' . $name . '] does not reference the expected shard URLs.';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$locs = $this->extractLocs($document, 'url');
|
||||
$report['url_count'] += count($locs);
|
||||
$totalUrlCount += count($locs);
|
||||
|
||||
foreach ($locs as $loc) {
|
||||
if (isset($seenUrls[$loc])) {
|
||||
$duplicates[$loc] = ($duplicates[$loc] ?? 1) + 1;
|
||||
}
|
||||
|
||||
$seenUrls[$loc] = true;
|
||||
|
||||
$reason = $this->urlError($family, $loc);
|
||||
if ($reason !== null) {
|
||||
$report['errors'][] = $reason . ' [' . $loc . ']';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->extractImageLocs($document) as $imageLoc) {
|
||||
if (! preg_match('/^https?:\/\//i', $imageLoc)) {
|
||||
$report['warnings'][] = 'Non-absolute image URL found [' . $imageLoc . ']';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$familyReports[] = $report;
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => $indexErrors === [] && $this->familyErrors($familyReports) === [] && $duplicates === [],
|
||||
'index' => [
|
||||
'errors' => $indexErrors,
|
||||
'url_count' => count($actualIndexLocs),
|
||||
],
|
||||
'families' => $familyReports,
|
||||
'duplicates' => array_keys($duplicates),
|
||||
'totals' => [
|
||||
'families' => count($familyReports),
|
||||
'documents' => array_sum(array_map(static fn (array $report): int => (int) $report['documents'], $familyReports)),
|
||||
'urls' => $totalUrlCount,
|
||||
'shards' => $totalShardCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $reports
|
||||
* @return list<string>
|
||||
*/
|
||||
private function familyErrors(array $reports): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($reports as $report) {
|
||||
foreach ((array) ($report['errors'] ?? []) as $error) {
|
||||
$errors[] = (string) $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function loadXml(string $content): ?DOMDocument
|
||||
{
|
||||
$document = new DOMDocument();
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadXML($content);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
return $loaded ? $document : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractLocs(DOMDocument $document, string $nodeName): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="' . $nodeName . '"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractImageLocs(DOMDocument $document): array
|
||||
{
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[local-name()="image"]/*[local-name()="loc"]');
|
||||
$locs = [];
|
||||
|
||||
foreach ($nodes ?: [] as $node) {
|
||||
$value = trim((string) $node->textContent);
|
||||
if ($value !== '') {
|
||||
$locs[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $locs;
|
||||
}
|
||||
|
||||
private function isFamilySelected(array $families, string $loc): bool
|
||||
{
|
||||
foreach ($families as $family) {
|
||||
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function urlError(string $family, string $loc): ?string
|
||||
{
|
||||
$parts = parse_url($loc);
|
||||
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return 'Non-absolute URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['query'] ?? '') !== '') {
|
||||
return 'Query-string URL emitted';
|
||||
}
|
||||
|
||||
if (($parts['fragment'] ?? '') !== '') {
|
||||
return 'Fragment URL emitted';
|
||||
}
|
||||
|
||||
$path = '/' . ltrim((string) ($parts['path'] ?? '/'), '/');
|
||||
|
||||
foreach ((array) config('sitemaps.validation.forbidden_paths', []) as $forbidden) {
|
||||
if ($forbidden !== '/' && str_contains($path, (string) $forbidden)) {
|
||||
return 'Non-public path emitted';
|
||||
}
|
||||
}
|
||||
|
||||
return match ($family) {
|
||||
'artworks' => $this->validateArtworkUrl($path),
|
||||
'users' => $this->validateUserUrl($path),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function validateArtworkUrl(string $path): ?string
|
||||
{
|
||||
if (! preg_match('~^/art/(\d+)(?:/[^/?#]+)?$~', $path, $matches)) {
|
||||
return 'Non-canonical artwork URL emitted';
|
||||
}
|
||||
|
||||
$artwork = Artwork::query()->public()->published()->find((int) $matches[1]);
|
||||
|
||||
return $artwork === null ? 'Non-public artwork URL emitted' : null;
|
||||
}
|
||||
|
||||
private function validateUserUrl(string $path): ?string
|
||||
{
|
||||
if (! preg_match('#^/@([A-Za-z0-9_\-]+)$#', $path, $matches)) {
|
||||
return 'Non-canonical user URL emitted';
|
||||
}
|
||||
|
||||
$username = strtolower((string) $matches[1]);
|
||||
|
||||
$user = User::query()
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('LOWER(username) = ?', [$username])
|
||||
->first();
|
||||
|
||||
return $user === null ? 'Non-public user URL emitted' : null;
|
||||
}
|
||||
}
|
||||
49
app/Services/Sitemaps/SitemapXmlRenderer.php
Normal file
49
app/Services/Sitemaps/SitemapXmlRenderer.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sitemaps;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class SitemapXmlRenderer
|
||||
{
|
||||
/**
|
||||
* @param list<SitemapIndexItem> $items
|
||||
*/
|
||||
public function renderIndex(array $items): string
|
||||
{
|
||||
return \view('sitemaps.index', [
|
||||
'items' => $items,
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SitemapUrl> $items
|
||||
*/
|
||||
public function renderUrlset(array $items): string
|
||||
{
|
||||
return \view('sitemaps.urlset', [
|
||||
'items' => $items,
|
||||
'hasImages' => \collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<GoogleNewsSitemapUrl> $items
|
||||
*/
|
||||
public function renderGoogleNewsUrlset(array $items): string
|
||||
{
|
||||
return \view('sitemaps.news-urlset', [
|
||||
'items' => $items,
|
||||
])->render();
|
||||
}
|
||||
|
||||
public function xmlResponse(string $content): Response
|
||||
{
|
||||
return \response($content, 200, [
|
||||
'Content-Type' => 'application/xml; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=' . max(60, (int) \config('sitemaps.cache_ttl_seconds', 900)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user