Serve images without PHP, add the 720 and 1280 steps, trim two hot paths

- Image widths are 480, 720, 960, 1280, 1600 and 2400. A phone needed
  721 pixels and got 960. Existing images receive the new steps from
  a task; stored pages pick them up at render time, so no document is
  rewritten. Mobile backgrounds use 720.
- A variant served once is mirrored to public/media, and a rewrite rule
  lets the web server deliver it from there. var/media stays the source.
- The hero preload follows the image each device really shows.
- The post navigation reads the order and then two posts instead of
  up to 500 documents.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Fable 5.2 committed 2026-11-10 12:24:30 +01:00
1 parent dca2665138
commit 7d0369bada
32 files changed
+550 -57

No files matched your search

+111 -5
View File
@@ -1757,7 +1757,7 @@ final class CmsTest extends TestCase
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $html, $mobil);
preg_match('/#'.$id.'\{([^}]*)}/', $html, $desktop);
self::assertNotEmpty($mobil, 'Für kleine Geräte muss eine eigene Regel entstehen.');
self::assertStringContainsString('/960', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
self::assertStringContainsString('/720', $mobil[1], 'Das Telefon bekommt die kleine Variante.');
self::assertStringContainsString('/2000', $desktop[1], 'Der Schreibtisch die große.');
// Die Abdunkelung gehört zur selben Regel - sonst stünde heller Text plötzlich auf
// einem ungedämpften Bild.
@@ -1770,7 +1770,7 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, $version), $version + 1);
preg_match('/@media \(max-width:767px\)\{#'.$id.'\{([^}]*)}/', $this->request('/')->getContent(), $eigen);
self::assertStringContainsString('background-color:#ffffff', $eigen[1]);
self::assertStringContainsString('/960', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
self::assertStringContainsString('/720', $eigen[1], 'Das Bild fehlt dort nicht, nur weil eine andere Eigenschaft gesetzt ist.');
}
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
@@ -3828,6 +3828,97 @@ display: flex', '#b1{display:flex;color:red}'];
(new DocumentValidator())->validate($document);
}
public function testAnImageServedOnceLandsWhereTheWebServerDeliversItWithoutPhp(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
$image = imagecreatetruecolor(600, 400);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
@unlink($file);
$abbild = $this->path.'-abbild';
$ziel = $abbild.'/'.$item['id'].'/480.webp';
$quelle = $this->path.'-media/'.$item['id'].'-480.webp';
// Ohne Abbild (so laufen alle anderen Tests) bleibt es bei der Auslieferung über PHP.
self::assertSame(200, $this->request('/media/'.$item['id'].'/480')->getStatusCode());
self::assertDirectoryDoesNotExist($abbild);
$_SERVER['MEDIA_MIRROR_DIRECTORY'] = $_ENV['MEDIA_MIRROR_DIRECTORY'] = $abbild;
try {
$antwort = $this->request('/media/'.$item['id'].'/480');
self::assertSame(200, $antwort->getStatusCode());
self::assertFileEquals($quelle, $ziel, 'Die Variante liegt unter der Adresse, die public/.htaccess prüft.');
// Eine Breite, die es nicht gibt, legt nichts an.
self::assertSame(404, $this->request('/media/'.$item['id'].'/999')->getStatusCode());
self::assertFileDoesNotExist($abbild.'/'.$item['id'].'/999.webp');
} finally {
unset($_SERVER['MEDIA_MIRROR_DIRECTORY'], $_ENV['MEDIA_MIRROR_DIRECTORY']);
}
// Verschwindet das Bild aus dem Medienordner (Paketimport), räumt das Abbild es weg -
// sonst lieferte der Webserver weiter aus, was die Anwendung nicht mehr kennt.
$mirror = new \App\Content\MediaMirror($abbild, $this->path.'-media');
self::assertSame(0, $mirror->prune());
unlink($quelle);
file_put_contents($abbild.'/'.$item['id'].'/fremd.txt', 'gehört nicht hierher');
self::assertSame(2, $mirror->prune());
self::assertDirectoryDoesNotExist($abbild.'/'.$item['id']);
@rmdir($abbild);
self::assertSame(0, (new \App\Content\MediaMirror('', $this->path.'-media'))->prune());
}
public function testAnOlderImageGetsTheNewWidthsAndStoredPagesUseThem(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-upload-');
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
$image = imagecreatetruecolor(1700, 1000);
imagejpeg($image, $file, 92);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'alt.jpg', test: true));
// So sah ein Bild vor 1.42.0 aus: ohne 720 und 1280, in der Mediathek wie in der Seite.
$alt = array_values(array_filter($item['variants'], static fn (array $v): bool => !in_array($v['width'], [720, 1280], true)));
foreach ([720, 1280] as $width) {
unlink($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
}
$this->db->update('media', ['variants' => json_encode($alt)], ['id' => $item['id']]);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'] = [['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
['id' => 'bild', 'type' => 'image', 'props' => ['mediaId' => $item['id'], 'src' => '/media/'.$item['id'].'/1700', 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 1700, 'height' => 1000, 'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $alt)]],
]]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
self::assertStringNotContainsString('720w', $this->request('/')->getContent());
$media = new MediaLibrary($this->db, $this->path.'-media');
self::assertSame(['done' => 1, 'left' => 0], $media->fillVariants());
$neu = $media->find($item['id']);
self::assertSame([480, 720, 960, 1280, 1700], array_column($neu['variants'], 'width'));
self::assertSame($item['version'], $neu['version'], 'Die Schreibversion bleibt: ein offenes Formular zum Bild scheitert daran nicht.');
foreach ([720 => 424, 1280 => 753] as $width => $height) {
$size = getimagesize($this->path.'-media/'.$item['id'].'-'.$width.'.webp');
self::assertSame([$width, $height, 'image/webp'], [$size[0], $size[1], $size['mime']]);
}
// Ein zweiter Lauf hat nichts mehr zu tun.
self::assertSame(['done' => 0, 'left' => 0], $media->fillVariants());
// Die gespeicherte Seite nennt die neuen Größen, ohne dass ihr Dokument angefasst wurde -
// im Bild wie in seiner Vorabladung.
$html = $this->request('/')->getContent();
self::assertMatchesRegularExpression('#<link rel="preload" as="image"[^>]+imagesrcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
self::assertMatchesRegularExpression('#<img[^>]+srcset="[^"]*/720 720w[^"]*/1280 1280w#', $html);
self::assertStringNotContainsString('720', (string) $this->db->fetchOne('SELECT document FROM revision ORDER BY seq DESC LIMIT 1'));
} finally {
ini_set('memory_limit', $limit);
@unlink($file);
}
}
public function testAnUploadBecomesWebpInSeveralWidths(): void
{
$media = new MediaLibrary($this->db, $this->path.'-media');
@@ -3845,7 +3936,7 @@ display: flex', '#b1{display:flex;color:red}'];
$item = $media->upload(new UploadedFile($file, 'foto.jpg', test: true));
// Four widths, all WebP, none larger than the source.
self::assertSame([480, 960, 1600, 2400], array_column($item['variants'], 'width'));
self::assertSame([480, 720, 960, 1280, 1600, 2400], array_column($item['variants'], 'width'));
foreach ($item['variants'] as $variant) {
$path = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
self::assertSame('image/webp', getimagesize($path)['mime'], 'Jede Variante ist WebP.');
@@ -3886,7 +3977,7 @@ display: flex', '#b1{display:flex;color:red}'];
// CSS has no srcset, but the rules are written per device - so the device decides.
self::assertStringContainsString('#hero{background-image:url("/media/'.$item['id'].'/2000")}', $css);
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/960")}}', $css);
self::assertStringContainsString('@media (max-width:767px){#hero{background-image:url("/media/'.$item['id'].'/720")}}', $css);
} finally {
@unlink($file);
}
@@ -3983,7 +4074,7 @@ display: flex', '#b1{display:flex;color:red}'];
array_column($treffer, 2),
);
self::assertSame(
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/720'],
array_column($treffer, 1),
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
);
@@ -4003,6 +4094,21 @@ display: flex', '#b1{display:flex;color:red}'];
$mitBild,
$sparsam,
));
// Das Telefon ohne Hintergrund, das Tablet ausgeblendet: vorab geholt wird nur, was
// die Regel des jeweiligen Geräts auch zeigt. Vorher zählte allein der Desktop-Wert.
$mit['schemaVersion'] = max(22, $mit['schemaVersion']);
$mit['blocks'][0]['style']['mobile']['backgroundImage'] = 'none';
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
self::assertSame(['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)'], array_column($treffer, 2), 'Das Telefon lädt kein Bild vor, das es nicht zeigt.');
$mit['blocks'][0]['layout']['visibility'] = 'hide-tablet';
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', (string) $this->request('/')->getContent(), $treffer, PREG_SET_ORDER);
self::assertSame(['(min-width:1025px)'], array_column($treffer, 2));
} finally {
@unlink($datei);
ini_set('memory_limit', (string) $limit);