Build Hüfner Gartenbau as a ninth installation, let buttons call and write, colour the button arrow like its text

- Website of Garten- und Landschaftsbau Hüfner GmbH rebuilt from the delivered design with
  imprint, privacy, 404 and the enquiry form (scripts/reset-huefner.sh, ports 8102/8103),
  Barlow and Barlow Condensed served locally with their licence.
- Buttons, icon cards, calls to action, icons and linked images accept tel: and mailto:
  links, the same targets menus and rich text already allowed (ADR 107).
- The arrow inside a button takes the button's text colour instead of the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-06 19:47:06 +02:00
1 parent 0ba82b7d33
commit 5bfe1f3c6e
24 files changed
+1576 -11

No files matched your search

+37 -5
View File
@@ -236,14 +236,14 @@ final class DocumentValidator
}
}
if ('button' === $type) {
$this->url($props['href'] ?? null);
$this->link($props['href'] ?? null);
}
if ('iconcard' === $type) {
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
$this->text($props['title'] ?? null, 120);
$this->text($props['text'] ?? null, 600, true);
if ('' !== ($props['href'] ?? '')) {
$this->url($props['href']);
$this->link($props['href']);
}
}
if ('stat' === $type) {
@@ -261,7 +261,7 @@ final class DocumentValidator
$this->text($props['title'] ?? null, 160);
$this->text($props['text'] ?? null, 600, true);
$this->text($props['buttonText'] ?? null, 80);
$this->url($props['href'] ?? null);
$this->link($props['href'] ?? null);
}
if ('contact' === $type) {
foreach (['phone', 'email', 'address'] as $field) {
@@ -329,7 +329,7 @@ final class DocumentValidator
$this->text($props['label'] ?? null, 120, $props['decorative']);
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
if ('' !== ($props['href'] ?? '')) {
$this->url($props['href']);
$this->link($props['href']);
}
}
if ('social' === $type) {
@@ -414,7 +414,7 @@ final class DocumentValidator
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
if ('' !== ($props['href'] ?? '')) {
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
$this->url($props['href']);
$this->link($props['href']);
}
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
if (isset($props['click'])) {
@@ -498,6 +498,38 @@ final class DocumentValidator
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
}
/**
* Ein Linkziel: alles, was url() erlaubt, dazu tel: und mailto: - dieselben Ziele, die Menü
* (MenuRepository::url) und formatierter Text (RichText::safeHref) schon kennen. Ein Knopf
* „Jetzt anrufen“ war sonst nur als Textlink im Knopf-Aussehen zu bauen (Hüfner, 06.10.2026).
* Bildquellen und eingebettete Inhalte bleiben bei url(): dort ist tel: kein Ziel.
*/
private function link(mixed $url): void
{
if (is_string($url) && strlen($url) <= 2048 && !preg_match('/[\x00-\x20\\\\]/', $url)) {
if (1 === preg_match('/^tel:\+?[0-9()-]{3,32}$/D', $url)) {
return;
}
// Adresse vor dem ersten ?, danach höchstens Betreff und Text, prozentkodiert.
if (1 === preg_match('/^mailto:([^?]+)(\?[A-Za-z0-9%._~=&+-]{0,500})?$/D', $url, $teile)
&& false !== filter_var(rawurldecode($teile[1]), FILTER_VALIDATE_EMAIL)) {
return;
}
}
$this->require(true === $this->isUrl($url), 'Erlaubt sind lokale Pfade, Sprungmarken, HTTPS-Adressen sowie tel: und mailto:.');
}
private function isUrl(mixed $url): bool
{
try {
$this->url($url);
return true;
} catch (InvalidDocument) {
return false;
}
}
private function require(bool $condition, string $message): void
{
if (!$condition) {