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.",