To część 2 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. Część 1 opisuje, dlaczego WordPress musiał odejść.


Po podjęciu decyzji o odejściu od WordPressa pytanie brzmiało: czym go zastąpić? Hugo, Jekyll i Eleventy były oczywistymi kandydatami. Zdecydowałem się zbudować własną aplikację Symfony — nie dlatego, że istniejące narzędzia są niewystarczające, ale dlatego, że Symfony znam dobrze, a posiadanie pełnego stosu okazało się mieć realne zalety.

Podstawowa idea

Strona działa w dwóch trybach:

  • Deweloperski — Symfony obsługuje żądania dynamicznie. Edytujesz plik Markdown, odświeżasz przeglądarkę, widzisz wynik. Standardowy workflow developerski Symfony z paskiem profilera.
  • Build produkcyjny — jedno polecenie konsolowe renderuje każdy URL do pliku HTML na dysku. nginx serwuje te pliki bezpośrednio. PHP nie jest wywoływane przy dostarczaniu treści.

Te same szablony, kontrolery i pipeline treści obsługują oba tryby. Nie ma osobnego "silnika szablonów do budowania" oddzielnego od "silnika szablonów do działania". To po prostu Symfony.

Pipeline treści

Cała treść mieszka w plikach Markdown z nagłówkiem YAML:

content/
├── blog/
│   └── tutorials/
│       └── moj-wpis/
│           ├── en.md   ← wersja angielska
│           ├── pl.md   ← wersja polska
│           └── files/  ← obrazy, serwowane pod /media/moj-wpis/
└── pages/
    └── about/
        ├── en.md
        └── pl.md

ContentTreeBuilder skanuje system plików, parsuje każdy plik przez league/commonmark (GitHub Flavoured Markdown + nagłówki YAML) i buduje typowany ContentTree — indeks w pamięci wszystkich wpisów i stron dla danego locale. Tagi, kategorie, miesiące archiwum i mapy URL są wyliczane z tego indeksu.

ContentItem to obiekt wartości final readonly. Brak bazy danych, ORM, migracji. Dodanie wpisu oznacza stworzenie katalogu z dwoma plikami Markdown i uruchomienie buildu.

Polecenie build — sub-żądania Symfony

Build statyczny używa techniki specyficznej dla Symfony, która odróżnia to podejście od Hugo czy Jekylla: wywołuje HttpKernelInterface::handle() w celu tworzenia sub-żądań — wewnętrznych wywołań PHP przechodzących przez pełne jądro Symfony bez dotyku sieci.

$request = Request::create($url);
$request->attributes->set('_static_build', true);
$response = $this->kernel->handle($request, HttpKernelInterface::SUB_REQUEST, false);

if ($response->getStatusCode() < 400) {
    file_put_contents($outputPath, $response->getContent());
}

Dla każdego URL — wpisów blogowych, stron kategorii, stron tagów, miesięcy archiwum, stron statycznych, stron błędów, kanałów RSS, mapy strony — polecenie build tworzy żądanie, przepuszcza je przez Symfony i zapisuje HTML na dysk. URL /blog/ staje się public/static/blog/index.html. URL /sitemap.xml staje się public/static/sitemap.xml.

Brak osobnego silnika szablonów do nauki. Brak konfiguracji buildu. Jeśli URL działa w developmencie, będzie w statycznym buildzie.

nginx serwuje wszystko

W produkcji nginx obsługuje całe dostarczanie treści:

location / {
    try_files /static$uri/index.html /static$uri/index.xml /static$uri $uri =404;
}

# PHP tylko dla formularza kontaktowego
location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
}

Dla każdego przychodzącego URL nginx najpierw próbuje wstępnie wyrenderowanego pliku HTML. PHP-FPM jest wywoływane tylko dla /api/contact — jedynego dynamicznego endpointu. Każdy wpis blogowy, lista kategorii, strona tagów i strona statyczna to plik serwowany bezpośrednio z dysku. Raspberry Pi 5 obsługuje to banalnie.

Bez bazy danych

Drzewo treści jest budowane z plików Markdown podczas budowania. W produkcji jest cache'owane bez ograniczeń czasowych w cache'u systemu plików Symfony (unieważniane i przebudowywane przy każdym deployu). W developmencie jest przebudowywane przy każdym żądaniu, żeby zmiany plików były natychmiast widoczne.

Brak MySQL, schematu, migracji, connection poolingu, wolnych zapytań. Dodawanie treści oznacza tworzenie plików i uruchamianie ddev build. Usuwanie treści oznacza usuwanie plików. Cała historia strony jest w gicie.

Wielojęzyczność bez bazy danych

Zarówno polska, jak i angielska treść mieszka w tym samym katalogu:

content/blog/tutorials/moj-wpis/
    en.md  → slug: "blog/my-post"     → /blog/my-post/
    pl.md  → slug: "wpisy/moj-wpis"   → /pl/wpisy/moj-wpis/
    files/ → obrazy serwowane pod /media/moj-wpis/

Współlokalizacja to link translacji. Dwa pliki locale w tym samym katalogu są automatycznie traktowane jako tłumaczenia siebie nawzajem. TranslationMapBuilder buduje {directoryKey → {locale → url}} dla tagów hreflang i przełącznika języka. Bez pola translation_key, bez tabeli łączącej, bez synchronizacji do zarządzania.

Wyszukiwanie z Pagefind

Wyszukiwanie to oparty na WASM statyczny indeks budowany przez Pagefind po wygenerowaniu plików HTML:

npx pagefind --site public/static --output-path public/pagefind

Pagefind czyta wstępnie wyrenderowany HTML, indeksuje regiony data-pagefind-body i generuje binarny indeks w public/pagefind/. Strona wyszukiwania ładuje ten indeks po stronie klienta przez dynamiczny import(). Brak Elasticsearch, Algolii, zapytań wyszukiwania po stronie serwera. Indeks to zbiór plików statycznych.

Prostota redesignu

Zmiana wyglądu strony oznacza edycję szablonów Twig i SCSS. Brak biblioteki komponentów React do aktualizacji. Brak pipeline'u Node.js. Brak hierarchii motywów WordPress. Cały frontend to:

  • assets/styles/app.scss — jeden punkt wejścia importujący pliki częściowe
  • templates/ — szablony Twig
  • symfonycasts/sass-bundle — kompiluje SCSS przez dart-sass, bez Node.js

Żeby zmienić schemat kolorów: edytuj _variables.scss. Żeby zmienić layout: edytuj szablon Twig. Uruchom ddev build i gotowe.

Kompromisy

Głównym kompromisem w porównaniu z Hugo czy Jekyllem jest to, że to własny kod — ja go utrzymuję. Zaletą jest to, że jest dokładnie tym, czego potrzebuję, niczym więcej. Brak ekosystemu wtyczek do nawigowania, brak ścieżki aktualizacji do martwienia się, brak feature flag dla rzeczy, których nigdy nie użyję.

Jedyna naprawdę dynamiczna funkcja — formularz kontaktowy — jest omówiona w następnym wpisie.