Compare commits

..
6 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 668287f640 Release 1.23.0
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:38:01 +02:00
kantarciandClaude Opus 5.5 5e5d380fd3 Build Kantarci Markt as an eighth installation, allow content in own CSS and keep other fonts when fetching
- Coming-soon page "Kantarci Markt – Neueröffnung" rebuilt from the delivered package with
  imprint, privacy and 404 (scripts/reset-kantarci-markt.sh, ports 8100/8101).
- Own CSS accepts a short quoted string for `content`, so ::before/::after work.
- cms:fonts:fetch replaces only the rules of the fetched families in fonts.css.
- A variable font is linked and preloaded once with a weight range instead of once per weight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:37:54 +02:00
kantarciandClaude Opus 5 6fb6d30e8d Release 1.22.5
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:35 +02:00
kantarciandClaude Opus 5 ace6c50743 Raise loaded documents to the schema their blocks need, and bring the old browser checks back
The kant-it.de header used the hamburger switch at schema 19, so the server refused its
preview and save; the editor now raises a loaded document to the version its blocks and
properties require. The admin, button and navigation checks select blocks through the
structure tab, find the motion button by id and expect what the site's motion setting says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:35 +02:00
kantarciandClaude Opus 5 82a74277cb Release 1.22.4
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:59:21 +02:00
kantarciandClaude Opus 5 5d55a65c22 Put the menu drag handle next to the summary instead of inside it
A button inside a summary is an interactive element in an interactive element; screen
readers announce it unreliably. The handle keeps its look and its mouse, touch and arrow
key behaviour. Also corrects the anchor hints and waits for the menu error in the admin check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:59:21 +02:00
30 changed files with 1125 additions and 29 deletions

No files matched your search

+21 -1
View File
@@ -128,6 +128,26 @@
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
],
"port": 8099
},
{
"name": "kantarci-markt",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $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:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
],
"port": 8100
},
{
"name": "kantarci-markt-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$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
}
]
}
}
+4
View File
@@ -385,6 +385,10 @@ Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH,
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
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.
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`.
+1 -1
View File
@@ -1 +1 @@
1.22.3
1.23.0
+2
View File
@@ -711,6 +711,8 @@ Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können.
Zwei echte Löcher kamen beim Testen heraus. `& .block-text` wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde – aus dem Nachfahren wurde derselbe Knoten, die Regel traf das falsche Element. Und `url(//fremd.example/x.png)` kam durch, weil die Prüfung jedes `/` erlaubte: eine protokollrelative Adresse hätte über das Schema der Seite von Dritten geladen. Beides ist behoben und durch Tests abgedeckt.
**Nachtrag 23.09.2026: `content` mit Text.** Werte durften bisher keine Anführungszeichen enthalten, damit gab es kein `::before` oder `::after` – beim Nachbau von Kantarci Markt fehlten dadurch der Verlauf über einem Foto, ein Goldstrich und ein Buchstabe als Wasserzeichen. Jetzt nimmt `content` einen Text in einfachen oder doppelten Anführungszeichen an, höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen und `.,:!?·•–-`. Kein `<`, kein Backslash, kein zweites Anführungszeichen: Der Wert kann weder den String noch das `<style>` verlassen. Jede andere Eigenschaft bleibt ohne Anführungszeichen (also auch kein `font-family:'…'`). Folge: Dekoration, die Vorlesesoftware nicht vorlesen soll, gehört in ein Pseudo-Element statt in einen Textbaustein.
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
+40
View File
@@ -1,5 +1,45 @@
# Projektstatus
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
**Neu.** Datenbank `var/kantarci-markt.db`, Medien `var/kantarci-markt-media`, Bildquellen `var/kantarci-markt-assets`, Startkonfigurationen `kantarci-markt` (Port 8100, dev) und `kantarci-markt-prod` (Port 8101). `sh scripts/reset-kantarci-markt.sh` baut alles neu: `build-kantarci-markt-assets.mjs` (Logo zugeschnitten auf 600 px, Seitensymbol und Stempel als PNG über Chromium), `seed-kantarci-markt.php` (Startseite, Impressum, Datenschutz, 404, Kopf, Fuß, Einstellungen), dann DM Sans auf den eigenen Server. Alles aus gewöhnlichen Bausteinen, kein Plugin.
- **Abweichungen:** Manrope entfällt (DM Sans an seiner Stelle, eine Schrift weniger). Die Bildzeile „Vorfreude auf frische Vielfalt.“ steht höher als in der Vorlage, weil Stempel und Tomatenkarte sie dort verdeckten. Das große „K“ im Abschluss ist ein Pseudo-Element, kein Text. Keine LocalBusiness-Daten, solange der Markt nicht geöffnet hat.
- **Impressum und Datenschutz** sind Pflicht und angelegt. Offen, im Text als „NOCH ZU PRÜFEN“: Betreiber/Rechtsform, Hausnummer, Vertretung, Register, Telefon, USt-IdNr., ob `info@kantarci-markt.de` existiert (die Domain hat Mailserver), Quelle der beiden Fotos, Hosting.
**Im Kern dazu gebaut:**
- **Eigenes CSS: `content` mit kurzem Text** (`CustomCode`), damit `::before`/`::after` möglich sind. Eng begrenzt, siehe Nachtrag zu ADR 028.
- **`fonts.css` wird ergänzt statt überschrieben** (`FontLibrary::combine`): Ein Abruf ersetzt nur die Regeln der geholten Familien. Vorher hätte das Holen von DM Sans Lato und Cormorant der anderen lokalen Installationen aus der gemeinsamen Datei gelöscht.
- **Variable Schriften einmal statt je Schnitt:** DM Sans liefert Google als eine Datei für alle Schnitte. Der Kern verlinkte und lud sie dreimal vor (111 KB statt 37 KB), das kostete am Telefon den LCP. Jetzt eine `@font-face` mit Gewichtsbereich `400 700`, und `preload()` nennt nur Dateien, die `fonts.css` tatsächlich verlinkt.
- Bekannt, nicht geändert: Zeilen sind im Kern immer durchsichtig (`.block-row{background:transparent!important}`). Die grüne Karte „Wir öffnen bald“ ist deshalb eine Spalte.
**Geprüft:** Lighthouse im Produktionsmodus (Port 8101, je drei Läufe, Median): Startseite mobil 97 / Desktop 100, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. Vorher 87 mobil wegen CLS 0,19 (Logo im Kopf bekam seine Breite erst nach dem Laden) und der dreifachen Schrift. Bildschirmfotos 1440 px und 375 px ohne waagerechtes Scrollen und ohne Konsolenfehler, Impressum und 404 angesehen. Neue Tests in `CmsTest` (`content` erlaubt/abgewiesen, `combine`, Preload bei variabler Schrift); volle PHPUnit-Suite (596) und `npm test` (36) grün. Keine Felddaten, kein INP.
**Nächster Schritt:** Die offenen Angaben im Impressum von Umut bekommen; auf Wunsch ein Formular „Zur Eröffnung benachrichtigen“ (braucht dann Einwilligungstext und Datenschutzabsatz).
## Fassung 1.22.5: alte Browserchecks wieder grün, Kopfbereich von kant-it.de wieder bearbeitbar (22.09.2026)
**Anlass:** Umut: „räum den Strukturbaum-Fehler auch auf und Release 1.22.5“.
- **Strukturbaum:** `test:admin`, `test:buttons` und `test:navigation-ux` klickten direkt in den Strukturbaum. Seit 1.11 zeigt die Seitenleiste aber eine Ansicht auf einmal, Standard „Elemente“, und der Baum war versteckt. Jetzt wählen die Checks den Reiter „Struktur“, dann den Baustein und „Bearbeiten“, wie `editor-check.mjs`. Der Editor selbst war richtig.
- **Echter Fehler, dabei gefunden:** Der Kopfbereich in `var/kantit.db` (und damit vermutlich auf kant-it.de) trägt die Hamburger-Umschaltung (`collapse`) bei Schema v19, erlaubt ist sie erst ab v22. Die Website zeigt ihn, aber der Editor bekam für die Vorschau „Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22“, und Speichern wäre ebenso abgelehnt worden: Der Bereich war nicht bearbeitbar. Jetzt hebt `repairSchema()` (editor-model.js) ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen. Das ist dieselbe Hebung, die der Editor beim Einstellen ohnehin macht. Die Umstellung auf formatierten Text (v12/v17) kommt nie von selbst, weil sie die Bedeutung ändert. Beim nächsten Speichern ist das Dokument korrigiert. Alle anderen lokalen Installationen hatten keine solchen Dokumente (geprüft mit dem Validator über alle Seiten und Bereiche).
- **Weitere veraltete Stellen in `test:admin`:** Der Knopf „Bewegung testen“ heißt seit 1.11 „Bewegung in der Vorschau abspielen“ (`#preview-motion`), auch ein Hilfetext im Editor nannte noch den alten Namen. Der Slider-Teil erwartete, dass „Bewegung reduzieren“ die Automatik anhält. Seit 1.11 gilt das nur mit „Systemvorgabe beachten“. Der Check prüft jetzt das Verhalten, das die Einstellung der Website verspricht.
**Geprüft:** `test:admin` (vollständig, auch der öffentliche Teil), `test:buttons`, `test:navigation-ux` und `test:editor` grün. Neuer Einheitstest für `repairSchema` in `tests/editor-model.test.mjs`.
## Fassung 1.22.4: Ziehknopf im Menü neben der Zusammenfassung (22.09.2026)
**Anlass:** Umut: „ja räum das auf und Release 1.22.4“ – die bekannte axe-Meldung `nested-interactive` im Menü-Editor.
- Der Ziehknopf eines Menüeintrags stand in der `<summary>`, also ein Knopf in einem Knopf. Screenreader lesen so etwas unzuverlässig vor, und die Tastatur erreicht es nicht sauber. Jetzt steht er als Geschwister vor `<details>`, per CSS über die linke Ecke der Zusammenfassung gelegt (`menu-entry.html.twig`, `createRow` in `menu-editor.js`). Aussehen und Bedienung bleiben gleich: Ziehen mit Maus, Stift und Finger, Pfeiltasten auf dem Knopf.
- Der Hinweis zu fehlenden Sprungzielen im Menü und die Hilfe zum Feld „Sprungziel“ im Editor nennen jetzt die richtige Stelle (Sektion, Zeile oder Spalte) und den Weg über die Auswahl im Menü.
- `test:admin`: Die Fehlermeldung nach einem abgewiesenen Speichern wurde direkt nach der Serverantwort abgefragt, sie erscheint aber erst, nachdem die Antwort gelesen ist. Jetzt wartet der Test auf sie. Der Check scheitert weiterhin an der älteren Stelle beim Klick in den Strukturbaum des Editors.
**Geprüft:** Die Editor-Prüfung prüft die ganze Menüseite mit axe, ohne Ausnahme. Sie stellt sicher, dass in keiner Zusammenfassung ein Knopf steht, und verschiebt einen Eintrag per Pfeiltaste (Fokus bleibt auf dem Knopf). Bildschirmfoto `reports/editor/menu-editor.png` angesehen.
## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026)
**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2.
+6
View File
@@ -998,3 +998,9 @@ details.card > summary:hover { background: #fafbf8 }
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
.menu-entry { position: relative }
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
.menu-entry summary { padding-left: 58px; min-height: 60px }
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
+29
View File
@@ -152,6 +152,35 @@ export function createBlock(type) {
return block;
}
/**
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
*
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
*/
export function repairSchema(document) {
const walk = blocks => {
for (const block of blocks || []) {
upgradeForType(document, block.type);
const props = block.props || {};
if ('caption' in props) upgradeDocument(document, 3);
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
if ('newTab' in props) upgradeDocument(document, 20);
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
if (block.anchor) upgradeDocument(document, 21);
if ('collapse' in props) upgradeDocument(document, 22);
if ('faq' in props) upgradeDocument(document, 23);
walk(block.children);
}
};
walk(document.blocks);
return document;
}
/** Raises the document schema to the version that knows this block type. */
export function upgradeForType(document, type) {
if (type === 'gallery') upgradeDocument(document, 3);
+6 -4
View File
@@ -9,7 +9,7 @@ import { preview, rowPreview } from './library-previews.js';
import { decorateContainers } from './preview-containers.js';
import { initMotion } from './motion.js';
import { initSliders } from './slider.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS } from './editor-model.js';
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
@@ -530,7 +530,7 @@ function renderContent(block, panel) {
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
panel.append(hint, entries, add);
}
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt. Im Menü lässt es sich dann als Sprungziel dieser Seite auswählen, ein Button springt mit #kontakt hierher.' });
if (block.type === 'accordionitem') {
field('Titel', 'title', block.props.title, { maxLength: 160 });
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
@@ -768,7 +768,7 @@ function renderLegacyEffects(block, panel) {
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
panel.append(labelEl, select);
}
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit dem Abspielknopf über der Vorschau lässt sich das prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
}
/**
@@ -1096,7 +1096,9 @@ async function load() {
designTokens = await response.json();
}
if (!designSchema) await loadDesignSchema();
history = new EditorHistory(data.document);
// Ein Dokument mit Eigenschaften einer neueren Fassung wird beim Laden gehoben, sonst lehnt
// der Server schon die Vorschau ab (siehe repairSchema).
history = new EditorHistory(repairSchema(data.document));
({ version, revisionId, publishedRevisionId, csrfToken } = data);
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
// bekommt dafür eine eigene Gruppe.
+5
View File
@@ -23,6 +23,11 @@ die Verwendung des reservierten Namens für eine geänderte Fassung.
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
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.
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
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 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
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:
https://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.
+4
View File
@@ -14,3 +14,7 @@
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-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:'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 */
@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}
+4 -3
View File
@@ -75,8 +75,9 @@ function createRow(data) {
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
const details = el('details'); const summary = el('summary');
const { handle, chevron } = chrome();
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
if (data.type === 'page') {
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
@@ -136,7 +137,7 @@ function anchorChoice(row) {
for (const item of items) select.append(new Option(item.label, item.anchor));
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
select.value = current;
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor bekommt eine Sektion unter „Erweitert“ einen Anker.';
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
} catch {
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
}
+18 -6
View File
@@ -76,9 +76,15 @@ try {
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
await page.emulateMedia({ reducedMotion: 'reduce' });
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
// Seit 1.11 entscheidet die Website (Einstellungen → Bewegung): Standard ist „immer abspielen“,
// nur mit „Systemvorgabe beachten“ (Klasse motion-respect) hält „Bewegung reduzieren“ die
// Automatik an. Geprüft wird das Verhalten, das die Einstellung verspricht.
const respects = await page.evaluate(() => document.documentElement.classList.contains('motion-respect'));
const play = controls.locator('.slider-play');
assert.equal(await play.isDisabled(), respects, 'the play button follows the motion setting');
const reduced = await geometry(); await page.waitForTimeout(7100);
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
if (respects) assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay when the site respects it');
else assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'autoplay keeps running when the site plays motion always');
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
await page.emulateMedia({ reducedMotion: 'no-preference' });
@@ -143,7 +149,7 @@ try {
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
assert.equal((await menuSave()).status(), 422);
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
assert.equal(await page.locator('#menu-error').isVisible(), true);
await page.locator('#menu-error').waitFor({ state: 'visible' });
await entries.last().locator('summary').click();
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
assert.equal((await menuSave()).status(), 200);
@@ -155,7 +161,7 @@ try {
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
assert.equal((await menuSave()).status(), 409);
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
assert.equal(await page.locator('#menu-error').isVisible(), true);
await page.locator('#menu-error').waitFor({ state: 'visible' });
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
await page.goto(origin + '/admin/forms');
@@ -172,7 +178,10 @@ try {
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
// Die Sektion im Reiter „Struktur“ wählen, dann den Text aus „Elemente“ hineinsetzen.
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
await page.locator('#structure .tree-item').first().click();
await page.getByRole('tab', { name: 'Elemente', exact: true }).click();
await page.locator('#library [data-add="text"]').click();
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
await page.getByLabel('Text', { exact: true }).press('Tab');
@@ -197,7 +206,7 @@ try {
await page.goto(origin + '/admin/pages/' + fixture.homeId);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
await page.locator('#preview-motion').click();
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
@@ -210,7 +219,10 @@ try {
await page.waitForTimeout(800);
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
// Derselbe Knopf stoppt wieder; er sagt es in seiner Beschriftung und in aria-pressed.
assert.equal(await page.locator('#preview-motion').getAttribute('aria-label'), 'Bewegung in der Vorschau stoppen');
await page.locator('#preview-motion').click();
assert.equal(await page.locator('#preview-motion').getAttribute('aria-pressed'), 'false');
assert.equal(await preview.locator('.slider-controls').count(), 0);
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
+82
View File
@@ -0,0 +1,82 @@
/*
* Bereitet die Bilder der Seite „Kantarci Markt – Neueröffnung“ für die Mediathek vor.
*
* Die Mediathek nimmt kein SVG. Das Seitensymbol und der runde Stempel „Frische • Qualität •
* Vertrauen“ der Vorlage sind aber SVG (der Stempel mit Text auf einem Kreisbogen, das kann kein
* Baustein). Chromium rastert beide zu PNG. Das Logo der Vorlage (1536 × 1024, 1,3 MB) wird auf
* seinen sichtbaren Teil zugeschnitten und auf 600 px Breite verkleinert (doppelte Anzeigebreite),
* angezeigt wird es mit höchstens 300 px.
*
* node scripts/build-kantarci-markt-assets.mjs [ordner]
* → logo.png, symbol.png, stempel.png im Ordner (Standard var/kantarci-markt-assets)
*/
import { chromium } from 'playwright';
import { readFile, writeFile } from 'node:fs/promises';
const ordner = process.argv[2] || 'var/kantarci-markt-assets';
const FORST = '#123b26', GOLD = '#a88332', GOLD_HELL = '#d2b978';
const browser = await chromium.launch();
const seite = await browser.newPage();
// Logo: sichtbaren Bereich über den Alphakanal bestimmen, zuschneiden, verkleinern.
const logo = (await readFile(`${ordner}/kantarci-markt-logo.png`)).toString('base64');
const logoPng = await seite.evaluate(async (daten) => {
const bild = new Image();
bild.src = `data:image/png;base64,${daten}`;
await bild.decode();
const quelle = new OffscreenCanvas(bild.width, bild.height);
const q = quelle.getContext('2d');
q.drawImage(bild, 0, 0);
const { data } = q.getImageData(0, 0, bild.width, bild.height);
let [x0, y0, x1, y1] = [bild.width, bild.height, 0, 0];
for (let y = 0; y < bild.height; y++) {
for (let x = 0; x < bild.width; x++) {
// Der Schein um die Waage läuft sehr flach aus; erst ab etwa 8 % Deckkraft zählt er.
if (data[(y * bild.width + x) * 4 + 3] > 20) {
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
}
}
}
const rand = 8;
x0 = Math.max(0, x0 - rand); y0 = Math.max(0, y0 - rand);
x1 = Math.min(bild.width - 1, x1 + rand); y1 = Math.min(bild.height - 1, y1 + rand);
const breite = x1 - x0 + 1, hoehe = y1 - y0 + 1;
const ziel = Math.min(600, breite);
const leinwand = new OffscreenCanvas(ziel, Math.round(hoehe * ziel / breite));
const z = leinwand.getContext('2d');
z.imageSmoothingQuality = 'high';
z.drawImage(bild, x0, y0, breite, hoehe, 0, 0, leinwand.width, leinwand.height);
const blob = await leinwand.convertToBlob({ type: 'image/png' });
const bytes = new Uint8Array(await blob.arrayBuffer());
let binaer = '';
for (const b of bytes) binaer += String.fromCharCode(b);
return { png: btoa(binaer), breite: leinwand.width, hoehe: leinwand.height, ausschnitt: [x0, y0, breite, hoehe] };
}, logo);
await writeFile(`${ordner}/logo.png`, Buffer.from(logoPng.png, 'base64'));
console.log(`logo.png ${logoPng.breite} × ${logoPng.hoehe} (Ausschnitt ${logoPng.ausschnitt.join(', ')})`);
const rastern = async (svg, groesse, datei) => {
await seite.setViewportSize({ width: groesse, height: groesse });
await seite.setContent(`<!doctype html><style>html,body{margin:0;background:transparent}svg{display:block}</style>${svg}`);
await seite.screenshot({ path: `${ordner}/${datei}`, omitBackground: true });
console.log(`${datei} ${groesse} × ${groesse}`);
};
// Seitensymbol: das K mit Blatt aus dem Favicon der Vorlage.
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256">
<rect width="64" height="64" rx="14" fill="#062f24"/>
<path d="M19 43V18h7v10l10-10h9L33 30l13 13h-9L26 32v11z" fill="${GOLD}"/>
<path d="M41 10c-7 2-10 6-9 13 6-1 10-5 9-13Z" fill="${GOLD}"/></svg>`, 256, 'symbol.png');
// Stempel: goldene Scheibe, Umschrift auf dem Kreis, Blatt in der Mitte. Doppelte Größe für
// scharfe Kanten auf hochauflösenden Bildschirmen (angezeigt mit 122 px).
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122 122" width="244" height="244">
<circle cx="61" cy="61" r="61" fill="${GOLD_HELL}"/>
<g transform="translate(10 10) scale(1.1333)">
<defs><path id="kreis" d="M45,45 m-31,0 a31,31 0 1,1 62,0 a31,31 0 1,1 -62,0"/></defs>
<text font-family="Arial, sans-serif" font-weight="700" font-size="8" letter-spacing="1.4" fill="${FORST}"><textPath href="#kreis">FRISCHE • QUALITÄT • VERTRAUEN • </textPath></text>
<path d="M45 52c-2-16 6-25 22-26-1 16-8 24-22 26Zm0 0c-8-11-17-13-27-7 7 12 16 14 27 7Z" fill="${FORST}"/>
</g></svg>`, 244, 'stempel.png');
await browser.close();
+9 -2
View File
@@ -30,6 +30,13 @@ try {
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
// Ein Baustein wird im Reiter „Struktur“ gewählt und mit „Bearbeiten“ geöffnet - die
// Seitenleiste zeigt seit 1.11 nur eine Ansicht auf einmal.
const selectBlock = async id => {
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
await page.locator(`#structure [data-block-id="${id}"]`).click();
await page.locator(`#structure [data-edit-block="${id}"]`).click();
};
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
@@ -53,7 +60,7 @@ try {
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await page.locator('#structure [data-block-id="qa-button"]').click();
await selectBlock('qa-button');
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
@@ -75,7 +82,7 @@ try {
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false);
await page.reload(); await selectBlock('qa-button'); await waitVariant('qa-button', '8px', false);
const stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.schemaVersion, 21);
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
+16 -4
View File
@@ -1189,6 +1189,8 @@ try {
await page.locator('dialog.page-dialog [role="option"]').first().click();
await page.locator('dialog.page-dialog').waitFor({ state: 'detached' });
assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/);
await page.locator('#link-label').fill('Eigener Prüflink');
await page.locator('#menu-add-url').click();
await page.locator('#menu-search').focus();
await page.locator('#menu-search-results [role="option"]').first().click();
await page.locator('#menu-add-page').click();
@@ -1196,11 +1198,21 @@ try {
const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true });
assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice');
assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang');
// Nur die neuen Felder: die Zusammenfassung der Menüeinträge trägt seit jeher einen Ziehknopf
// (nested-interactive), das ist eine eigene, bekannte Baustelle.
// Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
// mehr in einem Knopf.
await neuerEintrag.locator('summary').click();
const menuAxe = await new AxeBuilder({ page }).include('#menu-items .menu-entry:last-child .menu-entry-fields').include('.menu-add').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
assert.deepEqual(menuAxe.violations.map(v => v.id), [], 'anchor choice and link field');
await axe('menu editor');
await page.screenshot({ path: 'reports/editor/menu-editor.png' });
assert.equal(await neuerEintrag.locator('summary [data-menu-drag]').count(), 0, 'no button inside the summary');
// Ziehknopf per Tastatur: Pfeil nach oben verschiebt den Eintrag.
const eintraege = page.locator('#menu-items .menu-entry');
const anzahl = await eintraege.count();
assert.ok(anzahl >= 2);
const titel = await neuerEintrag.locator('.menu-entry-title').textContent();
await eintraege.nth(anzahl - 1).locator('[data-menu-drag]').focus();
await page.keyboard.press('ArrowUp');
assert.equal(await eintraege.nth(anzahl - 2).locator('.menu-entry-title').textContent(), titel, 'the entry moved up');
assert.equal(await eintraege.nth(anzahl - 2).locator('[data-menu-drag]').evaluate(node => node === document.activeElement), true, 'the handle keeps focus');
checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true });
// Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld.
await page.goto(origin + '/admin/posts');
+3
View File
@@ -34,7 +34,10 @@ try {
data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
// Im Reiter „Struktur“ wählen und mit „Bearbeiten“ öffnen, wie im Editor selbst.
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
await page.locator('#structure [data-block-id="qa-anchor"]').click();
await page.locator('#structure [data-edit-block="qa-anchor"]').click();
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Wirft die Kantarci-Markt-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/kantarci-markt.db"
export MEDIA_DIRECTORY="$root/var/kantarci-markt-media"
rm -f "$root/var/kantarci-markt.db"
rm -rf "$root/var/kantarci-markt-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
assets="${1:-$root/var/kantarci-markt-assets}"
# Logo zuschneiden, Seitensymbol und Stempel rastern (Chromium ueber Playwright).
node scripts/build-kantarci-markt-assets.mjs "$assets"
"$php" -d memory_limit=512M scripts/seed-kantarci-markt.php "$assets"
# DM Sans vom eigenen Server. Der Abruf ersetzt in fonts.css nur die Regeln fuer DM Sans; die
# Schriften der anderen Installationen bleiben stehen. Das PHP 8.4 hier kennt keine
# Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
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
+651
View File
@@ -0,0 +1,651 @@
<?php
/**
* Baut die Vorab-Seite „Kantarci Markt – Neueröffnung“ als Inhalt dieses CMS auf.
*
* Vorlage ist das gelieferte Paket „Kantarci-Markt-Landingpage.zip“ (23.09.2026): eine statische
* Seite (index.html, styles.css) mit Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit zwei
* Fotos und Stempel, dem Versprechen (Frisch, Qualität, Vielfältig) auf Waldgrün, dem Abschluss
* „Frische. Qualität. Faire Preise.“ und einem schmalen Fuß. Wortlaut und Farben (Waldgrün
* #123b26, Gold #ad893b, Creme #fcf9f2) sind daraus übernommen. Gebaut ist alles aus gewöhnlichen
* Bausteinen – kein eigener Baustein, kein Plugin.
*
* Bewusste Abweichungen:
* - DM Sans kommt vom eigenen Server (cms:fonts:fetch), nicht von Google. Manrope der Vorlage
* (nur Kleinzeilen, Nummern und die Bildzeile) entfällt, dort steht DM Sans: eine Schrift
* weniger zu laden. Überschriften in Georgia wie in der letzten Fassung der Vorlage.
* - Der runde Stempel ist ein PNG (scripts/build-kantarci-markt-assets.mjs): Text auf einem
* Kreisbogen kann kein Baustein, und die Mediathek nimmt kein SVG.
* - Das große „K“ im Abschluss und der Goldstrich sind Pseudo-Elemente im eigenen CSS der
* Sektion, damit Vorlesesoftware und KI-Leser kein einzelnes „K“ als Inhalt bekommen.
* - Neu sind Impressum und Datenschutz, beide Pflicht für einen geschäftlichen Auftritt (§ 5 DDG,
* Art. 13 DSGVO). Betreiber, Anschrift mit Hausnummer und Kontakt fehlen in der Vorlage; sie
* stehen als „NOCH ZU PRÜFEN“ im Text.
*
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-kantarci-markt.sh).
* Erstes Argument: der Ordner mit logo.png, symbol.png, stempel.png, market.jpg, vegetables.jpg.
*/
require dirname(__DIR__).'/config/bootstrap.php';
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Infrastructure\ConnectionFactory;
use Symfony\Component\HttpFoundation\File\UploadedFile;
$root = dirname(__DIR__);
$assets = $argv[1] ?? null;
if (null === $assets || !is_dir($assets)) {
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
exit(1);
}
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
if (!str_contains($url, 'kantarci-markt')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Kantarci-Markt-Datenbank zeigen.\n");
exit(1);
}
$db = ConnectionFactory::create($url);
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
exit(1);
}
$media = new MediaLibrary($db, $mediaDirectory);
$settings = new SiteSettings($db, $media);
$pages = new PageRepository($db, new DocumentValidator(), $settings);
$partials = new PartialRepository($db, new DocumentValidator());
$theme = new Theme($db);
$name = 'Kantarci Markt';
// Die Domain kantarci-markt.de hat Mailserver (23.09.2026). Ob es das Postfach info@ gibt, ist
// nicht bestätigt – deshalb steht es nur im Impressum und dort als „NOCH ZU PRÜFEN“.
$mail = 'info@kantarci-markt.de';
$strasse = 'Wilhelm-Drapp-Straße';
$plz = '76532';
$ort = 'Baden-Baden';
$SCHEMA = 24;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w];
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = ''): array {
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$row = static function (string $id, array $children, array $design = [], array $style = []): array {
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
if ([] !== $design) {
$block['layout'] = ['design' => $design];
}
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array {
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]];
if ([] !== $tablet) {
$style['tablet'] = $tablet;
}
if ([] !== $mobil) {
$style['mobile'] = $mobil;
}
return $row($id, $children, [], $style);
};
$column = static function (string $id, array $children, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
/** Eine reine Fläche: der kurze Goldstrich vor der Kleinzeile. */
$flaeche = static function (string $id, array $style, string $css = ''): array {
$block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
/** Ein Button ohne den Pfeil des Bausteins. */
$button = static function (string $id, string $text, string $href, array $style = []): array {
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}'];
};
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = '', string $bildzeile = ''): array {
$gross = end($eintrag['variants']);
$props = [
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
'mediaId' => $eintrag['id'],
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
];
if (null !== $href) {
$props['href'] = $href;
$props['click'] = 'link';
}
if ('' !== $bildzeile) {
$props['caption'] = $bildzeile;
}
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$symbol = static function (string $id, string $name, int $groesse, array $style = []): array {
return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
};
/**
* Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen.
*
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
*/
$einblenden = static function (array $dokument, array $plan): array {
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
foreach ($bloecke as $i => $block) {
if (isset($plan[$block['id'] ?? ''])) {
[$art, $verzug] = [...$plan[$block['id']], 'none'];
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
}
if (isset($block['children'])) {
$bloecke[$i]['children'] = $gehen($block['children']);
}
}
return $bloecke;
};
$dokument['blocks'] = $gehen($dokument['blocks']);
return $dokument;
};
// Wiederkehrende Gestaltungswerte der Vorlage.
$kleinStil = ['fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.2em', 'lineHeight' => 1.2, 'textTransform' => 'uppercase'];
$linkCss = "& a{color:#123b26;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#806222}";
/** Die Kleinzeile in Versalien mit dem kurzen Goldstrich davor („— NEUERÖFFNUNG“). */
$kleinzeile = static function (string $id, string $inhalt, string $farbe, string $strichFarbe = '@gold') use ($text, $zeile, $flaeche, $kleinStil): array {
return $zeile($id, [
$flaeche($id.'-strich', ['desktop' => ['width' => '38px', 'height' => '2px', 'backgroundColor' => $strichFarbe, 'flexShrink' => 0]], 'min-height:0'),
$text($id.'-text', $inhalt, ['desktop' => [...$kleinStil, 'color' => $farbe, 'margin' => ['bottom' => '0px']]]),
], '12px', ['margin' => ['top' => '0px', 'bottom' => '24px']]);
};
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => $name, 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'forst', 'name' => 'Waldgrün', 'value' => '#123b26'],
['key' => 'tief', 'name' => 'Tiefgrün (Fuß)', 'value' => '#0b281a'],
['key' => 'gold', 'name' => 'Gold (Linien)', 'value' => '#ad893b'],
['key' => 'gold-hell', 'name' => 'Gold hell (Flächen, auf Grün)', 'value' => '#d2b978'],
['key' => 'gold-text', 'name' => 'Gold als Schrift auf Creme', 'value' => '#806222'],
['key' => 'gold-titel', 'name' => 'Gold in großen Überschriften', 'value' => '#8c6b27'],
['key' => 'creme', 'name' => 'Creme', 'value' => '#fcf9f2'],
['key' => 'sand', 'name' => 'Sand (Abschluss)', 'value' => '#f4efe3'],
['key' => 'text', 'name' => 'Fließtext', 'value' => '#58685e'],
['key' => 'text-gruen', 'name' => 'Fließtext auf Grün', 'value' => '#b7c8c1'],
['key' => 'klein-gruen', 'name' => 'Kleinzeile auf Grün', 'value' => '#a9bdb4'],
['key' => 'fuss-text', 'name' => 'Schrift im Fuß', 'value' => '#a9bab2'],
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
],
[
['key' => 'body', 'name' => 'Fließtext', 'family' => 'DM Sans', 'source' => 'google', 'weights' => [400, 500, 700], 'fallback' => 'sans-serif'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@forst', 'backgroundColor' => '@creme', 'linkColor' => '@forst', 'containerWidth' => '1280px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@forst', 'color' => '@papier', 'hoverBackgroundColor' => '#1d5236', 'hoverColor' => '@papier'],
);
echo "Farben, Schriften und Buttons gesetzt\n";
$medien = [];
foreach ([
'logo' => ['logo.png', 'Kantarci Markt – grünes K mit goldener Waage'],
'symbol' => ['symbol.png', 'Bildmarke von Kantarci Markt'],
'stempel' => ['stempel.png', ''],
'markt' => ['market.jpg', 'Frisches Blattgemüse an einem Marktstand – Beispielbild'],
'tomaten' => ['vegetables.jpg', 'Frische Strauchtomaten – Beispielbild'],
] as $schluessel => [$datei, $alt]) {
$pfad = rtrim($assets, '/\\').'/'.$datei;
if (!is_file($pfad)) {
fwrite(STDERR, "Bild fehlt: $pfad\n");
exit(1);
}
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
$medien[$schluessel] = $media->find($eintrag['id']);
echo "Bild übernommen: $datei\n";
}
// ---------------------------------------------------------------------------------------------
// Kopf- und Fußbereich
// ---------------------------------------------------------------------------------------------
$kopf = [
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
$section('kopf', [
$zeile('kopf-zeile', [
$bild('kopf-logo', $medien['logo'], 'Kantarci Markt – zur Startseite', [
// Volle Breite bis 300 px: sonst hängt die Breite in der zentrierten Zeile am
// geladenen Bild, und der Kopf springt beim Laden (CLS).
'desktop' => ['width' => '100%', 'maxWidth' => '300px', 'margin' => $seiten('0px')],
'mobile' => ['maxWidth' => '230px'],
], '/', '& img{display:block;width:100%;height:auto}'),
], '0px', ['justifyContent' => 'center']),
], [
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '18px', 'bottom' => '18px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
'mobile' => ['padding' => ['top' => '14px', 'bottom' => '14px']],
]),
],
];
$fussLink = "& a{color:#a9bab2}\n& a:hover{color:#fff;text-decoration:underline}";
$fuss = [
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
$section('fuss', [
$zeile('fuss-zeile', [
$text('fuss-copyright', '© 2026 '.$name, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'margin' => ['bottom' => '0px']]]),
$text('fuss-adresse', $strasse.' · '.$ort, ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'margin' => ['bottom' => '0px']]]),
$zeile('fuss-links', [
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
'style' => ['desktop' => ['fontSize' => '14px', 'gap' => '4px', 'color' => '@fuss-text']],
'css' => $fussLink],
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']]], $fussLink),
], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
], '20px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap'],
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']),
], [
'desktop' => ['backgroundColor' => '@tief', 'color' => '@fuss-text', 'padding' => ['top' => '28px', 'bottom' => '28px']],
]),
],
];
$partials->create('header', 'default', 'Kopfbereich', $kopf);
$partials->create('footer', 'default', 'Fußbereich', $fuss);
echo "Kopf- und Fußbereich angelegt\n";
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
/** Ein Versprechen: Nummer, Titel, Satz – auf dem Telefon rückt der Satz unter den Titel. */
$versprechen = static fn (string $n, string $titel, string $satz): array => $zeile('versprechen-'.$n, [
$text('versprechen-'.$n.'-nummer', $n, ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '50px', 'flexShrink' => 0, 'margin' => ['bottom' => '0px']], 'mobile' => ['flexBasis' => '34px']]),
$zeile('versprechen-'.$n.'-inhalt', [
$heading('versprechen-'.$n.'-titel', $titel, 3, [
'desktop' => ['fontSize' => '34px', 'fontWeight' => '400', 'lineHeight' => 1.2, 'color' => '@papier', 'flexGrow' => 3, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']],
'tablet' => ['fontSize' => '28px'],
'mobile' => ['fontSize' => '28px'],
]),
$text('versprechen-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text-gruen', 'maxWidth' => '390px', 'flexGrow' => 5, 'flexBasis' => '0', 'margin' => ['bottom' => '0px']]]),
], '20px', ['alignItems' => 'baseline', 'flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'],
['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '8px']),
], '20px', ['alignItems' => 'baseline', 'padding' => ['top' => '31px', 'bottom' => '31px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb57359']);
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'Neueröffnung',
'description' => 'Kantarci Markt eröffnet bald: Frische, Qualität und faire Preise in der Wilhelm-Drapp-Straße in Baden-Baden.',
'blocks' => [
// 1 – Hero: Text links, rechts das Marktfoto mit Bildzeile, die gedrehte Tomatenkarte und
// der Stempel.
$section('hero', [
$row('hero-zeile', [
$column('hero-text', [
$kleinzeile('hero-label', 'Neueröffnung', '@gold-text'),
$heading('hero-titel', 'Frische bekommt<br><em>eine neue Adresse.</em>', 1, [
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.045em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
'tablet' => ['fontSize' => '64px'],
'mobile' => ['fontSize' => '44px', 'lineHeight' => 1.07],
], '& em{font-style:italic;color:#8c6b27}'),
$text('hero-intro', 'Freuen Sie sich auf sorgfältig ausgewählte Produkte, echte Vielfalt und einen Markt, der Nachbarschaft neu denkt.', [
'desktop' => ['maxWidth' => '600px', 'fontSize' => '20px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '32px', 'bottom' => '38px']],
'mobile' => ['fontSize' => '17px', 'margin' => ['top' => '24px', 'bottom' => '30px']],
]),
// Eine Spalte statt einer Zeile: Zeilen sind im Kern immer durchsichtig.
$column('hero-termin', [
$column('hero-termin-symbol', [
$symbol('hero-termin-icon', 'shop', 24, ['desktop' => ['color' => '@forst']]),
], ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '50px', 'height' => '50px', 'flexShrink' => 0, 'backgroundColor' => '@gold-hell', 'borderRadius' => $rund('50%')]]),
$column('hero-termin-text', [
$text('hero-termin-klein', 'Wir öffnen bald', ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'lineHeight' => 1.3, 'color' => '#bfd1c9', 'margin' => ['bottom' => '3px']]]),
$text('hero-termin-satz', 'Eröffnungstermin folgt', ['desktop' => ['fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.2, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
]),
], [
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '17px', 'backgroundColor' => '@forst', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold',
'borderRadius' => $rund('3px'), 'padding' => ['top' => '15px', 'right' => '24px', 'bottom' => '15px', 'left' => '15px'],
'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#062f2429'],
'mobile' => ['gap' => '12px', 'padding' => ['top' => '14px', 'right' => '16px', 'bottom' => '14px', 'left' => '14px']],
]),
$text('hero-adresse', 'Hier finden Sie uns bald<br><strong>'.$strasse.'</strong><br>'.$ort, [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.55, 'color' => '@text', 'borderWidth' => $nurLinks('3px'), 'borderStyle' => 'solid', 'borderColor' => '@gold', 'padding' => ['left' => '18px'], 'margin' => ['top' => '28px', 'bottom' => '0px']],
], "& strong{color:#123b26;font-weight:700}"),
], [
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center'],
]),
$column('hero-visual', [
$bild('hero-markt', $medien['markt'], 'Frisches Blattgemüse an einem Marktstand – Beispielbild', [
'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '4%', 'bottom' => '8%', 'left' => '5%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'backgroundColor' => '@forst',
'outlineWidth' => '1px', 'outlineStyle' => 'solid', 'outlineColor' => '@gold', 'outlineOffset' => '8px',
'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e'],
'mobile' => ['right' => '0px', 'bottom' => '10%', 'left' => '0px'],
], null,
"& img{display:block;width:100%;height:100%;object-fit:cover}\n"
."&::after{content:'';position:absolute;top:25%;right:0;bottom:0;left:0;background:linear-gradient(transparent,rgba(2,29,22,.85) 65%)}\n"
."& figcaption{position:absolute;z-index:1;bottom:150px;left:28px;right:28px;max-width:260px;margin:0;color:#fff;font-size:26px;font-weight:700;line-height:1.2;text-shadow:0 2px 14px rgba(2,29,22,.7)}",
'Vorfreude auf frische Vielfalt.'),
$bild('hero-tomaten', $medien['tomaten'], 'Frische Strauchtomaten – Beispielbild', [
'desktop' => ['position' => 'absolute', 'width' => '49%', 'height' => '33%', 'right' => '-3%', 'bottom' => '-1%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'rotate' => 4,
'borderWidth' => $seiten('8px'), 'borderStyle' => 'solid', 'borderColor' => '@creme', 'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#021d162e', 'zIndex' => 2],
'mobile' => ['width' => '63%', 'height' => '39%', 'right' => '0px', 'borderWidth' => $seiten('7px')],
], null, '& img{display:block;width:100%;height:100%;object-fit:cover}'),
$bild('hero-stempel', $medien['stempel'], '', [
'desktop' => ['position' => 'absolute', 'maxWidth' => '122px', 'width' => '122px', 'left' => '-1%', 'bottom' => '9%', 'margin' => $seiten('0px'), 'rotate' => -12, 'zIndex' => 3,
'borderRadius' => $rund('50%'), 'shadowY' => '8px', 'shadowBlur' => '24px', 'shadowColor' => '#123b2626'],
'mobile' => ['maxWidth' => '96px', 'width' => '96px', 'left' => '-2%', 'bottom' => '5%'],
], null, '& img{display:block;width:100%;height:auto}'),
], [
'desktop' => ['position' => 'relative', 'minHeight' => '600px'],
'tablet' => ['minHeight' => '520px'],
'mobile' => ['minHeight' => '390px'],
]),
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '80px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '51 49'],
'tablet' => ['gap' => '56px'],
'mobile' => ['gap' => '40px'],
]),
], [
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '72px', 'bottom' => '84px'], 'overflow' => 'hidden'],
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '72px']],
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '64px']],
], 'start'),
// 2 – Das Versprechen auf Waldgrün.
$section('versprechen', [
$row('versprechen-zeile', [
$zeile('versprechen-intro', [
$text('versprechen-nummer', '01', ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@gold-hell', 'flexBasis' => '52px', 'flexShrink' => 0, 'margin' => ['top' => '4px', 'bottom' => '0px']], 'mobile' => ['flexBasis' => '38px']]),
$column('versprechen-titel', [
$text('versprechen-klein', 'Unser Versprechen', ['desktop' => [...$kleinStil, 'letterSpacing' => '0.18em', 'color' => '@klein-gruen', 'margin' => ['bottom' => '20px']]]),
$heading('versprechen-h2', 'Mehr als<br>einkaufen.', 2, [
'desktop' => ['fontSize' => '80px', 'fontWeight' => '400', 'lineHeight' => 1.04, 'letterSpacing' => '-0.035em', 'color' => '@papier', 'margin' => ['bottom' => '0px']],
'tablet' => ['fontSize' => '64px'],
'mobile' => ['fontSize' => '48px'],
]),
]),
], '0px', ['alignItems' => 'flex-start']),
$column('versprechen-liste', [
$versprechen('01', 'Frisch', 'Ausgewählte Produkte, die jeden Einkauf ein bisschen besser machen.'),
$versprechen('02', 'Qualität', 'Sorgfältig ausgewählt, mit Vertrauen gekauft. Gute Produkte zu fairen Preisen.'),
$versprechen('03', 'Vielfältig', 'Ein Sortiment für den Alltag, besondere Momente und jeden Geschmack.'),
], ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#cfb573a6']]),
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '120px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '3 5'],
'tablet' => ['gap' => '56px'],
'mobile' => ['gap' => '48px'],
]),
], [
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '140px', 'bottom' => '140px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '100px']],
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '80px']],
], 'unser-versprechen'),
// 3 – Abschluss: Handschrift links, Gruß rechts, das große K als Wasserzeichen.
$section('abschluss', [
$row('abschluss-zeile', [
$text('abschluss-spruch', 'Frische. Qualität.<br>Faire Preise.', [
'desktop' => ['fontFamily' => 'heading', 'fontStyle' => 'italic', 'fontSize' => '84px', 'lineHeight' => 1.12, 'letterSpacing' => '-0.05em', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
'tablet' => ['fontSize' => '64px'],
'mobile' => ['fontSize' => '48px'],
]),
$column('abschluss-gruss', [
$text('abschluss-gruss-text', 'Wir bereiten gerade alles für Sie vor.', ['desktop' => ['fontSize' => '17px', 'color' => '@text', 'margin' => ['bottom' => '10px']]]),
$text('abschluss-gruss-satz', 'Bis ganz bald im Kantarci Markt.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '34px', 'lineHeight' => 1.25, 'color' => '@forst', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '26px']]),
], ['desktop' => ['maxWidth' => '390px', 'alignSelf' => 'center']]),
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'position' => 'relative', 'zIndex' => 2],
'tablet' => ['gap' => '56px'],
]),
], [
'desktop' => ['backgroundColor' => '@sand', 'position' => 'relative', 'overflow' => 'hidden', 'minHeight' => '450px', 'padding' => ['top' => '130px', 'bottom' => '130px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
'tablet' => ['padding' => ['top' => '100px', 'bottom' => '110px']],
'mobile' => ['padding' => ['top' => '80px', 'bottom' => '96px']],
], 'bis-bald', "&::before{content:'K';position:absolute;right:3%;top:4%;font-family:Georgia,serif;font-size:min(30vw,480px);line-height:1;color:rgba(6,47,36,.045);pointer-events:none}\n"
."&::after{content:'';position:absolute;right:7%;bottom:58px;width:180px;height:2px;background:#ad893b}"),
],
];
// ---------------------------------------------------------------------------------------------
// Seiten anlegen und veröffentlichen
// ---------------------------------------------------------------------------------------------
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
$id = $pages->createPage($slug, null, 'page', $dokument);
if ('' !== $seoTitel) {
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
}
if ('' !== $teilbild) {
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
}
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
return $id;
};
// Der Text des Hero spielt beim Laden; die Fotos bleiben ohne Einblendung (LCP).
$startseite = $einblenden($startseite, [
'hero-label' => ['fade'],
'hero-titel' => ['rise', 'short'],
'hero-intro' => ['rise', 'medium'],
'hero-termin' => ['rise', 'long'],
'hero-adresse' => ['fade', 'long'],
'versprechen-intro' => ['rise'],
'versprechen-liste' => ['rise', 'short'],
'abschluss-spruch' => ['rise'],
'abschluss-gruss' => ['rise', 'short'],
]);
$anlegen('home', $startseite, 'Kantarci Markt – Neueröffnung in Baden-Baden', $medien['markt']['id']);
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Waldgrün, dann Absätze mit Zwischenüberschriften. */
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
$kopf = $section($praefix.'-kopf', [
$kleinzeile($praefix.'-label', $label, '@gold-hell', '@gold-hell'),
$heading($praefix.'-h1', $titel, 1, [
'desktop' => ['fontSize' => '56px', 'fontWeight' => '400', 'lineHeight' => 1.1, 'letterSpacing' => '-0.03em', 'color' => '@papier', 'margin' => ['bottom' => '20px']],
'mobile' => ['fontSize' => '40px'],
]),
$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text-gruen', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '16px'],
]),
], [
'desktop' => ['backgroundColor' => '@forst', 'color' => '@papier', 'padding' => ['top' => '76px', 'bottom' => '68px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@gold'],
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '44px']],
]);
$punkte = [];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
$punkte[] = $row($praefix.'-punkt-'.$i, [
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
'desktop' => ['fontFamily' => 'body', 'fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.5, 'letterSpacing' => '0', 'color' => '@forst', 'margin' => ['bottom' => '0px']],
]),
], [
'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0],
'tablet' => ['flexBasis' => '200px'],
'mobile' => ['flexBasis' => 'auto'],
]),
$column($praefix.'-punkt-'.$i.'-text', [
$text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss),
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'],
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4dcc8', 'padding' => ['top' => '26px', 'bottom' => '26px']],
'tablet' => ['gap' => '28px'],
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']],
]);
}
$inhalt = $section($praefix, [
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]),
], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '56px']]]);
return [$kopf, $inhalt];
};
$pruefen = static fn (string $was): string => '<strong>NOCH ZU PRÜFEN:</strong> '.$was;
$anlegen('impressum', [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben von Kantarci Markt, Baden-Baden.',
'blocks' => [
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
['Anbieter', $name.'<br>'.$pruefen('Inhaber bzw. Firma mit Rechtsform (etwa „… GmbH“ oder „Inhaber …, e. K.“).').'<br>'.$strasse.' '.$pruefen('Hausnummer').'<br>'.$plz.' '.$ort],
['Vertreten durch', $pruefen('Geschäftsführer bzw. Inhaber.')],
['Registereintrag', $pruefen('Handelsregisternummer und Registergericht, falls eingetragen.')],
['Kontakt', "E-Mail: <a href=\"mailto:$mail\">$mail</a><br>".$pruefen('Ob das Postfach existiert, und eine Telefonnummer als zweiten schnellen Kontaktweg ergänzen.')],
['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')],
['Verantwortlich für den Inhalt', $pruefen('Name der verantwortlichen Person, Anschrift wie oben.')],
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'],
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'],
['Bildnachweis', 'Logo: '.$name.'. Die Fotos von Marktstand und Tomaten sind Beispielbilder. '.$pruefen('Quelle und Lizenz der beiden Fotos eintragen.')],
]),
],
], 'Impressum | '.$name);
$anlegen('datenschutz', [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
'description' => 'Welche Daten beim Besuch der Website von Kantarci Markt verarbeitet werden.',
'blocks' => [
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website verarbeitet werden – und welche Rechte Sie haben.', [
['1. Verantwortlicher', $name.', '.$strasse.', '.$plz.' '.$ort.". E-Mail <a href=\"mailto:$mail\">$mail</a>.<br><br>".$pruefen('Inhaber bzw. Firma wie im Impressum.')],
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.<br><br>'.$pruefen('Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen und die Speicherdauer der Serverprotokolle eintragen.')],
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
['4. Kontakt per E-Mail', 'Wenn Sie uns schreiben, verarbeiten wir Ihre Adresse und Ihre Nachricht, um Ihr Anliegen zu beantworten (Art. 6 Abs. 1 lit. b oder f DSGVO). Wir löschen die Nachricht, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
['5. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen.'],
['6. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
['', "Fragen zum Datenschutz beantworten wir gern unter <a href=\"mailto:$mail\">$mail</a>."],
]),
],
], 'Datenschutz | '.$name);
$fehlerId = $anlegen('404', [
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
'blocks' => [
$section('fehler', [
$kleinzeile('fehler-label', 'Fehler 404', '@gold-text'),
$heading('fehler-titel', 'Diese Seite<br><em>gibt es nicht.</em>', 1, [
'desktop' => ['fontSize' => '64px', 'fontWeight' => '400', 'lineHeight' => 1.05, 'letterSpacing' => '-0.04em', 'color' => '@forst', 'margin' => ['bottom' => '22px']],
'mobile' => ['fontSize' => '42px'],
], '& em{font-style:italic;color:#8c6b27}'),
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
'desktop' => ['maxWidth' => '460px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '28px']],
]),
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]),
], [
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '130px']],
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
]),
],
]);
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
// ---------------------------------------------------------------------------------------------
// Einstellungen, Konto
// ---------------------------------------------------------------------------------------------
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => $name,
'description' => 'Kantarci Markt eröffnet bald in der Wilhelm-Drapp-Straße in Baden-Baden: frische Produkte, echte Vielfalt und faire Preise.',
'email' => '',
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
// Noch kein Geschäft mit Öffnungszeiten: keine LocalBusiness-Angaben, bis der Markt öffnet.
'localBusiness' => false, 'llmsTxt' => true,
'faviconMediaId' => $medien['symbol']['id'],
'stickyHeader' => false, 'headerOverlay' => false,
'motion' => 'always',
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
'consentBanner' => false, 'scrollTop' => false, 'postMetaHeader' => false,
]);
echo "Einstellungen gespeichert\n";
$passwort = bin2hex(random_bytes(12));
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
+8
View File
@@ -239,6 +239,14 @@ final class CustomCode
if (1 !== preg_match('/^-{0,2}[a-z][a-z0-9-]{0,40}$/D', $property)) {
throw new InvalidDocument(sprintf('„%s“ ist kein CSS-Eigenschaftsname.', mb_substr($property, 0, 40)));
}
// `content` braucht einen Text in Anfuehrungszeichen, sonst gibt es kein ::before und
// ::after fuer Striche, Verlaeufe oder ein Wasserzeichen. Erlaubt ist ein kurzer Text
// aus Buchstaben, Ziffern und wenigen Satzzeichen: kein <, kein Backslash, kein
// weiteres Anfuehrungszeichen, also kein Weg aus dem String oder dem <style>.
if ('content' === $property && 1 === preg_match('/^(["\'])[\p{L}\p{N} .,:!?·•–-]{0,40}\1$/uD', $value)) {
$out[] = $property.':'.$value;
continue;
}
if ('' === $value || 1 !== preg_match('/^[a-zA-Z0-9 ,.%#()\/:_+*-]{1,300}$/D', $value)) {
throw new InvalidDocument(sprintf('Der Wert von „%s“ enthält Zeichen, die in eigenem CSS nicht vorgesehen sind.', $property));
}
+61 -7
View File
@@ -84,21 +84,41 @@ final class FontLibrary
$bytes = 0;
foreach ($wanted as $family => $weights) {
$css = $this->stylesheet($family, $weights);
// Eine variable Schrift (etwa DM Sans) liefert Google als eine Datei für alle
// Schnitte, mit einer @font-face-Regel je Schnitt. Die Datei liegt trotzdem je Schnitt
// im Ordner, damit missing() weiter nach Schnitten fragen kann, aber das Stylesheet
// nennt sie nur einmal, mit einem Gewichtsbereich. Sonst lädt der Browser dieselben
// Bytes je Schnitt einmal, und preload() holt sie dreimal vorab.
$groups = [];
foreach ($this->parse($css, $family) as $face) {
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
$path = $this->directory.'/'.$name;
if (!is_file($path)) {
file_put_contents($path, $this->download($face['url']));
++$files;
if (isset($groups[$face['url']])) {
copy($this->directory.'/'.$groups[$face['url']]['name'], $path);
} else {
file_put_contents($path, $this->download($face['url']));
++$files;
}
}
if (isset($groups[$face['url']])) {
$groups[$face['url']]['weights'][] = $face['weight'];
continue;
}
$bytes += (int) filesize($path);
$groups[$face['url']] = ['name' => $name, 'weights' => [$face['weight']], 'face' => $face];
}
foreach ($groups as $group) {
$face = $group['face'];
$low = min($group['weights']);
$high = max($group['weights']);
$rules[] = sprintf(
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%s;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
$face['subset'],
$family,
$face['style'],
$face['weight'],
$name,
$low === $high ? (string) $low : $low.' '.$high,
$group['name'],
$face['range'],
);
}
@@ -106,11 +126,40 @@ final class FontLibrary
if ([] === $rules) {
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
}
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
$stylesheet = $this->directory.'/fonts.css';
$existing = is_file($stylesheet) ? (string) file_get_contents($stylesheet) : '';
file_put_contents($stylesheet, $this->combine($existing, $rules, array_keys($wanted)));
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
}
/**
* Setzt fonts.css aus den neuen Regeln und den Regeln anderer Familien zusammen.
*
* Bisher schrieb jeder Abruf die Datei neu und nur mit den Familien des aktuellen Designs.
* Teilen sich mehrere Websites den Ordner (lokal alle Installationen) oder wechselt ein Design
* die Schrift und wieder zurück, verschwanden damit Schriften, deren Dateien noch daliegen.
* Regeln für Familien, die gerade neu geholt wurden, werden ersetzt; die übrigen bleiben.
* Eine unbenutzte @font-face-Regel kostet nichts: der Browser lädt die Datei erst, wenn Text
* in dieser Familie steht.
*
* @param list<string> $rules je Regel ein Kommentar mit dem Bereich und die @font-face
* @param list<string> $families die Familien, zu denen $rules gehören
*/
public function combine(string $existing, array $rules, array $families): string
{
$kept = [];
preg_match_all('~(?:/\*[^*]*\*/\s*)?@font-face\s*\{[^}]*\}~', $existing, $matches);
foreach ($matches[0] as $rule) {
if (1 !== preg_match("~font-family:\\s*'([^']+)'~", $rule, $family) || in_array($family[1], $families, true)) {
continue;
}
$kept[] = trim($rule);
}
return implode("\n", [...$kept, ...$rules])."\n";
}
/**
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
*
@@ -124,11 +173,16 @@ final class FontLibrary
*/
public function preload(array $theme): array
{
// Nur Dateien, die das Stylesheet auch nennt: bei einer variablen Schrift steht dort eine
// Datei für alle Schnitte, die Kopien je Schnitt holt der Browser nie.
$stylesheet = is_file($this->directory.'/fonts.css') ? (string) file_get_contents($this->directory.'/fonts.css') : null;
$preload = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach ($weights as $weight) {
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
if (is_file($this->directory.'/'.$name)) {
$genannt = null === $stylesheet || !str_contains($stylesheet, '/assets/fonts/'.$this->slug($family).'-')
|| str_contains($stylesheet, '/assets/fonts/'.$name.')');
if (is_file($this->directory.'/'.$name) && $genannt) {
$preload[] = '/assets/fonts/'.$name;
}
}
+4 -1
View File
@@ -2,8 +2,11 @@
<input type="hidden" data-key="position" name="items[{{ n }}][position]" value="{{ n }}">
<input type="hidden" data-key="level" name="items[{{ n }}][level]" value="{{ row.level }}">
<input type="hidden" data-key="type" name="items[{{ n }}][type]" value="{{ row.type }}">
{# Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr: ein Knopf in einem Knopf ist
für Screenreader und Tastatur nicht bedienbar (axe: nested-interactive). #}
<button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button>
<details>
<summary><button class="menu-drag-handle" type="button" data-menu-drag aria-label="{{ row.label }} verschieben" title="Ziehen · Pfeiltasten ändern Reihenfolge und Ebene"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg></button><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<summary><strong class="menu-entry-title">{{ row.label }}</strong><span class="menu-entry-kind">{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}</span><svg class="menu-chevron" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="menu-entry-fields">
<label for="menu-label-{{ n }}">Beschriftung</label><input id="menu-label-{{ n }}" data-key="label" name="items[{{ n }}][label]" maxlength="80" required value="{{ row.label }}">
{% if row.type == 'page' %}
+21
View File
@@ -1516,6 +1516,16 @@ final class CmsTest extends TestCase
self::assertSame('https://fonts.gstatic.com/s/lato/latein.woff2', $faces[0]['url']);
self::assertStringContainsString('U+0131', $faces[0]['range']);
// Ein neuer Abruf ersetzt nur die Regeln der geholten Familien; andere Familien, deren
// Dateien noch im Ordner liegen, bleiben im Stylesheet.
$vorher = "/* latin */\n@font-face{font-family:'Lato';font-weight:400;src:url(/assets/fonts/lato-400-latin.woff2)}\n"
."/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/alt.woff2)}\n";
$neu = $fonts->combine($vorher, ["/* latin */\n@font-face{font-family:'DM Sans';font-weight:400;src:url(/assets/fonts/dm-sans-400-latin.woff2)}"], ['DM Sans']);
self::assertStringContainsString('lato-400-latin.woff2', $neu);
self::assertStringContainsString('dm-sans-400-latin.woff2', $neu);
self::assertStringNotContainsString('alt.woff2', $neu);
self::assertSame(2, substr_count($neu, '@font-face'));
// Ohne heruntergeladene Dateien bleibt es bei der Ersatzschrift - verlinkt wird Google
// nie, denn das gäbe die IP jedes Besuchers an einen Dritten weiter.
$theme = new Theme($this->db);
@@ -1547,6 +1557,11 @@ final class CmsTest extends TestCase
'/assets/fonts/lato-700-latin.woff2',
'/assets/fonts/lato-900-latin.woff2',
], $fonts->preload($theme->load()));
// Eine variable Schrift ist eine Datei für alle Schnitte: das Stylesheet nennt sie einmal
// mit Gewichtsbereich, und vorgeladen wird nur diese eine Datei statt dreimal dieselbe.
file_put_contents($this->path.'-fonts/fonts.css', "@font-face{font-family:'Lato';font-weight:400 900;src:url(/assets/fonts/lato-400-latin.woff2) format('woff2')}");
self::assertSame(['/assets/fonts/lato-400-latin.woff2'], $fonts->preload($theme->load()));
}
public function testTheStylesheetsGoOutAsOneFileWithoutComments(): void
@@ -3138,6 +3153,9 @@ final class CmsTest extends TestCase
& .card { transform: none }', '#b1 .card{border:0;transform:none}'];
yield 'element rule after a nested one' => ['& { color: red }
display: flex', '#b1{display:flex;color:red}'];
// Ein Pseudo-Element braucht content: leer fuer Striche und Verlaeufe, ein Zeichen fuer ein Wasserzeichen.
yield 'empty content' => ["&::after { content: ''; height: 2px }", "#b1::after{content:'';height:2px}"];
yield 'short content' => ['&::before { content: "K" }', '#b1::before{content:"K"}'];
}
#[DataProvider('ownCssCases')]
@@ -3161,6 +3179,9 @@ display: flex', '#b1{display:flex;color:red}'];
yield 'old expression syntax' => ['color: expression(alert(1))'];
yield 'no value' => ['color'];
yield 'nonsense property' => ['co lor: red'];
yield 'content leaving the style tag' => ['content: "</style><script>"'];
yield 'content with a second quote' => ["content: 'a' 'b'"];
yield 'quotes outside content' => ["font-family: 'Evil'"];
}
#[DataProvider('refusedOwnCss')]
+15
View File
@@ -642,3 +642,18 @@ test('registered plugin blocks get labels, defaults, container rules and presets
assert.equal(pluginDefinition(bild), null);
assert.equal(isContainer(galerie), false);
});
test('a loaded document is raised to the schema its blocks and properties need', async () => {
const { repairSchema } = await import('../public/assets/editor-model.js');
// So stand der Kopfbereich von kant-it.de da: Hamburger-Umschaltung bei Fassung 19.
const header = { schemaVersion: 19, title: 'Kopf', description: '', blocks: [
{ id: 's', type: 'section', anchor: 'oben', children: [{ id: 'm', type: 'menu', props: { slot: 'header', collapse: 'tablet' } }] },
] };
assert.equal(repairSchema(header).schemaVersion, 22);
// Nichts Neues darin: die Fassung bleibt - auch die Umstellung auf formatierten Text (12)
// kommt nie von allein.
const plain = { schemaVersion: 5, title: 'Alt', description: '', blocks: [{ id: 't', type: 'text', props: { text: 'a < b' } }] };
assert.equal(repairSchema(plain).schemaVersion, 5);
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
assert.equal(repairSchema(faq).schemaVersion, 23);
});