<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title><![CDATA[holas.pl]]></title>
        <link>https://holas.pl/pl/</link>
        <description><![CDATA[Piece of web by Holas]]></description>
        <language>pl</language>
        <atom:link href="https://holas.pl/pl/feed/" rel="self" type="application/rss+xml"/>
                        <lastBuildDate>Fri, 24 Jul 2026 00:00:00 +0000</lastBuildDate>
                        <item>
            <title><![CDATA[Budowanie produkcyjnego motywu notACMS — od szablonu do wydania]]></title>
            <link>https://holas.pl/pl/wpisy/budowanie-motywu-notacms/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/budowanie-motywu-notacms/</guid>
                        <pubDate>Fri, 24 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 7 serii o przygotowaniu notACMS do wydania jako open-source. Część 6 opisała pipeline budowania. Oryginalna seria o migracji z WordPressa opisuje samą migrację. Część 4 wyjaśniła jak wzorzec nadpisywania local/ działa w teorii — priorytet przestrzeni nazw Twig, kolejność kaskady SCSS i model łączenia w czasie budowy. Ten post buduje z nim prawdziwy motyw, od początku do końca. Nie zabawko…]]></description>
            <content:encoded><![CDATA[<p><em>To część 7 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/jak-notacms-buduje-statyczna-strone/">Część 6</a> opisała pipeline budowania. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p><a href="/wpisy/wzorzec-lokalnych-nadpisan/">Część 4</a> wyjaśniła jak wzorzec nadpisywania <code>local/</code> działa w teorii — priorytet przestrzeni nazw Twig, kolejność kaskady SCSS i model łączenia w czasie budowy. Ten post buduje z nim prawdziwy motyw, od początku do końca. Nie zabawkowy przykład. Produkcyjny motyw z własnymi komponentami, responsywnymi obrazami, paskiem bocznym i wyszukiwarką.</p>
<h2>Punkty startowe — szablony<a id="punkty-startowe--szablony" href="#punkty-startowe--szablony" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Katalog <code>docs/customization/</code> zawiera sześć zestawów szablonów, każdy demonstrujący jeden wzorzec nadpisywania:</p>
<table>
<thead>
<tr>
<th>Katalog</th>
<th>Wzorzec</th>
<th>Co pokazuje</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>starter-extend/</code></td>
<td>Minimalne nadpisanie bazy</td>
<td>Rozszerza <code>@base</code>, dodaje punkt wejścia <code>app-local</code></td>
</tr>
<tr>
<td><code>block-override/</code></td>
<td>Dostosowywanie na poziomie bloków</td>
<td>Nadpisuje pojedyncze bloki Twig</td>
</tr>
<tr>
<td><code>full-override/</code></td>
<td>Pełna zamiana bazy</td>
<td>Pełna kopia <code>base.html.twig</code> — maksymalna kontrola</td>
</tr>
<tr>
<td><code>material-cards/</code></td>
<td>Układ bloga oparty na kartach</td>
<td>Poziome karty wpisów z obrazem po lewej</td>
</tr>
<tr>
<td><code>translation-override/</code></td>
<td>Własne tłumaczenia</td>
<td>Dodawanie lub zastępowanie kluczy w <code>local/translations/</code></td>
</tr>
<tr>
<td><code>custom-post-card/</code></td>
<td>Nadpisanie pojedynczego komponentu</td>
<td>Zastąpienie jednego komponentu karty + jego SCSS</td>
</tr>
<tr>
<td><code>custom-footer/</code></td>
<td>Pełna baza dla stopki</td>
<td>Zastąpienie bazy bo stopka nie jest blokiem w rdzeniu</td>
</tr>
<tr>
<td><code>self-hosted-fonts/</code></td>
<td>Własna typografia</td>
<td>Deklaracje <code>@font-face</code> + podpowiedzi preload</td>
</tr>
<tr>
<td><code>twig-filter/</code></td>
<td>Własny filtr Twig</td>
<td>Atrybut <code>#[AsTwigFilter]</code>, zero konfiguracji YAML</td>
</tr>
<tr>
<td><code>php-service-decorator/</code></td>
<td>Dekoracja serwisów</td>
<td><code>#[AsDecorator]</code> do zastępowania zachowania rdzenia</td>
</tr>
</tbody>
</table>
<p>Zaczniemy od <code>material-cards</code> i rozbudujemy w pełny motyw.</p>
<h2>Krok 1: Przygotowanie bazy<a id="krok-1-przygotowanie-bazy" href="#krok-1-przygotowanie-bazy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kopiujemy szablon do <code>local/</code>:</p>
<pre><code class="language-bash">cp -r docs/customization/starter-extend/. local/
</code></pre>
<p>Szablon zawiera dwa pliki. Pierwszy, <code>local/templates/base.html.twig</code> — minimalne nadpisanie bazy:</p>
<pre><code class="language-twig">{% extends '@base/base.html.twig' %}

{% block stylesheets %}
    {{ importmap('app') }}
    {{ importmap('app-local') }}
{% endblock %}
</code></pre>
<p>Prefiks <code>@base/</code> jawnie przypina do rdzennych szablonów. Bez niego <code>extends 'base.html.twig'</code> szukałoby najpierw w <code>local/</code> — tworząc cykliczną referencję gdzie local rozszerza samego siebie. Blok <code>stylesheets</code> dodaje punkt wejścia <code>app-local</code> obok rdzennego <code>app</code>.</p>
<p>Drugi, <code>local/assets/app.js</code> — lokalny punkt wejścia JS:</p>
<pre><code class="language-js">import './styles/app_local.scss';
</code></pre>
<p>Ten plik sprawia że <code>app-local</code> pojawia się w <code>importmap.php</code>. Jego jedynym zadaniem jest importowanie lokalnego SCSS. Wszelkie dodatkowe importy JS też trafiają tutaj.</p>
<h2>Krok 2: Nadpisanie tokenów SCSS<a id="krok-2-nadpisanie-tokenów-scss" href="#krok-2-nadpisanie-tokenów-scss" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Tożsamość wizualna motywu żyje w dwóch plikach: <code>local/assets/styles/_tokens.scss</code> (własności CSS) i <code>local/assets/styles/_variables.scss</code> (stałe SCSS).</p>
<p>Motyw demo używa ciemnej palety bursztynowo-fosforowej. Oto co autor motywu zmienia w <code>_tokens.scss</code>:</p>
<pre><code class="language-scss">:root {
    --bg: #171717;           /* prawie czarne tło strony */
    --bg-sidebar: #0f0f0f;   /* jeszcze ciemniejszy sidebar */
    --text: #e8e8e8;         /* jasnoszary tekst */
    --text-muted: #8c8c8c;   /* przygaszony tekst drugorzędny */
    --accent: #f59e0b;       /* bursztynowy kolor wyróżnienia */
    --border: #404040;       /* widoczny ale nie krzykliwy */
    --code-bg: #1c1c1c;      /* tło bloków kodu */
}
</code></pre>
<p>A w <code>_variables.scss</code>:</p>
<pre><code class="language-scss">$font: 'Inter', ui-sans-serif, system-ui, sans-serif;
$font-mono: 'JetBrains Mono', ui-monospace, monospace;
$radius-sm: 4px;
$radius: 6px;
$radius-md: 8px;
</code></pre>
<p>Wszystko inne w kaskadzie — <code>_base.scss</code>, <code>_components.scss</code>, <code>_prose.scss</code> — używa tych zmiennych. Zmień <code>--accent</code> a każdy przycisk, link, odznaka i pierścień focus się aktualizuje. Żaden SCSS komponentu nietknięty.</p>
<h2>Krok 3: Nadpisanie bloków Twig<a id="krok-3-nadpisanie-bloków-twig" href="#krok-3-nadpisanie-bloków-twig" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>base.html.twig</code> definiuje około 20 regionów <code>{% block %}</code>. Te które faktycznie będziesz nadpisywać:</p>
<p><strong><code>stylesheets</code></strong> — zrobione w kroku 1. Dodaje importmapę <code>app-local</code>.</p>
<p><strong><code>favicons</code></strong> — własne ścieżki ikon:</p>
<pre><code class="language-twig">{% block favicons %}
    &lt;link rel=&quot;icon&quot; href=&quot;{{ asset('local/favicon.svg') }}&quot; type=&quot;image/svg+xml&quot;&gt;
    &lt;link rel=&quot;apple-touch-icon&quot; href=&quot;{{ asset('local/apple-touch-icon.png') }}&quot;&gt;
{% endblock %}
</code></pre>
<p>Lokalne assety używają prefiksu ścieżki <code>local/</code> bo <code>asset_mapper.yaml</code> mapuje <code>local/assets/: local</code>.</p>
<p><strong><code>navigation</code></strong> — zastępuje cały include nawigacji:</p>
<pre><code class="language-twig">{% block navigation %}
    {{ include('components/navigation.html.twig') }}
{% endblock %}
</code></pre>
<p>Deleguje do lokalnego nadpisania komponentu — to samo wywołanie include ale rozwiązuje się najpierw do <code>local/templates/components/navigation.html.twig</code>.</p>
<p><strong><code>sidebar_top</code></strong> i <strong><code>sidebar_bottom</code></strong> — własna zawartość widgetów:</p>
<pre><code class="language-twig">{% block sidebar_top %}
    &lt;div class=&quot;widget widget-cta&quot;&gt;
        &lt;h3&gt;Współpraca&lt;/h3&gt;
        &lt;p&gt;Buduję niestandardowe aplikacje Symfony i statyczne strony.&lt;/p&gt;
        &lt;a href=&quot;{{ path('contact_' ~ locale) }}&quot; class=&quot;btn btn-primary&quot;&gt;Skontaktuj się&lt;/a&gt;
    &lt;/div&gt;
{% endblock %}
</code></pre>
<p><strong><code>footer</code></strong> — <strong>nie jest blokiem w rdzeniu.</strong> Stopka to zakodowany HTML w <code>base.html.twig</code>. Żeby ją dostosować, musisz zastąpić cały <code>base.html.twig</code> pełną kopią. To jedyna część systemu która wymaga pełnego nadpisania szablonu. Alternatywą jest dodanie własnego bloku Twig w lokalnym nadpisaniu bazy i aktualizacja wszystkich szablonów stron by go używały, ale pełna zamiana jest prostsza w większości przypadków.</p>
<h2>Krok 4: Nadpisanie szablonów komponentów<a id="krok-4-nadpisanie-szablonów-komponentów" href="#krok-4-nadpisanie-szablonów-komponentów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Serce tworzenia motywu. Kopiuj komponent z <code>templates/components/</code> do <code>local/templates/components/</code> i edytuj:</p>
<pre><code class="language-bash">cp templates/components/post_card.html.twig local/templates/components/post_card.html.twig
</code></pre>
<p>Wywołanie include się nie zmienia:</p>
<pre><code class="language-twig">{{ include('components/post_card.html.twig') }}
</code></pre>
<p>Rozwiązywanie szablonów szuka najpierw w <code>local/templates/</code> (główna przestrzeń nazw <code>''</code>), potem przepada do <code>templates/</code> (rdzeń). Wywołujący nie wie i nie dba który plik jest użyty.</p>
<p>Sześć komponentów które będziesz najczęściej nadpisywać:</p>
<p><strong><code>post_card.html.twig</code></strong> — główna karta wpisu. Otrzymywane zmienne: <code>post</code> (ContentItem), <code>featured</code> (bool), <code>locale</code> (string), <code>new_post_days</code> (int). Lokalne nadpisanie może dodać układ poziomy, przesunąć obraz na prawo lub zmienić kolejność meta-linii.</p>
<p><strong><code>post_card_mini.html.twig</code></strong> — kompaktowa karta dla ostatnich wpisów w sidebarze i powiązanych wpisów. Te same zmienne, mniejszy markup, bez obrazu.</p>
<p><strong><code>navigation.html.twig</code></strong> — główna nawigacja z linkami i przełącznikiem języków. Zmienne: <code>locale</code>, dostęp do funkcji <code>content_item()</code> i <code>path()</code> Twig. Lokalne nadpisanie dodaje, usuwa lub zmienia kolejność elementów nawigacji.</p>
<p><strong><code>sidebar_top.html.twig</code></strong> — widget formularza wyszukiwania. Zmienne: <code>sidebar</code> (obiekt wartości SidebarData). Lokalne nadpisanie zastępuje formularz wyszukiwania widgetem CTA lub własną treścią.</p>
<p><strong><code>sidebar_bottom.html.twig</code></strong> — widgety ostatnich wpisów, kategorii, tagów. Zmienne: <code>sidebar</code>, <code>locale</code>, <code>filter_type</code>, <code>filter_value</code>. Lokalne nadpisanie zmienia prezentację filtrów lub ogranicza liczbę ostatnich wpisów.</p>
<p><strong><code>breadcrumb.html.twig</code></strong> — ścieżka nawigacyjna. Zmienne: <code>breadcrumbs</code> (tablica z funkcji Twig <code>breadcrumbs()</code>). Lokalne nadpisanie zmienia separator z <code>/</code> na <code>→</code> lub restrukturyzuje markup.</p>
<p><strong><code>pagination.html.twig</code></strong> — linki poprzedniej/następnej strony. Zmienne: <code>current_page</code>, <code>total_pages</code>, <code>base_url</code>, <code>paginated_route</code>. Lokalne nadpisanie stylizuje kontener paginacji.</p>
<h2>Krok 5: Tworzenie własnych komponentów<a id="krok-5-tworzenie-własnych-komponentów" href="#krok-5-tworzenie-własnych-komponentów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Wyodrębnij powtarzające się wzorce UI do nowych plików w <code>local/templates/components/</code>:</p>
<p><code>local/templates/components/hero_banner.html.twig</code>:</p>
<pre><code class="language-twig">&lt;section class=&quot;hero-banner&quot;&gt;
    &lt;h1&gt;{{ title }}&lt;/h1&gt;
    {% if subtitle is defined %}
        &lt;p class=&quot;hero-banner__subtitle&quot;&gt;{{ subtitle }}&lt;/p&gt;
    {% endif %}
&lt;/section&gt;
</code></pre>
<p>Załączony w szablonie strony głównej:</p>
<pre><code class="language-twig">{% block body %}
    {{ include('components/hero_banner.html.twig', {
        title: site_name,
        subtitle: site_description
    }) }}
{% endblock %}
</code></pre>
<p>Komponenty otrzymują zmienne z kontekstu szablonu wywołującego — bez specjalnej rejestracji, bez manifestu, bez konfiguracji autoloadingu. Po prostu plik Twig w odpowiednim katalogu.</p>
<h2>Krok 6: Dodanie lokalnego JavaScript<a id="krok-6-dodanie-lokalnego-javascript" href="#krok-6-dodanie-lokalnego-javascript" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Punkt wejścia <code>app-local</code> importuje dodatkowe zachowania:</p>
<pre><code class="language-js">// local/assets/app.js
import './styles/app_local.scss';
import './js/theme-toggle.js';
import './js/scroll-animations.js';
</code></pre>
<p>Pliki JS żyją w <code>local/assets/js/</code> i są kompilowane do <code>public/assets/local/</code> przez <code>asset-map:compile</code>. Są dostępne w szablonach przez:</p>
<pre><code class="language-twig">&lt;script src=&quot;{{ asset('local/js/theme-toggle.js') }}&quot; defer&gt;&lt;/script&gt;
</code></pre>
<p>Kontrolery Stimulus auto-wykrywane z <code>local/assets/controllers/</code> działają zgodnie z konwencją Symfony — po prostu umieść tam plik <code>*_controller.js</code> i odwołuj się atrybutami <code>data-controller</code> w markupie.</p>
<h2>Krok 7: Dodanie własnych funkcji i filtrów Twig<a id="krok-7-dodanie-własnych-funkcji-i-filtrów-twig" href="#krok-7-dodanie-własnych-funkcji-i-filtrów-twig" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Umieść pliki PHP w <code>local/src/</code> pod przestrzenią nazw <code>NotACms\Local\</code>:</p>
<pre><code class="language-php">&lt;?php

declare(strict_types=1);

namespace NotACms\Local\Twig;

use Symfony\Bridge\Twig\Attribute\AsTwigFilter;
use Twig\Extension\AbstractExtension;

final class ReadingTimeExtension extends AbstractExtension
{
    #[AsTwigFilter('reading_time')]
    public function readingTime(string $html, int $wordsPerMinute = 200): string
    {
        $wordCount = str_word_count(strip_tags($html));

        return (string) max(1, (int) ceil($wordCount / $wordsPerMinute));
    }
}
</code></pre>
<p>Zero ręcznej rejestracji serwisów. Plik <code>services.yaml</code> już skanuje <code>local/src/</code>:</p>
<pre><code class="language-yaml">services:
    NotACms\Local\:
        resource: '../local/src/'
</code></pre>
<p>Symfony auto-wykrywa klasy z atrybutami <code>#[AsTwigFilter]</code>, <code>#[AsTwigFunction]</code> lub <code>#[AsTwigExtension]</code>. Użyj atrybutu, umieść plik w <code>local/src/</code>, a funkcja jest dostępna we wszystkich szablonach.</p>
<h2>Krok 8: Dekoracja serwisu<a id="krok-8-dekoracja-serwisu" href="#krok-8-dekoracja-serwisu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Zmień zachowanie rdzenia bez modyfikowania rdzennego kodu. Umieść klasę dekoratora w <code>local/src/Service/</code>:</p>
<pre><code class="language-php">&lt;?php

declare(strict_types=1);

namespace NotACms\Local\Service;

use NotACms\Service\Turnstile\TurnstileValidatorInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;

#[AsDecorator(decorates: TurnstileValidatorInterface::class)]
final readonly class TurnstileSkipDecorator implements TurnstileValidatorInterface
{
    public function verify(string $token, ?string $remoteIp = null): bool
    {
        return true;
    }
}
</code></pre>
<p>Atrybut <code>#[AsDecorator]</code> mówi Symfony by opakowało prawdziwy <code>TurnstileValidator</code> tym dekoratorem. Cały kod type-hintujący <code>TurnstileValidatorInterface</code> otrzymuje teraz dekorator — który całkowicie pomija weryfikację. Przydatne w lokalnym dev gdzie Cloudflare Turnstile nie może się połączyć z <code>localhost</code>. Usuń plik na produkcji, a prawdziwy walidator przejmuje kontrolę.</p>
<h2>Krok 9: Testowanie motywu<a id="krok-9-testowanie-motywu" href="#krok-9-testowanie-motywu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Uruchom <code>ddev build</code> i sprawdź każdy typ strony:</p>
<ol>
<li>Strona główna — hero banner, karty wpisów, sekcja CTA</li>
<li>Lista wpisów — paginacja, linki filtrów, układ kart</li>
<li>Pojedynczy wpis — obraz wyróżniający, pasek postępu czytania, tagi, nawigacja serii, powiązane wpisy</li>
<li>Strona kategorii — filtrowana lista z nagłówkiem kategorii</li>
<li>Strona tagu — filtrowana lista z nagłówkiem tagu</li>
<li>Miesiąc/rok archiwum — lista archiwalna z nagłówkiem daty</li>
<li>Strony statyczne — o mnie, kontakt, realizacje, polityka prywatności</li>
<li>Strona wyszukiwarki — wyniki Pagefind z własnym stylowaniem</li>
<li>Strony błędów — 404 i 500 ze stylowaniem motywu</li>
<li>Styleguide — <code>/styleguide/</code> pokazujący wszystkie komponenty w izolacji</li>
</ol>
<p>Strona styleguide to najcenniejsze narzędzie debugowania. Zaktualizuj <code>local/templates/page/styleguide.html.twig</code> by zawierał nowe własne komponenty. Uruchom build, otwórz styleguide, a każdy komponent — rdzenny i własny — jest widoczny na jednej stronie z przykładowymi danymi.</p>
<p>Sprawdź breakpointy responsywne na mobile (375px), tablecie (768px) i desktopie (1280px). Zweryfikuj że fingerprinting assetów wygenerował nowe hashe w <code>public/assets/</code> — jeśli hashe zgadzają się z poprzednim buildem, SCSS motywu się nie zmienił.</p>
<h2>Mechanizm rozwiązywania szablonów<a id="mechanizm-rozwiązywania-szablonów" href="#mechanizm-rozwiązywania-szablonów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Zrozumienie jak dokładnie Twig rozwiązuje szablony zapobiega cichym błędom:</p>
<pre><code>include('components/post_card.html.twig')
  1. local/templates/components/post_card.html.twig  ← główna przestrzeń '' (pierwsza)
  2. templates/components/post_card.html.twig          ← rdzeń (fallback)

extends '@base/base.html.twig'   ← jawnie przypięte do rdzenia, nigdy local
extends 'base.html.twig'         ← szuka najpierw local, potem rdzeń
</code></pre>
<p>Konfiguracja która to umożliwia, z <code>config/packages/twig.yaml</code>:</p>
<pre><code class="language-yaml">twig:
    paths:
        '%kernel.project_dir%/local/templates': ''    # główna przestrzeń — najwyższy priorytet
        '%kernel.project_dir%/templates': 'base'       # przestrzeń @base — cel jawnego przypięcia
</code></pre>
<p>Główna przestrzeń nazw (<code>''</code>) zawsze ma priorytet. Rdzenne szablony są osiągalne przez <code>@base/</code> gdy potrzebne jest jawne przypięcie. Lokalne szablony które chcą rozszerzać z rdzenia używają <code>{% extends '@base/base.html.twig' %}</code> — to nigdy się nie zmienia, nawet jeśli lokalny <code>base.html.twig</code> też istnieje.</p>
<h2>Strategia kaskady SCSS<a id="strategia-kaskady-scss" href="#strategia-kaskady-scss" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Dwa punkty wejścia importmap tworzą kaskadę bez <code>!important</code>:</p>
<pre><code>Przeglądarka ładuje:
  1. public/assets/app-{h1}.css   ← kaskada rdzenia (tokens → variables → components → ...)
  2. public/assets/local/app-{h2}.css ← kaskada lokalna (tokens → variables → własne komponenty)

Wynik:
  Lokalne reguły ładują się po rdzennych.
  Przy równej specyficzności, lokalne wygrywają.
  Nigdzie !important.
</code></pre>
<p>To ten sam mechanizm który wzorzec <code>local/</code> opisał w części 4, teraz zastosowany do prawdziwej budowy motywu. Rozdzielenie odpowiedzialności jest czyste: rdzeń dostarcza bazowe style przez zmienne, local nadpisuje te zmienne i dodaje własne style komponentów. Kaskada robi resztę.</p>
<h2>Porównanie do innych podejść<a id="porównanie-do-innych-podejść" href="#porównanie-do-innych-podejść" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<table>
<thead>
<tr>
<th>Podejście</th>
<th>Mechanizm motywu</th>
<th>Model nadpisywania</th>
<th>Ograniczenia</th>
</tr>
</thead>
<tbody>
<tr>
<td>Motywy potomne WordPress</td>
<td>Osobny katalog, <code>@import</code> CSS rodzica</td>
<td>Tylko pełna zamiana plików</td>
<td>Kruche — aktualizacje rodzica psują child jeśli zmienią się selektory</td>
</tr>
<tr>
<td>Motywy Hugo / Jekyll</td>
<td>Szablony Go/Liquid, <code>config.toml</code></td>
<td>Napędzane konfiguracją, limitowane do wyeksponowanych parametrów</td>
<td>Nie można nadpisać dowolnych szablonów</td>
</tr>
<tr>
<td>Motywy SSG oparte na npm</td>
<td><code>npm install nazwa-motywu</code>, plik konfiguracyjny</td>
<td>Oparte na wtyczkach lub konfiguracji</td>
<td>Ciężki toolchain, motyw jest zależnością</td>
</tr>
<tr>
<td><strong>notACMS <code>local/</code></strong></td>
<td>Jeden katalog, scalanie w czasie builda</td>
<td>Nadpisywanie na poziomie plików + rozszerzanie na poziomie bloków</td>
<td>Stopka wymaga zastąpienia bazy</td>
</tr>
</tbody>
</table>
<p>Motywy notACMS to po prostu Twig i SCSS. Bez specjalnego formatu, bez manifestu motywu, bez rejestracji. Cały &quot;system motywów&quot; to dwie linijki w <code>twig.yaml</code> i warunkowy wpis w <code>importmap.php</code>. Kopiuj plik, edytuj, buduj.</p>
<hr />
<p><a href="/wpisy/pagefind-wyszukiwanie-statyczne/">Część 8</a> zagłębia się w warstwę wyszukiwania: indeksowanie WASM Pagefind, dwa interfejsy wyszukiwania (samodzielna strona i nakładka Cmd+K), wdrożenie produkcyjne na ARM64 i czego Pagefind nie potrafi.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-24-building-notacms-theme/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[architektura]]></category>
                        <category><![CDATA[rwd]]></category>
                    </item>
                <item>
            <title><![CDATA[Jak notACMS buduje statyczną stronę — pełny pipeline]]></title>
            <link>https://holas.pl/pl/wpisy/jak-notacms-buduje-statyczna-strone/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/jak-notacms-buduje-statyczna-strone/</guid>
                        <pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[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…]]></description>
            <content:encoded><![CDATA[<p><em>To część 6 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/otwarcie-zrodel-projektu-osobistego/">Część 5</a> opisuje listę kontrolną open-source. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>Polecenie <code>ddev build</code> 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.</p>
<h2>10-etapowy pipeline<a id="10-etapowy-pipeline" href="#10-etapowy-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Etap 1: Zainicjowanie <code>local/</code><a id="etap-1-zainicjowanie-local" href="#etap-1-zainicjowanie-local" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Zanim cokolwiek się skompiluje, build upewnia się że istnieje działający katalog <code>local/</code>. Jeśli jest pusty lub nie istnieje, build kopiuje do niego <code>docs/demo/</code>:</p>
<pre><code class="language-bash">cp -r docs/demo/. local/
</code></pre>
<p>Jeśli <code>local/</code> ma już prawdziwą zawartość, build pomija ten krok. Flaga <code>--bare</code> inicjuje z <code>docs/bare/</code> — minimalnego szkieletu bez treści, przydatnego przy zaczynaniu od zera. Flaga <code>--demo</code> wymusza ponowną inicjalizację nawet jeśli treść istnieje, tworząc kopię zapasową starego <code>local/</code> jako <code>local-{TIMESTAMP}/</code>.</p>
<p>Build kopiuje też <code>assets/images/og-default.jpg</code> do <code>local/assets/images/</code> jeśli go brakuje — zapasowy obraz Open Graph, gdy strona nie ma własnego.</p>
<h3>Etap 2: Instalacja Composera<a id="etap-2-instalacja-composera" href="#etap-2-instalacja-composera" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">composer install --optimize-autoloader --no-interaction
</code></pre>
<p>Standardowy krok Symfony. Flaga <code>--optimize-autoloader</code> tworzy classmapę dla szybszego autoloadingu — istotne, bo każde pod-żądanie w buildzie tworzy nowy kontener serwisów.</p>
<h3>Etap 3: Wyczyszczenie dart-sass<a id="etap-3-wyczyszczenie-dart-sass" href="#etap-3-wyczyszczenie-dart-sass" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">rm -rf var/dart-sass
</code></pre>
<p>Polecenie <code>sass:build</code> 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.</p>
<h3>Etap 4: Wyczyszczenie cache<a id="etap-4-wyczyszczenie-cache" href="#etap-4-wyczyszczenie-cache" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console cache:clear
</code></pre>
<p>Tworzy skompilowany kontener wstrzykiwania zależności Symfony. Ciepły cache poprawia wydajność pod-żądań podczas budowania statycznego.</p>
<h3>Etap 5: Kompilacja SCSS<a id="etap-5-kompilacja-scss" href="#etap-5-kompilacja-scss" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console sass:build
</code></pre>
<p>Kompiluje <code>assets/styles/app.scss</code> do <code>var/sass/app.css</code>. Kaskada importów rdzennego SCSS, w kolejności:</p>
<pre><code>_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
</code></pre>
<p>Lokalne motywy nadpisują <code>_tokens.scss</code> i <code>_variables.scss</code> — wszystko inne dziedziczy nowe wartości przez kaskadę. Zmień <code>--accent</code> z <code>#2563EB</code> na <code>#FFA040</code>, a każdy komponent, przycisk i link się aktualizuje bez dotykania SCSS komponentów.</p>
<h3>Etap 6: Asset-map compile<a id="etap-6-asset-map-compile" href="#etap-6-asset-map-compile" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">rm -rf public/assets
php bin/console asset-map:compile
</code></pre>
<p>Najpierw czyści wcześniej skompilowane assety, potem kompiluje wszystko zmapowane przez Symfony AssetMapper:</p>
<ul>
<li><code>assets/app.js</code> → <code>public/assets/app-{contenthash}.js</code></li>
<li><code>var/sass/app.css</code> → <code>public/assets/app-{contenthash}.css</code></li>
<li><code>local/assets/**/*</code> → <code>public/assets/local/**/*</code> (jeśli <code>local/assets/</code> istnieje)</li>
</ul>
<p><code>{contenthash}</code> 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ą.</p>
<p>Po kompilacji paczka <code>sensiolabs_minify</code> minifikuje wszystkie pliki CSS i JS. Plik <code>importmap.php</code> definiuje które punkty wejścia istnieją:</p>
<pre><code class="language-php">return array_filter([
    'app' =&gt; [
        'path' =&gt; './assets/app.js',
        'entrypoint' =&gt; true,
    ],
    'app-local' =&gt; file_exists(__DIR__ . '/local/assets/app.js')
        ? [
            'path' =&gt; './local/assets/app.js',
            'entrypoint' =&gt; true,
          ]
        : null,
]);
</code></pre>
<p>Dwa punkty wejścia: <code>app</code> (zawsze — rdzenny CSS + JS) i <code>app-local</code> (warunkowy — tylko jeśli <code>local/assets/app.js</code> istnieje). Oba są <code>entrypoint: true</code>, więc <code>{{ importmap('app') }}</code> i <code>{{ importmap('app-local') }}</code> w Twigu ładują je niezależnie. <code>app-local</code> ładuje się po <code>app</code> w szablonie, dając lokalnemu CSS ostatnie słowo w przypadku równej specyficzności bez <code>!important</code>.</p>
<h3>Etap 7: Budowa statycznego HTML<a id="etap-7-budowa-statycznego-html" href="#etap-7-budowa-statycznego-html" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console app:build -v
</code></pre>
<p>To jest sedno magii. Flaga <code>-v</code> włącza szczegółowe wyjście, pokazujące każdy wyrenderowany URL. Pięć pod-etapów wykonuje się sekwencyjnie:</p>
<p><strong>7a. Unieważnienie cache treści</strong></p>
<p>Build czyści pulę cache <code>app.content</code> dla każdego skonfigurowanego locale. Cache przechowuje sparsowane obiekty <code>ContentTree</code> — 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 <code>var/cache/</code>.</p>
<p><strong>7b. Zebranie tras</strong></p>
<p>Metoda <code>collectRoutes()</code> zbiera każdy URL który serwuje strona. Dla każdego locale:</p>
<ul>
<li>Strona główna (<code>home_{locale}</code>)</li>
<li>Lista wpisów (<code>blog_list_{locale}</code>)</li>
<li>Paginowane strony listy (strony 2 do <code>ceil(liczbaPostów / postówNaStronę)</code>)</li>
<li>URL każdego opublikowanego wpisu (`$post-&gt;url()``)</li>
<li>URL każdego zaplanowanego wpisu (renderuje stronę „wkrótce&quot; z <code>noindex</code>)</li>
<li>Każda strona kategorii (<code>blog_category_{locale}</code>)</li>
<li>Każda strona tagu (<code>blog_tag_{locale}</code>)</li>
<li>Każdy miesiąc archiwum (<code>blog_archive_{locale}</code> z parametrami rok + miesiąc)</li>
<li>Każdy rok archiwum (<code>blog_archive_year_{locale}</code> — osobna trasa od miesięcy)</li>
<li>Każda strona statyczna (nie-dynamiczna, niepusty URL, nie strona główna)</li>
<li>Strona wyszukiwarki (<code>search_{locale}</code>)</li>
</ul>
<p>Wynik jest deduplikowany przez <code>array_unique()</code>. 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.</p>
<p><strong>7c. Renderowanie stron</strong></p>
<p>Każdy URL jest renderowany przez pod-żądanie Symfony:</p>
<pre><code class="language-php">$request = Request::create($url, Request::METHOD_GET);
$request-&gt;attributes-&gt;set('_static_build', true);

$response = $this-&gt;httpKernel-&gt;handle(
    $request,
    HttpKernelInterface::SUB_REQUEST,
    false,
);
</code></pre>
<p>Atrybut <code>_static_build</code> 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.)</p>
<p>Ciało odpowiedzi jest zapisywane na dysk jako <code>{katalogWyjściowy}/{url}/index.html</code>. Główny URL <code>/</code> staje się <code>index.html</code> 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).</p>
<p>Po wyrenderowaniu wszystkich stron z treścią, build renderuje URLe feedów:</p>
<ul>
<li><code>robots_{locale}</code> — zapisywany jako <code>{katalogWyjściowy}/robots.txt</code></li>
<li><code>sitemap_{locale}</code> — zapisywany jako <code>{katalogWyjściowy}/sitemap.xml</code> (lub <code>index.xml</code> dla URL-i zakończonych ukośnikiem)</li>
<li><code>rss_{locale}</code> — zapisywany jako <code>{katalogWyjściowy}/rss.xml</code> (lub <code>index.xml</code>)</li>
</ul>
<p>Następnie strony błędów są renderowane przez pod-żądanie:</p>
<ul>
<li><code>error_404_{locale}</code> → <code>{prefiks}404.html</code></li>
<li><code>error_500_{locale}</code> → <code>{prefiks}500.html</code></li>
</ul>
<p>Gdzie <code>{prefiks}</code> to ścieżka locale innego niż domyślny (np. <code>pl/404.html</code>). Są one wstępnie renderowane, żeby nginx mógł je serwować bezpośrednio, bez wybudzania PHP-FPM.</p>
<p>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 <code>ddev start</code> ląduje na produkcji. Bez osobnej ścieżki renderowania. Bez rozbieżności szablonów między trybem dev a build.</p>
<p><strong>7d. Kopiowanie plików mediów</strong></p>
<p>Symfony Finder lokalizuje każdy katalog <code>files/</code> w drzewie treści i mirroruje je do wyjścia:</p>
<pre><code>{katalogTreści}/{katalogWpisu}/files/  →  {katalogWyjściowy}/media/{katalogWpisu}/
</code></pre>
<p>To lustrzane kopiowanie brute-force — bez hashowania, bez deduplikacji, bez tree-shakingu. Autorzy treści kontrolują co jest w <code>files/</code>. Usuń stamtąd plik, znika z kolejnego builda. Prostota jest zamierzona: bez grafu assetów, bez manifestu builda, bez zliczania referencji.</p>
<p><strong>7e. Optymalizacja oryginałów i generowanie wariantów responsywnych</strong></p>
<p>Dwa przebiegi ImageMagick na zduplikowanym katalogu mediów:</p>
<p>Optymalizacja każdego pliku <code>.webp</code> (z pominięciem istniejących wariantów identyfikowanych przez wzorzec sufiksu <code>-{szerokość}w</code>):</p>
<pre><code class="language-php">$this-&gt;imageResizer-&gt;optimize($path);
// → exec('magick convert input.webp -quality 82 -strip -define webp:method=6 output.webp')
</code></pre>
<p>Jakość domyślnie 82, konfigurowalna w <code>_site.yaml</code>. Flaga <code>-strip</code> usuwa metadane (EXIF, profile ICC). Flaga <code>webp:method=6</code> używa najwolniejszego ale najwydajniejszego enkodera WebP.</p>
<p>Generowanie wariantów responsywnych dla każdego oryginału <code>.webp</code>:</p>
<pre><code class="language-php">$width = getimagesize($path)[0];
$variantWidths = $this-&gt;responsiveImageService-&gt;getVariantWidths($width);
// [640, 960] przefiltrowane z konfiguracji — tylko szerokości &lt; źródłowa

foreach ($variantWidths as $variantWidth) {
    $this-&gt;imageResizer-&gt;resize($path, $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp', $variantWidth);
}
</code></pre>
<p>Dla obrazu źródłowego 1280px z skonfigurowanymi szerokościami wariantów <code>[640, 960]</code>, produkuje to <code>image-640w.webp</code> i <code>image-960w.webp</code>. Oryginalny <code>image.webp</code> służy jako fallback 1280w.</p>
<h3>Etap 8: Kopiowanie favicon<a id="etap-8-kopiowanie-favicon" href="#etap-8-kopiowanie-favicon" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">cp local/assets/favicon.ico public/
</code></pre>
<p>Jeśli <code>local/assets/favicon.ico</code> nie istnieje, sięga po <code>assets/images/favicon.ico</code> z rdzenia. Blok <code>favicons</code> w <code>base.html.twig</code> można nadpisać w lokalnych szablonach by wskazywać inne ścieżki ikon.</p>
<h3>Etap 9: Indeks Pagefind<a id="etap-9-indeks-pagefind" href="#etap-9-indeks-pagefind" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Pagefind skanuje wstępnie wyrenderowany HTML w poszukiwaniu regionów <code>data-pagefind-body</code>, buduje per-językowe binarne indeksy wyszukiwania WASM i zapisuje ~437 plików w <code>public/pagefind/</code>. 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 <code>node_modules</code>.</p>
<p>Skrypt produkcyjnego deployu (<code>scripts/rebuild-content.sh</code>) przypina Pagefind do wersji <code>@1.5.0</code>:</p>
<pre><code class="language-bash">docker compose run --rm php npx --yes pagefind@1.5.0 --site public/static --output-path public/pagefind
</code></pre>
<p>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: <code>&quot;&lt;jemalloc&gt;: Unsupported system page size&quot;</code>. Przypięcie do 1.5.0 omija ten problem. DDEV-lokalny skrypt <code>build</code> nie przypina — działa na x86_64 gdzie wszystkie wersje są poprawne.</p>
<h3>Etap 10: nginx serwuje<a id="etap-10-nginx-serwuje" href="#etap-10-nginx-serwuje" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Po wszystkich krokach <code>public/</code> zawiera trzy katalogi:</p>
<pre><code>public/
├── assets/        ← zfingerprintowane JS/CSS (immutable, długi cache)
├── static/        ← cały HTML + RSS + sitemap + media/
└── pagefind/      ← indeks wyszukiwarki (WASM + metadane)
</code></pre>
<p>Nginx <code>try_files</code> rozwiązuje każde przychodzące żądanie do wstępnie wyrenderowanego pliku:</p>
<pre><code class="language-nginx">location / {
    try_files /static$uri/index.html /static$uri /static$uri.html =404;
}
</code></pre>
<p>PHP-FPM jest wołane tylko dla endpointa formularza kontaktowego:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass php:9000;
}
</code></pre>
<p>Cała strona poza formularzem kontaktowym to statyczne pliki na dysku. Bez runtime PHP. Bez bazy danych. Bez serwera aplikacji.</p>
<h2>Podsumowanie pipeline'u<a id="podsumowanie-pipelineu" href="#podsumowanie-pipelineu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<table>
<thead>
<tr>
<th>Etap</th>
<th>Polecenie</th>
<th>Produkuje</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. Inicjalizacja local/</td>
<td><code>cp -r docs/demo/. local/</code></td>
<td>Działająca konfiguracja strony</td>
</tr>
<tr>
<td>2. Composer</td>
<td><code>composer install --optimize-autoloader</code></td>
<td>Autoload vendora</td>
</tr>
<tr>
<td>3. Czyszczenie dart-sass</td>
<td><code>rm -rf var/dart-sass</code></td>
<td>Czysty stan builda</td>
</tr>
<tr>
<td>4. Czyszczenie cache</td>
<td><code>cache:clear</code></td>
<td>Skompilowany kontener DI</td>
</tr>
<tr>
<td>5. SCSS</td>
<td><code>sass:build</code></td>
<td><code>var/sass/app.css</code></td>
</tr>
<tr>
<td>6. Kompilacja assetów</td>
<td><code>asset-map:compile</code></td>
<td><code>public/assets/app-{hash}.css</code></td>
</tr>
<tr>
<td>7. Budowa statyczna</td>
<td><code>app:build -v</code></td>
<td><code>public/static/*/index.html</code></td>
</tr>
<tr>
<td>8. Favicon</td>
<td><code>cp</code></td>
<td><code>public/favicon.ico</code></td>
</tr>
<tr>
<td>9. Pagefind</td>
<td><code>npx pagefind</code></td>
<td><code>public/pagefind/</code></td>
</tr>
<tr>
<td>10. nginx</td>
<td><code>try_files</code></td>
<td>Serwuje wszystko</td>
</tr>
</tbody>
</table>
<h2>Decyzje projektowe<a id="decyzje-projektowe" href="#decyzje-projektowe" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Dlaczego pod-żądania?<a id="dlaczego-pod-żądania" href="#dlaczego-pod-żądania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Pod-żądania ponownie wykorzystują te same kontrolery i szablony Twig używane w trybie deweloperskim. Bez osobnej ścieżki renderowania. To co widzisz w <code>ddev start</code> trafia na produkcję. Atrybut <code>_static_build</code> 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.</p>
<h3>Dlaczego osobne etapy SCSS i asset-map?<a id="dlaczego-osobne-etapy-scss-i-asset-map" href="#dlaczego-osobne-etapy-scss-i-asset-map" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>sass:build</code> produkuje surowe CSS — przydatne w trybie deweloperskim watch, gdzie rekompilujesz przy zmianach plików ale nie chcesz re-fingerprintować przy każdym zapisie. <code>asset-map:compile</code> fingerprintuje i minifikuje — zmartwienie wyłącznie produkcyjne. Rozdzielenie oznacza że te dwie odpowiedzialności pozostają niezależne.</p>
<h3>Dlaczego brute-copy mediów?<a id="dlaczego-brute-copy-mediów" href="#dlaczego-brute-copy-mediów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Autorzy treści kontrolują co jest w <code>files/</code>. Bez manifestu builda. Bez tree-shakingu. Bez grafu assetów. Usuń z <code>files/</code>, znika z builda. Prostota jest warta miejsca na dysku — a miejsce na dysku dla obrazów WebP na statycznej stronie jest trywialne.</p>
<h3>Dlaczego ImageMagick CLI, nie GD lub Imagick?<a id="dlaczego-imagemagick-cli-nie-gd-lub-imagick" href="#dlaczego-imagemagick-cli-nie-gd-lub-imagick" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">exec('magick convert ...')
</code></pre>
<p>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.</p>
<h3>Dlaczego Pagefind jako osobny krok?<a id="dlaczego-pagefind-jako-osobny-krok" href="#dlaczego-pagefind-jako-osobny-krok" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>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 <code>node_modules</code> — znaczące uproszczenie dla deploymentu i setupu deweloperskiego.</p>
<h2>Wydajność<a id="wydajność" href="#wydajność" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>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:</p>
<ul>
<li><strong>ImageMagick</strong> — 100+ optymalizacji obrazów i generowania wariantów. Obecnie sekwencyjne — jednowątkowa pętla PHP przez każdy plik. Zrównoleglenie (wiele <code>exec()</code> jednocześnie) byłoby największą pojedynczą poprawą.</li>
<li><strong>Renderowanie pod-żądań</strong> — ~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.</li>
<li><strong>Pagefind</strong> — 2–5 sekund, zdominowane przez kompilację WASM z kodu źródłowego Rust, nie parsowanie HTML. Skaluje się liniowo z liczbą stron.</li>
</ul>
<h2>Punkty dostosowywania<a id="punkty-dostosowywania" href="#punkty-dostosowywania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pipeline ma pięć celowych punktów wstrzykiwania dla lokalnych nadpisań:</p>
<ul>
<li><strong><code>local/assets/styles/</code></strong> — nadpisuj częściowe pliki SCSS (<code>_tokens.scss</code>, <code>_variables.scss</code>)</li>
<li><strong><code>local/assets/</code></strong> — dodawaj własny JS (staje się punktem wejścia <code>app-local</code> gdy <code>app.js</code> istnieje)</li>
<li><strong><code>local/templates/</code></strong> — nadpisuj dowolny szablon Twig (przepada do rdzenia przez priorytet przestrzeni nazw)</li>
<li><strong><code>local/content/_site.yaml</code></strong> — zmieniaj szerokości wariantów, jakość obrazów, flagi Magick, liczbę postów na stronę</li>
<li><strong><code>local/src/</code></strong> — dodawaj własne rozszerzenia Twig lub dekoratory serwisów (auto-wykrywane przez Symfony)</li>
</ul>
<p>Zero plików konfiguracyjnych do edycji. Zero kroku rejestracji. Zero systemu wtyczek frameworka. Kopiuj plik do <code>local/</code>, edytuj, buduj. To cały model.</p>
<hr />
<p><a href="/wpisy/budowanie-motywu-notacms/">Część 7</a> opisuje budowę kompletnego motywu produkcyjnego na tym pipeline — bloki Twig, tokeny SCSS, własne komponenty, lokalny JS i dekorację serwisów. <a href="/wpisy/pagefind-wyszukiwanie-statyczne/">Część 8</a> zagłębia się w Pagefind: dwa interfejsy wyszukiwania, kontrakt <code>data-pagefind-*</code>, deployment produkcyjny i wojenna opowieść o przypinaniu wersji na ARM64.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-17-how-notacms-builds-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[architektura]]></category>
                        <category><![CDATA[dev-tools]]></category>
                    </item>
                <item>
            <title><![CDATA[Open-source notACMS — pełna lista kontrolna]]></title>
            <link>https://holas.pl/pl/wpisy/otwarcie-zrodel-projektu-osobistego/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/otwarcie-zrodel-projektu-osobistego/</guid>
                        <pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 5 serii o przygotowaniu notACMS do wydania jako open-source. Część 4 opisuje wzorzec lokalnych nadpisań. Oryginalna seria o migracji z WordPressa opisuje samą migrację. Spędziłem miesiąc przygotowując kod mojej osobistej strony do wydania jako open-source. Nie dlatego że spodziewałem się tysięcy kontrybutorów — ale dlatego że publiczne udostępnienie zmusiło mnie do napisania dokumentacji,…]]></description>
            <content:encoded><![CDATA[<p><em>To część 5 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/wzorzec-lokalnych-nadpisan/">Część 4</a> opisuje wzorzec lokalnych nadpisań. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>Spędziłem miesiąc przygotowując kod mojej osobistej strony do wydania jako open-source. Nie dlatego że spodziewałem się tysięcy kontrybutorów — ale dlatego że publiczne udostępnienie zmusiło mnie do napisania dokumentacji, dodania testów, naprawy luk bezpieczeństwa i uczynienia architektury faktycznie wielokrotnego użytku. Kod który jest wystarczająco dobry żeby pokazać nieznajomym, jest lepszym kodem niż ten który nie jest.</p>
<p>Oto pełna lista kontrolna.</p>
<h2>Dlaczego open-source projektu osobistego?<a id="dlaczego-open-source-projektu-osobistego" href="#dlaczego-open-source-projektu-osobistego" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Odpowiedzialność — kod który pokażesz światu jest lepszym kodem. Dyscyplina dokumentacji — piszesz dokumentację gdy nieznajomi mogą ją czytać. I ostateczne ćwiczenie refaktoryzacji: czy byłbyś dumny pokazując ten kod?</p>
<p>notACMS to oparty na Symfony generator statycznych stron bez bazy danych, bez CMS i bez PHP zaangażowanego w serwowanie treści, na licencji Apache 2.0. Jeśli ktoś inny może go użyć, to waliduje architekturę.</p>
<h2>Audyt: wynik 9.5/10 gotowości<a id="audyt-wynik-9510-gotowości" href="#audyt-wynik-9510-gotowości" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<table>
<thead>
<tr>
<th>Kategoria</th>
<th>Wynik</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>LICENSE (Apache 2.0)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>README</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Dokumentacja (5 docs, 1227 linii)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Konfiguracja Rector</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Narzędzia jakości kodu (CS Fixer, PHPStan, Rector, Twig lint)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Wytyczne kontrybucji</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Polityka bezpieczeństwa</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Workflows CI/CD</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Szablony Issue/PR</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Suite testów (368 testów, ~80% pokrycia)</td>
<td>8/10</td>
<td>✅</td>
</tr>
<tr>
<td>Zahardcodowane sekrety (udokumentowane w .env)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>CHANGELOG</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td><strong>Ogółem</strong></td>
<td><strong>9.5/10</strong></td>
<td><strong>Gotowy</strong></td>
</tr>
</tbody>
</table>
<h2>Czego brakowało (i jak to naprawiłem)<a id="czego-brakowało-i-jak-to-naprawiłem" href="#czego-brakowało-i-jak-to-naprawiłem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>GitHub Actions CI/CD</strong> — <code>.github/workflows/ci.yml</code>: PHP 8.5, composer install, validate, audit, Twig lint, Rector dry-run, CS Fixer, PHPStan, PHPUnit. Nic nie trafia na produkcję dopóki wszystkie checki nie przejdą.</p>
<p><strong>CONTRIBUTING.md</strong> — Setup DDEV, standardy stylu kodu, proces PR, konwencje commitów. Bariera kontrybucji powinna być jedna komenda: <code>ddev start</code>.</p>
<p><strong>SECURITY.md</strong> — Obsługiwane wersje, raportowanie luk (prywatne zgłoszenie), polityka aktualizacji bezpieczeństwa. Nawet dla jednoosobowego projektu, posiadanie zdefiniowanego procesu ma znaczenie.</p>
<p><strong>CHANGELOG.md</strong> — Format Keep a Changelog 1.1.0, wpis początkowego wydania. Przyszłe wydania będą śledzić co się zmieniło.</p>
<p><strong>Szablony Issue/PR</strong> — Raport błędu, propozycja funkcji, wytyczne PR. Dobre szablony redukują obciążenie poznawcze zarówno dla zgłaszającego jak i opiekuna.</p>
<p><strong>CODEOWNERS</strong> — Śledzenie własności kodu. Jedna osoba, ale to dobra praktyka i sygnalizuje że projekt jest utrzymywany.</p>
<p><strong>Suite testów</strong> — 368 testów z Unit i Integration, opisane w <a href="/wpisy/368-testow-dla-statycznego-generatora-stron/">części 1</a> tej serii.</p>
<h2>Rector jako zautomatyzowana refaktoryzacja<a id="rector-jako-zautomatyzowana-refaktoryzacja" href="#rector-jako-zautomatyzowana-refaktoryzacja" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Rector używa <code>withPreparedSets(codeQuality: true, codingStyle: true, deadCode: true, typeDeclarations: true, symfonyCodeQuality: true)</code> plus <code>withPhpSets(php85: true)</code>. Dry-run w CI, <code>--fix</code> w <code>ddev code-fix</code>. Co łapie: zbędne checki null, uproszczenia tablic, nowoczesna składnia PHP 8.5, martwy kod, deklaracje typów. Co pomija: <code>ControllerMethodInjectionToConstructorRector</code> — ErrorController potrzebuje wartości runtime których nie da się wstrzyknąć przez konstruktor.</p>
<h2>Co pominąłem (i dlaczego)<a id="co-pominąłem-i-dlaczego" href="#co-pominąłem-i-dlaczego" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Code of Conduct</strong> — Jednoosobowy projekt. Brak społeczności do zarządzania. Jeśli się zmieni, to się zmieni.</p>
<p><strong>Informacje o licencjach stron trzecich</strong> — Zależności MIT nie wymagają atrybucji w źródle. Może później jeśli projekt urośnie.</p>
<p><strong>100% pokrycia testami</strong> — Pozostałe ~20% to wysyłanie emaili, ścieżki kopiowania mediów, <code>__invoke</code> error controllera. Wysoki koszt uruchomienia, niski zwrot. 80% pokrywa wszystkie publiczne API, główne ścieżki kodu i przypadki brzegowe.</p>
<h2>Pipeline deployowania<a id="pipeline-deployowania" href="#pipeline-deployowania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">ddev code-check   # Composer validate + audit, Twig lint, Rector, CS Fixer, PHPStan
ddev test         # 368 testów, 490 asercji
./deploy.sh --prod
</code></pre>
<p>Brama jakości: nic nie trafia na produkcję dopóki wszystkie checki nie przejdą. Skrypt deployu działa wewnątrz produkcyjnego kontenera PHP gdzie znana jest poprawna architektura CPU. Po buildzie nginx już serwuje nowe statyczne pliki.</p>
<h2>Wynik: notACMS<a id="wynik-notacms" href="#wynik-notacms" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Generator statycznych stron oparty na Symfony, na licencji Apache 2.0, z dokumentacją, testami, CI i wzorcem lokalnych nadpisań. Nie framework — szablon projektu który możesz sklonować, customizować przez <code>local/</code> i deployować. Cały kod, treść i konfiguracja mieści się w jednym repozytorium gita. Backup to <code>git push</code>.</p>
<p>Ta seria zaczęła się od <a href="/wpisy/368-testow-dla-statycznego-generatora-stron/">368 testów</a>, przeszła przez <a href="/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/">audyt AI</a>, <a href="/wpisy/audyt-bezpieczenstwa-statycznej-strony/">poprawki bezpieczeństwa</a> i <a href="/wpisy/wzorzec-lokalnych-nadpisan/">wzorzec lokalnych nadpisań</a>. Po wydaniu, kolejne posty opisują <a href="/wpisy/jak-notacms-buduje-statyczna-strone/">pipeline budowania</a>, <a href="/wpisy/budowanie-motywu-notacms/">budowę produkcyjnego motywu</a> i <a href="/wpisy/pagefind-wyszukiwanie-statyczne/">wyszukiwarkę Pagefind</a>. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opowiada historię jak ten kod powstał.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-10-open-sourcing-personal-project/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[open-source]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                    </item>
                <item>
            <title><![CDATA[Wzorzec lokalnych nadpisań — szablony Symfony bez forkowania]]></title>
            <link>https://holas.pl/pl/wpisy/wzorzec-lokalnych-nadpisan/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/wzorzec-lokalnych-nadpisan/</guid>
                        <pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 4 serii o przygotowaniu notACMS do wydania jako open-source. Część 3 opisuje luki bezpieczeństwa. Oryginalna seria o migracji z WordPressa opisuje samą migrację. Chcesz udostępnić szablon projektu Symfony. Każdy użytkownik musi go dostosować — inne kolory, inna strona główna, inna nawigacja. Forkowanie oznacza że nie może pobierać aktualizacji z upstreama. Pliki konfiguracyjne nie obsłużą…]]></description>
            <content:encoded><![CDATA[<p><em>To część 4 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/audyt-bezpieczenstwa-statycznej-strony/">Część 3</a> opisuje luki bezpieczeństwa. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>Chcesz udostępnić szablon projektu Symfony. Każdy użytkownik musi go dostosować — inne kolory, inna strona główna, inna nawigacja. Forkowanie oznacza że nie może pobierać aktualizacji z upstreama. Pliki konfiguracyjne nie obsłużą zmian w szablonach. Motywy są zbyt sztywne.</p>
<p>Rozwiązanie które zbudowałem dla notACMS to katalog <code>local/</code> który scala się na projekt bazowy w czasie buildu. Użytkownicy customizują <code>local/</code>, reszta zostaje nienaruszona, a gdy upstream się zmienia — pull i merge jak każda inna operacja gita.</p>
<h2>Problem<a id="problem" href="#problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Trzy scenariusze których pliki konfiguracyjne nie rozwiążą:</p>
<ul>
<li>Użytkownik A chce inne kolory CSS i customowy layout strony głównej</li>
<li>Użytkownik B chce nadpisać tylko komponent nawigacji</li>
<li>Użytkownik C chce dodać własne stringi tłumaczeń</li>
</ul>
<p>Forkowanie to tradycyjna odpowiedź. Ale forkowanie oznacza że każda aktualizacja upstreama to ręczny merge. Dla projektu który dostaje regularne usprawnienia, to obciążenie utrzymaniowe które zabija adopcję.</p>
<h2>Rozwiązanie: Warstwa scalania<a id="rozwiązanie-warstwa-scalania" href="#rozwiązanie-warstwa-scalania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>local/</code> leży obok projektu bazowego. W czasie buildu ścieżki rozwiązują się z priorytetem: <code>local/</code> pierwsze, baza druga. Trzy typy nadpisań:</p>
<p><strong>Pełne nadpisanie</strong> — Zamiana całego pliku. <code>local/templates/base.html.twig</code> zastępuje <code>templates/base.html.twig</code> kompletnie.</p>
<p><strong>Nadpisanie bloków</strong> — Rozszerzenie i nadpisanie konkretnych bloków Twig:</p>
<pre><code class="language-twig">{% extends 'base.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    &lt;link rel=&quot;stylesheet&quot; href=&quot;{{ asset('styles/custom.css') }}&quot;&gt;
{% endblock %}
</code></pre>
<p><strong>Nadpisanie tłumaczeń</strong> — <code>local/translations/messages.en.yaml</code> scala się z bazowymi tłumaczeniami, dodając lub zastępując klucze:</p>
<pre><code class="language-yaml"># local/translations/messages.en.yaml
site.title: &quot;Moja Własna Strona&quot;
nav.home: &quot;Strona główna&quot;
</code></pre>
<h2>Kolejność CSS: Importmap z dwoma entrypointami<a id="kolejność-css-importmap-z-dwoma-entrypointami" href="#kolejność-css-importmap-z-dwoma-entrypointami" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Najtrudniejsza część. Lokalne style muszą ładować się <em>po</em> stylach bazowych żeby je nadpisać przez kaskadę:</p>
<pre><code class="language-php">// importmap.php
-&gt;add('app', 'assets/app.js')           // baza: importuje app.scss
-&gt;add('local', 'assets/local/app.js')   // local: importuje local.scss
</code></pre>
<p><code>local.scss</code> importowany po <code>app.scss</code>, więc kaskada CSS działa poprawnie. Bez <code>!important</code>. Importmap rejestruje oba entrypointy; przeglądarka ładuje je w kolejności.</p>
<h2>Rozwiązywanie szablonów<a id="rozwiązywanie-szablonów" href="#rozwiązywanie-szablonów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>Kernel::build()</code> sprawdza pliki w <code>local/templates/</code> i kopiuje je do outputu, nadpisując bazowe szablony. Katalog <code>local/</code> jest gitignorowany w projekcie użytkownika, ale placeholdery <code>.gitkeep</code> zapewniają że świeże klony mają strukturę:</p>
<pre><code>local/
├── assets/
│   └── styles/
│       └── .gitkeep
├── templates/
│   └── .gitkeep
└── translations/
    └── .gitkeep
</code></pre>
<h2>Boilplate'y w docs/examples/<a id="boilplatey-w-docsexamples" href="#boilplatey-w-docsexamples" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Gotowe szablony startowe do różnych poziomów customizacji:</p>
<table>
<thead>
<tr>
<th>Boilerplate</th>
<th>Poziom customizacji</th>
<th>Kiedy użyć</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>starter-extend/</code></td>
<td>Najlżejszy</td>
<td>Chcesz rozszerzyć bazę drobnymi poprawkami</td>
</tr>
<tr>
<td><code>block-override/</code></td>
<td>Średni</td>
<td>Chcesz zastąpić konkretne komponenty</td>
</tr>
<tr>
<td><code>full-override/</code></td>
<td>Pełny</td>
<td>Chcesz pełną kontrolę nad layoutem</td>
</tr>
<tr>
<td><code>material-cards/</code></td>
<td>Motyw</td>
<td>Kompletny przykład ciemnego motywu z local.scss</td>
</tr>
<tr>
<td><code>translation-override/</code></td>
<td>Stringi</td>
<td>Tylko customowe stringi tłumaczeń</td>
</tr>
</tbody>
</table>
<h2>Co jest śledzone w Gicie<a id="co-jest-śledzone-w-gicie" href="#co-jest-śledzone-w-gicie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pliki <code>.gitkeep</code> w katalogach placeholder <code>local/</code> żeby struktura istniała w świeżych klonach. Faktyczne pliki nadpisań są gitignorowane — to customizacje użytkownika. <code>docs/examples/</code> zawiera szablony copy-paste od których użytkownicy zaczynają.</p>
<h2>Dlaczego nie framework?<a id="dlaczego-nie-framework" href="#dlaczego-nie-framework" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>notACMS nie jest frameworkiem. Użytkownicy nie robią <code>composer install notacms/core</code>. Klonują repo, customizują <code>local/</code> i deployują. Wzorzec jest na tyle prosty że da się go zrozumieć w 5 minut, i na tyle potężny że obsłuży każdą customizację. Cała powierzchnia customizacji to jeden katalog.</p>
<p><a href="/wpisy/otwarcie-zrodel-projektu-osobistego/">W następnym poście</a> opisuję pełną listę kontrolną wydania open-source — wszystko z poprzednich czterech postów prowadzące do finałowego launchu.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-03-local-override-pattern/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[architektura]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                    </item>
                <item>
            <title><![CDATA[XSS, open redirecty i path traversal na 'statycznej' stronie]]></title>
            <link>https://holas.pl/pl/wpisy/audyt-bezpieczenstwa-statycznej-strony/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/audyt-bezpieczenstwa-statycznej-strony/</guid>
                        <pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 3 serii o przygotowaniu notACMS do wydania jako open-source. Część 2 opisuje audyt kodu przez AI. Oryginalna seria o migracji z WordPressa opisuje samą migrację. &quot;Statyczne strony są bezpieczne.&quot; To najczęstsza rzecz jaką ludzie mówią, gdy powiesz im że zamieniłeś WordPressa na statyczny HTML. I to prawda — o ile &quot;statyczna&quot; oznacza &quot;bez bazy danych i bez kont uży…]]></description>
            <content:encoded><![CDATA[<p><em>To część 3 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/">Część 2</a> opisuje audyt kodu przez AI. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>&quot;Statyczne strony są bezpieczne.&quot; To najczęstsza rzecz jaką ludzie mówią, gdy powiesz im że zamieniłeś WordPressa na statyczny HTML. I to prawda — o ile &quot;statyczna&quot; oznacza &quot;bez bazy danych i bez kont użytkowników&quot;. W momencie gdy statyczna strona ma formularz kontaktowy, wyszukiwarkę albo jakikolwiek JavaScript renderujący treść od użytkownika, powierzchnia ataku nie jest zerowa. Jest po prostu inna.</p>
<p>Oto luki które znalazłem w mojej.</p>
<h2>XSS w search.js — i dlaczego oczywista poprawka była błędna<a id="xss-w-searchjs--i-dlaczego-oczywista-poprawka-była-błędna" href="#xss-w-searchjs--i-dlaczego-oczywista-poprawka-była-błędna" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pagefind zwraca wyniki wyszukiwania jako JSON. JavaScript renderuje je przez <code>innerHTML</code>. Po code review naturalnym odruchem było escapowanie fragmentu:</p>
<pre><code class="language-javascript">// &quot;Naprawione&quot; w 1.1.2
'&lt;p class=&quot;post-card-excerpt&quot;&gt;' + esc(r.excerpt) + '&lt;/p&gt;'
</code></pre>
<p>Przeszło review. Wylądowało w kodzie. Potem ktoś coś wyszukał i zauważył że podświetlone trafienie nie jest już pogrubione — wyświetlało się jako literalny tekst:</p>
<pre><code>…wyniki dla &lt;mark&gt;notacms&lt;/mark&gt; w…
</code></pre>
<p>Pagefind wstrzykuje tagi <code>&lt;mark&gt;</code> do fragmentów w czasie zapytania, żeby podświetlić dopasowane wyrazy. <code>esc()</code> escapował te tagi razem ze wszystkim innym. &quot;Poprawka bezpieczeństwa&quot; po cichu zepsuła podświetlanie wyników.</p>
<p>Revert:</p>
<pre><code class="language-javascript">// Aktualny kod
'&lt;p class=&quot;post-card-excerpt&quot;&gt;' + r.excerpt + '&lt;/p&gt;'
</code></pre>
<p>To nie jest zostawienie luki. <code>r.excerpt</code> to nie input użytkownika — jest generowany przez pagefind z własnego, pre-indeksowanego statycznego HTML. Jedyny HTML jaki zawiera to tagi <code>&lt;mark&gt;</code> wstrzyknięte przez sam pagefind. Właściwa zasada to nie &quot;zawsze escapuj przed <code>innerHTML</code>&quot; — to <strong>wiedz kto kontroluje stringa</strong>. Wszystkie inne pola (<code>r.url</code>, <code>r.meta.title</code>, <code>r.meta.category</code>, tagi) są escapowane, bo pochodzą z frontmattera który w teorii może zawierać cokolwiek.</p>
<p>Naiwne escapowanie było gorsze niż brak poprawki: wprowadziło regresję i dało fałszywe poczucie że problem XSS został rozwiązany.</p>
<h2>Open redirect przez nagłówek Referer<a id="open-redirect-przez-nagłówek-referer" href="#open-redirect-przez-nagłówek-referer" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kontrolery podglądu draftów i zaplanowanych postów przełączają widoczność i przekierowują z powrotem:</p>
<pre><code class="language-php">// Luka (przed 1.1.x)
return $this-&gt;redirect($request-&gt;headers-&gt;get('referer', '/'));
</code></pre>
<p>Nagłówek <code>Referer</code> jest kontrolowany przez użytkownika. Atakujący może stworzyć link który przeprowadzi użytkownika przez przełączenie podglądu a potem przekieruje go na dowolną zewnętrzną stronę. Poprawka z 1.1.x walidowała czy cel przekierowania to lokalna ścieżka:</p>
<pre><code class="language-php">// Naprawione w 1.1.x
$referer = $request-&gt;headers-&gt;get('referer', '/');
if ($referer &amp;&amp; str_starts_with($referer, '/') &amp;&amp; !str_starts_with($referer, '//')) {
    return $this-&gt;redirect($referer);
}
return $this-&gt;redirect('/');
</code></pre>
<p>W 1.2.0 to zostało wzmocnione. Sprawdzenie <code>str_starts_with</code> operuje na surowym stringu nagłówka — poprawne, ale kruche przy niestandardowych lub zniekształconych wartościach. Ulepszona wersja używa <code>parse_url()</code> żeby osobno wyodrębnić host i ścieżkę, zwalidować każdy komponent z osobna i przekierować tylko do ścieżki (nigdy do pełnej wartości nagłówka):</p>
<pre><code class="language-php">// Ulepszone w 1.2.0
$referer = (string) $request-&gt;headers-&gt;get('referer', '');
$refererParts = parse_url($referer);
$refererHost = is_array($refererParts) ? ($refererParts['host'] ?? null) : null;
$refererPath = is_array($refererParts) ? ($refererParts['path'] ?? '') : '';

if (
    (null === $refererHost || $request-&gt;getHost() === $refererHost)
    &amp;&amp; str_starts_with($refererPath, '/')
    &amp;&amp; !str_starts_with($refererPath, '//')
) {
    return $this-&gt;redirect($refererPath);
}
return $this-&gt;redirect('/');
</code></pre>
<h2>Path traversal w MediaController<a id="path-traversal-w-mediacontroller" href="#path-traversal-w-mediacontroller" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cache wariantów serwuje przeskalowane obrazki. Nazwa pliku wariantu pochodzi z URL:</p>
<pre><code class="language-php">$variantPath = $this-&gt;cacheDir . '/' . $variantFilename;
</code></pre>
<p>Bez guarda <code>realpath()</code>, spreparowany URL typu <code>../../etc/passwd</code> mógłby uciec z katalogu cache. Rozwiązanie to pojedyncze sprawdzenie:</p>
<pre><code class="language-php">$realCacheDir = realpath($this-&gt;cacheDir);
if (false === $realCacheDir || !str_starts_with(dirname($variantPath), $realCacheDir)) {
    throw new NotFoundHttpException('Invalid variant path');
}
</code></pre>
<h2>CSRF na pre-renderowanych formularzach<a id="csrf-na-pre-renderowanych-formularzach" href="#csrf-na-pre-renderowanych-formularzach" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>To kwestia architektoniczna, nie błąd. Tokeny CSRF Symfony są powiązane z sesją użytkownika. Ale statyczne strony nie mają sesji — HTML jest generowany raz w czasie buildu. Każdy odwiedzający dostałby ten sam token, wypalony w statycznym pliku, który nie może się zwalidować wobec jego własnej sesji.</p>
<p>Rzeczywiste zabezpieczenia są inne:</p>
<ol>
<li><strong>Turnstile CAPTCHA</strong> — zastępuje warstwę ochrony przed botami weryfikacją po stronie serwera</li>
<li><strong><code>X-Requested-With: XMLHttpRequest</code></strong> — blokuje proste submit formularzy z klientów nie-JS</li>
<li><strong>Ograniczenie nginx</strong> — PHP-FPM dostępne tylko na <code>^/(api|pl/api)/</code></li>
</ol>
<h2>Flag Secure cookie — flaga która po cichu psuje dev<a id="flag-secure-cookie--flaga-która-po-cichu-psuje-dev" href="#flag-secure-cookie--flaga-która-po-cichu-psuje-dev" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Przekierowanie locale ustawia cookie <code>lang</code> po stronie klienta. <code>; Secure</code> było w kodzie od pierwszego commita:</p>
<pre><code class="language-javascript">document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; Secure';
</code></pre>
<p>Potem podczas developmentu na DDEV przez HTTP przekierowanie locale przestało działać. Kliknięcie przełącznika języka nie robiło nic. Cookie po prostu nie było.</p>
<p><code>; Secure</code> mówi przeglądarce żeby ustawiała cookie tylko na originie HTTPS. Na HTTP po cichu nie robi nic — bez błędu, bez ostrzeżenia, bez komunikatu w konsoli. Mechanizm locale był kompletnie niefunkcjonalny na każdym originie HTTP.</p>
<p>Poprawka to uzależnienie od protokołu:</p>
<pre><code class="language-javascript">var secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax' + secure;
</code></pre>
<p>Produkcja jest zawsze HTTPS, więc <code>; Secure</code> jest tam zawsze dodawane. Dev przez HTTP dostaje działające cookie bez niego. Baner cookie używa bezwarunkowego <code>; Secure</code> — zgoda na ciasteczka nie musi działać na HTTP dev, więc tam to nie problem.</p>
<p>Lekcja to nie &quot;pamiętaj dodać <code>; Secure</code>&quot;. To że flagi bezpieczeństwa które po cichu nie działają to osobna klasa problemów. Dodajesz je, czujesz się bezpiecznie, i nie wiesz że coś zepsułeś dopóki nie ugrzęźniesz w debugowaniu czegoś zupełnie innego.</p>
<h2>Przed czym &quot;statyczna&quot; strona naprawdę chroni<a id="przed-czym-statyczna-strona-naprawdę-chroni" href="#przed-czym-statyczna-strona-naprawdę-chroni" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>SQL injection — brak bazy danych. Server-side template injection — szablony kompilowane w czasie buildu. Ataki upload plików — brak uploadów. Ataki brute-force na logowanie — brak strony logowania.</p>
<p>Przed czym nie chroni: XSS po stronie klienta, open redirecty, path traversal w dynamicznych endpointach, CSRF na formularzach, brakujące nagłówki bezpieczeństwa, JavaScript który ufa niezaufanym danym.</p>
<h2>Formularz kontaktowy: jedyny dynamiczny endpoint jest najbardziej atakowany<a id="formularz-kontaktowy-jedyny-dynamiczny-endpoint-jest-najbardziej-atakowany" href="#formularz-kontaktowy-jedyny-dynamiczny-endpoint-jest-najbardziej-atakowany" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>nginx ogranicza PHP-FPM do jednego wzorca:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
}
</code></pre>
<p>Reszta serwowana z dysku. Formularz kontaktowy ma weryfikację Turnstile, ciasny CSP który dopuszcza tylko <code>challenges.cloudflare.com</code> jako zewnętrzną domenę, bez <code>'unsafe-inline'</code> i <code>'unsafe-eval'</code> w <code>script-src</code>:</p>
<pre><code class="language-nginx">add_header Content-Security-Policy
    &quot;default-src 'self';
     script-src  'self' challenges.cloudflare.com;
     style-src   'self' 'unsafe-inline';
     img-src     'self' data:;
     frame-src   challenges.cloudflare.com;
     connect-src 'self' challenges.cloudflare.com;&quot;
    always;
</code></pre>
<p><code>'unsafe-inline'</code> w <code>style-src</code> to świadomy kompromis: widget Turnstile wstrzykuje inline styles których nie da się uniknąć bez CSP nonce. Reszta jest zamknięta.</p>
<p><a href="/wpisy/wzorzec-lokalnych-nadpisan/">W następnym poście</a> opisuję wzorzec lokalnych nadpisan — jak notACMS pozwala użytkownikom customizować szablony, CSS i tłumaczenia bez forka.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-26-security-audit-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[nginx]]></category>
                    </item>
                <item>
            <title><![CDATA[79 błędów w kodzie Symfony który przeszedł PHPStan]]></title>
            <link>https://holas.pl/pl/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/</guid>
                        <pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 2 serii o przygotowaniu notACMS do wydania jako open-source. Część 1 opisuje suite testowy. Oryginalna seria o migracji z WordPressa opisuje samą migrację. Kod przeszedł PHPStan level 6. PHP CS Fixer nie miał nic do zarzucenia. Rector dry-run czysty. Przejrzałem każdy plik osobiście. Byłem gotowy na open-source. Potem poprosiłem AI o audyt na podstawie 200-linijkowego pliku instrukcji. Zn…]]></description>
            <content:encoded><![CDATA[<p><em>To część 2 serii o przygotowaniu notACMS do wydania jako open-source. <a href="/wpisy/368-testow-dla-statycznego-generatora-stron/">Część 1</a> opisuje suite testowy. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>Kod przeszedł PHPStan level 6. PHP CS Fixer nie miał nic do zarzucenia. Rector dry-run czysty. Przejrzałem każdy plik osobiście. Byłem gotowy na open-source.</p>
<p>Potem poprosiłem AI o audyt na podstawie 200-linijkowego pliku instrukcji. Znalazł 79 problemów.</p>
<h2>Setup: AGENTS.md jako instrukcja audytu<a id="setup-agentsmd-jako-instrukcja-audytu" href="#setup-agentsmd-jako-instrukcja-audytu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Nie &quot;przejrzyj mój kod&quot; — to daje generyczne uwagi o obsłudze błędów i przypadkach brzegowych. Zamiast tego szczegółowa checklista w <code>AGENTS.md</code>:</p>
<ul>
<li><strong>Zasady architektury</strong> — tylko klasy final, segregacja interfejsów, value objects zamiast tablic asocjacyjnych</li>
<li><strong>Konwencje nazewnicze</strong> — <code>XxxInterface</code> → <code>Xxx</code>, <code>public const</code> w interfejsach</li>
<li><strong>Wzorce bezpieczeństwa</strong> — guardy przed path traversal, walidacja przekierowań, sanitizacja danych</li>
<li><strong>Zasady stylu kodu</strong> — warunki Yody, pusta linia przed return, strict types</li>
<li><strong>Synchronizacja dokumentacji</strong> — tabele serwisów zgodne z <code>src/</code>, tabele zmiennych zgodne z <code>_variables.scss</code></li>
</ul>
<p>AI nie zgadywało czego szukać. Wykonywało instrukcje. Różnica jest taka sama jak między przeglądem kodu od kogoś kto zna projekt a przeglądem od kogoś kto go nie zna.</p>
<h2>Co AI złapało, a ja przeoczyłem<a id="co-ai-złapało-a-ja-przeoczyłem" href="#co-ai-złapało-a-ja-przeoczyłem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Mutowalny stan w cache</strong> — <code>ContentTree::setIncludeDrafts()</code> mutował obiekt z cache. Ta sama instancja drzewa była współdzielona między requestami, więc przełączenie draftów w jednym requeście wpływało na następny. Race condition czekający na okazję. Rozwiązanie: uczynić <code>ContentTree</code> niemutowalnym i przenieść filtrowanie draftów do warstwy serwisu.</p>
<p><strong>Naruszenia SOLID</strong> — <code>SiteConfigServiceInterface</code> miał 16 metod. Zasada projektu to max 5. <code>ContentTree</code> miał 20+ metod robiących strukturę danych, silnik zapytań i scoring rekomendacji — trzy zadania w jednej klasie. Rozwiązanie: wyciągnąć <code>RelatedPostsService</code> (73-linijkowy algorytm scoringu → osobny serwis z własnym interfejsem).</p>
<p><strong>Zduplikowana logika</strong> — <code>readingTime()</code> i <code>excerpt()</code> obie robiły <code>strip_tags</code> + <code>str_word_count</code> na tej samej zawartości HTML. Wyciągnięte do wspólnej metody <code>getPlainText()</code>.</p>
<p><strong>Luki w dostępności</strong> — Zagnieżdżone elementy <code>&lt;label&gt;</code> w formularzu kontaktowym (niepoprawny HTML), brakujące <code>role=&quot;alert&quot;</code> na spanach błędów, <code>aria-current=&quot;true&quot;</code> zamiast <code>&quot;page&quot;</code>. Każde poprawka na jedną linię, ale niewidoczne bez checklisty.</p>
<p><strong>Gnicie dokumentacji</strong> — <code>docs/STYLEGUIDE.md</code> podający Bootstrap blue (<code>#0d6efd</code>) zamiast faktycznego zielonego (<code>#2d8a4e</code>). <code>docs/ARCHITECTURE.md</code> odwołujący się do fantomowych plików: <code>tagline.js</code>, <code>contact_widget.html.twig</code>, <code>_header.scss</code> — żaden nie istniał.</p>
<h2>Co AI źle oceniło (false positives)<a id="co-ai-źle-oceniło-false-positives" href="#co-ai-źle-oceniło-false-positives" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>CSRF na formularzu kontaktowym</strong> — oznaczone jako luka, ale celowe. Statyczne, pre-renderowane strony nie mogą używać tokenów CSRF powiązanych z sesją — token jest wypalony w HTML w czasie buildu, powiązany z sesją serwera buildowego. Każdy odwiedzający dostaje ten sam token. Turnstile + nagłówek <code>X-Requested-With</code> zapewniają rzeczywistą ochronę.</p>
<p><strong>Dane użytkownika w komunikatach błędów 404</strong> — oznaczone jako ryzyko enumeracji, ale te komunikaty pojawiają się tylko podczas statycznego buildu, nie dla odwiedzających. Produkcyjne strony błędów Symfony nie ujawniają komunikatów wyjątków.</p>
<p><strong>&quot;Niejodyczne&quot; porównania</strong> — oznaczone wzorce zmienna-vs-metoda jak <code>$page-&gt;directoryKey() === $directoryKey</code>, ale prawdziwa reguła Yody dotyczy literałów (<code>null</code>, <code>false</code>, stringi) po lewej. Porównania zmienna-vs-zmienna nie wymagają odwracania.</p>
<h2>Co zmieniłem vs co zaakceptowałem<a id="co-zmieniłem-vs-co-zaakceptowałem" href="#co-zmieniłem-vs-co-zaakceptowałem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Około 60 problemów naprawionych. Około 19 zaakceptowanych:</p>
<ul>
<li><strong>Zduplikowane serwisy podglądu</strong> — <code>DraftPreviewService</code> i <code>ScheduledPreviewService</code> są prawie identyczne, ale każdy ma 3 linie. Sparametryzowanie wymagałoby ServiceLocatora, wstrzykiwania enuma i customowej serializacji na data collectorze. Więcej złożoności niż oryginał. Zaakceptowane.</li>
<li><strong>Szeroki <code>SiteConfigServiceInterface</code></strong> — 16 metod, ale to obiekt konfiguracyjny, nie serwis biznesowy. Podział dotyka 20 plików dla porządku anotacji tylko. Zaakceptowane.</li>
</ul>
<p>Naprawki, które się liczyły:</p>
<pre><code class="language-php">// Przed: mutowalne drzewo w cache
$tree-&gt;setIncludeDrafts(true);  // mutuje współdzieloną instancję

// Po: niemutowalne drzewo, filtrowanie w warstwie serwisu
$posts = $this-&gt;contentService-&gt;getPosts($locale, includeDrafts: true);
</code></pre>
<pre><code class="language-php">// Przed: 73-linijkowy algorytm w ContentTree
public function getRelatedPosts(ContentItem $post, int $limit = 3): array
{
    // ... 73 linie scoringu tagów/kategorii/serii
}

// Po: osobny serwis
final class RelatedPostsService implements RelatedPostsServiceInterface
{
    public function findRelated(ContentItem $post, ContentTree $tree, int $limit = 3): array
    {
        // ... ta sama logika, ale izolowana
    }
}
</code></pre>
<h2>Wniosek<a id="wniosek" href="#wniosek" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>&quot;Przejrzyj mój kod&quot; daje generyczne uwagi. &quot;Audytuj według tych 200 linii standardów projektu&quot; daje konkretne, akcjonowalne wyniki. AGENTS.md nie służy tylko do budowania — służy też do przeglądów. AI złapało rzeczy, na które patrzyłem dziesiątki razy, bo sprawdzało według reguł, a nie polegało na znajomości.</p>
<p><a href="/wpisy/audyt-bezpieczenstwa-statycznej-strony/">W następnym poście</a> opisuję luki bezpieczeństwa wśród tych 79 problemów: XSS w wyszukiwarce, open redirecty i path traversal na &quot;statycznej&quot; stronie.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-19-79-bugs-ai-code-review/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                    </item>
                <item>
            <title><![CDATA[368 testów dla statycznego generatora stron — po co?]]></title>
            <link>https://holas.pl/pl/wpisy/368-testow-dla-statycznego-generatora-stron/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/368-testow-dla-statycznego-generatora-stron/</guid>
                        <pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 1 serii o przygotowaniu notACMS do wydania jako open-source. Seria obejmuje testy, przegląd kodu, bezpieczeństwo, wzorzec lokalnych nadpisań, pipeline budowania, system motywów, architekturę wyszukiwania i listę kontrolną wydania. Oryginalna seria o migracji z WordPressa opisuje samą migrację. Treści to pliki Markdown. Szablony to Twig. Style to SCSS. Nie ma bazy danych, nie ma API, nie m…]]></description>
            <content:encoded><![CDATA[<p><em>To część 1 serii o przygotowaniu notACMS do wydania jako open-source. Seria obejmuje testy, przegląd kodu, bezpieczeństwo, wzorzec lokalnych nadpisań, pipeline budowania, system motywów, architekturę wyszukiwania i listę kontrolną wydania. Oryginalna <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">seria o migracji z WordPressa</a> opisuje samą migrację.</em></p>
<hr />
<p>Treści to pliki Markdown. Szablony to Twig. Style to SCSS. Nie ma bazy danych, nie ma API, nie ma kont użytkowników. Co tu właściwie testować?</p>
<p>Sporo. Logika parsująca frontmatter, budująca drzewo treści, obliczająca mapy tłumaczeń, generująca responsywne srcsety i rozwiązująca zlokalizowane trasy to czyste PHP — i jest podatne na błędy jak każdy inny kod. Większość statycznych generatorów stron ma zero testów. notACMS ma 368.</p>
<h2>Dlaczego testować statyczną stronę?<a id="dlaczego-testować-statyczną-stronę" href="#dlaczego-testować-statyczną-stronę" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pliki z treścią nie potrzebują testów. Potrzebuje ich pipeline, który je przetwarza: <code>ContentTreeBuilder</code> skanujący katalogi, <code>MarkdownParser</code> wyciągający frontmatter, <code>ContentService</code> cache'ujący wyniki, <code>TranslationMapBuilder</code> obliczający mapy locale→URL, <code>LocalizedRouteLoader</code> generujący trasy z <code>_routes.yaml</code>. Wszystko to czysta logika bez zależności od bazy danych — idealna do testów jednostkowych.</p>
<p>Piramida testów dla aplikacji opartej na treściach wygląda inaczej niż dla typowej aplikacji webowej:</p>
<ul>
<li><strong>Testy jednostkowe</strong> — <code>ContentItem</code>, <code>ContentTree</code>, Value Objects. Bez mocków, bez kernela. Tablice frontmattera wchodzą, oczekiwane właściwości wychodzą.</li>
<li><strong>Testy jednostkowe ze stubami</strong> — <code>SiteConfigService</code>, <code>TagTranslationService</code>, <code>SrcsetExtension</code>. Stuby interfejsów dla zależności, katalogi tymczasowe dla operacji na plikach.</li>
<li><strong>Testy integracyjne</strong> — kontrolery, komendy, serwisy z uruchomionym kernelem Symfony. Testy smoke HTTP dla kluczowych tras.</li>
</ul>
<h2>Liczby<a id="liczby" href="#liczby" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>368 testów, 490 asercji, ~80% pokrycia linii, ~82% pokrycia metod. Trzy fazy:</p>
<p><strong>Faza 1: Czyste testy jednostkowe (191 testów)</strong> — <code>ContentItem</code> z <code>isDraft</code>, <code>isScheduled</code>, <code>isPinned</code>, <code>readingTime</code>, <code>excerpt</code> i dziesiątkami przypadków parsowania frontmattera. <code>ContentTree</code> filtrujący po kategorii, tagu, miesiącu archiwum, paginacji. Value Objects: <code>AdjacentPosts</code>, <code>ArchiveMonth</code>, <code>CategoryCount</code>, <code>ParsedMarkdown</code>, <code>ParsedVariant</code>, <code>RenderResult</code>, <code>SidebarData</code>, <code>TagCount</code>. Bez mocków, bez kernela, bez systemu plików.</p>
<p><strong>Faza 2: Testy jednostkowe ze stubami interfejsów (49 testów)</strong> — <code>SiteConfigService</code> czytający YAML z katalogów tymczasowych, <code>TagTranslationService</code> tłumaczący tagi między locale, <code>SrcsetExtension</code> generujący atrybuty srcset. <code>createStub()</code> dla interfejsów które nie potrzebują <code>expects()</code>, <code>TmpDirTrait</code> do sprzątania plików.</p>
<p><strong>Faza 3: Testy integracyjne (127 testów)</strong> — <code>BlogController</code> zwracający 200 dla poprawnych stron i 404 dla nieistniejących, <code>ContactController</code> obsługujący formularz, <code>BuildStaticSiteCommand</code> kończący się bez błędów, <code>LocalizedRouteLoader</code> generujący poprawne trasy. Kernel Symfony startuje, drzewo treści się buduje, klient HTTP wykonuje requesty.</p>
<h2>Co warto testować<a id="co-warto-testować" href="#co-warto-testować" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ContentItem::isScheduled()</code> poprawnie wykrywający przyszłe daty — włącznie z przypadkiem granicznym gdzie data to dokładnie teraz (przedział prawostronnie otwarty: post jest już opublikowany na granicy). <code>ContentTree::getPaginatedPosts()</code> poprawnie dzielący na strony. <code>LocalizedRouteLoader</code> generujący trasy dla każdego locale. <code>BuildStaticSiteCommand</code> kończący się bez błędów.</p>
<p>Czego NIE warto testować: ścieżki wysyłania emaili w <code>ContactController</code> (wymaga mockowania mailera, testowania infrastruktury której nie posiadam), operacji kopiowania plików w <code>BuildStaticSiteCommand</code> (wymaga prawdziwego systemu plików z obrazkami), <code>ErrorController::__invoke</code> (trudne do wywołania przez klienta HTTP). Wysoki koszt uruchomienia, niski zwrot.</p>
<h2>Konwencje testowe, które mają znaczenie<a id="konwencje-testowe-które-mają-znaczenie" href="#konwencje-testowe-które-mają-znaczenie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong><code>ContentItemFactory</code> dla wszystkich fixture'ów</strong> — Nigdy nie konstruuj <code>ContentItem</code> bezpośrednio w testach. Fabryka dostarcza sensowne wartości domyślne i nazwane konstruktory:</p>
<pre><code class="language-php">$post = ContentItemFactory::publishedPost([
    'title' =&gt; 'Test Post',
    'tags'  =&gt; ['testing', 'php'],
], 'test-post', '/blog/test-post/');
</code></pre>
<p><strong>Asercje XPath, nie selektory CSS</strong> — <code>symfony/css-selector</code> nie jest zainstalowany, więc <code>$crawler-&gt;filter('.class')</code> rzuca <code>LogicException</code>. Używaj <code>filterXpath()</code>:</p>
<pre><code class="language-php">$meta = $crawler-&gt;filterXpath('//meta[@name=&quot;robots&quot;]/@content');
self::assertGreaterThan(0, $meta-&gt;count());
self::assertStringContainsString('noindex', $meta-&gt;text());
</code></pre>
<p><strong><code>createStub()</code> vs <code>createMock()</code></strong> — PHPUnit 13 wyświetla ostrzeżenia dla mocków bez <code>expects()</code>. Używaj <code>createStub()</code> w <code>setUp()</code> dla interfejsów gdzie potrzebujesz tylko wartości zwrotnej. Twórz <code>createMock()</code> lokalnie tylko w testach które weryfikują liczbę wywołań:</p>
<pre><code class="language-php">// setUp() — bez expects, użyj createStub()
$this-&gt;config = $this-&gt;createStub(SiteConfigServiceInterface::class);
$this-&gt;config-&gt;method('getPostsPerPage')-&gt;willReturn(10);

// Pojedynczy test — ma expects, użyj createMock()
$mock = $this-&gt;createMock(MarkdownParserInterface::class);
$mock-&gt;expects(self::once())-&gt;method('parse')-&gt;willReturn($parsed);
</code></pre>
<p><strong><code>declare(strict_types=1)</code> w każdym pliku testowym</strong> — Tak jak w kodzie produkcyjnym. Warunki Yody w asercjach. Pusta linia przed <code>return</code>.</p>
<h2>Integracja z CI<a id="integracja-z-ci" href="#integracja-z-ci" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev test</code> uruchamia pełny zestaw z outputem testdox. GitHub Actions uruchamia suite Unit przy każdym pushu:</p>
<pre><code class="language-yaml">- name: PHPUnit
  run: vendor/bin/phpunit --testsuite Unit
</code></pre>
<p>Testy integracyjne potrzebują pełnego kernela i plików z treścią — działają lokalnie przez DDEV, ale Unit-only w CI to właściwy kompromis. Suite Unit łapie regresje w rdzeniu logiki; testy integracyjne łapią problemy z konfiguracją które pojawiają się tylko z pełnym stosem.</p>
<h2>Efekt<a id="efekt" href="#efekt" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kiedy celnie zepsułem <code>ContentItem::isDraft()</code>, suite testowy złamał to natychmiast — 12 testów padło z jasnymi komunikatami o tym, która właściwość była niepoprawna. Przed suite'em testowym, taka regresja wyszłaby dopiero gdy ktoś zauważyłby draft na żywej stronie.</p>
<p>368 zielonych testów to pewność przy refaktoryzacji. Wyciąganie <code>RelatedPostsService</code> z <code>ContentTree</code>, uczynienie drzewa niemutowalnym, przenoszenie stałych do interfejsów — wszystko to działo się z siatką bezpieczeństwa testów które wyłapałyby cokolwiek co by się zepsuło.</p>
<p><a href="/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/">W następnym poście</a> opisuję co stało się dalej: audyt AI znalazł 79 problemów w kodzie który przeszedł już PHPStan, CS Fixer i ręczny przegląd. Testy łapią tylko to, o czym pomyślisz. Audyt łapie to, o czym zapomniałeś pomyśleć.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-13-368-tests-static-site-generator/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[testing]]></category>
                        <category><![CDATA[phpunit]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.2.0 — co wyszło z audytu]]></title>
            <link>https://holas.pl/pl/wpisy/notacms-1-2-audyt/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/notacms-1-2-audyt/</guid>
                        <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[1.2.0 zaczęło się jako porządki, przerodziło w pełny audyt i skończyło jako najbardziej znaczące wydanie notACMS do tej pory. Około 170 poprawek w rdzeniu, obu drzewach szablonów, dokumentacji i JS — z kilkoma celowymi zmianami niekompatybilnymi po drodze. Kilka miesięcy temu pisałem o uruchomieniu audytu AI na notACMS i znalezieniu 79 bugów. 1.2.0 to kontynuacja: kolejne pełne przejście, tym raze…]]></description>
            <content:encoded><![CDATA[<p>1.2.0 zaczęło się jako porządki, przerodziło w pełny audyt i skończyło jako najbardziej znaczące wydanie notACMS do tej pory. Około 170 poprawek w rdzeniu, obu drzewach szablonów, dokumentacji i JS — z kilkoma celowymi zmianami niekompatybilnymi po drodze.</p>
<hr />
<p>Kilka miesięcy temu pisałem o <a href="/pl/wpisy/79-bledow-w-kodzie-ktory-uznalem-za-gotowy/">uruchomieniu audytu AI na notACMS i znalezieniu 79 bugów</a>. 1.2.0 to kontynuacja: kolejne pełne przejście, tym razem z projektem w lepszym stanie i bogatszym AGENTS.md prowadzącym przegląd. Znalazło się sporo oczywistych rzeczy i kilka nieoczywistych. Oto te warte opisania.</p>
<h2>Bug w nginx który po cichu psował formularze<a id="bug-w-nginx-który-po-cichu-psował-formularze" href="#bug-w-nginx-który-po-cichu-psował-formularze" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Najbardziej frustrujące znalezisko: formularz kontaktowy który działał perfekcyjnie w każdym środowisku poza produkcją w Dockerze.</p>
<p>Konfiguracja nginx używała lokalizacji z regexem do routowania API kontaktowego:</p>
<pre><code class="language-nginx">location ~ ^/[a-z]{2}/api/contact$ {
</code></pre>
<p>Tylko że nie działała. W składni nginx <code>{2}</code> w pewnych kontekstach jest traktowane dosłownie — kwantyfikator był dopasowywany jako ciąg znaków, nie jako &quot;dokładnie dwa znaki&quot;. Regex nigdy nie pasował, więc <code>POST /pl/api/contact</code> nigdy nie trafiał do PHP. Każda niedomyślna lokalizacja dostawała ciche 404 przy wysłaniu formularza.</p>
<p>Działało w DDEV (który ma własną konfigurację nginx) i lokalnie (gdzie PHP obsługuje routing inaczej). Docker produkcyjny, korzystający z commitowanego <code>nginx.conf.template</code>, był zepsuty od samego początku. Poprawka: jeden znak, zaescapowanie nawiasów klamrowych.</p>
<h2>locale-redirect: pomocność szkodliwa<a id="locale-redirect-pomocność-szkodliwa" href="#locale-redirect-pomocność-szkodliwa" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Skrypt <code>locale-redirect.js</code> czyta preferencje językowe przeglądarki i przekierowuje nowych użytkowników na ich lokalizację. Sensowny pomysł, ale logika miała dziurę.</p>
<p>Kiedy ktoś trafia na <code>/pl/</code> pierwszy raz bez ciasteczka <code>lang</code> — powiedzmy, klikając w link — skrypt czytał <code>navigator.language</code>, znajdował <code>en-US</code>, ustawiał ciasteczko na <code>en</code> i przekierowywał go z dala od strony, na którą celowo wszedł.</p>
<p>Poprawka jest oczywista z perspektywy czasu: jeśli aktualny URL jest już na niedomyślnej lokalizacji, sam URL jest preferencją. Ustaw ciasteczko i stop. Bez przekierowania. Obejmuje to też środowiska HTTP — flaga <code>Secure</code> po cichu traciła ciasteczko w non-HTTPS (domyślna konfiguracja DDEV), uniemożliwiając działanie całego mechanizmu.</p>
<p>Dorzuciłem też zabezpieczenie przed scenariuszem którego wcześniej nie przewidziałem: przestarzałe ciasteczko z lokalizacją która zniknęła z <code>_site.yaml</code>. Bez niego usunięcie języka ze strony wpędzałoby każdego odwiedzającego ze starym ciasteczkiem w nieskończoną pętlę przekierowań na 404.</p>
<h2>Excerpty wyszukiwarki i podwójne escapowanie<a id="excerpty-wyszukiwarki-i-podwójne-escapowanie" href="#excerpty-wyszukiwarki-i-podwójne-escapowanie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a href="/pl/wpisy/audyt-bezpieczenstwa-statycznej-strony/">Przegląd bezpieczeństwa z 1.1.2</a> znalazł XSS w wyszukiwarce: excerpty z pagefind były wstrzykiwane do innerHTML bez escapowania. Naprawione przez owinięcie w <code>esc()</code>.</p>
<p>Tyle że excerpty pagefind zawierają tagi <code>&lt;mark&gt;</code> — tak pagefind zaznacza dopasowania. Poprawka zepsuła wyróżnianie: <code>&lt;mark&gt;term&lt;/mark&gt;</code> zamieniło się w dosłowny tekst <code>&amp;lt;mark&amp;gt;term&amp;lt;/mark&amp;gt;</code>. Właściwa odpowiedź: treść pagefind to autorski statyczny HTML, nie dane wejściowe użytkownika. Tagi <code>&lt;mark&gt;</code> są wstrzykiwane przez silnik wyszukiwania na etapie budowania, nie przez odwiedzających. Usuń <code>esc()</code> z excerpta, zostaw wszędzie indziej. Niewidoczne dopóki nie zauważysz że wyróżnienia nie wyróżniają.</p>
<h2>Bezpieczeństwo i utwardzenie<a id="bezpieczeństwo-i-utwardzenie" href="#bezpieczeństwo-i-utwardzenie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>ImageMagick działa teraz przez <code>Symfony\Process</code> z tablicami argumentów zamiast ciągów shell — <code>exec()</code> zniknął. Turnstile weryfikuje hostname w odpowiedzi siteverify względem skonfigurowanego <code>base_url</code>, więc tokeny wygenerowane na domenie testowej nie mogą być odtworzone w produkcji. Output JSON-LD jest hex-escapowany — <code>&lt;/script&gt;</code> w tytule posta nie może już wyłamać się ze skryptu. Nagłówki bezpieczeństwa nginx (X-Frame-Options, CSP itd.) nie docierały do odpowiedzi <code>/assets/</code> i <code>/media/</code> — <code>add_header</code> na poziomie lokalizacji wyciszał dziedziczone nagłówki serwera.</p>
<h2>/llms.txt<a id="llmstxt" href="#llmstxt" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Mały dodatek: trasa <code>/llms.txt</code> dołączona do statycznego builda, wylistowująca najnowsze wpisy w formacie czytelnym maszynowo — per lokalizacja, konfigurowalne przez <code>llms_limit</code> w <code>_site.yaml</code>, nadpisywalne per-motyw przez przestrzeń nazw Twig <code>@base</code>. Statyczne strony nie są tradycyjnie łatwe do nawigowania dla modeli językowych — to niskokosztowy sposób na dostarczenie kontekstu komukolwiek (lub czemukolwiek) kto to czyta.</p>
<h2>Breaking changes<a id="breaking-changes" href="#breaking-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kilka zmian wymagających jednolinijkowej migracji w <code>local/src/</code>:</p>
<ul>
<li><code>ContentItem::directoryKey()</code> zwraca teraz pełną ścieżkę treści (<code>pages/about</code> nie <code>about</code>), naprawiając ciche kolizje URL między katalogami o tej samej nazwie w różnych sekcjach</li>
<li><code>getTree()</code> przeniesione do <code>ContentTreeProviderInterface</code> — z <code>ContentServiceInterface</code></li>
<li><code>structured_data().blogPosting()</code> przyjmuje nazwaną mapę (było 13 pozycyjnych argumentów)</li>
<li>Klucz kontekstu <code>lang_switch_url</code> usunięty — użyj <code>lang_switch</code></li>
<li><code>docs/customization/old-template/</code> usunięty z repo — pobierz z taga v1.1.x jeśli nadal potrzebujesz</li>
</ul>
<p>Pełny przewodnik migracji: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.2.md">UPGRADE-1.2.md</a>.</p>
<h2>Linki<a id="linki" href="#linki" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pełny changelog: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md">CHANGELOG.md</a>.</p>
<p>Repozytorium: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / holas1337/notACMS</a> — Apache 2.0.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-12-notacms-1-2/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[realizacje]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[ai]]></category>
                    </item>
                <item>
            <title><![CDATA[Iteracyjna architektura z AI — komponenty, rundy jakości i cykle refaktoryzacji]]></title>
            <link>https://holas.pl/pl/wpisy/iteracyjna-architektura-z-ai/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/iteracyjna-architektura-z-ai/</guid>
                        <pubDate>Sat, 06 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To jest część 10 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. Część 9 opisuje system wielojęzyczności. Część 5 opisywała jak Claude Code był używany podczas początkowego budowania — AGENTS.md jako instrukcja obsługi dla AI, generowanie serwisów i szablonów z konwencji, tłumaczenie treści. Ten wpis opisuje co dzieje się później: strona dzia…]]></description>
            <content:encoded><![CDATA[<p><em>To jest część 10 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. <a href="/pl/wpisy/wielojezycznosc-strona-statyczna/">Część 9</a> opisuje system wielojęzyczności.</em></p>
<hr />
<p><a href="/pl/wpisy/budowanie-z-ai-claude-code/">Część 5</a> opisywała jak Claude Code był używany podczas początkowego budowania — <code>AGENTS.md</code> jako instrukcja obsługi dla AI, generowanie serwisów i szablonów z konwencji, tłumaczenie treści. Ten wpis opisuje co dzieje się później: strona działa, build przechodzi, ale architektura ma niedociągnięcia. Trzy rundy poprawek jakości, ekstrakcja komponentów i reorganizacja namespace'ów — wszystko napędzane cyklami przeglądów z pomocą AI.</p>
<h2>Dlaczego iterować?<a id="dlaczego-iterować" href="#dlaczego-iterować" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pierwsza działająca wersja priorytetyzuje wysyłkę. Opublikuj treść, niech build przejdzie, wdróż na Raspberry Pi. Dług techniczny narasta naturalnie po drodze: metody zwracają tablice asocjacyjne zamiast value objects, serwisy wstrzykiwane są jako klasy konkretne zamiast interfejsów, szablony rosną w monolity.</p>
<p>W zespole code review wyłapuje te wzorce. W solowym projekcie nie ma nikogo do przeglądania pull requestów. AI wypełnia tę lukę — nie jako pieczątka akceptacji, ale jako systematyczny reviewer czytający każdy plik i raportujący problemy z numerami linii.</p>
<p>Cykl iteracji:</p>
<ol>
<li>Poproś AI o audyt pod kątem konkretnych wzorców (strict types, naruszenia SOLID, problemy DRY)</li>
<li>AI czyta każdy plik PHP, raportuje problemy ze ścieżkami plików i numerami linii</li>
<li>Zaplanuj poprawki w pliku <code>.plans/</code> z checkboxami</li>
<li>Implementuj fazami, weryfikuj <code>ddev code-check</code> po każdej</li>
<li>Powtórz z kolejnym fokusem jakościowym</li>
</ol>
<p>Każda runda ma konkretny zakres. Próba naprawienia wszystkiego naraz prowadzi do szumnych diffów i przeoczonych regresji. Skupione rundy produkują dające się przejrzeć i zweryfikować zmiany.</p>
<h2>Runda 1 — Value Objects zamiast tablic<a id="runda-1--value-objects-zamiast-tablic" href="#runda-1--value-objects-zamiast-tablic" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pierwsza runda celowała w własną zasadę projektu: &quot;Nigdy nie zwracaj tablic asocjacyjnych dla złożonych danych.&quot;</p>
<h3>Problem<a id="problem" href="#problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>MarkdownParser::parse()</code> zwracał tablicę:</p>
<pre><code class="language-php">// Przed
public function parse(string $markdown): array
{
    // ...
    return [
        'frontMatter' =&gt; $frontMatter,
        'html' =&gt; $html,
    ];
}

// Konsument
$result = $this-&gt;parser-&gt;parse($content);
$frontMatter = $result['frontMatter'];  // brak bezpieczeństwa typów
$html = $result['html'];               // literówka = cichy bug
</code></pre>
<p>Ten sam wzorzec dla nawigacji sąsiednich wpisów — <code>ContentTree::getAdjacentPosts()</code> zwracał <code>['prev' =&gt; $post, 'next' =&gt; $post]</code>.</p>
<p>Problemy: brak bezpieczeństwa typów, brak autouzupełniania w IDE, PHPStan nie wyłapie literówki w <code>$result['htlm']</code>.</p>
<h3>Poprawka<a id="poprawka" href="#poprawka" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">final readonly class ParsedMarkdown
{
    /** @param array&lt;string, mixed&gt; $frontMatter */
    public function __construct(
        public array $frontMatter,
        public string $html,
    ) {
    }
}
</code></pre>
<pre><code class="language-php">final readonly class AdjacentPosts
{
    public function __construct(
        public ?ContentItem $prev,
        public ?ContentItem $next,
    ) {
    }
}
</code></pre>
<p>Teraz parser zwraca <code>ParsedMarkdown</code>, konsument odwołuje się do <code>$parsed-&gt;frontMatter</code> i <code>$parsed-&gt;html</code>, a PHPStan wyłapuje każdą literówkę w nazwie właściwości na etapie analizy.</p>
<p>Siedem value objects zostało utworzonych lub przeniesionych w tej rundzie:</p>
<table>
<thead>
<tr>
<th>Value Object</th>
<th>Zastępuje</th>
<th>Właściwości</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ParsedMarkdown</code></td>
<td>tablicę <code>['frontMatter', 'html']</code></td>
<td><code>frontMatter</code>, <code>html</code></td>
</tr>
<tr>
<td><code>AdjacentPosts</code></td>
<td>tablicę <code>['prev', 'next']</code></td>
<td><code>prev</code>, <code>next</code></td>
</tr>
<tr>
<td><code>ArchiveMonth</code></td>
<td>tablicę inline</td>
<td><code>year</code>, <code>month</code>, <code>count</code></td>
</tr>
<tr>
<td><code>CategoryCount</code></td>
<td>tablicę inline</td>
<td><code>slug</code>, <code>count</code></td>
</tr>
<tr>
<td><code>TagCount</code></td>
<td>tablicę inline</td>
<td><code>slug</code>, <code>count</code></td>
</tr>
<tr>
<td><code>SidebarData</code></td>
<td>wiele wartości zwrotnych</td>
<td><code>recentPosts</code>, <code>categories</code>, <code>tags</code>, <code>archiveMonths</code></td>
</tr>
<tr>
<td><code>RenderResult</code></td>
<td>ad-hoc statystyki</td>
<td><code>pages</code>, <code>skipped</code>, <code>errors</code></td>
</tr>
</tbody>
</table>
<p>Wszystkie są <code>readonly</code>, używają constructor property promotion i żyją w <code>src/Content/ValueObject/</code>.</p>
<h2>Runda 2 — interfejsy dla wszystkiego<a id="runda-2--interfejsy-dla-wszystkiego" href="#runda-2--interfejsy-dla-wszystkiego" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Druga runda wymusiła kolejną zasadę projektu: &quot;Każda wstrzykiwana klasa w <code>src/Service/</code> musi mieć odpowiadający interfejs.&quot;</p>
<h3>Problem<a id="problem-1" href="#problem-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Dwa serwisy wstrzykiwane były jako klasy konkretne:</p>
<pre><code class="language-php">// Przed
public function __construct(
    private readonly ContentTreeBuilder $builder,
    private readonly MarkdownParser $parser,
) {
}
</code></pre>
<p>Działało, ale naruszało zasadę odwrócenia zależności. Reszta bazy kodu już wstrzykiwała przez interfejsy (<code>ContentServiceInterface</code>, <code>ImageResizerInterface</code>). Te dwa były wyjątkami.</p>
<h3>Poprawka<a id="poprawka-1" href="#poprawka-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">interface MarkdownParserInterface
{
    public function parse(string $markdown): ParsedMarkdown;
}

interface ContentTreeBuilderInterface
{
    public function build(string $locale): ContentTree;
}
</code></pre>
<pre><code class="language-php">// Po
public function __construct(
    private readonly ContentTreeBuilderInterface $builder,
    private readonly MarkdownParserInterface $parser,
) {
}
</code></pre>
<p>Klasy konkretne implementują interfejsy. Autowiring Symfony obsługuje powiązanie. Zasada jest teraz wymuszona wszędzie: 12 interfejsów serwisów w 4 podkatalogach.</p>
<pre><code>src/Service/
├── Content/
│   ├── ContentServiceInterface + ContentService
│   ├── ContentTreeBuilderInterface + ContentTreeBuilder
│   ├── MarkdownParserInterface + MarkdownParser
│   ├── SidebarDataProviderInterface + SidebarDataProvider
│   └── TranslationMapBuilderInterface + TranslationMapBuilder
├── Image/
│   ├── ImageResizerInterface + ImageResizer
│   └── ResponsiveImageServiceInterface + ResponsiveImageService
├── Preview/
│   ├── DraftPreviewServiceInterface + DraftPreviewService
│   └── ScheduledPreviewServiceInterface + ScheduledPreviewService
├── SiteConfigServiceInterface + SiteConfigService
└── TurnstileValidatorInterface + TurnstileValidator
</code></pre>
<p>Dodatkowa zasada z tej rundy: stałe należą do interfejsu, nie do klasy konkretnej. Klasa konkretna dziedziczy je przez <code>self::CONSTANT_NAME</code>.</p>
<h2>Runda 3 — eliminacja zakodowanych wartości<a id="runda-3--eliminacja-zakodowanych-wartości" href="#runda-3--eliminacja-zakodowanych-wartości" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Trzecia runda celowała w subtelniejszy problem: wartości, które działają dziś, ale rozjadą się jutro.</p>
<h3>Zakodowane URL-e w szablonach<a id="zakodowane-url-e-w-szablonach" href="#zakodowane-url-e-w-szablonach" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Przełącznik języka miał zakodowane ścieżki archiwum:</p>
<pre><code class="language-twig">{# Przed — psuje się gdy routing się zmieni #}
{% set url = locale is same as('pl') ? '/archive/' : '/pl/archiwum/' %}
</code></pre>
<pre><code class="language-twig">{# Po — używa nazwanych tras #}
{% set url = path('blog_archive_' ~ other, {year: archive_year, month: '%02d'|format(archive_month)}) %}
</code></pre>
<p>Ten sam wzorzec w <code>BlogController</code> dla przełączania tagów między locale — zakodowane <code>/pl/tag/</code> i <code>/blog/</code> zastąpione przez <code>$this-&gt;generateUrl('blog_tag_'.$otherLocale, ...)</code>.</p>
<h3>Zakodowane progi obrazów<a id="zakodowane-progi-obrazów" href="#zakodowane-progi-obrazów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Szablon responsywnych obrazów miał szerokości srcset zakodowane jako stringi. Gdyby zmienna środowiskowa <code>IMAGE_VARIANT_WIDTHS</code> się zmieniła, szablon odwoływałby się do plików, które nie istnieją:</p>
<pre><code class="language-twig">{# Przed — szablon musi ręcznie pasować do konfiguracji env #}
srcset=&quot;...640w.webp 640w, ...960w.webp 960w, ...&quot;
</code></pre>
<p>Poprawka: wstrzyknij szerokości wariantów jako Twig global z <code>SiteConfigExtension</code>, potem generuj srcset dynamicznie. Jedno źródło prawdy dla progów.</p>
<h3>Magiczne liczby<a id="magiczne-liczby" href="#magiczne-liczby" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>Response::HTTP_BAD_REQUEST</code> zastąpiło zakodowane <code>400</code>. Mała zmiana, ale spójna z zasadą: każda literalna wartość to przyszły bug, gdzie ktoś zmieni logikę ale nie liczbę.</p>
<h2>Ekstrakcja komponentów Twig<a id="ekstrakcja-komponentów-twig" href="#ekstrakcja-komponentów-twig" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Między rundami jakości osobne wysiłki wyodrębniły reużywalne komponenty z monolitycznych szablonów.</p>
<h3>Strona &quot;O mnie&quot;<a id="strona-o-mnie" href="#strona-o-mnie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Strona &quot;o mnie&quot; była najgorszym przypadkiem — 146 linii mieszających markup profilu, karty ekspertyzy, pigułki umiejętności, projekty open source i bloki cytatów rekomendacji w jednym szablonie.</p>
<p>Po ekstrakcji:</p>
<pre><code class="language-twig">{# about.html.twig — czysty i czytelny #}
{% block body %}
&lt;article class=&quot;page-content&quot; data-pagefind-body&gt;
    {{ include('components/about_profile.html.twig', {
        author: site_author,
        role: 'about.role'|trans,
        headline: 'about.headline'|trans
    }) }}

    &lt;section class=&quot;about-section&quot;&gt;
        &lt;h2&gt;{{ 'about.expertise_title'|trans }}&lt;/h2&gt;
        {{ include('components/expertise_grid.html.twig', { expertise: expertise_items }) }}
    &lt;/section&gt;

    {{ include('components/skills_pills.html.twig', { skills: ..., labels: ... }) }}
    {{ include('components/opensource_grid.html.twig', { projects: os_projects }) }}

    {% for rec in site_author.recommendations %}
        {{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
    {% endfor %}

    {{ include('components/about_cta.html.twig', { text: ..., url: ..., label: ... }) }}
&lt;/article&gt;
{% endblock %}
</code></pre>
<p>Osiem komponentów wyodrębnionych z jednej strony: <code>about_profile</code>, <code>expertise_grid</code>, <code>skills_pills</code>, <code>opensource_grid</code>, <code>recommendation_card</code>, <code>about_cta</code>, plus <code>error_terminal</code> i <code>coming_soon_terminal</code> z innych stron.</p>
<h3>Korzyść ze styleguide'a<a id="korzyść-ze-styleguidea" href="#korzyść-ze-styleguidea" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>holas.pl ma dostępny tylko w dev styleguide pod <code>/styleguide/</code>, który pokazuje wszystkie komponenty UI. Przed ekstrakcją styleguide miał własny zakodowany markup dla każdego komponentu — który rozjeżdżał się z prawdziwymi szablonami przy zmianach.</p>
<p>Po ekstrakcji zarówno prawdziwa strona, jak i styleguide includują te same pliki komponentów:</p>
<pre><code class="language-twig">{# styleguide.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: demo_recommendation, ... }) }}

{# about.html.twig #}
{{ include('components/recommendation_card.html.twig', { rec: rec, ... }) }}
</code></pre>
<p>Zmień komponent raz, oba się aktualizują automatycznie. Styleguide z ręcznego obciążenia synchronizacyjnego stał się bezobsługowy.</p>
<p>Liczba komponentów wzrosła z ~15 do 25 w ramach tych ekstrakcji.</p>
<h2>Reorganizacja namespace'ów<a id="reorganizacja-namespaceów" href="#reorganizacja-namespaceów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Praca nad value objects i interfejsami stworzyła wystarczająco dużo plików, że płaskie namespace'y stały się zatłoczone:</p>
<pre><code># Przed
src/Content/AdjacentPosts.php
src/Content/ArchiveMonth.php
src/Content/CategoryCount.php
src/Content/CardLayout.php
src/Service/ContentService.php
src/Service/ImageResizer.php
src/Service/DraftPreviewService.php

# Po
src/Content/ValueObject/AdjacentPosts.php
src/Content/ValueObject/ArchiveMonth.php
src/Content/ValueObject/CategoryCount.php
src/Content/Enum/CardLayout.php
src/Service/Content/ContentService.php
src/Service/Image/ImageResizer.php
src/Service/Preview/DraftPreviewService.php
</code></pre>
<p><code>CardLayout</code> przeniesiony do <code>Enum/</code> — to PHP enum z backing type dla wariantów layoutu kart wpisów:</p>
<pre><code class="language-php">enum CardLayout: string
{
    case Top = 'layout-top';
    case Right = 'layout-right';
    case Text = 'layout-text';
    case Left = 'layout-left';

    /** @return list&lt;string&gt; */
    public static function cycle(): array
    {
        return array_map(fn (self $l) =&gt; $l-&gt;value, self::cases());
    }
}
</code></pre>
<p>Bezpieczny typowo, z autouzupełnianiem, niemożliwy do literówki. Metoda <code>cycle()</code> zwraca wartości layoutów, przez które szablon listingu wpisów rotuje dla wizualnej różnorodności.</p>
<p>Reorganizacja dotknęła 46 plików w jednym commicie — każda instrukcja <code>use</code> odwołująca się do przeniesionej klasy wymagała aktualizacji. To jest dokładnie ten rodzaj mechanicznej refaktoryzacji, w którym AI błyszczy: zmień namespace, zaktualizuj wszystkie importy, zweryfikuj że nic się nie popsuło. Człowiek decyduje o docelowej strukturze; AI obsługuje żmudną część.</p>
<h2>Workflow AI dla refaktoryzacji<a id="workflow-ai-dla-refaktoryzacji" href="#workflow-ai-dla-refaktoryzacji" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Praktyczny workflow stojący za tymi rundami:</p>
<p><strong>Krok 1: Zawężony audyt.</strong> Poproś Claude Code o przegląd wszystkich plików PHP pod kątem konkretnej kategorii problemów. Nie &quot;znajdź wszystkie problemy&quot; — zbyt ogólne. Zamiast tego: &quot;sprawdź każdy plik w <code>src/</code> pod kątem metod zwracających tablice asocjacyjne zamiast value objects.&quot; AI czyta każdy plik i raportuje konkretne problemy ze ścieżkami plików i numerami linii.</p>
<p><strong>Krok 2: Plan.</strong> Napisz plik <code>.plans/</code> dokumentujący co trzeba zmienić, które pliki są dotknięte i kroki implementacji jako checkboxy. Plan jest źródłem prawdy — nie podsumowaniem intencji, ale szczegółową specyfikacją implementacji.</p>
<p><strong>Krok 3: Implementacja fazami.</strong> Wykonaj jedną fazę, uruchom <code>ddev code-check</code> (PHP CS Fixer + PHPStan level 6), zweryfikuj że build przechodzi z <code>ddev build</code>. Przejdź do następnej fazy.</p>
<p><strong>Krok 4: Weryfikacja.</strong> Po zakończeniu rundy uruchom pełny zestaw kontroli jakości. Liczby dla holas.pl:</p>
<pre><code>$ ddev code-check
PHP CS Fixer: Found 0 of 53 files that can be fixed
PHPStan: [OK] No errors (53 files, level 6)
</code></pre>
<p>53 pliki PHP, zero problemów CS Fixer, zero błędów PHPStan. Automatyczne narzędzia potwierdzają to, co przegląd zamierzał.</p>
<h3>W czym AI jest dobry przy refaktoryzacji<a id="w-czym-ai-jest-dobry-przy-refaktoryzacji" href="#w-czym-ai-jest-dobry-przy-refaktoryzacji" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li><strong>Systematyczny przegląd plik po pliku</strong>: czyta 53 pliki i raportuje każde naruszenie wzorca. Ludzie prześlizgują się; AI nie.</li>
<li><strong>Mechaniczna refaktoryzacja</strong>: zmiana nazw namespace'ów w 46 plikach, aktualizacja instrukcji import, przenoszenie stałych z klas konkretnych do interfejsów.</li>
<li><strong>Sprawdzanie spójności</strong>: weryfikacja że każdy serwis ma interfejs, każdy value object jest <code>readonly</code>, każde porównanie używa stylu Yoda — w całej bazie kodu.</li>
</ul>
<h3>Do czego AI potrzebuje ludzi<a id="do-czego-ai-potrzebuje-ludzi" href="#do-czego-ai-potrzebuje-ludzi" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li><strong>Decydowanie które abstrakcje wprowadzić</strong>: czy <code>SidebarData</code> powinno być jednym value object czy czterema osobnymi wartościami zwrotnymi? AI może zaimplementować jedno i drugie; człowiek decyduje co jest czystsze.</li>
<li><strong>Ocena kiedy interfejs dodaje wartość vs. obciążenie</strong>: serwis używany w jednym miejscu nie potrzebuje interfejsu dla elastyczności testowania. Zasada projektu mówi &quot;każdy serwis dostaje interfejs&quot; — ale to człowiek ustalił tę zasadę i człowiek może ją zmienić.</li>
<li><strong>Wiedzieć kiedy przestać</strong>: trzy rundy jakości wystarczą. Baza kodu jest czysta. Czwarta runda mikro-optymalizacji byłaby over-engineeringiem.</li>
</ul>
<h3>Pętla zwrotna AGENTS.md<a id="pętla-zwrotna-agentsmd" href="#pętla-zwrotna-agentsmd" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Każda runda odkrywa wzorce warte udokumentowania. Runda 1 dodała konwencję value objects do <code>AGENTS.md</code>. Runda 2 dodała zasadę nazewnictwa interfejsów. Runda 3 dodała zasadę &quot;zero zakodowanych URL-i&quot;.</p>
<p>Następnym razem gdy Claude Code generuje nowy serwis, od razu stosuje nauki ze wszystkich trzech rund. Pierwsza wersja serwisu teraz wysyłana jest z interfejsem, używa value objects dla złożonych zwrotów i odwołuje się do nazwanych tras zamiast zakodowanych ścieżek. Iteracja kumuluje się.</p>
<h2>Rezultat<a id="rezultat" href="#rezultat" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Po trzech rundach stan bazy kodu:</p>
<ul>
<li><strong>53 pliki PHP</strong>, wszystkie z <code>declare(strict_types=1)</code>, jawnymi typami zwrotnymi, warunkami Yoda</li>
<li><strong>12 interfejsów serwisów</strong> — każdy serwis wstrzykiwany przez interfejs</li>
<li><strong>7 value objects</strong> — zero tablic asocjacyjnych dla wielopolowych zwrotów</li>
<li><strong>1 enum</strong> — bezpieczne typowo warianty layoutu kart</li>
<li><strong>25 komponentów Twig</strong> — reużywalne, współdzielone między stroną a styleguide'em</li>
<li><strong>0 problemów PHP CS Fixer</strong>, <strong>0 błędów PHPStan</strong> na poziomie 6</li>
<li><strong>0 zakodowanych URL-i</strong> w szablonach i kontrolerach</li>
</ul>
<p>Nic z tego nie było w pierwszej wersji. Pierwsza wersja miała wstrzyknięcia konkretnych klas, zwroty tablic, monolityczne szablony i zakodowane sprawdzenia locale. Działała — strona się budowała, strony się renderowały, użytkownicy mogli czytać posty na blogu.</p>
<p>Różnica to utrzymywalność. Dodanie systemu wielojęzyczności (<a href="/pl/wpisy/wielojezycznosc-strona-statyczna/">poprzedni wpis</a>) było proste, bo baza kodu była już czysta: interfejsy dla wszystkiego, typowane zwroty, czyste rozdzielenie odpowiedzialności. Refaktoryzacja czystej bazy kodu jest szybka. Refaktoryzacja bałaganu jest wolna i podatna na błędy.</p>
<p>Wyślij najpierw. Iteruj potem. Używaj AI do systematycznej pracy, którą solowy deweloper pominąłby — lub odłożył aż stanie się problemem. Trzy skupione rundy, każda budująca na poprzedniej, każda zweryfikowana automatycznymi narzędziami. Kod jest mierzalnie lepszy, a inwestycja to kilka godzin cykli przeglądów, nie tygodniowy rewrite.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-06-iterating-architecture-with-ai/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[architektura]]></category>
                    </item>
                <item>
            <title><![CDATA[Wielojęzyczność na stronie statycznej — konfiguracja zamiast kodu]]></title>
            <link>https://holas.pl/pl/wpisy/wielojezycznosc-strona-statyczna/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/wielojezycznosc-strona-statyczna/</guid>
                        <pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To jest część 9 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. Część 8 opisuje responsywne obrazy i zaplanowane wpisy. Dodanie drugiego języka do strony Symfony zazwyczaj oznacza duplikowanie kontrolerów, zakodowane na sztywno prefiksy URL-i i rozsiane po całym kodzie sprawdzenia locale. holas.pl podchodzi do tego inaczej: konfiguracja local…]]></description>
            <content:encoded><![CDATA[<p><em>To jest część 9 serii o migracji holas.pl z WordPressa na niestandardowy generator stron statycznych oparty na Symfony. <a href="/pl/wpisy/responsywne-obrazy-zaplanowane-wpisy/">Część 8</a> opisuje responsywne obrazy i zaplanowane wpisy.</em></p>
<hr />
<p>Dodanie drugiego języka do strony Symfony zazwyczaj oznacza duplikowanie kontrolerów, zakodowane na sztywno prefiksy URL-i i rozsiane po całym kodzie sprawdzenia locale. holas.pl podchodzi do tego inaczej: konfiguracja locale żyje w jednym pliku YAML, trasy generowane są z własnego atrybutu PHP, a tłumaczenia łączone są przez system plików — nie przez jawne klucze.</p>
<h2>Problem z zakodowanymi na sztywno locale<a id="problem-z-zakodowanymi-na-sztywno-locale" href="#problem-z-zakodowanymi-na-sztywno-locale" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pierwsza działająca wersja wielojęzyczności holas.pl miała około 30 miejsc z takimi wzorcami:</p>
<pre><code class="language-php">#[Route('/blog/', name: 'blog_list_en')]
public function listEn(int $page = 1): Response
{
    return $this-&gt;renderList('en', $page);
}

#[Route('/pl/wpisy/', name: 'blog_list_pl')]
public function listPl(int $page = 1): Response
{
    return $this-&gt;renderList('pl', $page);
}
</code></pre>
<p>Każda trasa miała dwie metody — jedną na locale. Właściwa logika kontrolera żyła w prywatnej metodzie <code>render*()</code>; publiczne metody były czystym boilerplate'em ustawiającym locale i delegującym dalej. Siedem kontrolerów × dwa locale = 28 metod nie robiących nic pożytecznego.</p>
<p>Dodanie trzeciego języka wymagałoby dodania 14 kolejnych metod, plus aktualizacji szablonów, listenera locale i każdego miejsca sprawdzającego <code>'pl' === $locale</code>. Kod się nie skalował.</p>
<h2>Jedno źródło prawdy — <code>_site.yaml</code><a id="jedno-źródło-prawdy--siteyaml" href="#jedno-źródło-prawdy--siteyaml" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Naprawa zaczyna się od centralizacji listy locale. Zamiast rozrzucać wiedzę o locale po plikach PHP, wszystko żyje w <code>content/_site.yaml</code>:</p>
<pre><code class="language-yaml">site:
  locales:
    en:
      label: &quot;English&quot;
      og_locale: en_US
      date_format: &quot;M d, Y&quot;
    pl:
      label: &quot;Polski&quot;
      og_locale: pl_PL
      font_preload: fonts/inter-normal-latin-ext.woff2
      date_format: &quot;d.m.Y&quot;
</code></pre>
<p>Kolejność ma znaczenie: pierwszy klucz to domyślny locale. Każdy locale niesie własne metadane — <code>og_locale</code> dla tagów Open Graph, <code>date_format</code> do renderowania szablonów, <code>font_preload</code> dla znaków Latin Extended, których potrzebuje tylko polski.</p>
<p><code>SiteConfigService</code> czyta ten plik raz, cachuje go i udostępnia całej aplikacji:</p>
<pre><code class="language-php">interface SiteConfigServiceInterface
{
    /** @return string[] Uporządkowane kody locale, pierwszy = domyślny */
    public function getLocales(): array;

    public function getDefaultLocale(): string;

    /** @return array&lt;string, mixed&gt; Konfiguracja pojedynczego locale */
    public function getLocaleConfig(string $locale): array;
}
</code></pre>
<p>Każdy kontroler, listener i loader tras wstrzykuje ten interfejs. Żaden kod PHP nie importuje listy locale z <code>framework.yaml</code> ani nie koduje na sztywno <code>['en', 'pl']</code>.</p>
<h2>Własny atrybut — <code>#[LocalizedRoute]</code><a id="własny-atrybut--localizedroute" href="#własny-atrybut--localizedroute" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kluczową innowacją jest własny atrybut PHP, który zastępuje zduplikowane metody:</p>
<pre><code class="language-php">#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class LocalizedRoute
{
    public function __construct(
        public readonly string $name,
        public readonly string $path,
        public readonly array $requirements = [],
        public readonly array $methods = [],
        public readonly int $priority = 0,
    ) {
    }
}
</code></pre>
<p>Atrybut definiuje trasę tylko dla <strong>domyślnego locale</strong>. Własny <code>LocalizedRouteLoader</code> skanuje wszystkie kontrolery, znajduje atrybuty <code>#[LocalizedRoute]</code> i generuje trasy <code>{name}_{locale}</code> dla każdego skonfigurowanego locale:</p>
<pre><code class="language-php">#[LocalizedRoute('blog_list', path: '/blog/')]
#[LocalizedRoute('blog_list_paginated', path: '/blog/page/{page}/', requirements: ['page' =&gt; '\d+'])]
public function list(string $locale, int $page = 1): Response
{
    // jedna metoda obsługuje wszystkie locale
}
</code></pre>
<p>Ta jedna metoda zastępuje dwie <code>listEn()</code> / <code>listPl()</code> z poprzedniej wersji. Loader generuje cztery trasy z dwóch atrybutów: <code>blog_list_en</code>, <code>blog_list_pl</code>, <code>blog_list_paginated_en</code>, <code>blog_list_paginated_pl</code>.</p>
<p>Łącznie we wszystkich kontrolerach: 28 metod stało się 14. Każda usunięta metoda była czystym boilerplate'em.</p>
<h3>Rozwiązywanie tras<a id="rozwiązywanie-tras" href="#rozwiązywanie-tras" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Loader musi wiedzieć, że <code>/blog/</code> to angielska ścieżka, a <code>/pl/wpisy/</code> to polska. Trzystopniowy algorytm to obsługuje:</p>
<pre><code class="language-php">private function resolvePath(
    string $name, string $defaultPath,
    string $locale, string $defaultLocale,
    array $overrides,
): string {
    if ($locale === $defaultLocale) {
        return $defaultPath;                          // EN: /blog/
    }

    if (isset($overrides[$name][$locale])) {
        return '/'.$locale.$overrides[$name][$locale]; // PL: /pl/wpisy/
    }

    return '/'.$locale.$defaultPath;                   // fallback: /pl/blog/
}
</code></pre>
<ol>
<li><strong>Domyślny locale</strong> — użyj <code>path</code> z atrybutu bez zmian: <code>/blog/</code></li>
<li><strong>Nadpisanie istnieje</strong> — dodaj <code>/{locale}</code> + przetłumaczoną ścieżkę z <code>_routes.yaml</code></li>
<li><strong>Brak nadpisania</strong> — dodaj <code>/{locale}</code> + domyślną ścieżkę: <code>/pl/blog/</code></li>
</ol>
<h3><code>_routes.yaml</code> — przetłumaczone segmenty ścieżek<a id="routesyaml--przetłumaczone-segmenty-ścieżek" href="#routesyaml--przetłumaczone-segmenty-ścieżek" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Tylko trasy z przetłumaczonymi segmentami URL potrzebują nadpisań. Trasy bez wpisów dostają automatyczny prefiks:</p>
<pre><code class="language-yaml">routes:
  blog_list:
    pl: /wpisy/
  blog_list_paginated:
    pl: /wpisy/strona/{page}/
  blog_category:
    pl: /wpisy/{category}/
  blog_archive:
    pl: /archiwum/{year}/{month}/
  contact:
    pl: /kontakt/
  search:
    pl: /szukaj/
</code></pre>
<p><code>blog_tag</code> nie ma wpisu, więc <code>blog_tag_pl</code> dostaje automatyczny prefiks: <code>/pl/tag/{tag}/</code>. Dodanie niemieckiego wymagałoby dodania wpisów <code>de:</code> do tras wymagających tłumaczenia i niczego dla tras, gdzie angielska ścieżka jest odpowiednia.</p>
<h2>Dwa wzorce rozwiązywania URL-i<a id="dwa-wzorce-rozwiązywania-url-i" href="#dwa-wzorce-rozwiązywania-url-i" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Szablony muszą linkować do stron. Są dwa fundamentalnie różne przypadki:</p>
<table>
<thead>
<tr>
<th>Typ</th>
<th>Wzorzec</th>
<th>Przykład</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Strukturalne</strong> (listingi, szukanie, kontakt, archiwum)</td>
<td><code>path('route_name_' ~ locale)</code></td>
<td><code>path('blog_list_' ~ locale)</code></td>
</tr>
<tr>
<td><strong>Treść</strong> (strony, wpisy, o mnie, prywatność)</td>
<td><code>content_url(directoryKey, locale)</code></td>
<td><code>content_url('about', locale)</code></td>
</tr>
</tbody>
</table>
<p>Trasy strukturalne pochodzą z routera — generowane przez <code>LocalizedRouteLoader</code> i mają nazwy <code>{name}_{locale}</code>. URL-e treści pochodzą z pól <code>slug</code> we frontmatter — każdy <code>en.md</code> i <code>pl.md</code> definiuje własny URL.</p>
<pre><code class="language-twig">{# Strukturalne: router zna ścieżkę #}
&lt;a href=&quot;{{ path('blog_list_' ~ locale) }}&quot;&gt;Blog&lt;/a&gt;
&lt;a href=&quot;{{ path('contact_' ~ locale) }}&quot;&gt;Kontakt&lt;/a&gt;

{# Treść: wyszukaj po kluczu katalogu #}
&lt;a href=&quot;{{ content_url('about', locale) }}&quot;&gt;O mnie&lt;/a&gt;
&lt;a href=&quot;{{ content_url('privacy-policy', locale) }}&quot;&gt;Prywatność&lt;/a&gt;
</code></pre>
<p><code>content_url()</code> to własna funkcja Twig, która wyszukuje <code>ContentItem</code> po jego kluczu katalogu — nazwie folderu — i zwraca URL z frontmatter. Zastępuje to stare podejście z zakodowanymi na sztywno slugami per locale w szablonach.</p>
<h2>Współlokalizowana treść — system plików jako łącznik tłumaczeń<a id="współlokalizowana-treść--system-plików-jako-łącznik-tłumaczeń" href="#współlokalizowana-treść--system-plików-jako-łącznik-tłumaczeń" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pliki treści tego samego wpisu żyją w tym samym katalogu:</p>
<pre><code>content/blog/tutorials/my-post/
    en.md  → slug: &quot;blog/my-post&quot;
    pl.md  → slug: &quot;pl/blog/moj-wpis&quot;
    files/ → obrazy serwowane pod /media/my-post/
</code></pre>
<p>Oba pliki w tym samym folderze są automatycznie łączone jako tłumaczenia. Nie potrzeba jawnego pola <code>translation_key</code>. <code>ContentItem::directoryKey()</code> zwraca nazwę folderu (<code>&quot;my-post&quot;</code>), a <code>TranslationMapBuilder</code> używa jej do budowy tablicy wyszukiwania:</p>
<pre><code class="language-php">public function build(array $trees): array
{
    $map = [];

    foreach ($trees as $locale =&gt; $tree) {
        foreach ($tree-&gt;getAllItems() as $item) {
            $key = $item-&gt;directoryKey();
            if (null === $key || '' === $item-&gt;url()) {
                continue;
            }
            $map[$key][$locale] = $item-&gt;url();
        }
    }

    return $map;
}
</code></pre>
<p>Wynik: <code>$map['my-post']['en'] = '/blog/my-post/'</code>, <code>$map['my-post']['pl'] = '/pl/blog/moj-wpis/'</code>. Ta mapa napędza tagi hreflang <code>&lt;link&gt;</code> w nagłówku HTML i przełącznik języka.</p>
<p>Nie każdy wpis potrzebuje obu plików locale. Wpis z samym <code>en.md</code> nie pojawi się na polskich listingach, a przełącznik języka przekieruje na stronę główną drugiego języka.</p>
<h2>Przełącznik języka — łańcuch fallbacków<a id="przełącznik-języka--łańcuch-fallbacków" href="#przełącznik-języka--łańcuch-fallbacków" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Przełącznik języka wydaje się prosty — link do tej samej strony w innym języku. W praktyce musi obsługiwać częściowe tłumaczenia, strony tagów, strony archiwum i paginowane listingi. Łańcuch fallbacków:</p>
<pre><code class="language-twig">{# 1. Spróbuj mapy tłumaczeń (strona istnieje w innym locale) #}
{% if tk and translation_map[tk][other] is defined %}
    {% set url = translation_map[tk][other] %}
{% endif %}

{# 2. Spróbuj URL podany przez kontroler (strony tagów) #}
{% if url is null and lang_switch_url is defined %}
    {% set url = lang_switch_url %}
{% endif %}

{# 3. Fallback: archiwum, paginowany listing lub strona główna #}
{% if url is null %}
    {% if filter_type is same as('archive') and archive_year is defined %}
        {% set url = path('blog_archive_' ~ other, {year: ..., month: ...}) %}
    {% elseif current_page &gt; 1 %}
        {% set url = path('blog_list_paginated_' ~ other, {page: current_page}) %}
    {% else %}
        {% set url = path('home_' ~ other) %}
    {% endif %}
{% endif %}
</code></pre>
<p>Strony tagów wymagają specjalnej obsługi: <code>BlogController</code> tłumaczy slug tagu między locale (np. <code>security</code> → <code>bezpieczenstwo</code>) i sprawdza, czy drugi locale ma jakieś wpisy z tym tagiem. Jeśli tak, przełącznik linkuje do przetłumaczonej strony tagu. Jeśli nie, wraca do listingu wpisów drugiego locale.</p>
<p>Po stronie klienta <code>locale-redirect.js</code> obsługuje detekcję języka przy pierwszej wizycie. Czyta <code>navigator.language</code>, porównuje z listą skonfigurowanych locale (z atrybutu <code>data-locales</code> na <code>&lt;html&gt;</code>) i zapisuje preferencje w ciasteczku. Przy kolejnych wizytach przekierowuje do zapisanej preferencji. Lista locale nie jest zakodowana na sztywno w JavaScript — pochodzi z tej samej konfiguracji <code>_site.yaml</code>, przekazanej przez Twig.</p>
<h2>Detekcja locale<a id="detekcja-locale" href="#detekcja-locale" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>LocaleListener</code> działa z priorytetem 8 — po wbudowanych listenerach locale Symfony — i wykrywa locale ze ścieżki URL:</p>
<pre><code class="language-php">private function detectLocale(string $path): string
{
    $defaultLocale = $this-&gt;siteConfig-&gt;getDefaultLocale();

    foreach ($this-&gt;siteConfig-&gt;getLocales() as $locale) {
        if ($locale === $defaultLocale) {
            continue;
        }

        if (str_starts_with($path, '/'.$locale.'/') || '/'.$locale === $path) {
            return $locale;
        }
    }

    return $defaultLocale;
}
</code></pre>
<p>Żadnego zakodowanego sprawdzenia <code>/pl/</code>. Iteruje skonfigurowane locale dynamicznie. Dodanie nowego locale do <code>_site.yaml</code> wystarczy, aby listener zaczął go wykrywać.</p>
<h2>Dodanie nowego języka — zero zmian w PHP<a id="dodanie-nowego-języka--zero-zmian-w-php" href="#dodanie-nowego-języka--zero-zmian-w-php" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>To jest efekt końcowy. Dodanie niemieckiego do holas.pl wymaga:</p>
<ol>
<li><strong><code>content/_site.yaml</code></strong> — dodaj wpis <code>de:</code> z label, og_locale, date_format</li>
<li><strong><code>content/_routes.yaml</code></strong> — dodaj nadpisania <code>de:</code> dla przetłumaczonych segmentów tras</li>
<li><strong><code>translations/messages.de.yaml</code></strong> — niemieckie stringi UI (etykiety nawigacji, tekst przycisków itp.)</li>
<li><strong><code>content/_tags.yaml</code></strong> — niemieckie tłumaczenia tagów</li>
<li><strong>Pliki treści</strong> — utwórz <code>de.md</code> obok <code>en.md</code> i <code>pl.md</code> dla wpisów, które powinny istnieć po niemiecku</li>
</ol>
<p>Zero zmienionych plików PHP. Zero edytowanych szablonów Twig. Zero dodanych metod kontrolera. Loader tras generuje niemieckie trasy automatycznie. Listener locale wykrywa ścieżki <code>/de/</code>. Przełącznik języka renderuje dropdown zamiast pojedynczego linka. Mapa tłumaczeń zawiera niemieckie URL-e.</p>
<p>28 metod kontrolera specyficznych per locale i 30 zakodowanych na sztywno sprawdzeń locale z pierwszej wersji wymagałoby edycji ponad 20 plików, aby dodać niemiecki. Podejście oparte na konfiguracji oznacza edycję 5 plików konfiguracyjnych i tworzenie treści.</p>
<p>Decyzje architektoniczne, które to umożliwiają — <code>SiteConfigService</code> jako jedyne źródło prawdy o locale, <code>LocalizedRouteLoader</code> generujący trasy z atrybutów, współlokalizowana treść jako łącznik tłumaczeń — to decyzje, które wyglądają na over-engineering, gdy masz tylko dwa języki. Nie są. To różnica między &quot;dodanie języka to tydzień pracy&quot; a &quot;dodanie języka to popołudnie z konfiguracją.&quot;</p>
<p><a href="/pl/wpisy/iteracyjna-architektura-z-ai/">Następny wpis</a> opisuje jak baza kodu poprawiała się przez iteracyjne rundy jakości — value objects, interfejsy, ekstrakcja komponentów — z AI obsługującym systematyczną pracę przeglądową.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-30-multilanguage-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[architektura]]></category>
                    </item>
                <item>
            <title><![CDATA[Responsywne obrazy i zaplanowane wpisy na statycznej stronie — rozwiązania w kroku budowania]]></title>
            <link>https://holas.pl/pl/wpisy/responsywne-obrazy-zaplanowane-wpisy/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/responsywne-obrazy-zaplanowane-wpisy/</guid>
                        <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[WordPress automatycznie skaluje wgrane obrazy. Zaplanowane wpisy mają selektor daty &quot;opublikuj dnia&quot; w edytorze. Obie funkcje działają bez żadnego niestandardowego kodu. Na statycznej stronie nie ma serwera obsługującego żądania ani warstwy aplikacyjnej sprawdzającej czas. Obie funkcje wymagają celowej implementacji — a właściwym miejscem dla obu jest krok budowania. Responsywne obrazy P…]]></description>
            <content:encoded><![CDATA[<p>WordPress automatycznie skaluje wgrane obrazy. Zaplanowane wpisy mają selektor daty &quot;opublikuj dnia&quot; w edytorze. Obie funkcje działają bez żadnego niestandardowego kodu.</p>
<p>Na statycznej stronie nie ma serwera obsługującego żądania ani warstwy aplikacyjnej sprawdzającej czas. Obie funkcje wymagają celowej implementacji — a właściwym miejscem dla obu jest krok budowania.</p>
<h2>Responsywne obrazy<a id="responsywne-obrazy" href="#responsywne-obrazy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Problem<a id="problem" href="#problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Wyróżnione obrazy na holas.pl mają rozmiar 1280×720px w formacie WebP. Na przeglądarce desktopowej to właściwy rozmiar. Na ekranie mobilnym o szerokości 400px przeglądarka pobiera obraz o szerokości 1280 pikseli, aby wyświetlić go na 400 pikselach — to mniej więcej 10-krotność potrzebnych danych.</p>
<p>Rozwiązaniem jest <code>srcset</code> + <code>sizes</code>: poinformuj przeglądarkę o dostępnych wariantach obrazu i o tym, jak duży jest obraz przy każdej szerokości widoku, a następnie pozwól jej wybrać właściwy plik. Ograniczenie statycznej strony: każdy wariant musi istnieć jako plik zanim nadejdzie jakiekolwiek żądanie. Nie ma skalowania na żądanie.</p>
<h3>Generowanie wariantów w czasie budowania<a id="generowanie-wariantów-w-czasie-budowania" href="#generowanie-wariantów-w-czasie-budowania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand</code> generuje warianty po skopiowaniu plików multimedialnych do <code>public/static/media/</code>. Skanuje pliki <code>.webp</code>, odczytuje faktyczne wymiary każdego pliku za pomocą <code>getimagesize()</code> i generuje warianty przez <code>ImageResizerInterface::resize()</code>:</p>
<pre><code class="language-php">$variantWidths = $this-&gt;responsiveImageService-&gt;getVariantWidths($width);

foreach ($variantWidths as $variantWidth) {
    $this-&gt;imageResizer-&gt;resize(
        $filePath,
        $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp',
        $variantWidth,
    );
}
</code></pre>
<p><code>ImageResizer::resize()</code> wywołuje ImageMagick:</p>
<pre><code class="language-bash">magick source.webp -resize 640x -quality 82 -strip -define webp:method=6 source-640w.webp
</code></pre>
<p><code>-resize 640x</code> skaluje do 640px szerokości, zachowując proporcje. <code>-quality 82 -strip -define webp:method=6</code> odpowiada ustawieniom produkcyjnym obrazów i usuwa dane EXIF.</p>
<p>Nazwy wariantów są konwencjonalne: <code>obraz.webp</code> → <code>obraz-640w.webp</code>, <code>obraz-960w.webp</code>. Budowanie pomija pliki kończące się na <code>-640w</code> lub <code>-960w</code>, aby uniknąć ponownego przetwarzania wcześniej wygenerowanych wariantów.</p>
<h3>ResponsiveImageService<a id="responsiveimageservice" href="#responsiveimageservice" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Dwa miejsca muszą wiedzieć, które warianty istnieją: <code>BuildStaticSiteCommand</code> (które pliki generować) i <code>SrcsetExtension</code> (które nazwy plików referencjonować w HTML). Zamiast duplikować logikę progów, oba wstrzykują <code>ResponsiveImageServiceInterface</code>:</p>
<pre><code class="language-php">interface ResponsiveImageServiceInterface
{
    /** @return int[] */
    public function getVariantWidths(int $sourceWidth): array;

    public function buildSrcset(string $src, int $sourceWidth): string;
}
</code></pre>
<p>Implementacja:</p>
<pre><code class="language-php">public function getVariantWidths(int $sourceWidth): array
{
    if (960 &lt; $sourceWidth) {
        return [640, 960];
    }
    if (640 &lt; $sourceWidth) {
        return [640];
    }

    return [];
}

public function buildSrcset(string $src, int $sourceWidth): string
{
    $base = substr($src, 0, -5);  // usuń .webp

    if (960 &lt; $sourceWidth) {
        return sprintf('%s-640w.webp 640w, %s-960w.webp 960w, %s 1280w', $base, $base, $src);
    }
    if (640 &lt; $sourceWidth) {
        return sprintf('%s-640w.webp 640w, %s 960w', $base, $src);
    }

    return '';
}
</code></pre>
<p>Obrazy o szerokości ≤640px nie mają wariantów — oryginał jest już wystarczająco mały. <code>buildSrcset()</code> zwraca <code>''</code> sygnalizując, że atrybut srcset nie jest potrzebny.</p>
<p>Jeśli kiedykolwiek zajdzie potrzeba zmiany progów, jest jedno miejsce do aktualizacji.</p>
<h3>Komponent wyróżnionego obrazu<a id="komponent-wyróżnionego-obrazu" href="#komponent-wyróżnionego-obrazu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Komponent <code>responsive_img.html.twig</code> renderuje wyróżnione obrazy z srcset zakodowanym na stałe dla progów 640/960/1280:</p>
<pre><code class="language-twig">&lt;img src=&quot;{{ src }}&quot;
     srcset=&quot;{{ src|replace({'.webp': '-640w.webp'}) }} 640w,
             {{ src|replace({'.webp': '-960w.webp'}) }} 960w,
             {{ src }} 1280w&quot;
     sizes=&quot;{{ sizes|default('(max-width: 48em) 100vw, 720px') }}&quot;
     alt=&quot;{{ alt }}&quot;
     width=&quot;{{ width|default(1280) }}&quot;
     height=&quot;{{ height|default(720) }}&quot;&gt;
</code></pre>
<p><code>sizes=&quot;(max-width: 48em) 100vw, 720px&quot;</code> mówi przeglądarce: poniżej szerokości widoku 48em obraz wypełnia cały widok; powyżej jest ograniczony do 720px (szerokość kolumny treści). Przeglądarka używa tego do wyboru właściwego wariantu przed pobraniem czegokolwiek.</p>
<p><code>width</code> i <code>height</code> są jawne w celu zapobiegania Cumulative Layout Shift — przeglądarka rezerwuje dokładną przestrzeń dla obrazu przed jego załadowaniem. Bez nich układ przesuwa się gdy obraz przybywa.</p>
<h3>Obrazy inline w treści<a id="obrazy-inline-w-treści" href="#obrazy-inline-w-treści" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Obrazy Markdown w treści wpisu renderują się jako zwykłe tagi <code>&lt;img&gt;</code> bez srcset. Wpis z diagramem lub zrzutem ekranu pod adresem <code>/media/post-dir/diagram.webp</code> serwowałby pełnowymiarowy obraz również na urządzeniach mobilnych.</p>
<p>Filtr Twig <code>srcset_media</code> rozwiązuje ten problem. W <code>post.html.twig</code>:</p>
<pre><code class="language-twig">{{ content.htmlContent|srcset_media|raw }}
</code></pre>
<p><code>SrcsetExtension::srcsetMedia()</code> znajduje wszystkie tagi <code>&lt;img&gt;</code> z <code>/media/*.webp</code> za pomocą wyrażenia regularnego, odczytuje szerokość źródłowego obrazu z katalogu <code>content/</code> (nie ze statycznego wyjścia) i wstrzykuje <code>srcset</code> i <code>sizes</code>:</p>
<pre><code class="language-php">$result = preg_replace_callback(
    '/&lt;img(\s[^&gt;]*)src=&quot;(\/media\/[^&quot;]+\.webp)&quot;([^&gt;]*)&gt;/i',
    function (array $matches): string {
        $src = $matches[2];

        // pomiń jeśli srcset już jest obecny
        if (str_contains($matches[1], 'srcset') || str_contains($matches[3], 'srcset')) {
            return $matches[0];
        }

        $width = $this-&gt;getSourceWidth($src);
        if (null === $width) {
            return $matches[0];
        }

        $srcset = $this-&gt;responsiveImageService-&gt;buildSrcset($src, $width);
        if ('' === $srcset) {
            return $matches[0];  // brak wariantów — zostaw bez zmian
        }

        return sprintf(
            '&lt;img%ssrc=&quot;%s&quot; srcset=&quot;%s&quot; sizes=&quot;(max-width: 48em) 100vw, 720px&quot;%s&gt;',
            $matches[1], $src, $srcset, $matches[3],
        );
    },
    $html,
);
</code></pre>
<p><code>getSourceWidth()</code> wyszukuje faktyczny plik źródłowy w <code>content/</code> (nie w <code>public/static/</code>), ponieważ tam żyją oryginalne wymiary. Obrazy bez wariantów — małe zrzuty ekranu inline o szerokości ≤640px — pozostają niezmienione.</p>
<h2>Zaplanowane wpisy<a id="zaplanowane-wpisy" href="#zaplanowane-wpisy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Problem<a id="problem-1" href="#problem-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Wpis z <code>date: 2026-06-01</code> nie powinien pojawiać się na listingach do 1 czerwca. Strona przebudowuje się każdej nocy, więc wpis z przyszłą datą po prostu nie pojawi się w <code>ContentTree::getAllPosts()</code> aż do budowania po dacie publikacji. Ta część działa automatycznie.</p>
<p>URL to inny problem. Jeśli ktoś udostępni link do wpisu przed jego opublikowaniem, dostanie błąd 404. Lepiej serwować stronę &quot;coming soon&quot; pod dokładnym URL-em, który wpis zajmie.</p>
<h3>ContentItem::isScheduled()<a id="contentitemisscheduled" href="#contentitemisscheduled" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">public function isScheduled(): bool
{
    $date = $this-&gt;date();

    return null !== $date &amp;&amp; $date &gt; new \DateTimeImmutable();
}
</code></pre>
<p>Jedno porównanie. <code>isDraft()</code> ma pierwszeństwo — wpis z jednoczesnym <code>draft: true</code> i przyszłą datą jest traktowany jako szkic i wykluczony ze wszystkich budowań.</p>
<h3>Krok budowania: strony coming soon<a id="krok-budowania-strony-coming-soon" href="#krok-budowania-strony-coming-soon" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand::collectRoutes()</code> zbiera dwie kategorie URL-i wpisów:</p>
<ul>
<li>Opublikowane wpisy przez <code>ContentTree::getAllPosts()</code> — renderowane pełnym szablonem wpisu</li>
<li>Zaplanowane wpisy przez <code>ContentTree::getScheduledPosts()</code> — renderowane szablonem coming soon</li>
</ul>
<p>Oba produkują statyczne pliki HTML pod swoim docelowym URL-em. Gdy data wpisu minie i nastąpi kolejne budowanie, <code>isScheduled()</code> zwraca <code>false</code>, URL przechodzi na listę opublikowanych, a pełny HTML wpisu zastępuje HTML coming soon. Nie potrzeba przekierowania ani specjalnej obsługi.</p>
<h3>Strona coming soon<a id="strona-coming-soon" href="#strona-coming-soon" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Szablon coming soon używa tej samej estetyki zielonego terminalu co strony błędów:</p>
<pre><code class="language-twig">{% block robots %}&lt;meta name=&quot;robots&quot; content=&quot;noindex, nofollow&quot;&gt;{% endblock %}

&lt;pre class=&quot;coming-soon-terminal&quot;&gt;&lt;code&gt;
&lt;span class=&quot;coming-soon-terminal__code&quot;&gt;COMING_SOON&lt;/span&gt;
{% if days_until &lt;= 14 %}
&lt;span class=&quot;coming-soon-terminal__text&quot;&gt;{{ post.title }}&lt;/span&gt;
&lt;span class=&quot;coming-soon-terminal__date&quot;&gt;Publikacja: {{ post.date|date('Y-m-d') }}&lt;/span&gt;
{% endif %}
&lt;/code&gt;&lt;/pre&gt;
</code></pre>
<p><code>noindex, nofollow</code> — strona obsługuje bezpośrednie linki bez indeksowania i bez przekazywania link equity.</p>
<p>Jeśli data publikacji jest ≤14 dni, tytuł i data są pokazane. Dalej: tylko kod <code>COMING_SOON</code>, bez daty. Próg 14 dni unika publicznego zobowiązania do konkretnej daty, która mogłaby się opóźnić.</p>
<h3>Pasek narzędzi podglądu w trybie dev<a id="pasek-narzędzi-podglądu-w-trybie-dev" href="#pasek-narzędzi-podglądu-w-trybie-dev" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>W produkcji zaplanowane wpisy są niewidoczne — pojawiają się tylko jako strony coming soon pod swoimi URL-ami, nie na żadnym listingu.</p>
<p>W trybie deweloperskim piszesz treść zaplanowanego wpisu i potrzebujesz ją widzieć. Pasek narzędzi profilera Symfony dostaje przełącznik z ikoną kalendarza (&quot;Podgląd zaplanowanych&quot;). Gdy jest włączony, zaplanowane wpisy pojawiają się na listingach z plakietką <code>[PLANNED]</code>:</p>
<pre><code class="language-twig">{% if post.isDraft() %}
    &lt;span class=&quot;post-card-badge post-card-badge--draft&quot;&gt;[DRAFT]&lt;/span&gt;
{% elseif post.isScheduled() %}
    &lt;span class=&quot;post-card-badge post-card-badge--planned&quot;&gt;[PLANNED]&lt;/span&gt;
{% else %}
    {# plakietki: przypięty / nowy / niedawno zaktualizowany #}
{% endif %}
</code></pre>
<p>Wyłącz go, aby podejrzeć jak będzie wyglądać produkcja. Mechanizm jest lustrzanym odbiciem istniejącego przełącznika podglądu szkiców — ten sam wzorzec klucza sesji (<code>scheduled_preview</code>), ta sama struktura kontrolera.</p>
<h2>Wzorzec kroku budowania<a id="wzorzec-kroku-budowania" href="#wzorzec-kroku-budowania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Obie funkcje podążają tym samym podejściem: przenieś pracę do kroku budowania, utrzymaj prostą warstwę serwowania.</p>
<p>Responsywne obrazy: generuj wszystkie warianty w czasie budowania. Kilka sekund wywołań ImageMagick podczas <code>ddev build</code> oszczędza przepustowość przy każdym mobilnym ładowaniu strony przez cały okres życia wpisu.</p>
<p>Zaplanowane wpisy: wstępnie renderuj strony coming soon zamiast obsługiwać &quot;jeszcze nieopublikowany&quot; w czasie żądania. Statyczny plik istnieje, nginx go serwuje, PHP nie jest zaangażowany.</p>
<p>Budowanie wykonuje się raz. nginx serwuje wynik każdemu odwiedzającemu. Każda praca, która może się przenieść do kroku budowania, to praca, której serwer nie musi wykonywać.</p>
<p>Potok budowania, który to umożliwia, opisany jest w <a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">części 2 tej serii</a>. Dwukonteinerowe środowisko produkcyjne, które go uruchamia, opisane jest w <a href="/pl/wpisy/dev-experience-dwa-kontenery/">części 3</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-25-responsive-images-scheduled-posts/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[php]]></category>
                    </item>
                <item>
            <title><![CDATA[Inżynieria SEO na statycznej stronie — dane strukturalne, karty społecznościowe i sygnały dla crawlerów]]></title>
            <link>https://holas.pl/pl/wpisy/inzynieria-seo-statyczna-strona/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/inzynieria-seo-statyczna-strona/</guid>
                        <pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl ma 100 punktów w kategorii SEO Lighthouse. Co to faktycznie sprawdza: czy tytuł meta jest obecny, czy opis meta jest obecny, czy canonical URL jest ustawiony, czy linki są dostępne dla crawlerów, czy strona jest mobilna. To minimalne wymagania — rzeczy, które blokują indeksowanie, gdy ich brakuje. Czego Lighthouse SEO nie sprawdza: czy dane strukturalne są kompletne, jak strona renderuje …]]></description>
            <content:encoded><![CDATA[<p>holas.pl ma 100 punktów w kategorii SEO Lighthouse. Co to faktycznie sprawdza: czy tytuł meta jest obecny, czy opis meta jest obecny, czy canonical URL jest ustawiony, czy linki są dostępne dla crawlerów, czy strona jest mobilna. To minimalne wymagania — rzeczy, które blokują indeksowanie, gdy ich brakuje.</p>
<p>Czego Lighthouse SEO nie sprawdza: czy dane strukturalne są kompletne, jak strona renderuje się jako karta społecznościowa, co widzą czytniki RSS, czy Google może znaleźć i zaindeksować obrazy bez crawlowania każdej podstrony.</p>
<p>Ten wpis opisuje warstwę implementacyjną pod tym wynikiem.</p>
<h2>Dane strukturalne<a id="dane-strukturalne" href="#dane-strukturalne" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Dane strukturalne to JSON-LD w bloku <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code>. Mówią wyszukiwarkom, czym jest strona — nie tylko co mówi. holas.pl używa czterech typów schematu.</p>
<h3>WebSite + SearchAction<a id="website--searchaction" href="#website--searchaction" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Każda strona zawiera schemat <code>WebSite</code> identyfikujący witrynę i jej punkt wyszukiwania:</p>
<pre><code class="language-json">{
    &quot;@context&quot;: &quot;https://schema.org&quot;,
    &quot;@type&quot;: &quot;WebSite&quot;,
    &quot;name&quot;: &quot;holas.pl&quot;,
    &quot;url&quot;: &quot;https://holas.pl&quot;,
    &quot;author&quot;: {
        &quot;@type&quot;: &quot;Person&quot;,
        &quot;name&quot;: &quot;Paweł Holik&quot;,
        &quot;url&quot;: &quot;https://holas.pl&quot;
    },
    &quot;potentialAction&quot;: {
        &quot;@type&quot;: &quot;SearchAction&quot;,
        &quot;target&quot;: {
            &quot;@type&quot;: &quot;EntryPoint&quot;,
            &quot;urlTemplate&quot;: &quot;https://holas.pl/search/?q={search_term_string}&quot;
        },
        &quot;query-input&quot;: &quot;required name=search_term_string&quot;
    }
}
</code></pre>
<p><code>potentialAction</code> umożliwia <a rel="nofollow noopener noreferrer" target="_blank" href="https://developers.google.com/search/docs/appearance/sitelinks-searchbox">pole wyszukiwania w wynikach Google</a> — pole wyszukiwania widoczne bezpośrednio w wynikach Google dla witryny. Kieruje do wyszukiwania opartego na Pagefind pod adresem <code>/search/</code>. To jedno dodatkowe pole w istniejącym schemacie bez żadnych wad.</p>
<h3>BlogPosting<a id="blogposting" href="#blogposting" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Wpisy na blogu mają najbogatszy schemat. Poza <code>headline</code>, <code>description</code>, <code>url</code> i <code>datePublished</code>, kilka pól wpływa na to, jak Google reprezentuje treść:</p>
<ul>
<li><strong><code>inLanguage</code></strong> — <code>&quot;en&quot;</code> lub <code>&quot;pl&quot;</code>, wymagane do indeksowania wielojęzycznego</li>
<li><strong><code>wordCount</code></strong> — obliczany w czasie parsowania przez <code>ContentItem::wordCount()</code> (usuwa tagi HTML, liczy tokeny)</li>
<li><strong><code>articleSection</code></strong> — kategoria wpisu</li>
<li><strong><code>keywords</code></strong> — tagi wpisu jako ciąg rozdzielony przecinkami</li>
<li><strong><code>image</code></strong> — zagnieżdżony <code>ImageObject</code> z <code>url</code>, <code>width</code> i <code>height</code></li>
</ul>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;BlogPosting&quot;,
    &quot;headline&quot;: &quot;Tytuł wpisu&quot;,
    &quot;inLanguage&quot;: &quot;pl&quot;,
    &quot;wordCount&quot;: 842,
    &quot;articleSection&quot;: &quot;porady&quot;,
    &quot;keywords&quot;: &quot;seo, symfony, statyczna-strona&quot;,
    &quot;image&quot;: {
        &quot;@type&quot;: &quot;ImageObject&quot;,
        &quot;url&quot;: &quot;https://holas.pl/media/2026-05-16-seo-engineering-static-site/featured.webp&quot;,
        &quot;width&quot;: 1280,
        &quot;height&quot;: 720
    }
}
</code></pre>
<p>Bez <code>ImageObject</code> Google traktuje wyróżniony obraz jako nieznany załącznik. Z jawnie podanymi szerokością i wysokością obraz kwalifikuje się do dużych kart podglądu w Google Discover i Search.</p>
<h3>BreadcrumbList<a id="breadcrumblist" href="#breadcrumblist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Google może zastąpić surowy URL w wynikach wyszukiwania nawigacją okruszkową — &quot;Strona główna / Blog / porady / Tytuł wpisu&quot;. Wymaga to schematu <code>BreadcrumbList</code>.</p>
<p>Jest renderowany w <code>breadcrumb.html.twig</code> obok nawigacji HTML. Każdy okruszek to <code>ListItem</code> z <code>position</code> i <code>item</code> (URL). Ostatni element — bieżąca strona — ma nazwę, ale bez URL:</p>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;BreadcrumbList&quot;,
    &quot;itemListElement&quot;: [
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;name&quot;: &quot;Strona główna&quot;, &quot;item&quot;: &quot;https://holas.pl/pl/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 2, &quot;name&quot;: &quot;Wpisy&quot;, &quot;item&quot;: &quot;https://holas.pl/pl/wpisy/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 3, &quot;name&quot;: &quot;porady&quot;, &quot;item&quot;: &quot;https://holas.pl/pl/wpisy/porady/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 4, &quot;name&quot;: &quot;Tytuł wpisu&quot; }
    ]
}
</code></pre>
<h3>CollectionPage + ItemList<a id="collectionpage--itemlist" href="#collectionpage--itemlist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Strony z listingami kategorii, tagów i archiwum zawierają <code>CollectionPage</code> z zagnieżdżonym <code>ItemList</code>. Każdy wpis ma <code>position</code> i <code>url</code>. Renderowany tylko gdy listing zawiera wpisy — pusta strona kategorii tego nie dostaje.</p>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;CollectionPage&quot;,
    &quot;name&quot;: &quot;porady | holas.pl&quot;,
    &quot;mainEntity&quot;: {
        &quot;@type&quot;: &quot;ItemList&quot;,
        &quot;numberOfItems&quot;: 5,
        &quot;itemListElement&quot;: [
            { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;url&quot;: &quot;https://holas.pl/pl/wpis/&quot; }
        ]
    }
}
</code></pre>
<h2>Udostępnianie w mediach społecznościowych<a id="udostępnianie-w-mediach-społecznościowych" href="#udostępnianie-w-mediach-społecznościowych" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Wymiary obrazu OpenGraph<a id="wymiary-obrazu-opengraph" href="#wymiary-obrazu-opengraph" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Bez <code>og:image:width</code> i <code>og:image:height</code> platformy jak LinkedIn i Slack muszą pobrać obraz zanim wyrenderują kartę podglądu. Z nimi karta renderuje się natychmiast:</p>
<pre><code class="language-html">&lt;!-- wpis na blogu (WebP, 1280×720) --&gt;
&lt;meta property=&quot;og:image:width&quot; content=&quot;1280&quot;&gt;
&lt;meta property=&quot;og:image:height&quot; content=&quot;720&quot;&gt;
&lt;meta property=&quot;og:image:type&quot; content=&quot;image/webp&quot;&gt;

&lt;!-- inne strony (domyślny og:image, JPG, 1200×630) --&gt;
&lt;meta property=&quot;og:image:width&quot; content=&quot;1200&quot;&gt;
&lt;meta property=&quot;og:image:height&quot; content=&quot;630&quot;&gt;
&lt;meta property=&quot;og:image:type&quot; content=&quot;image/jpeg&quot;&gt;
</code></pre>
<p>Warunek jest w <code>base.html.twig</code>: jeśli obiekt <code>content</code> z obrazem jest zdefiniowany (wpis lub strona z wyróżnionym obrazem), użyj wymiarów WebP; w przeciwnym razie użyj wartości domyślnych dla <code>og-default.jpg</code>. Wyjątek dla JPG istnieje dlatego, że <code>og:image</code> jest odczytywany przez zewnętrzne crawlery, które nie obsługują WebP niezawodnie.</p>
<h3>Karta Twitter/X<a id="karta-twitterx" href="#karta-twitterx" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Podstawowy typ <code>twitter:card</code> był już obecny. Dodano trzy jawne pola:</p>
<pre><code class="language-html">&lt;meta name=&quot;twitter:title&quot; content=&quot;...&quot;&gt;
&lt;meta name=&quot;twitter:description&quot; content=&quot;...&quot;&gt;
&lt;meta name=&quot;twitter:image&quot; content=&quot;...&quot;&gt;
</code></pre>
<p>Bez nich Twitter/X wraca do właściwości OG. Jawne meta usuwa tę zależność — jeśli przetwarzanie OG ma jakikolwiek problem, Twitter Card nadal ma poprawne wartości.</p>
<h3>Meta article:*<a id="meta-article" href="#meta-article" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Wpisy na blogu dostają specyficzne dla artykułu meta OG w bloku <code>og_article_meta</code> szablonu <code>post.html.twig</code>:</p>
<pre><code class="language-html">&lt;meta property=&quot;article:published_time&quot; content=&quot;2026-06-21T00:00:00+00:00&quot;&gt;
&lt;meta property=&quot;article:modified_time&quot; content=&quot;2026-06-21T00:00:00+00:00&quot;&gt;
&lt;meta property=&quot;article:author&quot; content=&quot;Paweł Holik&quot;&gt;
&lt;meta property=&quot;article:section&quot; content=&quot;porady&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;seo&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;symfony&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;statyczna-strona&quot;&gt;
</code></pre>
<p><code>article:tag</code> to jeden element na tag — nie oddzielony przecinkami ciąg. Specyfikacja Open Graph wymaga osobnych elementów dla właściwości wielowartościowych.</p>
<h2>Czytniki RSS<a id="czytniki-rss" href="#czytniki-rss" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>content:encoded<a id="contentencoded" href="#contentencoded" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Domyślny <code>&lt;description&gt;</code> RSS zawiera tylko fragment wpisu — pierwszy akapit z usuniętym HTML. <code>content:encoded</code> przenosi pełny HTML wpisu w bloku CDATA:</p>
<pre><code class="language-xml">&lt;content:encoded&gt;&lt;![CDATA[&lt;p&gt;Pełna treść wpisu...&lt;/p&gt;]]&gt;&lt;/content:encoded&gt;
</code></pre>
<p>Wymaga to <code>xmlns:content=&quot;http://purl.org/rss/1.0/modules/content/&quot;</code> na elemencie głównym <code>&lt;rss&gt;</code>. Czytniki RSS jak NetNewsWire, Reeder i Feedbin renderują <code>content:encoded</code> inline — subskrybenci czytają pełny artykuł bez opuszczania czytnika.</p>
<h3>category i media:content<a id="category-i-mediacontent" href="#category-i-mediacontent" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Każdy element RSS dostaje elementy <code>&lt;category&gt;</code> dla kategorii wpisu i każdego tagu:</p>
<pre><code class="language-xml">&lt;category&gt;porady&lt;/category&gt;
&lt;category&gt;seo&lt;/category&gt;
&lt;category&gt;symfony&lt;/category&gt;
</code></pre>
<p><code>media:content</code> dołącza wyróżniony obraz jako typowany załącznik multimedialny:</p>
<pre><code class="language-xml">&lt;media:content url=&quot;https://holas.pl/media/2026-05-16-seo-engineering-static-site/featured.webp&quot;
               medium=&quot;image&quot; type=&quot;image/webp&quot; width=&quot;1280&quot; height=&quot;720&quot;/&gt;
</code></pre>
<p>Czytniki RSS renderujące obrazy inline (Feedly, Inoreader) używają tego do miniatury wpisu na liście. Wymaga to <code>xmlns:media=&quot;http://search.yahoo.com/mrss/&quot;</code> na elemencie <code>&lt;rss&gt;</code>.</p>
<h2>Sygnały dla crawlerów<a id="sygnały-dla-crawlerów" href="#sygnały-dla-crawlerów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>max-image-preview:large<a id="max-image-previewlarge" href="#max-image-previewlarge" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Domyślne zachowanie robots ogranicza podglądy obrazów w Google Search i Discover do standardowego rozmiaru. <code>max-image-preview:large</code> włącza podglądy pełnowymiarowe. W połączeniu z <code>max-snippet:-1</code> (bez ograniczenia długości fragmentu tekstu) jest to domyślny robots meta na każdej stronie:</p>
<pre><code class="language-html">&lt;meta name=&quot;robots&quot; content=&quot;max-image-preview:large, max-snippet:-1&quot;&gt;
</code></pre>
<p>Zaimplementowane jako domyślny <code>{% block robots %}</code> w <code>base.html.twig</code>. Szablony potomne nadpisują blok dla stron, które nie powinny być indeksowane — strony &quot;coming soon&quot; używają <code>noindex, nofollow</code>, strona wyszukiwania używa <code>noindex</code>.</p>
<h3>Sitemap obrazów<a id="sitemap-obrazów" href="#sitemap-obrazów" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Standardowy sitemap wyświetla URL stron. Sitemap obrazów dodaje bloki <code>&lt;image:image&gt;</code>, dając Google bezpośredni wgląd w lokalizacje obrazów i ich teksty alternatywne bez crawlowania każdej strony:</p>
<pre><code class="language-xml">&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;
        xmlns:image=&quot;http://www.google.com/schemas/sitemap-image/1.1&quot;&gt;
    &lt;url&gt;
        &lt;loc&gt;https://holas.pl/wpis/&lt;/loc&gt;
        &lt;image:image&gt;
            &lt;image:loc&gt;https://holas.pl/media/post-dir/featured.webp&lt;/image:loc&gt;
            &lt;image:title&gt;Tekst alternatywny z frontmatter image_alt&lt;/image:title&gt;
        &lt;/image:image&gt;
    &lt;/url&gt;
</code></pre>
<p><code>image:title</code> pochodzi z pola frontmatter <code>image_alt</code> — tego samego tekstu, który jest używany w atrybucie HTML <code>alt</code>. Zarówno przestrzeń nazw <code>xmlns:image</code>, jak i blok <code>image:image</code> są w <code>sitemap.xml.twig</code>.</p>
<h2>Co się zmieniło<a id="co-się-zmieniło" href="#co-się-zmieniło" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Wynik Lighthouse SEO był 100 przed tymi zmianami. Po nich nadal jest 100. Ten wynik mierzy techniczne minimum: indeksowalność, tagi meta, responsywność mobilną.</p>
<p>Powyższe zmiany działają na innym poziomie. Dane strukturalne kształtują sposób reprezentowania treści przez wyszukiwarki w bogatych wynikach. Jawne meta społecznościowe zapewniają poprawne renderowanie bez polegania na logice fallback platformy. Rozszerzenia RSS pozwalają subskrybentom czytać pełne wpisy w swoim czytniku. Sitemap obrazów daje Google widoczność obrazów bez potrzeby crawlowania każdej strony.</p>
<p>Żadna z tych zmian nie jest architektonicznie złożona — większość to uzupełnienia szablonów Twig i deklaracje przestrzeni nazw. Ograniczeniem jest dyscyplina: każde pole wymaga rzeczywistej wartości z frontmatter, nie zastępnika.</p>
<p>Architektura, która sprawia, że to wszystko jest proste, opisana jest w <a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">części 2 tej serii</a> — potok generowania statycznego, który produkuje kompletny HTML dla każdej strony.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-16-seo-engineering-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[seo]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[4×100 w Lighthouse Mobile — co daje statyczna strona]]></title>
            <link>https://holas.pl/pl/wpisy/lighthouse-wynik-100/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/lighthouse-wynik-100/</guid>
                        <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl osiąga 100 we wszystkich czterech kategoriach Lighthouse na urządzeniach mobilnych — Performance, Accessibility, Best Practices i SEO. Testy mobilne są ostrzejsze: wolniejszy symulowany procesor, ograniczona przepustowość sieci, wyższe progi punktacji. Osiągnięcie czterech setnych tam oznacza, że wyniki desktopowe dbają o siebie same. Ten wpis omawia, co konkretnie napędza każdy wynik. Wi…]]></description>
            <content:encoded><![CDATA[<p>holas.pl osiąga 100 we wszystkich czterech kategoriach Lighthouse na urządzeniach mobilnych — <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagespeed.web.dev/analysis/https-holas-pl/g7kl99oxfg?form_factor=mobile">Performance, Accessibility, Best Practices i SEO</a>. Testy mobilne są ostrzejsze: wolniejszy symulowany procesor, ograniczona przepustowość sieci, wyższe progi punktacji. Osiągnięcie czterech setnych tam oznacza, że wyniki desktopowe dbają o siebie same.</p>
<p><img src="/media/2026-05-10-lighthouse-perfect-score/lighthouse-scores.webp" alt="Lighthouse mobile: 4×100 — Performance, Accessibility, Best Practices, SEO" /></p>
<p>Ten wpis omawia, co konkretnie napędza każdy wynik. Większość z tego nie jest wynikiem optymalizacji — to efekt uboczny sposobu, w jaki strona jest zbudowana.</p>
<h2>Performance<a id="performance" href="#performance" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Główny powód, dla którego wynik performance wynosi 100, jest taki, że nginx serwuje wstępnie wyrenderowane pliki HTML bez udziału PHP. Nie ma zapytania do bazy danych, renderowania szablonów ani bootstrapowania frameworka przy każdym żądaniu. Plik trafia z dysku do klienta. Raspberry Pi 5 obsługuje to bez najmniejszego wysiłku.</p>
<p>Reszta wynika z tego bezpośrednio:</p>
<p><strong>Zasoby są hashowane i niezmienne.</strong> Pliki JavaScript i CSS skompilowane przez Symfony AssetMapper otrzymują hash zawartości w nazwie pliku (<code>app-a1b2c3d4.css</code>). nginx serwuje je z nagłówkiem <code>Cache-Control: public, max-age=31536000, immutable</code> — rok czasu, bez rewalidacji. Przy ponownych odwiedzinach przeglądarka obsługuje wszystko z cache. Przy deploymencie hash się zmienia i nowy plik jest pobierany.</p>
<p><strong>JavaScript jest minimalny i nie blokuje renderowania.</strong> Strona używa natywnych modułów ES przez importmap — bez bundlera, bez webpacka, bez jQuery. Jest siedem małych plików JS: <code>app.js</code>, <code>contact.js</code>, <code>cookie-banner.js</code>, <code>lightbox.js</code>, <code>locale-redirect.js</code>, <code>nav-toggle.js</code>, <code>tagline.js</code>. Żaden z nich nie blokuje renderowania. Wyszukiwanie obsługuje <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a> — statyczny indeks wyszukiwania oparty na WebAssembly, który ładuje się leniwie — tylko na stronie wyszukiwania, tylko gdy jest potrzebny.</p>
<p><strong>Obrazy są w formacie WebP.</strong> Zdjęcia wyróżniające są zapisane jako WebP o wymiarach 1280×720. Żadnych dużych nieskompresowanych JPEGów.</p>
<p><strong>Brak zasobów blokujących renderowanie.</strong> Nie ma <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> do zewnętrznego CDN z fontami ani synchronicznego skryptu third-party ładowanego w <code>&lt;head&gt;</code>. CSS jest kompilowany lokalnie i serwowany jako hashowany zasób.</p>
<h2>Accessibility<a id="accessibility" href="#accessibility" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong><code>&lt;html lang&gt;</code> ustawiany na podstawie locale.</strong> Każda strona ma poprawny atrybut języka — <code>lang=&quot;en&quot;</code> dla stron angielskich, <code>lang=&quot;pl&quot;</code> dla polskich. Jest ustawiany w bazowym szablonie Twig na podstawie aktualnego locale, nie na sztywno.</p>
<p><strong>Semantyczny HTML w całości.</strong> Layout używa <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;article&gt;</code>, <code>&lt;aside&gt;</code>, <code>&lt;footer&gt;</code> — nie sekwencji elementów <code>&lt;div&gt;</code>. Nagłówki zachowują logiczną hierarchię: jeden <code>&lt;h1&gt;</code> na stronę, <code>&lt;h2&gt;</code> dla sekcji najwyższego poziomu, <code>&lt;h3&gt;</code> poniżej.</p>
<p><strong>Kontrast kolorów jest zachowany.</strong> Strona używa ciemnej palety opartej na Monokai: tekst <code>#F8F8F2</code> na tle <code>#242424</code>. To współczynnik kontrastu 15,5:1, znacznie powyżej progu WCAG AA wynoszącego 4,5:1.</p>
<p><strong>Wszystkie obrazy mają atrybuty alt.</strong> Jest to wymuszane w szablonach Twig — tag <code>&lt;img&gt;</code> zawsze wyprowadza tekst alt z frontmatter elementu treści.</p>
<p><strong>Metatag viewport jest obecny.</strong> Każda strona zawiera <code>&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;</code>.</p>
<h2>Best Practices<a id="best-practices" href="#best-practices" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>HTTPS.</strong> Strona działa za Cloudflare, który obsługuje zakończenie TLS. Wszystkie żądania HTTP są przekierowywane na HTTPS.</p>
<p><strong>Nagłówki bezpieczeństwa.</strong> nginx ustawia pełny zestaw na każdej odpowiedzi:</p>
<pre><code class="language-nginx">add_header X-Frame-Options           &quot;SAMEORIGIN&quot;                       always;
add_header X-Content-Type-Options    &quot;nosniff&quot;                          always;
add_header Referrer-Policy           &quot;strict-origin-when-cross-origin&quot;  always;
add_header Permissions-Policy        &quot;camera=(), microphone=(), geolocation=()&quot; always;
add_header Content-Security-Policy   &quot;default-src 'self'; ...&quot; always;
</code></pre>
<p>CSP wymagał pewnej uwagi — <code>wasm-unsafe-eval</code> dla paczki WASM Pagefinda oraz <code>challenges.cloudflare.com</code> jako dozwolone źródło ramki dla CAPTCHA Turnstile w formularzu kontaktowym. Wszystko inne to <code>'self'</code>.</p>
<p><strong>Brak przestarzałych API.</strong> Strona nie używa <code>document.write</code>, <code>XMLHttpRequest</code>, layoutów <code>&lt;table&gt;</code> ani niczego innego, co Lighthouse oznacza jako przestarzałą praktykę.</p>
<p><strong>Brak mixed content.</strong> Każdy zewnętrzny zasób (skrypt Cloudflare Turnstile) jest ładowany przez HTTPS.</p>
<h2>SEO<a id="seo" href="#seo" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Wstępnie wyrenderowany HTML.</strong> Roboty wyszukiwarek otrzymują kompletny HTML — każdy nagłówek, akapit, blok kodu i link jest w źródle strony. Nie ma renderowania po stronie klienta, na które trzeba czekać, nie potrzeba JavaScript, żeby zobaczyć treść.</p>
<p><strong>Sitemap z hreflang.</strong> Mapa strony pod adresem <code>/sitemap.xml</code> wyświetla wszystkie wpisy i strony dla obu wersji językowych. Każdy wpis zawiera pary <code>&lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> wskazujące na wersje EN i PL. Jeśli wpis istnieje tylko w jednym języku, wpis alternatywny jest pomijany.</p>
<p><strong>hreflang w <code>&lt;head&gt;</code>.</strong> Każda strona zawiera tagi <code>&lt;link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> dla obu locale. Przełącznik języka używa tej samej mapy tłumaczeń — zbudowanej z współlokalizowanych plików <code>en.md</code>/<code>pl.md</code> w każdym katalogu wpisu.</p>
<p><strong>Kanoniczne URL-e.</strong> Każda strona zawiera <code>&lt;link rel=&quot;canonical&quot; href=&quot;...&quot;&gt;</code> wskazujący na autorytatywny URL tej strony.</p>
<p><strong>Metadane OpenGraph.</strong> Każda strona ma <code>og:title</code>, <code>og:description</code>, <code>og:image</code> i <code>og:url</code>. Są wypełniane z frontmatter — pola <code>title</code>, <code>description</code> i <code>image</code> mapują się bezpośrednio na tagi OG w bazowym szablonie.</p>
<p><strong>Opisowe tytuły i meta opisy.</strong> Pola frontmatter <code>title</code> i <code>description</code> są wymagane. Strona nie ma żadnych stron z domyślnymi lub brakującymi meta opisami.</p>
<h2>Co nie było automatyczne<a id="co-nie-było-automatyczne" href="#co-nie-było-automatyczne" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Większość z powyższego wynika z architektury — pliki statyczne, minimalny JS, wstępnie wyrenderowany HTML. Ale kilka rzeczy wymagało świadomej pracy.</p>
<p><strong>Atrybuty dostępności.</strong> Atrybut <code>lang</code>, wymuszanie tekstu alt, hierarchia nagłówków i wybór semantycznych elementów — wszystko to musiało zostać zapisane w szablonach. Nie pojawia się samo z siebie.</p>
<p><strong>CSP.</strong> Prawidłowe skonfigurowanie Content-Security-Policy wymagało kilku iteracji. Pagefind używa WebAssembly, co wymaga <code>wasm-unsafe-eval</code>. Cloudflare Turnstile ładuje się z <code>challenges.cloudflare.com</code> i potrzebuje wyjątku frame-src. Każdy zewnętrzny zasób wymaga jawnego wyjątku w CSP — dodanie jednego bez sprawdzenia psuje wynik.</p>
<p><strong>hreflang.</strong> Serwis <code>TranslationMapBuilder</code> buduje mapę <code>{directoryKey → {locale → url}}</code> ze współlokalizowanych plików treści. Jeśli wpis istnieje tylko w jednym locale, wpis hreflang dla brakującego locale jest pomijany zamiast wskazywać na nieistniejący URL. Wymagało to celowego fallbacku w szablonie, nie tylko pętli po wszystkich locale.</p>
<h2>Wynik<a id="wynik" href="#wynik" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cztery setki to nie efekt sprintu optymalizacyjnego. To to, co dostaje się, gdy strona serwuje pliki statyczne, używa minimalnego JavaScript, ma właściwą strukturę HTML i ustawia nagłówki bezpieczeństwa, które powinny być na każdej stronie produkcyjnej.</p>
<p>Architektura jest szczegółowo opisana w <a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">części 2 tej serii</a> (jak Symfony generuje statyczny HTML) i <a href="/pl/wpisy/dev-experience-dwa-kontenery/">części 4</a> (jak nginx serwuje go na produkcji).</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-10-lighthouse-perfect-score/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[accessibility]]></category>
                        <category><![CDATA[seo]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1 — Skeleton, motyw i dowód że to działa]]></title>
            <link>https://holas.pl/pl/wpisy/notacms-1-1-skeleton-motyw-dowod/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/notacms-1-1-skeleton-motyw-dowod/</guid>
                        <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS 1.1.0 pojawił się 24 kwietnia, 1.1.1 dwa dni później, a 1.1.2 jakieś półtora tygodnia po tym. Trzy wydania które zmieniają nie tyle to co notACMS robi, ale to jak się z niego korzysta — i co dostajesz na starcie. notACMS wyrósł z mojej własnej strony i przez pierwsze wydanie był jednym kawałkiem: klonujesz repo, masz gotowy design, zaczynasz od nadpisywania. Działało, ale każdy kto chciał …]]></description>
            <content:encoded><![CDATA[<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.0">notACMS 1.1.0</a> pojawił się 24 kwietnia, <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.1">1.1.1</a> dwa dni później, a <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.2">1.1.2</a> jakieś półtora tygodnia po tym. Trzy wydania które zmieniają nie tyle to co notACMS robi, ale to jak się z niego korzysta — i co dostajesz na starcie.</p>
<hr />
<p>notACMS wyrósł z mojej własnej strony i przez pierwsze wydanie był jednym kawałkiem: klonujesz repo, masz gotowy design, zaczynasz od nadpisywania. Działało, ale każdy kto chciał zbudować własny wygląd od zera musiał walczyć z rzeczami których nie potrzebował. 1.1.0 rozwiązuje to przez podział na rdzeń i motyw demo.</p>
<h2>Rdzeń jest skeletonem<a id="rdzeń-jest-skeletonem" href="#rdzeń-jest-skeletonem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>templates/</code>, <code>assets/</code>, <code>translations/</code> to teraz minimalny szkielet. Systemowe fonty, tryb jasny, około 200 linii CSS. Wszystkie funkcje działają — blog, strony, wyszukiwarka, RSS, mapa strony, responsywne obrazki, formularz kontaktowy — ale wygląda to jak strona z lat 90. Celowo. Jeśli budujesz własny design, nie musisz walczyć z motywem który narzuca ci język wizualny.</p>
<p>Motyw demo mieszka w <code>docs/demo/</code> i jest domyślnym seedem — <code>./notACMS deploy</code> albo <code>ddev build</code> położą go przy pierwszym uruchomieniu. Jeśli wolisz skeleton, dodaj <code>--bare</code>:</p>
<pre><code class="language-bash">./notACMS deploy           # amber-phosphor (domyślnie), gotowy do poprawiania
./notACMS deploy --bare    # skeleton, budujesz od zera
</code></pre>
<p>Cała reszta — <a href="/pl/wpisy/wzorzec-lokalnych-nadpisan/">wzorzec lokalnych nadpisań</a> przez katalog <code>local/</code>, brak edycji rdzenia, czysty <code>git pull</code> — działa tak samo niezależnie od wyboru.</p>
<h3>Co jeszcze trafiło do 1.1.0<a id="co-jeszcze-trafiło-do-110" href="#co-jeszcze-trafiło-do-110" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Czas czytania i pasek postępu na postach i dokumentach. Przełącznik języków jako rozszerzenie Twig. Fragmenty postów które nie przeciekają już <code>#</code> z anchorów nagłówków. Szkielet testów PHPUnit w <code>tests/</code>. Skille AI-agent do pracy z repozytorium. Pakiet zgodności starego motywu w <code>docs/customization/old-template/</code> — jedno <code>cp -r</code> i wracasz do wyglądu z 1.0.0.</p>
<h2>1.1.1 — łatka którą wymusiło realne użycie<a id="111--łatka-którą-wymusiło-realne-użycie" href="#111--łatka-którą-wymusiło-realne-użycie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Przygotowując ten wpis na holas.pl, deployując na produkcję, zauważyłem coś irytującego: <code>./notACMS deploy --prod</code> przy każdym uruchomieniu robił backup <code>local/</code> i podmieniał go świeżą kopią. Jeśli miałeś tam już treść, znikała. Deploy nie odróżniał &quot;użytkownik chce zastąpić cały motyw&quot; od &quot;użytkownik chce tylko zbudować stronę z istniejącą treścią&quot;.</p>
<p>1.1.1 naprawia to tak, że deploy działa jak <code>ddev build</code>: inicjuje <code>local/</code> tylko gdy katalog nie istnieje lub jest pusty. Treść którą już masz zostaje nienaruszona. Chcesz wymusić reseed? Podaj <code>--bare</code> lub <code>--demo</code> jawnie.</p>
<p>Drugą zmianą są etykiety nawigacji z frontmatteru. Do tej pory każda zakładka w menu wymagała klucza tłumaczenia w każdym pliku locale — <code>nav.home</code>, <code>nav.about</code>, <code>site.releases</code> i tak dalej. Dodanie nowej strony oznaczało aktualizację N plików YAML. Teraz wystarczy <code>menu.label</code> w frontmatterze strony:</p>
<pre><code class="language-yaml">---
title: &quot;Architecture guide&quot;
menu:
  label: &quot;Architecture&quot;
  weight: 30
---
</code></pre>
<p>Nowa funkcja Twig <code>content_item()</code> odczytuje to bez dodatkowej konfiguracji:</p>
<pre><code class="language-twig">{{ content_item('architecture-guide', 'en').menuLabel() }}
</code></pre>
<p>Polskie, niemieckie i francuskie treści demo dostały pełny przegląd przez wszystkie strony i wpisy.</p>
<h2>1.1.2 — utwardzenie po przeglądzie<a id="112--utwardzenie-po-przeglądzie" href="#112--utwardzenie-po-przeglądzie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>1.1.2 to to, co wychodzi gdy siądziesz nad kodem przed taggiem i spojrzysz na niego świeżym okiem. Podczas przeglądu wypłynęły dwa bugi bezpieczeństwa i zostały naprawione przed wydaniem: open-redirect przez normalizację ścieżki (<code>Request::getPathInfo()</code> nie zwija powtórzonych slashy, więc <code>/&lt;default-locale&gt;//evil.com</code> wyprodukowałoby <code>Location: //evil.com</code> — przekierowanie cross-origin) oraz XSS na stronie wyników wyszukiwania, gdzie pole <code>excerpt</code> z Pagefind było wstrzykiwane do innerHTML bez escapowania.</p>
<p>Ficzer headline'owy to kanoniczne URL-e. Jeśli twoja domyślna locale to <code>en</code>, <code>/en/blog/</code> i <code>/blog/</code> były obie dostępne i renderowały tę samą treść — dwa indeksowalne URL-e dla jednej strony. Nowy event listener zwraca teraz <code>301</code> z <code>/&lt;default-locale&gt;/...</code> na wersję bez prefiksu, zanim router Symfony w ogóle ruszy.</p>
<p>Wewnętrznie, te wszystkie inline'owe bloki <code>|json_encode|raw</code> z JSON-LD rozsiane po szablonach zniknęły. Mały serwis <code>StructuredDataBuilder</code> plus dwie funkcje Twig (<code>json_ld()</code> i <code>structured_data()</code>) zastępują je płynnym, typowanym API. <code>JSON_THROW_ON_ERROR</code> jest włączone, więc zły bajt UTF-8 we frontmatterze rzuci wyjątkiem podczas renderu zamiast po cichu wysyłać <code>&lt;script&gt;false&lt;/script&gt;</code>. Bazowe szablony rdzenia dla stron <code>contact</code>, <code>default</code> i <code>projects</code> też emitują teraz znaczniki Schema.org — bare deploy nie ma już słabszego SEO niż każdy przykład customizacji, który dostarczamy.</p>
<h2>Demo jako żywy dowód<a id="demo-jako-żywy-dowód" href="#demo-jako-żywy-dowód" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Najbardziej satysfakcjonująca część tego wydania to nie kod — to co jest w <code>docs/demo/</code>. Nie motyw. Kompletna, czterojęzyczna strona która jest dołączona do repozytorium. Ma własny manual, dokumentację architektury, styleguide i blog wydań — wszystko działające, wszystko wyrenderowane przez ten sam system który dostajesz po <code>git clone</code>.</p>
<ul>
<li><strong><a href="/manual/">Manual</a></strong> — instalacja, konfiguracja, struktura treści, frontmatter, komendy, deploy, zmienne środowiskowe, rozwiązywanie problemów</li>
<li><strong><a href="/architecture/">Architektura</a></strong> — routing, pipeline treści, statyczny build, wyszukiwarka, wielojęzyczność, deployment</li>
<li><strong><a href="/styleguide/">Styleguide</a></strong> — każdy komponent udokumentowany z prawdziwymi tokenami SCSS</li>
<li><strong><a href="/blog/releases/">Blog wydań</a></strong> — wpisy o każdej wersji, renderowane przez ten sam system</li>
</ul>
<p>&quot;Ufam ci że działa, ale pokaż&quot; — to jest właśnie to. Demo nie jest przykładem, jest dowodem. Wielojęzyczne routowanie, pre-renderowanie statyczne, Pagefind, responsywne obrazki, formularz kontaktowy, RSS, sitemap — wszystko działa w treściach demo. Ktoś po świeżym <code>git clone</code> i <code>ddev build</code> widzi dokładnie tę stronę.</p>
<h2>Linki<a id="linki" href="#linki" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pełny changelog ze wszystkimi zmianami: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#112---2026-05-04">CHANGELOG.md</a>.</p>
<p>Zmiany breakingowe i migracja z 1.0.0: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.1.md">UPGRADE-1.1.md</a>.</p>
<p>Repozytorium: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / holas1337/notACMS</a> — Apache 2.0.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-04-notacms-1-1/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[realizacje]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[architektura]]></category>
                    </item>
                <item>
            <title><![CDATA[Budowanie holas.pl z AI — Claude Code, MCP i lokalne generowanie obrazów]]></title>
            <link>https://holas.pl/pl/wpisy/budowanie-strony-z-ai-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/budowanie-strony-z-ai-claude-code/</guid>
                        <pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 5 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. Część 4 opisuje środowisko deweloperskie i deployment. Budowanie holas.pl wymagało napisania sporej ilości PHP, Twig i SCSS — i podejmowania decyzji architektonicznych, których cofnięcie byłoby uciążliwe. Przez cały czas używałem Claude Code jako AI pair programmera. Ten wpis opisuje jak…]]></description>
            <content:encoded><![CDATA[<p><em>To część 5 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. <a href="/pl/wpisy/dev-experience-dwa-kontenery/">Część 4</a> opisuje środowisko deweloperskie i deployment.</em></p>
<hr />
<p>Budowanie holas.pl wymagało napisania sporej ilości PHP, Twig i SCSS — i podejmowania decyzji architektonicznych, których cofnięcie byłoby uciążliwe. Przez cały czas używałem <a rel="nofollow noopener noreferrer" target="_blank" href="https://claude.ai/code">Claude Code</a> jako AI pair programmera. Ten wpis opisuje jak ten workflow faktycznie wygląda, gdzie działa dobrze, a gdzie nadal wymaga ludzkiego osądu.</p>
<h2>AGENTS.md — instrukcja obsługi dla AI<a id="agentsmd--instrukcja-obsługi-dla-ai" href="#agentsmd--instrukcja-obsługi-dla-ai" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pierwsze praktyczne spostrzeżenie z tego projektu: asystent AI jest tak dobry jak instrukcje, które dostaje. Bez jawnych wytycznych Claude domyślnie generuje funkcjonalny, ale generyczny kod — rozsądne wybory, ale niekoniecznie takie, jakie sam byś podjął.</p>
<p>Rozwiązaniem jest <code>AGENTS.md</code>, plik w korzeniu projektu, który Claude Code czyta na początku każdej sesji. Dokumentuje:</p>
<ul>
<li><strong>Zasady architektury</strong> — tylko klasy final, bez dziedziczenia, segregacja interfejsów, value objects zamiast tablic asocjacyjnych</li>
<li><strong>Styl kodu</strong> — strict types w każdym pliku, warunki Yoda, pusta linia przed return, konwencja przestrzeni nazw PSR-4</li>
<li><strong>Konwencje nazewnictwa</strong> — nazewnictwo interfejsów (<code>ContentServiceInterface</code> → <code>ContentService</code>), readonly value objects, constructor property promotion</li>
<li><strong>Polecenia DDEV</strong> — jak uruchomić środowisko, uruchomić buildy, sprawdzić jakość kodu</li>
<li><strong>Struktura treści</strong> — gdzie mieszkają pliki Markdown, jak działa frontmatter, jakie są konwencje slug URL</li>
</ul>
<p>Z tym kontekstem Claude generuje kod zgodny z rzeczywistymi konwencjami projektu. Przeglądanie wygenerowanej klasy wygląda jak przeglądanie pull requesta od kolegi, który przeczytał przewodnik stylu — nie jak przeglądanie outputu wymagającego tłumaczenia na konwencje projektu.</p>
<h2>EDITOR_GUIDE.md — delegowanie tworzenia treści<a id="editorguidemd--delegowanie-tworzenia-treści" href="#editorguidemd--delegowanie-tworzenia-treści" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Ta sama zasada dotyczy treści. <code>EDITOR_GUIDE.md</code> dokumentuje:</p>
<ul>
<li>Format i długość tytułu (poniżej 70 znaków, nazwy technologii uwzględnione, bez clickbaitu)</li>
<li>Format opisu (120–160 znaków, bez &quot;W tym wpisie...&quot;, zacznij od korzyści dla czytelnika)</li>
<li>Struktura wstępu (2–3 zdania, bez powitania, najpierw problem)</li>
<li>Konwencje treści (bloki kodu dla wszystkich poleceń, krótkie akapity, ponumerowane kroki dla procedur)</li>
<li>Wymagania parytetu EN/PL (obie wersje równej głębokości, te same bloki kodu)</li>
<li>Pola frontmatter i ich formaty</li>
</ul>
<p>Dzięki temu przewodnikowi workflow tworzenia treści staje się:</p>
<ol>
<li>Napisz wpis w surowej formie — pomysły, fragmenty kodu, struktura</li>
<li>Przekaż go Claude z: &quot;sprawdź korektę, popraw angielski, przetłumacz na polski i wygeneruj dwa pliki <code>.md</code> z poprawnym frontmatter&quot;</li>
<li>Przejrzyj wynik</li>
</ol>
<p>Przewodnik jest na tyle konkretny, że Claude nie musi zadawać pytań wyjaśniających. Format tytułu, konwencja slug, wartości kategorii, format tagów, wzorzec ścieżki do obrazu — wszystko jest udokumentowane. Wynik jest gotowy do zacommitowania.</p>
<h2>Generowanie obrazów z Draw Things i MCP<a id="generowanie-obrazów-z-draw-things-i-mcp" href="#generowanie-obrazów-z-draw-things-i-mcp" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Każdy wpis potrzebuje obrazu wyróżniającego (minimum 1200×630px). Dla holas.pl obrazy są generowane lokalnie za pomocą <a rel="nofollow noopener noreferrer" target="_blank" href="https://drawthings.ai/">Draw Things</a> przez integrację MCP (Model Context Protocol) w Claude Code.</p>
<p>Workflow:</p>
<ol>
<li>Claude Code wywołuje <code>mcp__draw-things__generate_image</code> z promptem opisującym obraz, 4 równoległe warianty 1024×576px z <code>steps=4</code></li>
<li>Najlepszy wariant jest wybierany z <code>.generated/YYYY-MM-DD-nazwa-sesji/</code></li>
<li>ImageMagick skaluje go do rozmiaru produkcyjnego:</li>
</ol>
<pre><code class="language-bash">ddev exec convert .generated/sesja/obraz.jpg \
    -resize 1920x1080! -filter Lanczos -quality 92 \
    assets/images/wynik.jpg
</code></pre>
<ol start="4">
<li>Obraz jest przenoszony do <code>content/blog/kategoria/nazwa-wpisu/files/</code> i referencjonowany w frontmatter</li>
</ol>
<p>Katalog <code>.generated/</code> jest w gitignore — zawiera jednorazowe podglądy. Tylko zatwierdzone obrazy zacommitowane do <code>files/</code> stają się produkcyjnymi assetami.</p>
<p>MCP (Model Context Protocol) to właśnie to, co sprawia że to działa: standardowy interfejs łączący asystentów AI z zewnętrznymi narzędziami. Claude Code łączy się z Draw Things działającym lokalnie, przestrzeniami Hugging Face i innymi serwisami bez opuszczania sesji deweloperskiej. Generowanie obrazów dzieje się na lokalnej maszynie — bez limitu API, bez zewnętrznego serwisu, bez kosztu per obraz.</p>
<h2>Co AI robi dobrze<a id="co-ai-robi-dobrze" href="#co-ai-robi-dobrze" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Boilerplate i wzorce</strong> — generowanie nowego serwisu z interfejsem, value objectem i poprawną kolejnością importów jest natychmiastowe. Struktura jest spójna z resztą bazy kodu, bo konwencje są udokumentowane.</p>
<p><strong>SCSS z opisu</strong> — opisanie układu komponentu słowami i otrzymanie działającego SCSS z nazwami zmiennych projektu jest szybsze niż pisanie od podstaw.</p>
<p><strong>Tłumaczenie</strong> — polska i angielska treść równej jakości. Konkretność przewodnika redakcyjnego w kwestii tego, co &quot;równa jakość&quot; oznacza (te same bloki kodu, ta sama głębokość, nie streszczenie) daje tłumaczenia niewymagające znaczącej edycji.</p>
<p><strong>Powtarzalna praca strukturalna</strong> — generowanie list przekierowań nginx, aktualizowanie frontmatter w wielu plikach, pisanie wpisów mapy strony — zadania z jasnymi zasadami, ale wieloma instancjami.</p>
<p><strong>Pozostawanie w kontekście</strong> — Claude Code czyta pliki projektu, rozumie istniejące wzorce i generuje kod, który pasuje bez mówienia mu, co robi każda klasa.</p>
<h2>Gdzie nadal potrzebny jest ludzki osąd<a id="gdzie-nadal-potrzebny-jest-ludzki-osąd" href="#gdzie-nadal-potrzebny-jest-ludzki-osąd" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Decyzje architektoniczne</strong> — które abstrakcje wprowadzić, kiedy value object jest uzasadniony, jak ustrukturyzować pipeline treści — wymagają rozumienia kompromisów w sposób wykraczający poza dopasowywanie wzorców. AI generuje wiarygodne opcje; decyzja nadal należy do człowieka.</p>
<p><strong>Estetyka designu</strong> — zmienne SCSS mogą być generowane, ale decyzja czy paleta kolorów dobrze wygląda na ciemnym tle w stylu terminala wymaga oczu i gustu.</p>
<p><strong>Głos treści</strong> — przewodnik redakcyjny ujmuje konwencje tonu, ale faktyczne pomysły — co warto pisać, który kąt jest interesujący — pochodzą z doświadczenia, nie z promptu.</p>
<p><strong>Przeglądanie outputu AI</strong> — kod i treść nadal muszą być czytane. Kod generowany przez AI wygląda wiarygodnie; wymaga developera, żeby zauważyć, gdy coś jest technicznie poprawne, ale architektonicznie złe.</p>
<h2>Praktyczny wynik<a id="praktyczny-wynik" href="#praktyczny-wynik" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cała strona — architektura, frontend, pipeline treści, formularz kontaktowy, skrypty deploymentu, ta seria wpisów blogowych — została zbudowana z Claude Code. Inwestycja czasu w <code>AGENTS.md</code> i <code>EDITOR_GUIDE.md</code> zwróciła się natychmiast: mniej czasu na korygowanie stylu, mniej czasu na tłumaczenie tych samych konwencji sesja po sesji, więcej czasu na faktyczną pracę.</p>
<p>Najbardziej zaskakującą korzyścią była treść. Napisanie wpisu w surowej formie, korekta, tłumaczenie na angielski i konwersja do dwóch poprawnie ustrukturyzowanych plików <code>.md</code> z prawidłowym frontmatter w jednym kroku — to usuwa wystarczająco dużo tarcia, że publikowanie faktycznie się dzieje.</p>
<p>Architektura zbudowana w ten sposób nie tylko produkuje łatwy w utrzymaniu kod — daje mierzalne wyniki. <a href="/pl/wpisy/lighthouse-wynik-100/">Następny wpis</a> omawia wyniki Lighthouse: co napędza każdy z czterech wskaźników i dlaczego większość z tego wynika z architektury, nie z pracy optymalizacyjnej.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-02-building-with-ai-claude-code/building-with-ai-claude-code.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                    </item>
                <item>
            <title><![CDATA[Środowisko deweloperskie — od lokalnego devu do produkcji w dwóch kontenerach]]></title>
            <link>https://holas.pl/pl/wpisy/dev-experience-dwa-kontenery/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/dev-experience-dwa-kontenery/</guid>
                        <pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 4 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. Część 3 opisuje bezpieczeństwo formularza kontaktowego. Jednym z celów dla holas.pl było środowisko deweloperskie tak proste jak produkcyjne. Brak bazy danych do uruchomienia, brak ręcznej konfiguracji sieci Docker, brak pięciominutowej sekwencji startowej. Efektem jest konfiguracja opar…]]></description>
            <content:encoded><![CDATA[<p><em>To część 4 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. <a href="/pl/wpisy/bezpieczenstwo-formularza-kontaktowego/">Część 3</a> opisuje bezpieczeństwo formularza kontaktowego.</em></p>
<hr />
<p>Jednym z celów dla holas.pl było środowisko deweloperskie tak proste jak produkcyjne. Brak bazy danych do uruchomienia, brak ręcznej konfiguracji sieci Docker, brak pięciominutowej sekwencji startowej. Efektem jest konfiguracja oparta na DDEV uruchamiająca się jednym poleceniem i stos produkcyjny działający w dwóch kontenerach.</p>
<h2>Development z DDEV<a id="development-z-ddev" href="#development-z-ddev" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://ddev.readthedocs.io/">DDEV</a> zarządza lokalnym środowiskiem deweloperskim. Cała konfiguracja jest w <code>.ddev/config.yaml</code>:</p>
<pre><code class="language-yaml">name: holas-pl
type: php
php_version: &quot;8.5&quot;
webserver_type: nginx-fpm
nodejs_version: &quot;22&quot;
omit_containers: [db]
web_environment:
  - APP_ENV=dev
</code></pre>
<p>Linia <code>omit_containers: [db]</code> jest znacząca — nie ma bazy danych, więc nie ma kontenera bazy danych. Domyślna konfiguracja MySQL/MariaDB DDEV jest całkowicie pominięta. <code>ddev start</code> uruchamia nginx + PHP-FPM i nic więcej.</p>
<p>W developmencie drzewo treści jest przebudowywane przy każdym żądaniu, więc edycja pliku Markdown i odświeżenie przeglądarki od razu pokazuje zmianę. Brak cache do czyszczenia. Pasek narzędzi debugowania Symfony jest dostępny. Wpisy draft są widoczne.</p>
<h2>Polecenie build<a id="polecenie-build" href="#polecenie-build" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev build</code> uruchamia pełny pipeline:</p>
<pre><code class="language-bash">rm -rf var/dart-sass              # usuwa binarny plik specyficzny dla architektury (patrz niżej)
php bin/console cache:clear
php bin/console sass:build        # kompiluje SCSS przez dart-sass
php bin/console asset-map:compile # fingerprinting assetów
php bin/console app:build         # renderuje wszystkie URL do public/static/
npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Po tym <code>public/static/</code> zawiera kompletną stronę jako pliki HTML. nginx serwuje z tego katalogu. Krok dart-sass usuwa binarny plik specyficzny dla platformy przed buildem, aby wymusić świeże pobranie — więcej o tym poniżej.</p>
<h2>Kontrola jakości kodu<a id="kontrola-jakości-kodu" href="#kontrola-jakości-kodu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev code-check</code> uruchamia cztery sprawdzenia po kolei:</p>
<pre><code class="language-bash">composer validate --strict
composer audit --no-dev          # sprawdza znane CVE w zależnościach
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse       # poziom 6
</code></pre>
<p><code>ddev code-fix</code> automatycznie naprawia problemy PHP CS Fixer i ponownie uruchamia sprawdzenie. PHPStan poziom 6 wyłapuje brakujące type hinty, złe typy argumentów i nieznane metody przed dotarciem do produkcji.</p>
<h2>Styleguide<a id="styleguide" href="#styleguide" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Strona dostępna tylko w deweloperskim środowisku pod adresem <code>/styleguide/</code> dokumentuje każdy komponent UI używając rzeczywistych klas CSS — nie wrapperów ani migawek. Strona jest serwowana tylko w środowisku <code>dev</code> (kontroler rzuca 404 w produkcji) i nie jest uwzględniana w statycznym buildzie.</p>
<p>Wartość styleguide'a jest w developmencie: zmiana SCSS komponentu natychmiast aktualizuje styleguide. Nie ma osobnego systemu designu do synchronizowania.</p>
<h2>Pipeline assetów bez Node.js<a id="pipeline-assetów-bez-nodejs" href="#pipeline-assetów-bez-nodejs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>SCSS jest kompilowany przez <code>symfonycasts/sass-bundle</code>, który opakowuje dart-sass i nie wymaga instalacji Node.js. Jeden punkt wejścia <code>assets/styles/app.scss</code> importuje wszystkie pliki częściowe. W developmencie kompiluje w locie. W produkcji <code>php bin/console sass:build</code> uruchamia się przed buildem.</p>
<p>Moduły JavaScript są obsługiwane przez AssetMapper Symfony — bez webpacka, bez Vite, bez rollupa. Skrypty są ładowane jako zwykłe tagi <code>&lt;script src=&quot;...&quot;&gt;</code> z nazwami plików zawierającymi hash treści. Import map rejestruje tylko główny punkt wejścia <code>app.js</code>; inne skrypty (formularz kontaktowy, wyszukiwanie, baner cookies) są ładowane osobno przez <code>{{ asset('script.js') }}</code> w szablonach.</p>
<p><strong>Problem z binarnym plikiem dart-sass:</strong> <code>symfonycasts/sass-bundle</code> pobiera binarny plik dart-sass specyficzny dla platformy do <code>var/dart-sass/</code>. Plik binarny skompilowany na maszynie deweloperskiej (x86_64) nie uruchomi się w kontenerze produkcyjnym Docker (również x86_64 w tym przypadku, ale ścieżka i wersja binarna mogą się różnić). Rozwiązanie jest proste: usuń plik binarny przed każdym buildem i pozwól dart-sass pobrać właściwy dla bieżącej platformy.</p>
<h2>Produkcja: dwa kontenery<a id="produkcja-dwa-kontenery" href="#produkcja-dwa-kontenery" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Produkcyjny <code>docker-compose.yaml</code>:</p>
<pre><code class="language-yaml">services:
  nginx:
    image: nginx:alpine
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - .:/app:ro
    depends_on:
      - php

  php:
    build:
      context: .
      dockerfile: docker/Dockerfile
    user: &quot;${UID:-1000}:${GID:-1000}&quot;
    volumes:
      - .:/app
</code></pre>
<p>Dwa kontenery: nginx i PHP-FPM. Brak kontenera bazy danych. nginx montuje projekt jako tylko do odczytu; PHP-FPM działa jako użytkownik hosta, aby uniknąć problemów z uprawnieniami plików cache Symfony.</p>
<p>Obraz PHP (<code>docker/Dockerfile</code>) to <code>php:8.5-fpm-alpine</code> z tylko tym, co potrzebne: <code>icu-dev</code> (Symfony intl), <code>nodejs</code> i <code>npm</code> (dla kroku budowania Pagefind), <code>unzip</code>, <code>git</code> i Composer.</p>
<h2>Deployment<a id="deployment" href="#deployment" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cały deployment to jeden skrypt <code>./deploy.sh --prod</code>:</p>
<ol>
<li><code>docker compose down</code></li>
<li><code>docker compose build --pull</code> — przebudowuje obraz PHP od zera</li>
<li><code>docker compose up -d</code></li>
<li><code>composer install --no-dev --optimize-autoloader</code></li>
<li><code>php bin/console cache:clear</code></li>
<li><code>rm -rf var/dart-sass</code> — usuwa plik binarny specyficzny dla architektury</li>
<li><code>php bin/console sass:build</code></li>
<li><code>php bin/console asset-map:compile</code></li>
<li><code>php bin/console app:build</code> — renderuje cały statyczny HTML</li>
<li><code>npx --yes pagefind --site public/static --output-path public/pagefind</code></li>
</ol>
<p>Build uruchamia się wewnątrz kontenera PHP, gdzie znana jest poprawna architektura CPU. Po kroku 10 nginx już serwuje statyczne pliki poprzedniego buildu. Nowe pliki zastępują je na poziomie systemu plików. Istnieje krótkie okno, w którym częściowy build jest live, ale dla portfolio z niewielkim ruchem jest to akceptowalne bez złożoności blue-green deploymentu.</p>
<p>Brak pipeline'u CI/CD, brak środowiska stagingowego. Deployment to <code>ssh server</code>, <code>cd holas.pl</code>, <code>git pull</code>, <code>./deploy.sh --prod</code>.</p>
<h2>Porównanie z WordPressem<a id="porównanie-z-wordpressem" href="#porównanie-z-wordpressem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pełny produkcyjny stos WordPress wymagał: PHP-FPM, MySQL, warstwy cache (Redis lub system plików), uruchamiania zaplanowanych zadań dla wp-cron i wystarczająco dużo RAM, żeby utrzymać MySQL w pamięci podręcznej. Aktualizacje dowolnego komponentu wymagały przestoju lub starannego sekwencjonowania.</p>
<p>Obecny stos to dwa kontenery. Raspberry Pi 5 obsługuje je bez presji na pamięć. Deployment to skrypt shell. Cała baza kodu, treść i konfiguracja mieszczą się w jednym repozytorium git. Backup to <code>git push</code>.</p>
<p><a href="/pl/wpisy/budowanie-strony-z-ai-claude-code/">Kolejny wpis z serii</a> opisuje najbardziej niekonwencjonalną część tego projektu: budowanie całej strony z Claude Code jako AI pair programmerem i używanie narzędzi MCP do lokalnego generowania obrazów.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-27-dev-experience-two-containers/dev-experience-two-containers.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[ddev]]></category>
                        <category><![CDATA[raspberry-pi]]></category>
                        <category><![CDATA[homelab]]></category>
                    </item>
                <item>
            <title><![CDATA[Drzewo decyzyjne narzędzi dla Claude Code z globalną pamięcią]]></title>
            <link>https://holas.pl/pl/wpisy/drzewo-decyzyjne-narzedzi-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/drzewo-decyzyjne-narzedzi-claude-code/</guid>
                        <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Claude Code może łączyć się z zewnętrznymi narzędziami przez serwery MCP (Model Context Protocol) — Sentry do błędów produkcyjnych, JetBrains do introspekcji IDE, Context7 do dokumentacji bibliotek, Perplexity do wyszukiwania w sieci. Problem: mając sześć serwerów MCP do dyspozycji, Claude nie zawsze wybiera właściwy. Potrafi przeszukiwać grepem 20 plików w poszukiwaniu route'a Symfony, kiedy JetB…]]></description>
            <content:encoded><![CDATA[<p>Claude Code może łączyć się z zewnętrznymi narzędziami przez serwery MCP (Model Context Protocol) — Sentry do błędów produkcyjnych, JetBrains do introspekcji IDE, Context7 do dokumentacji bibliotek, Perplexity do wyszukiwania w sieci. Problem: mając sześć serwerów MCP do dyspozycji, Claude nie zawsze wybiera właściwy. Potrafi przeszukiwać grepem 20 plików w poszukiwaniu route'a Symfony, kiedy JetBrains zwróciłby go jednym wywołaniem, albo odpytywać Context7 o &quot;najnowszą wersję PHP&quot;, choć aktualne dane ma tylko Perplexity.</p>
<p>Rozwiązaniem jest globalny <code>CLAUDE.md</code> — trwały plik instrukcji, który uczy Claude'a, po które narzędzie sięgnąć w zależności od typu zapytania. Ten wpis opisuje mój setup, drzewo decyzyjne, które zbudowałem, i jak możesz stworzyć własne dla swojego stosu technologicznego.</p>
<h2>Problem: za dużo narzędzi, brak strategii<a id="problem-za-dużo-narzędzi-brak-strategii" href="#problem-za-dużo-narzędzi-brak-strategii" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Serwery MCP dają Claude Code supermoce: automatyzację przeglądarki, śledzenie błędów, wyszukiwanie dokumentacji, integrację z IDE. Ale więcej narzędzi oznacza więcej wyborów, a bez wskazówek Claude podejmuje rozsądne, ale nieoptymalne decyzje.</p>
<p>Typowe problemy, które zaobserwowałem:</p>
<ul>
<li><strong>Złe narzędzie do zadania</strong> — odpytywanie Context7 o &quot;najnowszą wersję Symfony&quot; (ma tylko dokumentację, nie metadane o wydaniach) zamiast Perplexity</li>
<li><strong>Droga ścieżka zamiast taniej</strong> — używanie Grepa do szukania route'ów Symfony w wielu plikach zamiast zapytania JetBrains MCP o strukturalną listę</li>
<li><strong>Pominięcie specjalisty</strong> — brak sprawdzenia Sentry przy błędzie produkcyjnym, kiedy stack trace natychmiast ujawniłby przyczynę</li>
<li><strong>Zbędne zapytania</strong> — próbowanie wielu narzędzi po kolei, kiedy pamięć mogłaby od razu skierować do właściwego</li>
</ul>
<p>Rozwiązaniem są jawne reguły routingu zapisane w globalnym <code>CLAUDE.md</code>. Claude czyta ten plik na początku każdej sesji, więc drzewo decyzyjne jest zawsze dostępne.</p>
<h2>Mój stos MCP<a id="mój-stos-mcp" href="#mój-stos-mcp" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Oto sześć serwerów MCP, których używam, i w czym każdy jest najlepszy.</p>
<h3>Context7 — dokumentacja bibliotek i frameworków<a id="context7--dokumentacja-bibliotek-i-frameworków" href="#context7--dokumentacja-bibliotek-i-frameworków" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Context7 serwuje wersjonowaną dokumentację z przykładami kodu. Podajesz nazwę biblioteki i pytanie, a on zwraca odpowiednią sekcję z oficjalnej dokumentacji.</p>
<p><strong>Workflow:</strong> <code>resolve-library-id</code> (znajdź bibliotekę) → <code>query-docs</code> (pobierz dokumentację do konkretnego pytania). Obsługuje wersjonowane zapytania — mogę odpytać <code>/sylius/sylius/v1.14.6</code> bezpośrednio, nie tylko &quot;latest&quot;.</p>
<p><strong>Najlepszy do:</strong></p>
<ul>
<li>Sygnatur metod i przykładów konfiguracji</li>
<li>Poradników migracji między wersjami frameworków</li>
<li>Wzorców z oficjalnej dokumentacji (formularze Symfony, mapowania Doctrine, filtry API Platform)</li>
</ul>
<p><strong>Nie radzi sobie z:</strong></p>
<ul>
<li>Aktualnymi numerami wersji i datami wydań (ma dokumentację, nie metadane)</li>
<li>Funkcjami języka PHP (PHP nie jest biblioteką z wersjonowaną dokumentacją w Context7)</li>
<li>Poradnikami bezpieczeństwa i CVE</li>
<li>Czymkolwiek wymagającym informacji w czasie rzeczywistym</li>
</ul>
<h3>Perplexity — aktualne fakty i research<a id="perplexity--aktualne-fakty-i-research" href="#perplexity--aktualne-fakty-i-research" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Perplexity to wyszukiwarka z AI w czterech trybach: <code>search</code> (wyniki z cytatami ze źródeł), <code>ask</code> (odpowiedzi AI przez sonar-pro), <code>research</code> (dogłębna analiza wieloźródłowa, 30+ sekund) i <code>reason</code> (logiczne rozumowanie krok po kroku).</p>
<p><strong>Najlepszy do:</strong></p>
<ul>
<li>Najnowszych numerów wersji, dat wydań, harmonogramów EOL</li>
<li>Poradników bezpieczeństwa i szczegółów CVE</li>
<li>Funkcji języka PHP i RFC (property hooks, asymmetric visibility — tego nie ma w Context7)</li>
<li>Cen usług zewnętrznych (koszty API, porównania hostingów)</li>
<li>Ogólnych dobrych praktyk programistycznych i benchmarków</li>
</ul>
<p><strong>Kluczowa rola:</strong> Perplexity wypełnia każdą lukę Context7. Kiedy Context7 nic nie zwraca albo zwraca nieaktualne dane, Perplexity prawie zawsze ma odpowiedź.</p>
<h3>JetBrains — inteligencja kodu na poziomie IDE<a id="jetbrains--inteligencja-kodu-na-poziomie-ide" href="#jetbrains--inteligencja-kodu-na-poziomie-ide" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>To serwer MCP, który oszczędza najwięcej tokenów. JetBrains MCP łączy Claude Code z indeksem Twojego IDE — tym samym, który napędza autouzupełnianie, go-to-definition i refaktoryzację. Bazowy JetBrains MCP dostarcza generyczne narzędzia (wyszukiwanie plików, lookup symboli, wyszukiwanie tekstowe, komendy terminala), a pluginy frameworkowe rozszerzają go o specjalistyczne narzędzia — plugin Symfony dodaje listowanie route'ów, wyszukiwanie serwisów, inspekcję encji Doctrine i analizę Twiga.</p>
<p><strong>Mój typowy workflow:</strong> Wklejam link do ticketu Jira do konwersacji. Claude czyta zadanie przez Atlassian MCP, a potem używa JetBrains MCP do szybkiego rekonesansu kodu — znajdując odpowiednie serwisy, sprawdzając definicje route'ów, inspekcjonując pola encji — zanim napisze choćby jedną linię kodu. Ten krok &quot;najpierw przeanalizuj&quot; wyłapuje nieporozumienia wcześnie i daje Claude'owi kontekst do zadawania lepszych pytań wyjaśniających.</p>
<p><strong>Możliwości specyficzne dla Symfony</strong> (przez plugin Symfony):</p>
<ul>
<li><code>list_symfony_routes_controllers</code> — wszystkie route'y z kontrolerem, ścieżką, metodami. Jedno wywołanie zamiast grepowania atrybutów w dziesiątkach plików</li>
<li><code>locate_symfony_service</code> — znajdź definicję dowolnego serwisu po pełnej nazwie klasy</li>
<li><code>list_doctrine_entity_fields</code> — pola encji, typy i relacje w ustrukturyzowanym formacie</li>
<li><code>list_symfony_commands</code>, <code>list_symfony_forms</code> — komendy konsolowe i typy formularzy w jednym widoku</li>
</ul>
<p><strong>Przykład oszczędności tokenów:</strong> Znalezienie wszystkich route'ów pasujących do <code>/api/</code> w projekcie Symfony:</p>
<ul>
<li><strong>Bez JetBrains:</strong> Grep po <code>#[Route</code> w <code>src/Controller/</code>, odczyt każdego pasującego pliku, parsowanie atrybutów route'ów, konfrontacja z <code>_routes.yaml</code>. Łatwo 5-10 wywołań narzędzi i tysiące tokenów treści plików.</li>
<li><strong>Z JetBrains:</strong> Jedno wywołanie <code>list_symfony_routes_controllers</code> zwraca strukturalną, filtrowalną listę. Gotowe.</li>
</ul>
<p><strong>Przydatny też do:</strong> Indeksowane wyszukiwanie tekstowe (<code>search_in_files_by_text</code>), wyszukiwanie plików po nazwie, lookup symboli, uruchamianie komend terminala w IDE, budowanie i testowanie.</p>
<h3>Chrome DevTools — automatyzacja przeglądarki<a id="chrome-devtools--automatyzacja-przeglądarki" href="#chrome-devtools--automatyzacja-przeglądarki" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Chrome DevTools MCP pozwala Claude'owi sterować przeglądarką: nawigować po URL-ach, klikać elementy, wypełniać formularze, robić zrzuty ekranu, inspekcjonować żądania sieciowe, uruchamiać JavaScript i wykonywać audyty Lighthouse.</p>
<p><strong>Najlepszy do:</strong></p>
<ul>
<li>Wizualnego testowania zmian UI po modyfikacji szablonów lub CSS</li>
<li>Audytów wydajności i dostępności Lighthouse</li>
<li>Debugowania problemów frontendowych (błędy w konsoli, żądania sieciowe)</li>
<li>Weryfikacji responsywności przy różnych szerokościach viewportu</li>
</ul>
<h3>Sentry — śledzenie błędów produkcyjnych<a id="sentry--śledzenie-błędów-produkcyjnych" href="#sentry--śledzenie-błędów-produkcyjnych" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Sentry MCP łączy Claude'a z systemem śledzenia błędów. Może wyszukiwać issues, pobierać stack trace'y, analizować błędy z AI Sentry (Seer) i sprawdzać informacje o wydaniach i wdrożeniach.</p>
<p><strong>Workflow, który sprawia, że to jest wartościowe:</strong></p>
<ol>
<li>Zauważasz błąd (lub zgłasza go użytkownik)</li>
<li>Claude odpytuje Sentry: &quot;wyszukaj błędy 500 z ostatnich 24 godzin&quot;</li>
<li>Sentry zwraca stack trace, liczbę dotkniętych użytkowników, timestampy first/last seen</li>
<li>Claude czyta odpowiedni plik źródłowy, identyfikuje przyczynę i proponuje fix</li>
<li>Cały cykl debugowania odbywa się bez opuszczania terminala</li>
</ol>
<p><strong>Najlepszy do:</strong></p>
<ul>
<li>Badania błędów produkcyjnych z pełnymi stack trace'ami</li>
<li>Rozumienia częstotliwości i wzorców błędów (czy jest nowy? czy się pogarsza?)</li>
<li>Korelowania błędów z ostatnimi wdrożeniami</li>
</ul>
<h3>Atlassian/Jira — zarządzanie zadaniami<a id="atlassianjira--zarządzanie-zadaniami" href="#atlassianjira--zarządzanie-zadaniami" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Jira MCP zapewnia pełne zarządzanie cyklem życia issues: tworzenie, odczyt, edycję, przejścia statusów, komentowanie i wyszukiwanie JQL.</p>
<p><strong>Najlepszy do:</strong></p>
<ul>
<li>Czytania specyfikacji zadań przed rozpoczęciem pracy</li>
<li>Aktualizowania statusu issues w trakcie pracy</li>
<li>Dodawania technicznych komentarzy do issues dla widoczności zespołu</li>
<li>Wyszukiwania JQL do znajdowania powiązanych issues lub sprawdzania bieżącego sprintu</li>
</ul>
<h2>Drzewo decyzyjne<a id="drzewo-decyzyjne" href="#drzewo-decyzyjne" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Sercem globalnego <code>CLAUDE.md</code> jest tablica routingu, która mapuje typy zapytań na najlepsze narzędzie. Oto faktyczna treść z mojego pliku:</p>
<pre><code class="language-markdown">## Search &amp; Research — Tool Decision Tree

### When to use Context7
**Best for**: library/framework API docs with clean code examples
- Versioned library docs (Sylius, Doctrine, API Platform, Symfony, GitHub Actions)
- Official method signatures, configuration examples, how-to patterns
- Concise, authoritative answers directly from official source
- `resolve-library-id` first, then `query-docs`
- **Fails for**: current version/release info, PHP language features,
  security CVEs, pricing, general programming

### When to use Perplexity
**Best for**: anything current, factual, or not a library doc
- Latest versions, release dates, EOL schedules
- Security advisories, CVEs, vulnerability details
- PHP language features (property hooks, new syntax)
- External service pricing
- General programming best practices, benchmarks
- Supplement when Context7 fails or for real-world context

### When to use WebSearch
- Official blog posts / release announcements
- As last resort or to supplement
</code></pre>
<p>Kluczowy wzorzec: każda sekcja zaczyna się od &quot;best for&quot; (kiedy wybrać to narzędzie) i kończy &quot;fails for&quot; (kiedy je pominąć). Claude korzysta z obu sygnałów — routingu pozytywnego i negatywnego.</p>
<h3>Tablica benchmarków<a id="tablica-benchmarków" href="#tablica-benchmarków" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Przepuściłem te same 10 typów zapytań przez Context7, Perplexity i WebSearch i oceniłem wyniki. Ta tablica znajduje się w globalnym <code>CLAUDE.md</code>, żeby Claude mógł się do niej odwoływać przy podejmowaniu decyzji:</p>
<table>
<thead>
<tr>
<th>Typ zapytania</th>
<th>Context7</th>
<th>Perplexity</th>
<th>WebSearch</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wersjonowana dokumentacja bibliotek</td>
<td>★★★★★</td>
<td>★★★★</td>
<td>★★★</td>
</tr>
<tr>
<td>Aktualne wersje / daty wydań</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Przykłady kodu z oficjalnej dokumentacji</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Funkcje języka PHP</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Framework how-to (API Platform itp.)</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Poradniki bezpieczeństwa / CVE</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Ogólne programowanie (benchmarki itp.)</td>
<td>✗</td>
<td>★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>CI/DevOps workflows</td>
<td>★★★★</td>
<td>★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Release notes / nowe funkcje</td>
<td>★★★★</td>
<td>★★★</td>
<td>★★★★★</td>
</tr>
<tr>
<td>Ceny usług zewnętrznych</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★★</td>
</tr>
</tbody>
</table>
<p>Wzorzec jest czytelny: Context7 jest doskonały do wersjonowanej dokumentacji, ale dostaje zero w czymkolwiek wymagającym aktualnych lub rzeczywistych danych. Perplexity pokrywa niemal wszystko. WebSearch jest najsilniejszy w blogpostach i ogłoszeniach o wydaniach.</p>
<p>Umieszczenie tej tabeli w globalnym <code>CLAUDE.md</code> daje Claude'owi ilościową podstawę do wyboru narzędzia, nie tylko reguły.</p>
<h2>Jak działa globalna pamięć<a id="jak-działa-globalna-pamięć" href="#jak-działa-globalna-pamięć" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Claude Code obsługuje globalny plik instrukcji w <code>~/.claude/CLAUDE.md</code>. Ten plik jest ładowany na początku każdej konwersacji, niezależnie od projektu. To właściwe miejsce na reguły routingu narzędzi, bo serwery MCP są konfigurowane globalnie, nie per projekt.</p>
<p>Porównanie z projektowym <code>AGENTS.md</code>:</p>
<table>
<thead>
<tr>
<th></th>
<th><code>AGENTS.md</code></th>
<th><code>~/.claude/CLAUDE.md</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Zakres</td>
<td>Jeden projekt</td>
<td>Wszystkie projekty</td>
</tr>
<tr>
<td>Treść</td>
<td>Konwencje kodu, architektura, komendy</td>
<td>Routing narzędzi, preferencje osobiste</td>
</tr>
<tr>
<td>Przykład</td>
<td>&quot;Używaj <code>ddev exec</code> do komend PHP&quot;</td>
<td>&quot;Używaj Context7 do dokumentacji Symfony&quot;</td>
</tr>
</tbody>
</table>
<h3>Struktura globalnego CLAUDE.md<a id="struktura-globalnego-claudemd" href="#struktura-globalnego-claudemd" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Globalny <code>CLAUDE.md</code> działa najlepiej, gdy jest ustrukturyzowany jak podręcznik referencyjny, nie narracja. Claude skanuje go na początku sesji — czytelne nagłówki i jawne reguły sprawiają, że ten skan jest skuteczny.</p>
<p><strong>Wskazówki z mojego doświadczenia:</strong></p>
<ol>
<li><strong>Zacznij od reguły decyzyjnej, nie od opisu.</strong> &quot;Best for: versioned library docs&quot; jest bardziej użyteczne niż &quot;Context7 is a documentation server that...&quot;</li>
<li><strong>Uwzględnij tryby awarii.</strong> &quot;Fails for: current versions&quot; zapobiega próbom użycia Context7 do zapytań, z którymi sobie nie radzi.</li>
<li><strong>Dodaj inwentarz serwerów.</strong> Wymień każdy serwer MCP z jego narzędziami — Claude może się do tego odwołać, gdy potrzebuje funkcji, której jeszcze nie używał.</li>
<li><strong>Używaj konkretnych przykładów.</strong> &quot;Latest Symfony version → Perplexity&quot; jest lepsze niż &quot;use Perplexity for current data.&quot;</li>
<li><strong>Aktualizuj przy zmianach narzędzi.</strong> Dodałeś nowy serwer MCP? Zaktualizuj plik. Usunąłeś jeden? Usuń jego wpis. Nieaktualne reguły routingu są gorsze niż brak reguł.</li>
</ol>
<h2>Zbuduj własne drzewo<a id="zbuduj-własne-drzewo" href="#zbuduj-własne-drzewo" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Konkretne serwery MCP nie mają znaczenia — liczy się wzorzec. Niezależnie od tego, czy używasz Cursora, Windsurf czy Claude Code, czy piszesz w Pythonie czy Go, zasada jest ta sama: naucz swojego asystenta AI, po które narzędzie sięgnąć.</p>
<p><strong>Krok po kroku:</strong></p>
<ol>
<li><strong>Wymień swoje serwery MCP</strong> (lub równoważne integracje narzędziowe) i do czego każdy służy</li>
<li><strong>Zidentyfikuj nakładanie się</strong> — gdzie dwa narzędzia mogą odpowiedzieć na to samo zapytanie? (Context7 i Perplexity obsługują dokumentację Symfony, ale z różnymi mocnymi stronami)</li>
<li><strong>Zbenchmarkuj</strong> — przepuść te same 5-10 reprezentatywnych zapytań przez każde nakładające się narzędzie. Oceń wyniki. To daje dane, nie przeczucie.</li>
<li><strong>Napisz reguły routingu</strong> — dla każdego narzędzia napisz sekcje &quot;best for&quot; i &quot;fails for&quot; z konkretnymi typami zapytań</li>
<li><strong>Dołącz benchmark</strong> — tablica daje Twojemu AI ilościowy punkt odniesienia, nie tylko instrukcje</li>
<li><strong>Iteruj</strong> — pierwsza wersja nie będzie idealna. Kiedy Claude wybierze złe narzędzie, zaktualizuj plik. Po kilku sesjach routing się doszlifuje.</li>
</ol>
<p>Mój globalny <code>CLAUDE.md</code> zaczął się jako lista serwerów MCP z jednolinijkowymi opisami. Po kilku tygodniach obserwacji, gdzie Claude podejmował nieoptymalne wybory, ewoluował w drzewo decyzyjne opisane powyżej. Tablica benchmarków była największą pojedynczą poprawą — zamieniła niejasne reguły &quot;preferuj X nad Y&quot; w konkretne dane, na podstawie których Claude mógł działać.</p>
<p>Inwestycja jest niewielka (godzina na konfigurację, kilka minut na aktualizację) a zwrot się kumuluje: mniej zmarnowanych tokenów, szybsze odpowiedzi i mniej czasu na korygowanie wyborów narzędzi w trakcie konwersacji.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-20-claude-code-mcp-memory/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[dev-tools]]></category>
                    </item>
                <item>
            <title><![CDATA[Zabezpieczenie jedynego dynamicznego endpointu strony statycznej — formularz kontaktowy]]></title>
            <link>https://holas.pl/pl/wpisy/bezpieczenstwo-formularza-kontaktowego/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/bezpieczenstwo-formularza-kontaktowego/</guid>
                        <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To część 3 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. Część 2 opisuje architekturę. holas.pl jest stroną statyczną z jednym wyjątkiem: formularzem kontaktowym. Każdy wpis blogowy, strona kategorii i strona statyczna to wstępnie wyrenderowany plik HTML serwowany przez nginx. Formularz kontaktowy to jedyny endpoint uruchamiający PHP. To jedno…]]></description>
            <content:encoded><![CDATA[<p><em>To część 3 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. <a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">Część 2</a> opisuje architekturę.</em></p>
<hr />
<p>holas.pl jest stroną statyczną z jednym wyjątkiem: formularzem kontaktowym. Każdy wpis blogowy, strona kategorii i strona statyczna to wstępnie wyrenderowany plik HTML serwowany przez nginx. Formularz kontaktowy to jedyny endpoint uruchamiający PHP.</p>
<p>To jedno wyjście rodzi konkretne pytanie bezpieczeństwa: jak chronić formularz na stronie statycznej przed botami i atakami CSRF, gdy nie ma sesji?</p>
<h2>Problem CSRF na stronach statycznych<a id="problem-csrf-na-stronach-statycznych" href="#problem-csrf-na-stronach-statycznych" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Standardowa ochrona CSRF działa przez osadzenie tokena w formularzu powiązanego z sesją użytkownika. Przy przesyłaniu formularza serwer weryfikuje, czy token pasuje do tego z sesji.</p>
<p>Strony statyczne nie mają sesji. Strona kontaktowa jest generowana raz podczas <code>ddev build</code> i serwowana jako plik. Nie może generować tokena per użytkownik w czasie renderowania. Wbudowana <code>csrf_protection</code> Symfony jest więc jawnie wyłączona w formularzu kontaktowym:</p>
<pre><code class="language-php">public function configureOptions(OptionsResolver $resolver): void
{
    $resolver-&gt;setDefaults([
        'csrf_protection' =&gt; false,  // celowe — strona statyczna, brak sesji
    ]);
}
</code></pre>
<p>Wyłączenie ochrony CSRF to właściwa decyzja. Alternatywa — dodanie dynamicznego endpointu PHP tylko do generowania tokenów dla formularza — przywróciłaby problem PHP-przy-każdym-żądaniu dla strony, która poza tym go nie potrzebuje.</p>
<h2>Cloudflare Turnstile jako zamiennik<a id="cloudflare-turnstile-jako-zamiennik" href="#cloudflare-turnstile-jako-zamiennik" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://developers.cloudflare.com/turnstile/">Cloudflare Turnstile</a> to alternatywa dla CAPTCHA działająca po stronie klienta, potwierdzająca człowieczeństwo użytkownika i dostarczająca jednorazowy token weryfikowalny po stronie serwera. Zastępuje CSRF jako warstwa zapobiegania botom.</p>
<p>Przepływ formularza:</p>
<ol>
<li>Strona kontaktowa jest serwowana jako statyczny HTML z osadzonym widżetem Turnstile (klucz strony jest wbudowany podczas buildu)</li>
<li>Turnstile uruchamia swoje wyzwanie niewidocznie; po sukcesie wywołuje <code>window.onTurnstileSuccess(token)</code>, który zapisuje token do ukrytego pola</li>
<li><code>contact.js</code> przechwytuje zdarzenie <code>submit</code> formularza i wysyła dane przez <code>fetch()</code> zamiast standardowego wysłania formularza</li>
<li>Endpoint PHP otrzymuje przesłanie, weryfikuje pola formularza, następnie weryfikuje token Turnstile przez API Cloudflare</li>
<li>Tylko jeśli oba sprawdzenia przejdą, e-mail jest wysyłany</li>
</ol>
<p>Weryfikacja po stronie serwera to kluczowy krok:</p>
<pre><code class="language-php">final class TurnstileValidator
{
    public function verify(string $token, string $remoteIp): bool
    {
        try {
            $response = $this-&gt;httpClient-&gt;request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
                'body' =&gt; [
                    'secret'   =&gt; $this-&gt;secretKey,
                    'response' =&gt; $token,
                    'remoteip' =&gt; $remoteIp,
                ],
            ]);

            $data = $response-&gt;toArray();

            return true === ($data['success'] ?? false);
        } catch (\Throwable $e) {
            $this-&gt;logger-&gt;error('Weryfikacja Turnstile nie powiodła się: '.$e-&gt;getMessage());

            return false;
        }
    }
}
</code></pre>
<p>Walidator zawodzi bezpiecznie — każdy wyjątek zwraca <code>false</code> i blokuje przesłanie. Pusty lub brakujący token również zwraca <code>false</code> natychmiast.</p>
<h2>Minimalna powierzchnia ataku PHP<a id="minimalna-powierzchnia-ataku-php" href="#minimalna-powierzchnia-ataku-php" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cała powierzchnia PHP aplikacji w produkcji to dwa wzorce URL:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
    fastcgi_read_timeout 30;
}
</code></pre>
<p>Wszystko inne — każdy wpis blogowy, każda strona kategorii, mapa strony, kanał RSS, strona wyszukiwania — jest obsługiwane przez nginx serwujący pliki statyczne. PHP-FPM nie jest nigdy wywoływane przy dostarczaniu treści. Powierzchnia ataku warstwy PHP to jeden endpoint POST.</p>
<h2>Content Security Policy<a id="content-security-policy" href="#content-security-policy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Konfiguracja nginx stosuje rygorystyczny CSP przy każdej odpowiedzi:</p>
<pre><code class="language-nginx">add_header Content-Security-Policy
    &quot;default-src 'self';
     script-src  'self' challenges.cloudflare.com;
     style-src   'self' 'unsafe-inline';
     img-src     'self' data:;
     frame-src   challenges.cloudflare.com;
     connect-src 'self' challenges.cloudflare.com;&quot;
    always;
</code></pre>
<p>Jedyną dozwoloną zewnętrzną domeną jest <code>challenges.cloudflare.com</code>, której Turnstile wymaga dla swojego skryptu, iframe i wywołania API. Brak Google Analytics, skryptów CDN, piksela Facebooka. <code>script-src</code> nie ma <code>'unsafe-inline'</code> ani <code>'unsafe-eval'</code> — cały JavaScript jest ładowany z plików z hashami przez Symfony AssetMapper.</p>
<p><code>'unsafe-inline'</code> w <code>style-src</code> to celowy kompromis: widżet Turnstile wstrzykuje style inline, których nie można uniknąć bez nonce CSP, a AssetMapper aktualnie nie wstrzykuje nonces. Wszystko inne jest zablokowane.</p>
<h2>Dodatkowe nagłówki bezpieczeństwa<a id="dodatkowe-nagłówki-bezpieczeństwa" href="#dodatkowe-nagłówki-bezpieczeństwa" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-nginx">add_header X-Frame-Options        &quot;SAMEORIGIN&quot;                       always;
add_header X-Content-Type-Options &quot;nosniff&quot;                          always;
add_header Referrer-Policy        &quot;strict-origin-when-cross-origin&quot;  always;
add_header Permissions-Policy     &quot;camera=(), microphone=(), geolocation=()&quot; always;
</code></pre>
<p>Ukryte pliki są blokowane:</p>
<pre><code class="language-nginx">location ~ /\. { deny all; }
</code></pre>
<p>HSTS jest obsługiwane przez Cloudflare, więc nie ma nagłówka <code>Strict-Transport-Security</code> w konfiguracji nginx — byłby zbędny.</p>
<h2>Wynik<a id="wynik" href="#wynik" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Formularz kontaktowy działa bez sesji, bez tokenów CSRF i bez JavaScriptu wymaganego do czegokolwiek poza samym wysłaniem formularza. Strona renderuje się w pełni jako statyczny HTML. Przesłania botów są blokowane przez weryfikację Turnstile po stronie serwera. Endpoint PHP jest nieosiągalny dla czegokolwiek poza żądaniami POST do <code>/api/contact</code>.</p>
<p>W porównaniu z WordPressem z wtyczką formularza kontaktowego, powierzchnia ataku zmalała z &quot;PHP działającego przy każdym żądaniu, wp-admin wystawione, XML-RPC włączone, 22 wtyczki, każda mogąca mieć lukę&quot; do &quot;jeden endpoint POST z weryfikacją Cloudflare&quot;.</p>
<p><a href="/pl/wpisy/dev-experience-dwa-kontenery/">Następny wpis</a> opisuje środowisko deweloperskie — DDEV, pipeline buildu i deployment do produkcji w dwóch kontenerach Docker.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-14-contact-form-security/contact-form-security.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[cloudflare]]></category>
                        <category><![CDATA[nginx]]></category>
                    </item>
                <item>
            <title><![CDATA[# notACMS — generator statycznych stron na Symfony]]></title>
            <link>https://holas.pl/pl/wpisy/notacms-generator-statycznych-stron/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/notacms-generator-statycznych-stron/</guid>
                        <pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS to oparty na Symfony generator statycznych stron który zbudowałem na początku 2026 roku żeby zastąpić 19 lat WordPressa na holas.pl. Bez bazy danych, bez panelu admina CMS, bez PHP zaangażowanego w serwowanie treści. Cała strona — posty z bloga, strony kategorii, listy tagów, miesiące archiwum, wyszukiwarka, feed RSS, sitemap — jest pre-renderowana do statycznych plików HTML i serwowana pr…]]></description>
            <content:encoded><![CDATA[<p>notACMS to oparty na Symfony generator statycznych stron który zbudowałem na początku 2026 roku żeby zastąpić 19 lat WordPressa na holas.pl. Bez bazy danych, bez panelu admina CMS, bez PHP zaangażowanego w serwowanie treści. Cała strona — posty z bloga, strony kategorii, listy tagów, miesiące archiwum, wyszukiwarka, feed RSS, sitemap — jest pre-renderowana do statycznych plików HTML i serwowana przez nginx.</p>
<p>Kod jest open-source na licencji Apache 2.0 — zobacz <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / notACMS</a>. Zawiera 368 testów PHPUnit, CI/CD przez GitHub Actions i wzorzec lokalnych nadpisań który pozwala użytkownikom customizować szablony, CSS i tłumaczenia bez forkowania.</p>
<h2>Architektura<a id="architektura" href="#architektura" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cała treść żyje w plikach Markdown z YAML frontmatterem:</p>
<pre><code>content/
├── blog/
│   └── tutorials/
│       └── my-post/
│           ├── en.md   ← wersja angielska
│           ├── pl.md   ← wersja polska
│           └── files/  ← obrazki, serwowane z /media/my-post/
└── pages/
    └── about/
        ├── en.md
        └── pl.md
</code></pre>
<p><code>ContentTreeBuilder</code> skanuje system plików, parsuje każdy plik przez <code>league/commonmark</code> (GitHub Flavoured Markdown + YAML frontmatter) i buduje typowane <code>ContentTree</code> — indeks w pamięci wszystkich postów i stron dla danego locale. Tagi, kategorie, miesiące archiwum i mapy URL są obliczane z tego indeksu.</p>
<p>Statyczny build używa sub-requestów Symfony: <code>HttpKernelInterface::handle()</code> z <code>SUB_REQUEST</code> renderuje każdy URL przez pełny kernel bez dotykania sieci. Jeśli URL działa w development, będzie w buildzie statycznym. Brak osobnego silnika szablonów, brak konfiguracji buildu.</p>
<h2>Kluczowe funkcje<a id="kluczowe-funkcje" href="#kluczowe-funkcje" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Wielojęzyczność</strong> — sparowane <code>en.md</code> + <code>pl.md</code> w tym samym katalogu, automatyczne mapowanie tłumaczeń, tagi <code>hreflang</code>, przełącznik języka</li>
<li><strong>Wyszukiwarka</strong> — statyczny indeks oparty na WASM przez Pagefind, po stronie klienta, bez Elasticsearch ani Algolia</li>
<li><strong>Responsywne obrazki</strong> — automatyczne generowanie srcset przez ImageMagick, konfigurowalne szerokości wariantów</li>
<li><strong>Drafty i zaplanowane posty</strong> — dev-only przełączniki podglądu, zaplanowane posty publikowane automatycznie w czasie buildu</li>
<li><strong>Formularz kontaktowy</strong> — Cloudflare Turnstile CAPTCHA, ciasny CSP, pojedynczy endpoint POST</li>
<li><strong>Styleguide</strong> — strona tylko dla dev dokumentująca każdy komponent z prawdziwymi klasami CSS</li>
<li><strong>Wzorzec lokalnych nadpisań</strong> — katalog <code>local/</code> scala się na bazę w czasie buildu, customizacja bez forkowania</li>
</ul>
<h2>Stos technologiczny<a id="stos-technologiczny" href="#stos-technologiczny" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>PHP 8.5, Symfony 7.x</li>
<li>Szablony Twig, SCSS (dart-sass przez symfonycasts/sass-bundle)</li>
<li>Symfony AssetMapper (bez webpacka, bez Vite, bez pipeline'u Node.js)</li>
<li>nginx + PHP-FPM (dwa kontenery Docker na produkcji)</li>
<li>Pagefind do wyszukiwania</li>
<li>PHPUnit 13, PHPStan level 6, Rector, PHP CS Fixer</li>
</ul>
<h2>Dlaczego go zbudowałem?<a id="dlaczego-go-zbudowałem" href="#dlaczego-go-zbudowałem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Hugo, Jekyll i Eleventy były oczywistymi kandydatami. Wybrałem budowę customowej aplikacji Symfony bo znam Symfony dobrze, a posiadanie pełnego stacka okazało się mieć realne zalety: te same szablony, kontrolery i pipeline treści obsługują zarówno development jak i produkcję. Nie ma &quot;silnika szablonów build-time&quot; oddzielonego od &quot;silnika szablonów runtime.&quot; To po prostu Symfony.</p>
<p>Pełna historia dlaczego WordPress musiał odejść jest w <a href="/wpisy/dlaczego-odszedlem-od-wordpressa/">Dlaczego odszedłem od WordPressa</a>. Głębokie zanurzenie w architekturę jest w <a href="/wpisy/symfony-jako-generator-statycznych-stron/">Symfony jako generator statycznych stron</a>.</p>
<h2>Open Source<a id="open-source" href="#open-source" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>notACMS jest dostępny na <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHubie</a> na licencji Apache 2.0. Przygotowanie do wydania — testy, audyt AI, poprawki bezpieczeństwa, wzorzec lokalnych nadpisań — jest udokumentowane w <a href="/wpisy/368-testow-dla-statycznego-generatora-stron/">serii o open-source</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-09-notacms/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[realizacje]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architektura]]></category>
                    </item>
                <item>
            <title><![CDATA[Symfony jako generator stron statycznych — jak działa holas.pl]]></title>
            <link>https://holas.pl/pl/wpisy/symfony-jako-generator-statycznych-stron/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/symfony-jako-generator-statycznych-stron/</guid>
                        <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[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ą niewystar…]]></description>
            <content:encoded><![CDATA[<p><em>To część 2 serii o migracji holas.pl z WordPressa do własnego generatora stron statycznych opartego na Symfony. <a href="/pl/wpisy/dlaczego-odszedlem-od-wordpressa/">Część 1</a> opisuje, dlaczego WordPress musiał odejść.</em></p>
<hr />
<p>Po <a href="/pl/wpisy/dlaczego-odszedlem-od-wordpressa/">podjęciu decyzji o odejściu od WordPressa</a> 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.</p>
<h2>Podstawowa idea<a id="podstawowa-idea" href="#podstawowa-idea" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Strona działa w dwóch trybach:</p>
<ul>
<li><strong>Deweloperski</strong> — Symfony obsługuje żądania dynamicznie. Edytujesz plik Markdown, odświeżasz przeglądarkę, widzisz wynik. Standardowy workflow developerski Symfony z paskiem profilera.</li>
<li><strong>Build produkcyjny</strong> — 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.</li>
</ul>
<p>Te same szablony, kontrolery i pipeline treści obsługują oba tryby. Nie ma osobnego &quot;silnika szablonów do budowania&quot; oddzielnego od &quot;silnika szablonów do działania&quot;. To po prostu Symfony.</p>
<h2>Pipeline treści<a id="pipeline-treści" href="#pipeline-treści" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Cała treść mieszka w plikach Markdown z nagłówkiem YAML:</p>
<pre><code>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
</code></pre>
<p><code>ContentTreeBuilder</code> skanuje system plików, parsuje każdy plik przez <code>league/commonmark</code> (GitHub Flavoured Markdown + nagłówki YAML) i buduje typowany <code>ContentTree</code> — 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.</p>
<p><code>ContentItem</code> to obiekt wartości <code>final readonly</code>. Brak bazy danych, ORM, migracji. Dodanie wpisu oznacza stworzenie katalogu z dwoma plikami Markdown i uruchomienie buildu.</p>
<h2>Polecenie build — sub-żądania Symfony<a id="polecenie-build--sub-żądania-symfony" href="#polecenie-build--sub-żądania-symfony" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Build statyczny używa techniki specyficznej dla Symfony, która odróżnia to podejście od Hugo czy Jekylla: wywołuje <code>HttpKernelInterface::handle()</code> w celu tworzenia <strong>sub-żądań</strong> — wewnętrznych wywołań PHP przechodzących przez pełne jądro Symfony bez dotyku sieci.</p>
<pre><code class="language-php">$request = Request::create($url);
$request-&gt;attributes-&gt;set('_static_build', true);
$response = $this-&gt;kernel-&gt;handle($request, HttpKernelInterface::SUB_REQUEST, false);

if ($response-&gt;getStatusCode() &lt; 400) {
    file_put_contents($outputPath, $response-&gt;getContent());
}
</code></pre>
<p>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 <code>/blog/</code> staje się <code>public/static/blog/index.html</code>. URL <code>/sitemap.xml</code> staje się <code>public/static/sitemap.xml</code>.</p>
<p>Brak osobnego silnika szablonów do nauki. Brak konfiguracji buildu. Jeśli URL działa w developmencie, będzie w statycznym buildzie.</p>
<h2>nginx serwuje wszystko<a id="nginx-serwuje-wszystko" href="#nginx-serwuje-wszystko" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>W produkcji nginx obsługuje całe dostarczanie treści:</p>
<pre><code class="language-nginx">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;
}
</code></pre>
<p>Dla każdego przychodzącego URL nginx najpierw próbuje wstępnie wyrenderowanego pliku HTML. PHP-FPM jest wywoływane tylko dla <code>/api/contact</code> — 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.</p>
<h2>Bez bazy danych<a id="bez-bazy-danych" href="#bez-bazy-danych" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>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.</p>
<p>Brak MySQL, schematu, migracji, connection poolingu, wolnych zapytań. Dodawanie treści oznacza tworzenie plików i uruchamianie <code>ddev build</code>. Usuwanie treści oznacza usuwanie plików. Cała historia strony jest w gicie.</p>
<h2>Wielojęzyczność bez bazy danych<a id="wielojęzyczność-bez-bazy-danych" href="#wielojęzyczność-bez-bazy-danych" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Zarówno polska, jak i angielska treść mieszka w tym samym katalogu:</p>
<pre><code>content/blog/tutorials/moj-wpis/
    en.md  → slug: &quot;blog/my-post&quot;     → /blog/my-post/
    pl.md  → slug: &quot;wpisy/moj-wpis&quot;   → /pl/wpisy/moj-wpis/
    files/ → obrazy serwowane pod /media/moj-wpis/
</code></pre>
<p>Współlokalizacja to link translacji. Dwa pliki locale w tym samym katalogu są automatycznie traktowane jako tłumaczenia siebie nawzajem. <code>TranslationMapBuilder</code> buduje <code>{directoryKey → {locale → url}}</code> dla tagów <code>hreflang</code> i przełącznika języka. Bez pola <code>translation_key</code>, bez tabeli łączącej, bez synchronizacji do zarządzania.</p>
<h2>Wyszukiwanie z Pagefind<a id="wyszukiwanie-z-pagefind" href="#wyszukiwanie-z-pagefind" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Wyszukiwanie to oparty na WASM statyczny indeks budowany przez <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a> po wygenerowaniu plików HTML:</p>
<pre><code class="language-bash">npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Pagefind czyta wstępnie wyrenderowany HTML, indeksuje regiony <code>data-pagefind-body</code> i generuje binarny indeks w <code>public/pagefind/</code>. Strona wyszukiwania ładuje ten indeks po stronie klienta przez dynamiczny <code>import()</code>. Brak Elasticsearch, Algolii, zapytań wyszukiwania po stronie serwera. Indeks to zbiór plików statycznych.</p>
<h2>Prostota redesignu<a id="prostota-redesignu" href="#prostota-redesignu" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>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:</p>
<ul>
<li><code>assets/styles/app.scss</code> — jeden punkt wejścia importujący pliki częściowe</li>
<li><code>templates/</code> — szablony Twig</li>
<li><code>symfonycasts/sass-bundle</code> — kompiluje SCSS przez dart-sass, bez Node.js</li>
</ul>
<p>Żeby zmienić schemat kolorów: edytuj <code>_variables.scss</code>. Żeby zmienić layout: edytuj szablon Twig. Uruchom <code>ddev build</code> i gotowe.</p>
<h2>Kompromisy<a id="kompromisy" href="#kompromisy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>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ę.</p>
<p>Jedyna naprawdę dynamiczna funkcja — formularz kontaktowy — jest omówiona w <a href="/pl/wpisy/bezpieczenstwo-formularza-kontaktowego/">następnym wpisie</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-02-symfony-static-site-generator/symfony-static-site-generator.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architektura]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
            </channel>
</rss>
