A WordPress site resizes uploaded images automatically. Scheduled posts have a "publish on" 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 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.

The solution is srcset + sizes: 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.

Build-Time Variant Generation

BuildStaticSiteCommand generates variants after copying media files to public/static/media/. It scans for .webp files, reads each file's actual dimensions with getimagesize(), and generates variants via ImageResizerInterface::resize():

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

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

ImageResizer::resize() calls ImageMagick:

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

-resize 640x scales to 640px wide, preserving aspect ratio. -quality 82 -strip -define webp:method=6 matches the production image settings and removes EXIF data.

Variant filenames follow a convention: image.webpimage-640w.webp, image-960w.webp. The build skips files that already end in -640w or -960w to avoid re-processing previously generated variants.

ResponsiveImageService

Two places need to know which variants exist: BuildStaticSiteCommand (which files to generate) and SrcsetExtension (which filenames to reference in HTML). Rather than duplicating the breakpoint logic, both inject ResponsiveImageServiceInterface:

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

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

The implementation:

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);  // strip .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 '';
}

Images ≤640px wide get no variants — the original is already small enough. buildSrcset() returns '' to signal that no srcset attribute is needed.

If breakpoints ever need to change, there's one place to update.

Featured Image Component

The responsive_img.html.twig component renders featured images with srcset hardcoded to the 640/960/1280 breakpoints:

<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" 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.

width and height 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.

Inline Content Images

Markdown images inside post body render as plain <img> tags. No srcset. A post with a diagram or screenshot at /media/post-dir/diagram.webp would serve the full-size image to mobile too.

The srcset_media Twig filter handles this. In post.html.twig:

{{ content.htmlContent|srcset_media|raw }}

SrcsetExtension::srcsetMedia() finds all /media/*.webp <img> tags with a regex, reads the source image width from the content directory (not the static output), and injects srcset and sizes:

$result = preg_replace_callback(
    '/<img(\s[^>]*)src="(\/media\/[^"]+\.webp)"([^>]*)>/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->getSourceWidth($src);
        if (null === $width) {
            return $matches[0];
        }

        $srcset = $this->responsiveImageService->buildSrcset($src, $width);
        if ('' === $srcset) {
            return $matches[0];  // no variants generated — leave as-is
        }

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

getSourceWidth() looks up the actual source file under content/ (not public/static/), since that's where the original dimensions live. Images with no variants — small inline screenshots ≤640px wide — are left unchanged.

Scheduled Posts

The Problem

A post with date: 2026-06-01 shouldn't appear in listings until June 1. The site rebuilds nightly, so a future-dated post simply won't appear in ContentTree::getAllPosts() until the build after its publish date. That part is automatic.

The URL is a different problem. If someone shares the link before the post is live, they get a 404. Better to serve a "coming soon" page at the exact URL the post will occupy.

ContentItem::isScheduled()

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

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

One comparison. isDraft() takes priority — a post with both draft: true and a future date is treated as a draft and excluded from all builds.

Static Build: Coming-Soon Pages

BuildStaticSiteCommand::collectRoutes() collects two categories of post URLs:

  • Published posts via ContentTree::getAllPosts() — rendered with the full post template
  • Scheduled posts via ContentTree::getScheduledPosts() — rendered with the coming-soon template

Both produce static HTML files at their eventual URL. When the post's date passes and the next build runs, isScheduled() returns false, the URL moves to the published list, and the full post HTML replaces the coming-soon HTML. No redirect, no special handling needed.

The Coming-Soon Page

The coming-soon template uses the same green terminal aesthetic as the error pages:

{% 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">Publishing: {{ post.date|date('Y-m-d') }}</span>
{% endif %}
</code></pre>

noindex, nofollow — the page handles direct links gracefully without ranking or passing link equity.

If the publish date is ≤14 days away, the title and date are shown. Further out: just the COMING_SOON code, no date. The 14-day threshold avoids making a public commitment to a specific date that might slip.

Dev Preview Toolbar

In production, scheduled posts are invisible — they appear only as coming-soon pages at their URLs, not in any listing.

In development, you're writing scheduled post content and need to see it. The Symfony profiler toolbar gets a calendar icon toggle ("Scheduled preview"). When on, scheduled posts appear in listings with a [PLANNED] badge:

{% 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 %}
    {# pinned / new / recently updated badges #}
{% endif %}

Toggle it off to preview what production will look like. The mechanism mirrors the existing draft preview toggle exactly — same session key pattern (scheduled_preview), same controller structure.

The Build-Step Pattern

Both features follow the same approach: push work into the build step, keep the serving layer simple.

Responsive images: generate all variants at build time. A few seconds of ImageMagick calls during ddev build saves bandwidth on every mobile page load for the lifetime of the post.

Scheduled posts: pre-render coming-soon pages rather than handling "not yet published" at request time. The static file exists, nginx serves it, no PHP involved.

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.

The build pipeline that makes this possible is covered in Part 2 of this series. The two-container production setup that runs it is in Part 3.