Jak notACMS buduje statyczną stronę — pełny pipeline
statyczna-strona,symfony,architektura,dev-toolsCzęść 6 z 6
- 368 testów dla statycznego generatora stron — po co?
- 79 błędów w kodzie Symfony który przeszedł PHPStan
- XSS, open redirecty i path traversal na 'statycznej' stronie
- Wzorzec lokalnych nadpisań — szablony Symfony bez forkowania
- Open-source notACMS — pełna lista kontrolna
- Jak notACMS buduje statyczną stronę — pełny pipeline
To część 6 serii o przygotowaniu notACMS do wydania jako open-source. Część 5 opisuje listę kontrolną open-source. Oryginalna seria o migracji z WordPressa opisuje samą migrację.
Polecenie ddev build tworzy kompletną statyczną stronę w mniej niż minutę. Ale co właściwie się dzieje? 10 odrębnych etapów, każdy to decyzja projektowa. Oto pełny pipeline — każde polecenie, każdy plik konfiguracyjny, każdy wybór architektoniczny.
10-etapowy pipeline
Etap 1: Zainicjowanie local/
Zanim cokolwiek się skompiluje, build upewnia się że istnieje działający katalog local/. Jeśli jest pusty lub nie istnieje, build kopiuje do niego docs/demo/:
cp -r docs/demo/. local/
Jeśli local/ ma już prawdziwą zawartość, build pomija ten krok. Flaga --bare inicjuje z docs/bare/ — minimalnego szkieletu bez treści, przydatnego przy zaczynaniu od zera. Flaga --demo wymusza ponowną inicjalizację nawet jeśli treść istnieje, tworząc kopię zapasową starego local/ jako local-{TIMESTAMP}/.
Build kopiuje też assets/images/og-default.jpg do local/assets/images/ jeśli go brakuje — zapasowy obraz Open Graph, gdy strona nie ma własnego.
Etap 2: Instalacja Composera
composer install --optimize-autoloader --no-interaction
Standardowy krok Symfony. Flaga --optimize-autoloader tworzy classmapę dla szybszego autoloadingu — istotne, bo każde pod-żądanie w buildzie tworzy nowy kontener serwisów.
Etap 3: Wyczyszczenie dart-sass
rm -rf var/dart-sass
Polecenie sass:build pobiera binarkę dart-sass specyficzną dla architektury. Wyczyszczenie przed każdym buildem zapewnia poprawną binarkę dla bieżącej platformy. Kluczowe dla przepływów międzyplatformowych — development na ARM64 (Raspberry Pi 5), CI/CD na x86_64 (GitHub Actions), deployment znów na ARM64.
Etap 4: Wyczyszczenie cache
php bin/console cache:clear
Tworzy skompilowany kontener wstrzykiwania zależności Symfony. Ciepły cache poprawia wydajność pod-żądań podczas budowania statycznego.
Etap 5: Kompilacja SCSS
php bin/console sass:build
Kompiluje assets/styles/app.scss do var/sass/app.css. Kaskada importów rdzennego SCSS, w kolejności:
_tokens.scss ← CSS custom properties (--bg, --text, --accent, ...)
_variables.scss ← stałe SCSS czasu kompilacji ($font, $sp-*, $radius-*, ...)
_base.scss ← reset, box-sizing, domyślne style body
_layout.scss ← .container, .content-layout, .site-header/footer
_nav.scss ← nawigacja, hamburger, dropdown, .skip-link
_components.scss ← post-card, paginacja, odznaki, przyciski, alerty, widgety
_prose.scss ← klasa .prose dla wyrenderowanego Markdowna
_pages.scss ← sekcje strony głównej, hero, CTA
_blog.scss ← pasek postępu czytania, przycisk kopiowania kodu
_styleguide.scss ← klasy szkieletowe sg-*
_utilities.scss ← atomowe klasy narzędziowe
Lokalne motywy nadpisują _tokens.scss i _variables.scss — wszystko inne dziedziczy nowe wartości przez kaskadę. Zmień --accent z #2563EB na #FFA040, a każdy komponent, przycisk i link się aktualizuje bez dotykania SCSS komponentów.
Etap 6: Asset-map compile
rm -rf public/assets
php bin/console asset-map:compile
Najpierw czyści wcześniej skompilowane assety, potem kompiluje wszystko zmapowane przez Symfony AssetMapper:
assets/app.js→public/assets/app-{contenthash}.jsvar/sass/app.css→public/assets/app-{contenthash}.csslocal/assets/**/*→public/assets/local/**/*(jeślilocal/assets/istnieje)
{contenthash} to fingerprint pochodzący z zawartości pliku. Zmiana jednego znaku w SCSS generuje zupełnie inny hash, automatycznie unieważniając cache CDN i przeglądarki. Bez parametrów wersji w query stringu — sama nazwa pliku jest wersją.
Po kompilacji paczka sensiolabs_minify minifikuje wszystkie pliki CSS i JS. Plik importmap.php definiuje które punkty wejścia istnieją:
return array_filter([
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'app-local' => file_exists(__DIR__ . '/local/assets/app.js')
? [
'path' => './local/assets/app.js',
'entrypoint' => true,
]
: null,
]);
Dwa punkty wejścia: app (zawsze — rdzenny CSS + JS) i app-local (warunkowy — tylko jeśli local/assets/app.js istnieje). Oba są entrypoint: true, więc {{ importmap('app') }} i {{ importmap('app-local') }} w Twigu ładują je niezależnie. app-local ładuje się po app w szablonie, dając lokalnemu CSS ostatnie słowo w przypadku równej specyficzności bez !important.
Etap 7: Budowa statycznego HTML
php bin/console app:build -v
To jest sedno magii. Flaga -v włącza szczegółowe wyjście, pokazujące każdy wyrenderowany URL. Pięć pod-etapów wykonuje się sekwencyjnie:
7a. Unieważnienie cache treści
Build czyści pulę cache app.content dla każdego skonfigurowanego locale. Cache przechowuje sparsowane obiekty ContentTree — unieważnienie wymusza pełne ponowne parsowanie wszystkich plików Markdown, zapewniając że statyczny build odzwierciedla bieżący stan treści. W developmentcie cache używa adaptera tablicowego w pamięci. W produkcyjnych buildach używa adaptera systemu plików w var/cache/.
7b. Zebranie tras
Metoda collectRoutes() zbiera każdy URL który serwuje strona. Dla każdego locale:
- Strona główna (
home_{locale}) - Lista wpisów (
blog_list_{locale}) - Paginowane strony listy (strony 2 do
ceil(liczbaPostów / postówNaStronę)) - URL każdego opublikowanego wpisu (`$post->url()``)
- URL każdego zaplanowanego wpisu (renderuje stronę „wkrótce" z
noindex) - Każda strona kategorii (
blog_category_{locale}) - Każda strona tagu (
blog_tag_{locale}) - Każdy miesiąc archiwum (
blog_archive_{locale}z parametrami rok + miesiąc) - Każdy rok archiwum (
blog_archive_year_{locale}— osobna trasa od miesięcy) - Każda strona statyczna (nie-dynamiczna, niepusty URL, nie strona główna)
- Strona wyszukiwarki (
search_{locale})
Wynik jest deduplikowany przez array_unique(). Dla strony z 4 locale i ~40 elementami treści na locale, daje to około 500 URL-i, uwzględniając paginację, archiwa, tagi i feedy.
7c. Renderowanie stron
Każdy URL jest renderowany przez pod-żądanie Symfony:
$request = Request::create($url, Request::METHOD_GET);
$request->attributes->set('_static_build', true);
$response = $this->httpKernel->handle(
$request,
HttpKernelInterface::SUB_REQUEST,
false,
);
Atrybut _static_build sygnalizuje kontrolerom, serwisom i szablonom, że działają wewnątrz builda, a nie obsługują prawdziwego żądania HTTP. Motywy i lokalne rozszerzenia mogą na jego podstawie różnicować output między serwowaniem na żywo a buildem statycznym. (O widoczności szkiców/zaplanowanych wpisów decyduje natomiast brak deweloperskich przełączników podglądu w sesji — CLI nie ma sesji, więc build zawsze renderuje widok opublikowany.)
Ciało odpowiedzi jest zapisywane na dysk jako {katalogWyjściowy}/{url}/index.html. Główny URL / staje się index.html w katalogu wyjściowym. Każdy URL zwracający HTTP 400 lub więcej jest łapany jako błąd i pomijany (strona jest liczona jako pominięta, ale build kontynuuje).
Po wyrenderowaniu wszystkich stron z treścią, build renderuje URLe feedów:
robots_{locale}— zapisywany jako{katalogWyjściowy}/robots.txtsitemap_{locale}— zapisywany jako{katalogWyjściowy}/sitemap.xml(lubindex.xmldla URL-i zakończonych ukośnikiem)rss_{locale}— zapisywany jako{katalogWyjściowy}/rss.xml(lubindex.xml)
Następnie strony błędów są renderowane przez pod-żądanie:
error_404_{locale}→{prefiks}404.htmlerror_500_{locale}→{prefiks}500.html
Gdzie {prefiks} to ścieżka locale innego niż domyślny (np. pl/404.html). Są one wstępnie renderowane, żeby nginx mógł je serwować bezpośrednio, bez wybudzania PHP-FPM.
Podejście z pod-żądaniem ma znaczenie dla poprawności: te same kontrolery i szablony Twig używane w trybie deweloperskim tworzą statyczne wyjście. To co widzisz w ddev start ląduje na produkcji. Bez osobnej ścieżki renderowania. Bez rozbieżności szablonów między trybem dev a build.
7d. Kopiowanie plików mediów
Symfony Finder lokalizuje każdy katalog files/ w drzewie treści i mirroruje je do wyjścia:
{katalogTreści}/{katalogWpisu}/files/ → {katalogWyjściowy}/media/{katalogWpisu}/
To lustrzane kopiowanie brute-force — bez hashowania, bez deduplikacji, bez tree-shakingu. Autorzy treści kontrolują co jest w files/. Usuń stamtąd plik, znika z kolejnego builda. Prostota jest zamierzona: bez grafu assetów, bez manifestu builda, bez zliczania referencji.
7e. Optymalizacja oryginałów i generowanie wariantów responsywnych
Dwa przebiegi ImageMagick na zduplikowanym katalogu mediów:
Optymalizacja każdego pliku .webp (z pominięciem istniejących wariantów identyfikowanych przez wzorzec sufiksu -{szerokość}w):
$this->imageResizer->optimize($path);
// → exec('magick convert input.webp -quality 82 -strip -define webp:method=6 output.webp')
Jakość domyślnie 82, konfigurowalna w _site.yaml. Flaga -strip usuwa metadane (EXIF, profile ICC). Flaga webp:method=6 używa najwolniejszego ale najwydajniejszego enkodera WebP.
Generowanie wariantów responsywnych dla każdego oryginału .webp:
$width = getimagesize($path)[0];
$variantWidths = $this->responsiveImageService->getVariantWidths($width);
// [640, 960] przefiltrowane z konfiguracji — tylko szerokości < źródłowa
foreach ($variantWidths as $variantWidth) {
$this->imageResizer->resize($path, $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp', $variantWidth);
}
Dla obrazu źródłowego 1280px z skonfigurowanymi szerokościami wariantów [640, 960], produkuje to image-640w.webp i image-960w.webp. Oryginalny image.webp służy jako fallback 1280w.
Etap 8: Kopiowanie favicon
cp local/assets/favicon.ico public/
Jeśli local/assets/favicon.ico nie istnieje, sięga po assets/images/favicon.ico z rdzenia. Blok favicons w base.html.twig można nadpisać w lokalnych szablonach by wskazywać inne ścieżki ikon.
Etap 9: Indeks Pagefind
npx pagefind --site public/static --output-path public/pagefind
Pagefind skanuje wstępnie wyrenderowany HTML w poszukiwaniu regionów data-pagefind-body, buduje per-językowe binarne indeksy wyszukiwania WASM i zapisuje ~437 plików w public/pagefind/. Ten krok działa osobno od builda PHP, bo wymaga Node.js. Warstwa PHP nie ma zależności od Node — trzymanie Pagefind jako osobnego kroku orkiestracji oznacza że cała aplikacja PHP działa bez node_modules.
Skrypt produkcyjnego deployu (scripts/rebuild-content.sh) przypina Pagefind do wersji @1.5.0:
docker compose run --rm php npx --yes pagefind@1.5.0 --site public/static --output-path public/pagefind
Późniejsze wersje Pagefind dostarczają binarkę ARM64 linkowaną z jemalloc, która crashuje na hostach z jądrem używającym 16K stron (Raspberry Pi 5). Błąd: "<jemalloc>: Unsupported system page size". Przypięcie do 1.5.0 omija ten problem. DDEV-lokalny skrypt build nie przypina — działa na x86_64 gdzie wszystkie wersje są poprawne.
Etap 10: nginx serwuje
Po wszystkich krokach public/ zawiera trzy katalogi:
public/
├── assets/ ← zfingerprintowane JS/CSS (immutable, długi cache)
├── static/ ← cały HTML + RSS + sitemap + media/
└── pagefind/ ← indeks wyszukiwarki (WASM + metadane)
Nginx try_files rozwiązuje każde przychodzące żądanie do wstępnie wyrenderowanego pliku:
location / {
try_files /static$uri/index.html /static$uri /static$uri.html =404;
}
PHP-FPM jest wołane tylko dla endpointa formularza kontaktowego:
location ~ ^/(api|pl/api)/ {
fastcgi_pass php:9000;
}
Cała strona poza formularzem kontaktowym to statyczne pliki na dysku. Bez runtime PHP. Bez bazy danych. Bez serwera aplikacji.
Podsumowanie pipeline'u
| Etap | Polecenie | Produkuje |
|---|---|---|
| 1. Inicjalizacja local/ | cp -r docs/demo/. local/ |
Działająca konfiguracja strony |
| 2. Composer | composer install --optimize-autoloader |
Autoload vendora |
| 3. Czyszczenie dart-sass | rm -rf var/dart-sass |
Czysty stan builda |
| 4. Czyszczenie cache | cache:clear |
Skompilowany kontener DI |
| 5. SCSS | sass:build |
var/sass/app.css |
| 6. Kompilacja assetów | asset-map:compile |
public/assets/app-{hash}.css |
| 7. Budowa statyczna | app:build -v |
public/static/*/index.html |
| 8. Favicon | cp |
public/favicon.ico |
| 9. Pagefind | npx pagefind |
public/pagefind/ |
| 10. nginx | try_files |
Serwuje wszystko |
Decyzje projektowe
Dlaczego pod-żądania?
Pod-żądania ponownie wykorzystują te same kontrolery i szablony Twig używane w trybie deweloperskim. Bez osobnej ścieżki renderowania. To co widzisz w ddev start trafia na produkcję. Atrybut _static_build to jedyny sygnał który się różni — cała reszta działa identycznie. Eliminuje to najczęstszy błąd statycznych generatorów: tryb dev renderujący inaczej niż output builda.
Dlaczego osobne etapy SCSS i asset-map?
sass:build produkuje surowe CSS — przydatne w trybie deweloperskim watch, gdzie rekompilujesz przy zmianach plików ale nie chcesz re-fingerprintować przy każdym zapisie. asset-map:compile fingerprintuje i minifikuje — zmartwienie wyłącznie produkcyjne. Rozdzielenie oznacza że te dwie odpowiedzialności pozostają niezależne.
Dlaczego brute-copy mediów?
Autorzy treści kontrolują co jest w files/. Bez manifestu builda. Bez tree-shakingu. Bez grafu assetów. Usuń z files/, znika z builda. Prostota jest warta miejsca na dysku — a miejsce na dysku dla obrazów WebP na statycznej stronie jest trywialne.
Dlaczego ImageMagick CLI, nie GD lub Imagick?
exec('magick convert ...')
GD nie radzi sobie niezawodnie ze wszystkimi wariantami WebP (bezstratny, animowany, alfa). Imagick ma problemy z kompatybilnością rozszerzeń między wersjami PHP. ImageMagick CLI jest zawsze dostępny w kontenerze Dockera i jest najbardziej przenośną opcją między architekturami.
Dlaczego Pagefind jako osobny krok?
Zależność od Node.js. Build PHP nie potrzebuje Node do niczego innego. Trzymanie Pagefind jako osobnego kroku orkiestracji oznacza że cała warstwa PHP działa bez node_modules — znaczące uproszczenie dla deploymentu i setupu deweloperskiego.
Wydajność
Pełny pipeline wykonuje się w mniej niż 60 sekund dla strony o ~170 podstronach (4 locale, ~40 elementów treści na locale). Wąskie gardła:
- ImageMagick — 100+ optymalizacji obrazów i generowania wariantów. Obecnie sekwencyjne — jednowątkowa pętla PHP przez każdy plik. Zrównoleglenie (wiele
exec()jednocześnie) byłoby największą pojedynczą poprawą. - Renderowanie pod-żądań — ~1ms na żądanie, ~500 łącznie z paginacją. Skompilowany kontener Symfony sprawia że to szybkie. Główny koszt to kompilacja szablonów Twig, nie I/O.
- Pagefind — 2–5 sekund, zdominowane przez kompilację WASM z kodu źródłowego Rust, nie parsowanie HTML. Skaluje się liniowo z liczbą stron.
Punkty dostosowywania
Pipeline ma pięć celowych punktów wstrzykiwania dla lokalnych nadpisań:
local/assets/styles/— nadpisuj częściowe pliki SCSS (_tokens.scss,_variables.scss)local/assets/— dodawaj własny JS (staje się punktem wejściaapp-localgdyapp.jsistnieje)local/templates/— nadpisuj dowolny szablon Twig (przepada do rdzenia przez priorytet przestrzeni nazw)local/content/_site.yaml— zmieniaj szerokości wariantów, jakość obrazów, flagi Magick, liczbę postów na stronęlocal/src/— dodawaj własne rozszerzenia Twig lub dekoratory serwisów (auto-wykrywane przez Symfony)
Zero plików konfiguracyjnych do edycji. Zero kroku rejestracji. Zero systemu wtyczek frameworka. Kopiuj plik do local/, edytuj, buduj. To cały model.
Część 7 opisuje budowę kompletnego motywu produkcyjnego na tym pipeline — bloki Twig, tokeny SCSS, własne komponenty, lokalny JS i dekorację serwisów. Część 8 zagłębia się w Pagefind: dwa interfejsy wyszukiwania, kontrakt data-pagefind-*, deployment produkcyjny i wojenna opowieść o przypinaniu wersji na ARM64.