Skip to content

Commit a47a75c

Browse files
committed
Add missing category, author, static pages to sitemap.
1 parent 6c3d8c9 commit a47a75c

4 files changed

Lines changed: 206 additions & 29 deletions

File tree

src/App/src/Factory/SitemapGeneratorFactory.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
namespace Light\App\Factory;
66

77
use Light\App\Service\SitemapGenerator;
8+
use Light\Blog\Repository\AuthorRepository;
9+
use Light\Blog\Repository\CategoryRepository;
810
use Light\Blog\Repository\PostRepository;
911
use Psr\Container\ContainerInterface;
1012

13+
use function array_keys;
14+
use function array_merge;
1115
use function assert;
16+
use function is_array;
1217

1318
class SitemapGeneratorFactory
1419
{
@@ -17,10 +22,26 @@ public function __invoke(ContainerInterface $container): SitemapGenerator
1722
$postRepository = $container->get(PostRepository::class);
1823
assert($postRepository instanceof PostRepository);
1924

25+
$categoryRepository = $container->get(CategoryRepository::class);
26+
assert($categoryRepository instanceof CategoryRepository);
27+
28+
$authorRepository = $container->get(AuthorRepository::class);
29+
assert($authorRepository instanceof AuthorRepository);
30+
2031
$config = $container->get('config');
2132

33+
$pageRoutes = [];
34+
foreach ($config['routes'] ?? [] as $moduleRoutes) {
35+
if (is_array($moduleRoutes)) {
36+
$pageRoutes = array_merge($pageRoutes, array_keys($moduleRoutes));
37+
}
38+
}
39+
2240
return new SitemapGenerator(
2341
$postRepository,
42+
$categoryRepository,
43+
$authorRepository,
44+
$pageRoutes,
2445
$config['sitemap']['path'],
2546
$config['application']['baseUrl'] ?? '',
2647
);

src/App/src/Service/SitemapGenerator.php

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,28 @@
77
use DateTimeInterface;
88
use DOMDocument;
99
use DOMElement;
10+
use Light\Blog\Repository\AuthorRepository;
11+
use Light\Blog\Repository\CategoryRepository;
1012
use Light\Blog\Repository\PostRepository;
1113
use RuntimeException;
1214

13-
use function count;
15+
use function sprintf;
1416

1517
class SitemapGenerator
1618
{
1719
public const CONTENT_TYPE = 'application/rss+xml; charset=UTF-8';
1820

1921
private const SITEMAP_NAMESPACE = 'http://www.sitemaps.org/schemas/sitemap/0.9';
2022

23+
/**
24+
* @param array<int, string> $pageRoutes Route URIs registered under config['routes'][*],
25+
* e.g. ['contact'] for the /contact/ static page.
26+
*/
2127
public function __construct(
2228
private readonly PostRepository $postRepository,
29+
private readonly CategoryRepository $categoryRepository,
30+
private readonly AuthorRepository $authorRepository,
31+
private readonly array $pageRoutes,
2332
private readonly string $sitemapFile,
2433
private readonly string $baseUrl,
2534
) {
@@ -32,26 +41,67 @@ public function getSitemapFile(): string
3241

3342
public function write(): int
3443
{
35-
$posts = $this->postRepository->getPublishedPosts();
44+
$posts = $this->postRepository->getPublishedPosts();
45+
$categories = $this->categoryRepository->getCategories();
46+
$authors = $this->authorRepository->getAuthorsWithPublishedPosts();
3647

3748
$dom = new DOMDocument('1.0', 'UTF-8');
3849
$dom->formatOutput = true;
3950

4051
$urlset = $dom->createElementNS(self::SITEMAP_NAMESPACE, 'urlset');
4152
$dom->appendChild($urlset);
4253

43-
$this->appendUrl($dom, $urlset, $this->baseUrl);
54+
$count = 0;
55+
56+
$this->appendUrl($dom, $urlset, $this->baseUrl . '/');
57+
$count++;
58+
59+
$this->appendUrl($dom, $urlset, $this->baseUrl . '/blog/');
60+
$count++;
61+
62+
$this->appendUrl($dom, $urlset, $this->baseUrl . '/categories/');
63+
$count++;
64+
65+
$this->appendUrl($dom, $urlset, $this->baseUrl . '/dotkernel-packages-oss-lifecycle/');
66+
$count++;
67+
68+
foreach ($this->pageRoutes as $routeUri) {
69+
$this->appendUrl($dom, $urlset, sprintf('%s/%s/', $this->baseUrl, $routeUri));
70+
$count++;
71+
}
72+
73+
foreach ($categories as $category) {
74+
$lastmod = $category->getUpdated() ?? $category->getCreated();
75+
$this->appendUrl(
76+
$dom,
77+
$urlset,
78+
sprintf('%s/category/%s/', $this->baseUrl, $category->getSlug()),
79+
$lastmod?->format(DateTimeInterface::W3C)
80+
);
81+
$count++;
82+
}
83+
84+
foreach ($authors as $author) {
85+
$this->appendUrl($dom, $urlset, sprintf('%s/author/%s/', $this->baseUrl, $author->getSlug()));
86+
$count++;
87+
}
4488

4589
foreach ($posts as $post) {
46-
$link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/';
90+
$link = sprintf(
91+
'%s/%s/%s/',
92+
$this->baseUrl,
93+
$post->getCategory()->getSlug(),
94+
$post->getSlug()
95+
);
4796
$this->appendUrl($dom, $urlset, $link, $post->getPostDate()->format(DateTimeInterface::W3C));
97+
$count++;
4898
}
4999

50100
if ($dom->save($this->sitemapFile) === false) {
51101
throw new RuntimeException('Unable to write sitemap.');
52102
}
53103

54-
return count($posts) + 1;
104+
return $count;
55105
}
56106

57107
private function appendUrl(DOMDocument $dom, DOMElement $urlset, string $loc, ?string $lastmod = null): void

src/Blog/src/Repository/AuthorRepository.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Light\App\Repository\AbstractRepository;
88
use Light\Blog\Entity\Author;
9+
use Light\Blog\Entity\Post;
10+
use Light\Blog\Enum\PostStatusEnum;
911

1012
class AuthorRepository extends AbstractRepository
1113
{
@@ -15,8 +17,31 @@ class AuthorRepository extends AbstractRepository
1517
public function getAuthor(): array
1618
{
1719
$qb = $this->getQueryBuilder()
18-
->select('author.name, author.slug')
19-
->from(Author::class, 'authors');
20+
->select('author')
21+
->from(Author::class, 'author');
22+
23+
return $qb->getQuery()->getResult();
24+
}
25+
26+
/**
27+
* Authors with at least one published post, i.e. authors whose page actually has content.
28+
*
29+
* @return array<Author>
30+
*/
31+
public function getAuthorsWithPublishedPosts(): array
32+
{
33+
$publishedAuthorIds = $this->getQueryBuilder()
34+
->select('publishedAuthor.id')
35+
->from(Post::class, 'post')
36+
->join('post.author', 'publishedAuthor')
37+
->where('post.status = :published');
38+
39+
$qb = $this->getQueryBuilder()
40+
->select('author')
41+
->from(Author::class, 'author');
42+
43+
$qb->where($qb->expr()->in('author.id', $publishedAuthorIds->getDQL()))
44+
->setParameter('published', PostStatusEnum::Published);
2045

2146
return $qb->getQuery()->getResult();
2247
}

test/Unit/App/Service/SitemapGeneratorTest.php

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
use DateTimeImmutable;
88
use DateTimeZone;
99
use Light\App\Service\SitemapGenerator;
10+
use Light\Blog\Entity\Author;
1011
use Light\Blog\Entity\Category;
1112
use Light\Blog\Entity\Post;
13+
use Light\Blog\Repository\AuthorRepository;
14+
use Light\Blog\Repository\CategoryRepository;
1215
use Light\Blog\Repository\PostRepository;
1316
use LightTest\Unit\UnitTest;
1417
use PHPUnit\Framework\MockObject\Exception;
@@ -31,6 +34,9 @@
3134

3235
class SitemapGeneratorTest extends UnitTest
3336
{
37+
/** Homepage, /blog/, /categories/ and the packages-lifecycle page are always present. */
38+
private const FIXED_URL_COUNT = 4;
39+
3440
private string $sitemapFile;
3541

3642
protected function setUp(): void
@@ -64,37 +70,76 @@ protected function tearDown(): void
6470

6571
public function testGetSitemapFileReturnsTheConfiguredPath(): void
6672
{
67-
$this->assertSame($this->sitemapFile, $this->createGenerator([])->getSitemapFile());
73+
$this->assertSame($this->sitemapFile, $this->createGenerator()->getSitemapFile());
6874
}
6975

7076
/**
71-
* The count includes the homepage entry in addition to one entry per post.
72-
*
7377
* @throws Exception
7478
*/
75-
public function testWriteReturnsTheNumberOfPostsPlusTheHomepage(): void
79+
public function testWriteAlwaysIncludesTheFixedPagesEvenWithoutAnyContent(): void
7680
{
77-
$generator = $this->createGenerator([
78-
$this->createPost('first-post', 'news'),
79-
$this->createPost('second-post', 'news'),
80-
]);
81+
$generator = $this->createGenerator();
82+
83+
$this->assertSame(self::FIXED_URL_COUNT, $generator->write());
8184

82-
$this->assertSame(3, $generator->write());
85+
$urls = $this->loadSitemap()->url;
86+
$this->assertCount(self::FIXED_URL_COUNT, $urls);
87+
$this->assertSame('https://example.test/', (string) $urls[0]->loc);
88+
$this->assertSame('https://example.test/blog/', (string) $urls[1]->loc);
89+
$this->assertSame('https://example.test/categories/', (string) $urls[2]->loc);
90+
$this->assertSame(
91+
'https://example.test/dotkernel-packages-oss-lifecycle/',
92+
(string) $urls[3]->loc
93+
);
94+
$this->assertCount(0, $urls[0]->lastmod);
8395
}
8496

8597
/**
8698
* @throws Exception
8799
*/
88-
public function testWriteAlwaysIncludesTheHomepageEvenWithoutPosts(): void
100+
public function testWriteAddsOneUrlEntryPerConfiguredStaticPage(): void
89101
{
90-
$generator = $this->createGenerator([]);
102+
$generator = $this->createGenerator(pageRoutes: ['contact']);
91103

92-
$this->assertSame(1, $generator->write());
104+
$this->assertSame(self::FIXED_URL_COUNT + 1, $generator->write());
93105

94106
$urls = $this->loadSitemap()->url;
95-
$this->assertCount(1, $urls);
96-
$this->assertSame('https://example.test', (string) $urls[0]->loc);
97-
$this->assertCount(0, $urls[0]->lastmod);
107+
$this->assertSame('https://example.test/contact/', (string) $urls[self::FIXED_URL_COUNT]->loc);
108+
}
109+
110+
/**
111+
* @throws Exception
112+
*/
113+
public function testWriteAddsOneUrlEntryPerCategoryWithItsLastModifiedDate(): void
114+
{
115+
$category = $this->createCategory('news', '2026-08-01 10:00:00');
116+
$generator = $this->createGenerator(categories: [$category]);
117+
118+
$this->assertSame(self::FIXED_URL_COUNT + 1, $generator->write());
119+
120+
$urls = $this->loadSitemap()->url;
121+
$this->assertSame('https://example.test/category/news/', (string) $urls[self::FIXED_URL_COUNT]->loc);
122+
$this->assertSame(
123+
'2026-08-01T10:00:00+00:00',
124+
(string) $urls[self::FIXED_URL_COUNT]->lastmod
125+
);
126+
}
127+
128+
/**
129+
* @throws Exception
130+
*/
131+
public function testWriteAddsOneUrlEntryPerAuthor(): void
132+
{
133+
$author = $this->createStub(Author::class);
134+
$author->method('getSlug')->willReturn('jane-doe');
135+
136+
$generator = $this->createGenerator(authors: [$author]);
137+
138+
$this->assertSame(self::FIXED_URL_COUNT + 1, $generator->write());
139+
140+
$urls = $this->loadSitemap()->url;
141+
$this->assertSame('https://example.test/author/jane-doe/', (string) $urls[self::FIXED_URL_COUNT]->loc);
142+
$this->assertCount(0, $urls[self::FIXED_URL_COUNT]->lastmod);
98143
}
99144

100145
/**
@@ -103,13 +148,19 @@ public function testWriteAlwaysIncludesTheHomepageEvenWithoutPosts(): void
103148
public function testWriteAddsOneUrlEntryPerPostWithACategoryQualifiedLink(): void
104149
{
105150
$post = $this->createPost('a-post', 'news', '2026-08-01 10:00:00');
106-
$this->createGenerator([$post])->write();
151+
$this->createGenerator(posts: [$post])->write();
107152

108153
$urls = $this->loadSitemap()->url;
109154

110-
$this->assertCount(2, $urls);
111-
$this->assertSame('https://example.test/news/a-post/', (string) $urls[1]->loc);
112-
$this->assertSame('2026-08-01T10:00:00+00:00', (string) $urls[1]->lastmod);
155+
$this->assertCount(self::FIXED_URL_COUNT + 1, $urls);
156+
$this->assertSame(
157+
'https://example.test/news/a-post/',
158+
(string) $urls[self::FIXED_URL_COUNT]->loc
159+
);
160+
$this->assertSame(
161+
'2026-08-01T10:00:00+00:00',
162+
(string) $urls[self::FIXED_URL_COUNT]->lastmod
163+
);
113164
}
114165

115166
/**
@@ -120,7 +171,7 @@ public function testWriteAddsOneUrlEntryPerPostWithACategoryQualifiedLink(): voi
120171
*/
121172
public function testWriteThrowsWhenTheSitemapFileCannotBeWritten(): void
122173
{
123-
$generator = $this->createGenerator([], sitemapFile: '/nonexistent-directory/sitemap.xml');
174+
$generator = $this->createGenerator(sitemapFile: '/nonexistent-directory/sitemap.xml');
124175

125176
$this->expectException(RuntimeException::class);
126177
$this->expectExceptionMessage('Unable to write sitemap.');
@@ -130,15 +181,32 @@ public function testWriteThrowsWhenTheSitemapFileCannotBeWritten(): void
130181

131182
/**
132183
* @param list<Post> $posts
184+
* @param list<Category> $categories
185+
* @param list<Author> $authors
186+
* @param list<string> $pageRoutes
133187
* @throws Exception
134188
*/
135-
private function createGenerator(array $posts, ?string $sitemapFile = null): SitemapGenerator
136-
{
189+
private function createGenerator(
190+
array $posts = [],
191+
array $categories = [],
192+
array $authors = [],
193+
array $pageRoutes = [],
194+
?string $sitemapFile = null,
195+
): SitemapGenerator {
137196
$postRepository = $this->createStub(PostRepository::class);
138197
$postRepository->method('getPublishedPosts')->willReturn($posts);
139198

199+
$categoryRepository = $this->createStub(CategoryRepository::class);
200+
$categoryRepository->method('getCategories')->willReturn($categories);
201+
202+
$authorRepository = $this->createStub(AuthorRepository::class);
203+
$authorRepository->method('getAuthorsWithPublishedPosts')->willReturn($authors);
204+
140205
return new SitemapGenerator(
141206
$postRepository,
207+
$categoryRepository,
208+
$authorRepository,
209+
$pageRoutes,
142210
$sitemapFile ?? $this->sitemapFile,
143211
'https://example.test',
144212
);
@@ -160,6 +228,19 @@ private function createPost(string $slug, string $categorySlug, string $postDate
160228
return $post;
161229
}
162230

231+
/**
232+
* @throws Exception
233+
*/
234+
private function createCategory(string $slug, string $updated): Category
235+
{
236+
$category = $this->createStub(Category::class);
237+
$category->method('getSlug')->willReturn($slug);
238+
$category->method('getUpdated')->willReturn(new DateTimeImmutable($updated, new DateTimeZone('UTC')));
239+
$category->method('getCreated')->willReturn(new DateTimeImmutable($updated, new DateTimeZone('UTC')));
240+
241+
return $category;
242+
}
243+
163244
private function loadSitemap(): SimpleXMLElement
164245
{
165246
$this->assertFileExists($this->sitemapFile);

0 commit comments

Comments
 (0)