Eigener Suchtitel, Schriften lokal, Bilder in Anzeigegroesse
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Getrennter Titel fuer Suchergebnisse je Seite und Beitrag (Migration
20260913000100): auf der Seite steht "Leistungen", im Suchergebnis darf
der lange Titel stehen. Leer heisst weiterhin: der Titel der Seite.

Schriften kommen jetzt vom eigenen Server. Ein Knopf im Designbereich
oder cms:fonts:fetch holt die gewaehlten Google-Schnitte als WOFF2 nach
public/assets/fonts. Das spart zwei fremde Verbindungen nacheinander
(gemessen 820 ms) und die IP-Weitergabe an Google. Ohne Dateien bleibt
es beim bisherigen Verweis - eine Seite ohne Schrift waere schlimmer.

Ein Stylesheet statt vier, mit Pruefsumme im Namen und ohne Kommentare,
dazu eine .htaccess mit Kompression, Cache und Schutz interner Dateien.
Der Entwicklungsserver sendet dieselben Kopfzeilen, sonst misst man
lokal etwas, das es im Betrieb nicht gibt.

Bilder in der Groesse, in der sie erscheinen: ein nur fuer Desktop
gesetztes Hintergrundbild gilt auf dem Telefon in Telefongroesse
(189 KB -> 40 KB), img nennt seine feste Breite oder sizes="auto", und
fetchpriority liegt auf dem ersten Bild des Inhalts statt auf dem Logo.

Neu: npm run test:links prueft jeden internen Verweis auf seine
Antwort. Lighthouse gegen den Nachbau: Barrierefreiheit, Best
Practices und SEO 100 auf allen vier geprueften Adressen, Performance
100 ausser auf der bilderreichen Startseite (98 mobil, 96 Desktop).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 15:11:46 +02:00
1 parent 35d8270afd
commit 074ef0e7e5
34 files changed
+1223 -45

No files matched your search

+195 -3
View File
@@ -6,6 +6,7 @@ use App\Content\Consent;
use App\Content\CustomCode;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
@@ -15,6 +16,7 @@ use App\Content\PostRepository;
use App\Content\RedirectRepository;
use App\Content\RichText;
use App\Content\SiteSettings;
use App\Content\StyleBundle;
use App\Content\StyleCompiler;
use App\Content\StyleSchema;
use App\Content\Theme;
@@ -707,7 +709,7 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
$mit = $this->request('/');
self::assertStringContainsString('<script src="/assets/slider.js" defer></script>', $mit->getContent());
self::assertStringContainsString('<script src="/assets/slider.js?v=', $mit->getContent());
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
@@ -877,6 +879,194 @@ final class CmsTest extends TestCase
self::assertStringContainsString('Nächstes Projekt', $rand);
}
public function testABackgroundPictureShrinksForSmallScreensEvenWhenSetOnce(): void
{
// Gemessen auf der nachgebauten Startseite: das Telefon lud das Hintergrundbild mit 1920
// Pixeln und 189 KB, weil die Regel nur unter „Desktop" stand und ein kleines Gerät sie
// erbt. Sichtbar war davon nichts - bezahlt wurde es trotzdem.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-hintergrund-');
$image = imagecreatetruecolor(2000, 1200);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'hintergrund.png', test: true));
@unlink($file);
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'][0]['style'] = ['desktop' => [
'backgroundImage' => $item['id'],
'backgroundSize' => 'cover',
'overlayColor' => '#0a192f',
'overlayOpacity' => 0.6,
]];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
$id = $document['blocks'][0]['id'];
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('/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.
self::assertStringContainsString('#0a192f 60%', $mobil[1]);
self::assertStringContainsString('background-size:cover', $mobil[1]);
// Eine eigene Regel für kleine Geräte wird nie überschrieben.
$document['blocks'][0]['style']['mobile'] = ['backgroundColor' => '#ffffff'];
$version = $this->pages->findForEditor('page-1')['version'];
$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.');
}
public function testTheBigPictureLoadsFirstAndSmallOnesAskForSmallVariants(): void
{
// Zwei Fehler auf einmal: die Eile lag auf dem Logo im Kopfbereich statt auf dem großen
// Bild darunter, und jedes Bild fragte nach einer Variante für 800 px Breite - auch ein
// Logo, das 190 px breit ist. Das waren 32 KB für nichts.
$media = new MediaLibrary($this->db, $this->path.'-media');
$file = tempnam(sys_get_temp_dir(), 'kant-bild-');
$image = imagecreatetruecolor(1200, 800);
imagepng($image, $file);
imagedestroy($image);
$item = $media->upload(new UploadedFile($file, 'bild.png', test: true));
@unlink($file);
$variants = array_column($item['variants'], 'src', 'width');
$bild = static fn (string $id, array $style): array => [
'id' => $id,
'type' => 'image',
'props' => ['src' => $variants[960], 'alt' => 'Ein Bild', 'decorative' => false, 'width' => 960, 'height' => 640, 'sources' => [['src' => $variants[480], 'width' => 480], ['src' => $variants[960], 'width' => 960]]],
'style' => $style,
];
$document = DemoContent::starter('Startseite');
$document['schemaVersion'] = 18;
$document['blocks'] = [
['id' => 'oben', 'type' => 'section', 'props' => [], 'children' => [
['id' => 'titel', 'type' => 'heading', 'props' => ['text' => 'Startseite', 'level' => 1]],
$bild('gross', []),
$bild('logo', ['desktop' => ['maxWidth' => '190px']]),
]],
['id' => 'unten', 'type' => 'section', 'props' => [], 'children' => [$bild('weiter-unten', [])]],
];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt.
self::assertSame(1, substr_count($html, 'fetchpriority="high"'));
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
// Ein Block mit fester Breite fragt nach genau dieser Breite.
self::assertMatchesRegularExpression('#<figure id="logo".{0,500}sizes="190px"#s', $html);
self::assertMatchesRegularExpression('#<figure id="gross".{0,500}sizes="\(max-width: 650px\) 100vw#s', $html);
}
public function testFontsAreReadFromGoogleStylesheetAndServedFromHere(): void
{
// Eine Schrift von Google kostet zwei fremde Verbindungen hintereinander und schickt die
// IP jedes Besuchers nach Kalifornien. Der Parser holt aus dem Stylesheet genau das, was
// eine deutschsprachige Website braucht - Kyrillisch bleibt liegen.
$fonts = new FontLibrary($this->path.'-fonts');
$css = <<<'CSS'
/* cyrillic */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/kyrillisch.woff2) format('woff2');unicode-range:U+0301, U+0400-045F}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:400;src:url(https://fonts.gstatic.com/s/lato/latein.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:700;src:url(https://fonts.gstatic.com/s/lato/latein-fett.woff2) format('woff2');unicode-range:U+0000-00FF}
CSS;
$faces = $fonts->parse($css, 'Lato');
self::assertCount(2, $faces, 'Kyrillisch gehört nicht dazu.');
self::assertSame([400, 700], array_column($faces, 'weight'));
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
self::assertStringContainsString('U+0131', $faces[0]['range']);
// Ohne heruntergeladene Dateien bleibt es beim Verweis auf Google - und das Theme weiß es.
$theme = new Theme($this->db);
$gespeichert = $theme->load();
$theme->save($gespeichert['colors'], [
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700], 'fallback' => 'system-ui'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700], 'fallback' => 'system-ui'],
], $gespeichert['base']);
self::assertFalse($fonts->hasLocal($theme->load()));
self::assertSame(['Lato 400', 'Lato 700'], $fonts->missing($theme->load()));
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::starter('Startseite'), 0), 1);
self::assertStringContainsString('fonts.googleapis.com', $this->request('/')->getContent());
// Mit Dateien: kein Wort mehr von Google, dafür die eigenen Dateien und ein Vorabholen.
mkdir($this->path.'-fonts', 0o775, true);
foreach (['lato-400-latin.woff2', 'lato-700-latin.woff2'] as $datei) {
file_put_contents($this->path.'-fonts/'.$datei, 'wOF2test');
}
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato'}");
self::assertTrue($fonts->hasLocal($theme->load()));
self::assertSame(['/assets/fonts/lato-400-latin.woff2', '/assets/fonts/lato-700-latin.woff2'], $fonts->preload($theme->load()));
}
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
{
// Vier Stylesheets sind vier Anfragen, und jede einzelne hält das Zeichnen der Seite auf.
$assets = $this->path.'-assets';
mkdir($assets, 0o775, true);
file_put_contents($assets.'/site.css', '/* Das Grundgerüst. */
.kant {
color: red;
}
');
file_put_contents($assets.'/design.css', '/* Gestaltung. */
.block-row { gap: 10px }
');
$bundle = new StyleBundle($assets);
$url = $bundle->url(false);
self::assertNotNull($url);
self::assertStringStartsWith('/assets/build/kant-', $url);
$inhalt = (string) file_get_contents($assets.'/build/'.basename((string) $url));
self::assertStringNotContainsString('Das Grundgerüst', $inhalt, 'Kommentare erklären den Quelltext, nicht den Browser.');
self::assertStringContainsString('color: red;', $inhalt, 'Leerzeichen innerhalb einer Zeile bleiben - in calc() tragen sie Bedeutung.');
self::assertStringContainsString('.block-row { gap: 10px }', $inhalt);
// Derselbe Inhalt ergibt denselben Namen; eine Änderung einen neuen.
self::assertSame($url, (new StyleBundle($assets))->url(false));
touch($assets.'/site.css', time() + 10);
clearstatcache();
self::assertNotSame($url, (new StyleBundle($assets))->url(false));
}
public function testTheTitleInSearchResultsCanDifferFromTheTitleOnThePage(): void
{
// Auf der Seite steht "Leistungen", im Suchergebnis hilft der lange Titel mit Ort und
// Firma. Bisher war beides dasselbe Feld, also musste man sich entscheiden.
$document = DemoContent::starter('Leistungen');
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>Leistungen</title>', $html);
$this->posts->saveMetadata('page-1', ['seoTitle' => 'IT-Leistungen für Mittelbaden – Kant-IT Solutions'], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
$html = $this->request('/')->getContent();
self::assertStringContainsString('<title>IT-Leistungen für Mittelbaden – Kant-IT Solutions</title>', $html);
self::assertStringContainsString('<meta property="og:title" content="IT-Leistungen für Mittelbaden – Kant-IT Solutions">', $html);
// Die Überschrift auf der Seite bleibt, was sie war.
self::assertStringContainsString('>Leistungen</h1>', $html);
// Wieder geleert heißt: der Titel der Seite gilt.
$this->posts->saveMetadata('page-1', ['seoTitle' => ''], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::assertStringContainsString('<title>Leistungen</title>', $this->request('/')->getContent());
try {
$this->posts->saveMetadata('page-1', ['seoTitle' => str_repeat('a', 161)], [], [], (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
self::fail('Ein Titel über 160 Zeichen muss abgelehnt werden.');
} catch (InvalidPage) {
}
}
public function testThePreviewPictureForSharingActuallyReachesTheHead(): void
{
// Das Feld „Vorschaubild beim Teilen" gab es seit dem SEO-Paket - ausgegeben wurde es
@@ -1370,7 +1560,9 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<section id="tab-web" class="block-tabitem">', $html);
self::assertStringContainsString('<section id="tab-app" class="block-tabitem">', $html);
// The enhancement is an external file; inline script never appears.
self::assertStringContainsString('<script src="/assets/tabs.js" defer></script>', $html);
// Die Fassung haengt als Parameter an: nach einem Update laedt niemand ein altes Skript
// aus dem Zwischenspeicher.
self::assertStringContainsString('<script src="/assets/tabs.js?v=', $html);
// Scripting is permitted for this page only, and only from this origin.
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
@@ -3100,7 +3292,7 @@ final class CmsTest extends TestCase
private function request(string $url, string $method = 'GET', array $body = [], array $cookies = []): Response
{
$previous = [];
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media', 'FONT_DIRECTORY' => $this->path.'-fonts', 'ASSET_DIRECTORY' => $this->path.'-assets'] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
+2
View File
@@ -17,6 +17,7 @@ use DoctrineMigrations\Version20260912001000;
use DoctrineMigrations\Version20260912001100;
use DoctrineMigrations\Version20260912001200;
use DoctrineMigrations\Version20260912001300;
use DoctrineMigrations\Version20260913000100;
use Psr\Log\NullLogger;
/**
@@ -45,6 +46,7 @@ final class TestSchema
Version20260912001100::class,
Version20260912001200::class,
Version20260912001300::class,
Version20260913000100::class,
] as $migration) {
(new $migration($db, new NullLogger()))->up($schema);
}