WordPress automatycznie skaluje wgrane obrazy. Zaplanowane wpisy mają selektor daty "opublikuj dnia" w edytorze. Obie funkcje działają bez żadnego niestandardowego kodu.

Na statycznej stronie nie ma serwera obsługującego żądania ani warstwy aplikacyjnej sprawdzającej czas. Obie funkcje wymagają celowej implementacji — a właściwym miejscem dla obu jest krok budowania.

Responsywne obrazy

Problem

Wyróżnione obrazy na holas.pl mają rozmiar 1280×720px w formacie WebP. Na przeglądarce desktopowej to właściwy rozmiar. Na ekranie mobilnym o szerokości 400px przeglądarka pobiera obraz o szerokości 1280 pikseli, aby wyświetlić go na 400 pikselach — to mniej więcej 10-krotność potrzebnych danych.

Rozwiązaniem jest srcset + sizes: poinformuj przeglądarkę o dostępnych wariantach obrazu i o tym, jak duży jest obraz przy każdej szerokości widoku, a następnie pozwól jej wybrać właściwy plik. Ograniczenie statycznej strony: każdy wariant musi istnieć jako plik zanim nadejdzie jakiekolwiek żądanie. Nie ma skalowania na żądanie.

Generowanie wariantów w czasie budowania

BuildStaticSiteCommand generuje warianty po skopiowaniu plików multimedialnych do public/static/media/. Skanuje pliki .webp, odczytuje faktyczne wymiary każdego pliku za pomocą getimagesize() i generuje warianty przez ImageResizerInterface::resize():

$variantWidths = $this->responsiveImageService->getVariantWidths($width);

foreach ($variantWidths as $variantWidth) {
    $this->imageResizer->resize(
        $filePath,
        $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp',
        $variantWidth,
    );
}

ImageResizer::resize() wywołuje ImageMagick:

magick source.webp -resize 640x -quality 82 -strip -define webp:method=6 source-640w.webp

-resize 640x skaluje do 640px szerokości, zachowując proporcje. -quality 82 -strip -define webp:method=6 odpowiada ustawieniom produkcyjnym obrazów i usuwa dane EXIF.

Nazwy wariantów są konwencjonalne: obraz.webpobraz-640w.webp, obraz-960w.webp. Budowanie pomija pliki kończące się na -640w lub -960w, aby uniknąć ponownego przetwarzania wcześniej wygenerowanych wariantów.

ResponsiveImageService

Dwa miejsca muszą wiedzieć, które warianty istnieją: BuildStaticSiteCommand (które pliki generować) i SrcsetExtension (które nazwy plików referencjonować w HTML). Zamiast duplikować logikę progów, oba wstrzykują ResponsiveImageServiceInterface:

interface ResponsiveImageServiceInterface
{
    /** @return int[] */
    public function getVariantWidths(int $sourceWidth): array;

    public function buildSrcset(string $src, int $sourceWidth): string;
}

Implementacja:

public function getVariantWidths(int $sourceWidth): array
{
    if (960 < $sourceWidth) {
        return [640, 960];
    }
    if (640 < $sourceWidth) {
        return [640];
    }

    return [];
}

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

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

    return '';
}

Obrazy o szerokości ≤640px nie mają wariantów — oryginał jest już wystarczająco mały. buildSrcset() zwraca '' sygnalizując, że atrybut srcset nie jest potrzebny.

Jeśli kiedykolwiek zajdzie potrzeba zmiany progów, jest jedno miejsce do aktualizacji.

Komponent wyróżnionego obrazu

Komponent responsive_img.html.twig renderuje wyróżnione obrazy z srcset zakodowanym na stałe dla progów 640/960/1280:

<img src="{{ src }}"
     srcset="{{ src|replace({'.webp': '-640w.webp'}) }} 640w,
             {{ src|replace({'.webp': '-960w.webp'}) }} 960w,
             {{ src }} 1280w"
     sizes="{{ sizes|default('(max-width: 48em) 100vw, 720px') }}"
     alt="{{ alt }}"
     width="{{ width|default(1280) }}"
     height="{{ height|default(720) }}">

sizes="(max-width: 48em) 100vw, 720px" mówi przeglądarce: poniżej szerokości widoku 48em obraz wypełnia cały widok; powyżej jest ograniczony do 720px (szerokość kolumny treści). Przeglądarka używa tego do wyboru właściwego wariantu przed pobraniem czegokolwiek.

width i height są jawne w celu zapobiegania Cumulative Layout Shift — przeglądarka rezerwuje dokładną przestrzeń dla obrazu przed jego załadowaniem. Bez nich układ przesuwa się gdy obraz przybywa.

Obrazy inline w treści

Obrazy Markdown w treści wpisu renderują się jako zwykłe tagi <img> bez srcset. Wpis z diagramem lub zrzutem ekranu pod adresem /media/post-dir/diagram.webp serwowałby pełnowymiarowy obraz również na urządzeniach mobilnych.

Filtr Twig srcset_media rozwiązuje ten problem. W post.html.twig:

{{ content.htmlContent|srcset_media|raw }}

SrcsetExtension::srcsetMedia() znajduje wszystkie tagi <img> z /media/*.webp za pomocą wyrażenia regularnego, odczytuje szerokość źródłowego obrazu z katalogu content/ (nie ze statycznego wyjścia) i wstrzykuje srcset i sizes:

$result = preg_replace_callback(
    '/<img(\s[^>]*)src="(\/media\/[^"]+\.webp)"([^>]*)>/i',
    function (array $matches): string {
        $src = $matches[2];

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

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

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

        return sprintf(
            '<img%ssrc="%s" srcset="%s" sizes="(max-width: 48em) 100vw, 720px"%s>',
            $matches[1], $src, $srcset, $matches[3],
        );
    },
    $html,
);

getSourceWidth() wyszukuje faktyczny plik źródłowy w content/ (nie w public/static/), ponieważ tam żyją oryginalne wymiary. Obrazy bez wariantów — małe zrzuty ekranu inline o szerokości ≤640px — pozostają niezmienione.

Zaplanowane wpisy

Problem

Wpis z date: 2026-06-01 nie powinien pojawiać się na listingach do 1 czerwca. Strona przebudowuje się każdej nocy, więc wpis z przyszłą datą po prostu nie pojawi się w ContentTree::getAllPosts() aż do budowania po dacie publikacji. Ta część działa automatycznie.

URL to inny problem. Jeśli ktoś udostępni link do wpisu przed jego opublikowaniem, dostanie błąd 404. Lepiej serwować stronę "coming soon" pod dokładnym URL-em, który wpis zajmie.

ContentItem::isScheduled()

public function isScheduled(): bool
{
    $date = $this->date();

    return null !== $date && $date > new \DateTimeImmutable();
}

Jedno porównanie. isDraft() ma pierwszeństwo — wpis z jednoczesnym draft: true i przyszłą datą jest traktowany jako szkic i wykluczony ze wszystkich budowań.

Krok budowania: strony coming soon

BuildStaticSiteCommand::collectRoutes() zbiera dwie kategorie URL-i wpisów:

  • Opublikowane wpisy przez ContentTree::getAllPosts() — renderowane pełnym szablonem wpisu
  • Zaplanowane wpisy przez ContentTree::getScheduledPosts() — renderowane szablonem coming soon

Oba produkują statyczne pliki HTML pod swoim docelowym URL-em. Gdy data wpisu minie i nastąpi kolejne budowanie, isScheduled() zwraca false, URL przechodzi na listę opublikowanych, a pełny HTML wpisu zastępuje HTML coming soon. Nie potrzeba przekierowania ani specjalnej obsługi.

Strona coming soon

Szablon coming soon używa tej samej estetyki zielonego terminalu co strony błędów:

{% block robots %}<meta name="robots" content="noindex, nofollow">{% endblock %}

<pre class="coming-soon-terminal"><code>
<span class="coming-soon-terminal__code">COMING_SOON</span>
{% if days_until <= 14 %}
<span class="coming-soon-terminal__text">{{ post.title }}</span>
<span class="coming-soon-terminal__date">Publikacja: {{ post.date|date('Y-m-d') }}</span>
{% endif %}
</code></pre>

noindex, nofollow — strona obsługuje bezpośrednie linki bez indeksowania i bez przekazywania link equity.

Jeśli data publikacji jest ≤14 dni, tytuł i data są pokazane. Dalej: tylko kod COMING_SOON, bez daty. Próg 14 dni unika publicznego zobowiązania do konkretnej daty, która mogłaby się opóźnić.

Pasek narzędzi podglądu w trybie dev

W produkcji zaplanowane wpisy są niewidoczne — pojawiają się tylko jako strony coming soon pod swoimi URL-ami, nie na żadnym listingu.

W trybie deweloperskim piszesz treść zaplanowanego wpisu i potrzebujesz ją widzieć. Pasek narzędzi profilera Symfony dostaje przełącznik z ikoną kalendarza ("Podgląd zaplanowanych"). Gdy jest włączony, zaplanowane wpisy pojawiają się na listingach z plakietką [PLANNED]:

{% if post.isDraft() %}
    <span class="post-card-badge post-card-badge--draft">[DRAFT]</span>
{% elseif post.isScheduled() %}
    <span class="post-card-badge post-card-badge--planned">[PLANNED]</span>
{% else %}
    {# plakietki: przypięty / nowy / niedawno zaktualizowany #}
{% endif %}

Wyłącz go, aby podejrzeć jak będzie wyglądać produkcja. Mechanizm jest lustrzanym odbiciem istniejącego przełącznika podglądu szkiców — ten sam wzorzec klucza sesji (scheduled_preview), ta sama struktura kontrolera.

Wzorzec kroku budowania

Obie funkcje podążają tym samym podejściem: przenieś pracę do kroku budowania, utrzymaj prostą warstwę serwowania.

Responsywne obrazy: generuj wszystkie warianty w czasie budowania. Kilka sekund wywołań ImageMagick podczas ddev build oszczędza przepustowość przy każdym mobilnym ładowaniu strony przez cały okres życia wpisu.

Zaplanowane wpisy: wstępnie renderuj strony coming soon zamiast obsługiwać "jeszcze nieopublikowany" w czasie żądania. Statyczny plik istnieje, nginx go serwuje, PHP nie jest zaangażowany.

Budowanie wykonuje się raz. nginx serwuje wynik każdemu odwiedzającemu. Każda praca, która może się przenieść do kroku budowania, to praca, której serwer nie musi wykonywać.

Potok budowania, który to umożliwia, opisany jest w części 2 tej serii. Dwukonteinerowe środowisko produkcyjne, które go uruchamia, opisane jest w części 3.