Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/App/src/Factory/SitemapGeneratorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
namespace Light\App\Factory;

use Light\App\Service\SitemapGenerator;
use Light\Blog\Repository\AuthorRepository;
use Light\Blog\Repository\CategoryRepository;
use Light\Blog\Repository\PostRepository;
use Psr\Container\ContainerInterface;

use function array_keys;
use function array_merge;
use function assert;
use function is_array;

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

$categoryRepository = $container->get(CategoryRepository::class);
assert($categoryRepository instanceof CategoryRepository);

$authorRepository = $container->get(AuthorRepository::class);
assert($authorRepository instanceof AuthorRepository);

$config = $container->get('config');

$pageRoutes = [];
foreach ($config['routes'] ?? [] as $moduleRoutes) {
if (is_array($moduleRoutes)) {
$pageRoutes = array_merge($pageRoutes, array_keys($moduleRoutes));
}
}

return new SitemapGenerator(
$postRepository,
$categoryRepository,
$authorRepository,
$pageRoutes,
$config['sitemap']['path'],
$config['application']['baseUrl'] ?? '',
);
Expand Down
59 changes: 54 additions & 5 deletions src/App/src/Service/SitemapGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,27 @@
use DateTimeInterface;
use DOMDocument;
use DOMElement;
use Light\Blog\Repository\AuthorRepository;
use Light\Blog\Repository\CategoryRepository;
use Light\Blog\Repository\PostRepository;
use RuntimeException;

use function count;
use function sprintf;

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

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

/**
* @param array<int, string> $pageRoutes
*/
public function __construct(
private readonly PostRepository $postRepository,
private readonly CategoryRepository $categoryRepository,
private readonly AuthorRepository $authorRepository,
private readonly array $pageRoutes,
private readonly string $sitemapFile,
private readonly string $baseUrl,
) {
Expand All @@ -32,26 +40,67 @@ public function getSitemapFile(): string

public function write(): int
{
$posts = $this->postRepository->getPublishedPosts();
$posts = $this->postRepository->getPublishedPosts();
$categories = $this->categoryRepository->getCategories();
$authors = $this->authorRepository->getAuthorsWithPublishedPosts();

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

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

$this->appendUrl($dom, $urlset, $this->baseUrl);
$count = 0;

$this->appendUrl($dom, $urlset, $this->baseUrl . '/');
$count++;

$this->appendUrl($dom, $urlset, $this->baseUrl . '/blog/');
$count++;

$this->appendUrl($dom, $urlset, $this->baseUrl . '/categories/');
$count++;

$this->appendUrl($dom, $urlset, $this->baseUrl . '/dotkernel-packages-oss-lifecycle/');
$count++;

foreach ($this->pageRoutes as $routeUri) {
$this->appendUrl($dom, $urlset, sprintf('%s/%s/', $this->baseUrl, $routeUri));
$count++;
}

foreach ($categories as $category) {
$lastmod = $category->getUpdated() ?? $category->getCreated();
$this->appendUrl(
$dom,
$urlset,
sprintf('%s/category/%s/', $this->baseUrl, $category->getSlug()),
$lastmod?->format(DateTimeInterface::W3C)
);
$count++;
}

foreach ($authors as $author) {
$this->appendUrl($dom, $urlset, sprintf('%s/author/%s/', $this->baseUrl, $author->getSlug()));
$count++;
}

foreach ($posts as $post) {
$link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/';
$link = sprintf(
'%s/%s/%s/',
$this->baseUrl,
$post->getCategory()->getSlug(),
$post->getSlug()
);
$this->appendUrl($dom, $urlset, $link, $post->getPostDate()->format(DateTimeInterface::W3C));
$count++;
}

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

return count($posts) + 1;
return $count;
}

private function appendUrl(DOMDocument $dom, DOMElement $urlset, string $loc, ?string $lastmod = null): void
Expand Down
27 changes: 25 additions & 2 deletions src/Blog/src/Repository/AuthorRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Light\App\Repository\AbstractRepository;
use Light\Blog\Entity\Author;
use Light\Blog\Entity\Post;
use Light\Blog\Enum\PostStatusEnum;

class AuthorRepository extends AbstractRepository
{
Expand All @@ -15,8 +17,29 @@ class AuthorRepository extends AbstractRepository
public function getAuthor(): array
{
$qb = $this->getQueryBuilder()
->select('author.name, author.slug')
->from(Author::class, 'authors');
->select('author')
->from(Author::class, 'author');

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

/**
* @return array<Author>
*/
public function getAuthorsWithPublishedPosts(): array
{
$publishedAuthorIds = $this->getQueryBuilder()
->select('publishedAuthor.id')
->from(Post::class, 'post')
->join('post.author', 'publishedAuthor')
->where('post.status = :published');

$qb = $this->getQueryBuilder()
->select('author')
->from(Author::class, 'author');

$qb->where($qb->expr()->in('author.id', $publishedAuthorIds->getDQL()))
->setParameter('published', PostStatusEnum::Published);

return $qb->getQuery()->getResult();
}
Expand Down
125 changes: 103 additions & 22 deletions test/Unit/App/Service/SitemapGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
use DateTimeImmutable;
use DateTimeZone;
use Light\App\Service\SitemapGenerator;
use Light\Blog\Entity\Author;
use Light\Blog\Entity\Category;
use Light\Blog\Entity\Post;
use Light\Blog\Repository\AuthorRepository;
use Light\Blog\Repository\CategoryRepository;
use Light\Blog\Repository\PostRepository;
use LightTest\Unit\UnitTest;
use PHPUnit\Framework\MockObject\Exception;
Expand All @@ -31,6 +34,9 @@

class SitemapGeneratorTest extends UnitTest
{
/** Homepage, /blog/, /categories/ and the packages-lifecycle page are always present. */
private const FIXED_URL_COUNT = 4;

private string $sitemapFile;

protected function setUp(): void
Expand Down Expand Up @@ -64,37 +70,76 @@ protected function tearDown(): void

public function testGetSitemapFileReturnsTheConfiguredPath(): void
{
$this->assertSame($this->sitemapFile, $this->createGenerator([])->getSitemapFile());
$this->assertSame($this->sitemapFile, $this->createGenerator()->getSitemapFile());
}

/**
* The count includes the homepage entry in addition to one entry per post.
*
* @throws Exception
*/
public function testWriteReturnsTheNumberOfPostsPlusTheHomepage(): void
public function testWriteAlwaysIncludesTheFixedPagesEvenWithoutAnyContent(): void
{
$generator = $this->createGenerator([
$this->createPost('first-post', 'news'),
$this->createPost('second-post', 'news'),
]);
$generator = $this->createGenerator();

$this->assertSame(self::FIXED_URL_COUNT, $generator->write());

$this->assertSame(3, $generator->write());
$urls = $this->loadSitemap()->url;
$this->assertCount(self::FIXED_URL_COUNT, $urls);
$this->assertSame('https://example.test/', (string) $urls[0]->loc);
$this->assertSame('https://example.test/blog/', (string) $urls[1]->loc);
$this->assertSame('https://example.test/categories/', (string) $urls[2]->loc);
$this->assertSame(
'https://example.test/dotkernel-packages-oss-lifecycle/',
(string) $urls[3]->loc
);
$this->assertCount(0, $urls[0]->lastmod);
}

/**
* @throws Exception
*/
public function testWriteAlwaysIncludesTheHomepageEvenWithoutPosts(): void
public function testWriteAddsOneUrlEntryPerConfiguredStaticPage(): void
{
$generator = $this->createGenerator([]);
$generator = $this->createGenerator(pageRoutes: ['contact']);

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

$urls = $this->loadSitemap()->url;
$this->assertCount(1, $urls);
$this->assertSame('https://example.test', (string) $urls[0]->loc);
$this->assertCount(0, $urls[0]->lastmod);
$this->assertSame('https://example.test/contact/', (string) $urls[self::FIXED_URL_COUNT]->loc);
}

/**
* @throws Exception
*/
public function testWriteAddsOneUrlEntryPerCategoryWithItsLastModifiedDate(): void
{
$category = $this->createCategory('news', '2026-08-01 10:00:00');
$generator = $this->createGenerator(categories: [$category]);

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

$urls = $this->loadSitemap()->url;
$this->assertSame('https://example.test/category/news/', (string) $urls[self::FIXED_URL_COUNT]->loc);
$this->assertSame(
'2026-08-01T10:00:00+00:00',
(string) $urls[self::FIXED_URL_COUNT]->lastmod
);
}

/**
* @throws Exception
*/
public function testWriteAddsOneUrlEntryPerAuthor(): void
{
$author = $this->createStub(Author::class);
$author->method('getSlug')->willReturn('jane-doe');

$generator = $this->createGenerator(authors: [$author]);

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

$urls = $this->loadSitemap()->url;
$this->assertSame('https://example.test/author/jane-doe/', (string) $urls[self::FIXED_URL_COUNT]->loc);
$this->assertCount(0, $urls[self::FIXED_URL_COUNT]->lastmod);
}

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

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

$this->assertCount(2, $urls);
$this->assertSame('https://example.test/news/a-post/', (string) $urls[1]->loc);
$this->assertSame('2026-08-01T10:00:00+00:00', (string) $urls[1]->lastmod);
$this->assertCount(self::FIXED_URL_COUNT + 1, $urls);
$this->assertSame(
'https://example.test/news/a-post/',
(string) $urls[self::FIXED_URL_COUNT]->loc
);
$this->assertSame(
'2026-08-01T10:00:00+00:00',
(string) $urls[self::FIXED_URL_COUNT]->lastmod
);
}

/**
Expand All @@ -120,7 +171,7 @@ public function testWriteAddsOneUrlEntryPerPostWithACategoryQualifiedLink(): voi
*/
public function testWriteThrowsWhenTheSitemapFileCannotBeWritten(): void
{
$generator = $this->createGenerator([], sitemapFile: '/nonexistent-directory/sitemap.xml');
$generator = $this->createGenerator(sitemapFile: '/nonexistent-directory/sitemap.xml');

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

/**
* @param list<Post> $posts
* @param list<Category> $categories
* @param list<Author> $authors
* @param list<string> $pageRoutes
* @throws Exception
*/
private function createGenerator(array $posts, ?string $sitemapFile = null): SitemapGenerator
{
private function createGenerator(
array $posts = [],
array $categories = [],
array $authors = [],
array $pageRoutes = [],
?string $sitemapFile = null,
): SitemapGenerator {
$postRepository = $this->createStub(PostRepository::class);
$postRepository->method('getPublishedPosts')->willReturn($posts);

$categoryRepository = $this->createStub(CategoryRepository::class);
$categoryRepository->method('getCategories')->willReturn($categories);

$authorRepository = $this->createStub(AuthorRepository::class);
$authorRepository->method('getAuthorsWithPublishedPosts')->willReturn($authors);

return new SitemapGenerator(
$postRepository,
$categoryRepository,
$authorRepository,
$pageRoutes,
$sitemapFile ?? $this->sitemapFile,
'https://example.test',
);
Expand All @@ -160,6 +228,19 @@ private function createPost(string $slug, string $categorySlug, string $postDate
return $post;
}

/**
* @throws Exception
*/
private function createCategory(string $slug, string $updated): Category
{
$category = $this->createStub(Category::class);
$category->method('getSlug')->willReturn($slug);
$category->method('getUpdated')->willReturn(new DateTimeImmutable($updated, new DateTimeZone('UTC')));
$category->method('getCreated')->willReturn(new DateTimeImmutable($updated, new DateTimeZone('UTC')));

return $category;
}

private function loadSitemap(): SimpleXMLElement
{
$this->assertFileExists($this->sitemapFile);
Expand Down