<?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>Sat, 06 Jun 2026 00:00:00 +0000</lastBuildDate>
                        <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/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/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 &amp;quot;opublikuj dnia&amp;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/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/post-dir/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/post-dir/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-name/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/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/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/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/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[static-site]]></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/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/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&#039;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/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/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/369-testow-dla-statycznego-generatora-stron/">serii o open-source</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/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[static-site]]></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/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>
                <item>
            <title><![CDATA[19 lat z WordPressem — dlaczego w końcu zrezygnowałem]]></title>
            <link>https://holas.pl/pl/wpisy/dlaczego-odszedlem-od-wordpressa/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/dlaczego-odszedlem-od-wordpressa/</guid>
                        <pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl działał na WordPressie od 2007 do początku 2026 roku. Dziewiętnaście lat. W tym czasie WordPress wyrósł z przyzwoitej platformy blogowej w coś, czego prawie nie rozpoznaję — a jego utrzymanie stało się większym wysiłkiem niż utrzymanie samej treści. To pierwszy wpis z serii dokumentującej migrację do własnego generatora stron statycznych opartego na Symfony. Kolejne wpisy omawiają archite…]]></description>
            <content:encoded><![CDATA[<p>holas.pl działał na WordPressie od 2007 do początku 2026 roku. Dziewiętnaście lat. W tym czasie WordPress wyrósł z przyzwoitej platformy blogowej w coś, czego prawie nie rozpoznaję — a jego utrzymanie stało się większym wysiłkiem niż utrzymanie samej treści.</p>
<p>To pierwszy wpis z serii dokumentującej migrację do własnego generatora stron statycznych opartego na Symfony. Kolejne wpisy omawiają <a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">architekturę</a>, <a href="/pl/wpisy/bezpieczenstwo-formularza-kontaktowego/">formularz kontaktowy</a>, <a href="/pl/wpisy/dev-experience-dwa-kontenery/">środowisko deweloperskie</a> i <a href="/pl/wpisy/budowanie-strony-z-ai-claude-code/">budowanie strony z pomocą AI</a>.</p>
<h2>Taśmociąg aktualizacji<a id="taśmociąg-aktualizacji" href="#taśmociąg-aktualizacji" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>WordPress regularnie wydaje aktualizacje bezpieczeństwa. Wtyczki wydają aktualizacje niezależnie. Motywy też. W spokojnym tygodniu miałem trzy pozycje w kolejce. W gorszy tydzień — piętnaście. Każda wymagała:</p>
<ol>
<li>Kopii zapasowej bazy danych i plików</li>
<li>Zastosowania aktualizacji</li>
<li>Sprawdzenia, czy nic się nie posypało</li>
</ol>
<p>Ten ostatni krok to największy problem. Wtyczki WordPressa wchodzą ze sobą w interakcje w sposób niemożliwy do przewidzenia. Aktualizacja wtyczki do cache'owania psuła wyniki wtyczki SEO. Aktualizacja wtyczki SEO zmieniała sposób generowania map strony. Każda aktualizacja to mały hazard.</p>
<p>Dla portfolio z wpisem co kilka miesięcy ten narzut na utrzymanie jest absurdalny.</p>
<h2>Dziesiątki wtyczek do podstawowych funkcji<a id="dziesiątki-wtyczek-do-podstawowych-funkcji" href="#dziesiątki-wtyczek-do-podstawowych-funkcji" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Świeża instalacja WordPressa nie robi prawie nic. Żeby prowadzić przyzwoite portfolio blogowe, potrzebowałem wtyczek do:</p>
<ul>
<li><strong>SEO</strong> — Yoast lub Rank Math, z własnym interfejsem, ustawieniami i cyklem aktualizacji</li>
<li><strong>Cache'owania</strong> — W3 Total Cache lub WP Super Cache, ze skomplikowaną konfiguracją</li>
<li><strong>Bezpieczeństwa</strong> — Wordfence lub podobna, skanowanie intruzji, blokowanie IP, codzienne raporty</li>
<li><strong>Formularza kontaktowego</strong> — Contact Form 7 lub Gravity Forms</li>
<li><strong>Kopii zapasowych</strong> — UpdraftPlus lub podobna, zwykle wysyłająca dane do zewnętrznego serwisu</li>
<li><strong>Wydajności</strong> — optymalizacja obrazów, lazy loading, minifikacja</li>
<li><strong>SMTP</strong> — bo WordPress domyślnie wysyła e-maile przez <code>mail()</code> w PHP, które trafiają do spamu</li>
</ul>
<p>Każda wtyczka to kod, którego nie kontroluję, utrzymywany przez kogoś innego, potencjalnie porzucony jutro, działający przy każdym wczytaniu strony.</p>
<p>Kiedy zacząłem planować migrację, miałem 22 aktywne wtyczki.</p>
<h2>Ataki botów i problemy z logowaniem<a id="ataki-botów-i-problemy-z-logowaniem" href="#ataki-botów-i-problemy-z-logowaniem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Strona logowania do WordPressa jest dobrze znana botom. URL to zawsze <code>/wp-admin/</code> lub <code>/wp-login.php</code>. Każdy bot w internecie to wie. Efekt: ciągłe próby włamania przez brute-force, przez całą dobę.</p>
<p>Wordfence ograniczał próby logowania, blokował podejrzane IP i wysyłał mi codzienne raporty o atakach. Działało — ale to kolejny element pochłaniający zasoby serwera na stronie, która w ogóle nie potrzebuje logowania użytkowników. Jedyną osobą logującą się byłem ja, raz w miesiącu.</p>
<p>XML-RPC to kolejny wektor ataku. WordPress domyślnie go włącza dla pingbacków i aplikacji mobilnej. Boty ciągle go sondują. Rozwiązaniem była reguła nginx blokująca ten endpoint — ale dowiedziałem się o tym dopiero po zobaczeniu ruchu w logach.</p>
<h2>PHP i MySQL na Raspberry Pi<a id="php-i-mysql-na-raspberry-pi" href="#php-i-mysql-na-raspberry-pi" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>holas.pl działa na własnym sprzęcie — najpierw <a href="/pl/wpisy/wlasny-domowy-serwer-banana-pi/">Banana Pi</a>, teraz <a href="/pl/wpisy/raspberry-pi-5-migracja-na-nvme-i-konteneryzacje-uslug/">Raspberry Pi 5</a>. To sprawne płyty ARM, ale nie są maszynami serwerowymi. Uruchamianie PHP-FPM i MySQL jednocześnie dla bloga, który zmienia się raz w miesiącu, to czyste marnotrawstwo.</p>
<p>Typowe wczytanie strony WordPress na tym sprzęcie: PHP przetwarza żądanie, MySQL wykonuje kilka zapytań po treść wpisu, dane nawigacji, widżety paska bocznego i opcje strony, PHP renderuje szablony, każda aktywna wtyczka wykonuje swoje hooki. Wszystko to dla treści identycznej z poprzednim żądaniem i identycznej z następnym.</p>
<p>Ze stroną statyczną nginx serwuje wstępnie wyrenderowany plik HTML bezpośrednio z dysku. Procesor ARM ledwo się budzi. Strony ładują się szybciej niż WordPress zdążył przetworzyć żądanie.</p>
<h2>Baza danych jako ryzyko<a id="baza-danych-jako-ryzyko" href="#baza-danych-jako-ryzyko" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Dziewiętnaście lat WordPressa to baza danych z tysiącami wierszy metadanych wpisów, opcji, rewizji i transientów. Wymaga:</p>
<ul>
<li>Regularnych kopii zapasowych (i testowania, że przywracanie faktycznie działa)</li>
<li>Okazjonalnego czyszczenia — WordPress gromadzi śmieci: rewizje wpisów, wygasłe transienty, osierocone wiersze metadanych</li>
<li>Monitorowania pod kątem uszkodzeń po nieoczystym wyłączeniu</li>
<li>Ciągle działającego procesu MySQL, zużywającego pamięć</li>
</ul>
<p>Portfolio blog to zbiór tekstów i obrazów. Przechowywanie go w relacyjnej bazie danych dodaje złożoności operacyjnej bez dodawania wartości.</p>
<h2>Co zostało zachowane<a id="co-zostało-zachowane" href="#co-zostało-zachowane" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Treść. Wszystkie 26 wpisów zostało wyeksportowanych i przekonwertowanych do plików Markdown — tytuły, daty, kategorie, tagi, obrazy. Adresy URL zostały zachowane dokładnie: 25 indywidualnych przekierowań wpisów i 43 przekierowania ścieżek obrazów jest teraz wbudowanych w konfigurację nginx. Pozycje w wyszukiwarce przetrwały migrację nienaruszone.</p>
<p>Wszystko inne — baza danych, wtyczki, kolejka aktualizacji, strona logowania <code>/wp-admin/</code> — zniknęło.</p>
<p><a href="/pl/wpisy/symfony-jako-generator-statycznych-stron/">Następny wpis</a> opisuje, co zastąpiło WordPressa: własna aplikacja Symfony generująca statyczne pliki HTML, serwowane przez nginx bez udziału PHP w dostarczaniu treści do odwiedzających.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/why-i-left-wordpress/why-i-left-wordpress.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[wordpress]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[statyczna-strona]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[Let&#039;s Encrypt z Dockerem i Traefik — automatyczne HTTPS dla każdej usługi]]></title>
            <link>https://holas.pl/pl/wpisy/lets-encrypt-docker-traefik/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/lets-encrypt-docker-traefik/</guid>
                        <pubDate>Sun, 08 Mar 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[To kontynuacja wpisu o Let&#039;s Encrypt z 2016 roku opartego na acme.sh i Apache. Tamto rozwiązanie działało, ale ręczne zarządzanie certyfikatami nie skaluje się, gdy prowadzisz wiele usług w Dockerze. Traefik rozwiązuje to w całości. Traefik to reverse proxy zaprojektowany dla środowisk kontenerowych. Obserwuje Docker socket, wykrywa uruchomione kontenery i automatycznie pobiera certyfikaty Let&#039;s E…]]></description>
            <content:encoded><![CDATA[<p><em>To kontynuacja <a href="/pl/wpisy/zabezpiecz-swoja-strone-www-za-darmo-certyfikatem-ssl-od-lets-encrypt/">wpisu o Let's Encrypt z 2016 roku</a> opartego na acme.sh i Apache. Tamto rozwiązanie działało, ale ręczne zarządzanie certyfikatami nie skaluje się, gdy prowadzisz wiele usług w Dockerze. Traefik rozwiązuje to w całości.</em></p>
<hr />
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://traefik.io/">Traefik</a> to reverse proxy zaprojektowany dla środowisk kontenerowych. Obserwuje Docker socket, wykrywa uruchomione kontenery i automatycznie pobiera certyfikaty Let's Encrypt — bez certbota, bez crona, bez ręcznej konfiguracji per domena.</p>
<p>Konfiguracja składa się z dwóch części: jedna instancja Traefik działająca cały czas na serwerze oraz etykiety przy każdej usłudze, które mówią Traefik jak routować ruch i dla jakiej domeny wystawić certyfikat.</p>
<h2>Część 1 — docker-compose.yml dla Traefik<a id="część-1--docker-composeyml-dla-traefik" href="#część-1--docker-composeyml-dla-traefik" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Uruchamiany raz na serwerze. Wszystkie pozostałe usługi przechodzą przez niego.</p>
<pre><code class="language-yaml">services:
    traefik:
        image: traefik:${TRAEFIK_VERSION}
        container_name: traefik
        command:
            - &quot;--providers.docker=true&quot;
            - &quot;--providers.docker.exposedbydefault=false&quot;
            - &quot;--entrypoints.web.address=:80&quot;
            - &quot;--entrypoints.websecure.address=:443&quot;
            # globalne przekierowanie HTTP → HTTPS
            - &quot;--entrypoints.web.http.redirections.entrypoint.scheme=https&quot;
            - &quot;--entrypoints.web.http.redirections.entrypoint.to=websecure&quot;
            # Let's Encrypt
            - &quot;--certificatesResolvers.le.acme.email=${ACME_EMAIL}&quot;
            - &quot;--certificatesResolvers.le.acme.storage=acme.json&quot;
            - &quot;--certificatesResolvers.le.acme.tlsChallenge=true&quot;
        restart: always
        ports:
            - 80:80
            - 443:443
        networks:
            - web
        volumes:
            - /etc/localtime:/etc/localtime:ro
            - /var/run/docker.sock:/var/run/docker.sock
            - ./acme.json:/acme.json
        labels:
            - traefik.http.middlewares.gzip.compress=true

networks:
    web:
        external: true
</code></pre>
<p>Kilka rzeczy wartych uwagi:</p>
<ul>
<li><strong><code>exposedbydefault=false</code></strong> — Traefik ignoruje kontenery, chyba że jawnie się zgłoszą przez etykiety. Nic nie zostaje przypadkowo wystawione na zewnątrz.</li>
<li><strong>TLS challenge</strong> — Traefik obsługuje weryfikację domeny bezpośrednio na porcie 443. Nie trzeba tymczasowo otwierać portu 80.</li>
<li><strong><code>acme.json</code></strong> — certyfikaty są zapisywane na dysku i przeżywają restarty kontenera. Przed pierwszym uruchomieniem utwórz plik i ustaw odpowiednie uprawnienia:</li>
</ul>
<pre><code class="language-bash">touch acme.json &amp;&amp; chmod 600 acme.json
</code></pre>
<p>Traefik odmówi uruchomienia przy zbyt szerokich uprawnieniach.</p>
<ul>
<li><strong>Zewnętrzna sieć <code>web</code></strong> — tworzysz ją raz:</li>
</ul>
<pre><code class="language-bash">docker network create web
</code></pre>
<p>Każda usługa potrzebująca routingu HTTP/S dołącza do tej sieci.</p>
<h2>Część 2 — Certyfikaty testowe (staging)<a id="część-2--certyfikaty-testowe-staging" href="#część-2--certyfikaty-testowe-staging" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Przed przejściem na produkcję przetestuj z serwerem staging Let's Encrypt, żeby nie trafić na limity:</p>
<pre><code class="language-yaml"># dodaj do sekcji command w traefik:
- &quot;--log.level=DEBUG&quot;
- &quot;--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory&quot;
</code></pre>
<p>Certyfikaty stagingowe nie są zaufane przez przeglądarki, ale funkcjonalnie działają identycznie. Usuń te linie gdy wszystko działa.</p>
<h2>Część 3 — Dodanie usługi<a id="część-3--dodanie-usługi" href="#część-3--dodanie-usługi" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Tu widać zwrot z inwestycji. Dowolny kontener dostaje HTTPS przez dodanie czterech etykiet:</p>
<pre><code class="language-yaml">services:
    myapp:
        image: myapp:latest
        networks:
            - web
        labels:
            - traefik.enable=true
            - traefik.http.routers.myapp.rule=Host(`myapp.example.com`)
            - traefik.http.routers.myapp.entrypoints=websecure
            - traefik.http.routers.myapp.tls.certresolver=le

networks:
    web:
        external: true
</code></pre>
<p>Traefik wykrywa kontener, pobiera certyfikat od Let's Encrypt i zaczyna routować ruch — bez konfiguracji nginx, bez uruchamiania certbota, bez crona. Odnowienia dzieją się automatycznie w tle.</p>
<h2>Alternatywa bez Dockera<a id="alternatywa-bez-dockera" href="#alternatywa-bez-dockera" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Jeśli nie używasz Dockera, <a rel="nofollow noopener noreferrer" target="_blank" href="https://certbot.eff.org/">Certbot</a> z wtyczką nginx to standardowe podejście: <code>apt install certbot python3-certbot-nginx</code>, <code>certbot --nginx -d mojastrona.pl</code>, a timer systemd zajmuje się odnowieniami. Sprawdza się dobrze na jednym serwerze z kilkoma stronami. Gdy usług jest więcej, podejście z Traefik zaczyna się opłacać.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/lets-encrypt-modern/lets-encrypt-modern.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[traefik]]></category>
                        <category><![CDATA[ssl]]></category>
                    </item>
                <item>
            <title><![CDATA[Raspberry Pi 5: Migracja na NVMe i konteneryzację usług]]></title>
            <link>https://holas.pl/pl/wpisy/raspberry-pi-5-migracja-na-nvme-i-konteneryzacje-uslug/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/raspberry-pi-5-migracja-na-nvme-i-konteneryzacje-uslug/</guid>
                        <pubDate>Sat, 10 Jan 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Mój poprzedni serwer — Banana Pi na Debianie — przez lata spełniał swoją funkcję, jednak ograniczenia wydajnościowe oraz brak wsparcia dla nowszych technologii skłoniły mnie do modernizacji. Głównym celem była przesiadka na architekturę opartą o kontenery oraz wyeliminowanie kart SD na rzecz standardu NVMe. Specyfikacja sprzętowa Nowy zestaw został skompletowany z myślą o maksymalnej responsywnośc…]]></description>
            <content:encoded><![CDATA[<p>Mój poprzedni serwer — <a href="/pl/wpisy/wlasny-domowy-serwer-banana-pi/">Banana Pi</a> na Debianie — przez lata spełniał swoją funkcję, jednak ograniczenia wydajnościowe oraz brak wsparcia dla nowszych technologii skłoniły mnie do modernizacji. Głównym celem była przesiadka na architekturę opartą o kontenery oraz wyeliminowanie kart SD na rzecz standardu NVMe.</p>
<h2>Specyfikacja sprzętowa<a id="specyfikacja-sprzętowa" href="#specyfikacja-sprzętowa" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Nowy zestaw został skompletowany z myślą o maksymalnej responsywności i stabilności:</p>
<ul>
<li><strong>Jednostka:</strong> Raspberry Pi 5 (8GB RAM).</li>
<li><strong>Obudowa:</strong> Argon NEO 5 M.2 NVMe – zapewnia skuteczne chłodzenie i bezpośrednią obsługę dysków SSD przez dedykowany interfejs.</li>
<li><strong>Pamięć masowa:</strong> Dysk Lexar 1TB M.2 PCIe NVMe NM620. Rezygnacja z kart micro SD na rzecz NVMe znacząco redukuje opóźnienia i zwiększa trwałość nośnika.</li>
</ul>
<p><img data-full="/media/rpi5-migration/rpi5.webp" src="/media/rpi5-migration/rpi5.webp" alt="Raspberry Pi 5 z obudową Argon NEO 5 i dyskiem NVMe" /></p>
<h2>Energooszczędność<a id="energooszczędność" href="#energooszczędność" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Jednym z kluczowych argumentów za wyborem Raspberry Pi w roli serwera 24/7 jest niski pobór mocy. W obecnej konfiguracji parametry prezentują się następująco:</p>
<ul>
<li><strong>Idle (bezczynność):</strong> Zapotrzebowanie na poziomie <strong>3W</strong> (potwierdzone odczytem z zasilacza).</li>
<li><strong>Stress (obciążenie):</strong> Pobór mocy wzrasta do przedziału <strong>9-12W</strong>.</li>
</ul>
<p>Stosunek wydajności do zużytej energii czyni tę jednostkę niezwykle efektywnym rozwiązaniem pod kątem kosztów eksploatacji.</p>
<h2>System i usługi systemowe<a id="system-i-usługi-systemowe" href="#system-i-usługi-systemowe" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Całość pracuje pod kontrolą <strong>Raspberry Pi OS</strong>. Na poziomie systemu operacyjnego (poza Dockerem) skonfigurowałem kluczowe usługi zarządzania i dostępu:</p>
<ul>
<li><strong>Dostęp zdalny:</strong> VPN oparty na protokole Wireguard (<strong>PiVPN</strong>) zapewnia bezpieczny tunel do sieci domowej.</li>
<li><strong>Zarządzanie:</strong> Do obsługi graficznego interfejsu wykorzystuję <strong>VNC</strong>.</li>
<li><strong>Współdzielenie plików:</strong> Standardowa usługa <strong>Samba</strong> do szybkiego dostępu do danych w sieci lokalnej.</li>
</ul>
<h2>Środowisko Docker<a id="środowisko-docker" href="#środowisko-docker" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Kluczową zmianą w stosunku do poprzedniego serwera jest pełna konteneryzacja usług sieciowych. Za routing i automatyczne wystawianie certyfikatów SSL odpowiada <strong>Traefik</strong>.</p>
<p>W ramach Dockera utrzymuję obecnie:</p>
<ol>
<li><strong>holas.pl</strong> – moja strona internetowa.</li>
<li><strong>Nextcloud</strong> – prywatna chmura do synchronizacji danych.</li>
<li><strong>Immich</strong> – rozwiązanie do backupu i zarządzania biblioteką zdjęć.</li>
<li><strong>Traefik</strong> – reverse proxy zarządzające ruchem przychodzącym.</li>
</ol>
<h2>Potencjał rozwojowy<a id="potencjał-rozwojowy" href="#potencjał-rozwojowy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Mimo uruchomienia kilku wymagających usług, Raspberry Pi 5 w wersji 8GB wykazuje <strong>spory zapas mocy obliczeniowej oraz pamięci RAM</strong>. Obecne obciążenie pozwala na swobodne wdrażanie kolejnych kontenerów bez ryzyka spadku wydajności działających już systemów. Przejście na standard NVMe sprawiło, że operacje na bazach danych (szczególnie w Immich i Nextcloud) są wykonywane natychmiastowo.</p>
<p><img data-full="/media/rpi5-migration/Zrzut-ekranu-2026-01-10-224632-1024x500.webp" src="/media/rpi5-migration/Zrzut-ekranu-2026-01-10-224632-1024x500.webp" alt="Obciążenie systemu – htop" /></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/rpi5-migration/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[raspberry-pi]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[homelab]]></category>
                        <category><![CDATA[self-hosted]]></category>
                    </item>
                <item>
            <title><![CDATA[ddev-sylius: Boilerplate DDEV dla Syliusa 2.x]]></title>
            <link>https://holas.pl/pl/wpisy/ddev-sylius-boilerplate/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/ddev-sylius-boilerplate/</guid>
                        <pubDate>Tue, 02 Dec 2025 00:00:00 +0000</pubDate>
                        <description><![CDATA[Projekt powstał przy okazji zdalnej konfiguracji Syliusa — całkowicie przez SSH, z telefonu. Kroki konfiguracyjne były na tyle powtarzalne, że warto było je zautomatyzować, więc złożyłem boilerplate DDEV. Zanim uznałem go za gotowy, ktoś udostępnił go publicznie — opublikowałem go więc jako wczesną alfę i rozwijałem dalej. Po roku użytkowania wewnętrznego wyszło v1.0.0 — z pełnym wsparciem dla Syl…]]></description>
            <content:encoded><![CDATA[<p>Projekt powstał przy okazji zdalnej konfiguracji Syliusa — całkowicie przez SSH, z telefonu. Kroki konfiguracyjne były na tyle powtarzalne, że warto było je zautomatyzować, więc złożyłem boilerplate DDEV. Zanim uznałem go za gotowy, ktoś udostępnił go publicznie — opublikowałem go więc jako wczesną alfę i rozwijałem dalej.</p>
<p>Po roku użytkowania wewnętrznego wyszło v1.0.0 — z pełnym wsparciem dla Syliusa 2.x, czystą strukturą i wszystkim, czego używam na co dzień. Następnego dnia pojawiło się v1.0.1 z poprawkami dla różnych platform.</p>
<h2>Dlaczego konfiguracja Syliusa bez narzędzi jest uciążliwa<a id="dlaczego-konfiguracja-syliusa-bez-narzędzi-jest-uciążliwa" href="#dlaczego-konfiguracja-syliusa-bez-narzędzi-jest-uciążliwa" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Sylius 2.x ma nietrywialną konfigurację lokalną. Wymaga PHP 8.4, Composera, bazy danych, serwera WWW z poprawnymi regułami rewrite, Node.js i Yarn do kompilacji assetów panelu admina (opartych na Webpack Encore) oraz binarki Symfony do zadań konsolowych. Sprawne uruchomienie tego wszystkiego na różnych maszynach — Windows, macOS, Linux — bez środowiska kontenerowego jest zawodne. Wersje się rozjeżdżają, zmienne środowiskowe różnią się, ścieżki kolidują.</p>
<p>DDEV rozwiązuje to, deklarując całe środowisko jako konfigurację. Boilerplate ma wbudowane właściwe wersje i łączy je razem, żeby nikt nie musiał tego rozgryzać ręcznie.</p>
<h2>Co jest konfigurowane<a id="co-jest-konfigurowane" href="#co-jest-konfigurowane" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Plik <code>.ddev/config.yaml</code> ustawia:</p>
<ul>
<li><strong>PHP 8.4</strong> z <code>apache-fpm</code> (Sylius 2.x wymaga PHP 8.2+; 8.4 to obecna stabilna wersja)</li>
<li><strong>MariaDB 11.8</strong> — najnowsza stabilna, z <code>upload_dirs</code> dostosowanym tak, żeby wykluczyć <code>media/</code>, <code>node_modules/</code> i <code>backups/</code> z synchronizacji Mutagen na macOS</li>
<li><strong>Composer 2</strong></li>
<li><strong>Porty</strong> 8123 (HTTP) i 8443 (HTTPS), żeby unikać konfliktów z innymi lokalnymi projektami</li>
<li><strong>Xdebug wyłączony</strong> domyślnie — włączany przez <code>ddev xdebug on</code> gdy potrzebny</li>
</ul>
<h2>Co jest w środku<a id="co-jest-w-środku" href="#co-jest-w-środku" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/ddev-sylius">ddev-sylius</a> to szablon projektu oparty na DDEV dla Syliusa 2.x. Sklonuj, uruchom dwie komendy i masz działającą lokalną instancję Syliusa — bez ręcznej konfiguracji wersji PHP, bazy danych ani serwera WWW.</p>
<p>Niestandardowe komendy DDEV dołączone do boilerplate'u:</p>
<ul>
<li><code>ddev sylius-install</code> — pełna instalacja Syliusa od zera</li>
<li><code>ddev cc</code> — czyszczenie cache</li>
<li><code>ddev dist</code> — instalacja zależności i budowanie assetów (Composer + Yarn)</li>
<li><code>ddev yarn &lt;param&gt;</code> — komendy Yarn wewnątrz kontenera</li>
<li><code>ddev security-checker</code> — skanowanie znanych podatności</li>
<li><code>ddev code-check</code> — weryfikacja standardów kodowania</li>
<li><code>ddev backup</code> / <code>ddev database-import</code> / <code>ddev files-import</code> — backup i przywracanie bazy danych i mediów</li>
<li><code>ddev sylius-cleanup</code> — resetowanie wszystkich danych (przydatne przy testowaniu przepływu instalacji)</li>
<li><code>ddev build-site</code> / <code>ddev rebuild-site</code> — pełna lub częściowa przebudowa projektu</li>
</ul>
<p>Przetestowane na Windowsie 11 z WSL2 (Ubuntu 24.04), macOS Tahoe (Apple Silicon) i Linuksie z Dockerem.</p>
<h2>Jak zacząć<a id="jak-zacząć" href="#jak-zacząć" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">git clone https://github.com/holas1337/ddev-sylius my-projekt
cd my-projekt
ddev start
ddev sylius-install
</code></pre>
<p>Tyle. Po kilku minutach masz działający sklep Sylius z panelem administracyjnym, dostępny pod lokalnym URL-em wygenerowanym przez DDEV (domyślnie <code>https://ddev-sylius-boilerplate.ddev.site:8443</code>).</p>
<h2>Codzienna praca<a id="codzienna-praca" href="#codzienna-praca" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Po pierwszej instalacji typowe komendy to:</p>
<pre><code class="language-bash">ddev start             # start środowiska
ddev cc                # czyszczenie cache po zmianach konfiguracji
ddev dist              # przebudowa assetów po zmianach frontendu
ddev code-check        # sprawdzenie standardów przed commitem
ddev backup            # snapshot bazy przed ryzykowną migracją
</code></pre>
<p>Do debugowania <code>ddev xdebug on</code> włącza Xdebug, a <code>ddev exec bin/console &lt;komenda&gt;</code> daje bezpośredni dostęp do konsoli Symfony wewnątrz kontenera.</p>
<h2>Co zmieniło się w v1.0.1<a id="co-zmieniło-się-w-v101" href="#co-zmieniło-się-w-v101" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Poprawka wydana następnego dnia naprawiła kilka rzeczy, które wyszły podczas testów na różnych platformach: dostosowania specyficzne dla macOS dla Mutagen i wyłączeń katalogów uploadu, aktualizacja MariaDB z 11.4 do 11.8 oraz aktualizacja phpMyAdmin do najnowszej wersji.</p>
<p>Repozytorium jest na GitHubie: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/ddev-sylius">holas1337/ddev-sylius</a>. Issues i PR-y mile widziane.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/ddev-sylius/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[realizacje]]></category>
                                    <category><![CDATA[ddev]]></category>
                        <category><![CDATA[sylius]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[e-commerce]]></category>
                    </item>
                <item>
            <title><![CDATA[Jak ograniczyć śledzenie przez Facebooka w przeglądarce]]></title>
            <link>https://holas.pl/pl/wpisy/facebook-nas-sledzi/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/facebook-nas-sledzi/</guid>
                        <pubDate>Tue, 27 Mar 2018 00:00:00 +0000</pubDate>
                        <description><![CDATA[Facebook śledzi cię w całej sieci przez przyciski &amp;quot;Lubię to&amp;quot;, widżety komentarzy i niewidoczne piksele osadzone na milionach stron — nawet jeśli nigdy w nic nie klikasz. Kilka rozszerzeń przeglądarki znacząco ogranicza ten zasięg. uBlock Origin Niezbędna pierwsza warstwa ochrony. uBlock Origin (Firefox, Chrome) blokuje reklamy i śledzenie na poziomie sieciowym. Już z domyślnymi listami f…]]></description>
            <content:encoded><![CDATA[<p>Facebook śledzi cię w całej sieci przez przyciski &quot;Lubię to&quot;, widżety komentarzy i niewidoczne piksele osadzone na milionach stron — nawet jeśli nigdy w nic nie klikasz. Kilka rozszerzeń przeglądarki znacząco ogranicza ten zasięg.</p>
<h2>uBlock Origin<a id="ublock-origin" href="#ublock-origin" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Niezbędna pierwsza warstwa ochrony. <strong>uBlock Origin</strong> (<a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/pl/firefox/addon/ublock-origin/">Firefox</a>, <a rel="nofollow noopener noreferrer" target="_blank" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">Chrome</a>) blokuje reklamy i śledzenie na poziomie sieciowym. Już z domyślnymi listami filtrów blokuje większość pikseli śledzących Facebooka i widżetów społecznościowych na stronach trzecich.</p>
<p><strong>Uwaga dla użytkowników Chrome:</strong> Przejście Google na Manifest V3 ogranicza niektóre możliwości uBlock Origin w Chrome. Pełna wersja pozostaje dostępna w Firefoksie.</p>
<h2>Privacy Badger<a id="privacy-badger" href="#privacy-badger" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://privacybadger.org/">Privacy Badger</a> od Electronic Frontier Foundation (<a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/privacy-badger17/">Firefox</a>, <a rel="nofollow noopener noreferrer" target="_blank" href="https://chromewebstore.google.com/detail/privacy-badger/pkehgijcmpdhfbdbbnkijodmdjhbjlgp">Chrome</a>) działa inaczej niż klasyczne blokery: zamiast statycznej listy blokowania, uczy się, które śledziki podążają za tobą między stronami i stopniowo je blokuje. Dobrze współpracuje z uBlock Origin.</p>
<h2>Facebook Container (tylko Firefox)<a id="facebook-container-tylko-firefox" href="#facebook-container-tylko-firefox" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/facebook-container/">Facebook Container</a> to rozszerzenie Mozilli, które izoluje sesję Facebooka w osobnej zakładce kontenera. Facebook nie widzi twojej aktywności na innych stronach, a widżety Facebooka na innych stronach nie mogą odczytać twoich ciasteczek sesji.</p>
<p>To najskuteczniejsze pojedyncze rozszerzenie wymierzone konkretnie w śledzenie Facebooka między stronami — stworzone i utrzymywane przez Mozillę.</p>
<h2>Wbudowana ochrona przeglądarek<a id="wbudowana-ochrona-przeglądarek" href="#wbudowana-ochrona-przeglądarek" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Zarówno <strong>Firefox</strong>, jak i <strong>Brave</strong> mają domyślnie włączoną rozszerzoną ochronę przed śledzeniem, która blokuje wielu znanych śledzicieli — w tym Facebooka. Jeśli korzystasz z jednej z tych przeglądarek, masz już solidną bazę — rozszerzenia powyżej dodają do niej bardziej szczegółową kontrolę.</p>
<p>Jeśli używasz Chrome, rozważ przejście na Firefoksa dla lepszej domyślnej prywatności.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/facebook-tracking-2018/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[prywatnosc]]></category>
                    </item>
                <item>
            <title><![CDATA[Zabezpiecz swoją stronę za darmo certyfikatem SSL od Let&#039;s Encrypt]]></title>
            <link>https://holas.pl/pl/wpisy/zabezpiecz-swoja-strone-www-za-darmo-certyfikatem-ssl-od-lets-encrypt/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/zabezpiecz-swoja-strone-www-za-darmo-certyfikatem-ssl-od-lets-encrypt/</guid>
                        <pubDate>Wed, 24 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[To historyczny zapis konfiguracji z 2016 roku — acme.sh i Apache na Debianie Jessie. Działało bez zarzutu. Dla nowoczesnego podejścia z Dockerem i Traefik zapraszam do wpisu kontynuacji. Przed Let&#039;s Encrypt uzyskanie zaufanego certyfikatu SSL wymagało płacenia komerycjnemu CA (Comodo, DigiCert, GoDaddy) od 200 do kilku tysięcy złotych rocznie, ręcznej weryfikacji tożsamości i samodzielnej obsługi …]]></description>
            <content:encoded><![CDATA[<p><em>To historyczny zapis konfiguracji z 2016 roku — acme.sh i Apache na Debianie Jessie. Działało bez zarzutu. Dla nowoczesnego podejścia z Dockerem i Traefik zapraszam do wpisu kontynuacji.</em></p>
<hr />
<p>Przed Let's Encrypt uzyskanie zaufanego certyfikatu SSL wymagało płacenia komerycjnemu CA (Comodo, DigiCert, GoDaddy) od 200 do kilku tysięcy złotych rocznie, ręcznej weryfikacji tożsamości i samodzielnej obsługi odnowień. Let's Encrypt uruchomił się w 2015 roku, wyszedł z bety w kwietniu 2016 i zmienił to całkowicie: darmowe certyfikaty Domain Validation, automatyczne wydawanie przez protokół ACME, 90-dniowy czas ważności z wbudowaną obsługą odnowień. Dla małych serwerów i prywatnych stron działających dotychczas na HTTP była to pierwsza praktyczna droga do HTTPS.</p>
<p>Oficjalny klient Certbot nie był jeszcze dostępny w repozytoriach Debiana Jessie, więc skorzystałem z jednego z <a rel="nofollow noopener noreferrer" target="_blank" href="https://community.letsencrypt.org/t/list-of-client-implementations/2103">alternatywnych klientów</a> — prostego skryptu bash: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/Neilpang/acme.sh">https://github.com/Neilpang/acme.sh</a></p>
<p>Używając tego skryptu można wszystko zrobić dosłownie w 5 minut.</p>
<h2>Wymagania<a id="wymagania" href="#wymagania" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>Dostęp root do serwera</li>
<li>Apache działający na Debianie Jessie</li>
<li>Domena wskazująca na publiczne IP serwera</li>
<li>Otwarty port 80 (używany przez challenge HTTP-01 do weryfikacji własności domeny)</li>
</ul>
<p>Na wstępie ustalmy, że wszystko wykonujemy z roota oraz:</p>
<pre><code>/root/.acme.sh/acme.sh     # miejsce składowania skryptów klienta
mojastrona.pl              # strona, dla której chcemy uzyskać certyfikat
/mnt/www/mojastrona.pl     # katalog na dysku naszej strony
/etc/apache2               # lokalizacja Apache wraz z plikami konfiguracji
</code></pre>
<h3>Krok 1 — Pobierz klienta<a id="krok-1--pobierz-klienta" href="#krok-1--pobierz-klienta" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Przechodzimy do katalogu <code>/root/.acme.sh/acme.sh</code> (tworzymy jeśli nie istnieje) i wykonujemy:</p>
<pre><code class="language-bash">git clone https://github.com/Neilpang/acme.sh
</code></pre>
<p>Jeśli nie mamy gita, pobieramy pliki ręcznie ze strony projektu i wypakowujemy.</p>
<h3>Krok 2 — Symlink dla wygody<a id="krok-2--symlink-dla-wygody" href="#krok-2--symlink-dla-wygody" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">ln -s /root/.acme.sh/ /etc/apache2/letsencrypt
</code></pre>
<h3>Krok 3 — Pobierz certyfikat<a id="krok-3--pobierz-certyfikat" href="#krok-3--pobierz-certyfikat" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Upewniamy się, że strona jest dostępna z internetu, następnie:</p>
<pre><code class="language-bash">./acme.sh issue /mnt/www/mojastrona.pl/ mojastrona.pl
</code></pre>
<p>Lub jeśli mamy aliasy (np. <code>www.mojastrona.pl</code>):</p>
<pre><code class="language-bash">./acme.sh issue /mnt/www/mojastrona.pl/ mojastrona.pl www.mojastrona.pl
</code></pre>
<p>acme.sh umieszcza tymczasowy plik w webroocie, Let's Encrypt go pobiera, żeby zweryfikować że kontrolujesz domenę, i wydaje certyfikat. Pliki zapisane zostaną w:</p>
<pre><code>/root/.acme.sh/mojastrona.pl/
</code></pre>
<h3>Krok 4 — Konfiguracja Apache<a id="krok-4--konfiguracja-apache" href="#krok-4--konfiguracja-apache" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Wskazujemy Apache na nowe certyfikaty w pliku virtual hosta:</p>
<pre><code class="language-apache">SSLCACertificateFile  /etc/apache2/letsencrypt/mojastrona.pl/ca.cer
SSLCertificateFile    /etc/apache2/letsencrypt/mojastrona.pl/mojastrona.pl.cer
SSLCertificateKeyFile /etc/apache2/letsencrypt/mojastrona.pl/mojastrona.pl.key
</code></pre>
<p>Następnie przeładowujemy Apache:</p>
<pre><code class="language-bash">service apache2 reload
</code></pre>
<p>Od tego momentu strona powinna serwować certyfikat Let's Encrypt.</p>
<h3>Krok 5 — Automatyczne odnawianie<a id="krok-5--automatyczne-odnawianie" href="#krok-5--automatyczne-odnawianie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Certyfikaty mają ważność 90 dni. Aby odnawiać automatycznie, tworzymy wykonywalny skrypt <code>acme_cron</code>:</p>
<pre><code class="language-sh">#!/bin/sh
/root/.acme.sh/acme.sh/acme.sh cron &gt;&gt; /var/log/le-renew.log
service apache2 reload
</code></pre>
<p>I przenosimy go do <code>/etc/cron.daily</code>.</p>
<p><img data-full="/media/lets-encrypt-howto/holas.pl-ssl.webp" src="/media/lets-encrypt-howto/holas.pl-ssl.webp" alt="holas.pl z certyfikatem Let's Encrypt" /></p>
<p>Proces nie jest skomplikowany — po pierwszej konfiguracji odnawianie przebiega w pełni automatycznie.</p>
<hr />
<p><em>Ta konfiguracja działała sprawnie, dopóki nie przeniosłem wszystkiego na Dockera. Nowoczesne podejście z Traefik obsługuje Let's Encrypt automatycznie — bez skryptów, bez crona, bez ręcznej konfiguracji. Więcej: <a href="/pl/wpisy/lets-encrypt-docker-traefik/">Let's Encrypt z Dockerem i Traefik</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/lets-encrypt-howto/letsencrypt-b2.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[bezpieczenstwo]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[ssl]]></category>
                    </item>
                <item>
            <title><![CDATA[Tymczasowy adres e-mail — zarejestruj się wszędzie bez śmiecenia w skrzynce]]></title>
            <link>https://holas.pl/pl/wpisy/tymczasowy-adres-e-mail/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/tymczasowy-adres-e-mail/</guid>
                        <pubDate>Mon, 15 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[Chcesz zajrzeć za ścianę rejestracyjną — pobrać PDF, obejrzeć demo, przeczytać wątek na forum. Konta tak naprawdę nie potrzebujesz, potrzebujesz tylko przejść przez weryfikację e-mail. Więc zakładasz kolejnego throwaway Gmaila, czekasz na link aktywacyjny i nigdy więcej tam nie zaglądasz. Jest prostsze wyjście: tymczasowe adresy e-mail — gotowe w kilka sekund, bez rejestracji, znikają po kilku god…]]></description>
            <content:encoded><![CDATA[<p>Chcesz zajrzeć za ścianę rejestracyjną — pobrać PDF, obejrzeć demo, przeczytać wątek na forum. Konta tak naprawdę nie potrzebujesz, potrzebujesz tylko przejść przez weryfikację e-mail. Więc zakładasz kolejnego throwaway Gmaila, czekasz na link aktywacyjny i nigdy więcej tam nie zaglądasz.</p>
<p>Jest prostsze wyjście: <strong>tymczasowe adresy e-mail</strong> — gotowe w kilka sekund, bez rejestracji, znikają po kilku godzinach.</p>
<h2>Guerrilla Mail<a id="guerrilla-mail" href="#guerrilla-mail" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://www.guerrillamail.com/">Guerrilla Mail</a> to najprostszy wybór. Wchodzisz na stronę, kopiujesz wygenerowany adres, używasz go gdzie trzeba. Poczta pojawia się od razu — bez odświeżania. Skrzynka działa w ramach sesji przeglądarki: zamkniesz kartę i adres znika.</p>
<p>Możesz też wysyłać wiadomości z tymczasowego adresu — przydaje się przy niektórych formularzach potwierdzających.</p>
<h2>Dropmail<a id="dropmail" href="#dropmail" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://dropmail.me/en/">Dropmail</a> oferuje więcej. To lepszy wybór, gdy adres ma przetrwać dłużej albo gdy zależy Ci na niezawodnym odbieraniu wiadomości.</p>
<p>Co wyróżnia Dropmail:</p>
<ul>
<li><strong>Przekierowanie na prawdziwy adres</strong> — maile trafiają do Twojej normalnej skrzynki, nie musisz trzymać otwartej karty z Dropmail. Przekierowanie możesz anulować w każdej chwili.</li>
<li><strong>Klucz odzyskiwania</strong> — zapisz go i możesz wrócić do tego samego adresu później, nawet z innego urządzenia lub sesji przeglądarki</li>
<li><strong>Dwa typy domen</strong> — domeny stałe dla długotrwałych adresów, rotujące dla jednorazowego użytku</li>
<li><strong>Bot na Telegrama</strong> — odbieraj maile bezpośrednio w Telegramie bez otwierania przeglądarki</li>
<li><strong>Aplikacja na Androida</strong> — zarządzaj tymczasowymi adresami z telefonu</li>
</ul>
<p>Żadna z powyższych funkcji nie wymaga rejestracji ani płatnego planu.</p>
<h2>Apple Hide My Email<a id="apple-hide-my-email" href="#apple-hide-my-email" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Jeśli korzystasz z urządzeń Apple i masz subskrypcję iCloud+ (dostępną w każdym płatnym planie iCloud od 50 GB), masz już wbudowaną opcję: <strong>Hide My Email</strong>.</p>
<p>Generuje unikalny, losowy adres, który przekierowuje pocztę na Twoją prawdziwą skrzynkę — na stałe, dopóki go nie dezaktywujesz. Żadnej osobnej aplikacji, żadnej strony do odwiedzania. Funkcja jest wbudowana bezpośrednio w iOS, iPadOS i macOS:</p>
<ul>
<li><strong>Autouzupełnianie w Safari</strong> — gdy wypełniasz pole e-mail na stronie, Safari proponuje automatyczne wygenerowanie ukrytego adresu</li>
<li><strong>Ustawienia → iCloud → Hide My Email</strong> — twórz adresy i zarządzaj nimi w jednym miejscu, sprawdzaj które są aktywne, dezaktywuj lub usuń dowolny z nich</li>
<li><strong>Zaloguj się przez Apple</strong> — gdy wybierasz opcję ukrycia adresu przy logowaniu przez Apple, Hide My Email generuje adres automatycznie</li>
</ul>
<p>Kluczowa różnica w stosunku do Guerrilla Mail i Dropmail: te adresy to stałe aliasy powiązane z Twoim Apple ID, nie jednorazowe skrzynki. Ty nimi zarządzasz, możesz je wyłączyć, a poczta zawsze trafia do Twojej prawdziwej skrzynki. To najbardziej bezproblemowa opcja jeśli jesteś w ekosystemie Apple — zero dodatkowych narzędzi, stałe i zarządzalne aliasy.</p>
<p>Ograniczenie: wymaga płatnego planu iCloud+ i działa tylko na urządzeniach Apple.</p>
<h2>Kiedy używać którego<a id="kiedy-używać-którego" href="#kiedy-używać-którego" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Używaj <strong>Guerrilla Mail</strong> gdy potrzebujesz tylko linku potwierdzającego i za dwie minuty zamkniesz kartę.</p>
<p>Używaj <strong>Dropmail</strong> gdy adres ma przeżyć zamknięcie przeglądarki albo gdy chcesz, żeby maile trafiały w miejsce, gdzie je faktycznie zobaczysz.</p>
<p>Używaj <strong>Apple Hide My Email</strong> gdy jesteś na iPhonie lub Macu, masz iCloud+, i chcesz generować adresy bez żadnego dodatkowego narzędzia — wbudowane w system, stałe i zarządzalne aliasy.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/temp-email/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[porady]]></category>
                                    <category><![CDATA[bezpieczenstwo]]></category>
                    </item>
                <item>
            <title><![CDATA[Alfa Consilium – doradztwo finansowe i ubezpieczeniowe]]></title>
            <link>https://holas.pl/pl/wpisy/alfa-consilium-financial-insurance-consulting/</link>
            <guid isPermaLink="true">https://holas.pl/pl/wpisy/alfa-consilium-financial-insurance-consulting/</guid>
                        <pubDate>Fri, 12 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[Serwis korporacyjny dla Alfa Consilium sp. z o.o. — licencjonowanego brokera ubezpieczeniowego i firmy doradztwa finansowego działającej pod nadzorem Komisji Nadzoru Finansowego. Projekt obejmował zarówno projekt graficzny, jak i wdrożenie WordPressa od zera. Co serwis miał robić Pośrednictwo ubezpieczeniowe to branża regulowana. Strona brokera to nie tylko broszura — musi komunikować status regul…]]></description>
            <content:encoded><![CDATA[<p>Serwis korporacyjny dla <a rel="nofollow noopener noreferrer" target="_blank" href="https://alfaconsilium.eu/">Alfa Consilium sp. z o.o.</a> — licencjonowanego brokera ubezpieczeniowego i firmy doradztwa finansowego działającej pod nadzorem Komisji Nadzoru Finansowego. Projekt obejmował zarówno projekt graficzny, jak i wdrożenie WordPressa od zera.</p>
<h2>Co serwis miał robić<a id="co-serwis-miał-robić" href="#co-serwis-miał-robić" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pośrednictwo ubezpieczeniowe to branża regulowana. Strona brokera to nie tylko broszura — musi komunikować status regulacyjny (numer licencji KNF, ubezpieczenie OC zawodowe), odróżniać firmę od agentów (broker reprezentuje klienta, nie ubezpieczyciela) i budować wystarczające zaufanie, żeby potencjalny klient korporacyjny zadzwonił.</p>
<p>Portfolio usług Alfa Consilium było szerokie: ubezpieczenia D&amp;O, cargo, OC spedytora i OC przewoźnika, ubezpieczenia maszyn, brokerstwo leasingowe, faktoring, zarządzanie wierzytelnościami i zarządzanie ryzykiem z wizytami audytowymi na miejscu. Każdy obszar usług wymagał czytelnego, samodzielnego opisu — nie ściany tekstu, ale treści wystarczającej, żeby decydent zrozumiał zakres i skontaktował się z zespołem.</p>
<h2>Projekt i wdrożenie<a id="projekt-i-wdrożenie" href="#projekt-i-wdrożenie" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Serwis został zaprojektowany w czystej estetyce korporacyjnej: ustrukturyzowany układ, czytelna nawigacja po usługach i dane kontaktowe dostępne z każdej podstrony. Klienci z sektora finansowego — firmy logistyczne, produkcyjne, floty — oczekują profesjonalnej prezentacji, nie startupowej landing page.</p>
<p>Wdrożenie na WordPressie z autorskim motywem PHP pisanym od podstaw — bez gotowego szablonu. Szablon obsługiwał trzy breakpointy responsywne: komputer, tablet i telefon. Responsywny design w 2016 roku wciąż nie był standardem dla małych polskich serwisów korporacyjnych; większość działała na stałej szerokości.</p>
<p>WordPress był właściwym wyborem dla klienta: zespół mógł aktualizować opisy usług i aktualności bez udziału dewelopera, a panel administracyjny nie wymagał żadnego szkolenia.</p>
<h2>Co zostało dostarczone<a id="co-zostało-dostarczone" href="#co-zostało-dostarczone" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>Pełny projekt graficzny (desktop + tablet + mobile)</li>
<li>Autorski motyw WordPress w PHP</li>
<li>Podstrony usług dla wszystkich linii ubezpieczeniowych i finansowych</li>
<li>Sekcja kontaktowa z informacjami regulacyjnymi (dane nadzoru KNF, OC zawodowe)</li>
<li>Responsywny układ na wszystkich trzech breakpointach</li>
</ul>
<p><img data-full="/media/alfa-consilium/alfaconsilium_screenshot.webp" src="/media/alfa-consilium/alfaconsilium_screenshot_crop.webp" alt="Serwis Alfa Consilium" />
<em>Zrzut ekranu z działającego serwisu.</em></p>
<p>Firma działa nadal — pierwotna domena .pl przekierowuje teraz na <strong><a rel="nofollow noopener noreferrer" target="_blank" href="https://alfaconsilium.eu/">alfaconsilium.eu</a></strong>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/alfa-consilium/ac.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[realizacje]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[wordpress]]></category>
                        <category><![CDATA[rwd]]></category>
                    </item>
            </channel>
</rss>
