Browse Source

improve accessibility

imwald
Silberengel 1 week ago
parent
commit
7a46d24e8a
  1. 25
      assets/styles/app.css
  2. 9
      src/Controller/DefaultController.php
  3. 354
      src/Controller/SeoController.php
  4. 21
      src/Repository/ArticleRepository.php
  5. 48
      src/Service/MagazineContentService.php
  6. 20
      templates/home.html.twig

25
assets/styles/app.css

@ -770,6 +770,31 @@ a:focus-visible {
outline-offset: 2px; 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) { @media (max-width: 600px) {
.header__logo .brand { .header__logo .brand {
font-size: clamp(0.95rem, 4.8vw, 1.25rem); font-size: clamp(0.95rem, 4.8vw, 1.25rem);

9
src/Controller/DefaultController.php

@ -21,8 +21,17 @@ class DefaultController extends AbstractController
#[Route('/', name: 'home')] #[Route('/', name: 'home')]
public function index(): Response 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', [ return $this->render('home.html.twig', [
'indices' => $this->magazineContent->getHomeCategoryIndexTags(), 'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
'categories_for_feed' => $categoriesForFeed,
]); ]);
} }

354
src/Controller/SeoController.php

@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Repository\ArticleRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Sitemap, robots.txt, and Atom feeds for the magazine and each category.
*/
final class SeoController extends AbstractController
{
private const FEED_MAX_ITEMS = 100;
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly MagazineContentService $magazineContent,
private readonly MagazineIndexStore $magazineIndexStore,
private readonly ParameterBagInterface $params,
) {
}
#[Route('/sitemap.xml', name: 'sitemap', methods: ['GET'])]
public function sitemap(): Response
{
$urls = [];
$urls[] = ['loc' => $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 = '<?xml version="1.0" encoding="UTF-8"?>'
."\n"
.'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
foreach ($urls as $row) {
$body .= "\n <url>\n <loc>".$this->xmlText($row['loc']).'</loc>';
if ($row['lastmod'] instanceof \DateTimeInterface) {
$body .= "\n <lastmod>".$row['lastmod']->format('Y-m-d').'</lastmod>';
}
$body .= "\n </url>";
}
$body .= "\n</urlset>\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<Article> $list
*/
private function buildAtomFeed(
string $title,
string $subtitle,
string $id,
string $selfUrl,
string $alternateHtmlUrl,
\DateTimeImmutable $updated,
Request $request,
array $list,
): string {
$xml = '<?xml version="1.0" encoding="utf-8"?>'
."\n"
.'<feed xmlns="http://www.w3.org/2005/Atom">'
."\n <title>".$this->xmlText($title)."</title>\n <subtitle>".$this->xmlText($subtitle)."</subtitle>";
$xml .= "\n <id>".$this->xmlText($id).'</id>';
$xml .= "\n <link href=\"".$this->xmlAttr($selfUrl)."\" rel=\"self\" type=\"application/atom+xml\"/>";
$xml .= "\n <link href=\"".$this->xmlAttr($alternateHtmlUrl)."\" rel=\"alternate\" type=\"text/html\"/>";
$xml .= "\n <updated>".$this->xmlText($updated->format('c')).'</updated>';
$authorName = (string) $this->params->get('name');
$xml .= "\n <author><name>".$this->xmlText($authorName)."</name></author>\n <generator uri=\"https://github.com/decent-newsroom/unfold\" version=\"1\">unfold</generator>";
foreach ($list as $article) {
$xml .= $this->atomEntryForArticle($request, $article);
}
$xml .= "\n</feed>\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 <entry>";
$out .= "\n <title>".$this->xmlText($title)."</title>";
$out .= "\n <link href=\"".$this->xmlAttr($permalink)."\" rel=\"alternate\" type=\"text/html\"/>";
$out .= "\n <id>".$this->xmlText($entryId).'</id>';
$out .= "\n <updated>".$this->xmlText($tArticle->format('c'))."</updated>\n <published>".$this->xmlText($pub->format('c')).'</published>';
$out .= "\n <summary type=\"text\">".$this->xmlText($this->oneLine($sum))."</summary>";
$out .= "\n </entry>";
return $out;
}
private function oneLine(string $s): string
{
return trim(preg_replace("/[\r\n]+/", ' ', $s) ?? '');
}
/**
* @param list<Article> $articles
* @return array<string, Article>
*/
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<Article> $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',
],
);
}
}

21
src/Repository/ArticleRepository.php

@ -3,7 +3,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\Article; use App\Entity\Article;
use App\Enum\IndexStatusEnum; use App\Enum\EventStatusEnum;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -143,4 +143,23 @@ class ArticleRepository extends ServiceEntityRepository
->getQuery() ->getQuery()
->getResult(); ->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<Article>
*/
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();
}
} }

48
src/Service/MagazineContentService.php

@ -95,6 +95,54 @@ final class MagazineContentService
return $age > self::ROOT_REVALIDATE_SECONDS; return $age > self::ROOT_REVALIDATE_SECONDS;
} }
/**
* Category path slugs from the persisted root index (third segment of each category `a` tag).
*
* @return list<string>
*/
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<Article>, category: array{title: string, summary: string}} * @return array{list: list<Article>, category: array{title: string, summary: string}}
*/ */

20
templates/home.html.twig

@ -1,9 +1,17 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}{{ website_name }}{% endblock %}
{% block meta_description %}
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
{% block magazine_sync_page %}home{% endblock %} {% block magazine_sync_page %}home{% endblock %}
{% block ogtags %} {% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %} {% set _og_image = absolute_url(asset('og-image.jpg')) %}
<link rel="canonical" href="{{ url('home') }}">
<link rel="alternate" type="application/atom+xml" title="{{ website_name|e('html_attr') }} — all articles" href="{{ url('feed_magazine') }}">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="{{ url('home') }}"> <meta property="og:url" content="{{ url('home') }}">
<meta property="og:title" content="{{ website_name|e('html_attr') }}"> <meta property="og:title" content="{{ website_name|e('html_attr') }}">
@ -20,6 +28,18 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="home-subscribe" aria-label="Sitemap and syndication">
<h2 class="home-subscribe__title">Sitemap and feeds</h2>
<p class="home-subscribe__hint">For search engines and feed readers. Atom is supported by most clients.</p>
<div class="home-subscribe__actions">
<a class="btn btn-secondary" href="{{ path('sitemap') }}">Sitemap (XML)</a>
<a class="btn btn-secondary" href="{{ path('robots_txt') }}">Robots</a>
<a class="btn btn-secondary" href="{{ path('feed_magazine') }}">Atom — all articles</a>
{% for c in categories_for_feed %}
<a class="btn btn-secondary" href="{{ path('feed_category', {slug: c.slug}) }}">Atom — {{ c.title }}</a>
{% endfor %}
</div>
</div>
<div class="home-body" data-magazine-sync-target="pageBody"> <div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %} {% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/> <twig:Organisms:FeaturedList :category="item" class="featured-list"/>

Loading…
Cancel
Save