diff --git a/src/App/src/Fixture/AuthorLoader.php b/src/App/src/Fixture/AuthorLoader.php index c5b38808..63342794 100644 --- a/src/App/src/Fixture/AuthorLoader.php +++ b/src/App/src/Fixture/AuthorLoader.php @@ -7,14 +7,18 @@ use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Light\Blog\Entity\Author; +use Light\Blog\Entity\Post; use RuntimeException; use function file_get_contents; +use function html_entity_decode; use function json_decode; use function preg_replace; use function strtolower; use function trim; +use const ENT_QUOTES; + class AuthorLoader extends Fixture { public function load(ObjectManager $manager): void @@ -28,8 +32,9 @@ public function load(ObjectManager $manager): void $categories = json_decode($contents, true); - $repository = $manager->getRepository(Author::class); - $seenAuthorIds = []; + $authorRepository = $manager->getRepository(Author::class); + $postRepository = $manager->getRepository(Post::class); + $seenAuthorIds = []; foreach ($categories as $cat) { foreach ($cat['articles'] as $article) { @@ -38,16 +43,26 @@ public function load(ObjectManager $manager): void continue; } - $name = $authorData['display_name']; - if (isset($seenAuthorIds[$name])) { + $name = $authorData['display_name']; + $github = $authorData['github'] ?: null; + + $identityKey = $github ?? ('name:' . $name); + if (isset($seenAuthorIds[$identityKey])) { continue; } - $seenAuthorIds[$name] = true; + $seenAuthorIds[$identityKey] = true; - $github = $authorData['github'] ?: null; - $slug = $this->slugify($name); + $slug = $this->slugify($name); - $author = $repository->findOneBy(['name' => $name]); + $postTitle = html_entity_decode($article['post_title'] ?? '', ENT_QUOTES, 'UTF-8'); + $existingPost = $postTitle !== '' + ? $postRepository->findOneBy(['slug' => $this->slugify($postTitle)]) + : null; + + $author = $existingPost?->getAuthor() + ?? ($github !== null + ? $authorRepository->findOneBy(['github' => $github]) + : $authorRepository->findOneBy(['name' => $name])); if ($author === null) { $author = new Author(); @@ -58,6 +73,10 @@ public function load(ObjectManager $manager): void echo "CREATE: {$name}\n"; } else { $changed = false; + if ($author->getName() !== $name) { + $author->setName($name); + $changed = true; + } if ($author->getSlug() !== $slug) { $author->setSlug($slug); $changed = true; diff --git a/src/App/src/Fixture/PostTagLoader.php b/src/App/src/Fixture/PostTagLoader.php index abac8b50..8ff62a03 100644 --- a/src/App/src/Fixture/PostTagLoader.php +++ b/src/App/src/Fixture/PostTagLoader.php @@ -13,17 +13,22 @@ use RuntimeException; use function file_get_contents; +use function in_array; use function json_decode; class PostTagLoader extends Fixture implements DependentFixtureInterface { + public function __construct( + private readonly string $jsonFile = __DIR__ . '/articles_cleaned.json', + ) { + } + public function load(ObjectManager $manager): void { - $jsonFile = __DIR__ . '/articles_cleaned.json'; - $contents = file_get_contents($jsonFile); + $contents = file_get_contents($this->jsonFile); if ($contents === false) { - throw new RuntimeException("Unable to read file: {$jsonFile}"); + throw new RuntimeException("Unable to read file: {$this->jsonFile}"); } $categories = json_decode($contents, true); @@ -42,7 +47,11 @@ public function load(ObjectManager $manager): void /** @var Post $post */ $post = $this->getReference('post_' . $postIndex, Post::class); + $currentTagSlugs = []; + foreach ($articleData['tags'] ?? [] as $tagData) { + $currentTagSlugs[] = $tagData['slug']; + /** @var Tag $tag */ $tag = $this->getReference('tag_' . $tagData['slug'], Tag::class); @@ -58,6 +67,15 @@ public function load(ObjectManager $manager): void echo "UNCHANGED: {$post->getTitle()} - {$tag->getName()}\n"; } } + + foreach ($repository->findBy(['post' => $post]) as $existingPostTag) { + if (in_array($existingPostTag->getTag()->getSlug(), $currentTagSlugs, true)) { + continue; + } + + echo "REMOVE: {$post->getTitle()} - {$existingPostTag->getTag()->getName()}\n"; + $manager->remove($existingPostTag); + } } } diff --git a/src/App/src/Fixture/articles_cleaned.json b/src/App/src/Fixture/articles_cleaned.json index 83bd6d5b..7529fd29 100644 --- a/src/App/src/Fixture/articles_cleaned.json +++ b/src/App/src/Fixture/articles_cleaned.json @@ -9,7 +9,7 @@ "post_date": "2010-03-10 16:56:24", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "excerpt": "Dotkernel borrows the database naming conventions from FaZend: Rules of naming of database tables and columns. FaZend is an open-source PHP framework based on Zend Framework.", @@ -23,7 +23,7 @@ "post_date": "2010-03-12 19:40:36", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -37,7 +37,7 @@ "post_date": "2010-04-28 04:08:15", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -163,7 +163,7 @@ "post_date": "2011-06-15 06:14:08", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -218,7 +218,7 @@ "post_date": "2011-06-08 20:29:04", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -260,7 +260,7 @@ "post_date": "2011-07-28 19:03:38", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -288,7 +288,7 @@ "post_date": "2011-11-22 10:47:56", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -302,7 +302,7 @@ "post_date": "2012-02-16 10:58:17", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -316,7 +316,7 @@ "post_date": "2012-03-02 11:08:16", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -330,7 +330,7 @@ "post_date": "2012-03-22 12:26:18", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -344,7 +344,7 @@ "post_date": "2012-04-07 23:49:34", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -358,7 +358,7 @@ "post_date": "2012-04-20 11:27:16", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -372,7 +372,7 @@ "post_date": "2012-05-09 10:28:05", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -414,7 +414,7 @@ "post_date": "2012-05-25 18:36:47", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": true, @@ -428,7 +428,7 @@ "post_date": "2012-06-18 13:44:26", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -442,7 +442,7 @@ "post_date": "2013-06-25 12:42:19", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -456,7 +456,7 @@ "post_date": "2013-07-02 18:55:09", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -484,7 +484,7 @@ "post_date": "2013-07-31 17:16:27", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -498,7 +498,7 @@ "post_date": "2013-11-29 11:09:25", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -512,7 +512,7 @@ "post_date": "2014-06-06 11:54:56", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -666,7 +666,7 @@ "post_date": "2016-05-11 11:30:20", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -812,7 +812,7 @@ "post_date": "2008-03-28 15:44:51", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -826,7 +826,7 @@ "post_date": "2010-01-28 10:41:05", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -840,7 +840,7 @@ "post_date": "2009-10-02 16:08:47", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -854,7 +854,7 @@ "post_date": "2009-10-02 16:21:27", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1055,7 +1055,7 @@ "post_date": "2008-10-03 18:54:37", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1093,7 +1093,7 @@ "post_date": "2011-06-14 16:49:36", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1112,7 +1112,7 @@ "post_date": "2014-03-06 20:11:56", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1126,7 +1126,7 @@ "post_date": "2015-05-19 17:27:17", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1140,7 +1140,7 @@ "post_date": "2016-06-28 11:22:06", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1154,7 +1154,7 @@ "post_date": "2008-10-03 19:26:28", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1181,7 +1181,7 @@ "post_date": "2008-11-17 16:58:06", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1195,7 +1195,7 @@ "post_date": "2009-03-31 03:44:53", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1209,7 +1209,7 @@ "post_date": "2022-11-11 12:55:22", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1230,7 +1230,7 @@ "post_date": "2010-06-11 00:20:52", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1244,7 +1244,7 @@ "post_date": "2010-06-16 11:21:40", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1290,7 +1290,7 @@ "post_date": "2010-07-22 16:53:04", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1332,7 +1332,7 @@ "post_date": "2010-12-13 11:08:43", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1346,7 +1346,7 @@ "post_date": "2011-03-18 17:10:42", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1373,7 +1373,7 @@ "post_date": "2011-09-07 19:14:18", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1404,7 +1404,7 @@ "post_date": "2011-10-18 12:11:27", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1418,7 +1418,7 @@ "post_date": "2013-04-10 22:30:42", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1432,7 +1432,7 @@ "post_date": "2013-11-25 10:17:21", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1446,7 +1446,7 @@ "post_date": "2014-05-06 19:43:03", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1546,7 +1546,7 @@ "post_date": "2009-12-01 17:34:34", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -1708,7 +1708,7 @@ "post_date": "2020-09-22 10:31:14", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2027,7 +2027,7 @@ "post_date": "2020-05-19 15:15:21", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2041,7 +2041,7 @@ "post_date": "2020-07-30 09:35:04", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2319,7 +2319,7 @@ "post_date": "2011-03-14 12:18:09", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2370,7 +2370,7 @@ "post_date": "2011-06-12 19:23:00", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2384,7 +2384,7 @@ "post_date": "2013-02-21 11:50:07", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, @@ -2468,7 +2468,7 @@ "post_date": "2019-08-05 11:38:26", "post_status": "publish", "author": { - "display_name": "admin", + "display_name": "arhimede", "github": "arhimede" }, "isObsolete": false, diff --git a/src/App/src/Service/SitemapGenerator.php b/src/App/src/Service/SitemapGenerator.php index 1e9f34e5..8afc921e 100644 --- a/src/App/src/Service/SitemapGenerator.php +++ b/src/App/src/Service/SitemapGenerator.php @@ -7,6 +7,7 @@ use DateTimeInterface; use DOMDocument; use DOMElement; +use Light\Blog\Entity\Post; use Light\Blog\Repository\AuthorRepository; use Light\Blog\Repository\CategoryRepository; use Light\Blog\Repository\PostRepository; @@ -41,9 +42,12 @@ public function getSitemapFile(): string public function write(): int { $posts = $this->postRepository->getPublishedPosts(); - $categories = $this->categoryRepository->getCategories(); + $categories = $this->categoryRepository->getCategoriesWithPublishedPosts(); $authors = $this->authorRepository->getAuthorsWithPublishedPosts(); + [$latestOverall, $latestByCategory, $latestByAuthor] = $this->buildLastmodIndex($posts); + $siteLastmod = $latestOverall?->format(DateTimeInterface::W3C); + $dom = new DOMDocument('1.0', 'UTF-8'); $dom->formatOutput = true; @@ -52,13 +56,16 @@ public function write(): int $count = 0; - $this->appendUrl($dom, $urlset, $this->baseUrl . '/'); + $this->appendUrl($dom, $urlset, $this->baseUrl . '/', $siteLastmod); + $count++; + + $this->appendUrl($dom, $urlset, $this->baseUrl . '/blog/', $siteLastmod); $count++; - $this->appendUrl($dom, $urlset, $this->baseUrl . '/blog/'); + $this->appendUrl($dom, $urlset, $this->baseUrl . '/categories/', $siteLastmod); $count++; - $this->appendUrl($dom, $urlset, $this->baseUrl . '/categories/'); + $this->appendUrl($dom, $urlset, $this->baseUrl . '/authors/', $siteLastmod); $count++; $this->appendUrl($dom, $urlset, $this->baseUrl . '/dotkernel-packages-oss-lifecycle/'); @@ -70,7 +77,7 @@ public function write(): int } foreach ($categories as $category) { - $lastmod = $category->getUpdated() ?? $category->getCreated(); + $lastmod = $latestByCategory[$category->getSlug()] ?? null; $this->appendUrl( $dom, $urlset, @@ -81,7 +88,13 @@ public function write(): int } foreach ($authors as $author) { - $this->appendUrl($dom, $urlset, sprintf('%s/author/%s/', $this->baseUrl, $author->getSlug())); + $lastmod = $latestByAuthor[$author->getSlug()] ?? null; + $this->appendUrl( + $dom, + $urlset, + sprintf('%s/author/%s/', $this->baseUrl, $author->getSlug()), + $lastmod?->format(DateTimeInterface::W3C) + ); $count++; } @@ -103,6 +116,37 @@ public function write(): int return $count; } + /** + * @param array $posts + * @return array{0: ?DateTimeInterface, 1: array, 2: array} + */ + private function buildLastmodIndex(array $posts): array + { + $latestOverall = null; + $latestByCategory = []; + $latestByAuthor = []; + + foreach ($posts as $post) { + $postDate = $post->getPostDate(); + + if ($latestOverall === null || $postDate > $latestOverall) { + $latestOverall = $postDate; + } + + $categorySlug = $post->getCategory()->getSlug(); + if (! isset($latestByCategory[$categorySlug]) || $postDate > $latestByCategory[$categorySlug]) { + $latestByCategory[$categorySlug] = $postDate; + } + + $authorSlug = $post->getAuthor()->getSlug(); + if (! isset($latestByAuthor[$authorSlug]) || $postDate > $latestByAuthor[$authorSlug]) { + $latestByAuthor[$authorSlug] = $postDate; + } + } + + return [$latestOverall, $latestByCategory, $latestByAuthor]; + } + private function appendUrl(DOMDocument $dom, DOMElement $urlset, string $loc, ?string $lastmod = null): void { $url = $dom->createElement('url'); diff --git a/src/Blog/src/ConfigProvider.php b/src/Blog/src/ConfigProvider.php index fe9069b5..55955446 100644 --- a/src/Blog/src/ConfigProvider.php +++ b/src/Blog/src/ConfigProvider.php @@ -6,6 +6,7 @@ use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Light\Blog\DBAL\Types\PostStatusEnumType; +use Light\Blog\Factory\Author\AuthorCollectionHandlerFactory; use Light\Blog\Factory\Author\AuthorResourceHandlerFactory; use Light\Blog\Factory\Author\AuthorResourceRepositoryFactory; use Light\Blog\Factory\Category\CategoryCollectionHandlerFactory; @@ -16,6 +17,7 @@ use Light\Blog\Factory\Post\PostResourceHandlerFactory; use Light\Blog\Factory\Tag\TagResourceHandlerFactory; use Light\Blog\Factory\Tag\TagResourceRepositoryFactory; +use Light\Blog\Handler\GetAuthorCollectionHandler; use Light\Blog\Handler\GetAuthorResourceHandler; use Light\Blog\Handler\GetCategoryCollectionHandler; use Light\Blog\Handler\GetCategoryResourceHandler; @@ -68,6 +70,7 @@ private function getDependencies(): array GetCategoryResourceHandler::class => CategoryResourceHandlerFactory::class, AuthorRepository::class => AuthorResourceRepositoryFactory::class, GetAuthorResourceHandler::class => AuthorResourceHandlerFactory::class, + GetAuthorCollectionHandler::class => AuthorCollectionHandlerFactory::class, TagRepository::class => TagResourceRepositoryFactory::class, GetTagResourceHandler::class => TagResourceHandlerFactory::class, ], diff --git a/src/Blog/src/Factory/Author/AuthorCollectionHandlerFactory.php b/src/Blog/src/Factory/Author/AuthorCollectionHandlerFactory.php new file mode 100644 index 00000000..3967a59c --- /dev/null +++ b/src/Blog/src/Factory/Author/AuthorCollectionHandlerFactory.php @@ -0,0 +1,35 @@ +get(AuthorRepository::class); + $categoryRepository = $container->get(CategoryRepository::class); + $template = $container->get(TemplateRendererInterface::class); + + assert($repository instanceof AuthorRepository); + assert($categoryRepository instanceof CategoryRepository); + assert($template instanceof TemplateRendererInterface); + + return new GetAuthorCollectionHandler($template, $repository, $categoryRepository); + } +} diff --git a/src/Blog/src/Handler/GetAuthorCollectionHandler.php b/src/Blog/src/Handler/GetAuthorCollectionHandler.php new file mode 100644 index 00000000..03c027b7 --- /dev/null +++ b/src/Blog/src/Handler/GetAuthorCollectionHandler.php @@ -0,0 +1,36 @@ +authorRepository->getAuthorsWithPublishedPosts(); + $categories = $this->categoryRepository->getCategories(); + + return new HtmlResponse( + $this->template->render('page::authors', [ + 'authors' => $authors, + 'categories' => $categories, + ]) + ); + } +} diff --git a/src/Blog/src/Repository/CategoryRepository.php b/src/Blog/src/Repository/CategoryRepository.php index ed8088f1..542a2bb4 100644 --- a/src/Blog/src/Repository/CategoryRepository.php +++ b/src/Blog/src/Repository/CategoryRepository.php @@ -26,6 +26,27 @@ public function getCategories(): array return $qb->getQuery()->getResult(); } + /** + * @return array + */ + public function getCategoriesWithPublishedPosts(): array + { + $publishedCategoryIds = $this->getQueryBuilder() + ->select('publishedCategory.id') + ->from(Post::class, 'post') + ->join('post.category', 'publishedCategory') + ->where('post.status = :published'); + + $qb = $this->getQueryBuilder() + ->select('categories') + ->from(Category::class, 'categories'); + + $qb->where($qb->expr()->in('categories.id', $publishedCategoryIds->getDQL())) + ->setParameter('published', PostStatusEnum::Published); + + return $qb->getQuery()->getResult(); + } + public function getCategoryResource(string $slug): ?Category { $qb = $this->getQueryBuilder() diff --git a/src/Blog/src/RoutesDelegator.php b/src/Blog/src/RoutesDelegator.php index 088867c4..8ba0d3f1 100644 --- a/src/Blog/src/RoutesDelegator.php +++ b/src/Blog/src/RoutesDelegator.php @@ -4,6 +4,7 @@ namespace Light\Blog; +use Light\Blog\Handler\GetAuthorCollectionHandler; use Light\Blog\Handler\GetAuthorResourceHandler; use Light\Blog\Handler\GetCategoryCollectionHandler; use Light\Blog\Handler\GetCategoryResourceHandler; @@ -24,6 +25,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal $app->get('/blog/', [GetPostCollectionHandler::class], 'page::blog'); $app->get('/category/{slug}/', [GetCategoryResourceHandler::class], 'page::category-resource'); $app->get('/categories/', [GetCategoryCollectionHandler::class], 'page::categories'); + $app->get('/authors/', [GetAuthorCollectionHandler::class], 'page::authors'); $app->get('/author/{slug}/', [GetAuthorResourceHandler::class], 'page::author-resource'); $app->get('/tag/{slug}/', [GetTagResourceHandler::class], 'page::tag-resource'); $app->get('/{categorySlug}/{slug}/', [GetPostResourceHandler::class], 'page::blog-resource'); diff --git a/src/Blog/templates/page/JSON-LD/authors.jsonld.twig b/src/Blog/templates/page/JSON-LD/authors.jsonld.twig new file mode 100644 index 00000000..7cd5d463 --- /dev/null +++ b/src/Blog/templates/page/JSON-LD/authors.jsonld.twig @@ -0,0 +1,24 @@ + diff --git a/src/Blog/templates/page/authors.html.twig b/src/Blog/templates/page/authors.html.twig new file mode 100644 index 00000000..f28bd7d3 --- /dev/null +++ b/src/Blog/templates/page/authors.html.twig @@ -0,0 +1,42 @@ +{% extends '@layout/default.html.twig' %} +{% block json_ld %} {{ include('@jsonld/authors.jsonld.twig') }} {% endblock %} + +{% block title %}Authors{% endblock %} + +{% block page_title %} + {{ include('@partial/title-section.html.twig', { + badge: 'Explore', + title: 'Authors', + }) }} +{% endblock %} + +{% block content %} +
+
+ + {{ include('@partial/left-menu.html.twig') }} + +
+
+ {% for author in authors %} + + +

{{ author.name }}

+

Discover latest articles from this author.

+ Browse articles → +
+ {% else %} +
+

No authors found.

+
+ {% endfor %} +
+
+ +
+
+{% endblock %} diff --git a/test/Unit/App/Fixture/PostTagLoaderTest.php b/test/Unit/App/Fixture/PostTagLoaderTest.php new file mode 100644 index 00000000..8a96f00e --- /dev/null +++ b/test/Unit/App/Fixture/PostTagLoaderTest.php @@ -0,0 +1,197 @@ +jsonFile = sprintf( + '%s%slight-post-tag-loader-%s%sarticles_cleaned.json', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + + mkdir(dirname($this->jsonFile), 0775, true); + } + + protected function tearDown(): void + { + if (is_file($this->jsonFile)) { + unlink($this->jsonFile); + } + + $directory = dirname($this->jsonFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + /** + * @throws Exception + */ + public function testLoadRemovesPostTagLinksForTagsNoLongerListedOnTheArticle(): void + { + // The article now only lists "htaccess" — "admin" was removed from the JSON. + $this->writeArticles([ + ['post_title' => 'A post', 'tags' => [['slug' => 'htaccess', 'name' => 'htaccess']]], + ]); + + $post = $this->createStub(Post::class); + $post->method('getTitle')->willReturn('A post'); + + $htaccessTag = $this->createStub(Tag::class); + $htaccessTag->method('getSlug')->willReturn('htaccess'); + $htaccessTag->method('getName')->willReturn('htaccess'); + + $adminTag = $this->createStub(Tag::class); + $adminTag->method('getSlug')->willReturn('admin'); + $adminTag->method('getName')->willReturn('admin'); + + $existingHtaccessLink = $this->createStub(PostTag::class); + $existingHtaccessLink->method('getTag')->willReturn($htaccessTag); + + $existingAdminLink = $this->createStub(PostTag::class); + $existingAdminLink->method('getTag')->willReturn($adminTag); + + // The DB still has links to both tags from a previous run. + $repository = $this->createStub(EntityRepository::class); + $repository->method('findOneBy')->willReturn($existingHtaccessLink); + $repository->method('findBy')->willReturn([$existingHtaccessLink, $existingAdminLink]); + + $manager = $this->createEntityManager($repository); + $manager->expects($this->once())->method('remove')->with($existingAdminLink); + $manager->expects($this->once())->method('flush'); + + $referenceRepository = $this->createReferenceRepository($manager); + $referenceRepository->addReference('post_1', $post); + $referenceRepository->addReference('tag_htaccess', $htaccessTag); + + $loader = new PostTagLoader($this->jsonFile); + $loader->setReferenceRepository($referenceRepository); + $loader->load($manager); + } + + /** + * @throws Exception + */ + public function testLoadDoesNotRemoveLinksForTagsStillListedOnTheArticle(): void + { + $this->writeArticles([ + ['post_title' => 'A post', 'tags' => [['slug' => 'htaccess', 'name' => 'htaccess']]], + ]); + + $post = $this->createStub(Post::class); + $post->method('getTitle')->willReturn('A post'); + + $htaccessTag = $this->createStub(Tag::class); + $htaccessTag->method('getSlug')->willReturn('htaccess'); + $htaccessTag->method('getName')->willReturn('htaccess'); + + $existingHtaccessLink = $this->createStub(PostTag::class); + $existingHtaccessLink->method('getTag')->willReturn($htaccessTag); + + $repository = $this->createStub(EntityRepository::class); + $repository->method('findOneBy')->willReturn($existingHtaccessLink); + $repository->method('findBy')->willReturn([$existingHtaccessLink]); + + $manager = $this->createEntityManager($repository); + $manager->expects($this->never())->method('remove'); + + $referenceRepository = $this->createReferenceRepository($manager); + $referenceRepository->addReference('post_1', $post); + $referenceRepository->addReference('tag_htaccess', $htaccessTag); + + $loader = new PostTagLoader($this->jsonFile); + $loader->setReferenceRepository($referenceRepository); + $loader->load($manager); + } + + /** + * @param list}> $articles + */ + private function writeArticles(array $articles): void + { + file_put_contents($this->jsonFile, json_encode([ + ['slug' => 'category', 'articles' => $articles], + ])); + } + + /** + * @param EntityRepository $repository + * @throws Exception + */ + private function createEntityManager(EntityRepository $repository): EntityManagerInterface&MockObject + { + $manager = $this->createMock(EntityManagerInterface::class); + $manager->method('getRepository')->willReturn($repository); + $manager->method('contains')->willReturn(true); + + $unitOfWork = $this->createStub(UnitOfWork::class); + $unitOfWork->method('isInIdentityMap')->willReturn(false); + $manager->method('getUnitOfWork')->willReturn($unitOfWork); + + // PHPUnit stubs are dynamically-generated *subclasses* of the entity they stub — mirror + // what Doctrine's real getClassMetadata() does for proxies: resolve back to the real + // mapped class, otherwise ReferenceRepository stores/looks up references under the + // wrong (generated) class name and every hasReference() check silently returns false. + $manager->method('getClassMetadata')->willReturnCallback(function (string $class) { + $realClass = match (true) { + is_a($class, Post::class, true) => Post::class, + is_a($class, Tag::class, true) => Tag::class, + is_a($class, PostTag::class, true) => PostTag::class, + default => $class, + }; + + $metadata = $this->createStub(ClassMetadata::class); + $metadata->method('getName')->willReturn($realClass); + return $metadata; + }); + + return $manager; + } + + private function createReferenceRepository(EntityManagerInterface $manager): ReferenceRepository + { + return new ReferenceRepository($manager); + } +} diff --git a/test/Unit/App/Service/SitemapGeneratorTest.php b/test/Unit/App/Service/SitemapGeneratorTest.php index c9de1c43..fbba7c98 100644 --- a/test/Unit/App/Service/SitemapGeneratorTest.php +++ b/test/Unit/App/Service/SitemapGeneratorTest.php @@ -34,8 +34,8 @@ class SitemapGeneratorTest extends UnitTest { - /** Homepage, /blog/, /categories/ and the packages-lifecycle page are always present. */ - private const FIXED_URL_COUNT = 4; + /** Homepage, /blog/, /categories/, /authors/ and the packages-lifecycle page are always present. */ + private const FIXED_URL_COUNT = 5; private string $sitemapFile; @@ -87,9 +87,10 @@ public function testWriteAlwaysIncludesTheFixedPagesEvenWithoutAnyContent(): voi $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/authors/', (string) $urls[3]->loc); $this->assertSame( 'https://example.test/dotkernel-packages-oss-lifecycle/', - (string) $urls[3]->loc + (string) $urls[4]->loc ); $this->assertCount(0, $urls[0]->lastmod); } @@ -105,22 +106,45 @@ public function testWriteAddsOneUrlEntryPerConfiguredStaticPage(): void $urls = $this->loadSitemap()->url; $this->assertSame('https://example.test/contact/', (string) $urls[self::FIXED_URL_COUNT]->loc); + $this->assertCount(0, $urls[self::FIXED_URL_COUNT]->lastmod); } /** * @throws Exception */ - public function testWriteAddsOneUrlEntryPerCategoryWithItsLastModifiedDate(): void + public function testWriteSetsLastmodOnHomeBlogCategoriesAndAuthorsFromTheNewestPost(): void { - $category = $this->createCategory('news', '2026-08-01 10:00:00'); - $generator = $this->createGenerator(categories: [$category]); + $olderPost = $this->createPost('older-post', 'news', '2026-08-01 10:00:00'); + $newerPost = $this->createPost('newer-post', 'news', '2026-08-05 10:00:00'); - $this->assertSame(self::FIXED_URL_COUNT + 1, $generator->write()); + $generator = $this->createGenerator(posts: [$olderPost, $newerPost]); + $generator->write(); + + $urls = $this->loadSitemap()->url; + foreach ([0, 1, 2, 3] as $index) { + $this->assertSame('2026-08-05T10:00:00+00:00', (string) $urls[$index]->lastmod); + } + } + + /** + * @throws Exception + */ + public function testWriteAddsOneUrlEntryPerCategoryWithItsLastModifiedDateDerivedFromItsNewestPost(): void + { + $category = $this->createCategory('news'); + $posts = [ + $this->createPost('older-post', 'news', '2026-08-01 10:00:00'), + $this->createPost('newer-post', 'news', '2026-08-03 10:00:00'), + $this->createPost('other-category-post', 'other', '2026-08-09 10:00:00'), + ]; + + $generator = $this->createGenerator(categories: [$category], posts: $posts); + $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', + '2026-08-03T10:00:00+00:00', (string) $urls[self::FIXED_URL_COUNT]->lastmod ); } @@ -142,6 +166,31 @@ public function testWriteAddsOneUrlEntryPerAuthor(): void $this->assertCount(0, $urls[self::FIXED_URL_COUNT]->lastmod); } + /** + * @throws Exception + */ + public function testWriteAddsOneUrlEntryPerAuthorWithItsLastModifiedDateDerivedFromItsNewestPost(): void + { + $author = $this->createStub(Author::class); + $author->method('getSlug')->willReturn('jane-doe'); + + $posts = [ + $this->createPost('older-post', 'news', '2026-08-01 10:00:00', 'jane-doe'), + $this->createPost('newer-post', 'news', '2026-08-04 10:00:00', 'jane-doe'), + $this->createPost('other-author-post', 'news', '2026-08-09 10:00:00', 'john-doe'), + ]; + + $generator = $this->createGenerator(authors: [$author], posts: $posts); + $generator->write(); + + $urls = $this->loadSitemap()->url; + $this->assertSame('https://example.test/author/jane-doe/', (string) $urls[self::FIXED_URL_COUNT]->loc); + $this->assertSame( + '2026-08-04T10:00:00+00:00', + (string) $urls[self::FIXED_URL_COUNT]->lastmod + ); + } + /** * @throws Exception */ @@ -197,7 +246,7 @@ private function createGenerator( $postRepository->method('getPublishedPosts')->willReturn($posts); $categoryRepository = $this->createStub(CategoryRepository::class); - $categoryRepository->method('getCategories')->willReturn($categories); + $categoryRepository->method('getCategoriesWithPublishedPosts')->willReturn($categories); $authorRepository = $this->createStub(AuthorRepository::class); $authorRepository->method('getAuthorsWithPublishedPosts')->willReturn($authors); @@ -215,14 +264,22 @@ private function createGenerator( /** * @throws Exception */ - private function createPost(string $slug, string $categorySlug, string $postDate = '2026-08-01 10:00:00'): Post - { + private function createPost( + string $slug, + string $categorySlug, + string $postDate = '2026-08-01 10:00:00', + string $authorSlug = 'author', + ): Post { $category = $this->createStub(Category::class); $category->method('getSlug')->willReturn($categorySlug); + $author = $this->createStub(Author::class); + $author->method('getSlug')->willReturn($authorSlug); + $post = $this->createStub(Post::class); $post->method('getSlug')->willReturn($slug); $post->method('getCategory')->willReturn($category); + $post->method('getAuthor')->willReturn($author); $post->method('getPostDate')->willReturn(new DateTimeImmutable($postDate, new DateTimeZone('UTC'))); return $post; @@ -231,12 +288,10 @@ private function createPost(string $slug, string $categorySlug, string $postDate /** * @throws Exception */ - private function createCategory(string $slug, string $updated): Category + private function createCategory(string $slug): 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; }