diff --git a/bin/sitemap b/bin/sitemap
new file mode 100755
index 0000000..64981aa
--- /dev/null
+++ b/bin/sitemap
@@ -0,0 +1,23 @@
+#!/usr/bin/env php
+get(SitemapGenerator::class);
+
+$count = $sitemapGenerator->write();
+
+printf(
+ "Done. %d url%s written to %s%s",
+ $count,
+ $count === 1 ? '' : 's',
+ $sitemapGenerator->getSitemapFile(),
+ PHP_EOL
+);
diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist
index 40881f0..0b9acc5 100644
--- a/config/autoload/local.php.dist
+++ b/config/autoload/local.php.dist
@@ -64,11 +64,14 @@ return [
* scopes at all; without a token the generator still runs, at a lower rate limit.
* Non-credential settings (ignore list, output path) live in `packages.global.php`.
*/
- 'github' => [
+ 'github' => [
'userAgent' => 'dotkernel.com',
'authBearer' => '',
'org' => 'dotkernel',
],
+ 'sitemap' => [
+ 'path' => realpath(__DIR__ . '/../../public/sitemap.xml'),
+ ],
// `dotkernel-packages-oss-lifecycle` is intentionally absent: it has a dedicated handler
// and is routed in Light\App\RoutesDelegator. Re-adding it here registers the path twice.
'routes' => [
diff --git a/public/sitemap.xml b/public/sitemap.xml
new file mode 100644
index 0000000..e69de29
diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php
index 5fae52c..754779a 100644
--- a/src/App/src/ConfigProvider.php
+++ b/src/App/src/ConfigProvider.php
@@ -17,17 +17,21 @@
use Light\App\Factory\GetIndexViewHandlerFactory;
use Light\App\Factory\GetMarkdownArticleHandlerFactory;
use Light\App\Factory\GetPackagesViewHandlerFactory;
+use Light\App\Factory\GetSitemapViewHandlerFactory;
use Light\App\Factory\GitHubClientFactory;
use Light\App\Factory\PackageGeneratorFactory;
+use Light\App\Factory\SitemapGeneratorFactory;
use Light\App\Handler\GetFeedViewHandler;
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
use Light\App\Handler\GetPackagesViewHandler;
+use Light\App\Handler\GetSitemapViewHandler;
use Light\App\Resolver\EntityListenerResolver;
use Light\App\Service\FeedGenerator;
use Light\App\Service\GitHubClient;
use Light\App\Service\GitHubClientInterface;
use Light\App\Service\PackageGenerator;
+use Light\App\Service\SitemapGenerator;
use Mezzio\Application;
use Roave\PsrContainerDoctrine\EntityManagerFactory;
use Symfony\Component\Cache\Adapter\AdapterInterface;
@@ -128,8 +132,10 @@ public function getDependencies(): array
GetIndexViewHandler::class => GetIndexViewHandlerFactory::class,
GetFeedViewHandler::class => GetFeedViewHandlerFactory::class,
GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class,
+ GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class,
GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class,
FeedGenerator::class => FeedGeneratorFactory::class,
+ SitemapGenerator::class => SitemapGeneratorFactory::class,
GitHubClient::class => GitHubClientFactory::class,
PackageGenerator::class => PackageGeneratorFactory::class,
],
diff --git a/src/App/src/Factory/FeedGeneratorFactory.php b/src/App/src/Factory/FeedGeneratorFactory.php
index a05087a..154a5e5 100644
--- a/src/App/src/Factory/FeedGeneratorFactory.php
+++ b/src/App/src/Factory/FeedGeneratorFactory.php
@@ -9,7 +9,6 @@
use Psr\Container\ContainerInterface;
use function assert;
-use function rtrim;
class FeedGeneratorFactory
{
@@ -23,7 +22,7 @@ public function __invoke(ContainerInterface $container): FeedGenerator
return new FeedGenerator(
$postRepository,
$config['feed']['path'],
- rtrim($config['application']['baseUrl'] ?? '', '/') . '/',
+ $config['application']['baseUrl'] ?? '',
$config['application']['meta']['title'] ?? '',
$config['application']['meta']['description'] ?? '',
$config['application']['meta']['image'] ?? '',
diff --git a/src/App/src/Factory/GetSitemapViewHandlerFactory.php b/src/App/src/Factory/GetSitemapViewHandlerFactory.php
new file mode 100644
index 0000000..237f02a
--- /dev/null
+++ b/src/App/src/Factory/GetSitemapViewHandlerFactory.php
@@ -0,0 +1,33 @@
+get(TemplateRendererInterface::class);
+ assert($template instanceof TemplateRendererInterface);
+
+ $categoryRepository = $container->get(CategoryRepository::class);
+ assert($categoryRepository instanceof CategoryRepository);
+
+ $sitemapGenerator = $container->get(SitemapGenerator::class);
+ assert($sitemapGenerator instanceof SitemapGenerator);
+
+ return new GetSitemapViewHandler($template, $categoryRepository, $sitemapGenerator);
+ }
+}
diff --git a/src/App/src/Factory/SitemapGeneratorFactory.php b/src/App/src/Factory/SitemapGeneratorFactory.php
new file mode 100644
index 0000000..488706c
--- /dev/null
+++ b/src/App/src/Factory/SitemapGeneratorFactory.php
@@ -0,0 +1,28 @@
+get(PostRepository::class);
+ assert($postRepository instanceof PostRepository);
+
+ $config = $container->get('config');
+
+ return new SitemapGenerator(
+ $postRepository,
+ $config['sitemap']['path'],
+ $config['application']['baseUrl'] ?? '',
+ );
+ }
+}
diff --git a/src/App/src/Handler/GetSitemapViewHandler.php b/src/App/src/Handler/GetSitemapViewHandler.php
new file mode 100644
index 0000000..fffdd17
--- /dev/null
+++ b/src/App/src/Handler/GetSitemapViewHandler.php
@@ -0,0 +1,66 @@
+sitemapGenerator->getSitemapFile();
+
+ if (! is_file($sitemapFile) || filesize($sitemapFile) === 0) {
+ try {
+ $this->sitemapGenerator->write();
+ } catch (Throwable) {
+ }
+ }
+
+ if (! is_file($sitemapFile) || filesize($sitemapFile) === 0) {
+ return $this->notFound($this->categoryRepository->getCategories());
+ }
+
+ return new XmlResponse(
+ (string) file_get_contents($sitemapFile),
+ StatusCodeInterface::STATUS_OK,
+ ['Content-Type' => SitemapGenerator::CONTENT_TYPE]
+ );
+ }
+
+ /**
+ * @param Category[] $categories
+ */
+ private function notFound(array $categories): HtmlResponse
+ {
+ return new HtmlResponse(
+ $this->template->render('error::404', [
+ 'categories' => $categories,
+ ]),
+ StatusCodeInterface::STATUS_NOT_FOUND
+ );
+ }
+}
diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php
index 613b0c3..fd61ac2 100644
--- a/src/App/src/RoutesDelegator.php
+++ b/src/App/src/RoutesDelegator.php
@@ -9,6 +9,7 @@
use Light\App\Handler\GetIndexViewHandler;
use Light\App\Handler\GetMarkdownArticleHandler;
use Light\App\Handler\GetPackagesViewHandler;
+use Light\App\Handler\GetSitemapViewHandler;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
@@ -22,6 +23,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal
assert($app instanceof Application);
$app->get('/', [GetIndexViewHandler::class], 'app::index');
$app->get('/feed/', [GetFeedViewHandler::class], 'app::feed');
+ $app->get('/sitemap/', [GetSitemapViewHandler::class], 'app::sitemap');
$app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article');
// Route name kept as `page::…` because `@layout/default.html.twig` links it by name.
diff --git a/src/App/src/Service/FeedGenerator.php b/src/App/src/Service/FeedGenerator.php
index 7b2b7be..c99de8e 100644
--- a/src/App/src/Service/FeedGenerator.php
+++ b/src/App/src/Service/FeedGenerator.php
@@ -55,7 +55,7 @@ public function write(): int
$this->appendText($dom, $channel, 'lastBuildDate', (new DateTimeImmutable())->format(DateTimeInterface::RSS));
foreach ($posts as $post) {
- $link = $this->baseUrl . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/';
+ $link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/';
$item = $dom->createElement('item');
$channel->appendChild($item);
diff --git a/src/App/src/Service/SitemapGenerator.php b/src/App/src/Service/SitemapGenerator.php
new file mode 100644
index 0000000..9fdbd82
--- /dev/null
+++ b/src/App/src/Service/SitemapGenerator.php
@@ -0,0 +1,74 @@
+sitemapFile;
+ }
+
+ public function write(): int
+ {
+ $posts = $this->postRepository->getPublishedPosts();
+
+ $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);
+
+ foreach ($posts as $post) {
+ $link = $this->baseUrl . '/' . $post->getCategory()->getSlug() . '/' . $post->getSlug() . '/';
+ $this->appendUrl($dom, $urlset, $link, $post->getPostDate()->format(DateTimeInterface::W3C));
+ }
+
+ if ($dom->save($this->sitemapFile) === false) {
+ throw new RuntimeException('Unable to write sitemap.');
+ }
+
+ return count($posts) + 1;
+ }
+
+ private function appendUrl(DOMDocument $dom, DOMElement $urlset, string $loc, ?string $lastmod = null): void
+ {
+ $url = $dom->createElement('url');
+ $urlset->appendChild($url);
+
+ $this->appendText($dom, $url, 'loc', $loc);
+ if ($lastmod !== null) {
+ $this->appendText($dom, $url, 'lastmod', $lastmod);
+ }
+ }
+
+ private function appendText(DOMDocument $dom, DOMElement $parent, string $name, string $text): void
+ {
+ $el = $dom->createElement($name);
+ $el->appendChild($dom->createTextNode($text));
+ $parent->appendChild($el);
+ }
+}
diff --git a/src/App/templates/JSON-LD/index.jsonld.twig b/src/App/templates/JSON-LD/index.jsonld.twig
index 704e37d..50396ef 100644
--- a/src/App/templates/JSON-LD/index.jsonld.twig
+++ b/src/App/templates/JSON-LD/index.jsonld.twig
@@ -4,7 +4,7 @@
"@graph": [
{
"@type": "WebSite",
- "@id": "{{ absolute_url('/') }}#website",
+ "@id": "{{ absolute_url('/') }}#website ",
"url": "{{ absolute_url('/') }}",
"name": "Dotkernel",
"description": "Dotkernel is a collection of open-source application skeletons built on Mezzio and Laminas - pre-configured and ready for anything from a presentation site to an enterprise-grade API.",
diff --git a/test/Unit/App/Handler/GetFeedViewHandlerTest.php b/test/Unit/App/Handler/GetFeedViewHandlerTest.php
new file mode 100644
index 0000000..0990bb0
--- /dev/null
+++ b/test/Unit/App/Handler/GetFeedViewHandlerTest.php
@@ -0,0 +1,174 @@
+feedFile = sprintf(
+ '%s%slight-feed-view-%s%sfeed.xml',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ DIRECTORY_SEPARATOR
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->feedFile)) {
+ unlink($this->feedFile);
+ }
+
+ $directory = dirname($this->feedFile);
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleServesTheExistingFileWithoutRegenerating(): void
+ {
+ $this->writeFeedFile('cached');
+
+ $feedGenerator = $this->createMock(FeedGenerator::class);
+ $feedGenerator->method('getFeedFile')->willReturn($this->feedFile);
+ $feedGenerator->expects($this->never())->method('write');
+
+ $response = $this->createHandler($feedGenerator)->handle(new ServerRequest());
+
+ $this->assertInstanceOf(XmlResponse::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame(FeedGenerator::CONTENT_TYPE, $response->getHeaderLine('Content-Type'));
+ $this->assertSame('cached', (string) $response->getBody());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleGeneratesTheFeedWhenTheFileIsMissing(): void
+ {
+ $feedGenerator = $this->createMock(FeedGenerator::class);
+ $feedGenerator->method('getFeedFile')->willReturn($this->feedFile);
+ $feedGenerator->expects($this->once())->method('write')
+ ->willReturnCallback(function (): int {
+ $this->writeFeedFile('fresh');
+ return 1;
+ });
+
+ $response = $this->createHandler($feedGenerator)->handle(new ServerRequest());
+
+ $this->assertInstanceOf(XmlResponse::class, $response);
+ $this->assertSame('fresh', (string) $response->getBody());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleGeneratesTheFeedWhenTheExistingFileIsEmpty(): void
+ {
+ $this->writeFeedFile('');
+
+ $feedGenerator = $this->createMock(FeedGenerator::class);
+ $feedGenerator->method('getFeedFile')->willReturn($this->feedFile);
+ $feedGenerator->expects($this->once())->method('write')
+ ->willReturnCallback(function (): int {
+ $this->writeFeedFile('fresh');
+ return 1;
+ });
+
+ $this->createHandler($feedGenerator)->handle(new ServerRequest());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenGenerationFailsAndNoFileExists(): void
+ {
+ $feedGenerator = $this->createStub(FeedGenerator::class);
+ $feedGenerator->method('getFeedFile')->willReturn($this->feedFile);
+ $feedGenerator->method('write')->willThrowException(new RuntimeException('boom'));
+
+ $categories = [$this->createStub(Category::class)];
+ $categoryRepository = $this->createStub(CategoryRepository::class);
+ $categoryRepository->method('getCategories')->willReturn($categories);
+
+ $template = $this->createMock(TemplateRendererInterface::class);
+ $template->expects($this->once())->method('render')
+ ->willReturnCallback(function (string $name, mixed $parameters = []) use ($categories): string {
+ $this->assertSame('error::404', $name);
+ $this->assertIsArray($parameters);
+ $this->assertSame($categories, $parameters['categories']);
+
+ return 'not found';
+ });
+
+ $response = $this->createHandler($feedGenerator, $template, $categoryRepository)
+ ->handle(new ServerRequest());
+
+ $this->assertInstanceOf(HtmlResponse::class, $response);
+ $this->assertSame(404, $response->getStatusCode());
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createHandler(
+ FeedGenerator $feedGenerator,
+ ?TemplateRendererInterface $template = null,
+ ?CategoryRepository $categoryRepository = null,
+ ): GetFeedViewHandler {
+ return new GetFeedViewHandler(
+ $template ?? $this->createStub(TemplateRendererInterface::class),
+ $categoryRepository ?? $this->createStub(CategoryRepository::class),
+ $feedGenerator,
+ );
+ }
+
+ private function writeFeedFile(string $contents): void
+ {
+ $directory = dirname($this->feedFile);
+ if (! is_dir($directory)) {
+ mkdir($directory, 0775, true);
+ }
+
+ file_put_contents($this->feedFile, $contents);
+ }
+}
diff --git a/test/Unit/App/Handler/GetIndexViewHandlerTest.php b/test/Unit/App/Handler/GetIndexViewHandlerTest.php
new file mode 100644
index 0000000..3ebb14e
--- /dev/null
+++ b/test/Unit/App/Handler/GetIndexViewHandlerTest.php
@@ -0,0 +1,47 @@
+createStub(Post::class), $this->createStub(Post::class)];
+
+ $postRepository = $this->createMock(PostRepository::class);
+ $postRepository->expects($this->once())->method('getRecentPosts')->with(3)->willReturn($posts);
+
+ $captured = [];
+ $template = $this->createMock(TemplateRendererInterface::class);
+ $template->expects($this->once())->method('render')
+ ->willReturnCallback(function (string $name, mixed $parameters = []) use (&$captured): string {
+ $this->assertSame('app::index', $name);
+ $this->assertIsArray($parameters);
+ $captured = $parameters;
+
+ return '';
+ });
+
+ $handler = new GetIndexViewHandler($template, $postRepository);
+ $response = $handler->handle(new ServerRequest());
+
+ $this->assertSame($posts, $captured['posts']);
+ $this->assertInstanceOf(HtmlResponse::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('', (string) $response->getBody());
+ }
+}
diff --git a/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php b/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php
new file mode 100644
index 0000000..ba87b2e
--- /dev/null
+++ b/test/Unit/App/Handler/GetMarkdownArticleHandlerTest.php
@@ -0,0 +1,160 @@
+articlesPath = sprintf(
+ '%s%slight-articles-%s',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ );
+
+ mkdir($this->articlesPath . DIRECTORY_SEPARATOR . 'news', 0775, true);
+ file_put_contents(
+ $this->articlesPath . DIRECTORY_SEPARATOR . 'news' . DIRECTORY_SEPARATOR . 'a-post.md',
+ '# A post'
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDirectory($this->articlesPath);
+
+ parent::tearDown();
+ }
+
+ public function testHandleReturnsTheMarkdownFileContents(): void
+ {
+ $response = $this->createHandler()->handle($this->request('news', 'a-post'));
+
+ $this->assertInstanceOf(TextResponse::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type'));
+ $this->assertSame('# A post', (string) $response->getBody());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenTheCategorySlugIsMissing(): void
+ {
+ $this->assertNotFound($this->createHandler()->handle($this->request('', 'a-post')));
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenTheSlugIsMissing(): void
+ {
+ $this->assertNotFound($this->createHandler()->handle($this->request('news', '')));
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenTheFileDoesNotExist(): void
+ {
+ $this->assertNotFound($this->createHandler()->handle($this->request('news', 'missing-post')));
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenTheCategoryDoesNotExist(): void
+ {
+ $this->assertNotFound($this->createHandler()->handle($this->request('unknown-category', 'a-post')));
+ }
+
+ /**
+ * A slug containing ".." must never escape the configured articles directory.
+ *
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenTheSlugAttemptsToEscapeTheArticlesDirectory(): void
+ {
+ $this->assertNotFound($this->createHandler()->handle($this->request('news', '../../etc/passwd')));
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createHandler(): GetMarkdownArticleHandler
+ {
+ $categoryRepository = $this->createStub(CategoryRepository::class);
+ $categoryRepository->method('getCategories')->willReturn([$this->createStub(Category::class)]);
+
+ $template = $this->createStub(TemplateRendererInterface::class);
+ $template->method('render')->willReturn('not found');
+
+ return new GetMarkdownArticleHandler($template, $categoryRepository, $this->articlesPath);
+ }
+
+ private function request(string $categorySlug, string $slug): ServerRequest
+ {
+ return (new ServerRequest())
+ ->withAttribute('categorySlug', $categorySlug)
+ ->withAttribute('slug', $slug);
+ }
+
+ private function assertNotFound(mixed $response): void
+ {
+ $this->assertInstanceOf(HtmlResponse::class, $response);
+ $this->assertSame(404, $response->getStatusCode());
+ }
+
+ private function removeDirectory(string $path): void
+ {
+ if (! is_dir($path)) {
+ return;
+ }
+
+ $items = scandir($path);
+ foreach ($items as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+
+ $itemPath = $path . DIRECTORY_SEPARATOR . $item;
+ if (is_dir($itemPath)) {
+ $this->removeDirectory($itemPath);
+ } else {
+ unlink($itemPath);
+ }
+ }
+
+ rmdir($path);
+ }
+}
diff --git a/test/Unit/App/Handler/GetSitemapViewHandlerTest.php b/test/Unit/App/Handler/GetSitemapViewHandlerTest.php
new file mode 100644
index 0000000..87c9bac
--- /dev/null
+++ b/test/Unit/App/Handler/GetSitemapViewHandlerTest.php
@@ -0,0 +1,156 @@
+sitemapFile = sprintf(
+ '%s%slight-sitemap-view-%s%ssitemap.xml',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ DIRECTORY_SEPARATOR
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->sitemapFile)) {
+ unlink($this->sitemapFile);
+ }
+
+ $directory = dirname($this->sitemapFile);
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleServesTheExistingFileWithoutRegenerating(): void
+ {
+ $this->writeSitemapFile('cached');
+
+ $sitemapGenerator = $this->createMock(SitemapGenerator::class);
+ $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile);
+ $sitemapGenerator->expects($this->never())->method('write');
+
+ $response = $this->createHandler($sitemapGenerator)->handle(new ServerRequest());
+
+ $this->assertInstanceOf(XmlResponse::class, $response);
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame(SitemapGenerator::CONTENT_TYPE, $response->getHeaderLine('Content-Type'));
+ $this->assertSame('cached', (string) $response->getBody());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleGeneratesTheSitemapWhenTheFileIsMissing(): void
+ {
+ $sitemapGenerator = $this->createMock(SitemapGenerator::class);
+ $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile);
+ $sitemapGenerator->expects($this->once())->method('write')
+ ->willReturnCallback(function (): int {
+ $this->writeSitemapFile('fresh');
+ return 1;
+ });
+
+ $response = $this->createHandler($sitemapGenerator)->handle(new ServerRequest());
+
+ $this->assertInstanceOf(XmlResponse::class, $response);
+ $this->assertSame('fresh', (string) $response->getBody());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleReturns404WhenGenerationFailsAndNoFileExists(): void
+ {
+ $sitemapGenerator = $this->createStub(SitemapGenerator::class);
+ $sitemapGenerator->method('getSitemapFile')->willReturn($this->sitemapFile);
+ $sitemapGenerator->method('write')->willThrowException(new RuntimeException('boom'));
+
+ $categories = [$this->createStub(Category::class)];
+ $categoryRepository = $this->createStub(CategoryRepository::class);
+ $categoryRepository->method('getCategories')->willReturn($categories);
+
+ $template = $this->createMock(TemplateRendererInterface::class);
+ $template->expects($this->once())->method('render')
+ ->willReturnCallback(function (string $name, mixed $parameters = []) use ($categories): string {
+ $this->assertSame('error::404', $name);
+ $this->assertIsArray($parameters);
+ $this->assertSame($categories, $parameters['categories']);
+
+ return 'not found';
+ });
+
+ $response = $this->createHandler($sitemapGenerator, $template, $categoryRepository)
+ ->handle(new ServerRequest());
+
+ $this->assertInstanceOf(HtmlResponse::class, $response);
+ $this->assertSame(404, $response->getStatusCode());
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createHandler(
+ SitemapGenerator $sitemapGenerator,
+ ?TemplateRendererInterface $template = null,
+ ?CategoryRepository $categoryRepository = null,
+ ): GetSitemapViewHandler {
+ return new GetSitemapViewHandler(
+ $template ?? $this->createStub(TemplateRendererInterface::class),
+ $categoryRepository ?? $this->createStub(CategoryRepository::class),
+ $sitemapGenerator,
+ );
+ }
+
+ private function writeSitemapFile(string $contents): void
+ {
+ $directory = dirname($this->sitemapFile);
+ if (! is_dir($directory)) {
+ mkdir($directory, 0775, true);
+ }
+
+ file_put_contents($this->sitemapFile, $contents);
+ }
+}
diff --git a/test/Unit/App/Helper/PaginatorTest.php b/test/Unit/App/Helper/PaginatorTest.php
new file mode 100644
index 0000000..8f03147
--- /dev/null
+++ b/test/Unit/App/Helper/PaginatorTest.php
@@ -0,0 +1,225 @@
+assertSame([
+ 'offset' => 0,
+ 'limit' => 10,
+ 'page' => 1,
+ 'sort' => 'title',
+ 'dir' => 'desc',
+ ], Paginator::getParams([], 'title'));
+ }
+
+ public function testGetParamsUsesTheGivenDefaultDir(): void
+ {
+ $this->assertSame('asc', Paginator::getParams([], 'title', 'asc')['dir']);
+ }
+
+ public function testGetParamsOverridesSortWhenProvided(): void
+ {
+ $this->assertSame('name', Paginator::getParams(['sort' => 'name'], 'title')['sort']);
+ }
+
+ public function testGetParamsIgnoresAnEmptySort(): void
+ {
+ $this->assertSame('title', Paginator::getParams(['sort' => ''], 'title')['sort']);
+ }
+
+ public function testGetParamsIgnoresANonStringSort(): void
+ {
+ $this->assertSame('title', Paginator::getParams(['sort' => 123], 'title')['sort']);
+ }
+
+ public function testGetParamsOverridesDirWhenValid(): void
+ {
+ $this->assertSame('asc', Paginator::getParams(['dir' => 'asc'], 'title')['dir']);
+ }
+
+ public function testGetParamsIgnoresAnInvalidDir(): void
+ {
+ $this->assertSame('desc', Paginator::getParams(['dir' => 'sideways'], 'title')['dir']);
+ }
+
+ public function testGetParamsWithAllSetsALargeLimitAndIgnoresPagingParams(): void
+ {
+ $result = Paginator::getParams(
+ ['all' => '1', 'limit' => 5, 'offset' => 20, 'page' => 3],
+ 'title'
+ );
+
+ $this->assertSame([
+ 'offset' => 0,
+ 'limit' => 1_000,
+ 'page' => 1,
+ 'sort' => 'title',
+ 'dir' => 'desc',
+ ], $result);
+ }
+
+ public function testGetParamsUsesTheGivenLimit(): void
+ {
+ $this->assertSame(25, Paginator::getParams(['limit' => 25], 'title')['limit']);
+ }
+
+ public function testGetParamsIgnoresANonPositiveLimit(): void
+ {
+ $this->assertSame(10, Paginator::getParams(['limit' => 0], 'title')['limit']);
+ $this->assertSame(10, Paginator::getParams(['limit' => -5], 'title')['limit']);
+ }
+
+ public function testGetParamsComputesThePageFromAnEvenlyDivisibleOffset(): void
+ {
+ $result = Paginator::getParams(['limit' => 10, 'offset' => 20], 'title');
+
+ $this->assertSame(20, $result['offset']);
+ $this->assertSame(3, $result['page']);
+ }
+
+ /**
+ * The implementation does not round the derived page, so an offset that is not a multiple
+ * of the limit produces a fractional page.
+ */
+ public function testGetParamsProducesAFractionalPageForAnUnevenOffset(): void
+ {
+ $result = Paginator::getParams(['limit' => 10, 'offset' => 5], 'title');
+
+ $this->assertSame(1.5, $result['page']);
+ }
+
+ public function testGetParamsIgnoresANonPositiveOffset(): void
+ {
+ $this->assertSame(0, Paginator::getParams(['offset' => -5], 'title')['offset']);
+ }
+
+ public function testGetParamsComputesTheOffsetFromThePage(): void
+ {
+ $result = Paginator::getParams(['limit' => 10, 'page' => 3], 'title');
+
+ $this->assertSame(3, $result['page']);
+ $this->assertSame(20, $result['offset']);
+ }
+
+ public function testGetParamsIgnoresANonPositivePage(): void
+ {
+ $this->assertSame(1, Paginator::getParams(['page' => 0], 'title')['page']);
+ }
+
+ public function testGetParamsPageOverridesAnOffsetGivenAlongsideIt(): void
+ {
+ $result = Paginator::getParams(['limit' => 10, 'offset' => 50, 'page' => 2], 'title');
+
+ $this->assertSame(2, $result['page']);
+ $this->assertSame(10, $result['offset']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWrapperAddsCountItemsAndFilters(): void
+ {
+ $paginator = $this->createPaginator(5, ['a', 'b']);
+
+ $result = Paginator::wrapper(
+ $paginator,
+ ['offset' => 0, 'limit' => 10, 'page' => 1, 'sort' => 'title', 'dir' => 'desc'],
+ ['status' => 'active']
+ );
+
+ $this->assertSame(5, $result['count']);
+ $this->assertSame(['a', 'b'], $result['items']);
+ $this->assertSame(['status' => 'active'], $result['filters']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWrapperComputesPaginationMetadataForAMiddlePage(): void
+ {
+ $paginator = $this->createPaginator(25, []);
+
+ $result = Paginator::wrapper(
+ $paginator,
+ ['offset' => 10, 'limit' => 10, 'page' => 2, 'sort' => 'title', 'dir' => 'desc']
+ );
+
+ $this->assertSame(2, $result['currentPage']);
+ $this->assertSame(1, $result['firstPage']);
+ $this->assertSame(1, $result['previousPage']);
+ $this->assertSame(3, $result['lastPage']);
+ $this->assertFalse($result['isOutOfBounds']);
+ $this->assertSame(3, $result['nextPage']);
+ $this->assertFalse($result['isFirstPage']);
+ $this->assertFalse($result['isLastPage']);
+ $this->assertTrue($result['hasPreviousPage']);
+ $this->assertTrue($result['hasNextPage']);
+ $this->assertSame([1, 2, 3], $result['pages']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWrapperMarksOutOfBoundsWhenCurrentPageExceedsLastPage(): void
+ {
+ $paginator = $this->createPaginator(5, []);
+
+ $result = Paginator::wrapper(
+ $paginator,
+ ['offset' => 100, 'limit' => 10, 'page' => 11, 'sort' => 'title', 'dir' => 'desc']
+ );
+
+ $this->assertTrue($result['isOutOfBounds']);
+ $this->assertTrue($result['isLastPage']);
+ $this->assertFalse($result['hasNextPage']);
+ $this->assertSame(1, $result['lastPage']);
+ $this->assertSame(1, $result['previousPage']);
+ $this->assertSame([1], $result['pages']);
+ $this->assertSame(0, $result['previousOffset']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWrapperHandlesZeroResults(): void
+ {
+ $paginator = $this->createPaginator(0, []);
+
+ $result = Paginator::wrapper(
+ $paginator,
+ ['offset' => 0, 'limit' => 10, 'page' => 1, 'sort' => 'title', 'dir' => 'desc']
+ );
+
+ $this->assertSame(1, $result['lastPage']);
+ $this->assertTrue($result['isLastPage']);
+ $this->assertFalse($result['hasNextPage']);
+ }
+
+ /**
+ * @param list $items
+ * @return DoctrinePaginator
+ * @throws Exception
+ */
+ private function createPaginator(int $count, array $items): DoctrinePaginator
+ {
+ $query = $this->createStub(Query::class);
+ $query->method('getResult')->willReturn($items);
+
+ $paginator = $this->createStub(DoctrinePaginator::class);
+ $paginator->method('count')->willReturn($count);
+ $paginator->method('getQuery')->willReturn($query);
+
+ return $paginator;
+ }
+}
diff --git a/test/Unit/App/Repository/AbstractRepositoryTest.php b/test/Unit/App/Repository/AbstractRepositoryTest.php
new file mode 100644
index 0000000..e3170a7
--- /dev/null
+++ b/test/Unit/App/Repository/AbstractRepositoryTest.php
@@ -0,0 +1,86 @@
+createStub(QueryBuilder::class);
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects($this->once())->method('createQueryBuilder')->willReturn($queryBuilder);
+
+ $repository = $this->createRepository($entityManager);
+
+ $this->assertSame($queryBuilder, $repository->getQueryBuilder());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testSaveResourcePersistsThenFlushes(): void
+ {
+ $resource = $this->createStub(EntityInterface::class);
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $calls = [];
+ $entityManager->expects($this->once())->method('persist')
+ ->willReturnCallback(function (object $entity) use (&$calls, $resource): void {
+ $this->assertSame($resource, $entity);
+ $calls[] = 'persist';
+ });
+ $entityManager->expects($this->once())->method('flush')
+ ->willReturnCallback(function () use (&$calls): void {
+ $calls[] = 'flush';
+ });
+
+ $this->createRepository($entityManager)->saveResource($resource);
+
+ $this->assertSame(['persist', 'flush'], $calls);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testDeleteResourceRemovesThenFlushes(): void
+ {
+ $resource = $this->createStub(EntityInterface::class);
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+
+ $calls = [];
+ $entityManager->expects($this->once())->method('remove')
+ ->willReturnCallback(function (object $entity) use (&$calls, $resource): void {
+ $this->assertSame($resource, $entity);
+ $calls[] = 'remove';
+ });
+ $entityManager->expects($this->once())->method('flush')
+ ->willReturnCallback(function () use (&$calls): void {
+ $calls[] = 'flush';
+ });
+
+ $this->createRepository($entityManager)->deleteResource($resource);
+
+ $this->assertSame(['remove', 'flush'], $calls);
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createRepository(EntityManagerInterface $entityManager): AbstractRepository
+ {
+ return new AbstractRepository($entityManager, new ClassMetadata(EntityInterface::class));
+ }
+}
diff --git a/test/Unit/App/Service/FeedGeneratorTest.php b/test/Unit/App/Service/FeedGeneratorTest.php
new file mode 100644
index 0000000..1e48e6d
--- /dev/null
+++ b/test/Unit/App/Service/FeedGeneratorTest.php
@@ -0,0 +1,232 @@
+feedFile = sprintf(
+ '%s%slight-feed-%s%sfeed.xml',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ DIRECTORY_SEPARATOR
+ );
+
+ mkdir(dirname($this->feedFile), 0775, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->feedFile)) {
+ unlink($this->feedFile);
+ }
+
+ $directory = dirname($this->feedFile);
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+
+ parent::tearDown();
+ }
+
+ public function testGetFeedFileReturnsTheConfiguredPath(): void
+ {
+ $this->assertSame($this->feedFile, $this->createGenerator([])->getFeedFile());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteReturnsTheNumberOfPostsWritten(): void
+ {
+ $generator = $this->createGenerator([
+ $this->createPost('First post', 'first-post', 'news'),
+ $this->createPost('Second post', 'second-post', 'news'),
+ ]);
+
+ $this->assertSame(2, $generator->write());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteProducesAChannelWithNoItemsWhenThereAreNoPosts(): void
+ {
+ $this->createGenerator([])->write();
+
+ $xml = $this->loadFeed();
+
+ $this->assertSame('Light Blog', (string) $xml->channel->title);
+ $this->assertSame('https://example.test', (string) $xml->channel->link);
+ $this->assertCount(0, $xml->channel->item);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteFallsBackToTheExcerptWhenTlDrIsMissing(): void
+ {
+ $post = $this->createPost('A post', 'a-post', 'news', tlDr: null, excerpt: 'The excerpt');
+ $this->createGenerator([$post])->write();
+
+ $this->assertSame('The excerpt', (string) $this->loadFeed()->channel->item->description);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWritePrefersTlDrOverTheExcerptWhenBothArePresent(): void
+ {
+ $post = $this->createPost('A post', 'a-post', 'news', tlDr: 'The tl;dr', excerpt: 'The excerpt');
+ $this->createGenerator([$post])->write();
+
+ $this->assertSame('The tl;dr', (string) $this->loadFeed()->channel->item->description);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteBuildsTheItemLinkFromCategoryAndPostSlugs(): void
+ {
+ $post = $this->createPost('A post', 'a-post', 'news');
+ $this->createGenerator([$post])->write();
+
+ $this->assertSame('https://example.test/news/a-post/', (string) $this->loadFeed()->channel->item->link);
+ $this->assertSame('https://example.test/news/a-post/', (string) $this->loadFeed()->channel->item->guid);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteUsesTheConfiguredFallbackImageWhenThePostHasNone(): void
+ {
+ $post = $this->createPost('A post', 'a-post', 'news', openGraphImage: null);
+ $this->createGenerator([$post], image: 'https://example.test/default.png')->write();
+
+ $xml = $this->loadFeed();
+ $media = $xml->channel->item->children('media', true)->content;
+
+ $this->assertSame('https://example.test/default.png', (string) $media->attributes()['url']);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteQualifiesThePostsOwnOpenGraphImageWithTheBaseUrl(): void
+ {
+ $post = $this->createPost('A post', 'a-post', 'news', openGraphImage: '/uploads/a-post.png');
+ $this->createGenerator([$post])->write();
+
+ $xml = $this->loadFeed();
+ $media = $xml->channel->item->children('media', true)->content;
+
+ $this->assertSame('https://example.test/uploads/a-post.png', (string) $media->attributes()['url']);
+ }
+
+ /**
+ * DOMDocument::save() emits a native PHP warning on failure in addition to the return value
+ * this method already checks; suppress it so it doesn't surface as a spurious test warning.
+ *
+ * @throws Exception
+ */
+ public function testWriteThrowsWhenTheFeedFileCannotBeWritten(): void
+ {
+ $generator = $this->createGenerator([], feedFile: '/nonexistent-directory/feed.xml');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unable to write RSS feed.');
+
+ @$generator->write();
+ }
+
+ /**
+ * @param list $posts
+ * @throws Exception
+ */
+ private function createGenerator(
+ array $posts,
+ ?string $feedFile = null,
+ string $image = 'https://example.test/default.png',
+ ): FeedGenerator {
+ $postRepository = $this->createStub(PostRepository::class);
+ $postRepository->method('getPublishedPosts')->willReturn($posts);
+
+ return new FeedGenerator(
+ $postRepository,
+ $feedFile ?? $this->feedFile,
+ 'https://example.test',
+ 'Light Blog',
+ 'A blog built with Dotkernel Light',
+ $image,
+ );
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createPost(
+ string $title,
+ string $slug,
+ string $categorySlug,
+ ?string $tlDr = null,
+ string $excerpt = 'An excerpt',
+ ?string $openGraphImage = null,
+ ): Post {
+ $category = $this->createStub(Category::class);
+ $category->method('getSlug')->willReturn($categorySlug);
+
+ $post = $this->createStub(Post::class);
+ $post->method('getTitle')->willReturn($title);
+ $post->method('getSlug')->willReturn($slug);
+ $post->method('getCategory')->willReturn($category);
+ $post->method('getTlDr')->willReturn($tlDr);
+ $post->method('getExcerpt')->willReturn($excerpt);
+ $post->method('getPostDate')->willReturn(new DateTimeImmutable('2026-08-01 10:00:00'));
+ $post->method('getUpdatedFormatted')->willReturn(null);
+ $post->method('getOpenGraphImage')->willReturn($openGraphImage);
+
+ return $post;
+ }
+
+ private function loadFeed(): SimpleXMLElement
+ {
+ $this->assertFileExists($this->feedFile);
+
+ $xml = simplexml_load_file($this->feedFile);
+ $this->assertInstanceOf(SimpleXMLElement::class, $xml);
+
+ return $xml;
+ }
+}
diff --git a/test/Unit/App/Service/SitemapGeneratorTest.php b/test/Unit/App/Service/SitemapGeneratorTest.php
new file mode 100644
index 0000000..7ffc34b
--- /dev/null
+++ b/test/Unit/App/Service/SitemapGeneratorTest.php
@@ -0,0 +1,172 @@
+sitemapFile = sprintf(
+ '%s%slight-sitemap-%s%ssitemap.xml',
+ sys_get_temp_dir(),
+ DIRECTORY_SEPARATOR,
+ bin2hex(random_bytes(8)),
+ DIRECTORY_SEPARATOR
+ );
+
+ mkdir(dirname($this->sitemapFile), 0775, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_file($this->sitemapFile)) {
+ unlink($this->sitemapFile);
+ }
+
+ $directory = dirname($this->sitemapFile);
+ if (is_dir($directory)) {
+ rmdir($directory);
+ }
+
+ parent::tearDown();
+ }
+
+ public function testGetSitemapFileReturnsTheConfiguredPath(): void
+ {
+ $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
+ {
+ $generator = $this->createGenerator([
+ $this->createPost('first-post', 'news'),
+ $this->createPost('second-post', 'news'),
+ ]);
+
+ $this->assertSame(3, $generator->write());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteAlwaysIncludesTheHomepageEvenWithoutPosts(): void
+ {
+ $generator = $this->createGenerator([]);
+
+ $this->assertSame(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);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testWriteAddsOneUrlEntryPerPostWithACategoryQualifiedLink(): void
+ {
+ $post = $this->createPost('a-post', 'news', '2026-08-01 10:00:00');
+ $this->createGenerator([$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);
+ }
+
+ /**
+ * DOMDocument::save() emits a native PHP warning on failure in addition to the return value
+ * this method already checks; suppress it so it doesn't surface as a spurious test warning.
+ *
+ * @throws Exception
+ */
+ public function testWriteThrowsWhenTheSitemapFileCannotBeWritten(): void
+ {
+ $generator = $this->createGenerator([], sitemapFile: '/nonexistent-directory/sitemap.xml');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unable to write sitemap.');
+
+ @$generator->write();
+ }
+
+ /**
+ * @param list $posts
+ * @throws Exception
+ */
+ private function createGenerator(array $posts, ?string $sitemapFile = null): SitemapGenerator
+ {
+ $postRepository = $this->createStub(PostRepository::class);
+ $postRepository->method('getPublishedPosts')->willReturn($posts);
+
+ return new SitemapGenerator(
+ $postRepository,
+ $sitemapFile ?? $this->sitemapFile,
+ 'https://example.test',
+ );
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function createPost(string $slug, string $categorySlug, string $postDate = '2026-08-01 10:00:00'): Post
+ {
+ $category = $this->createStub(Category::class);
+ $category->method('getSlug')->willReturn($categorySlug);
+
+ $post = $this->createStub(Post::class);
+ $post->method('getSlug')->willReturn($slug);
+ $post->method('getCategory')->willReturn($category);
+ $post->method('getPostDate')->willReturn(new DateTimeImmutable($postDate, new DateTimeZone('UTC')));
+
+ return $post;
+ }
+
+ private function loadSitemap(): SimpleXMLElement
+ {
+ $this->assertFileExists($this->sitemapFile);
+
+ $xml = simplexml_load_file($this->sitemapFile);
+ $this->assertInstanceOf(SimpleXMLElement::class, $xml);
+
+ return $xml;
+ }
+}