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

+20
View File
@@ -148,6 +148,26 @@
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php" "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
], ],
"port": 8101 "port": 8101
},
{
"name": "huefner",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
],
"port": 8102
},
{
"name": "huefner-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
],
"port": 8103
} }
] ]
} }
+4
View File
@@ -389,6 +389,10 @@ Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien G
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`. Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`. Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`. Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
+12
View File
@@ -2,6 +2,18 @@
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
**`tel:` und `mailto:` als Linkziel von Bausteinen.** Menüeinträge (`MenuRepository::url`), Links im formatierten Text (`RichText::safeHref`) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über `DocumentValidator::url()` und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft `link()` diese fünf Stellen: zuerst wie bisher, dazu `tel:` mit Ziffern, `+`, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und `mailto:` mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (`?subject=…`). Bildquellen und eingebettete Inhalte bleiben bei `url()`: `tel:` ist dort kein sinnvolles Ziel.
Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit `tel:` an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update.
**Der Pfeil im Knopf folgt der Schriftfarbe.** Das Pfeilsymbol trägt die Klasse `block-icon`, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. `.button-arrow .block-icon{color:inherit}` in `site.css` setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor.
**Geprüft:** `CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite` (speichern, ausgeben, Bildquelle mit `tel:` abgewiesen), neue Ungültig-Fälle in `invalidDocuments` (Buchstaben oder Leerzeichen in der Nummer, `mailto:` ohne Adresse oder mit Anführungszeichen bzw. `<`), `testTheButtonArrowTakesTheColourOfTheButtonText`.
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026) ## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte. Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
+22
View File
@@ -1,5 +1,27 @@
# Projektstatus # Projektstatus
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/huefner.db`, Medien `var/huefner-media`, Bildquellen `var/huefner-assets`, Startkonfigurationen `huefner` (Port 8102, dev) und `huefner-prod` (Port 8103). `sh scripts/reset-huefner.sh <Ordner des Entwurfs>` kopiert die Bilder und schneidet den Baum aus dem Logo als Seitensymbol (`build-huefner-assets.php`), baut mit `seed-huefner.php` alles neu und holt Barlow und Barlow Condensed auf den eigenen Server. Ohne Argument nimmt es die schon vorbereiteten Bilder.
- **Startseite** mit allen elf Abschnitten des Entwurfs: Hero mit Verlauf und Vorteilsband, Collage, Leistungs- und Referenzkacheln (Foto zoomt, Kachel bekommt Schatten), Chips, Ablauf mit gestrichelter Linie, Fuhrpark, Über uns, Karriere (Stellen als Links mit E-Mail-Betreff), Instagram-Raster, Abschlussband, Kontakt mit dem CMS-Formular `anfrage` (Versand an info@huefner-gartenbau.de). Dazu Impressum, Datenschutz, 404, Menü mit Sprungmarken, klebender Kopf, Einblendungen beim Scrollen.
- **Inhaltliche Ergänzungen** statt der Platzhalter der Vorlage: Öffnungszeiten Mo–Fr 8–17, Sa 8–12 (von Umut, auch als strukturierte Daten), Firmengeschichte und Impressum aus dem Handelsregister (HRB 742159, AG Mannheim, GF Florian Hüfner; Simone Hüfner war nur bis 27.04.2026 Prokuristin), Hoster IONOS, alle Fotos eigene Aufnahmen. Kennzahlen „[XX] Jahre“/„[XXX] Projekte“ ersetzt durch Aussagen der Vorlage (100 % Detailarbeit, Bagger bis 15 t, ein Team). Keine USt-IdNr. bekannt, deshalb keine Zeile.
**Grenzen des Kerns:**
- Nachtrag, gleicher Tag – auf Umuts Wunsch im Kern behoben (ADR 107): Knöpfe und andere verlinkbare Bausteine nehmen `tel:` und `mailto:` an, und der Pfeil im Knopf hat die Schriftfarbe statt der Akzentfarbe. Die Anrufknöpfe bei Hüfner sind jetzt echte Knöpfe, der Pfeil braucht keine eigene Regel mehr. Vorher: Textlink im Knopf-Aussehen und `color:inherit` im CSS des Knopfs.
- Kein LKW im Symbolsatz: Das Symbol steht als „Eigenes HTML“ mit dem SVG der Vorlage.
- Leiste und Kopf kleben gemeinsam (die Einstellung gilt für den ganzen Kopfbereich).
- Zeilen sind durchsichtig, farbige Karten deshalb Spalten mit Flex.
- Eigenes CSS ohne `@media`/`*`: gerätespezifische Werte über die Gestaltungswerte (Bildhöhe, Raster-Spalten, Ränder je Gerät).
- Das HTML-Budget von 150 KB (`test:browser`) war mit dem CSS je Kachel überschritten (152 KB). Die gemeinsamen Regeln stehen jetzt einmal am Abschnitt, Ergebnis 147,5 KB.
**Geprüft:** Lighthouse im Produktionsmodus (Port 8103, je drei Läufe, Median): Startseite mobil 97 / Desktop 98, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. `test:links` ohne toten Verweis, `test:browser` grün (axe in sechs Ansichten, Tastatur, ohne JavaScript, Budget). Ganzseitige Bildschirmfotos bei 1440, 820 und 375 px gegen den Entwurf verglichen, kein waagerechtes Scrollen, keine Konsolenfehler. Zoom, Schatten, Chip- und Knopf-Hover sowie das Klappmenü per Playwright nachgemessen. Keine Felddaten, kein INP. Das Formular wurde nicht abgeschickt (Mailversand nicht ausprobiert).
**Nächster Schritt:** Livegang nach `docs/LIVEGANG.md` (Mailversand einmal echt testen). Die Hüfner-Datenbank braucht einen Kern mit ADR 107 (`tel:` an Knöpfen).
## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026) ## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026)
**Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe. **Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe.
+5 -5
View File
@@ -402,7 +402,7 @@ function renderContent(block, panel) {
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
field('Titel', 'title', block.props.title, { maxLength: 120 }); field('Titel', 'title', block.props.title, { maxLength: 120 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' }); field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
} }
if (block.type === 'stat') { if (block.type === 'stat') {
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' }); field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
@@ -418,7 +418,7 @@ function renderContent(block, panel) {
field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 }); field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' }); field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
} }
if (block.type === 'contact') { if (block.type === 'contact') {
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' }); field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
@@ -439,7 +439,7 @@ function renderContent(block, panel) {
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' }); field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' }); field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 }); if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' }); field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
} }
if (block.type === 'social') { if (block.type === 'social') {
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' }); field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
@@ -556,7 +556,7 @@ function renderContent(block, panel) {
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true }); field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' }); field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
} }
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' }); if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
if (block.type === 'image') { if (block.type === 'image') {
renderImageWidth(block, panel); renderImageWidth(block, panel);
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption }); imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
@@ -568,7 +568,7 @@ function renderContent(block, panel) {
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true; const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine')); const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions }); field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' }); field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
showLinkOptions(clickAction); panel.append(linkOptions); showLinkOptions(clickAction); panel.append(linkOptions);
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
+4
View File
@@ -28,6 +28,10 @@ in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/goo
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei, DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700. `fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -18,3 +18,23 @@
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF} @font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */ /* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} @font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
+3
View File
@@ -267,6 +267,9 @@ body.consent-open{padding-bottom:320px}
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die /* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */ Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
.block-icon .block-icon{color:inherit} .block-icon .block-icon{color:inherit}
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
.button-arrow .block-icon{color:inherit}
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px} .icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px} .icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px} .icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
+45
View File
@@ -0,0 +1,45 @@
<?php
/**
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
*
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
*
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
if ('' === $quelle || '' === $ziel || !is_dir($quelle.'/images')) {
fwrite(STDERR, "Aufruf: php scripts/build-huefner-assets.php <Ordner mit images/> <Zielordner>\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
copy($datei, $ziel.'/'.basename($datei));
}
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
$logo = imagecreatefrompng($quelle.'/images/logo.png');
if (false === $logo) {
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
exit(1);
}
$kante = 512;
$symbol = imagecreatetruecolor($kante, $kante);
imagesavealpha($symbol, true);
imagealphablending($symbol, false);
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
imagealphablending($symbol, true);
[$x, $y, $b, $h] = [15, 10, 490, 550];
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
$zb = (int) round($b * $massstab);
$zh = (int) round($h * $massstab);
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
imagepng($symbol, $ziel.'/favicon.png');
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/huefner.db"
export MEDIA_DIRECTORY="$root/var/huefner-media"
assets="$root/var/huefner-assets"
if [ -n "$1" ]; then
"$php" scripts/build-huefner-assets.php "$1" "$assets"
fi
if [ ! -f "$assets/favicon.png" ]; then
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
exit 1
fi
rm -f "$root/var/huefner.db"
rm -rf "$root/var/huefner-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-huefner.php "$assets"
# Barlow und Barlow Condensed vom eigenen Server; der Abruf ergaenzt fonts.css nur.
if [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
File diff suppressed because it is too large. Load diff
+37 -5
View File
@@ -236,14 +236,14 @@ final class DocumentValidator
} }
} }
if ('button' === $type) { if ('button' === $type) {
$this->url($props['href'] ?? null); $this->link($props['href'] ?? null);
} }
if ('iconcard' === $type) { if ('iconcard' === $type) {
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
$this->text($props['title'] ?? null, 120); $this->text($props['title'] ?? null, 120);
$this->text($props['text'] ?? null, 600, true); $this->text($props['text'] ?? null, 600, true);
if ('' !== ($props['href'] ?? '')) { if ('' !== ($props['href'] ?? '')) {
$this->url($props['href']); $this->link($props['href']);
} }
} }
if ('stat' === $type) { if ('stat' === $type) {
@@ -261,7 +261,7 @@ final class DocumentValidator
$this->text($props['title'] ?? null, 160); $this->text($props['title'] ?? null, 160);
$this->text($props['text'] ?? null, 600, true); $this->text($props['text'] ?? null, 600, true);
$this->text($props['buttonText'] ?? null, 80); $this->text($props['buttonText'] ?? null, 80);
$this->url($props['href'] ?? null); $this->link($props['href'] ?? null);
} }
if ('contact' === $type) { if ('contact' === $type) {
foreach (['phone', 'email', 'address'] as $field) { foreach (['phone', 'email', 'address'] as $field) {
@@ -329,7 +329,7 @@ final class DocumentValidator
$this->text($props['label'] ?? null, 120, $props['decorative']); $this->text($props['label'] ?? null, 120, $props['decorative']);
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.'); $this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
if ('' !== ($props['href'] ?? '')) { if ('' !== ($props['href'] ?? '')) {
$this->url($props['href']); $this->link($props['href']);
} }
} }
if ('social' === $type) { if ('social' === $type) {
@@ -414,7 +414,7 @@ final class DocumentValidator
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. // Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
if ('' !== ($props['href'] ?? '')) { if ('' !== ($props['href'] ?? '')) {
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.'); $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. // Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
if (isset($props['click'])) { 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.'); $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 private function require(bool $condition, string $message): void
{ {
if (!$condition) { if (!$condition) {
+34 -1
View File
@@ -187,7 +187,10 @@ final class CmsTest extends TestCase
$bad = $base; $bad = $base;
$bad['blocks'][] = $base['blocks'][0]; $bad['blocks'][] = $base['blocks'][0];
yield 'duplicate IDs' => [$bad]; yield 'duplicate IDs' => [$bad];
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) { // tel: und mailto: sind Linkziele, aber nur in sauberer Form: keine Buchstaben in der
// Nummer, eine echte Adresse, kein Weg aus dem Attribut.
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo",
'tel:ruf-mich-an', 'tel:+49 7229 6992826', 'mailto:keine-adresse', 'mailto:a@b.de?subject="x"', 'mailto:a@b.de?subject=<script>'] as $url) {
$bad = $base; $bad = $base;
$bad['blocks'][0]['children'][3]['props']['href'] = $url; $bad['blocks'][0]['children'][3]['props']['href'] = $url;
yield $url => [$bad]; yield $url => [$bad];
@@ -243,6 +246,36 @@ final class CmsTest extends TestCase
yield 'missing meaningful alt' => [$bad]; yield 'missing meaningful alt' => [$bad];
} }
/**
* Ein Knopf darf anrufen oder eine E-Mail öffnen - wie ein Menüeintrag oder ein Link im Text.
* Vorher war „Jetzt anrufen“ nur als Textlink im Knopf-Aussehen zu bauen. Eine Bildquelle
* bleibt dagegen bei Pfad und HTTPS.
*/
public function testButtonsAndLinkedBlocksMayCallOrWrite(): void
{
$document = DemoContent::pages()['home'];
$ziele = ['tel:+4972296992826', 'mailto:info@huefner-gartenbau.de', 'mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer'];
foreach ($ziele as $i => $ziel) {
$document['blocks'][0]['children'][3]['props']['href'] = $ziel;
$this->pages->saveDraft('page-1', $document, $i);
}
$id = $this->pages->saveDraft('page-1', $document, 3);
$this->pages->publish('page-1', $id, 4);
$html = $this->request('/')->getContent();
self::assertStringContainsString('href="mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer"', (string) $html);
$bild = DemoContent::pages()['home'];
$bild['blocks'][0]['children'][] = ['id' => 'bild-tel', 'type' => 'image', 'props' => ['src' => 'tel:+4972296992826', 'alt' => 'x', 'decorative' => false, 'width' => 10, 'height' => 10]];
$this->expectException(InvalidDocument::class);
$this->pages->saveDraft('page-1', $bild, 5);
}
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
{
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
self::assertStringContainsString('.button-arrow .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
}
#[DataProvider('invalidDocuments')] #[DataProvider('invalidDocuments')]
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
{ {