Rozdział 5: Strona artykułu
Lista już działa (Rozdział 4) — teraz zbudujemy stronę pojedynczego artykułu pod /blog/{slug}.
Wyrenderujemy treść z Markdown, zliczymy odsłony, zapamiętamy „ostatnio oglądane” w sesji i dodamy
breadcrumb. Po tym rozdziale klikniesz artykuł z listy i otworzysz jego pełną treść.
Stan wejściowy: działająca lista z Rozdziału 4. Tu dokładamy trasę
show, filtr|markdowni widget „ostatnio oglądane”.
Trasa i rozwiązanie artykułu
Dodajemy akcję show() do BlogController. Artykuł rozwiązujemy po slug-u, a strona działa tylko dla
statusu published (inaczej 404). Potrzebujemy też sesji (do „ostatnio oglądanych”), więc dokładamy
RequestStack do konstruktora:
// src/Controller/BlogController.php
use App\Entity\BlogArticle;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
// … w konstruktorze dochodzi RequestStack:
public function __construct(
private readonly BlogArticleRepository $articles,
private readonly BlogCategoryRepository $categories,
private readonly BlogTagRepository $tags,
private readonly RequestStack $requestStack, // ← nowe w tym rozdziale
) {}
#[Route('/{slug}', name: 'show')]
public function show(string $slug, EntityManagerInterface $em): Response
{
$article = $this->articles->findOneBy(['slug' => $slug]);
if (null === $article || BlogArticle::STATUS_PUBLISHED !== $article->getStatus()) {
throw new NotFoundHttpException(sprintf('Artykuł "%s" nie istnieje.', $slug));
}
$article->incrementViews();
$em->flush();
$this->pushRecentlyViewed($article->getId());
return $this->render('blog/show.html.twig', [
'article' => $article,
]);
}
⚠️ Kolejność tras.
#[Route('/{slug}')]pasuje do każdego jednoczłonowego adresu pod/blog, więc musi być ostatnią trasą w kontrolerze. Gdy w Rozdziale 6 dodamy/blog/category/{slug}i/blog/tag/{slug}, zostaw akcjęshow()na samym dole — inaczej/blog/categorytrafiłoby doshow()zslug = "category".
EntityManagerInterface wstrzykujemy jako argument akcji (Symfony sam go poda) — potrzebny do flush()
po zliczeniu odsłony.
Render treści z Markdown
Treść artykułu trzymamy w Markdown (pole content, Rozdział 3). Do zamiany na HTML tworzymy rozszerzenie
Twiga z filtrem |markdown, oparte na league/commonmark. Zainstaluj paczkę:
docker compose exec app composer require league/commonmark
// src/Twig/MarkdownExtension.php
namespace App\Twig;
use League\CommonMark\Environment\Environment as CommonMarkEnvironment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\MarkdownConverter;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class MarkdownExtension extends AbstractExtension
{
private MarkdownConverter $converter;
public function __construct()
{
$environment = new CommonMarkEnvironment([
'html_input' => 'strip', // usuń surowy HTML z treści
'allow_unsafe_links' => false, // zablokuj niebezpieczne linki (javascript:)
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new GithubFlavoredMarkdownExtension());
$this->converter = new MarkdownConverter($environment);
}
public function getFilters(): array
{
return [
new TwigFilter('markdown', [$this, 'toHtml'], ['is_safe' => ['html']]),
];
}
public function toHtml(?string $markdown): string
{
if (null === $markdown || '' === $markdown) {
return '';
}
return $this->converter->convert($markdown)->getContent();
}
}
Bezpieczeństwo (ważne):
html_input: 'strip'— surowy HTML w treści jest usuwany. Autor pisze Markdown, nie wstrzyknie<script>.allow_unsafe_links: false— linkijavascript:i podobne są blokowane.is_safe: ['html']na filtrze mówi Twigowi, że wynik jest już bezpiecznym HTML-em (nie escape’ujemy go ponownie) — jest to bezpieczne tylko dzięki powyższym dwóm ustawieniom.
GitHub Flavored Markdown dokłada tabele, listy zadań, przekreślenia i autolinki. Kolorowanie składni bloków kodu (highlight.js) dołożymy w Rozdziale 9 — tu treść renderuje się poprawnie, choć jeszcze bez kolorów.
Licznik odsłon
Każde otwarcie artykułu zwiększa viewsCount. Dodajemy prostą metodę do encji BlogArticle (pole mamy
z Rozdziału 3):
// src/Entity/BlogArticle.php
public function incrementViews(): static
{
++$this->viewsCount;
return $this;
}
W akcji show() wołamy $article->incrementViews() i $em->flush() — dopiero flush zapisuje nową
wartość do bazy.
Uwaga wydajnościowa: liczymy każde wejście, także odświeżenia. Dla bloga to w zupełności wystarcza; zaawansowane odsiewanie botów/duplikatów to temat na osobną optymalizację.
„Ostatnio oglądane” w sesji
Zapamiętujemy ostatnio otwarte artykuły w sesji PHP — bez logowania, per przeglądarka. Trzymamy tylko
ID (nie całe encje), a kolejność „najnowsze pierwsze”. Dwie metody pomocnicze w BlogController:
private const RECENTLY_MAX = 5;
private const SESSION_KEY = 'blog_recently_viewed';
private function pushRecentlyViewed(int $articleId): void
{
$session = $this->requestStack->getSession();
$ids = $session->get(self::SESSION_KEY, []);
// usuń, jeśli już jest (przesuwamy na początek)
$ids = array_values(array_filter($ids, static fn(int $id) => $id !== $articleId));
array_unshift($ids, $articleId);
// ogranicz do 5 najnowszych
$session->set(self::SESSION_KEY, array_slice($ids, 0, self::RECENTLY_MAX));
}
/** @return array<BlogArticle> */
private function getRecentlyViewed(): array
{
$ids = $this->requestStack->getSession()->get(self::SESSION_KEY, []);
return $this->articles->findByIdsOrdered($ids);
}
Baza nie gwarantuje kolejności przy WHERE id IN (...), więc kolejność z sesji odtwarzamy w PHP.
Dodajemy metodę do repozytorium:
// src/Repository/BlogArticleRepository.php
/**
* @param int[] $ids
* @return BlogArticle[]
*/
public function findByIdsOrdered(array $ids): array
{
if ([] === $ids) {
return [];
}
$articles = $this->createQueryBuilder('a')
->where('a.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
// odtwórz kolejność z sesji (najnowsze pierwsze)
$indexed = [];
foreach ($articles as $article) {
$indexed[$article->getId()] = $article;
}
return array_values(array_filter(array_map(
static fn(int $id) => $indexed[$id] ?? null,
$ids,
)));
}
Teraz pokażemy widget na liście. Rozszerzamy akcję index() z Rozdziału 4 o przekazanie
recentlyViewed:
public function index(int $page = 1): Response
{
return $this->render('blog/index.html.twig', [
'pager' => $this->paginate($page),
'categories' => $this->categories->findAll(),
'tags' => $this->tags->findAll(),
'activeCategory' => null,
'activeTag' => null,
'recentlyViewed' => $this->getRecentlyViewed(), // ← nowe
]);
}
I dokładamy blok do sidebara (templates/blog/_sidebar.html.twig):
{% if recentlyViewed is defined and recentlyViewed|length %}
<div class="card border-0 shadow-sm mt-4">
<div class="card-body">
<h2 class="h6 text-uppercase text-muted mb-3">Ostatnio oglądane</h2>
<ul class="list-unstyled mb-0 d-flex flex-column gap-1">
{% for item in recentlyViewed %}
<li><a href="{{ path('app_blog_show', { slug: item.slug }) }}"
class="text-decoration-none small">{{ item.title }}</a></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
Breadcrumb i szablon artykułu
Szablon templates/blog/show.html.twig — breadcrumb, tytuł, meta i treść z |markdown:
{% extends 'base.html.twig' %}
{% block title %}{{ article.title }}{% endblock %}
{% block body %}
<div class="container py-5">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ path('app_blog_index') }}">Blog</a></li>
{% if article.category %}
<li class="breadcrumb-item">{{ article.category.name }}</li>
{% endif %}
<li class="breadcrumb-item active">{{ article.title }}</li>
</ol>
</nav>
<article>
<h1 class="h2 fw-bold mb-2">{{ article.title }}</h1>
<p class="text-muted small mb-4">
{{ article.publishedAt|date('d.m.Y') }}
· {{ article.author.name }}
· {{ article.viewsCount }} wyświetleń
</p>
<div class="blog-content">
{{ article.content|markdown }}
</div>
{% if article.tags|length %}
<div class="mt-4 d-flex flex-wrap gap-2">
{% for tag in article.tags %}
<span class="badge bg-light text-dark">#{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</article>
</div>
{% endblock %}
Kategoria w breadcrumbie i tagi są na razie tekstem — zamienimy je w linki w Rozdziale 6. Stylowanie
treści (.blog-content) i kolorowanie kodu dopieszczymy w Rozdziale 9.
Domknięcie listy z Rozdziału 4. Trasa app_blog_show już istnieje, więc możesz teraz zamienić tytuł
artykułu na liście w link:
{# templates/blog/index.html.twig — tytuł karty #}
<h2 class="h5 mb-1">
<a href="{{ path('app_blog_show', { slug: article.slug }) }}"
class="text-decoration-none text-dark">{{ article.title }}</a>
</h2>
Podsumowanie
Blog ma pełną ścieżkę czytania:
- ✅ akcja
show()(/blog/{slug}, tylkopublished→ inaczej 404) i zasada „{slug}ostatnia trasa”, - ✅
MarkdownExtension(filtr|markdown, CommonMark + GFM,html_input: strip) — treść renderuje się jako HTML, - ✅ licznik odsłon (
incrementViews()+flush()), - ✅ „ostatnio oglądane” w sesji (ID w sesji, kolejność w PHP przez
findByIdsOrdered()) + widget w sidebarze, - ✅ breadcrumb i szablon artykułu; tytuł na liście stał się linkiem do strony artykułu,
- ✅ działający stan: klikasz artykuł na
/blogi czytasz jego pełną treść.
W Rozdziale 6 dodamy strony kategorii i tagów — filtrowanie listy przez dodatkowy JOIN
w repozytorium, i zamienimy kategorie/tagi w klikalne linki.