<?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/</link>
        <description><![CDATA[Piece of web by Holas]]></description>
        <language>en</language>
        <atom:link href="https://holas.pl/feed/" rel="self" type="application/rss+xml"/>
                        <lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0000</lastBuildDate>
                        <item>
            <title><![CDATA[How notACMS Builds a Static Site — The Full Pipeline]]></title>
            <link>https://holas.pl/blog/how-notacms-builds-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/how-notacms-builds-static-site/</guid>
                        <pubDate>Fri, 17 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 6 of a series on preparing notACMS for open-source release. Part 5 covered the open-source release checklist. The original WordPress to Symfony series covers the migration itself. The command ddev build produces a complete static site in under a minute. But what actually happens? 10 distinct steps, each one a design decision. Here is the full pipeline — every command, every config fil…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 6 of a series on preparing notACMS for open-source release. <a href="/blog/open-sourcing-personal-project/">Part 5</a> covered the open-source release checklist. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>The command <code>ddev build</code> produces a complete static site in under a minute. But what actually happens? 10 distinct steps, each one a design decision. Here is the full pipeline — every command, every config file, every design choice.</p>
<h2>The 10-step pipeline<a id="the-10-step-pipeline" href="#the-10-step-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Step 1: Seed <code>local/</code><a id="step-1-seed-local" href="#step-1-seed-local" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Before anything compiles, the build ensures a working <code>local/</code> directory exists. If it is empty or missing, the build copies <code>docs/demo/</code> into it:</p>
<pre><code class="language-bash">cp -r docs/demo/. local/
</code></pre>
<p>If <code>local/</code> already has real content, the build skips this step. Passing <code>--bare</code> seeds from <code>docs/bare/</code> instead — a minimal wireframe theme with no content, useful for starting fresh. Passing <code>--demo</code> forces a re-seed even if content exists, backing up the old <code>local/</code> to <code>local-{TIMESTAMP}/</code> first.</p>
<p>The build also copies <code>assets/images/og-default.jpg</code> into <code>local/assets/images/</code> if it is missing — the fallback Open Graph image that renders when a page has no featured image.</p>
<h3>Step 2: Composer install<a id="step-2-composer-install" href="#step-2-composer-install" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">composer install --optimize-autoloader --no-interaction
</code></pre>
<p>Standard Symfony step. The <code>--optimize-autoloader</code> flag produces a classmap for faster autoloading — important because every sub-request in the build creates fresh service containers.</p>
<h3>Step 3: Clear dart-sass binary<a id="step-3-clear-dart-sass-binary" href="#step-3-clear-dart-sass-binary" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">rm -rf var/dart-sass
</code></pre>
<p>The <code>sass:build</code> command downloads an architecture-specific dart-sass binary. Clearing it before each build ensures the correct binary for the current platform. Essential for cross-platform workflows — developing on ARM64 (Raspberry Pi 5), running CI/CD on x86_64 (GitHub Actions), deploying to ARM64 again.</p>
<h3>Step 4: Cache clear<a id="step-4-cache-clear" href="#step-4-cache-clear" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console cache:clear
</code></pre>
<p>Produces the compiled Symfony dependency injection container. The warm cache improves sub-request performance during the static build.</p>
<h3>Step 5: SCSS compilation<a id="step-5-scss-compilation" href="#step-5-scss-compilation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console sass:build
</code></pre>
<p>Compiles <code>assets/styles/app.scss</code> into <code>var/sass/app.css</code>. The core SCSS import cascade, in order:</p>
<pre><code>_tokens.scss    ← CSS custom properties (--bg, --text, --accent, ...)
_variables.scss ← SCSS compile-time constants ($font, $sp-*, $radius-*, ...)
_base.scss      ← reset, box-sizing, body defaults
_layout.scss    ← .container, .content-layout, .site-header/footer
_nav.scss       ← navigation, hamburger, dropdown, .skip-link
_components.scss ← post-card, pagination, badges, buttons, alerts, widgets
_prose.scss     ← .prose class for rendered Markdown
_pages.scss     ← homepage sections, hero, CTA
_blog.scss      ← reading progress bar, code-copy button
_styleguide.scss ← sg-* scaffold classes
_utilities.scss ← atomic utility classes
</code></pre>
<p>Local themes override <code>_tokens.scss</code> and <code>_variables.scss</code> — everything else inherits the new values through the cascade. Change <code>--accent</code> from <code>#2563EB</code> to <code>#FFA040</code>, and every component, button, and link updates without touching component SCSS.</p>
<h3>Step 6: Asset-map compile<a id="step-6-asset-map-compile" href="#step-6-asset-map-compile" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">rm -rf public/assets
php bin/console asset-map:compile
</code></pre>
<p>First clears previously compiled assets, then compiles everything mapped by Symfony AssetMapper:</p>
<ul>
<li><code>assets/app.js</code> → <code>public/assets/app-{contenthash}.js</code></li>
<li><code>var/sass/app.css</code> → <code>public/assets/app-{contenthash}.css</code></li>
<li><code>local/assets/**/*</code> → <code>public/assets/local/**/*</code> (if <code>local/assets/</code> exists)</li>
</ul>
<p>The <code>{contenthash}</code> is a fingerprint derived from file contents. Changing one character in SCSS produces a completely different hash, busting CDN and browser cache automatically. No version query parameters — the filename itself is the version.</p>
<p>After compilation, the <code>sensiolabs_minify</code> bundle minifies all CSS and JS files. The <code>importmap.php</code> file defines which entrypoints exist:</p>
<pre><code class="language-php">return array_filter([
    'app' =&gt; [
        'path' =&gt; './assets/app.js',
        'entrypoint' =&gt; true,
    ],
    'app-local' =&gt; file_exists(__DIR__ . '/local/assets/app.js')
        ? [
            'path' =&gt; './local/assets/app.js',
            'entrypoint' =&gt; true,
          ]
        : null,
]);
</code></pre>
<p>Two entrypoints: <code>app</code> (always — core CSS + JS) and <code>app-local</code> (conditional — only if <code>local/assets/app.js</code> exists). Both are <code>entrypoint: true</code>, so Twig's <code>{{ importmap('app') }}</code> and <code>{{ importmap('app-local') }}</code> load them independently. <code>app-local</code> loads after <code>app</code> in the template, giving local CSS the last word on specificity ties without <code>!important</code>.</p>
<h3>Step 7: Static HTML build<a id="step-7-static-html-build" href="#step-7-static-html-build" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">php bin/console app:build -v
</code></pre>
<p>This is the core magic. The <code>-v</code> flag enables verbose output, showing every rendered URL. Five sub-steps execute in sequence:</p>
<p><strong>7a. Invalidate content cache</strong></p>
<p>The build clears the <code>app.content</code> cache pool for every configured locale. The cache stores parsed <code>ContentTree</code> objects — invalidating it forces a full re-parse of all Markdown files, ensuring the static build reflects the current content state. In development, the cache uses an in-memory array adapter. In production builds, it uses a filesystem adapter under <code>var/cache/</code>.</p>
<p><strong>7b. Collect routes</strong></p>
<p>The <code>collectRoutes()</code> method gathers every URL the site serves. For each locale:</p>
<ul>
<li>Home page (<code>home_{locale}</code>)</li>
<li>Blog listing (<code>blog_list_{locale}</code>)</li>
<li>Paginated blog pages (pages 2 through <code>ceil(postCount / postsPerPage)</code>)</li>
<li>Every published post URL (<code>$post-&gt;url()</code>)</li>
<li>Every scheduled post URL (renders a &quot;coming soon&quot; page with <code>noindex</code>)</li>
<li>Every category page (<code>blog_category_{locale}</code>)</li>
<li>Every tag page (<code>blog_tag_{locale}</code>)</li>
<li>Every archive month (<code>blog_archive_{locale}</code> with year + month parameters)</li>
<li>Every archive year (<code>blog_archive_year_{locale}</code> — a separate route from months)</li>
<li>Every static page (non-dynamic, non-empty URL, not the home URL)</li>
<li>Search page (<code>search_{locale}</code>)</li>
</ul>
<p>The result is deduplicated with <code>array_unique()</code>. For a site with 4 locales and ~40 content items per locale, this produces around 500 URLs including pagination, archives, tags, and feeds.</p>
<p><strong>7c. Render pages</strong></p>
<p>Each URL is rendered via a Symfony sub-request:</p>
<pre><code class="language-php">$request = Request::create($url, Request::METHOD_GET);
$request-&gt;attributes-&gt;set('_static_build', true);

$response = $this-&gt;httpKernel-&gt;handle(
    $request,
    HttpKernelInterface::SUB_REQUEST,
    false,
);
</code></pre>
<p>The <code>_static_build</code> attribute signals to controllers, services, and templates that they are running inside a build, not serving a real HTTP request. Themes and local extensions can branch on it to vary output between live serving and the static build. (Draft/scheduled visibility itself is decided by the absence of the dev-only preview session toggles — the CLI has no session, so the build always renders the published view.)</p>
<p>The response body is written to disk as <code>{outputDir}/{url}/index.html</code>. The root URL <code>/</code> becomes <code>index.html</code> at the output root. Any URL returning HTTP 400 or higher is caught as an error and skipped (the page is counted as skipped but the build continues).</p>
<p>After rendering all content pages, the build renders feed URLs:</p>
<ul>
<li><code>robots_{locale}</code> — written as <code>{outputDir}/robots.txt</code></li>
<li><code>sitemap_{locale}</code> — written as <code>{outputDir}/sitemap.xml</code> (or <code>index.xml</code> for slash-ending URLs)</li>
<li><code>rss_{locale}</code> — written as <code>{outputDir}/rss.xml</code> (or <code>index.xml</code>)</li>
</ul>
<p>Then error pages are rendered via sub-request:</p>
<ul>
<li><code>error_404_{locale}</code> → <code>{prefix}404.html</code></li>
<li><code>error_500_{locale}</code> → <code>{prefix}500.html</code></li>
</ul>
<p>Where <code>{prefix}</code> is the non-default locale path (e.g. <code>pl/404.html</code>). These are pre-rendered so nginx can serve them directly without waking PHP-FPM.</p>
<p>The sub-request approach matters for correctness: the same controllers and Twig templates used in development mode produce the static output. What you see at <code>ddev start</code> is what ships to production. No separate rendering code path. No &quot;dev mode vs build mode&quot; template discrepancies.</p>
<p><strong>7d. Copy media files</strong></p>
<p>Symfony Finder locates every <code>files/</code> directory under the content tree and mirrors them into the output:</p>
<pre><code>{contentDir}/{postDir}/files/  →  {outputDir}/media/{postDir}/
</code></pre>
<p>This is a brute-force mirror — no hashing, no deduplication, no tree-shaking. Content authors control what is in <code>files/</code>. Delete a file there, it disappears from the next build. The simplicity is intentional: no asset graph, no build manifest, no reference counting.</p>
<p><strong>7e. Optimise originals and generate responsive variants</strong></p>
<p>Two ImageMagick passes run on the mirrored media directory:</p>
<p>Optimisation on every <code>.webp</code> file (excluding existing variants identified by the <code>-{width}w</code> suffix pattern):</p>
<pre><code class="language-php">$this-&gt;imageResizer-&gt;optimize($path);
// → exec('magick convert input.webp -quality 82 -strip -define webp:method=6 output.webp')
</code></pre>
<p>Quality defaults to 82, configurable in <code>_site.yaml</code>. The <code>-strip</code> flag removes metadata (EXIF, ICC profiles). The <code>webp:method=6</code> flag uses the slowest but most efficient WebP encoder.</p>
<p>Responsive variant generation for every <code>.webp</code> original:</p>
<pre><code class="language-php">$width = getimagesize($path)[0];
$variantWidths = $this-&gt;responsiveImageService-&gt;getVariantWidths($width);
// [640, 960] filtered from config — only widths &lt; source width

foreach ($variantWidths as $variantWidth) {
    $this-&gt;imageResizer-&gt;resize($path, $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp', $variantWidth);
}
</code></pre>
<p>For a 1280px source image with configured variant widths of <code>[640, 960]</code>, this produces <code>image-640w.webp</code> and <code>image-960w.webp</code>. The original <code>image.webp</code> serves as the 1280w fallback.</p>
<h3>Step 8: Copy favicon<a id="step-8-copy-favicon" href="#step-8-copy-favicon" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">cp local/assets/favicon.ico public/
</code></pre>
<p>If <code>local/assets/favicon.ico</code> does not exist, falls back to <code>assets/images/favicon.ico</code> from core. The <code>favicons</code> Twig block in <code>base.html.twig</code> can be overridden in local templates to reference different icon paths.</p>
<h3>Step 9: Pagefind index<a id="step-9-pagefind-index" href="#step-9-pagefind-index" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Pagefind scans the pre-rendered HTML for <code>data-pagefind-body</code> regions, builds per-language WASM binary search indexes, and writes ~437 files into <code>public/pagefind/</code>. This step runs separately from the PHP build because it requires Node.js. The PHP layer has no Node dependency — keeping Pagefind as a separate orchestration step means the entire PHP application works without <code>node_modules</code>.</p>
<p>The production deploy script (<code>scripts/rebuild-content.sh</code>) pins Pagefind to version <code>@1.5.0</code>:</p>
<pre><code class="language-bash">docker compose run --rm php npx --yes pagefind@1.5.0 --site public/static --output-path public/pagefind
</code></pre>
<p>Later Pagefind versions ship a jemalloc-linked ARM64 binary that crashes on hosts with 16K-page kernels (Raspberry Pi 5). The error is <code>&quot;&lt;jemalloc&gt;: Unsupported system page size&quot;</code>. Pinning to 1.5.0 avoids this. The DDEV-local <code>build</code> command does not pin — it runs on x86_64 where all versions work correctly.</p>
<h3>Step 10: Nginx serves it<a id="step-10-nginx-serves-it" href="#step-10-nginx-serves-it" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>After all steps complete, <code>public/</code> contains three directories:</p>
<pre><code>public/
├── assets/        ← fingerprinted JS/CSS (immutable, long cache)
├── static/        ← all HTML + RSS + sitemap + media/
└── pagefind/      ← search index (WASM + metadata)
</code></pre>
<p>Nginx <code>try_files</code> resolves every incoming request to a pre-rendered file:</p>
<pre><code class="language-nginx">location / {
    try_files /static$uri/index.html /static$uri /static$uri.html =404;
}
</code></pre>
<p>PHP-FPM is only called for the contact form endpoint:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass php:9000;
}
</code></pre>
<p>The entire site except the contact form is static files on disk. No PHP runtime. No database. No application server.</p>
<h2>Pipeline summary<a id="pipeline-summary" href="#pipeline-summary" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>Command</th>
<th>Produces</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. Seed local/</td>
<td><code>cp -r docs/demo/. local/</code></td>
<td>Working site config</td>
</tr>
<tr>
<td>2. Composer</td>
<td><code>composer install --optimize-autoloader</code></td>
<td>Vendor autoload</td>
</tr>
<tr>
<td>3. Clear dart-sass</td>
<td><code>rm -rf var/dart-sass</code></td>
<td>Clean build state</td>
</tr>
<tr>
<td>4. Cache clear</td>
<td><code>cache:clear</code></td>
<td>Compiled DI container</td>
</tr>
<tr>
<td>5. SCSS</td>
<td><code>sass:build</code></td>
<td><code>var/sass/app.css</code></td>
</tr>
<tr>
<td>6. Asset compile</td>
<td><code>asset-map:compile</code></td>
<td><code>public/assets/app-{hash}.css</code></td>
</tr>
<tr>
<td>7. Static build</td>
<td><code>app:build -v</code></td>
<td><code>public/static/*/index.html</code></td>
</tr>
<tr>
<td>8. Favicon</td>
<td><code>cp</code></td>
<td><code>public/favicon.ico</code></td>
</tr>
<tr>
<td>9. Pagefind</td>
<td><code>npx pagefind</code></td>
<td><code>public/pagefind/</code></td>
</tr>
<tr>
<td>10. Nginx</td>
<td><code>try_files</code></td>
<td>Serves everything</td>
</tr>
</tbody>
</table>
<h2>Design decisions<a id="design-decisions" href="#design-decisions" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>Why sub-requests?<a id="why-sub-requests" href="#why-sub-requests" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Sub-requests reuse the same controllers and Twig templates used in development mode. No separate rendering code path. What you see in <code>ddev start</code> is what you get in production. The <code>_static_build</code> attribute is the only signal that differs — everything else runs identically. This eliminates the most common static site generator bug: dev mode rendering differently from build output.</p>
<h3>Why separate SCSS and asset-map steps?<a id="why-separate-scss-and-asset-map-steps" href="#why-separate-scss-and-asset-map-steps" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>sass:build</code> produces raw CSS — useful in development watch mode where you recompile on file changes but do not want to re-fingerprint on every save. <code>asset-map:compile</code> fingerprints and minifies — a production-only concern. Separation means these two concerns stay independent.</p>
<h3>Why brute-copy media?<a id="why-brute-copy-media" href="#why-brute-copy-media" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Content authors control what is in <code>files/</code>. No build manifest. No tree-shaking. No asset graph. Delete from <code>files/</code>, gone from the build. The simplicity is worth the disk space — and disk space for WebP images on a static site is trivial.</p>
<h3>Why ImageMagick CLI, not GD or Imagick?<a id="why-imagemagick-cli-not-gd-or-imagick" href="#why-imagemagick-cli-not-gd-or-imagick" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">exec('magick convert ...')
</code></pre>
<p>GD cannot reliably handle all WebP variants (lossless, animation, alpha). Imagick has PHP extension compatibility issues across PHP versions. ImageMagick CLI is always available in the Docker container and is the most portable option across architectures.</p>
<h3>Why Pagefind as a separate step?<a id="why-pagefind-as-a-separate-step" href="#why-pagefind-as-a-separate-step" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Node.js dependency. The PHP build does not need Node for anything else. Keeping Pagefind as a separate orchestration step means the entire PHP layer works without <code>node_modules</code> — a significant simplification for deployment and development setup.</p>
<h2>Performance<a id="performance" href="#performance" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The full pipeline runs in under 60 seconds for a ~170-page site (4 locales, ~40 content items each). The bottlenecks:</p>
<ul>
<li><strong>ImageMagick</strong> — 100+ image optimisations and variant generations. Currently sequential — a single-threaded PHP loop through every file. Parallelising this (multiple <code>exec()</code> calls) would be the biggest single improvement.</li>
<li><strong>Sub-request rendering</strong> — ~1ms per request, ~500 total including pagination. Symfony's compiled container makes this fast. The main cost is Twig template compilation, not I/O.</li>
<li><strong>Pagefind</strong> — 2–5 seconds, dominated by WASM compilation from Rust source, not HTML parsing. Scales linearly with page count.</li>
</ul>
<h2>Where to customise<a id="where-to-customise" href="#where-to-customise" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The pipeline has five deliberate injection points for local overrides:</p>
<ul>
<li><strong><code>local/assets/styles/</code></strong> — override SCSS partials (<code>_tokens.scss</code>, <code>_variables.scss</code>)</li>
<li><strong><code>local/assets/</code></strong> — add custom JS (becomes <code>app-local</code> entrypoint when <code>app.js</code> exists)</li>
<li><strong><code>local/templates/</code></strong> — override any Twig template (falls through to core via namespace priority)</li>
<li><strong><code>local/content/_site.yaml</code></strong> — change variant widths, image quality, Magick flags, post counts</li>
<li><strong><code>local/src/</code></strong> — add custom Twig extensions or service decorators (auto-discovered by Symfony)</li>
</ul>
<p>No config files to edit. No registration step. No framework plugin system. Copy a file into <code>local/</code>, edit it, build. That is the whole model.</p>
<hr />
<p><a href="/blog/building-notacms-theme/">Part 7</a> walks through building a complete production theme on top of this pipeline — Twig blocks, SCSS tokens, custom components, local JS, and service decoration. <a href="/blog/pagefind-static-search/">Part 8</a> dives deep into Pagefind: the two search UIs, the <code>data-pagefind-*</code> contract, production deployment, and the ARM64 version-pinning war story.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-17-how-notacms-builds-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[static-site]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[architecture]]></category>
                        <category><![CDATA[dev-tools]]></category>
                    </item>
                <item>
            <title><![CDATA[Open-Sourcing notACMS — The Complete Checklist]]></title>
            <link>https://holas.pl/blog/open-sourcing-personal-project/</link>
            <guid isPermaLink="true">https://holas.pl/blog/open-sourcing-personal-project/</guid>
                        <pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 5 of a series on preparing notACMS for open-source release. Part 4 covers the local override pattern. The original WordPress to Symfony series covers the migration itself. I spent a month preparing my personal website's codebase for open-source release. Not because I expected thousands of contributors — but because going public forced me to write the docs, add the tests, fix the secur…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 5 of a series on preparing notACMS for open-source release. <a href="/blog/local-override-pattern/">Part 4</a> covers the local override pattern. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>I spent a month preparing my personal website's codebase for open-source release. Not because I expected thousands of contributors — but because going public forced me to write the docs, add the tests, fix the security issues, and make the architecture actually reusable. The code that's good enough to show strangers is better code than the code that isn't.</p>
<p>Here's the complete checklist.</p>
<h2>Why Open-Source a Personal Project?<a id="why-open-source-a-personal-project" href="#why-open-source-a-personal-project" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Accountability — code you'll show the world is better code. Documentation discipline — you write docs when strangers might read them. And the ultimate refactoring exercise: would you be proud to show this code?</p>
<p>notACMS is a Symfony-based static site generator with no database, no CMS, and no PHP involved in serving content, licensed under Apache 2.0. If someone else can use it, that validates the architecture.</p>
<h2>The Audit: 9.5/10 Readiness Score<a id="the-audit-9510-readiness-score" href="#the-audit-9510-readiness-score" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<table>
<thead>
<tr>
<th>Category</th>
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>LICENSE (Apache 2.0)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>README</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Documentation (5 docs, 1,227 lines)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Rector Config</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Code Quality Tools (CS Fixer, PHPStan, Rector, Twig lint)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Contributing Guidelines</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Security Policy</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>CI/CD Workflows</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Issue/PR Templates</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>Tests (368 tests, ~80% coverage)</td>
<td>8/10</td>
<td>✅</td>
</tr>
<tr>
<td>Hardcoded Secrets (documented in .env)</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td>CHANGELOG</td>
<td>10/10</td>
<td>✅</td>
</tr>
<tr>
<td><strong>Overall</strong></td>
<td><strong>9.5/10</strong></td>
<td><strong>Ready</strong></td>
</tr>
</tbody>
</table>
<h2>What Was Missing (and How I Fixed It)<a id="what-was-missing-and-how-i-fixed-it" href="#what-was-missing-and-how-i-fixed-it" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>GitHub Actions CI/CD</strong> — <code>.github/workflows/ci.yml</code>: PHP 8.5, composer install, validate, audit, Twig lint, Rector dry-run, CS Fixer, PHPStan, PHPUnit. Nothing ships unless all checks pass.</p>
<p><strong>CONTRIBUTING.md</strong> — DDEV setup, code style standards, PR process, commit conventions. The barrier to contribution should be one command: <code>ddev start</code>.</p>
<p><strong>SECURITY.md</strong> — Supported versions, vulnerability reporting (private disclosure), security update policy. Even for a one-person project, having a defined process matters.</p>
<p><strong>CHANGELOG.md</strong> — Keep a Changelog 1.1.0 format, initial release entry. Future releases will track what changed.</p>
<p><strong>Issue/PR templates</strong> — Bug report, feature request, PR guidelines. Good templates reduce the cognitive load for both the reporter and the maintainer.</p>
<p><strong>CODEOWNERS</strong> — Code ownership tracking. It's one person, but it's good practice and signals that the project is maintained.</p>
<p><strong>Test suite</strong> — 368 tests across Unit and Integration, covered in <a href="/blog/368-tests-static-site-generator/">part 1</a> of this series.</p>
<h2>Rector as Automated Refactoring<a id="rector-as-automated-refactoring" href="#rector-as-automated-refactoring" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Rector uses <code>withPreparedSets(codeQuality: true, codingStyle: true, deadCode: true, typeDeclarations: true, symfonyCodeQuality: true)</code> plus <code>withPhpSets(php85: true)</code>. Dry-run in CI, <code>--fix</code> in <code>ddev code-fix</code>. What it catches: redundant null checks, array simplification, modern PHP 8.5 syntax, dead code, type declarations. What it skips: <code>ControllerMethodInjectionToConstructorRector</code> — the ErrorController needs runtime values that can't be injected via constructor.</p>
<h2>What I Skipped (and Why)<a id="what-i-skipped-and-why" href="#what-i-skipped-and-why" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Code of Conduct</strong> — It's a one-person project. No community to govern. If that changes, this changes.</p>
<p><strong>Third-party license notices</strong> — MIT dependencies don't require attribution in source. Maybe later if the project grows.</p>
<p><strong>100% test coverage</strong> — The remaining ~20% is email sending, media copy paths, error controller <code>__invoke</code>. High complexity to trigger, low ROI. 80% covers all public APIs, main code paths, and edge cases.</p>
<h2>The Deploy Pipeline<a id="the-deploy-pipeline" href="#the-deploy-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">ddev code-check   # Composer validate + audit, Twig lint, Rector, CS Fixer, PHPStan
ddev test         # 368 tests, 490 assertions
./deploy.sh --prod
</code></pre>
<p>The quality gate: nothing ships unless all checks pass. The deploy script runs inside the production PHP container where the correct CPU architecture is known. After the build, nginx is already serving the new static files.</p>
<h2>The Result: notACMS<a id="the-result-notacms" href="#the-result-notacms" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A Symfony-based static site generator, Apache 2.0 licensed, with docs, tests, CI, and a local override pattern. Not a framework — a project template you can clone, customise via <code>local/</code>, and deploy. The entire codebase, content, and configuration fits in a single git repository. Backup is <code>git push</code>.</p>
<p>This series started with <a href="/blog/368-tests-static-site-generator/">368 tests</a>, went through an <a href="/blog/79-bugs-ai-code-review/">AI code review</a>, <a href="/blog/security-audit-static-site/">security fixes</a>, and the <a href="/blog/local-override-pattern/">local override pattern</a>. After shipping, the next posts cover the <a href="/blog/how-notacms-builds-static-site/">build pipeline internals</a>, <a href="/blog/building-notacms-theme/">building a production theme</a>, and <a href="/blog/pagefind-static-search/">search with Pagefind</a>. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> tells the story of how this codebase came to exist.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-10-open-sourcing-personal-project/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[open-source]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[The Local Override Pattern — Symfony Templates Without Forking]]></title>
            <link>https://holas.pl/blog/local-override-pattern/</link>
            <guid isPermaLink="true">https://holas.pl/blog/local-override-pattern/</guid>
                        <pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 4 of a series on preparing notACMS for open-source release. Part 3 covers security vulnerabilities. The original WordPress to Symfony series covers the migration itself. You want to share a Symfony project template. Every user needs to customise it — different colours, different homepage, different navigation. Forking means they can't pull upstream updates. Configuration files can't h…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 4 of a series on preparing notACMS for open-source release. <a href="/blog/security-audit-static-site/">Part 3</a> covers security vulnerabilities. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>You want to share a Symfony project template. Every user needs to customise it — different colours, different homepage, different navigation. Forking means they can't pull upstream updates. Configuration files can't handle template changes. Themes are too rigid.</p>
<p>The solution I built for notACMS is a <code>local/</code> directory that merges on top of the base project at build time. Users customise <code>local/</code>, the rest stays untouched, and when the upstream changes they pull and merge like any other git operation.</p>
<h2>The Problem<a id="the-problem" href="#the-problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Three scenarios that configuration files can't solve:</p>
<ul>
<li>User A wants different CSS colours and a custom homepage layout</li>
<li>User B wants to override just the navigation component</li>
<li>User C wants to add their own translation strings</li>
</ul>
<p>Forking is the traditional answer. But forking means every upstream update is a manual merge. For a project that gets regular improvements, that's a maintenance burden that kills adoption.</p>
<h2>The Solution: A Merge Layer<a id="the-solution-a-merge-layer" href="#the-solution-a-merge-layer" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>local/</code> sits alongside the base project. At build time, paths resolve with priority: <code>local/</code> first, base second. Three override types:</p>
<p><strong>Full override</strong> — Replace an entire file. <code>local/templates/base.html.twig</code> replaces <code>templates/base.html.twig</code> completely.</p>
<p><strong>Block override</strong> — Extend and override specific Twig blocks:</p>
<pre><code class="language-twig">{% extends 'base.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    &lt;link rel=&quot;stylesheet&quot; href=&quot;{{ asset('styles/custom.css') }}&quot;&gt;
{% endblock %}
</code></pre>
<p><strong>Translation override</strong> — <code>local/translations/messages.en.yaml</code> merges with base translations, adding or replacing keys:</p>
<pre><code class="language-yaml"># local/translations/messages.en.yaml
site.title: &quot;My Custom Site&quot;
nav.home: &quot;Home&quot;
</code></pre>
<h2>CSS Ordering: Two-Entrypoint Importmap<a id="css-ordering-two-entrypoint-importmap" href="#css-ordering-two-entrypoint-importmap" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The trickiest part. Local styles must load <em>after</em> base styles to override them via cascade:</p>
<pre><code class="language-php">// importmap.php
-&gt;add('app', 'assets/app.js')           // base: imports app.scss
-&gt;add('local', 'assets/local/app.js')   // local: imports local.scss
</code></pre>
<p><code>local.scss</code> is imported after <code>app.scss</code>, so CSS cascade works correctly. No <code>!important</code> needed. The import map registers both entrypoints; the browser loads them in order.</p>
<h2>Template Resolution<a id="template-resolution" href="#template-resolution" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>Kernel::build()</code> checks for <code>local/templates/</code> files and copies them to the output, overriding base templates. The <code>local/</code> directory is gitignored in the user's project, but <code>.gitkeep</code> placeholders ensure fresh clones have the structure:</p>
<pre><code>local/
├── assets/
│   └── styles/
│       └── .gitkeep
├── templates/
│   └── .gitkeep
└── translations/
    └── .gitkeep
</code></pre>
<h2>Boilerplates in docs/examples/<a id="boilerplates-in-docsexamples" href="#boilerplates-in-docsexamples" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Copy-paste starting points for different customisation levels:</p>
<table>
<thead>
<tr>
<th>Boilerplate</th>
<th>Customisation level</th>
<th>Use when</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>starter-extend/</code></td>
<td>Lightest</td>
<td>You want to extend base with minor tweaks</td>
</tr>
<tr>
<td><code>block-override/</code></td>
<td>Medium</td>
<td>You want to replace specific components</td>
</tr>
<tr>
<td><code>full-override/</code></td>
<td>Full</td>
<td>You want complete control over the layout</td>
</tr>
<tr>
<td><code>material-cards/</code></td>
<td>Theme</td>
<td>Complete dark theme example with local.scss</td>
</tr>
<tr>
<td><code>translation-override/</code></td>
<td>Strings</td>
<td>Custom translation strings only</td>
</tr>
</tbody>
</table>
<h2>What's Tracked in Git<a id="whats-tracked-in-git" href="#whats-tracked-in-git" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>.gitkeep</code> files in <code>local/</code> placeholder dirs so the structure exists in fresh clones. The actual override files are gitignored — they're the user's customisations. <code>docs/examples/</code> contains the copy-paste templates that users start from.</p>
<h2>Why Not a Framework?<a id="why-not-a-framework" href="#why-not-a-framework" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>notACMS isn't a framework. Users don't <code>composer install notacms/core</code>. They clone the repo, customise <code>local/</code>, and deploy. The pattern is simple enough to understand in 5 minutes, powerful enough to handle any customisation. The entire customisation surface is one directory.</p>
<p>The <a href="/blog/open-sourcing-personal-project/">next post</a> covers the full open-source release checklist — everything from the previous four posts leading to the final launch.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-07-03-local-override-pattern/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[architecture]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[XSS, Open Redirects, and Path Traversal on a 'Static' Site]]></title>
            <link>https://holas.pl/blog/security-audit-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/security-audit-static-site/</guid>
                        <pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 3 of a series on preparing notACMS for open-source release. Part 2 covers the AI code review. The original WordPress to Symfony series covers the migration itself. &quot;Static sites are secure.&quot; It's the most common thing people say when you tell them you replaced WordPress with static HTML. And it's true — as long as &quot;static&quot; means &quot;no database and no user accoun…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 3 of a series on preparing notACMS for open-source release. <a href="/blog/79-bugs-ai-code-review/">Part 2</a> covers the AI code review. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>&quot;Static sites are secure.&quot; It's the most common thing people say when you tell them you replaced WordPress with static HTML. And it's true — as long as &quot;static&quot; means &quot;no database and no user accounts.&quot; The moment a static site has a contact form, a search function, or any JavaScript that renders user content, the attack surface isn't zero. It's just different.</p>
<p>Here are the vulnerabilities I found in mine.</p>
<h2>XSS in search.js — and why the obvious fix was wrong<a id="xss-in-searchjs--and-why-the-obvious-fix-was-wrong" href="#xss-in-searchjs--and-why-the-obvious-fix-was-wrong" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Pagefind returns search results as JSON. The JavaScript renders them with <code>innerHTML</code>. The initial instinct after a code review was to escape the excerpt:</p>
<pre><code class="language-javascript">// &quot;Fixed&quot; in 1.1.2
'&lt;p class=&quot;post-card-excerpt&quot;&gt;' + esc(r.excerpt) + '&lt;/p&gt;'
</code></pre>
<p>It passed review. Shipped. Then someone searched for something and noticed the highlighted match wasn't bold anymore — it showed as literal text:</p>
<pre><code>…results for &lt;mark&gt;notacms&lt;/mark&gt; in…
</code></pre>
<p>Pagefind injects <code>&lt;mark&gt;</code> tags into excerpts at query time to highlight matched terms. <code>esc()</code> escaped those tags along with everything else. The &quot;security fix&quot; silently broke search highlighting.</p>
<p>The revert:</p>
<pre><code class="language-javascript">// Current
'&lt;p class=&quot;post-card-excerpt&quot;&gt;' + r.excerpt + '&lt;/p&gt;'
</code></pre>
<p>This isn't leaving a vulnerability in place. <code>r.excerpt</code> isn't user input — it's generated by pagefind from your own pre-indexed static HTML. The only HTML it contains is the <code>&lt;mark&gt;</code> tags pagefind itself injects. The real rule isn't &quot;always escape before <code>innerHTML</code>&quot; — it's <strong>know who controls the string</strong>. Every other field (<code>r.url</code>, <code>r.meta.title</code>, <code>r.meta.category</code>, tags) is escaped, because those come from frontmatter that could in theory contain anything.</p>
<p>The naive escape was worse than no fix: it introduced a regression, and gave a false sense that the XSS surface had been addressed.</p>
<h2>Open Redirect via Referer Header<a id="open-redirect-via-referer-header" href="#open-redirect-via-referer-header" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The draft and scheduled preview controllers toggle visibility and redirect back:</p>
<pre><code class="language-php">// Vulnerable (pre-1.1.x)
return $this-&gt;redirect($request-&gt;headers-&gt;get('referer', '/'));
</code></pre>
<p>The <code>Referer</code> header is user-controlled. An attacker can craft a link that takes a user through the preview toggle and then redirects them to an arbitrary external site. The 1.1.x fix validated that the redirect target is a local path:</p>
<pre><code class="language-php">// Fixed in 1.1.x
$referer = $request-&gt;headers-&gt;get('referer', '/');
if ($referer &amp;&amp; str_starts_with($referer, '/') &amp;&amp; !str_starts_with($referer, '//')) {
    return $this-&gt;redirect($referer);
}
return $this-&gt;redirect('/');
</code></pre>
<p>1.2.0 tightened this further. The <code>str_starts_with</code> check operates on the raw header string — defensible, but fragile against malformed or unusual inputs. The improved version uses <code>parse_url()</code> to extract host and path separately, then validates each component explicitly, and redirects to the path only (never the full header value):</p>
<pre><code class="language-php">// Improved in 1.2.0
$referer = (string) $request-&gt;headers-&gt;get('referer', '');
$refererParts = parse_url($referer);
$refererHost = is_array($refererParts) ? ($refererParts['host'] ?? null) : null;
$refererPath = is_array($refererParts) ? ($refererParts['path'] ?? '') : '';

if (
    (null === $refererHost || $request-&gt;getHost() === $refererHost)
    &amp;&amp; str_starts_with($refererPath, '/')
    &amp;&amp; !str_starts_with($refererPath, '//')
) {
    return $this-&gt;redirect($refererPath);
}
return $this-&gt;redirect('/');
</code></pre>
<h2>Path Traversal in MediaController<a id="path-traversal-in-mediacontroller" href="#path-traversal-in-mediacontroller" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The variant cache serves resized images. The variant filename comes from the URL:</p>
<pre><code class="language-php">$variantPath = $this-&gt;cacheDir . '/' . $variantFilename;
</code></pre>
<p>Without a <code>realpath()</code> guard, a crafted URL like <code>../../etc/passwd</code> could escape the cache directory. The fix is a single check:</p>
<pre><code class="language-php">$realCacheDir = realpath($this-&gt;cacheDir);
if (false === $realCacheDir || !str_starts_with(dirname($variantPath), $realCacheDir)) {
    throw new NotFoundHttpException('Invalid variant path');
}
</code></pre>
<h2>CSRF on Pre-rendered Contact Forms<a id="csrf-on-pre-rendered-contact-forms" href="#csrf-on-pre-rendered-contact-forms" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This one is architectural, not a bug. Symfony's CSRF tokens are tied to the user's session. But static pages don't have sessions — the HTML is generated once at build time. Every visitor would get the same token, baked into the static file, which can't validate against their own session.</p>
<p>The real defenses are different:</p>
<ol>
<li><strong>Turnstile CAPTCHA</strong> — replaces the bot-prevention layer with server-side verification</li>
<li><strong><code>X-Requested-With: XMLHttpRequest</code></strong> — blocks simple form submissions from non-JS clients</li>
<li><strong>nginx restriction</strong> — PHP-FPM only reachable at <code>^/(api|pl/api)/</code></li>
</ol>
<h2>Cookie Secure Flag — the flag that breaks dev silently<a id="cookie-secure-flag--the-flag-that-breaks-dev-silently" href="#cookie-secure-flag--the-flag-that-breaks-dev-silently" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The locale redirect sets a <code>lang</code> cookie client-side. <code>; Secure</code> was in the code from day one:</p>
<pre><code class="language-javascript">document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; Secure';
</code></pre>
<p>Then during development on DDEV over HTTP, the locale redirect stopped working. Clicking the language switcher did nothing. The cookie just wasn't there.</p>
<p><code>; Secure</code> tells the browser to only set the cookie on HTTPS origins. On HTTP it silently does nothing — no error, no warning, no console message. The locale mechanism was completely non-functional on any HTTP origin.</p>
<p>The fix is to make it conditional:</p>
<pre><code class="language-javascript">var secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = COOKIE + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax' + secure;
</code></pre>
<p>Production is always HTTPS, so <code>; Secure</code> is always added there. Dev over HTTP gets a working cookie without it. The cookie banner uses unconditional <code>; Secure</code> — consent doesn't need to persist on HTTP dev, so that one is fine as-is.</p>
<p>The lesson isn't &quot;remember to add <code>; Secure</code>.&quot; It's that security flags which silently fail are a class of their own. You add them, feel good, and don't find out they broke something until you're deep into debugging an unrelated problem.</p>
<h2>What &quot;Static&quot; Actually Protects You From<a id="what-static-actually-protects-you-from" href="#what-static-actually-protects-you-from" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>SQL injection — no database. Server-side template injection — templates are compiled at build time. File upload attacks — no uploads. Brute-force login attacks — no login page.</p>
<p>What it doesn't protect you from: client-side XSS, open redirects, path traversal in dynamic endpoints, CSRF on forms, missing security headers, JavaScript that trusts untrusted data.</p>
<h2>The Contact Form: The Only Dynamic Endpoint Is the Most Attacked<a id="the-contact-form-the-only-dynamic-endpoint-is-the-most-attacked" href="#the-contact-form-the-only-dynamic-endpoint-is-the-most-attacked" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>nginx restricts PHP-FPM to a single pattern:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
}
</code></pre>
<p>Everything else is served from disk. The contact form has Turnstile verification, a tight CSP that only permits <code>challenges.cloudflare.com</code> as an external domain, and no <code>'unsafe-inline'</code> or <code>'unsafe-eval'</code> in <code>script-src</code>:</p>
<pre><code class="language-nginx">add_header Content-Security-Policy
    &quot;default-src 'self';
     script-src  'self' challenges.cloudflare.com;
     style-src   'self' 'unsafe-inline';
     img-src     'self' data:;
     frame-src   challenges.cloudflare.com;
     connect-src 'self' challenges.cloudflare.com;&quot;
    always;
</code></pre>
<p>The <code>'unsafe-inline'</code> in <code>style-src</code> is a deliberate compromise: Turnstile's widget injects inline styles that can't be avoided without a CSP nonce. Everything else is locked down.</p>
<p>The <a href="/blog/local-override-pattern/">next post</a> covers the local override pattern — how notACMS lets users customise templates, CSS, and translations without forking.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-26-security-audit-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[security]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[nginx]]></category>
                    </item>
                <item>
            <title><![CDATA[79 Bugs in a Symfony Codebase That Passed PHPStan]]></title>
            <link>https://holas.pl/blog/79-bugs-ai-code-review/</link>
            <guid isPermaLink="true">https://holas.pl/blog/79-bugs-ai-code-review/</guid>
                        <pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 2 of a series on preparing notACMS for open-source release. Part 1 covers the test suite. The original WordPress to Symfony series covers the migration itself. The code passed PHPStan level 6. PHP CS Fixer had nothing to complain about. Rector's dry-run was clean. I had reviewed every file myself. I was ready to open-source it. Then I asked an AI to audit it against a 200-line instruc…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 2 of a series on preparing notACMS for open-source release. <a href="/blog/368-tests-static-site-generator/">Part 1</a> covers the test suite. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>The code passed PHPStan level 6. PHP CS Fixer had nothing to complain about. Rector's dry-run was clean. I had reviewed every file myself. I was ready to open-source it.</p>
<p>Then I asked an AI to audit it against a 200-line instruction file. It found 79 issues.</p>
<h2>The Setup: AGENTS.md as an Audit Manual<a id="the-setup-agentsmd-as-an-audit-manual" href="#the-setup-agentsmd-as-an-audit-manual" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Not &quot;review my code&quot; — that produces generic feedback about error handling and edge cases. Instead, a detailed checklist in <code>AGENTS.md</code>:</p>
<ul>
<li><strong>Architecture principles</strong> — final classes only, interface segregation, value objects over associative arrays</li>
<li><strong>Naming conventions</strong> — <code>XxxInterface</code> → <code>Xxx</code>, <code>public const</code> in interfaces</li>
<li><strong>Security patterns</strong> — path traversal guards, redirect validation, input sanitisation</li>
<li><strong>Code style rules</strong> — Yoda conditions, blank line before return, strict types</li>
<li><strong>Documentation sync</strong> — service tables matching <code>src/</code>, variable tables matching <code>_variables.scss</code></li>
</ul>
<p>The AI didn't guess what to look for. It followed instructions. The difference is the same as between a code review from someone who knows your project and one from someone who doesn't.</p>
<h2>What AI Caught That I Missed<a id="what-ai-caught-that-i-missed" href="#what-ai-caught-that-i-missed" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Mutable cached state</strong> — <code>ContentTree::setIncludeDrafts()</code> was mutating a cached object. The same tree instance was shared across requests, so toggling drafts on one request affected the next. A race condition waiting to happen. The fix: make <code>ContentTree</code> immutable and move draft filtering to the service layer.</p>
<p><strong>SOLID violations</strong> — <code>SiteConfigServiceInterface</code> had 16 methods. The project rule is max 5. <code>ContentTree</code> had 20+ methods doing data structure, query engine, and recommendation scoring — three jobs in one class. The fix: extract <code>RelatedPostsService</code> (73-line scoring algorithm → separate service with its own interface).</p>
<p><strong>Duplicated logic</strong> — <code>readingTime()</code> and <code>excerpt()</code> both doing <code>strip_tags</code> + <code>str_word_count</code> on the same HTML content. Extracted to a shared <code>getPlainText()</code> method.</p>
<p><strong>Accessibility gaps</strong> — Nested <code>&lt;label&gt;</code> elements in the contact form (invalid HTML), missing <code>role=&quot;alert&quot;</code> on error spans, <code>aria-current=&quot;true&quot;</code> instead of <code>&quot;page&quot;</code>. Each a one-line fix, but invisible without a checklist.</p>
<p><strong>Documentation rot</strong> — <code>docs/STYLEGUIDE.md</code> listing Bootstrap blue (<code>#0d6efd</code>) instead of the actual green (<code>#2d8a4e</code>). <code>docs/ARCHITECTURE.md</code> referencing phantom files: <code>tagline.js</code>, <code>contact_widget.html.twig</code>, <code>_header.scss</code> — none of which existed.</p>
<h2>What AI Got Wrong (False Positives)<a id="what-ai-got-wrong-false-positives" href="#what-ai-got-wrong-false-positives" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>CSRF on the contact form</strong> — flagged as a vulnerability, but actually intentional. Static pre-rendered forms can't use session-bound CSRF tokens — the token is baked into the HTML at build time, tied to the build server's session. Every visitor gets the same token. Turnstile + <code>X-Requested-With</code> header provide the real defense.</p>
<p><strong>User input in 404 error messages</strong> — flagged as enumeration risk, but these messages only appear during the static build, not to visitors. Symfony's production error pages don't expose exception messages.</p>
<p><strong>&quot;Non-yoda&quot; comparisons</strong> — flagged variable-vs-method-call patterns like <code>$page-&gt;directoryKey() === $directoryKey</code>, but the real yoda rule is about literals (<code>null</code>, <code>false</code>, strings) on the left. Variable-vs-variable comparisons don't need flipping.</p>
<h2>What I Changed vs What I Accepted<a id="what-i-changed-vs-what-i-accepted" href="#what-i-changed-vs-what-i-accepted" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>About 60 issues fixed. About 19 accepted as-is:</p>
<ul>
<li><strong>Duplicate preview services</strong> — <code>DraftPreviewService</code> and <code>ScheduledPreviewService</code> are nearly identical, but each is 3 lines. Parameterizing them would require a ServiceLocator, enum injection, and custom serialization on the data collector. More complexity than the original. Accepted.</li>
<li><strong>Wide <code>SiteConfigServiceInterface</code></strong> — 16 methods, but it's a config object, not a business service. Splitting it touches 20 files for annotation tidiness only. Accepted.</li>
</ul>
<p>The fixes that mattered:</p>
<pre><code class="language-php">// Before: mutable cached tree
$tree-&gt;setIncludeDrafts(true);  // mutates shared instance

// After: immutable tree, filtering at service layer
$posts = $this-&gt;contentService-&gt;getPosts($locale, includeDrafts: true);
</code></pre>
<pre><code class="language-php">// Before: 73-line scoring algorithm in ContentTree
public function getRelatedPosts(ContentItem $post, int $limit = 3): array
{
    // ... 73 lines of tag/category/series scoring
}

// After: separate service
final class RelatedPostsService implements RelatedPostsServiceInterface
{
    public function findRelated(ContentItem $post, ContentTree $tree, int $limit = 3): array
    {
        // ... same logic, but isolated
    }
}
</code></pre>
<h2>The Lesson<a id="the-lesson" href="#the-lesson" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>&quot;Review my code&quot; produces generic feedback. &quot;Audit against these 200 lines of project standards&quot; produces specific, actionable findings. AGENTS.md isn't just for building — it's for reviewing too. The AI caught things I'd looked at dozens of times because it was checking against rules, not relying on familiarity.</p>
<p>The <a href="/blog/security-audit-static-site/">next post</a> covers the security vulnerabilities among those 79 issues: XSS in search, open redirects, and path traversal on a &quot;static&quot; site.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-19-79-bugs-ai-code-review/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[368 Tests for a Static Site Generator — Why Bother?]]></title>
            <link>https://holas.pl/blog/368-tests-static-site-generator/</link>
            <guid isPermaLink="true">https://holas.pl/blog/368-tests-static-site-generator/</guid>
                        <pubDate>Sat, 13 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 1 of a series on preparing notACMS for open-source release. The series covers testing, code review, security, the local override pattern, the build pipeline, theme system, search architecture, and the release checklist. The original WordPress to Symfony series covers the migration itself. The content is Markdown files. The templates are Twig. The styles are SCSS. There is no database,…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 1 of a series on preparing notACMS for open-source release. The series covers testing, code review, security, the local override pattern, the build pipeline, theme system, search architecture, and the release checklist. The original <a href="/blog/why-i-left-wordpress/">WordPress to Symfony series</a> covers the migration itself.</em></p>
<hr />
<p>The content is Markdown files. The templates are Twig. The styles are SCSS. There is no database, no API, no user accounts. What exactly is there to test?</p>
<p>Quite a lot, as it turns out. The logic that parses frontmatter, builds the content tree, computes translation maps, generates responsive srcsets, and resolves localized routes is pure PHP — and it's as bug-prone as any other codebase. Most static site generators have zero tests. notACMS has 368.</p>
<h2>Why Test a Static Site?<a id="why-test-a-static-site" href="#why-test-a-static-site" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The content files don't need testing. The pipeline that processes them does: <code>ContentTreeBuilder</code> scanning directories, <code>MarkdownParser</code> extracting frontmatter, <code>ContentService</code> caching results, <code>TranslationMapBuilder</code> computing locale-to-URL maps, <code>LocalizedRouteLoader</code> generating routes from <code>_routes.yaml</code>. All of this is pure logic with no database dependency — ideal for unit tests.</p>
<p>The test pyramid for a content-driven application looks different from a typical web app:</p>
<ul>
<li><strong>Unit tests</strong> — <code>ContentItem</code>, <code>ContentTree</code>, Value Objects. No mocks, no kernel. Frontmatter arrays go in, expected properties come out.</li>
<li><strong>Unit tests with stubs</strong> — <code>SiteConfigService</code>, <code>TagTranslationService</code>, <code>SrcsetExtension</code>. Interface stubs for dependencies, temp directories for filesystem operations.</li>
<li><strong>Integration tests</strong> — controllers, commands, services with booted Symfony kernel. HTTP smoke tests for key routes.</li>
</ul>
<h2>The Numbers<a id="the-numbers" href="#the-numbers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>368 tests, 490 assertions, ~80% line coverage, ~82% method coverage. Three phases:</p>
<p><strong>Phase 1: Pure unit tests (191 tests)</strong> — <code>ContentItem</code> with its <code>isDraft</code>, <code>isScheduled</code>, <code>isPinned</code>, <code>readingTime</code>, <code>excerpt</code>, and dozens of frontmatter parsing cases. <code>ContentTree</code> filtering by category, tag, archive month, pagination. Value Objects: <code>AdjacentPosts</code>, <code>ArchiveMonth</code>, <code>CategoryCount</code>, <code>ParsedMarkdown</code>, <code>ParsedVariant</code>, <code>RenderResult</code>, <code>SidebarData</code>, <code>TagCount</code>. No mocks, no kernel, no filesystem.</p>
<p><strong>Phase 2: Unit tests with interface stubs (49 tests)</strong> — <code>SiteConfigService</code> reading YAML from temp directories, <code>TagTranslationService</code> translating tags between locales, <code>SrcsetExtension</code> generating srcset attributes. <code>createStub()</code> for interfaces that don't need <code>expects()</code>, <code>TmpDirTrait</code> for filesystem cleanup.</p>
<p><strong>Phase 3: Integration tests (127 tests)</strong> — <code>BlogController</code> returning 200 for valid pages and 404 for nonexistent ones, <code>ContactController</code> handling form submissions, <code>BuildStaticSiteCommand</code> completing without errors, <code>LocalizedRouteLoader</code> generating the right routes. The Symfony kernel boots, the content tree builds, the HTTP client makes requests.</p>
<h2>What's Worth Testing<a id="whats-worth-testing" href="#whats-worth-testing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ContentItem::isScheduled()</code> detecting future dates correctly — including the boundary case where the date is exactly now (right-open interval: the post is already published at the boundary). <code>ContentTree::getPaginatedPosts()</code> slicing correctly across pages. <code>LocalizedRouteLoader</code> generating routes for every locale. <code>BuildStaticSiteCommand</code> completing without errors.</p>
<p>What's NOT worth testing: the email sending path in <code>ContactController</code> (requires mocking the mailer, testing infrastructure I don't own), media file copy operations in <code>BuildStaticSiteCommand</code> (requires real filesystem with images), <code>ErrorController::__invoke</code> (hard to trigger via HTTP client). High complexity to trigger, low ROI.</p>
<h2>Testing Conventions That Matter<a id="testing-conventions-that-matter" href="#testing-conventions-that-matter" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong><code>ContentItemFactory</code> for all fixtures</strong> — Never construct <code>ContentItem</code> directly in tests. The factory provides sensible defaults and named constructors:</p>
<pre><code class="language-php">$post = ContentItemFactory::publishedPost([
    'title' =&gt; 'Test Post',
    'tags'  =&gt; ['testing', 'php'],
], 'test-post', '/blog/test-post/');
</code></pre>
<p><strong>XPath assertions, not CSS selectors</strong> — <code>symfony/css-selector</code> is not installed, so <code>$crawler-&gt;filter('.class')</code> throws <code>LogicException</code>. Use <code>filterXpath()</code>:</p>
<pre><code class="language-php">$meta = $crawler-&gt;filterXpath('//meta[@name=&quot;robots&quot;]/@content');
self::assertGreaterThan(0, $meta-&gt;count());
self::assertStringContainsString('noindex', $meta-&gt;text());
</code></pre>
<p><strong><code>createStub()</code> vs <code>createMock()</code></strong> — PHPUnit 13 triggers notices for mocks that don't have <code>expects()</code> calls. Use <code>createStub()</code> in <code>setUp()</code> for interfaces where you only need return values. Create <code>createMock()</code> locally only in tests that verify method call counts:</p>
<pre><code class="language-php">// setUp() — no expects, use createStub()
$this-&gt;config = $this-&gt;createStub(SiteConfigServiceInterface::class);
$this-&gt;config-&gt;method('getPostsPerPage')-&gt;willReturn(10);

// Individual test — has expects, use createMock()
$mock = $this-&gt;createMock(MarkdownParserInterface::class);
$mock-&gt;expects(self::once())-&gt;method('parse')-&gt;willReturn($parsed);
</code></pre>
<p><strong><code>declare(strict_types=1)</code> on every test file</strong> — Same as production code. Yoda conditions in assertions. Blank line before <code>return</code>.</p>
<h2>CI Integration<a id="ci-integration" href="#ci-integration" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev test</code> runs the full suite with testdox output. GitHub Actions runs the Unit suite on every push:</p>
<pre><code class="language-yaml">- name: PHPUnit
  run: vendor/bin/phpunit --testsuite Unit
</code></pre>
<p>Integration tests need the full kernel and content files — viable locally via DDEV, but Unit-only in CI is the right trade-off. The Unit suite catches regressions in the core logic; Integration tests catch wiring issues that only appear with the full stack.</p>
<h2>The Payoff<a id="the-payoff" href="#the-payoff" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>When I deliberately broke <code>ContentItem::isDraft()</code>, the test suite caught it immediately — 12 tests failed with clear messages about which property was wrong. Before the test suite, that kind of regression would only surface when someone noticed a draft post on the live site.</p>
<p>368 green tests means confidence to refactor. Extracting <code>RelatedPostsService</code> from <code>ContentTree</code>, making the tree immutable, moving constants to interfaces — all of that happened with the safety net of tests that would catch anything that broke.</p>
<p>The <a href="/blog/79-bugs-ai-code-review/">next post</a> covers what happened next: an AI audit found 79 issues in code that had already passed PHPStan, CS Fixer, and manual review. Tests only catch what you think to test. An audit catches what you forgot to think about.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-13-368-tests-static-site-generator/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[testing]]></category>
                        <category><![CDATA[phpunit]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.2.0 — what an audit turned up]]></title>
            <link>https://holas.pl/blog/notacms-1-2-audit-release/</link>
            <guid isPermaLink="true">https://holas.pl/blog/notacms-1-2-audit-release/</guid>
                        <pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[1.2.0 started as a cleanup, turned into a full audit, and ended up being the most substantial notACMS release yet. Around 170 fixes across the core, both template trees, documentation, and JS — with a handful of deliberate breaking changes along the way. A few months ago I wrote about running an AI audit on notACMS and finding 79 bugs. 1.2.0 is the follow-up: another full pass, but this time with …]]></description>
            <content:encoded><![CDATA[<p>1.2.0 started as a cleanup, turned into a full audit, and ended up being the most substantial notACMS release yet. Around 170 fixes across the core, both template trees, documentation, and JS — with a handful of deliberate breaking changes along the way.</p>
<hr />
<p>A few months ago I wrote about <a href="/blog/79-bugs-ai-code-review/">running an AI audit on notACMS and finding 79 bugs</a>. 1.2.0 is the follow-up: another full pass, but this time with the project in better shape and with more context in AGENTS.md to guide the review. The process found obvious things and non-obvious things. Here are the ones worth talking about.</p>
<h2>The nginx bug that broke contact forms silently<a id="the-nginx-bug-that-broke-contact-forms-silently" href="#the-nginx-bug-that-broke-contact-forms-silently" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The most frustrating find: a contact form that worked perfectly in every environment except Docker production.</p>
<p>The nginx config used a regex location to route the contact API:</p>
<pre><code class="language-nginx">location ~ ^/[a-z]{2}/api/contact$ {
</code></pre>
<p>Except it didn't. Nginx regex syntax treats <code>{2}</code> as a literal string in some contexts — the quantifier was being matched literally, not as &quot;exactly two characters&quot;. The regex never matched, so <code>POST /pl/api/contact</code> never reached PHP. Every non-default locale got a silent 404 on form submission.</p>
<p>It worked fine in DDEV (which has its own nginx config) and in development (where PHP handles routing directly). Docker production, which used the committed <code>nginx.conf.template</code>, was broken from the start. One character fix: quote the braces.</p>
<h2>locale-redirect: being helpful was harmful<a id="locale-redirect-being-helpful-was-harmful" href="#locale-redirect-being-helpful-was-harmful" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The <code>locale-redirect.js</code> script reads the browser's language preference and redirects first-time visitors to their locale. Sensible idea, but the logic had a gap.</p>
<p>When someone lands on <code>/pl/</code> for the first time with no <code>lang</code> cookie — say, following a link — the script read <code>navigator.language</code>, found <code>en-US</code>, set the cookie to <code>en</code>, and redirected them away from the page they'd explicitly navigated to.</p>
<p>The fix is obvious in retrospect: if the current URL is already on a non-default locale, the URL itself is the preference. Set the cookie to match and stop. No redirect. This also covers the HTTP dev case — the <code>Secure</code> cookie flag was silently dropping the cookie in non-HTTPS environments (DDEV's default), making the whole mechanism non-functional until you switched to HTTPS.</p>
<p>I also added a guard for a scenario I hadn't considered: a stale cookie for a locale that no longer exists on the site. Without it, removing a language from <code>_site.yaml</code> would redirect every visitor with an old cookie into an infinite redirect loop to a 404.</p>
<h2>Search excerpts and double-escaping<a id="search-excerpts-and-double-escaping" href="#search-excerpts-and-double-escaping" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The <a href="/blog/security-audit-static-site/">1.1.2 security review</a> found an XSS in search: pagefind excerpts were being inserted into innerHTML without escaping. Fixed by wrapping them in <code>esc()</code>.</p>
<p>Except pagefind excerpts contain <code>&lt;mark&gt;</code> highlight tags — that's how pagefind shows what matched. The fix broke highlights, turning <code>&lt;mark&gt;term&lt;/mark&gt;</code> into literal <code>&amp;lt;mark&amp;gt;term&amp;lt;/mark&amp;gt;</code> text. The right answer: pagefind content is author-controlled static HTML, not user input. The <code>&lt;mark&gt;</code> tags are injected by the search engine at build time, not by visitors. Remove <code>esc()</code> from the excerpt specifically, keep it everywhere else. Invisible until you notice the highlights aren't highlighting.</p>
<h2>Security and hardening<a id="security-and-hardening" href="#security-and-hardening" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>ImageMagick now runs through <code>Symfony\Process</code> with argv arrays instead of shell strings — <code>exec()</code> is gone. Turnstile validates the hostname in the siteverify response against your configured <code>base_url</code>, so tokens minted on a test or staging domain can't be replayed against production. JSON-LD output is hex-escaped, meaning <code>&lt;/script&gt;</code> in a post title can no longer terminate the script block. The nginx security headers (X-Frame-Options, CSP, etc.) were missing from <code>/assets/</code> and <code>/media/</code> responses — location-level <code>add_header</code> was suppressing the inherited server-level headers.</p>
<h2>/llms.txt<a id="llmstxt" href="#llmstxt" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A small addition: a <code>/llms.txt</code> route included in the static build, listing the most recent posts in a machine-readable format per locale. Configurable via <code>llms_limit</code> in <code>_site.yaml</code>, overridable per-theme via the <code>@base</code> Twig namespace. Static sites aren't traditionally easy for LLMs to navigate — this is a low-effort way to provide context to whoever (or whatever) reads it.</p>
<h2>Breaking changes<a id="breaking-changes" href="#breaking-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A few things changed in ways that require a one-line migration in <code>local/src/</code>:</p>
<ul>
<li><code>ContentItem::directoryKey()</code> returns the full content path (<code>pages/about</code> not <code>about</code>), fixing silent URL collisions between same-named directories in different sections</li>
<li><code>getTree()</code> moved to <code>ContentTreeProviderInterface</code> — off <code>ContentServiceInterface</code></li>
<li><code>structured_data().blogPosting()</code> takes a named map (was 13 positional arguments)</li>
<li><code>lang_switch_url</code> context key removed — use <code>lang_switch</code></li>
<li><code>docs/customization/old-template/</code> removed from the repo — retrieve from a v1.1.x tag if needed</li>
</ul>
<p>Full migration guide: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.2.md">UPGRADE-1.2.md</a>.</p>
<h2>Links<a id="links" href="#links" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Full changelog: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md">CHANGELOG.md</a>.</p>
<p>Repository: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / holas1337/notACMS</a> — Apache 2.0.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-12-notacms-1-2/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[ai]]></category>
                    </item>
                <item>
            <title><![CDATA[Iterating on Architecture with AI — Components, Quality Rounds, and Refactoring Cycles]]></title>
            <link>https://holas.pl/blog/iterating-architecture-with-ai/</link>
            <guid isPermaLink="true">https://holas.pl/blog/iterating-architecture-with-ai/</guid>
                        <pubDate>Sat, 06 Jun 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 10 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 9 covers the multilanguage system. Part 5 covered how Claude Code was used during the initial build — AGENTS.md as the AI's instruction manual, generating services and templates from conventions, translating content. This post covers what happens after: the site works, the build p…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 10 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/multilanguage-static-site/">Part 9</a> covers the multilanguage system.</em></p>
<hr />
<p><a href="/blog/building-with-ai-claude-code/">Part 5</a> covered how Claude Code was used during the initial build — <code>AGENTS.md</code> as the AI's instruction manual, generating services and templates from conventions, translating content. This post covers what happens after: the site works, the build passes, but the architecture has rough edges. Three rounds of quality improvements, component extraction, and namespace reorganization — all driven by AI-assisted review cycles.</p>
<h2>Why Iterate?<a id="why-iterate" href="#why-iterate" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first working version prioritizes shipping. Get the content online, make the build pass, deploy to the Raspberry Pi. Technical debt accumulates naturally along the way: methods return associative arrays instead of value objects, services are injected as concrete classes instead of interfaces, templates grow into monoliths.</p>
<p>On a team, code review catches these patterns. On a solo project, there's nobody to review your pull requests. AI fills that gap — not as a rubber stamp, but as a systematic reviewer that reads every file and reports issues with line numbers.</p>
<p>The iteration cycle:</p>
<ol>
<li>Ask the AI to audit for specific patterns (strict types, SOLID violations, DRY issues)</li>
<li>AI reads every PHP file, reports issues with file paths and line numbers</li>
<li>Plan the fixes in a <code>.plans/</code> file with checkboxes</li>
<li>Implement phase by phase, verify with <code>ddev code-check</code> after each</li>
<li>Repeat with the next quality focus</li>
</ol>
<p>Each round has a specific scope. Trying to fix everything at once leads to noisy diffs and missed regressions. Focused rounds produce reviewable, verifiable changes.</p>
<h2>Round 1 — Value Objects Over Arrays<a id="round-1--value-objects-over-arrays" href="#round-1--value-objects-over-arrays" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first round targeted the project's own rule: &quot;Never return associative arrays for complex data.&quot;</p>
<h3>The Problem<a id="the-problem" href="#the-problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>MarkdownParser::parse()</code> returned an array:</p>
<pre><code class="language-php">// Before
public function parse(string $markdown): array
{
    // ...
    return [
        'frontMatter' =&gt; $frontMatter,
        'html' =&gt; $html,
    ];
}

// Consumer
$result = $this-&gt;parser-&gt;parse($content);
$frontMatter = $result['frontMatter'];  // no type safety
$html = $result['html'];               // typo = silent bug
</code></pre>
<p>Same pattern for adjacent post navigation — <code>ContentTree::getAdjacentPosts()</code> returned <code>['prev' =&gt; $post, 'next' =&gt; $post]</code>.</p>
<p>The issues: no type safety, no IDE autocomplete, PHPStan can't catch a typo in <code>$result['htlm']</code>.</p>
<h3>The Fix<a id="the-fix" href="#the-fix" 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>Now the parser returns <code>ParsedMarkdown</code>, the consumer accesses <code>$parsed-&gt;frontMatter</code> and <code>$parsed-&gt;html</code>, and PHPStan catches any property name typo at analysis time.</p>
<p>Seven value objects were created or moved in this round:</p>
<table>
<thead>
<tr>
<th>Value Object</th>
<th>Replaces</th>
<th>Properties</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ParsedMarkdown</code></td>
<td><code>['frontMatter', 'html']</code> array</td>
<td><code>frontMatter</code>, <code>html</code></td>
</tr>
<tr>
<td><code>AdjacentPosts</code></td>
<td><code>['prev', 'next']</code> array</td>
<td><code>prev</code>, <code>next</code></td>
</tr>
<tr>
<td><code>ArchiveMonth</code></td>
<td>inline array</td>
<td><code>year</code>, <code>month</code>, <code>count</code></td>
</tr>
<tr>
<td><code>CategoryCount</code></td>
<td>inline array</td>
<td><code>slug</code>, <code>count</code></td>
</tr>
<tr>
<td><code>TagCount</code></td>
<td>inline array</td>
<td><code>slug</code>, <code>count</code></td>
</tr>
<tr>
<td><code>SidebarData</code></td>
<td>multiple return values</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 stats</td>
<td><code>pages</code>, <code>skipped</code>, <code>errors</code></td>
</tr>
</tbody>
</table>
<p>All are <code>readonly</code>, use constructor property promotion, and live in <code>src/Content/ValueObject/</code>.</p>
<h2>Round 2 — Interfaces for Everything<a id="round-2--interfaces-for-everything" href="#round-2--interfaces-for-everything" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The second round enforced another project rule: &quot;Every injectable class in <code>src/Service/</code> must have a corresponding interface.&quot;</p>
<h3>The Problem<a id="the-problem-1" href="#the-problem-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Two services were injected as concrete classes:</p>
<pre><code class="language-php">// Before
public function __construct(
    private readonly ContentTreeBuilder $builder,
    private readonly MarkdownParser $parser,
) {
}
</code></pre>
<p>This worked, but it violated the dependency inversion principle. The rest of the codebase already injected via interfaces (<code>ContentServiceInterface</code>, <code>ImageResizerInterface</code>). These two were the exceptions.</p>
<h3>The Fix<a id="the-fix-1" href="#the-fix-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">// After
public function __construct(
    private readonly ContentTreeBuilderInterface $builder,
    private readonly MarkdownParserInterface $parser,
) {
}
</code></pre>
<p>The concrete classes implement the interfaces. Symfony's autowiring handles the binding. The rule is now enforced everywhere: 12 service interfaces across 4 subdirectories.</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>A secondary rule from this round: constants belong in the interface, not the concrete class. The concrete class inherits them via <code>self::CONSTANT_NAME</code>.</p>
<h2>Round 3 — Eliminating Hardcoded Values<a id="round-3--eliminating-hardcoded-values" href="#round-3--eliminating-hardcoded-values" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The third round targeted a subtler problem: values that work today but drift tomorrow.</p>
<h3>Hardcoded URLs in Templates<a id="hardcoded-urls-in-templates" href="#hardcoded-urls-in-templates" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The language switcher had hardcoded archive paths:</p>
<pre><code class="language-twig">{# Before — breaks if routing changes #}
{% set url = locale is same as('pl') ? '/archive/' : '/pl/archiwum/' %}
</code></pre>
<pre><code class="language-twig">{# After — uses named routes #}
{% set url = path('blog_archive_' ~ other, {year: archive_year, month: '%02d'|format(archive_month)}) %}
</code></pre>
<p>Same pattern in <code>BlogController</code> for cross-locale tag switching — hardcoded <code>/pl/tag/</code> and <code>/blog/</code> replaced with <code>$this-&gt;generateUrl('blog_tag_'.$otherLocale, ...)</code>.</p>
<h3>Hardcoded Image Breakpoints<a id="hardcoded-image-breakpoints" href="#hardcoded-image-breakpoints" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The responsive image template had srcset widths hardcoded as strings. If the <code>IMAGE_VARIANT_WIDTHS</code> environment variable changed, the template would reference files that don't exist:</p>
<pre><code class="language-twig">{# Before — template must match env config manually #}
srcset=&quot;...640w.webp 640w, ...960w.webp 960w, ...&quot;
</code></pre>
<p>The fix: inject variant widths as a Twig global from <code>SiteConfigExtension</code>, then generate srcset dynamically. One source of truth for breakpoints.</p>
<h3>Magic Numbers<a id="magic-numbers" href="#magic-numbers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>Response::HTTP_BAD_REQUEST</code> replaced a hardcoded <code>400</code>. Small change, but consistent with the principle: every literal value is a future bug where someone changes the logic but not the number.</p>
<h2>Twig Component Extraction<a id="twig-component-extraction" href="#twig-component-extraction" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Between quality rounds, a separate effort extracted reusable components from monolithic templates.</p>
<h3>The About Page<a id="the-about-page" href="#the-about-page" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The about page was the worst offender — 146 lines mixing profile markup, expertise cards, skill pills, open source projects, and recommendation blockquotes in one template.</p>
<p>After extraction:</p>
<pre><code class="language-twig">{# about.html.twig — clean and scannable #}
{% 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>Eight components extracted from one page: <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> and <code>coming_soon_terminal</code> from other pages.</p>
<h3>The Styleguide Benefit<a id="the-styleguide-benefit" href="#the-styleguide-benefit" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>holas.pl has a dev-only styleguide at <code>/styleguide/</code> that demonstrates all UI components. Before extraction, the styleguide hardcoded its own markup to show each component — which drifted from the real templates when changes were made.</p>
<p>After extraction, both the real site and the styleguide include the same component files:</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>Change the component once, both update automatically. The styleguide went from a manual sync burden to zero maintenance.</p>
<p>The component count grew from ~15 to 25 across these extractions.</p>
<h2>Namespace Reorganization<a id="namespace-reorganization" href="#namespace-reorganization" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The value object and interface work created enough files that flat namespaces became crowded:</p>
<pre><code># Before
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

# After
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> moved to <code>Enum/</code> — it's a backed PHP enum for post card layout variants:</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>Type-safe, auto-completable, impossible to typo. The <code>cycle()</code> method returns layout values that the blog listing template rotates through for visual variety.</p>
<p>The reorganization touched 46 files in a single commit — every <code>use</code> statement referencing a moved class needed updating. This is exactly the kind of mechanical refactoring where AI shines: change the namespace, update all imports, verify nothing broke. The human decides the target structure; the AI handles the tedious part.</p>
<h2>The AI Workflow for Refactoring<a id="the-ai-workflow-for-refactoring" href="#the-ai-workflow-for-refactoring" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The practical workflow behind these rounds:</p>
<p><strong>Step 1: Scoped audit.</strong> Ask Claude Code to review all PHP files for a specific category of issues. Not &quot;find all problems&quot; — too vague. Instead: &quot;check every file in <code>src/</code> for methods returning associative arrays instead of value objects.&quot; The AI reads every file and reports specific issues with file paths and line numbers.</p>
<p><strong>Step 2: Plan.</strong> Write a <code>.plans/</code> file documenting what needs to change, which files are affected, and the implementation steps as checkboxes. The plan is the source of truth — not a summary of intent, but a detailed implementation spec.</p>
<p><strong>Step 3: Phase-by-phase implementation.</strong> Execute one phase, run <code>ddev code-check</code> (PHP CS Fixer + PHPStan level 6), verify the build passes with <code>ddev build</code>. Move to the next phase.</p>
<p><strong>Step 4: Verify.</strong> After the round is complete, run the full quality suite. The numbers for 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 PHP files, zero CS Fixer issues, zero PHPStan errors. The automated tools confirm what the review intended.</p>
<h3>What AI Does Well in Refactoring<a id="what-ai-does-well-in-refactoring" href="#what-ai-does-well-in-refactoring" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li><strong>Systematic file-by-file review</strong>: reads 53 files and reports every violation of a pattern. Humans skim; AI doesn't.</li>
<li><strong>Mechanical refactoring</strong>: renaming namespaces across 46 files, updating import statements, moving constants from concrete classes to interfaces.</li>
<li><strong>Consistency checks</strong>: verifying that every service has an interface, every value object is <code>readonly</code>, every comparison uses Yoda style — across the entire codebase.</li>
</ul>
<h3>What AI Needs Humans For<a id="what-ai-needs-humans-for" href="#what-ai-needs-humans-for" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<ul>
<li><strong>Deciding which abstractions to introduce</strong>: should <code>SidebarData</code> be one value object or four separate return values? The AI can implement either; the human decides which is cleaner.</li>
<li><strong>Judging when an interface adds value vs. overhead</strong>: a service used in one place doesn't need an interface for testing flexibility. The project rule says &quot;every service gets an interface&quot; — but the human decided that rule, and the human could change it.</li>
<li><strong>Knowing when to stop</strong>: three quality rounds is enough. The codebase is clean. A fourth round of micro-optimizations would be over-engineering.</li>
</ul>
<h3>The AGENTS.md Feedback Loop<a id="the-agentsmd-feedback-loop" href="#the-agentsmd-feedback-loop" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Each round discovers patterns worth documenting. Round 1 added the value object convention to <code>AGENTS.md</code>. Round 2 added the interface naming rule. Round 3 added the &quot;no hardcoded URLs&quot; principle.</p>
<p>The next time Claude Code generates a new service, it follows all three rounds' learnings from the start. The first version of a service now ships with an interface, uses value objects for complex returns, and references named routes instead of hardcoded paths. The iteration compounds.</p>
<h2>The Result<a id="the-result" href="#the-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>After three rounds, the codebase state:</p>
<ul>
<li><strong>53 PHP files</strong>, all with <code>declare(strict_types=1)</code>, explicit return types, Yoda conditions</li>
<li><strong>12 service interfaces</strong> — every service injected via interface</li>
<li><strong>7 value objects</strong> — no associative arrays for multi-field returns</li>
<li><strong>1 enum</strong> — type-safe card layout variants</li>
<li><strong>25 Twig components</strong> — reusable, shared between site and styleguide</li>
<li><strong>0 PHP CS Fixer issues</strong>, <strong>0 PHPStan errors</strong> at level 6</li>
<li><strong>0 hardcoded URLs</strong> in templates or controllers</li>
</ul>
<p>None of this was in the first version. The first version had concrete injections, array returns, monolithic templates, and hardcoded locale checks. It worked — the site built, the pages rendered, users could read blog posts.</p>
<p>The difference is maintainability. Adding the multilanguage system (<a href="/blog/multilanguage-static-site/">previous post</a>) was straightforward because the codebase was already clean: interfaces for everything, typed returns, clear separation of concerns. Refactoring a clean codebase is fast. Refactoring a messy one is slow and error-prone.</p>
<p>Ship first. Iterate second. Use AI for the systematic work that a solo developer would skip — or postpone until it becomes a problem. Three focused rounds, each building on the last, each verified by automated tools. The code is measurably better, and the investment is a few hours of review cycles, not a week-long rewrite.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-06-06-iterating-architecture-with-ai/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Multilanguage on a Static Site — Configuration Over Code]]></title>
            <link>https://holas.pl/blog/multilanguage-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/multilanguage-static-site/</guid>
                        <pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 9 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 8 covers responsive images and scheduled posts. Adding a second language to a Symfony site usually means duplicating controllers, hardcoding URL prefixes, and scattering locale checks everywhere. holas.pl takes a different approach: locale config lives in one YAML file, routes are …]]></description>
            <content:encoded><![CDATA[<p><em>This is part 9 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/responsive-images-scheduled-posts/">Part 8</a> covers responsive images and scheduled posts.</em></p>
<hr />
<p>Adding a second language to a Symfony site usually means duplicating controllers, hardcoding URL prefixes, and scattering locale checks everywhere. holas.pl takes a different approach: locale config lives in one YAML file, routes are generated from a custom PHP attribute, and translations are linked by the filesystem — not by explicit keys.</p>
<h2>The Problem with Hardcoded Locales<a id="the-problem-with-hardcoded-locales" href="#the-problem-with-hardcoded-locales" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first working version of holas.pl's multilanguage support had around 30 places with patterns like this:</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>Every route had two methods — one per locale. The real controller logic lived in a private <code>render*()</code> method; the public methods were pure boilerplate that set the locale and delegated. Seven controllers × two locales = 28 methods doing nothing useful.</p>
<p>Adding a third language would mean adding 14 more methods, plus updating templates, the locale listener, and every place that checked <code>'pl' === $locale</code>. The code didn't scale.</p>
<h2>Single Source of Truth — <code>_site.yaml</code><a id="single-source-of-truth--siteyaml" href="#single-source-of-truth--siteyaml" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The fix starts with centralizing the locale list. Instead of spreading locale knowledge across PHP files, everything lives in <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>Order matters: the first key is the default locale. Each locale carries its own metadata — <code>og_locale</code> for Open Graph tags, <code>date_format</code> for template rendering, <code>font_preload</code> for Latin Extended characters that only Polish needs.</p>
<p><code>SiteConfigService</code> reads this file once, caches it, and provides it to the entire application:</p>
<pre><code class="language-php">interface SiteConfigServiceInterface
{
    /** @return string[] Ordered locale codes, first = default */
    public function getLocales(): array;

    public function getDefaultLocale(): string;

    /** @return array&lt;string, mixed&gt; Config for a single locale */
    public function getLocaleConfig(string $locale): array;
}
</code></pre>
<p>Every controller, listener, and route loader injects this interface. No PHP code imports a locale list from <code>framework.yaml</code> or hardcodes <code>['en', 'pl']</code>.</p>
<h2>Custom Route Attribute — <code>#[LocalizedRoute]</code><a id="custom-route-attribute--localizedroute" href="#custom-route-attribute--localizedroute" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The key innovation is a custom PHP attribute that replaces duplicate route methods:</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>The attribute defines a route for the <strong>default locale only</strong>. A custom <code>LocalizedRouteLoader</code> scans all controllers, finds <code>#[LocalizedRoute]</code> attributes, and generates <code>{name}_{locale}</code> routes for every configured 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
{
    // one method handles all locales
}
</code></pre>
<p>This single method replaces the two <code>listEn()</code> / <code>listPl()</code> methods from before. The loader generates four routes from the two attributes: <code>blog_list_en</code>, <code>blog_list_pl</code>, <code>blog_list_paginated_en</code>, <code>blog_list_paginated_pl</code>.</p>
<p>The total across all controllers: 28 methods became 14. Every removed method was pure boilerplate.</p>
<h3>Route Resolution<a id="route-resolution" href="#route-resolution" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The loader needs to know that <code>/blog/</code> is the English path but <code>/pl/wpisy/</code> is the Polish one. A three-step resolution algorithm handles this:</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>Default locale</strong> — use the attribute's <code>path</code> as-is: <code>/blog/</code></li>
<li><strong>Override exists</strong> — prepend <code>/{locale}</code> + the translated path from <code>_routes.yaml</code></li>
<li><strong>No override</strong> — prepend <code>/{locale}</code> + the default path: <code>/pl/blog/</code></li>
</ol>
<h3><code>_routes.yaml</code> — Translated Path Segments<a id="routesyaml--translated-path-segments" href="#routesyaml--translated-path-segments" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Only routes with translated URL segments need overrides. Routes without entries get auto-prefixed:</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> has no entry, so <code>blog_tag_pl</code> gets the auto-prefix: <code>/pl/tag/{tag}/</code>. Adding German would mean adding <code>de:</code> entries to the routes that need translation, and nothing for routes where the English path is fine.</p>
<h2>Two URL Resolution Patterns<a id="two-url-resolution-patterns" href="#two-url-resolution-patterns" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Templates need to link to pages. There are two fundamentally different cases:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Pattern</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Structural</strong> (listings, search, contact, archive)</td>
<td><code>path('route_name_' ~ locale)</code></td>
<td><code>path('blog_list_' ~ locale)</code></td>
</tr>
<tr>
<td><strong>Content</strong> (pages, posts, about, privacy)</td>
<td><code>content_url(directoryKey, locale)</code></td>
<td><code>content_url('about', locale)</code></td>
</tr>
</tbody>
</table>
<p>Structural routes come from the router — they're generated by <code>LocalizedRouteLoader</code> and have <code>{name}_{locale}</code> names. Content URLs come from frontmatter <code>slug</code> fields — each <code>en.md</code> and <code>pl.md</code> defines its own URL.</p>
<pre><code class="language-twig">{# Structural: the router knows the path #}
&lt;a href=&quot;{{ path('blog_list_' ~ locale) }}&quot;&gt;Blog&lt;/a&gt;
&lt;a href=&quot;{{ path('contact_' ~ locale) }}&quot;&gt;Contact&lt;/a&gt;

{# Content: look up by directory key #}
&lt;a href=&quot;{{ content_url('about', locale) }}&quot;&gt;About&lt;/a&gt;
&lt;a href=&quot;{{ content_url('privacy-policy', locale) }}&quot;&gt;Privacy&lt;/a&gt;
</code></pre>
<p><code>content_url()</code> is a custom Twig function that looks up a <code>ContentItem</code> by its directory key — the folder name — and returns the URL from its frontmatter. This replaces the old approach of hardcoding slugs per locale in templates.</p>
<h2>Co-located Content — The Filesystem as Translation Link<a id="co-located-content--the-filesystem-as-translation-link" href="#co-located-content--the-filesystem-as-translation-link" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Content files for the same post live in the same directory:</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/ → images served at /media/my-post/
</code></pre>
<p>Both files in the same folder are automatically linked as translations. No explicit <code>translation_key</code> field needed. <code>ContentItem::directoryKey()</code> returns the folder name (<code>&quot;my-post&quot;</code>), and <code>TranslationMapBuilder</code> uses it to build a lookup table:</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>The result: <code>$map['my-post']['en'] = '/blog/my-post/'</code>, <code>$map['my-post']['pl'] = '/pl/blog/moj-wpis/'</code>. This map drives hreflang <code>&lt;link&gt;</code> tags in the HTML head and the language switcher.</p>
<p>Not every post needs both locale files. A post with only <code>en.md</code> won't appear in Polish listings, and the language switcher falls back to the other language's homepage.</p>
<h2>Language Switcher — Fallback Chain<a id="language-switcher--fallback-chain" href="#language-switcher--fallback-chain" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The language switcher seems simple — link to the same page in the other language. In practice, it needs to handle partial translations, tag pages, archive pages, and paginated listings. The fallback chain:</p>
<pre><code class="language-twig">{# 1. Try translation map (page exists in other locale) #}
{% if tk and translation_map[tk][other] is defined %}
    {% set url = translation_map[tk][other] %}
{% endif %}

{# 2. Try controller-provided URL (tag pages) #}
{% if url is null and lang_switch_url is defined %}
    {% set url = lang_switch_url %}
{% endif %}

{# 3. Fallback: archive, paginated listing, or homepage #}
{% 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>Tag pages get special treatment: <code>BlogController</code> translates the tag slug between locales (e.g., <code>security</code> → <code>bezpieczenstwo</code>) and checks whether the other locale has any posts with that tag. If yes, the switcher links to the translated tag page. If no, it falls back to the other locale's blog listing.</p>
<p>On the client side, <code>locale-redirect.js</code> handles first-visit language detection. It reads <code>navigator.language</code>, matches it against the configured locale list (from a <code>data-locales</code> attribute on <code>&lt;html&gt;</code>), and stores the preference in a cookie. On return visits, it redirects to the saved preference. The locale list isn't hardcoded in JavaScript — it comes from the same <code>_site.yaml</code> config, passed through Twig.</p>
<h2>Locale Detection<a id="locale-detection" href="#locale-detection" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>LocaleListener</code> runs at priority 8 — after Symfony's built-in locale listeners — and detects locale from the URL path:</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>No hardcoded <code>/pl/</code> check. It iterates the configured locales dynamically. Adding a new locale to <code>_site.yaml</code> is enough for the listener to start detecting it.</p>
<h2>Adding a New Language — Zero PHP Changes<a id="adding-a-new-language--zero-php-changes" href="#adding-a-new-language--zero-php-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This is the payoff. Adding German to holas.pl requires:</p>
<ol>
<li><strong><code>content/_site.yaml</code></strong> — add a <code>de:</code> entry with label, og_locale, date_format</li>
<li><strong><code>content/_routes.yaml</code></strong> — add <code>de:</code> overrides for translated route segments</li>
<li><strong><code>translations/messages.de.yaml</code></strong> — German UI strings (nav labels, button text, etc.)</li>
<li><strong><code>content/_tags.yaml</code></strong> — German tag translations</li>
<li><strong>Content files</strong> — create <code>de.md</code> alongside <code>en.md</code> and <code>pl.md</code> for posts that should exist in German</li>
</ol>
<p>No PHP files touched. No Twig templates edited. No controller methods added. The route loader generates German routes automatically. The locale listener detects <code>/de/</code> paths. The language switcher renders a dropdown instead of a toggle link. The translation map includes German URLs.</p>
<p>The 28 locale-specific controller methods and 30 hardcoded locale checks from the first version would have meant editing 20+ files to add German. The configuration-driven approach means editing 5 config files and creating content.</p>
<p>The architecture decisions that make this work — <code>SiteConfigService</code> as the single locale authority, <code>LocalizedRouteLoader</code> generating routes from attributes, co-located content as the translation link — are the kind of decisions that seem like over-engineering when you only have two languages. They're not. They're the difference between &quot;adding a language is a week of work&quot; and &quot;adding a language is an afternoon of config.&quot;</p>
<p><a href="/blog/iterating-architecture-with-ai/">Next post</a> covers how the codebase improved through iterative quality rounds — value objects, interfaces, component extraction — with AI handling the systematic review work.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-30-multilanguage-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Responsive Images and Scheduled Posts on a Static Site — Build-Time Solutions]]></title>
            <link>https://holas.pl/blog/responsive-images-scheduled-posts/</link>
            <guid isPermaLink="true">https://holas.pl/blog/responsive-images-scheduled-posts/</guid>
                        <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[A WordPress site resizes uploaded images automatically. Scheduled posts have a &quot;publish on&quot; date picker in the editor. Both work without any custom code. On a static site, there's no server handling requests and no application layer checking the clock. Both features require deliberate implementation — and the right place for both is the build step. Responsive Images The Problem Featured …]]></description>
            <content:encoded><![CDATA[<p>A WordPress site resizes uploaded images automatically. Scheduled posts have a &quot;publish on&quot; date picker in the editor. Both work without any custom code.</p>
<p>On a static site, there's no server handling requests and no application layer checking the clock. Both features require deliberate implementation — and the right place for both is the build step.</p>
<h2>Responsive Images<a id="responsive-images" href="#responsive-images" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>The Problem<a id="the-problem" href="#the-problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Featured images on holas.pl are 1280×720px WebP. On a desktop browser, that's the right size. On a mobile screen 400px wide, the browser downloads a 1280-pixel image to display at 400 pixels — roughly 10× the data actually needed.</p>
<p>The solution is <code>srcset</code> + <code>sizes</code>: tell the browser what image variants exist and how large the image renders at each viewport width, then let it pick the right file. The static site constraint: every variant must exist as a file before any request arrives. There's no resize-on-demand.</p>
<h3>Build-Time Variant Generation<a id="build-time-variant-generation" href="#build-time-variant-generation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand</code> generates variants after copying media files to <code>public/static/media/</code>. It scans for <code>.webp</code> files, reads each file's actual dimensions with <code>getimagesize()</code>, and generates variants via <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> calls 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> scales to 640px wide, preserving aspect ratio. <code>-quality 82 -strip -define webp:method=6</code> matches the production image settings and removes EXIF data.</p>
<p>Variant filenames follow a convention: <code>image.webp</code> → <code>image-640w.webp</code>, <code>image-960w.webp</code>. The build skips files that already end in <code>-640w</code> or <code>-960w</code> to avoid re-processing previously generated variants.</p>
<h3>ResponsiveImageService<a id="responsiveimageservice" href="#responsiveimageservice" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Two places need to know which variants exist: <code>BuildStaticSiteCommand</code> (which files to generate) and <code>SrcsetExtension</code> (which filenames to reference in HTML). Rather than duplicating the breakpoint logic, both inject <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>The implementation:</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);  // strip .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>Images ≤640px wide get no variants — the original is already small enough. <code>buildSrcset()</code> returns <code>''</code> to signal that no srcset attribute is needed.</p>
<p>If breakpoints ever need to change, there's one place to update.</p>
<h3>Featured Image Component<a id="featured-image-component" href="#featured-image-component" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The <code>responsive_img.html.twig</code> component renders featured images with srcset hardcoded to the 640/960/1280 breakpoints:</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> tells the browser: below 48em viewport width, the image fills the full viewport; above that, it's constrained to 720px (the content column width). The browser uses this to pick the right srcset entry before downloading anything.</p>
<p><code>width</code> and <code>height</code> are explicit for Cumulative Layout Shift prevention — the browser reserves the exact space for the image before it loads. Without them, the layout shifts when the image arrives.</p>
<h3>Inline Content Images<a id="inline-content-images" href="#inline-content-images" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Markdown images inside post body render as plain <code>&lt;img&gt;</code> tags. No srcset. A post with a diagram or screenshot at <code>/media/post-dir/diagram.webp</code> would serve the full-size image to mobile too.</p>
<p>The <code>srcset_media</code> Twig filter handles this. In <code>post.html.twig</code>:</p>
<pre><code class="language-twig">{{ content.htmlContent|srcset_media|raw }}
</code></pre>
<p><code>SrcsetExtension::srcsetMedia()</code> finds all <code>/media/*.webp</code> <code>&lt;img&gt;</code> tags with a regex, reads the source image width from the content directory (not the static output), and injects <code>srcset</code> and <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];

        // skip if srcset already present
        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];  // no variants generated — leave as-is
        }

        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> looks up the actual source file under <code>content/</code> (not <code>public/static/</code>), since that's where the original dimensions live. Images with no variants — small inline screenshots ≤640px wide — are left unchanged.</p>
<h2>Scheduled Posts<a id="scheduled-posts" href="#scheduled-posts" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>The Problem<a id="the-problem-1" href="#the-problem-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>A post with <code>date: 2026-06-01</code> shouldn't appear in listings until June 1. The site rebuilds nightly, so a future-dated post simply won't appear in <code>ContentTree::getAllPosts()</code> until the build after its publish date. That part is automatic.</p>
<p>The URL is a different problem. If someone shares the link before the post is live, they get a 404. Better to serve a &quot;coming soon&quot; page at the exact URL the post will occupy.</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>One comparison. <code>isDraft()</code> takes priority — a post with both <code>draft: true</code> and a future date is treated as a draft and excluded from all builds.</p>
<h3>Static Build: Coming-Soon Pages<a id="static-build-coming-soon-pages" href="#static-build-coming-soon-pages" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand::collectRoutes()</code> collects two categories of post URLs:</p>
<ul>
<li>Published posts via <code>ContentTree::getAllPosts()</code> — rendered with the full post template</li>
<li>Scheduled posts via <code>ContentTree::getScheduledPosts()</code> — rendered with the coming-soon template</li>
</ul>
<p>Both produce static HTML files at their eventual URL. When the post's date passes and the next build runs, <code>isScheduled()</code> returns <code>false</code>, the URL moves to the published list, and the full post HTML replaces the coming-soon HTML. No redirect, no special handling needed.</p>
<h3>The Coming-Soon Page<a id="the-coming-soon-page" href="#the-coming-soon-page" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The coming-soon template uses the same green terminal aesthetic as the error pages:</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;Publishing: {{ post.date|date('Y-m-d') }}&lt;/span&gt;
{% endif %}
&lt;/code&gt;&lt;/pre&gt;
</code></pre>
<p><code>noindex, nofollow</code> — the page handles direct links gracefully without ranking or passing link equity.</p>
<p>If the publish date is ≤14 days away, the title and date are shown. Further out: just the <code>COMING_SOON</code> code, no date. The 14-day threshold avoids making a public commitment to a specific date that might slip.</p>
<h3>Dev Preview Toolbar<a id="dev-preview-toolbar" href="#dev-preview-toolbar" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>In production, scheduled posts are invisible — they appear only as coming-soon pages at their URLs, not in any listing.</p>
<p>In development, you're writing scheduled post content and need to see it. The Symfony profiler toolbar gets a calendar icon toggle (&quot;Scheduled preview&quot;). When on, scheduled posts appear in listings with a <code>[PLANNED]</code> badge:</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 %}
    {# pinned / new / recently updated badges #}
{% endif %}
</code></pre>
<p>Toggle it off to preview what production will look like. The mechanism mirrors the existing draft preview toggle exactly — same session key pattern (<code>scheduled_preview</code>), same controller structure.</p>
<h2>The Build-Step Pattern<a id="the-build-step-pattern" href="#the-build-step-pattern" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both features follow the same approach: push work into the build step, keep the serving layer simple.</p>
<p>Responsive images: generate all variants at build time. A few seconds of ImageMagick calls during <code>ddev build</code> saves bandwidth on every mobile page load for the lifetime of the post.</p>
<p>Scheduled posts: pre-render coming-soon pages rather than handling &quot;not yet published&quot; at request time. The static file exists, nginx serves it, no PHP involved.</p>
<p>The build runs once. nginx serves the result to every visitor. Any work that can move to the build step is work the server doesn't have to do.</p>
<p>The build pipeline that makes this possible is covered in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a>. The two-container production setup that runs it is in <a href="/blog/dev-experience-two-containers/">Part 3</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-25-responsive-images-scheduled-posts/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[php]]></category>
                    </item>
                <item>
            <title><![CDATA[SEO Engineering on a Static Site — Structured Data, Social Cards, and Crawler Signals]]></title>
            <link>https://holas.pl/blog/seo-engineering-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/seo-engineering-static-site/</guid>
                        <pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl scores 100 in Lighthouse's SEO category. What that actually checks: meta title is present, meta description is present, canonical URL is set, links are crawlable, the page is mobile-friendly. These are the minimum requirements — the things that block indexing if they're missing. What Lighthouse SEO doesn't check: whether your structured data is complete, how your page renders as a social …]]></description>
            <content:encoded><![CDATA[<p>holas.pl scores 100 in Lighthouse's SEO category. What that actually checks: meta title is present, meta description is present, canonical URL is set, links are crawlable, the page is mobile-friendly. These are the minimum requirements — the things that block indexing if they're missing.</p>
<p>What Lighthouse SEO doesn't check: whether your structured data is complete, how your page renders as a social card, what feed readers see when they subscribe, whether Google can find and index your images without crawling every page.</p>
<p>This post covers the implementation layer beneath that score.</p>
<h2>Structured Data<a id="structured-data" href="#structured-data" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Structured data is JSON-LD in a <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> block. It tells search engines what a page is, not just what it says. holas.pl uses four schema types.</p>
<h3>WebSite + SearchAction<a id="website--searchaction" href="#website--searchaction" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Every page carries a <code>WebSite</code> schema identifying the site and its search endpoint:</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>The <code>potentialAction</code> enables the <a rel="nofollow noopener noreferrer" target="_blank" href="https://developers.google.com/search/docs/appearance/sitelinks-searchbox">Google Sitelinks search box</a> — a search input that appears directly in the Google result for the site. It maps to the Pagefind-powered search at <code>/search/</code>. This is one extra field on an existing schema with no downside.</p>
<h3>BlogPosting<a id="blogposting" href="#blogposting" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Blog posts carry the richest schema. Beyond <code>headline</code>, <code>description</code>, <code>url</code>, and <code>datePublished</code>, several fields matter for how Google represents the content:</p>
<ul>
<li><strong><code>inLanguage</code></strong> — <code>&quot;en&quot;</code> or <code>&quot;pl&quot;</code>, needed for multilingual indexing</li>
<li><strong><code>wordCount</code></strong> — computed at parse time by <code>ContentItem::wordCount()</code> (strips HTML tags, counts tokens)</li>
<li><strong><code>articleSection</code></strong> — the post category</li>
<li><strong><code>keywords</code></strong> — the post tags as a comma-separated string</li>
<li><strong><code>image</code></strong> — a nested <code>ImageObject</code> with <code>url</code>, <code>width</code>, and <code>height</code></li>
</ul>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;BlogPosting&quot;,
    &quot;headline&quot;: &quot;Post title&quot;,
    &quot;inLanguage&quot;: &quot;en&quot;,
    &quot;wordCount&quot;: 842,
    &quot;articleSection&quot;: &quot;tutorials&quot;,
    &quot;keywords&quot;: &quot;seo, symfony, static-site&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>Without <code>ImageObject</code>, Google treats the featured image as an unknown attachment. With width and height explicitly set, the image becomes eligible for large preview cards in Google Discover and Search.</p>
<h3>BreadcrumbList<a id="breadcrumblist" href="#breadcrumblist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Google can replace the raw URL in search results with breadcrumb navigation — &quot;Home / Blog / tutorials / Post Title&quot;. This requires <code>BreadcrumbList</code> schema.</p>
<p>It's rendered in <code>breadcrumb.html.twig</code> alongside the HTML nav. Each crumb is a <code>ListItem</code> with <code>position</code> and <code>item</code> (URL). The last item — the current page — has a name but no 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;Home&quot;, &quot;item&quot;: &quot;https://holas.pl/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 2, &quot;name&quot;: &quot;Blog&quot;, &quot;item&quot;: &quot;https://holas.pl/blog/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 3, &quot;name&quot;: &quot;tutorials&quot;, &quot;item&quot;: &quot;https://holas.pl/blog/tutorials/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 4, &quot;name&quot;: &quot;Post Title&quot; }
    ]
}
</code></pre>
<h3>CollectionPage + ItemList<a id="collectionpage--itemlist" href="#collectionpage--itemlist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Category, tag, and archive listing pages carry <code>CollectionPage</code> with a nested <code>ItemList</code>. Each entry has a <code>position</code> and <code>url</code>. Only rendered when the listing has posts — an empty category page doesn't get it.</p>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;CollectionPage&quot;,
    &quot;name&quot;: &quot;tutorials | 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/blog/post-name/&quot; }
        ]
    }
}
</code></pre>
<h2>Social Sharing<a id="social-sharing" href="#social-sharing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>OpenGraph Image Dimensions<a id="opengraph-image-dimensions" href="#opengraph-image-dimensions" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Without <code>og:image:width</code> and <code>og:image:height</code>, platforms like LinkedIn and Slack must fetch the image before rendering the preview card. With them, the card renders immediately:</p>
<pre><code class="language-html">&lt;!-- blog post (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;!-- other pages (default 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>The conditional is in <code>base.html.twig</code>: if a <code>content</code> object with an image is defined (blog post or page with featured image), use the WebP dimensions; otherwise use the defaults for <code>og-default.jpg</code>. The JPG exception exists because <code>og:image</code> is consumed by external crawlers that don't reliably support WebP.</p>
<h3>Twitter/X Card<a id="twitterx-card" href="#twitterx-card" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The base <code>twitter:card</code> type was already present. Three explicit fields were added:</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>Without them, Twitter/X falls back to OG properties. Explicit meta removes that dependency — if OG processing has any issue, Twitter Card still has the correct values.</p>
<h3>article:* Meta<a id="article-meta" href="#article-meta" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Blog posts get article-specific OG meta in <code>post.html.twig</code>'s <code>og_article_meta</code> block:</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;tutorials&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;static-site&quot;&gt;
</code></pre>
<p><code>article:tag</code> is one element per tag — not comma-separated. The Open Graph spec requires separate elements for multi-value properties.</p>
<h2>Feed Readers<a id="feed-readers" href="#feed-readers" 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>The default RSS <code>&lt;description&gt;</code> contains only the post excerpt — first paragraph, HTML stripped. <code>content:encoded</code> carries the full post HTML in a CDATA block:</p>
<pre><code class="language-xml">&lt;content:encoded&gt;&lt;![CDATA[&lt;p&gt;Full post content...&lt;/p&gt;]]&gt;&lt;/content:encoded&gt;
</code></pre>
<p>This requires <code>xmlns:content=&quot;http://purl.org/rss/1.0/modules/content/&quot;</code> on the root <code>&lt;rss&gt;</code> element. Feed readers like NetNewsWire, Reeder, and Feedbin render <code>content:encoded</code> inline — subscribers read the full article without leaving their reader.</p>
<h3>category and media:content<a id="category-and-mediacontent" href="#category-and-mediacontent" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Each RSS item gets <code>&lt;category&gt;</code> elements for the post category and each tag:</p>
<pre><code class="language-xml">&lt;category&gt;tutorials&lt;/category&gt;
&lt;category&gt;seo&lt;/category&gt;
&lt;category&gt;symfony&lt;/category&gt;
</code></pre>
<p><code>media:content</code> attaches the featured image as a typed media attachment:</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>Feed readers that render inline images (Feedly, Inoreader) use this for the post thumbnail in the feed list. This requires <code>xmlns:media=&quot;http://search.yahoo.com/mrss/&quot;</code> on the <code>&lt;rss&gt;</code> element.</p>
<h2>Crawling Signals<a id="crawling-signals" href="#crawling-signals" 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>The default robots behavior limits image previews in Google Search and Discover to standard size. <code>max-image-preview:large</code> opts into full-size previews. Combined with <code>max-snippet:-1</code> (no restriction on text snippet length), this is the default robots meta on every page:</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>Implemented as the default <code>{% block robots %}</code> in <code>base.html.twig</code>. Child templates override the block for pages that shouldn't be indexed — coming-soon pages use <code>noindex, nofollow</code>, the search page uses <code>noindex</code>.</p>
<h3>Image Sitemap<a id="image-sitemap" href="#image-sitemap" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The standard sitemap lists page URLs. The image sitemap adds <code>&lt;image:image&gt;</code> blocks, giving Google direct visibility into image locations and alt text without crawling every page first:</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/blog/post-name/&lt;/loc&gt;
        &lt;image:image&gt;
            &lt;image:loc&gt;https://holas.pl/media/post-dir/featured.webp&lt;/image:loc&gt;
            &lt;image:title&gt;Alt text from image_alt frontmatter&lt;/image:title&gt;
        &lt;/image:image&gt;
    &lt;/url&gt;
</code></pre>
<p><code>image:title</code> comes from the <code>image_alt</code> frontmatter field — the same text used in the HTML <code>alt</code> attribute. Both the <code>xmlns:image</code> namespace and the <code>image:image</code> block are in <code>sitemap.xml.twig</code>.</p>
<h2>What Changed<a id="what-changed" href="#what-changed" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The Lighthouse SEO score was 100 before these changes. It's still 100 after them. That score measures the technical floor: crawlability, meta tags, mobile-friendliness.</p>
<p>The changes above operate at a different level. Structured data shapes how search engines represent content in rich results. Explicit social meta ensures correct rendering without relying on platform fallback logic. RSS extensions let subscribers read full posts in their reader. The image sitemap gives Google image visibility without requiring a crawl of every page.</p>
<p>None of it is architecturally complex — most of it is Twig template additions and namespace declarations. The constraint is discipline: every field needs a real value from frontmatter, not a placeholder.</p>
<p>The architecture that makes all of this straightforward is covered in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a> — the static generation pipeline that produces complete HTML for every page.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-16-seo-engineering-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[seo]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[4×100 on Lighthouse Mobile — What a Static Site Actually Gets You]]></title>
            <link>https://holas.pl/blog/lighthouse-perfect-score/</link>
            <guid isPermaLink="true">https://holas.pl/blog/lighthouse-perfect-score/</guid>
                        <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl scores 100 in all four Lighthouse categories on mobile — Performance, Accessibility, Best Practices, and SEO. Mobile is the stricter test: slower simulated CPU, throttled network, tighter scoring thresholds. Getting all four to 100 there means desktop takes care of itself. This post goes through what actually drives each number. Most of it isn't optimisation work — it's a side effect of h…]]></description>
            <content:encoded><![CDATA[<p>holas.pl scores 100 in all four Lighthouse categories on mobile — <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagespeed.web.dev/analysis/https-holas-pl/g7kl99oxfg?form_factor=mobile">Performance, Accessibility, Best Practices, and SEO</a>. Mobile is the stricter test: slower simulated CPU, throttled network, tighter scoring thresholds. Getting all four to 100 there means desktop takes care of itself.</p>
<p><img src="/media/2026-05-10-lighthouse-perfect-score/lighthouse-scores.webp" alt="Lighthouse mobile: 4×100 — Performance, Accessibility, Best Practices, SEO" /></p>
<p>This post goes through what actually drives each number. Most of it isn't optimisation work — it's a side effect of how the site is built.</p>
<h2>Performance<a id="performance" href="#performance" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The main reason the performance score is 100 is that nginx serves pre-rendered HTML files with no PHP involved. There is no database query, no template rendering, no framework bootstrap on every request. A file comes off disk and goes to the client. A Raspberry Pi 5 handles this without breaking a sweat.</p>
<p>Everything else follows from there:</p>
<p><strong>Assets are hashed and immutable.</strong> JavaScript and CSS files compiled by Symfony's AssetMapper get a content hash in the filename (<code>app-a1b2c3d4.css</code>). nginx serves them with <code>Cache-Control: public, max-age=31536000, immutable</code> — one year, no revalidation. On repeat visits, the browser serves everything from cache. On deploy, the hash changes and the new file is fetched.</p>
<p><strong>JavaScript is minimal and non-blocking.</strong> The site uses native ES module imports via a browser-native importmap — no bundler, no webpack, no jQuery. There are seven small JS files: <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>. None of them block rendering. Search is powered by <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a>, a WASM-based static search index that loads lazily — only on the search page, only when needed.</p>
<p><strong>Images are WebP.</strong> Featured images are stored as WebP at 1280×720. No large uncompressed JPEGs.</p>
<p><strong>No render-blocking resources.</strong> There is no <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> to an external font CDN, no synchronous third-party script loaded in <code>&lt;head&gt;</code>. The CSS is compiled locally and served as a hashed asset.</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> is set per locale.</strong> Every page has the correct language attribute — <code>lang=&quot;en&quot;</code> for English pages, <code>lang=&quot;pl&quot;</code> for Polish. This is set in the base Twig template based on the current locale, not hardcoded.</p>
<p><strong>Semantic HTML throughout.</strong> The layout uses <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> — not a sequence of <code>&lt;div&gt;</code> elements. Headings follow a logical hierarchy: one <code>&lt;h1&gt;</code> per page, <code>&lt;h2&gt;</code> for top-level sections, <code>&lt;h3&gt;</code> below that.</p>
<p><strong>Color contrast passes.</strong> The site uses a Monokai-derived dark palette: <code>#F8F8F2</code> text on <code>#242424</code> background. That's a contrast ratio of 15.5:1, well above the WCAG AA threshold of 4.5:1.</p>
<p><strong>All images have alt attributes.</strong> This is enforced in the Twig templates — the <code>&lt;img&gt;</code> tag always outputs the alt text from the content item's frontmatter.</p>
<p><strong>Viewport meta tag is present.</strong> Every page includes <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> The site runs behind Cloudflare, which handles TLS termination. All HTTP requests are redirected to HTTPS.</p>
<p><strong>Security headers.</strong> nginx sets the full set on every response:</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>The CSP required some care — <code>wasm-unsafe-eval</code> for Pagefind's WASM bundle, and <code>challenges.cloudflare.com</code> as an allowed frame source for the Turnstile CAPTCHA on the contact form. Everything else is <code>'self'</code>.</p>
<p><strong>No deprecated APIs.</strong> The site doesn't use <code>document.write</code>, <code>XMLHttpRequest</code>, <code>&lt;table&gt;</code> layouts, or anything else Lighthouse flags as a deprecated practice.</p>
<p><strong>No mixed content.</strong> Every external resource (Cloudflare Turnstile script) is loaded over HTTPS.</p>
<h2>SEO<a id="seo" href="#seo" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Pre-rendered HTML.</strong> Crawlers receive complete HTML — every heading, paragraph, code block, and link is in the source. There is no client-side rendering to wait for, no JavaScript required to see the content.</p>
<p><strong>Sitemap with hreflang.</strong> The sitemap at <code>/sitemap.xml</code> lists all posts and pages for both locales. Each entry includes <code>&lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> pairs pointing to the EN and PL versions. If a post exists only in one language, the alternate entry is omitted.</p>
<p><strong>hreflang in <code>&lt;head&gt;</code>.</strong> Every page includes <code>&lt;link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> tags for both locales. The language switcher uses the same translation map — built from co-located <code>en.md</code>/<code>pl.md</code> files in each post directory.</p>
<p><strong>Canonical URLs.</strong> Each page includes <code>&lt;link rel=&quot;canonical&quot; href=&quot;...&quot;&gt;</code> pointing to the authoritative URL for that page.</p>
<p><strong>OpenGraph meta.</strong> Every page has <code>og:title</code>, <code>og:description</code>, <code>og:image</code>, and <code>og:url</code>. These are populated from frontmatter — <code>title</code>, <code>description</code>, and <code>image</code> fields map directly to the OG tags in the base template.</p>
<p><strong>Descriptive titles and meta descriptions.</strong> Frontmatter <code>title</code> and <code>description</code> fields are required. The site doesn't have any pages with default or missing meta descriptions.</p>
<h2>What wasn't automatic<a id="what-wasnt-automatic" href="#what-wasnt-automatic" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Most of the above follows from the architecture — static files, minimal JS, pre-rendered HTML. But a few things required deliberate work.</p>
<p><strong>Accessibility attributes.</strong> The <code>lang</code> attribute, alt text enforcement, heading hierarchy, and semantic element choices all had to be written into the templates. They don't appear by themselves.</p>
<p><strong>The CSP.</strong> Getting the Content-Security-Policy right took iteration. Pagefind uses WebAssembly, which requires <code>wasm-unsafe-eval</code>. Cloudflare Turnstile loads from <code>challenges.cloudflare.com</code> and needs a frame-src exception. Every third-party resource requires an explicit CSP exception — adding one without checking breaks the score.</p>
<p><strong>hreflang.</strong> The <code>TranslationMapBuilder</code> service builds the <code>{directoryKey → {locale → url}}</code> map from co-located content files. If a post exists only in one locale, the hreflang entry for the missing locale is omitted rather than pointing to a non-existent URL. This required a deliberate fallback in the template, not just a loop over all locales.</p>
<h2>The result<a id="the-result" href="#the-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The 4×100 isn't the outcome of an optimisation sprint. It's what you get when a site serves static files, uses minimal JavaScript, has proper HTML structure, and sets the security headers that should be on every production site anyway.</p>
<p>The architecture is described in detail in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a> (how Symfony generates the static HTML) and <a href="/blog/dev-experience-two-containers/">Part 4</a> (how nginx serves it in production).</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-10-lighthouse-perfect-score/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[accessibility]]></category>
                        <category><![CDATA[seo]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1 — Bare core, demo theme, living proof]]></title>
            <link>https://holas.pl/blog/notacms-1-1-bare-core-demo-theme/</link>
            <guid isPermaLink="true">https://holas.pl/blog/notacms-1-1-bare-core-demo-theme/</guid>
                        <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS 1.1.0 landed on April 24, 1.1.1 two days later, and 1.1.2 followed about a week on. Three releases that change not what notACMS does, but how you start with it — and what you get out of the box. notACMS grew out of my own site and for the first release it was one piece: clone the repo, get a full design, start overriding. It worked, but anyone who wanted to build their own look from scratc…]]></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> landed on April 24, <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.1">1.1.1</a> two days later, and <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.2">1.1.2</a> followed about a week on. Three releases that change not what notACMS does, but how you start with it — and what you get out of the box.</p>
<hr />
<p>notACMS grew out of my own site and for the first release it was one piece: clone the repo, get a full design, start overriding. It worked, but anyone who wanted to build their own look from scratch had to fight against things they didn't need. 1.1.0 fixes this by splitting core from the demo theme.</p>
<h2>The core is a skeleton<a id="the-core-is-a-skeleton" href="#the-core-is-a-skeleton" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>templates/</code>, <code>assets/</code>, <code>translations/</code> are now a minimal wireframe. System fonts, light mode, ~200 lines of CSS. All features work — blog, pages, search, RSS, sitemap, responsive images, contact form — but it looks like a site from the 90s. Deliberately. If you're building your own design, you don't fight a theme that imposes a visual language on you.</p>
<p>The demo theme lives in <code>docs/demo/</code> and is the default seed — <code>./notACMS deploy</code> or <code>ddev build</code> will lay it down on first run. Pass <code>--bare</code> if you want the skeleton instead:</p>
<pre><code class="language-bash">./notACMS deploy           # amber-phosphor (default), ready to tweak
./notACMS deploy --bare    # skeleton, build your own from scratch
</code></pre>
<p>Everything else — the <a href="/local-override-pattern/">local override pattern</a>, no core editing, clean <code>git pull</code> — works the same regardless of your choice.</p>
<h3>What else shipped in 1.1.0<a id="what-else-shipped-in-110" href="#what-else-shipped-in-110" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Reading time and reading progress on posts and docs. Language switcher as a Twig extension. Post excerpts that no longer leak <code>#</code> from heading anchors. PHPUnit test scaffolding under <code>tests/</code>. AI-agent skills for working with the repo. An old-template compatibility package at <code>docs/customization/old-template/</code> — one <code>cp -r</code> and you're back to the 1.0.0 look.</p>
<h2>1.1.1 — the patch that real use forced<a id="111--the-patch-that-real-use-forced" href="#111--the-patch-that-real-use-forced" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Preparing this post on holas.pl, deploying to production, I noticed something annoying: <code>./notACMS deploy --prod</code> backed up and replaced <code>local/</code> on every run. If you already had content there, it was gone. Deploy couldn't tell &quot;user wants to replace the whole theme&quot; from &quot;user just wants to build their site with existing content.&quot;</p>
<p>1.1.1 fixes it so deploy behaves like <code>ddev build</code>: it seeds <code>local/</code> only when the directory is missing or empty. Content you already have stays untouched. Want to force a re-seed? Pass <code>--bare</code> or <code>--demo</code> explicitly.</p>
<p>The other change is frontmatter-driven navigation labels. Until now every tab in the menu needed a translation key in every locale file — <code>nav.home</code>, <code>nav.about</code>, <code>site.releases</code> and so on. Adding a page meant updating N YAML files. Now <code>menu.label</code> in a page's frontmatter is enough:</p>
<pre><code class="language-yaml">---
title: &quot;Architecture guide&quot;
menu:
  label: &quot;Architecture&quot;
  weight: 30
---
</code></pre>
<p>A new <code>content_item()</code> Twig function reads it without extra config:</p>
<pre><code class="language-twig">{{ content_item('architecture-guide', 'en').menuLabel() }}
</code></pre>
<p>Polish, German, and French demo content got a full review across every page and blog post.</p>
<h2>1.1.2 — review-driven hardening<a id="112--review-driven-hardening" href="#112--review-driven-hardening" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>1.1.2 is what happens when you sit down with the code before tagging and look at it with fresh eyes. Two security bugs surfaced during the review and got fixed before release: an open-redirect through path normalisation (<code>Request::getPathInfo()</code> doesn't collapse repeated slashes, so <code>/&lt;default-locale&gt;//evil.com</code> would have produced a <code>Location: //evil.com</code> cross-origin redirect), and an XSS in the search results page where Pagefind's <code>excerpt</code> was injected into innerHTML without escaping.</p>
<p>The headline feature is canonical URLs. If your default locale is <code>en</code>, <code>/en/blog/</code> and <code>/blog/</code> were both reachable and rendered the same content — two indexable URLs for one page. A new event listener now issues <code>301</code>s from <code>/&lt;default-locale&gt;/...</code> to the unprefixed form before Symfony's router even runs.</p>
<p>Internally, the inline <code>|json_encode|raw</code> JSON-LD blocks scattered across templates are gone. A small <code>StructuredDataBuilder</code> service plus two Twig functions (<code>json_ld()</code> and <code>structured_data()</code>) replace them with a fluent, typed API. <code>JSON_THROW_ON_ERROR</code> is on, so a bad UTF-8 byte in frontmatter raises an exception during render instead of silently shipping <code>&lt;script&gt;false&lt;/script&gt;</code>. The bare core templates for <code>contact</code>, <code>default</code>, and <code>projects</code> pages now emit Schema.org markup too — bare deploys no longer have weaker SEO than every customisation example.</p>
<h2>The demo as living proof<a id="the-demo-as-living-proof" href="#the-demo-as-living-proof" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The most satisfying part of this release is not the code — it's what ships in <code>docs/demo/</code>. Not the theme. A complete, four-language site that lives in the repository. It has its own manual, architecture docs, styleguide, and a releases blog — all running, all rendered by the same system you get after <code>git clone</code>.</p>
<ul>
<li><strong><a href="/manual/">Manual</a></strong> — install, config, content structure, frontmatter, build commands, deploy, environment variables, troubleshooting</li>
<li><strong><a href="/architecture/">Architecture</a></strong> — routing, content pipeline, static build, search, multi-language, deployment</li>
<li><strong><a href="/styleguide/">Styleguide</a></strong> — every component documented with real SCSS tokens</li>
<li><strong><a href="/blog/releases/">Releases blog</a></strong> — posts about every version, rendered by the system itself</li>
</ul>
<p>&quot;I trust you that it works, but show me&quot; — this is that. The demo is not an example, it's proof. Multi-language routing, static pre-rendering, Pagefind search, responsive images, contact form, RSS, sitemap — everything runs in the demo content. Someone doing a fresh <code>git clone</code> followed by <code>ddev build</code> sees exactly this site.</p>
<h2>Links<a id="links" href="#links" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Full changelog with every change categorised: <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>Breaking changes and migration from 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>Repository: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / holas1337/notACMS</a> — Apache 2.0.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-04-notacms-1-1/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></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[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Building holas.pl with AI — Claude Code, MCP, and Local Image Generation]]></title>
            <link>https://holas.pl/blog/building-with-ai-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/blog/building-with-ai-claude-code/</guid>
                        <pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 5 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 4 covers the developer experience and deployment. Building holas.pl involved writing a fair amount of PHP, Twig, and SCSS — and making architectural decisions that would be annoying to undo later. I used Claude Code as an AI pair programmer throughout. This post covers what that wo…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 5 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/dev-experience-two-containers/">Part 4</a> covers the developer experience and deployment.</em></p>
<hr />
<p>Building holas.pl involved writing a fair amount of PHP, Twig, and SCSS — and making architectural decisions that would be annoying to undo later. I used <a rel="nofollow noopener noreferrer" target="_blank" href="https://claude.ai/code">Claude Code</a> as an AI pair programmer throughout. This post covers what that workflow actually looks like, where it works well, and where it still needs human judgment.</p>
<h2>AGENTS.md — The AI's Instruction Manual<a id="agentsmd--the-ais-instruction-manual" href="#agentsmd--the-ais-instruction-manual" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first practical insight from this project: an AI assistant is only as good as the instructions it's given. Without explicit guidance, Claude defaults to generating functional but generic code — reasonable choices, but not necessarily the choices you'd make yourself.</p>
<p>The solution is <code>AGENTS.md</code>, a file in the project root that Claude Code reads at the start of every session. It documents:</p>
<ul>
<li><strong>Architecture rules</strong> — final classes only, no inheritance, interface segregation, value objects over associative arrays</li>
<li><strong>Code style</strong> — strict types on every file, Yoda conditions, blank line before return, PSR-4 namespace convention</li>
<li><strong>Naming conventions</strong> — interface naming (<code>ContentServiceInterface</code> → <code>ContentService</code>), readonly value objects, constructor property promotion</li>
<li><strong>DDEV commands</strong> — how to start the environment, run builds, check code quality</li>
<li><strong>Content structure</strong> — where Markdown files live, how frontmatter works, what the URL slug conventions are</li>
</ul>
<p>With this context, Claude generates code that follows the project's actual conventions. Reviewing a generated class feels like reviewing a pull request from a teammate who has read the style guide — not reviewing output that needs to be translated into project conventions.</p>
<h2>EDITOR_GUIDE.md — Delegating Content Creation<a id="editorguidemd--delegating-content-creation" href="#editorguidemd--delegating-content-creation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The same principle applies to content. <code>EDITOR_GUIDE.md</code> documents:</p>
<ul>
<li>Title format and length (under 70 characters, technology names included, no clickbait)</li>
<li>Description format (120–160 characters, no &quot;In this post...&quot;, lead with reader benefit)</li>
<li>Intro structure (2–3 sentences, no greeting, problem first)</li>
<li>Body conventions (code blocks for all commands, short paragraphs, numbered steps for procedures)</li>
<li>EN/PL parity requirements (both versions equal depth, same code blocks)</li>
<li>Frontmatter fields and their formats</li>
</ul>
<p>With this guide, the content creation workflow becomes:</p>
<ol>
<li>Write the post in rough form — the ideas, the code snippets, the structure</li>
<li>Hand it to Claude with: &quot;proofread this, correct the English, translate to Polish, and produce the two <code>.md</code> files with correct frontmatter&quot;</li>
<li>Review the result</li>
</ol>
<p>The guide is specific enough that Claude doesn't need to ask clarifying questions. Title format, slug convention, category values, tag format, image path pattern — it's all documented. The output is ready to commit.</p>
<h2>Image Generation with Draw Things and MCP<a id="image-generation-with-draw-things-and-mcp" href="#image-generation-with-draw-things-and-mcp" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every post needs a featured image (minimum 1200×630px). For holas.pl, images are generated locally using <a rel="nofollow noopener noreferrer" target="_blank" href="https://drawthings.ai/">Draw Things</a> via the MCP (Model Context Protocol) integration in Claude Code.</p>
<p>The workflow:</p>
<ol>
<li>Claude Code calls <code>mcp__draw-things__generate_image</code> with a prompt describing the image, 4 parallel variants at 1024×576px with <code>steps=4</code></li>
<li>The best variant is selected from <code>.generated/YYYY-MM-DD-session-name/</code></li>
<li>ImageMagick upscales it to production size:</li>
</ol>
<pre><code class="language-bash">ddev exec convert .generated/session/image.jpg \
    -resize 1920x1080! -filter Lanczos -quality 92 \
    assets/images/output.jpg
</code></pre>
<ol start="4">
<li>The image is moved to <code>content/blog/category/post-name/files/</code> and referenced in frontmatter</li>
</ol>
<p>The <code>.generated/</code> directory is gitignored — it holds expendable previews. Only approved images committed to <code>files/</code> become production assets.</p>
<p>MCP (Model Context Protocol) is what makes this work: it's a standard interface for connecting AI assistants to external tools. Claude Code connects to Draw Things running locally, Hugging Face Spaces, and other services without leaving the development session. The image generation happens on the local machine — no API quota, no external service, no per-image cost.</p>
<h2>What AI Does Well<a id="what-ai-does-well" href="#what-ai-does-well" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Boilerplate and patterns</strong> — generating a new service with its interface, value object, and correct import ordering is instant. The structure is consistent with the rest of the codebase because the conventions are documented.</p>
<p><strong>SCSS from description</strong> — describing a component layout in words and getting working SCSS back, with the project's variable names, is faster than writing it from scratch.</p>
<p><strong>Translation</strong> — Polish and English content at equal quality. The editorial guide's specificity about what &quot;equal quality&quot; means (same code blocks, same depth, not a summary) produces translations that don't need significant editing.</p>
<p><strong>Repetitive structured work</strong> — generating nginx redirect lists, updating frontmatter across multiple files, writing sitemap entries — tasks with clear rules but many instances.</p>
<p><strong>Staying in context</strong> — Claude Code reads the project files, understands the existing patterns, and generates code that fits without being told what every class does.</p>
<h2>Where Human Judgment Is Still Required<a id="where-human-judgment-is-still-required" href="#where-human-judgment-is-still-required" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Architecture decisions</strong> — which abstractions to introduce, when a value object is warranted, how to structure the content pipeline — these require understanding the trade-offs in a way that goes beyond pattern matching. The AI generates plausible options; the decision is still human.</p>
<p><strong>Design aesthetics</strong> — SCSS variables can be generated, but deciding whether the color palette looks right on a dark terminal-style background requires eyes and taste.</p>
<p><strong>Content voice</strong> — the editorial guide captures tone conventions, but the actual ideas — what's worth writing about, what angle is interesting — come from experience, not from a prompt.</p>
<p><strong>Reviewing AI output</strong> — the code and content still need to be read. AI-generated code is plausible-looking; it takes a developer to notice when something is technically correct but architecturally wrong.</p>
<h2>The Practical Result<a id="the-practical-result" href="#the-practical-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire site — architecture, frontend, content pipeline, contact form, deployment scripts, this blog post series — was built with Claude Code. The time investment in <code>AGENTS.md</code> and <code>EDITOR_GUIDE.md</code> paid back immediately: less time correcting style, less time explaining the same conventions session after session, more time on the actual work.</p>
<p>The most surprising benefit was content. Writing a post in rough Polish, having it proofread, translated to English, and converted to two properly-structured <code>.md</code> files with correct frontmatter in one step — that removes enough friction that publishing actually happens.</p>
<p>The architecture built this way doesn't just produce maintainable code — it produces measurable results. The <a href="/blog/lighthouse-perfect-score/">next post</a> looks at the Lighthouse scores: what drives each of the four metrics and why most of it follows from the architecture, not from optimisation work.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-05-02-building-with-ai-claude-code/building-with-ai-claude-code.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[Developer Experience — From Local Dev to Production in Two Containers]]></title>
            <link>https://holas.pl/blog/dev-experience-two-containers/</link>
            <guid isPermaLink="true">https://holas.pl/blog/dev-experience-two-containers/</guid>
                        <pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 4 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 3 covers the contact form security. One of the goals for holas.pl was a development environment as simple as the production one. No database to start, no Docker networking to configure by hand, no five-minute startup sequence. The result is a DDEV-based dev setup that starts with a…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 4 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/contact-form-security/">Part 3</a> covers the contact form security.</em></p>
<hr />
<p>One of the goals for holas.pl was a development environment as simple as the production one. No database to start, no Docker networking to configure by hand, no five-minute startup sequence. The result is a DDEV-based dev setup that starts with a single command and a production stack that runs in two containers.</p>
<h2>Development with DDEV<a id="development-with-ddev" href="#development-with-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> handles the local development environment. The entire setup is in <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>The <code>omit_containers: [db]</code> line is notable — there's no database, so there's no database container. DDEV's default MySQL/MariaDB setup is skipped entirely. <code>ddev start</code> spins up nginx + PHP-FPM and nothing else.</p>
<p>In development the content tree is rebuilt on every request, so editing a Markdown file and refreshing the browser shows the change immediately. No cache to clear. Symfony's debug toolbar is available. Draft posts are visible.</p>
<h2>The Build Command<a id="the-build-command" href="#the-build-command" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev build</code> runs the full pipeline:</p>
<pre><code class="language-bash">rm -rf var/dart-sass         # remove arch-specific binary (see below)
php bin/console cache:clear
php bin/console sass:build   # compile SCSS via dart-sass
php bin/console asset-map:compile  # fingerprint assets
php bin/console app:build    # render all URLs to public/static/
npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>After this, <code>public/static/</code> contains the complete site as HTML files. nginx serves from that directory. The dart-sass step removes the platform-specific binary before the build to force a fresh download — more on this below.</p>
<h2>Code Quality Checks<a id="code-quality-checks" href="#code-quality-checks" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev code-check</code> runs four checks in sequence:</p>
<pre><code class="language-bash">composer validate --strict
composer audit --no-dev         # checks for known CVEs in dependencies
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse      # level 6
</code></pre>
<p><code>ddev code-fix</code> auto-fixes PHP CS Fixer issues and re-runs the check. PHPStan level 6 catches missing type hints, wrong argument types, and unknown methods before they reach production.</p>
<h2>The Styleguide<a id="the-styleguide" href="#the-styleguide" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A dev-only page at <code>/styleguide/</code> documents every component in the UI using the actual CSS classes — not wrappers or snapshots. The page is served only in the <code>dev</code> environment (the controller throws a 404 in production) and is not included in the static build.</p>
<p>The value of the styleguide is in development: changing a component's SCSS immediately updates the styleguide. There's no separate design system to keep in sync.</p>
<h2>Asset Pipeline Without Node.js<a id="asset-pipeline-without-nodejs" href="#asset-pipeline-without-nodejs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>SCSS is compiled by <code>symfonycasts/sass-bundle</code>, which wraps dart-sass and requires no Node.js installation. The single entrypoint <code>assets/styles/app.scss</code> imports all partials. In development it compiles on-the-fly. In production <code>php bin/console sass:build</code> runs before the build.</p>
<p>JavaScript modules are handled by Symfony's AssetMapper — no webpack, no Vite, no rollup. Scripts are loaded as plain <code>&lt;script src=&quot;...&quot;&gt;</code> tags with content-hashed filenames. The import map registers only the main <code>app.js</code> entrypoint; other scripts (contact form, search, cookie banner) are loaded separately via <code>{{ asset('script.js') }}</code> in templates.</p>
<p><strong>The dart-sass binary problem:</strong> <code>symfonycasts/sass-bundle</code> downloads a platform-specific dart-sass binary to <code>var/dart-sass/</code>. The binary compiled on a development machine (x86_64) won't run in the production Docker container (also x86_64 in this case, but the binary path and version can differ). The solution is straightforward: delete the binary before every build and let dart-sass download the correct one for the current platform.</p>
<h2>Production: Two Containers<a id="production-two-containers" href="#production-two-containers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The production <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>Two containers: nginx and PHP-FPM. No database container. nginx mounts the project read-only; PHP-FPM runs as the host user to avoid file permission issues with the Symfony cache.</p>
<p>The PHP image (<code>docker/Dockerfile</code>) is <code>php:8.5-fpm-alpine</code> with only what's needed: <code>icu-dev</code> (Symfony intl), <code>nodejs</code> and <code>npm</code> (for the Pagefind build step), <code>unzip</code>, <code>git</code>, and Composer.</p>
<h2>Deployment<a id="deployment" href="#deployment" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire deployment is a single script <code>./deploy.sh --prod</code>:</p>
<ol>
<li><code>docker compose down</code></li>
<li><code>docker compose build --pull</code> — rebuilds the PHP image from scratch</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> — remove the arch-specific binary</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> — render all static HTML</li>
<li><code>npx --yes pagefind --site public/static --output-path public/pagefind</code></li>
</ol>
<p>The build runs inside the PHP container where the correct CPU architecture is known. After step 10, nginx is already serving the previous build's static files. The new files replace them atomically at the filesystem level. There's a brief window where a partial build is live, but for a low-traffic portfolio site this is acceptable without blue-green deployment complexity.</p>
<p>No CI/CD pipeline, no staging environment. Deployment is <code>ssh server</code>, <code>cd holas.pl</code>, <code>git pull</code>, <code>./deploy.sh --prod</code>.</p>
<h2>Compared to WordPress<a id="compared-to-wordpress" href="#compared-to-wordpress" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The full WordPress production stack required: PHP-FPM, MySQL, a caching layer (Redis or filesystem), a scheduled task runner for wp-cron, and enough RAM to keep MySQL warm. Updates to any component required downtime or careful sequencing.</p>
<p>The current stack is two containers. A Raspberry Pi 5 runs it without memory pressure. Deployment is a shell script. The entire codebase, content, and configuration fits in a single git repository. Backup is <code>git push</code>.</p>
<p>The <a href="/blog/building-with-ai-claude-code/">next post in the series</a> covers the most unconventional part of this project: building the entire site with Claude Code as an AI pair programmer, and using MCP tools to generate featured images locally.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-27-dev-experience-two-containers/dev-experience-two-containers.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></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[Building a Tool Decision Tree for Claude Code with Global Memory]]></title>
            <link>https://holas.pl/blog/building-tool-decision-tree-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/blog/building-tool-decision-tree-claude-code/</guid>
                        <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Claude Code can connect to external tools through MCP (Model Context Protocol) servers — Sentry for production errors, JetBrains for IDE introspection, Context7 for library docs, Perplexity for web search. The problem: with six MCP servers available, Claude doesn't always pick the right one. It might grep through 20 files to find a Symfony route when JetBrains can return it in one call, or query C…]]></description>
            <content:encoded><![CDATA[<p>Claude Code can connect to external tools through MCP (Model Context Protocol) servers — Sentry for production errors, JetBrains for IDE introspection, Context7 for library docs, Perplexity for web search. The problem: with six MCP servers available, Claude doesn't always pick the right one. It might grep through 20 files to find a Symfony route when JetBrains can return it in one call, or query Context7 for &quot;latest PHP version&quot; when only Perplexity has current data.</p>
<p>The fix is a global <code>CLAUDE.md</code> — a persistent instruction file that teaches Claude which tool to reach for based on the query type. This post walks through my setup, the decision tree I built, and how you can build one for your own stack.</p>
<h2>The Problem: Too Many Tools, No Strategy<a id="the-problem-too-many-tools-no-strategy" href="#the-problem-too-many-tools-no-strategy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>MCP servers give Claude Code superpowers: browser automation, error tracking, documentation lookup, IDE integration. But more tools means more choices, and without guidance Claude makes reasonable but suboptimal picks.</p>
<p>Common failure modes I observed:</p>
<ul>
<li><strong>Wrong tool for the job</strong> — querying Context7 for &quot;latest Symfony version&quot; (it only has docs, not release metadata) instead of Perplexity</li>
<li><strong>Expensive path when a cheap one exists</strong> — using Grep to search for Symfony routes across multiple files instead of asking JetBrains MCP for a structured route list</li>
<li><strong>Missing the specialist</strong> — not checking Sentry for a production error when the stack trace would immediately reveal the root cause</li>
<li><strong>Redundant queries</strong> — trying multiple tools sequentially when the memory could route to the right one immediately</li>
</ul>
<p>The solution is explicit routing rules stored in Claude Code's global <code>CLAUDE.md</code>. Claude reads this file at the start of every session, so the decision tree is always available.</p>
<h2>My MCP Stack<a id="my-mcp-stack" href="#my-mcp-stack" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Here are the six MCP servers I run and what each does best.</p>
<h3>Context7 — Library and Framework Docs<a id="context7--library-and-framework-docs" href="#context7--library-and-framework-docs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Context7 serves versioned documentation with code examples. You give it a library name and a question, and it returns the relevant section from official docs.</p>
<p><strong>Workflow:</strong> <code>resolve-library-id</code> (find the library) → <code>query-docs</code> (fetch docs for a specific question). It supports versioned lookups — I can query <code>/sylius/sylius/v1.14.6</code> specifically, not just &quot;latest.&quot;</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Method signatures and configuration examples</li>
<li>Migration guides between framework versions</li>
<li>How-to patterns from official docs (Symfony forms, Doctrine mappings, API Platform filters)</li>
</ul>
<p><strong>Fails for:</strong></p>
<ul>
<li>Current version numbers or release dates (it has docs, not metadata)</li>
<li>PHP language features (PHP itself isn't a library with versioned docs in Context7)</li>
<li>Security CVEs or advisories</li>
<li>Anything that requires real-time information</li>
</ul>
<h3>Perplexity — Current Facts and Research<a id="perplexity--current-facts-and-research" href="#perplexity--current-facts-and-research" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Perplexity is an AI-powered web search with four modes: <code>search</code> (web results with citations), <code>ask</code> (AI-synthesized answers via sonar-pro), <code>research</code> (deep multi-source analysis, 30+ seconds), and <code>reason</code> (step-by-step logical reasoning).</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Latest version numbers, release dates, EOL schedules</li>
<li>Security advisories and CVE details</li>
<li>PHP language features and RFCs (property hooks, asymmetric visibility — these aren't in Context7)</li>
<li>External service pricing (API costs, hosting comparisons)</li>
<li>General programming best practices and benchmarks</li>
</ul>
<p><strong>Key role:</strong> Perplexity fills every gap Context7 has. When Context7 returns nothing or returns stale docs, Perplexity almost always has the answer.</p>
<h3>JetBrains — IDE-Level Code Intelligence<a id="jetbrains--ide-level-code-intelligence" href="#jetbrains--ide-level-code-intelligence" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>This is the MCP server that saves the most tokens. JetBrains MCP connects Claude Code to your IDE's index — the same index that powers autocomplete, go-to-definition, and refactoring. The base JetBrains MCP provides generic tools (file search, symbol lookup, text search, terminal commands), and framework plugins extend it with specialized tools — the Symfony plugin adds route listing, service lookup, Doctrine entity inspection, and Twig analysis.</p>
<p><strong>My typical workflow:</strong> I paste a Jira ticket link into the conversation. Claude reads the task via the Atlassian MCP, then uses JetBrains MCP to do a quick code reconnaissance — finding the relevant services, checking route definitions, inspecting entity fields — all before writing a single line of code. This &quot;analyze first&quot; step catches misunderstandings early and gives Claude the context to ask better clarifying questions.</p>
<p><strong>Symfony-specific capabilities</strong> (via the Symfony plugin):</p>
<ul>
<li><code>list_symfony_routes_controllers</code> — all routes with controller, path, methods. One call instead of grepping through attributes across dozens of files</li>
<li><code>locate_symfony_service</code> — find any service definition by its fully-qualified class name</li>
<li><code>list_doctrine_entity_fields</code> — entity fields, types, and relationships in structured format</li>
<li><code>list_symfony_commands</code>, <code>list_symfony_forms</code> — console commands and form types at a glance</li>
</ul>
<p><strong>Token savings example:</strong> Finding all routes matching <code>/api/</code> in a Symfony project:</p>
<ul>
<li><strong>Without JetBrains:</strong> Grep for <code>#[Route</code> across <code>src/Controller/</code>, read each matching file, parse the route attributes, cross-reference with <code>_routes.yaml</code>. Easily 5-10 tool calls and thousands of tokens of file content.</li>
<li><strong>With JetBrains:</strong> One <code>list_symfony_routes_controllers</code> call returns a structured, filterable list. Done.</li>
</ul>
<p><strong>Also useful for:</strong> Indexed text search (<code>search_in_files_by_text</code>), file search by name, symbol lookup, running terminal commands in the IDE, build/test execution.</p>
<h3>Chrome DevTools — Browser Automation<a id="chrome-devtools--browser-automation" href="#chrome-devtools--browser-automation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Chrome DevTools MCP lets Claude control a browser: navigate to URLs, click elements, fill forms, take screenshots, inspect network requests, run JavaScript, and execute Lighthouse audits.</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Testing UI changes visually after modifying templates or CSS</li>
<li>Running Lighthouse performance/accessibility audits</li>
<li>Debugging frontend issues (checking console errors, network requests)</li>
<li>Verifying responsive behavior at different viewport sizes</li>
</ul>
<h3>Sentry — Production Error Tracking<a id="sentry--production-error-tracking" href="#sentry--production-error-tracking" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Sentry MCP connects Claude to your error tracking system. It can search issues, retrieve stack traces, analyze errors with Sentry's AI (Seer), and look up release and deployment information.</p>
<p><strong>The workflow that makes this valuable:</strong></p>
<ol>
<li>You notice an error (or a user reports one)</li>
<li>Claude queries Sentry: &quot;search for 500 errors in the last 24 hours&quot;</li>
<li>Sentry returns the stack trace, affected users count, first/last seen timestamps</li>
<li>Claude reads the relevant source file, identifies the root cause, and proposes a fix</li>
<li>The entire debug cycle happens without leaving the terminal</li>
</ol>
<p><strong>Best for:</strong></p>
<ul>
<li>Investigating production errors with full stack traces</li>
<li>Understanding error frequency and patterns (is it new? is it getting worse?)</li>
<li>Correlating errors with recent deployments</li>
</ul>
<h3>Atlassian/Jira — Task Management<a id="atlassianjira--task-management" href="#atlassianjira--task-management" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Jira MCP provides full issue lifecycle management: create, read, edit, transition, comment, and search with JQL.</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Reading task specifications before starting work</li>
<li>Updating issue status as work progresses</li>
<li>Adding technical comments to issues for team visibility</li>
<li>JQL searches to find related issues or check what's in the current sprint</li>
</ul>
<h2>The Decision Tree<a id="the-decision-tree" href="#the-decision-tree" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The core of the global <code>CLAUDE.md</code> is a routing table that maps query types to the best tool. Here's the actual content from mine:</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>The key pattern: each section leads with &quot;best for&quot; (when to pick this tool) and ends with &quot;fails for&quot; (when to skip it). Claude uses both signals — positive routing and negative routing.</p>
<h3>The Benchmark Table<a id="the-benchmark-table" href="#the-benchmark-table" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>I ran the same 10 query types through Context7, Perplexity, and WebSearch and rated the results. This table lives in the global <code>CLAUDE.md</code> so Claude can reference it when deciding:</p>
<table>
<thead>
<tr>
<th>Query type</th>
<th>Context7</th>
<th>Perplexity</th>
<th>WebSearch</th>
</tr>
</thead>
<tbody>
<tr>
<td>Versioned library docs</td>
<td>★★★★★</td>
<td>★★★★</td>
<td>★★★</td>
</tr>
<tr>
<td>Current version/release info</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Code examples from official docs</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>PHP language features</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Framework how-to (API Platform etc.)</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Security CVEs / advisories</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>General programming (benchmarks etc.)</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 / new features</td>
<td>★★★★</td>
<td>★★★</td>
<td>★★★★★</td>
</tr>
<tr>
<td>External service pricing</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★★</td>
</tr>
</tbody>
</table>
<p>The pattern is clear: Context7 is excellent for versioned docs but scores zero on anything requiring current or real-world data. Perplexity covers nearly everything. WebSearch is the strongest for blog posts and release announcements.</p>
<p>Including this table in the global <code>CLAUDE.md</code> gives Claude a quantitative basis for tool selection, not just rules.</p>
<h2>How Global Memory Works<a id="how-global-memory-works" href="#how-global-memory-works" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Claude Code supports a global instruction file at <code>~/.claude/CLAUDE.md</code>. This file is loaded at the start of every conversation, regardless of which project you're working in. It's the right place for tool routing rules because MCP servers are configured globally, not per-project.</p>
<p>Compare this with project-level <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>Scope</td>
<td>One project</td>
<td>All projects</td>
</tr>
<tr>
<td>Content</td>
<td>Code conventions, architecture, commands</td>
<td>Tool routing, personal preferences</td>
</tr>
<tr>
<td>Example</td>
<td>&quot;Use <code>ddev exec</code> for all PHP commands&quot;</td>
<td>&quot;Use Context7 for Symfony docs&quot;</td>
</tr>
</tbody>
</table>
<h3>Structuring the Global CLAUDE.md<a id="structuring-the-global-claudemd" href="#structuring-the-global-claudemd" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The global <code>CLAUDE.md</code> works best when it's structured like a reference manual, not a narrative. Claude scans it at session start — clear headings and explicit rules make that scan effective.</p>
<p><strong>Tips from my experience:</strong></p>
<ol>
<li><strong>Lead with the decision rule, not the description.</strong> &quot;Best for: versioned library docs&quot; is more useful than &quot;Context7 is a documentation server that...&quot;</li>
<li><strong>Include failure modes.</strong> &quot;Fails for: current versions&quot; prevents Claude from trying Context7 for queries it can't handle.</li>
<li><strong>Add a server inventory.</strong> List every MCP server with its tools — Claude can reference this when it needs a capability it hasn't used before.</li>
<li><strong>Use concrete examples.</strong> &quot;Latest Symfony version → Perplexity&quot; beats &quot;use Perplexity for current data.&quot;</li>
<li><strong>Update when tools change.</strong> Added a new MCP server? Update the file. Removed one? Remove its entry. Stale routing rules are worse than no rules.</li>
</ol>
<h2>Build Your Own<a id="build-your-own" href="#build-your-own" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The specific MCP servers don't matter — the pattern does. Whether you use Cursor, Windsurf, or Claude Code, whether you write Python or Go, the principle is the same: teach your AI assistant which tool to reach for.</p>
<p><strong>Step-by-step:</strong></p>
<ol>
<li><strong>List your MCP servers</strong> (or equivalent tool integrations) and what each one does</li>
<li><strong>Identify overlaps</strong> — where can two tools answer the same query? (Context7 and Perplexity both handle Symfony docs, but with different strengths)</li>
<li><strong>Benchmark</strong> — run the same 5-10 representative queries through each overlapping tool. Rate the results. This gives you data, not gut feeling.</li>
<li><strong>Write routing rules</strong> — for each tool, write &quot;best for&quot; and &quot;fails for&quot; sections with specific query types</li>
<li><strong>Include the benchmark</strong> — the table gives your AI a quantitative reference, not just instructions</li>
<li><strong>Iterate</strong> — the first version won't be perfect. When Claude picks the wrong tool, update the file. Over a few sessions, the routing gets tight.</li>
</ol>
<p>My global <code>CLAUDE.md</code> started as a list of MCP servers with one-line descriptions. After a few weeks of observing where Claude made suboptimal choices, it evolved into the decision tree above. The benchmark table was the biggest single improvement — it turned vague &quot;prefer X over Y&quot; rules into concrete data Claude could act on.</p>
<p>The investment is small (an hour to set up, a few minutes per update) and the payoff compounds: fewer wasted tokens, faster answers, and less time correcting tool choices mid-conversation.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-20-claude-code-mcp-memory/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[dev-tools]]></category>
                    </item>
                <item>
            <title><![CDATA[Securing a Static Site's Only Dynamic Endpoint — The Contact Form]]></title>
            <link>https://holas.pl/blog/contact-form-security/</link>
            <guid isPermaLink="true">https://holas.pl/blog/contact-form-security/</guid>
                        <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 3 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 2 covers the architecture. holas.pl is a static site with one exception: the contact form. Every blog post, category page, and static page is a pre-rendered HTML file served by nginx. The contact form is the only endpoint that runs PHP. That single exception raises a specific secur…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 3 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/symfony-static-site-generator/">Part 2</a> covers the architecture.</em></p>
<hr />
<p>holas.pl is a static site with one exception: the contact form. Every blog post, category page, and static page is a pre-rendered HTML file served by nginx. The contact form is the only endpoint that runs PHP.</p>
<p>That single exception raises a specific security question: how do you protect a form on a static page from bot submissions and CSRF attacks when there's no session?</p>
<h2>The CSRF Problem with Static Pages<a id="the-csrf-problem-with-static-pages" href="#the-csrf-problem-with-static-pages" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Standard CSRF protection works by embedding a token in the form that is tied to the user's session. When the form is submitted, the server verifies the token matches the one in the session.</p>
<p>Static pages don't have sessions. The contact page is generated once during <code>ddev build</code> and served as a file. It can't generate a per-user token at render time. Symfony's built-in <code>csrf_protection</code> is therefore explicitly disabled on the contact form:</p>
<pre><code class="language-php">public function configureOptions(OptionsResolver $resolver): void
{
    $resolver-&gt;setDefaults([
        'csrf_protection' =&gt; false,  // deliberate — static page, no session
    ]);
}
</code></pre>
<p>Disabling CSRF protection is the right call here. The alternative — adding a dynamic PHP endpoint just to generate tokens for the form — would reintroduce the PHP-on-every-request problem for a page that otherwise needs none.</p>
<h2>Cloudflare Turnstile as the Replacement<a id="cloudflare-turnstile-as-the-replacement" href="#cloudflare-turnstile-as-the-replacement" 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> is a CAPTCHA alternative that runs client-side, confirms the user is human, and provides a one-time token that can be verified server-side. It replaces CSRF as the bot-prevention layer.</p>
<p>The form flow:</p>
<ol>
<li>The contact page is served as static HTML with the Turnstile widget embedded (site key baked in at build time)</li>
<li>Turnstile runs its challenge invisibly; on success it calls <code>window.onTurnstileSuccess(token)</code>, which writes the token into a hidden field</li>
<li><code>contact.js</code> intercepts the form <code>submit</code> event and sends the data via <code>fetch()</code> instead of a standard form post</li>
<li>The PHP endpoint receives the submission, validates the form fields, then verifies the Turnstile token with Cloudflare's API</li>
<li>Only if both pass does the email get sent</li>
</ol>
<p>Server-side verification is the critical step:</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('Turnstile verification failed: '.$e-&gt;getMessage());

            return false;
        }
    }
}
</code></pre>
<p>The validator fails closed — any exception returns <code>false</code> and blocks the submission. An empty or missing token also returns <code>false</code> immediately.</p>
<h2>Minimal PHP Attack Surface<a id="minimal-php-attack-surface" href="#minimal-php-attack-surface" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire application's PHP surface in production is two URL patterns:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
    fastcgi_read_timeout 30;
}
</code></pre>
<p>Everything else — every blog post, every category page, the sitemap, the RSS feed, the search page — is handled by nginx serving static files. PHP-FPM is never invoked for content delivery. The attack surface for the PHP layer is a single POST endpoint.</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>The nginx config applies a strict CSP on every response:</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>The only external domain permitted is <code>challenges.cloudflare.com</code>, which Turnstile requires for its script, iframe, and API call. No Google Analytics, no CDN scripts, no Facebook pixel. <code>script-src</code> has no <code>'unsafe-inline'</code> and no <code>'unsafe-eval'</code> — all JavaScript is loaded from hashed files via Symfony AssetMapper.</p>
<p>The <code>'unsafe-inline'</code> in <code>style-src</code> is a deliberate compromise: Turnstile's widget injects inline styles that can't be avoided without a CSP nonce, and AssetMapper doesn't currently inject nonces. Everything else is locked down.</p>
<h2>Additional Security Headers<a id="additional-security-headers" href="#additional-security-headers" 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>Hidden files are denied:</p>
<pre><code class="language-nginx">location ~ /\. { deny all; }
</code></pre>
<p>HSTS is handled upstream at Cloudflare, so there's no <code>Strict-Transport-Security</code> header in the nginx config — it would be redundant.</p>
<h2>The Result<a id="the-result" href="#the-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The contact form works without sessions, without CSRF tokens, and without JavaScript being required for anything other than the form submission itself. The page renders fully as static HTML. Bot submissions are blocked by Turnstile's server-side verification. The PHP endpoint is unreachable for anything other than POST requests to <code>/api/contact</code>.</p>
<p>Compared to WordPress with a contact form plugin, the attack surface went from &quot;PHP running on every request, wp-admin exposed, XML-RPC enabled, 22 plugins any of which could have a vulnerability&quot; to &quot;one POST endpoint with Cloudflare verification.&quot;</p>
<p>The <a href="/blog/dev-experience-two-containers/">next post</a> covers the developer experience — DDEV, the build pipeline, and deployment to production in two Docker containers.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-14-contact-form-security/contact-form-security.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[cloudflare]]></category>
                        <category><![CDATA[nginx]]></category>
                    </item>
                <item>
            <title><![CDATA[# notACMS — Symfony Static Site Generator]]></title>
            <link>https://holas.pl/blog/notacms-static-site-generator/</link>
            <guid isPermaLink="true">https://holas.pl/blog/notacms-static-site-generator/</guid>
                        <pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS is a Symfony-based static site generator I built in early 2026 to replace 19 years of WordPress on holas.pl. No database, no CMS admin panel, no PHP involved in serving content. The entire site — blog posts, category pages, tag listings, archive months, search, RSS feed, sitemap — is pre-rendered to static HTML files and served by nginx. The codebase is open-source under Apache 2.0 — see G…]]></description>
            <content:encoded><![CDATA[<p>notACMS is a Symfony-based static site generator I built in early 2026 to replace 19 years of WordPress on holas.pl. No database, no CMS admin panel, no PHP involved in serving content. The entire site — blog posts, category pages, tag listings, archive months, search, RSS feed, sitemap — is pre-rendered to static HTML files and served by nginx.</p>
<p>The codebase is open-source under Apache 2.0 — see <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / notACMS</a>. It has 368 PHPUnit tests, CI/CD via GitHub Actions, and a local override pattern that lets users customise templates, CSS, and translations without forking.</p>
<h2>Architecture<a id="architecture" href="#architecture" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>All content lives in Markdown files with YAML frontmatter:</p>
<pre><code>content/
├── blog/
│   └── tutorials/
│       └── my-post/
│           ├── en.md   ← English version
│           ├── pl.md   ← Polish version
│           └── files/  ← images, served at /media/my-post/
└── pages/
    └── about/
        ├── en.md
        └── pl.md
</code></pre>
<p><code>ContentTreeBuilder</code> scans the filesystem, parses each file with <code>league/commonmark</code> (GitHub Flavoured Markdown + YAML frontmatter), and builds a typed <code>ContentTree</code> — an in-memory index of all posts and pages for a given locale. Tags, categories, archive months, and URL maps are computed from that index.</p>
<p>The static build uses Symfony sub-requests: <code>HttpKernelInterface::handle()</code> with <code>SUB_REQUEST</code> renders every URL through the full kernel without touching the network. If a URL works in development, it will be in the static build. No separate template engine, no build configuration.</p>
<h2>Key Features<a id="key-features" href="#key-features" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Multi-language</strong> — co-located <code>en.md</code> + <code>pl.md</code> in the same directory, automatic translation mapping, <code>hreflang</code> tags, language switcher</li>
<li><strong>Search</strong> — Pagefind WASM-based static index, client-side, no Elasticsearch or Algolia</li>
<li><strong>Responsive images</strong> — automatic srcset generation via ImageMagick, configured variant widths</li>
<li><strong>Draft &amp; scheduled posts</strong> — dev-only preview toggles, scheduled posts automatically published at build time</li>
<li><strong>Contact form</strong> — Cloudflare Turnstile CAPTCHA, tight CSP, single POST endpoint</li>
<li><strong>Styleguide</strong> — dev-only page documenting every component with real CSS classes</li>
<li><strong>Local override pattern</strong> — <code>local/</code> directory merges on top of base at build time, customise without forking</li>
</ul>
<h2>Tech Stack<a id="tech-stack" href="#tech-stack" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>PHP 8.5, Symfony 7.x</li>
<li>Twig templates, SCSS (dart-sass via symfonycasts/sass-bundle)</li>
<li>Symfony AssetMapper (no webpack, no Vite, no Node.js build pipeline)</li>
<li>nginx + PHP-FPM (two Docker containers in production)</li>
<li>Pagefind for search</li>
<li>PHPUnit 13, PHPStan level 6, Rector, PHP CS Fixer</li>
</ul>
<h2>Why Build It?<a id="why-build-it" href="#why-build-it" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application because I already know Symfony well, and owning the full stack turned out to have real advantages: the same templates, controllers, and content pipeline serve both development and production. There is no &quot;build-time template engine&quot; separate from the &quot;runtime template engine.&quot; It's just Symfony.</p>
<p>The full story of why WordPress had to go is in <a href="/blog/why-i-left-wordpress/">Why I left WordPress</a>. The architecture deep dive is in <a href="/blog/symfony-static-site-generator/">Symfony as a Static Site Generator</a>.</p>
<h2>Open Source<a id="open-source" href="#open-source" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>notACMS is available on <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub</a> under Apache 2.0. The preparation for release — testing, AI code review, security fixes, the local override pattern — is documented in the <a href="/blog/368-tests-static-site-generator/">open-source series</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-09-notacms/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Symfony as a Static Site Generator — How holas.pl Works]]></title>
            <link>https://holas.pl/blog/symfony-static-site-generator/</link>
            <guid isPermaLink="true">https://holas.pl/blog/symfony-static-site-generator/</guid>
                        <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 2 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 1 covers why WordPress had to go. After deciding to leave WordPress, the question was what to replace it with. Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application instead — not because the existing tools are inadequate, but because I al…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 2 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/why-i-left-wordpress/">Part 1</a> covers why WordPress had to go.</em></p>
<hr />
<p>After <a href="/blog/why-i-left-wordpress/">deciding to leave WordPress</a>, the question was what to replace it with. Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application instead — not because the existing tools are inadequate, but because I already know Symfony well, and owning the full stack turned out to have real advantages.</p>
<h2>The Core Idea<a id="the-core-idea" href="#the-core-idea" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The site works in two modes:</p>
<ul>
<li><strong>Development</strong> — Symfony handles requests dynamically. Edit a Markdown file, refresh the browser, see the result. Standard Symfony dev workflow with the profiler toolbar.</li>
<li><strong>Production build</strong> — a single console command renders every URL to an HTML file on disk. nginx serves those files directly. PHP is never called for content delivery.</li>
</ul>
<p>The same templates, controllers, and content pipeline serve both modes. There is no &quot;build-time template engine&quot; separate from the &quot;runtime template engine.&quot; It's just Symfony.</p>
<h2>Content Pipeline<a id="content-pipeline" href="#content-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>All content lives in Markdown files with YAML frontmatter:</p>
<pre><code>content/
├── blog/
│   └── tutorials/
│       └── my-post/
│           ├── en.md   ← English version
│           ├── pl.md   ← Polish version
│           └── files/  ← images, served at /media/my-post/
└── pages/
    └── about/
        ├── en.md
        └── pl.md
</code></pre>
<p><code>ContentTreeBuilder</code> scans the filesystem, parses each file with <code>league/commonmark</code> (GitHub Flavoured Markdown + YAML frontmatter), and builds a typed <code>ContentTree</code> — an in-memory index of all posts and pages for a given locale. Tags, categories, archive months, and URL maps are computed from that index.</p>
<p><code>ContentItem</code> is a <code>final readonly</code> value object. No database, no ORM, no migrations. Adding a post means creating a directory with two Markdown files and running the build.</p>
<h2>The Build Command — Symfony Sub-requests<a id="the-build-command--symfony-sub-requests" href="#the-build-command--symfony-sub-requests" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The static build uses a technique that's specific to Symfony and distinguishes this approach from Hugo or Jekyll: it calls <code>HttpKernelInterface::handle()</code> to make <strong>sub-requests</strong> — internal PHP calls that go through the full Symfony kernel without touching the network.</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>For every URL — blog posts, category pages, tag pages, archive months, static pages, error pages, RSS feeds, the sitemap — the build command creates a request, runs it through Symfony, and writes the HTML to disk. The URL <code>/blog/</code> becomes <code>public/static/blog/index.html</code>. The URL <code>/sitemap.xml</code> becomes <code>public/static/sitemap.xml</code>.</p>
<p>No separate template engine to learn. No build configuration. If a URL works in development, it will be in the static build.</p>
<h2>nginx Serves Everything<a id="nginx-serves-everything" href="#nginx-serves-everything" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>In production, nginx handles all content delivery:</p>
<pre><code class="language-nginx">location / {
    try_files /static$uri/index.html /static$uri/index.xml /static$uri $uri =404;
}

# PHP only for the contact form
location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
}
</code></pre>
<p>For any incoming URL, nginx tries the pre-rendered HTML file first. PHP-FPM is only invoked for <code>/api/contact</code> — the single dynamic endpoint. Every blog post, category listing, tag page, and static page is a file served directly from disk. A Raspberry Pi 5 handles this trivially.</p>
<h2>No Database<a id="no-database" href="#no-database" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The content tree is built from Markdown files at build time. In production it's cached indefinitely in Symfony's filesystem cache (invalidated and rebuilt on every deploy). In development it's rebuilt on every request so file changes are immediately visible.</p>
<p>There is no MySQL, no schema, no migrations, no connection pooling, no slow queries. Adding content means creating files and running <code>ddev build</code>. Removing content means deleting files. The entire site history is in git.</p>
<h2>Multi-language Without a Database<a id="multi-language-without-a-database" href="#multi-language-without-a-database" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both Polish and English content lives in the same directory:</p>
<pre><code>content/blog/tutorials/my-post/
    en.md  → slug: &quot;blog/my-post&quot;      → /blog/my-post/
    pl.md  → slug: &quot;wpisy/moj-wpis&quot;    → /pl/wpisy/moj-wpis/
    files/ → images served at /media/my-post/
</code></pre>
<p>Co-location is the translation link. Two locale files in the same directory are automatically treated as translations of each other. <code>TranslationMapBuilder</code> constructs <code>{directoryKey → {locale → url}}</code> for <code>hreflang</code> tags and the language switcher. No <code>translation_key</code> field, no join table, no synchronisation to manage.</p>
<h2>Search with Pagefind<a id="search-with-pagefind" href="#search-with-pagefind" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Search is a WASM-based static index built by <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a> after the HTML files are generated:</p>
<pre><code class="language-bash">npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Pagefind reads the pre-rendered HTML, indexes <code>data-pagefind-body</code> regions, and generates a binary index in <code>public/pagefind/</code>. The search page loads this index client-side via a dynamic <code>import()</code>. No Elasticsearch, no Algolia, no server-side search query. The index is a set of static files.</p>
<h2>Redesign Simplicity<a id="redesign-simplicity" href="#redesign-simplicity" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Changing the site's appearance means editing Twig templates and SCSS. No React component library to update. No Node.js build pipeline. No WordPress child theme hierarchy. The entire frontend is:</p>
<ul>
<li><code>assets/styles/app.scss</code> — one entrypoint importing partials</li>
<li><code>templates/</code> — Twig templates</li>
<li><code>symfonycasts/sass-bundle</code> — compiles SCSS with dart-sass, no Node required</li>
</ul>
<p>To change the colour scheme: edit <code>_variables.scss</code>. To change the layout: edit a Twig template. Run <code>ddev build</code> and it's done.</p>
<h2>Trade-offs<a id="trade-offs" href="#trade-offs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The main trade-off compared to Hugo or Jekyll is that this is custom code — I maintain it. The upside is that it's exactly what's needed, nothing more. There's no plugin ecosystem to navigate, no upgrade path to worry about, no feature flags for things I'll never use.</p>
<p>The only genuinely dynamic feature — the contact form — is covered in the <a href="/blog/contact-form-security/">next post</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-04-02-symfony-static-site-generator/symfony-static-site-generator.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architecture]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[19 Years of WordPress — Why I Finally Quit]]></title>
            <link>https://holas.pl/blog/why-i-left-wordpress/</link>
            <guid isPermaLink="true">https://holas.pl/blog/why-i-left-wordpress/</guid>
                        <pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl ran on WordPress from 2007 to early 2026. Nineteen years. In that time WordPress grew from a straightforward blogging platform into something I barely recognize — and maintaining it had become more work than maintaining the actual content. This is the first post in a series documenting the migration to a custom Symfony-based static site generator. The next posts cover the architecture, th…]]></description>
            <content:encoded><![CDATA[<p>holas.pl ran on WordPress from 2007 to early 2026. Nineteen years. In that time WordPress grew from a straightforward blogging platform into something I barely recognize — and maintaining it had become more work than maintaining the actual content.</p>
<p>This is the first post in a series documenting the migration to a custom Symfony-based static site generator. The next posts cover the <a href="/blog/symfony-static-site-generator/">architecture</a>, the <a href="/blog/contact-form-security/">contact form</a>, the <a href="/blog/dev-experience-two-containers/">developer workflow</a>, and <a href="/blog/building-with-ai-claude-code/">building the site with AI assistance</a>.</p>
<h2>The Update Treadmill<a id="the-update-treadmill" href="#the-update-treadmill" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>WordPress ships security updates frequently. Plugins ship updates independently. Themes do too. On a quiet week my update queue had three items. On a bad week it had fifteen. Each one required:</p>
<ol>
<li>Back up the database and files</li>
<li>Apply the update</li>
<li>Check that nothing broke</li>
</ol>
<p>That last step is the killer. WordPress plugins interact with each other in ways that are impossible to predict. An update to a caching plugin would break the SEO plugin's output. An update to the SEO plugin would change how sitemaps were generated. Every update was a small gamble.</p>
<p>For a portfolio site that publishes a post every few months, this maintenance overhead is absurd.</p>
<h2>Dozens of Plugins for Basic Features<a id="dozens-of-plugins-for-basic-features" href="#dozens-of-plugins-for-basic-features" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A fresh WordPress installation does almost nothing useful. To run a respectable portfolio blog you need plugins for:</p>
<ul>
<li><strong>SEO</strong> — Yoast or Rank Math, with their own settings screens, meta boxes, and update cycles</li>
<li><strong>Caching</strong> — W3 Total Cache or WP Super Cache, with complex configuration and edge cases</li>
<li><strong>Security</strong> — Wordfence or similar, scanning for intrusions, blocking IPs, sending daily reports</li>
<li><strong>Contact form</strong> — Contact Form 7 or Gravity Forms</li>
<li><strong>Backups</strong> — UpdraftPlus or similar, usually pushing to an external service</li>
<li><strong>Performance</strong> — image optimization, lazy loading, minification</li>
<li><strong>SMTP</strong> — because WordPress sends email through PHP <code>mail()</code> by default, which goes to spam</li>
</ul>
<p>Each plugin is code you don't control, maintained by someone else, potentially abandoned tomorrow, running on every page load.</p>
<p>When I started planning the migration I counted 22 active plugins.</p>
<h2>Bot Attacks and Login Problems<a id="bot-attacks-and-login-problems" href="#bot-attacks-and-login-problems" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A WordPress login page is a known target. The URL is always <code>/wp-admin/</code> or <code>/wp-login.php</code>. Every bot on the internet knows this. The result: constant brute-force login attempts, around the clock.</p>
<p>I had Wordfence rate-limiting login attempts, blocking suspicious IPs, and sending me daily attack reports. It worked — but it was another moving part consuming server resources on a site that didn't need user logins at all. The only person logging in was me, once a month.</p>
<p>XML-RPC was another attack vector. WordPress enables it by default for pingbacks and the mobile app. Bots probe it constantly. The fix was an nginx rule to block it — but you only find out about it after seeing the traffic in your logs.</p>
<h2>PHP and MySQL on a Raspberry Pi<a id="php-and-mysql-on-a-raspberry-pi" href="#php-and-mysql-on-a-raspberry-pi" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>holas.pl runs on self-hosted hardware — first a <a href="/blog/home-server-banana-pi/">Banana Pi</a>, now a <a href="/blog/raspberry-pi-5-migration-to-nvme/">Raspberry Pi 5</a>. These are capable ARM boards, but they are not server-grade machines. Running PHP-FPM and MySQL simultaneously for a blog that changes once a month is wasteful.</p>
<p>A typical WordPress page load on that hardware: PHP parses the request, MySQL runs several queries to fetch post content, navigation data, sidebar widgets, and site options, PHP renders templates, every active plugin executes its hooks. All of this for content that was identical to the last request and will be identical to the next one.</p>
<p>With a static site, nginx serves a pre-rendered HTML file directly from disk. The ARM processor barely wakes up. Pages load faster than WordPress could parse the incoming request.</p>
<h2>The Database as a Liability<a id="the-database-as-a-liability" href="#the-database-as-a-liability" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Nineteen years of WordPress produces a database with thousands of rows of post metadata, options, post revisions, and transients. It needs:</p>
<ul>
<li>Regular backups (and testing that restores actually work)</li>
<li>Occasional cleanup — WordPress accumulates garbage: post revisions, expired transients, orphaned metadata rows</li>
<li>Monitoring for corruption after unclean shutdowns</li>
<li>A MySQL process running continuously, consuming memory</li>
</ul>
<p>A portfolio blog is a collection of text and images. Storing it in a relational database adds operational complexity without adding value.</p>
<h2>What I Kept<a id="what-i-kept" href="#what-i-kept" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The content. All 26 posts were exported and converted to Markdown files — titles, dates, categories, tags, images. The URLs were preserved exactly: 25 individual post redirects and 43 image path redirects are now baked into the nginx config. Search rankings survived the migration intact.</p>
<p>Everything else — the database, the plugins, the update queue, the <code>/wp-admin/</code> login page — is gone.</p>
<p>The <a href="/blog/symfony-static-site-generator/">next post</a> covers what replaced it: a custom Symfony application that generates static HTML files and serves them through nginx, with no PHP involved in delivering content to visitors.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/2026-03-17-why-i-left-wordpress/why-i-left-wordpress.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[wordpress]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
            </channel>
</rss>
