Show the own site icon to Google under every address, and the KANTARCI landing page

- Google kept showing the KantSites K for Elektro Bernhard after the site
  icon was changed. The own icon only existed at 32 and 180 px, and Google
  takes multiples of 48 only; /favicon.svg was a file in public/, so the web
  server always delivered the bundled K.
- The icon is now also cut at 48 and 192 px. The head links /favicon-192.png,
  /favicon.ico serves the 48 px PNG, and /favicon.svg is answered by the
  application: the bundled mark without an own icon (now in
  public/assets/favicon.svg), otherwise a 302 to the 192 px PNG. Sites that
  set their icon earlier get the new sizes on first request. A removed icon
  no longer counts even though its files remain.
- The archive update only adds files to public/, so it now deletes the old
  public/favicon.svg explicitly.
- Seventh local reference site: the KANTARCI Immobilien landing page with a
  three-step rental request, own database, seed and reset scripts.

Tests: 582 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and
test:browser green (test:browser reports only the known editor colour
contrast on /grundlagen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-21 07:37:04 +02:00
1 parent 42e74af052
commit 45d1141951
14 files changed
+1057 -12

No files matched your search

+67 -8
View File
@@ -3,6 +3,7 @@
namespace App\Controller;
use App\Content\MediaLibrary;
use App\Content\SiteSettings;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -14,7 +15,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController
{
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings)
{
}
@@ -159,21 +160,79 @@ final class MediaController extends AbstractController
}
/**
* Das Symbol der Website, in den beiden Größen, die Browser abfragen.
* Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen.
*
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
* favicon.svg, auf das die Seiten weiterhin verweisen.
* Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das
* 48er-PNG, das liest jeder Browser auch unter diesem Namen.
*/
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
public function favicon(Request $request): Response
#[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])]
#[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])]
#[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])]
#[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])]
public function favicon(int $size): Response
{
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
$path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException();
$path = $this->faviconFile($size);
if (null === $path) {
if (48 === $size) {
return $this->redirect('/favicon.svg');
}
throw $this->createNotFoundException();
}
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
}
/**
* Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist.
*
* Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte
* es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen
* Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt.
*/
#[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])]
public function faviconSvg(): Response
{
if (null !== $this->faviconFile(192)) {
$response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302);
$response->headers->set('Cache-Control', 'public, max-age=3600');
return $response;
}
return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']);
}
/**
* Eine Symboldatei, oder null ohne eigenes Symbol.
*
* Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32
* und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass
* erst jemand die Einstellungen neu speichern muss.
*/
private function faviconFile(int $size): ?string
{
// Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten
// aber nicht mehr.
$settings = $this->siteSettings->load();
$favicon = (string) ($settings['faviconMediaId'] ?? '');
if ('' === $favicon) {
return null;
}
$path = $this->media->faviconFile($size);
if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) {
return $path;
}
$crop = (string) ($settings['faviconCrop'] ?? '');
try {
$this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop)));
} catch (\RuntimeException) {
return null;
}
return $this->media->faviconFile($size);
}
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
public function serve(string $id, int $width): Response
{