5

Rozdział 5 z 21

Strona artykułu

Opublikowany

Tworzymy stronę szczegółową artykułu rozwiązywaną po slug-u. Renderujemy treść z markdown (CommonMark), inkrementujemy licznik odsłon, zapamiętujemy „ostatnio oglądane” w sesji i budujemy breadcrumb.

Czego się nauczysz

  • Trasa /blog/{slug} i rozwiązywanie artykułu
  • Renderowanie treści z markdown (league/commonmark)
  • Inkrementacja licznika odsłon (viewsCount)
  • „Ostatnio oglądane” w sesji (RequestStack)
  • Breadcrumb i powiązane artykuły

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 |markdown i 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/category trafiłoby do show() z slug = "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 — linki javascript: 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}, tylko published → 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 /blog i 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.

Narzędzia / paczki

league/commonmark RequestStack sesja

Spis treści