diff --git a/assets/styles/app.css b/assets/styles/app.css index 7a70cce..6be7357 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -770,6 +770,31 @@ a:focus-visible { outline-offset: 2px; } +.home-subscribe { + margin-bottom: 1.75rem; + padding: 1rem 0 0; + border-bottom: 1px solid var(--color-border, rgba(0, 0, 0, 0.08)); +} + +.home-subscribe__title { + font-size: 1.15rem; + margin: 0 0 0.35rem; +} + +.home-subscribe__hint { + margin: 0 0 0.75rem; + font-size: 0.9rem; + color: var(--color-text); + opacity: 0.85; +} + +.home-subscribe__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.6rem; + margin-bottom: 1.25rem; +} + @media (max-width: 600px) { .header__logo .brand { font-size: clamp(0.95rem, 4.8vw, 1.25rem); diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 611ea6c..d822bbc 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -21,8 +21,17 @@ class DefaultController extends AbstractController #[Route('/', name: 'home')] public function index(): Response { + $categoriesForFeed = []; + foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { + $categoriesForFeed[] = [ + 'slug' => $slug, + 'title' => $this->magazineContent->getCategoryDisplayTitle($slug), + ]; + } + return $this->render('home.html.twig', [ 'indices' => $this->magazineContent->getHomeCategoryIndexTags(), + 'categories_for_feed' => $categoriesForFeed, ]); } diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php new file mode 100644 index 0000000..b525c00 --- /dev/null +++ b/src/Controller/SeoController.php @@ -0,0 +1,354 @@ + $this->absoluteUrlForRoute('home'), 'lastmod' => null]; + + if ((bool) $this->params->get('community_articles')) { + $urls[] = ['loc' => $this->absoluteUrlForRoute('articles'), 'lastmod' => null]; + } + + foreach ($this->magazineContent->getCategorySlugsFromStore() as $slug) { + $urls[] = [ + 'loc' => $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]), + 'lastmod' => null, + ]; + } + + $articles = $this->articleRepository->findPublishedForSyndication(8000); + $bySlug = $this->dedupeArticlesByLatestRevision($articles); + foreach ($bySlug as $article) { + $urls[] = [ + 'loc' => $this->absoluteUrlForRoute('article-slug', ['slug' => (string) $article->getSlug()]), + 'lastmod' => $this->articleLastMod($article), + ]; + } + + $body = '' + ."\n" + .''; + + foreach ($urls as $row) { + $body .= "\n \n ".$this->xmlText($row['loc']).''; + if ($row['lastmod'] instanceof \DateTimeInterface) { + $body .= "\n ".$row['lastmod']->format('Y-m-d').''; + } + $body .= "\n "; + } + $body .= "\n\n"; + + return $this->xmlResponse($body); + } + + #[Route('/robots.txt', name: 'robots_txt', methods: ['GET'])] + public function robots(): Response + { + $sitemap = $this->absoluteUrlForRoute('sitemap'); + $txt = "User-agent: *\nAllow: /\n\nSitemap: {$sitemap}\n"; + + return new Response( + $txt, + Response::HTTP_OK, + [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ], + ); + } + + #[Route('/feeds/magazine.xml', name: 'feed_magazine', methods: ['GET'])] + public function feedMagazine(Request $request): Response + { + $site = (string) $this->params->get('name'); + $articles = $this->articleRepository->findPublishedForSyndication(8000); + $bySlug = $this->dedupeArticlesByLatestRevision($articles); + $list = \array_values($bySlug); + usort($list, static function (Article $a, Article $b): int { + $ca = $a->getCreatedAt(); + $cb = $b->getCreatedAt(); + if ($ca === null && $cb === null) { + return 0; + } + if ($ca === null) { + return 1; + } + if ($cb === null) { + return -1; + } + + return $cb <=> $ca; + }); + $list = \array_slice($list, 0, self::FEED_MAX_ITEMS); + $feedUrl = $this->absoluteUrlForRoute('feed_magazine'); + $homeUrl = $this->absoluteUrlForRoute('home'); + $selfId = 'urn:web:'.$this->urlHostId($request).':feed:magazine'; + $updated = $this->newestArticleUpdate($list); + + $body = $this->buildAtomFeed( + $site.': all articles', + (string) $this->params->get('description'), + $selfId, + $feedUrl, + $homeUrl, + $updated, + $request, + $list, + ); + + return $this->atomResponse($body); + } + + #[Route('/feeds/cat/{slug}.xml', name: 'feed_category', methods: ['GET'])] + public function feedCategory(Request $request, string $slug): Response + { + if ($this->magazineIndexStore->getCategory($slug) === null) { + throw $this->createNotFoundException('Unknown category'); + } + $site = (string) $this->params->get('name'); + $data = $this->magazineContent->getCategoryPageData($slug); + $rawList = $data['list'] ?? []; + $catTitle = (string) ($data['category']['title'] ?? $this->magazineContent->getCategoryDisplayTitle($slug)); + $summary = (string) ($data['category']['summary'] ?? ''); + + $list = array_values( + array_filter( + $rawList, + static function (Article $a): bool { + $s = $a->getEventStatus(); + if ($s === null) { + return false; + } + + return $s === EventStatusEnum::PUBLISHED || $s === EventStatusEnum::ARCHIVED; + } + ) + ); + if (\count($list) > self::FEED_MAX_ITEMS) { + $list = \array_slice($list, 0, self::FEED_MAX_ITEMS); + } + $feedUrl = $this->absoluteUrlForRoute('feed_category', ['slug' => $slug]); + $categoryPage = $this->absoluteUrlForRoute('magazine-category', ['slug' => $slug]); + $selfId = 'urn:web:'.$this->urlHostId($request).':feed:cat:'.rawurlencode($slug); + $title = $catTitle !== '' ? $catTitle.' — '.$site : $site; + $subtitle = $summary !== '' ? $summary : (string) $this->params->get('description'); + $updated = $this->newestArticleUpdate($list); + + $body = $this->buildAtomFeed( + $title, + $subtitle, + $selfId, + $feedUrl, + $categoryPage, + $updated, + $request, + $list, + ); + + return $this->atomResponse($body); + } + + private function absoluteUrlForRoute(string $name, array $params = []): string + { + return $this->generateUrl($name, $params, UrlGeneratorInterface::ABSOLUTE_URL); + } + + private function urlHostId(Request $request): string + { + $h = $request->getHost(); + + return preg_replace('/[^a-zA-Z0-9.\\-]+/', '-', $h) ?? 'site'; + } + + /** + * @param list
$list + */ + private function buildAtomFeed( + string $title, + string $subtitle, + string $id, + string $selfUrl, + string $alternateHtmlUrl, + \DateTimeImmutable $updated, + Request $request, + array $list, + ): string { + $xml = '' + ."\n" + .'' + ."\n ".$this->xmlText($title)."\n ".$this->xmlText($subtitle).""; + $xml .= "\n ".$this->xmlText($id).''; + $xml .= "\n xmlAttr($selfUrl)."\" rel=\"self\" type=\"application/atom+xml\"/>"; + $xml .= "\n xmlAttr($alternateHtmlUrl)."\" rel=\"alternate\" type=\"text/html\"/>"; + $xml .= "\n ".$this->xmlText($updated->format('c')).''; + $authorName = (string) $this->params->get('name'); + $xml .= "\n ".$this->xmlText($authorName)."\n unfold"; + foreach ($list as $article) { + $xml .= $this->atomEntryForArticle($request, $article); + } + $xml .= "\n\n"; + + return $xml; + } + + private function atomEntryForArticle(Request $request, Article $article): string + { + $slug = \trim((string) $article->getSlug()); + if ($slug === '') { + return ''; + } + $permalink = $this->absoluteUrlForRoute('article-slug', ['slug' => $slug]); + $title = (string) ($article->getTitle() ?? 'Untitled'); + $tArticle = $this->articleLastMod($article); + $sum = (string) ($article->getSummary() ?? ''); + if ($sum === '' && $article->getContent() !== null) { + $plain = preg_replace('/\s+/', ' ', (string) $article->getContent()) ?? ''; + $sum = (string) mb_substr($plain, 0, 500); + } + $eId = (string) ($article->getEventId() ?? ''); + if ($eId === '') { + $eId = (string) ($article->getId() ?? 'item'); + } + $entryId = 'urn:web:'.$this->urlHostId($request).":article:{$eId}"; + + $pub = $article->getPublishedAt() ?? $article->getCreatedAt() ?? $tArticle; + $out = "\n "; + $out .= "\n ".$this->xmlText($title).""; + $out .= "\n xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>"; + $out .= "\n ".$this->xmlText($entryId).''; + $out .= "\n ".$this->xmlText($tArticle->format('c'))."\n ".$this->xmlText($pub->format('c')).''; + $out .= "\n ".$this->xmlText($this->oneLine($sum)).""; + $out .= "\n "; + + return $out; + } + + private function oneLine(string $s): string + { + return trim(preg_replace("/[\r\n]+/", ' ', $s) ?? ''); + } + + /** + * @param list
$articles + * @return array + */ + private function dedupeArticlesByLatestRevision(array $articles): array + { + $bySlug = []; + foreach ($articles as $article) { + $slug = \trim((string) $article->getSlug()); + if ($slug === '') { + continue; + } + $c = $article->getCreatedAt(); + if (!isset($bySlug[$slug])) { + $bySlug[$slug] = $article; + + continue; + } + $prev = $bySlug[$slug]->getCreatedAt(); + if ($c !== null && (null === $prev || $c > $prev)) { + $bySlug[$slug] = $article; + } + } + + return $bySlug; + } + + /** + * @param list
$list + */ + private function newestArticleUpdate(array $list): \DateTimeImmutable + { + $t = new \DateTimeImmutable('@0'); + foreach ($list as $a) { + $m = $this->articleLastMod($a); + if ($m > $t) { + $t = $m; + } + } + if ((int) $t->format('U') === 0) { + return new \DateTimeImmutable(); + } + + return $t; + } + + private function articleLastMod(Article $a): \DateTimeImmutable + { + $p = $a->getPublishedAt(); + $c = $a->getCreatedAt() ?? $p; + if ($p !== null && $c !== null) { + return $p > $c ? $p : $c; + } + + return $p ?? $c ?? new \DateTimeImmutable(); + } + + private function xmlText(string $s): string + { + return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); + } + + private function xmlAttr(string $s): string + { + return htmlspecialchars($s, \ENT_XML1 | \ENT_QUOTES, 'UTF-8'); + } + + private function xmlResponse(string $body): Response + { + return new Response( + $body, + Response::HTTP_OK, + [ + 'Content-Type' => 'application/xml; charset=UTF-8', + 'Cache-Control' => 'public, max-age=600', + ], + ); + } + + private function atomResponse(string $body): Response + { + return new Response( + $body, + Response::HTTP_OK, + [ + 'Content-Type' => 'application/atom+xml; charset=UTF-8', + 'Cache-Control' => 'public, max-age=300', + ], + ); + } +} diff --git a/src/Repository/ArticleRepository.php b/src/Repository/ArticleRepository.php index bad8686..3f5b67e 100644 --- a/src/Repository/ArticleRepository.php +++ b/src/Repository/ArticleRepository.php @@ -3,7 +3,7 @@ namespace App\Repository; use App\Entity\Article; -use App\Enum\IndexStatusEnum; +use App\Enum\EventStatusEnum; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\Exception; use Doctrine\Persistence\ManagerRegistry; @@ -143,4 +143,23 @@ class ArticleRepository extends ServiceEntityRepository ->getQuery() ->getResult(); } + + /** + * Published or archived long-form rows for sitemap/Atom (may include multiple rows per slug); + * callers should dedupe by slug if URLs are slug-only. + * + * @return list
+ */ + public function findPublishedForSyndication(int $limit = 5000): array + { + return $this->createQueryBuilder('a') + ->where('a.slug IS NOT NULL') + ->andWhere("TRIM(a.slug) != ''") + ->andWhere('a.eventStatus IN (:st)') + ->setParameter('st', [EventStatusEnum::PUBLISHED, EventStatusEnum::ARCHIVED]) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } } diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 9ccb0ef..2a9daf4 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -95,6 +95,54 @@ final class MagazineContentService return $age > self::ROOT_REVALIDATE_SECONDS; } + /** + * Category path slugs from the persisted root index (third segment of each category `a` tag). + * + * @return list + */ + public function getCategorySlugsFromStore(): array + { + $tags = $this->getHomeCategoryAIndexTagsFromStoreOnly(); + $out = []; + foreach ($tags as $row) { + $coord = $row[1] ?? ''; + if (!\is_string($coord) || $coord === '') { + continue; + } + $parts = explode(':', $coord, 3); + if (\count($parts) < 3) { + continue; + } + $slug = trim((string) $parts[2]); + if ($slug !== '') { + $out[] = $slug; + } + } + + return array_values(array_unique($out)); + } + + /** + * Title from cached category index event tags, or the slug when missing. + */ + public function getCategoryDisplayTitle(string $slug): string + { + if ($slug === '') { + return ''; + } + $catIndex = $this->store->getCategory($slug); + if ($catIndex === null) { + return $slug; + } + foreach ($catIndex->getTags() as $tag) { + if (($tag[0] ?? null) === 'title' && isset($tag[1])) { + return (string) $tag[1]; + } + } + + return $slug; + } + /** * @return array{list: list
, category: array{title: string, summary: string}} */ diff --git a/templates/home.html.twig b/templates/home.html.twig index 902c782..1e2ee86 100644 --- a/templates/home.html.twig +++ b/templates/home.html.twig @@ -1,9 +1,17 @@ {% extends 'base.html.twig' %} +{% block title %}{{ website_name }}{% endblock %} + +{% block meta_description %} + +{% endblock %} + {% block magazine_sync_page %}home{% endblock %} {% block ogtags %} {% set _og_image = absolute_url(asset('og-image.jpg')) %} + + @@ -20,6 +28,18 @@ {% endblock %} {% block body %} +
+

Sitemap and feeds

+

For search engines and feed readers. Atom is supported by most clients.

+
+ Sitemap (XML) + Robots + Atom — all articles + {% for c in categories_for_feed %} + Atom — {{ c.title }} + {% endfor %} +
+
{% for item in indices %}