Current state with latest updates

This commit is contained in:
2026-08-23 13:28:34 +02:00
parent 5af95f6533
commit f52879edbb
74 changed files with 5174 additions and 960 deletions
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Services\Sitemaps\Builders;
use App\Services\Sitemaps\AbstractSitemapBuilder;
use App\Services\Sitemaps\SitemapUrlBuilder;
use DateTimeInterface;
final class AcademyPagesSitemapBuilder extends AbstractSitemapBuilder
{
public function __construct(private readonly SitemapUrlBuilder $urls)
{
}
public function name(): string
{
return 'academy-pages';
}
public function items(): array
{
if (! (bool) config('academy.enabled', true)) {
return [];
}
return [
$this->urls->staticRoute('/academy'),
$this->urls->staticRoute('/academy/pricing'),
];
}
public function lastModified(): ?DateTimeInterface
{
return null;
}
}
@@ -27,7 +27,10 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
return [];
}
$items = [$this->urls->staticRoute('/academy/prompts')];
$items = [
$this->urls->staticRoute('/academy/prompts'),
$this->urls->staticRoute('/academy/prompts/popular'),
];
$details = AcademyPromptTemplate::query()
->active()
@@ -45,4 +48,4 @@ final class AcademyPromptsSitemapBuilder extends AbstractSitemapBuilder
{
return $this->dateTime(AcademyPromptTemplate::query()->active()->published()->max('updated_at'));
}
}
}
@@ -24,8 +24,6 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
{
$items = [
$this->urls->staticRoute('/'),
$this->urls->staticRoute('/academy'),
$this->urls->staticRoute('/academy/pricing'),
$this->urls->staticRoute('/web-stories'),
$this->urls->staticRoute('/faq'),
$this->urls->staticRoute('/rules-and-guidelines'),
@@ -61,4 +59,4 @@ final class StaticPagesSitemapBuilder extends AbstractSitemapBuilder
{
return $this->dateTime(Page::query()->published()->max('updated_at'));
}
}
}
@@ -102,6 +102,14 @@ final class PublishedSitemapResolver
}
}
foreach ((array) ($manifest['groups'] ?? []) as $groupName => $group) {
$entryName = (string) ($group['entry_name'] ?? '');
if ($requestedName === $groupName && $entryName !== '') {
return $entryName;
}
}
return null;
}
}
}
+36 -1
View File
@@ -42,6 +42,23 @@ final class SitemapBuildService
*/
public function buildNamed(string $name, bool $force = false, bool $persist = true): ?array
{
$groupFamilies = $this->groupFamilies($name);
if ($groupFamilies !== null) {
$built = $this->cache->remember(
$name,
fn (): string => $this->renderer->renderIndex($this->index->itemsForFamilies($groupFamilies)),
$force,
$persist,
);
return $built + [
'type' => SitemapTarget::TYPE_INDEX,
'url_count' => count($this->index->itemsForFamilies($groupFamilies)),
'shard_count' => 0,
'name' => $name,
];
}
$target = $this->shards->resolve($this->registry, $name);
if ($target === null) {
@@ -111,6 +128,24 @@ final class SitemapBuildService
));
}
/**
* @return list<string>|null
*/
public function groupFamilies(string $name): ?array
{
$families = $this->index->activeGroupIndexes($this->enabledFamilies())[$name] ?? null;
return is_array($families) && $families !== [] ? $families : null;
}
/**
* @return array<string, list<string>>
*/
public function enabledGroupIndexes(): array
{
return $this->index->activeGroupIndexes($this->enabledFamilies());
}
private function renderTarget(SitemapTarget $target): string
{
if ($target->type === SitemapTarget::TYPE_INDEX) {
@@ -140,4 +175,4 @@ final class SitemapBuildService
return count($target->builder->items());
}
}
}
+111 -2
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Services\Sitemaps;
use DateTimeImmutable;
use DateTimeInterface;
final class SitemapIndexService
{
public function __construct(
@@ -18,8 +21,25 @@ final class SitemapIndexService
public function items(?array $families = null): array
{
$items = [];
$selectedFamilies = $families ?? (array) config('sitemaps.enabled', []);
$groupedFamilies = [];
foreach ($this->activeGroupIndexes($selectedFamilies) as $groupName => $groupFamilies) {
$items[] = new SitemapIndexItem(
url('/sitemaps/' . $groupName . '.xml'),
$this->lastModifiedForFamilies($groupFamilies),
);
foreach ($groupFamilies as $family) {
$groupedFamilies[$family] = true;
}
}
foreach ($selectedFamilies as $name) {
if (isset($groupedFamilies[(string) $name])) {
continue;
}
foreach ($families ?? (array) config('sitemaps.enabled', []) as $name) {
$builder = $this->registry->get((string) $name);
if ($builder === null) {
@@ -35,6 +55,30 @@ final class SitemapIndexService
return $items;
}
/**
* @param list<string> $families
* @return list<SitemapIndexItem>
*/
public function itemsForFamilies(array $families): array
{
$items = [];
foreach ($families as $family) {
$builder = $this->registry->get($family);
if ($builder === null) {
continue;
}
$items[] = new SitemapIndexItem(
url('/sitemaps/' . $this->shards->rootEntryName($builder) . '.xml'),
$builder->lastModified(),
);
}
return $items;
}
/**
* @return list<SitemapIndexItem>
*/
@@ -58,4 +102,69 @@ final class SitemapIndexService
$builder->lastModified(),
)];
}
}
/**
* @param list<string> $selectedFamilies
* @return array<string, list<string>>
*/
public function activeGroupIndexes(array $selectedFamilies): array
{
$selectedLookup = array_fill_keys($selectedFamilies, true);
$groups = [];
foreach ((array) config('sitemaps.group_indexes', []) as $groupName => $groupFamilies) {
if (! is_string($groupName) || $groupName === '') {
continue;
}
$validFamilies = array_values(array_filter(
(array) $groupFamilies,
fn (mixed $family): bool => is_string($family) && $family !== '' && $this->registry->get($family) !== null,
));
if ($validFamilies === []) {
continue;
}
foreach ($validFamilies as $family) {
if (! isset($selectedLookup[$family])) {
continue 2;
}
}
$groups[$groupName] = $validFamilies;
}
return $groups;
}
/**
* @param list<string> $families
*/
private function lastModifiedForFamilies(array $families): ?DateTimeInterface
{
$latest = null;
foreach ($families as $family) {
$builder = $this->registry->get($family);
if ($builder === null) {
continue;
}
$lastModified = $builder->lastModified();
if ($lastModified !== null) {
$candidate = $lastModified instanceof DateTimeInterface
? $lastModified
: new DateTimeImmutable((string) $lastModified);
if ($latest === null || $candidate->getTimestamp() > $latest->getTimestamp()) {
$latest = $candidate;
}
}
}
return $latest;
}
}
@@ -130,6 +130,7 @@ final class SitemapPublishService
$releaseId ??= $this->releases->generateReleaseId();
$familyManifest = [];
$groupManifest = [];
$documents = [
SitemapCacheService::INDEX_DOCUMENT => $this->releases->documentRelativePath(SitemapCacheService::INDEX_DOCUMENT),
];
@@ -180,15 +181,43 @@ final class SitemapPublishService
];
}
foreach ($this->build->enabledGroupIndexes() as $groupName => $groupFamilies) {
foreach ($groupFamilies as $family) {
if (! in_array($family, $selectedFamilies, true)) {
continue 2;
}
}
$built = $this->build->buildNamed($groupName, true, false);
if ($built === null) {
throw new \RuntimeException('Failed to build sitemap group [' . $groupName . '].');
}
$this->releases->putDocument($releaseId, $groupName, (string) $built['content']);
$documents[$groupName] = $this->releases->documentRelativePath($groupName);
$groupManifest[$groupName] = [
'name' => $groupName,
'entry_name' => $groupName,
'families' => $groupFamilies,
'documents' => [$groupName],
'url_count' => (int) $built['url_count'],
'type' => SitemapTarget::TYPE_INDEX,
];
}
$manifest = [
'release_id' => $releaseId,
'status' => 'built',
'built_at' => now()->toAtomString(),
'published_at' => null,
'families' => $familyManifest,
'groups' => $groupManifest,
'documents' => $documents,
'totals' => [
'families' => count($familyManifest),
'groups' => count($groupManifest),
'documents' => count($documents),
'urls' => $totalUrls,
],
@@ -204,4 +233,4 @@ final class SitemapPublishService
return $manifest;
}
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ use App\Services\Sitemaps\Builders\ArtworksSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyChallengesSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyLessonsSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyPagesSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyPacksSitemapBuilder;
use App\Services\Sitemaps\Builders\AcademyPromptsSitemapBuilder;
use App\Services\Sitemaps\Builders\CardsSitemapBuilder;
@@ -33,6 +34,7 @@ final class SitemapRegistry
public function __construct(
ArtworksSitemapBuilder $artworks,
AcademyPagesSitemapBuilder $academyPages,
AcademyCoursesSitemapBuilder $academyCourses,
AcademyLessonsSitemapBuilder $academyLessons,
AcademyPromptsSitemapBuilder $academyPrompts,
@@ -54,6 +56,7 @@ final class SitemapRegistry
) {
$this->builders = [
$artworks->name() => $artworks,
$academyPages->name() => $academyPages,
$academyCourses->name() => $academyCourses,
$academyLessons->name() => $academyLessons,
$academyPrompts->name() => $academyPrompts,
@@ -87,4 +90,4 @@ final class SitemapRegistry
{
return $this->builders[$name] ?? null;
}
}
}
@@ -32,6 +32,7 @@ final class SitemapReleaseValidator
$errors = [];
$families = (array) ($manifest['families'] ?? []);
$groups = (array) ($manifest['groups'] ?? []);
$documents = (array) ($manifest['documents'] ?? []);
$rootContent = $this->releases->getDocument($releaseId, SitemapCacheService::INDEX_DOCUMENT);
@@ -41,10 +42,9 @@ final class SitemapReleaseValidator
$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)),
);
$expectedRoot = $this->build->buildIndex(true, false, array_keys($families));
$expectedRootXml = $this->loadXml((string) $expectedRoot['content']);
$expectedRootLocs = $expectedRootXml ? $this->extractLocs($expectedRootXml, 'sitemap') : [];
if ($rootLocs !== $expectedRootLocs) {
$errors[] = 'Root sitemap index does not match the manifest family entries.';
@@ -147,13 +147,48 @@ final class SitemapReleaseValidator
}
}
$groupReports = [];
foreach ($groups as $groupName => $group) {
$groupErrors = [];
$documentName = (string) ($group['entry_name'] ?? $groupName);
$artifact = $this->releases->getDocument($releaseId, $documentName);
if (! is_string($artifact) || $artifact === '') {
$groupErrors[] = 'Missing artifact [' . $documentName . '].';
} else {
$artifactXml = $this->loadXml($artifact);
$expected = $this->build->buildNamed($documentName, true, false);
$expectedXml = $expected !== null ? $this->loadXml((string) $expected['content']) : null;
if ($artifactXml === null || $expectedXml === null) {
$groupErrors[] = 'Invalid XML in group artifact [' . $documentName . '].';
} elseif ($this->extractLocs($artifactXml, 'sitemap') !== $this->extractLocs($expectedXml, 'sitemap')) {
$groupErrors[] = 'Group index artifact [' . $documentName . '] does not match expected sitemap references.';
}
}
$groupReports[] = [
'group' => $groupName,
'documents' => count((array) ($group['documents'] ?? [])),
'url_count' => (int) ($group['url_count'] ?? 0),
'errors' => $groupErrors,
];
foreach ($groupErrors as $groupError) {
$errors[] = $groupName . ': ' . $groupError;
}
}
return [
'ok' => $errors === [],
'release_id' => $releaseId,
'errors' => $errors,
'families' => $reports,
'groups' => $groupReports,
'totals' => [
'families' => count($families),
'groups' => count($groups),
'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)),
@@ -255,4 +290,4 @@ final class SitemapReleaseValidator
return null;
}
}
}
@@ -8,8 +8,8 @@ use Illuminate\Support\Facades\Storage;
/**
* Writes every document from a published release to the public disk so nginx
* can serve sitemap.xml and sitemaps/{name}.xml as plain static files,
* bypassing PHP entirely on subsequent requests.
* can serve sitemaps/{name}.xml as plain static files, bypassing PHP on
* subsequent child-sitemap requests. The root /sitemap.xml stays dynamic.
*/
final class SitemapStaticPublisher
{
@@ -50,6 +50,7 @@ final class SitemapStaticPublisher
}
$disk->put((string) $relativePath, $content);
$written++;
}
@@ -29,15 +29,7 @@ final class SitemapValidationService
? 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);
$indexBuild = $this->build->buildIndex(true, false, $families);
$indexErrors = [];
$indexXml = $this->loadXml($indexBuild['content']);
@@ -45,6 +37,7 @@ final class SitemapValidationService
$indexErrors[] = 'The main sitemap index XML could not be parsed.';
}
$expectedIndexLocs = $this->extractLocsFromContent((string) $indexBuild['content'], 'sitemap');
$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.';
@@ -214,15 +207,14 @@ final class SitemapValidationService
return $locs;
}
private function isFamilySelected(array $families, string $loc): bool
/**
* @return list<string>
*/
private function extractLocsFromContent(string $content, string $nodeName): array
{
foreach ($families as $family) {
if (str_contains($loc, '/sitemaps/' . $family . '.xml') || str_contains($loc, '/sitemaps/' . $family . '-')) {
return true;
}
}
$document = $this->loadXml($content);
return false;
return $document === null ? [] : $this->extractLocs($document, $nodeName);
}
private function urlError(string $family, string $loc): ?string
@@ -283,4 +275,4 @@ final class SitemapValidationService
return $user === null ? 'Non-public user URL emitted' : null;
}
}
}