Kant CMS: Grundgerüst, visueller Editor, Mediathek
Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting. Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen, getrennten Entwürfen/Veröffentlichungen und optimistischer Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau, responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek, Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript. Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
82b07820bc
87 files changed
+14463
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
APP_ENV=dev
|
||||||
|
APP_DEBUG=1
|
||||||
|
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||||
|
SITE_ORIGIN=http://127.0.0.1:8080
|
||||||
|
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
AGENTS.md -text
|
||||||
|
ENTWICKLUNGSPLAN.md -text
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
name: CMS quality
|
||||||
|
on: [push, pull_request]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
jobs:
|
||||||
|
php:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php: ['8.2', '8.4']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '${{ matrix.php }}'
|
||||||
|
extensions: pdo_sqlite, pdo_mysql, mbstring, xml, gd, exif
|
||||||
|
coverage: none
|
||||||
|
- run: composer install --no-interaction --prefer-dist
|
||||||
|
- run: php bin/setup.php
|
||||||
|
- run: composer validate --strict
|
||||||
|
- run: composer lint
|
||||||
|
- run: composer test
|
||||||
|
- run: composer audit
|
||||||
|
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||||
|
- run: php bin/console cms:seed-demo
|
||||||
|
- run: php bin/console cms:seed-demo
|
||||||
|
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
|
||||||
|
- run: composer build
|
||||||
|
browser:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: pdo_sqlite, gd, exif
|
||||||
|
coverage: none
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: npm
|
||||||
|
- run: composer install --no-interaction --prefer-dist
|
||||||
|
- run: php bin/setup.php
|
||||||
|
- run: php vendor/bin/doctrine-migrations migrate --no-interaction && php bin/console cms:seed-demo
|
||||||
|
- run: composer build
|
||||||
|
- run: npm ci && npx playwright install --with-deps chromium
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run test:editor -- --audit
|
||||||
|
- name: Start production-mode preview
|
||||||
|
run: APP_ENV=prod APP_DEBUG=0 php -S 127.0.0.1:8080 -t public > var/server.log 2>&1 &
|
||||||
|
- name: Wait for preview
|
||||||
|
run: node -e "for(let i=0;i<30;i++){try{if((await fetch('http://127.0.0.1:8080')).ok)process.exit(0)}catch{}await new Promise(r=>setTimeout(r,500))}process.exit(1)"
|
||||||
|
- run: npm run test:browser
|
||||||
|
- run: npm run audit
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: browser-and-lighthouse
|
||||||
|
path: reports/
|
||||||
|
mysql:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: kant_ci
|
||||||
|
MYSQL_USER: kant_ci
|
||||||
|
MYSQL_PASSWORD: ci_only
|
||||||
|
MYSQL_ROOT_PASSWORD: ci_only_root
|
||||||
|
ports: ['3306:3306']
|
||||||
|
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=10
|
||||||
|
env:
|
||||||
|
DATABASE_URL: mysql://kant_ci:ci_only@127.0.0.1:3306/kant_ci?charset=utf8mb4
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: pdo_mysql, gd, exif
|
||||||
|
coverage: none
|
||||||
|
- run: composer install --no-interaction --prefer-dist
|
||||||
|
- run: php bin/setup.php
|
||||||
|
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||||
|
- run: php bin/console cms:seed-demo
|
||||||
|
- run: php bin/console cms:seed-demo
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/.venv/
|
||||||
|
/vendor/
|
||||||
|
/var/
|
||||||
|
/node_modules/
|
||||||
|
/.env.local
|
||||||
|
/.env.*.local
|
||||||
|
/phpunit.cache/
|
||||||
|
/reports/
|
||||||
|
/.php-cs-fixer.cache
|
||||||
|
*.log
|
||||||
|
/config/reference.php
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return (new PhpCsFixer\Config())
|
||||||
|
->setRules(['@Symfony' => true])
|
||||||
|
->setFinder(PhpCsFixer\Finder::create()->in(['src', 'config', 'migrations', 'tests'])->append(['public/index.php', 'bin/console', 'bin/setup.php', 'cli-config.php', 'migrations.php']));
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Projektregeln – Eigenes CMS
|
||||||
|
|
||||||
|
## Ziel und Kontext
|
||||||
|
|
||||||
|
Entwickle für Umut ein selbst betreibbares CMS für Kundenwebsites. Der visuelle Editor soll das vertraute Bedienprinzip des Flatsome UX Builders aufgreifen. Prioritäten: gutes Bearbeiten, schnelle öffentliche Ausgabe, integriertes SEO, zugängliche Inhalte für KI-Systeme, Templates, Plugins und zuverlässiger Betrieb.
|
||||||
|
|
||||||
|
Der Entwicklungsplan steht in ENTWICKLUNGSPLAN.md. Produktname und Stack sind noch nicht festgelegt. Diese Datei ist eine vorbereitete Projektanweisung; sie gehört beim Einrichten in den Repository-Stamm.
|
||||||
|
|
||||||
|
## Vorrangige Qualitätsziele
|
||||||
|
|
||||||
|
- Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing haben ab dem ersten Prototyp höchste Priorität. Details und Messbedingungen stehen im Entwicklungsplan.
|
||||||
|
- Öffentliche Referenzseiten: Lighthouse-Leistung mindestens 95 mobil und Desktop, 100 anstreben; Barrierefreiheit, Best Practices und SEO jeweils 100 als automatische Testziele. Alle anwendbaren Agentic-Browsing-Prüfungen der dokumentierten Toolversion bestehen.
|
||||||
|
- Reale Inhalte, Produktionsbuild und reproduzierbare Messbedingungen verwenden. Keine Scores für beliebige Kundenseiten garantieren und keine leeren Demos als Leistungsnachweis verwenden.
|
||||||
|
- Manuelle Barrierefreiheits- und Bedienprüfungen ergänzen automatische Audits. Agentisches Browsing und GEO getrennt überprüfen.
|
||||||
|
- Core Web Vitals: LCP <= 2,5 s, INP <= 200 ms, CLS <= 0,1 am 75. Perzentil als Feldziele. Fehlende Felddaten nicht als bestanden markieren; INP nicht aus einem normalen Lighthouse-Ladelauf ableiten.
|
||||||
|
- Änderungen an Templates, Blöcken und Plugins dürfen diese Grundlagen nicht unbemerkt verschlechtern. Qualitätsprüfungen beginnen mit der ersten ausführbaren öffentlichen Seite, nicht erst in Phase 5.
|
||||||
|
|
||||||
|
## Vor jeder Aufgabe
|
||||||
|
|
||||||
|
- Lies ENTWICKLUNGSPLAN.md und, sobald vorhanden, docs/STATUS.md sowie relevante Entscheidungen in docs/ARCHITECTURE.md.
|
||||||
|
- Prüfe den tatsächlichen Repositoryzustand; erfinde keine vorhandenen Dateien, Startbefehle oder Testergebnisse.
|
||||||
|
- Wähle den nächsten umsetzbaren Schritt im beauftragten Umfang und führe ihn bis zu einem prüfbaren Ergebnis durch.
|
||||||
|
- Benenne nur solche offenen Fragen als Blocker, deren Antwort die konkrete Umsetzung wesentlich verändert.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- Öffentliche Seiten liefern den Hauptinhalt im HTML. Editorcode gehört nicht in das öffentliche Standardpaket.
|
||||||
|
- Inhalte als validierten, versionierten Blockbaum mit stabilen IDs speichern; keine neuen proprietären Shortcodes als Datenbasis.
|
||||||
|
- Inhalte, Layout und Designwerte getrennt halten. Vorschau und Veröffentlichung nutzen gemeinsame Blockverträge.
|
||||||
|
- Änderungen am Dokumentschema, an Plugin-APIs oder Datenbankstrukturen benötigen einen dokumentierten Migrationsweg.
|
||||||
|
- Entwürfe und veröffentlichte Inhalte trennen. Veraltete Schreibzugriffe erkennen; Fehler nicht durch stilles Überschreiben verdecken.
|
||||||
|
- Plugins über definierte Schnittstellen anbinden. Serverseitigen Erweiterungscode nicht ohne implementierte Isolation als sandboxed bezeichnen.
|
||||||
|
- Für Authentifizierung und andere Standardfunktionen gepflegte Bibliotheken verwenden; Frameworkwahl in Phase 0 begründen.
|
||||||
|
|
||||||
|
## Editor und Qualität
|
||||||
|
|
||||||
|
- Tastaturbedienung, responsive Verhalten, Undo/Redo, Speicherstatus und Fehlerzustände bei den betroffenen Funktionen mitliefern.
|
||||||
|
- Tests auf konkrete Risiken ausrichten: Speichern/Laden, Undo/Redo, Berechtigungen, Migrationen und Cacheerneuerung nach Publizieren.
|
||||||
|
- Visuelle Änderungen in einer Browser-Vorschau prüfen, sobald eine ausführbare Anwendung existiert.
|
||||||
|
- Nur tatsächlich ausgeführte Prüfungen als bestanden melden. Fehlende Zugriffe oder nicht ausführbare Prüfungen klar festhalten.
|
||||||
|
- Keine garantierten SEO-Rankings oder KI-Zitierungen versprechen.
|
||||||
|
|
||||||
|
## Zusammenarbeit und Übergabe
|
||||||
|
|
||||||
|
- Mit Umut auf Deutsch, klar und direkt kommunizieren.
|
||||||
|
- Bestehende Nutzeränderungen erhalten. Geheimnisse nicht in Code oder Dokumentation eintragen.
|
||||||
|
- Den beauftragten Umfang selbstständig abschließen; neue externe Veröffentlichungen und irreversible Änderungen anhand des konkreten Nutzerauftrags behandeln.
|
||||||
|
- Nach einem abgeschlossenen Arbeitsschritt docs/STATUS.md aktualisieren: erledigt, geprüft, bekannte Fehler, nächster Schritt.
|
||||||
|
- Bedeutende Entscheidungen mit Gründen und Folgen in docs/ARCHITECTURE.md festhalten.
|
||||||
|
- Beim initialen Setup diese beiden Dokumente anlegen und reale Start-, Build- und Prüfkommandos in README.md dokumentieren.
|
||||||
|
- Projektgedächtnis in Dateien pflegen; kein automatisches vollständiges Erinnern über neue Chats hinweg voraussetzen.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Eigenes CMS – Entwicklungsplan
|
||||||
|
|
||||||
|
Stand: 11.09.2026. Status: Planung; noch keine CMS-Implementierung und kein Repository eingerichtet.
|
||||||
|
Arbeitstitel: Kant CMS. Der Produktname ist noch offen.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein selbst betreibbares CMS für Umuts Kundenwebsites. Schwerpunkt: ein visueller Editor mit dem vertrauten Bedienprinzip des Flatsome UX Builders, schnelle öffentliche Seiten, integriertes SEO, gut zugängliche Inhalte für Suchmaschinen und KI-Systeme, erweiterbare Templates und Plugins.
|
||||||
|
|
||||||
|
Der Editor wird eigenständig implementiert. Die konkrete Referenzoberfläche und die von Umut meistgenutzten Abläufe werden zu Beginn anhand bereitgestellter Screenshots oder einer zugänglichen Demo erfasst. Der Plan behauptet keine vollständige Flatsome-Kompatibilität.
|
||||||
|
|
||||||
|
## Oberste Priorität: fünf Qualitätsbereiche ab Phase 0
|
||||||
|
|
||||||
|
Umut hat Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing ausdrücklich in den Vordergrund gestellt. Diese Anforderungen gelten für Architektur, jeden Block, jedes Template und jedes Plugin ab Beginn. Phase 5 bündelt zusätzliche Funktionen und die abschließende Überprüfung; Qualitätsarbeit darf nicht bis dahin aufgeschoben werden.
|
||||||
|
|
||||||
|
Ausgangswerte ausschließlich aus dem bereitgestellten mobilen PageSpeed-Screenshot für www.kant-it.de: Leistung 67, Barrierefreiheit 87, Best Practices 77, SEO 100, agentisches Browsing 2/3. Im Screenshot sind keine Felddaten vorhanden. Es wurde kein eigener Audit der bestehenden Website durchgeführt; Ursachen sind aus der Übersicht nicht ableitbar.
|
||||||
|
|
||||||
|
| Bereich | Entwicklungs- und Abnahmeziel |
|
||||||
|
| --- | --- |
|
||||||
|
| Leistung | Auf repräsentativen öffentlichen Referenzseiten mindestens 95 mobil, 100 anstreben; Desktop ebenfalls mindestens 95. Feste Testbedingungen, Produktionsbuild und Median aus drei Läufen. |
|
||||||
|
| Barrierefreiheit | 100 bei automatischen Lighthouse-Prüfungen plus manuelle Prüfung von Tastatur, Fokus, Dialogen, Formularen und Screenreader-Grundabläufen. |
|
||||||
|
| Best Practices | 100 bei den anwendbaren automatischen Prüfungen; zusätzlich funktionierende Fehlerbehandlung und Sicherheitsprüfungen für den CMS-Kern. |
|
||||||
|
| SEO | 100 bei automatischen Prüfungen indexierbarer Referenzseiten plus Prüfung von Canonicals, Sitemap, Weiterleitungen, strukturierten Daten und tatsächlicher HTML-Ausgabe. |
|
||||||
|
| Agentisches Browsing | Alle anwendbaren Prüfungen der eingesetzten Auditversion bestehen; zusätzlich reale Abläufe wie Navigation, Suche und Formularbedienung prüfen. Kein dauerhaft festgeschriebener Nenner von drei. |
|
||||||
|
|
||||||
|
Diese Werte sind Projektziele, keine Garantie für jede spätere Kundenkonfiguration. Testversion, Geräte-/Netzprofil, Inhalte, Server und externe Dienste werden im Bericht erfasst. Die Auswahl umfasst Startseite, Inhaltsseite, Beitrag und Formularseite mit realistischen Bildern und Texten; keine leeren Demo-Seiten als Leistungsnachweis.
|
||||||
|
|
||||||
|
Core Web Vitals im Feld: LCP <= 2,5 s, INP <= 200 ms, CLS <= 0,1 am 75. Perzentil, mobil und Desktop getrennt. Fehlende Felddaten als unbekannt ausweisen. Lighthouse-Labortests messen INP nicht direkt; TBT getrennt berichten.
|
||||||
|
|
||||||
|
Konkrete Konsequenzen für den Editor: semantische HTML-Ausgabe, echte Links und Buttons, Formularlabels, sichtbarer Fokus, Kontrastprüfung, Kennzeichnung dekorativer Bilder, Bildabmessungen und automatische Bildoptimierung. Große Bilder und zusätzliche externe Skripte sichtbar machen. Headerbilder gezielt priorisieren; nachgelagerte Bilder bei Bedarf verzögert laden. Keine Editor-Bibliotheken in öffentliche Seiten übernehmen.
|
||||||
|
|
||||||
|
Agentisches Browsing und GEO getrennt behandeln: Bedienbarkeit durch Agenten ist etwas anderes als Auffindbarkeit/Zitierung in KI-Antworten. Laut aktueller Chrome-Dokumentation ist die Agentic-Browsing-Kategorie experimentell und prüft unter anderem maschinelle Bedienbarkeit, Stabilität, WebMCP und llms.txt. Die konkreten Auditdetails der Nutzerseite müssen vor Diagnose geöffnet werden. WebMCP-Unterstützung austauschbar und nur für sinnvolle Funktionen vorsehen; Autorisierung und serverseitige Validierung gelten auch für Agentenzugriffe. Eine maschinenlesbare Zusammenfassung darf nur öffentliche Inhalte enthalten. llms.txt ist dabei kein allgemeiner Google-SEO-Rankingfaktor und keine Voraussetzung für die Google-KI-Suche.
|
||||||
|
|
||||||
|
Qualitätsprüfungen werden mit der ersten öffentlich gerenderten Seite eingerichtet und nach Änderungen an Renderer, Templates und Plugins auf Regressionen geprüft. Ein Score von 100 ersetzt weder vollständige Barrierefreiheitsprüfung noch Sicherheitsprüfung oder Inhaltsqualität.
|
||||||
|
|
||||||
|
## Arbeitsumgebung und Projektgedächtnis
|
||||||
|
|
||||||
|
Empfehlung: Umsetzung am Desktop in einem lokalen Git-Projekt mit Codex-Zugriff auf den Projektordner, Vorschau im Browser und reproduzierbarer Entwicklungsumgebung. Hier lassen sich Planung, Dateien und Prototypen vorbereiten; dieser Chat hat derzeit keinen Zugriff auf Umuts Desktop-Projekt.
|
||||||
|
|
||||||
|
AGENTS.md im Projektstamm enthält die dauerhaften Arbeitsregeln. Dieser Entwicklungsplan enthält Ziele, Reihenfolge und Abnahmekriterien. Bei Implementierungsbeginn werden docs/STATUS.md für den aktuellen Stand und docs/ARCHITECTURE.md für begründete Architekturentscheidungen angelegt. Alle Dokumente werden mit dem Code versioniert. Eine neue Sitzung muss auf dasselbe Projekt zugreifen können; Chat-Erinnerung ersetzt diese Dateien nicht.
|
||||||
|
|
||||||
|
## Leitentscheidungen als Vorschlag
|
||||||
|
|
||||||
|
- Eine selbstständige Installation je Kundenwebsite für Version 1; zentrale Verwaltung später.
|
||||||
|
- Modularer Kern in einer Anwendung. Öffentliche Ausgabe und Editor haben getrennte Ressourcen.
|
||||||
|
- Serverseitig erzeugtes oder vorab gerendertes HTML; der eigentliche Seiteninhalt benötigt kein Browser-JavaScript.
|
||||||
|
- Seiten als versionierter, validierter Blockbaum mit stabilen IDs speichern. Keine Bindung der Inhalte an proprietäre Shortcodes.
|
||||||
|
- Vorschau und öffentliche Ausgabe nutzen dieselben Blockdefinitionen und möglichst denselben Renderer.
|
||||||
|
- Inhaltsstruktur, Layout und globale Designwerte sind getrennt. Ein Templatewechsel erhält die Inhalte; fehlende Blocktypen bekommen einen kontrollierten Fallback.
|
||||||
|
- Plugin- und Template-Verträge früh definieren; einen öffentlichen Marktplatz erst später planen.
|
||||||
|
- Framework, Datenbank und Hostingprofil werden in Phase 0 festgelegt. Authentifizierung, Migrationen und andere Standardfunktionen auf bewährte Bibliotheken stützen.
|
||||||
|
|
||||||
|
## Phasen und Abnahme
|
||||||
|
|
||||||
|
### 0 – Anforderungen und technische Grundlage
|
||||||
|
|
||||||
|
- Eine konkrete Pilotwebsite wählen und deren Seitentypen, Formulare und Inhalte erfassen.
|
||||||
|
- Die wichtigsten UX-Builder-Abläufe dokumentieren: Elemente hinzufügen, verschachteln, verschieben, bearbeiten, responsive anpassen und wiederverwenden.
|
||||||
|
- Hostingziel klären: eigener Server mit Containern oder herkömmliches PHP-Webhosting. Danach den Stack auswählen und Entscheidung begründen.
|
||||||
|
- Git-Projekt, lokale Startanleitung, Formatierung, Build und minimale CI einrichten.
|
||||||
|
- Datenmodell für Website, Seite, Revision, Medien, Menü, Template und Block entwerfen.
|
||||||
|
|
||||||
|
Abnahme: Ein frischer Checkout startet nach dokumentierter Anleitung. Eine gespeicherte Beispielseite wird als HTML ausgegeben. Das Dokumentformat ist versioniert.
|
||||||
|
|
||||||
|
### 1 – Durchgängiger Editor-Prototyp
|
||||||
|
|
||||||
|
- Oberfläche mit Elementbibliothek/Strukturbaum, Live-Vorschau und Eigenschaftenpanel.
|
||||||
|
- Sektionen, Zeilen, Spalten, Überschrift, Text, Bild und Button.
|
||||||
|
- Auswahl in Vorschau und Strukturbaum synchronisieren.
|
||||||
|
- Drag-and-drop, Duplizieren, Löschen und alternative Bedienung per Tastatur/Schaltflächen.
|
||||||
|
- Grundlegende Farben, Abstände, Breiten und Schriftwerte bearbeiten.
|
||||||
|
- Undo/Redo; Speichern und erneutes Laden desselben Blockbaums.
|
||||||
|
- Eine bearbeitete Seite über die öffentliche Route ausgeben.
|
||||||
|
|
||||||
|
Abnahme: Eine Beispielseite visuell erstellen, Elemente umordnen, Änderungen rückgängig machen, speichern und nach Neuladen unverändert bearbeiten. Die öffentliche HTML-Ausgabe entspricht der Vorschau.
|
||||||
|
|
||||||
|
### 2 – Editor für tägliche Arbeit
|
||||||
|
|
||||||
|
- Desktop-, Tablet- und Mobilvorschau mit klarer Vererbung und gezielten Überschreibungen.
|
||||||
|
- Inline-Textbearbeitung, Mediathek, Bildausschnitt, Alt-Texte und responsive Bilder.
|
||||||
|
- Weitere Blöcke: Karten, Galerie, Akkordeon, Tabs, Icon/Text und Kontaktformular.
|
||||||
|
- Globale Farben/Schriften, Header/Footer, wiederverwendbare Sektionen.
|
||||||
|
- Kopierte Sektionen und global verknüpfte Sektionen unterscheiden.
|
||||||
|
- Autosave mit sichtbarem Speicherstatus, Fehlerbehandlung und Schutz vor veralteten Schreibständen.
|
||||||
|
- Lokales Undo/Redo von dauerhaft gespeicherten Revisionen trennen.
|
||||||
|
|
||||||
|
Abnahme: Die Pilotwebsite lässt sich ohne Code im Editor pflegen. Mobile Änderungen verhalten sich vorhersehbar. Gleichzeitige Bearbeitung überschreibt keine neueren Inhalte unbemerkt.
|
||||||
|
|
||||||
|
### 3 – CMS-Kern und Veröffentlichung
|
||||||
|
|
||||||
|
- Anmeldung, Rollen, serverseitige Berechtigungen, sichere Sitzungen und Uploadvalidierung.
|
||||||
|
- Seiten, Blogbeiträge, Menüs, Medien und Unternehmens-/Kontaktdaten verwalten.
|
||||||
|
- Entwürfe, Vorschau, Revisionen, Publizieren und Zurücksetzen.
|
||||||
|
- Entwurf und veröffentlichte Revision getrennt halten; Cache bei Veröffentlichung gezielt erneuern.
|
||||||
|
- Formulare mit serverseitiger Validierung, Spam-Schutz, Versandstatus und Fehlerbehandlung.
|
||||||
|
- Datenmodell für Sprachen von Beginn an berücksichtigen; Übersetzungsoberfläche ergänzen.
|
||||||
|
|
||||||
|
Abnahme: Unveröffentlichte Inhalte bleiben privat; Rollen werden serverseitig durchgesetzt. Wiederherstellung einer Revision und Formularversand funktionieren.
|
||||||
|
|
||||||
|
### 4 – Templates und Plugins
|
||||||
|
|
||||||
|
- Templatepaket mit Manifest, Versionsanforderungen, Designwerten, Blockvarianten und Seitenvorlagen.
|
||||||
|
- Zwei eigene Templates auf denselben Inhalten erproben.
|
||||||
|
- Plugins registrieren Blöcke, Einstellungen, Hooks und bei Bedarf Datenmigrationen über dokumentierte APIs.
|
||||||
|
- Abhängigkeiten und inkompatible Versionen erkennen; Ressourcen nur dort laden, wo sie gebraucht werden.
|
||||||
|
- Fehlende oder deaktivierte Erweiterungen dürfen Inhalte nicht löschen.
|
||||||
|
- Ein Beispielplugin als Referenz implementieren.
|
||||||
|
- Serverseitige Plugins in Version 1 als vertrauenswürdigen Installationscode behandeln; keine Sandbox versprechen, solange keine echte Isolation implementiert ist.
|
||||||
|
|
||||||
|
Abnahme: Templatewechsel erhält die Inhalte. Beispielplugin lässt sich aktivieren, aktualisieren und deaktivieren; fehlende Pluginblöcke werden kontrolliert dargestellt.
|
||||||
|
|
||||||
|
### 5 – Geschwindigkeit, SEO und KI-Zugänglichkeit
|
||||||
|
|
||||||
|
- Seitentitel, Beschreibungen, Canonicals, Indexierungssteuerung, Sitemap, robots.txt, Weiterleitungen und passende HTTP-Statuscodes.
|
||||||
|
- Strukturierte Daten aus konsistenten sichtbaren Inhalten erzeugen, z. B. Unternehmens-, Artikel- und Breadcrumb-Daten.
|
||||||
|
- Unternehmensdaten, Leistungen, Standorte, Autoren, Quellen und Aktualisierungen sinnvoll modellieren.
|
||||||
|
- Cachekonzept, Bildgrößen, moderne Bildformate, lokale Schriften und kleine Ressourcenpakete prüfen.
|
||||||
|
- Messziele auf repräsentativen Seiten: LCP <= 2,5 s, CLS <= 0,1, INP <= 200 ms bei realer Nutzung als Zielwerte; Laborwerte getrennt dokumentieren. Hosting und Inhalte beeinflussen die Ergebnisse.
|
||||||
|
- Redaktionelle KI optional und anbieteraustauschbar: Vorschläge für Texte, Übersetzungen und Metadaten mit Vorschau vor Übernahme.
|
||||||
|
- KI-Dateien wie llms.txt allenfalls optional; keine garantierte Aufnahme, Zitierung oder Rankingverbesserung versprechen.
|
||||||
|
|
||||||
|
Abnahme: Wichtige Inhalte sind im initialen HTML vorhanden. SEO-Felder, Weiterleitungen, Cacheinvalidierung und strukturierte Daten sind überprüft. Messbedingungen und verbleibende Engpässe sind dokumentiert.
|
||||||
|
|
||||||
|
### 6 – WordPress-Migration und Pilotbetrieb
|
||||||
|
|
||||||
|
- Export/Import für Seiten, Beiträge, Medien, Slugs und relevante SEO-Daten erstellen.
|
||||||
|
- Flatsome-Layouts separat behandeln: tatsächlich vorhandene Shortcodes inventarisieren und unterstützte Typen auf eigene Blöcke abbilden. Unbekannte Inhalte im Importbericht ausweisen und erhalten.
|
||||||
|
- URLs, Medienreferenzen, interne Links und Weiterleitungen vor/nach Umzug vergleichen.
|
||||||
|
- Staging aufsetzen; Backups einschließlich Wiederherstellung erproben.
|
||||||
|
- Updateverfahren mit passenden Datenbankmigrationen und Rückfallstrategie dokumentieren. Ein Code-Rollback allein genügt nach inkompatiblen Datenbankänderungen nicht.
|
||||||
|
- Pilot veröffentlichen, sobald Inhalt, Funktion und Betrieb überprüft sind und der konkrete Livegang beauftragt ist.
|
||||||
|
|
||||||
|
Abnahme: Eine echte Kundenwebsite ist vollständig geprüft migriert. Ein Rückweg zur bisherigen Website ist vorbereitet; Backup und Wiederherstellung sind nachgewiesen.
|
||||||
|
|
||||||
|
### 7 – Ausbau nach dem Pilot
|
||||||
|
|
||||||
|
- Zentrale Verwaltung mehrerer Installationen, überwachte Updates, Backups und Gesundheitsstatus.
|
||||||
|
- Vorlagen für Branchen, erweiterte dynamische Inhalte und Integrationen.
|
||||||
|
- Shop, Buchungen, öffentliche Pluginverteilung und Mehrmandantenbetrieb anhand tatsächlichen Bedarfs separat planen.
|
||||||
|
|
||||||
|
## Arbeitsablauf pro Entwicklungsschritt
|
||||||
|
|
||||||
|
1. AGENTS.md, diesen Plan und den aktuellen STATUS lesen; Repositoryzustand prüfen.
|
||||||
|
2. Den nächsten kleinen, prüfbaren Abschnitt wählen und Abnahme benennen.
|
||||||
|
3. Umsetzung einschließlich benötigter Fehler- und Leerzustände fertigstellen.
|
||||||
|
4. Passende Prüfungen ausführen: Dokument-Roundtrip und Undo/Redo für den Editor; Berechtigungen für Serverfunktionen; Migrationstests bei Schemaänderungen; visuelle Prüfung für Layoutarbeit.
|
||||||
|
5. Betroffene Architekturentscheidung, Status, bekannte Fehler und nächsten konkreten Schritt aktualisieren.
|
||||||
|
6. Ergebnis und tatsächlich ausgeführte Prüfungen knapp berichten. Keine ungetesteten Funktionen als fertig markieren.
|
||||||
|
|
||||||
|
## Nächster konkreter Schritt
|
||||||
|
|
||||||
|
Phase 0 beginnen: Pilotwebsite und Hostingprofil erfassen, UX-Builder-Referenz ansehen, Stackentscheidung dokumentieren und Projektgerüst erstellen. Anschließend Phase 1 als erstes vorzeigbares Ergebnis umsetzen.
|
||||||
|
|
||||||
|
## Quellen zur Einordnung
|
||||||
|
|
||||||
|
- Projektanweisungen mit AGENTS.md: https://developers.openai.com/codex/guides/agents-md
|
||||||
|
- Google zu JavaScript und HTML-Ausgabe: https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics
|
||||||
|
- Google zu KI-Suche und SEO: https://developers.google.com/search/docs/appearance/ai-features
|
||||||
|
- Lighthouse-Barrierefreiheit und Grenzen automatischer Scores: https://developer.chrome.com/docs/lighthouse/accessibility/scoring
|
||||||
|
- Agentisches Browsing: https://developer.chrome.com/docs/lighthouse/agentic-browsing/scoring
|
||||||
|
- Labor- und Felddaten: https://web.dev/articles/vitals
|
||||||
|
|
||||||
|
Die Phasen sind ein Arbeitsvorschlag. Die Implementierung ist noch nicht begonnen; Termine und Aufwand werden nach dem ersten durchgängigen Prototyp eingeschätzt.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Kant CMS
|
||||||
|
|
||||||
|
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
|
||||||
|
|
||||||
|
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
## Lokal starten
|
||||||
|
|
||||||
|
Voraussetzungen: PHP ab 8.2 mit Composer 2, PDO SQLite, Ctype, Iconv, Session, XML und GD mit WebP-Unterstützung. EXIF wird für die automatische Ausrichtung von JPEG-Fotos empfohlen. Für Tests zusätzlich die von `composer install` geprüften Erweiterungen, insbesondere DOM, XMLWriter und Mbstring. Für Hosting eine aktuell gepflegte PHP-Patchversion verwenden. Node 24 wird für Browserprüfungen und das Generieren der Design-CSS gebraucht, nicht im Hostingbetrieb.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
composer install
|
||||||
|
php bin/setup.php
|
||||||
|
php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||||
|
php bin/console cms:seed-demo
|
||||||
|
php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `http://127.0.0.1:8080/` öffnen. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
|
||||||
|
|
||||||
|
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||||
|
|
||||||
|
## Anmelden und bearbeiten
|
||||||
|
|
||||||
|
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
php bin/console cms:user:create meinname
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutzer werden nicht überschrieben. Optional `--role=editor` für Entwürfe oder `--role=publisher` für Entwürfe plus Veröffentlichung; Standard ist `admin`. Es gibt noch keine Benutzerverwaltung oder Passwortänderung im Browser. In der aktuellen lokalen Installation wurde auf Nutzerwunsch bereits der Administrator `umut` angelegt und angemeldet; keine Zugangsdaten sind im Repository abgelegt.
|
||||||
|
|
||||||
|
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
|
||||||
|
|
||||||
|
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||||
|
|
||||||
|
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||||
|
|
||||||
|
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||||
|
|
||||||
|
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||||
|
|
||||||
|
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||||
|
|
||||||
|
## Produktionsmodus lokal prüfen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
composer build
|
||||||
|
$env:APP_ENV = 'prod'
|
||||||
|
$env:APP_DEBUG = '0'
|
||||||
|
php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public
|
||||||
|
```
|
||||||
|
|
||||||
|
Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=0` verwenden. Vorher eine laufende Instanz auf diesem Port beenden. Zum Entwickeln `APP_ENV=dev` und `APP_DEBUG=1` setzen. Debug-Seiten senden absichtlich `X-Robots-Tag: noindex`; Audits laufen im Produktionsmodus. Der eingebaute PHP-Server bleibt ein lokaler Entwicklungsserver.
|
||||||
|
|
||||||
|
## Medien, Galerien und Effekte
|
||||||
|
|
||||||
|
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||||
|
|
||||||
|
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
|
||||||
|
|
||||||
|
Unter **Design → Effekte** Schatten, Hover-Anheben/Bildzoom und Einblendung beim Laden wählen. Effekte gelten für alle Geräte; bei aktivierter Bewegungsreduktion werden Animationen abgeschaltet. Die öffentliche Seite benötigt dafür kein JavaScript. Lightbox, Slideshow, Video/PDF-Upload und Bildzuschnitt sind noch nicht enthalten.
|
||||||
|
|
||||||
|
Medien liegen standardmäßig in `var/media`; mit `MEDIA_DIRECTORY` lässt sich ein absoluter Ordner außerhalb von `public` konfigurieren. Er muss für PHP beschreibbar sein. Auf dem Hosting `upload_max_filesize=8M`, `post_max_size=10M` und ausreichend Arbeitsspeicher (lokal 256M) einstellen, soweit der Tarif dies erlaubt. Datenbank und Medienordner gemeinsam sichern. Archivieren erhält Bild-URLs; es ist keine Dateilöschung.
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
composer validate --strict
|
||||||
|
composer lint
|
||||||
|
composer test
|
||||||
|
php bin/console lint:twig templates
|
||||||
|
php bin/console lint:yaml config
|
||||||
|
php bin/console lint:container
|
||||||
|
composer audit
|
||||||
|
composer build
|
||||||
|
npm.cmd ci
|
||||||
|
npx.cmd playwright install chromium
|
||||||
|
npm.cmd run test:browser
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run test:editor
|
||||||
|
npm.cmd run test:editor -- --audit
|
||||||
|
npm.cmd run audit
|
||||||
|
```
|
||||||
|
|
||||||
|
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
||||||
|
|
||||||
|
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||||
|
|
||||||
|
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||||
|
|
||||||
|
## Inhalte und Entwicklung
|
||||||
|
|
||||||
|
Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValidator.php`, Gestaltungstokens in `public/assets/design-tokens.json`, Entwicklungsinhalte in `src/Content/DemoContent.php`. Designwerte erfordern mindestens v2, Medien/Galerien/Effekte v3. Die erste entsprechende Aktion migriert den lokalen Entwurf erhaltend; erst Speichern schreibt eine neue Revision. Bestehende v1/v2-Seiten bleiben lesbar. Migrations- und Rückweg: ADR 008/009 in `docs/ARCHITECTURE.md`. `PageRepository` stellt `saveDraft`, `publish` und `findPublished` bereit. Speichern und Publizieren erfordern die erwartete Seitenversion; veraltete Zugriffe brechen mit `StaleWrite` ab.
|
||||||
|
|
||||||
|
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
|
||||||
|
|
||||||
|
## PHP-Webhosting
|
||||||
|
|
||||||
|
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||||
|
|
||||||
|
- Gepflegte PHP-Version ab 8.2, PDO MySQL und die Composer-Plattformanforderungen.
|
||||||
|
- MySQL 8 oder kompatible MariaDB; tatsächliche Version und Berechtigungen noch überprüfen.
|
||||||
|
- Domain-Dokumentenstamm muss auf `public/` zeigen. `vendor/`, `var/`, Konfiguration und Secrets gehören außerhalb des öffentlichen Verzeichnisses.
|
||||||
|
- Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration.
|
||||||
|
- Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen.
|
||||||
|
|
||||||
|
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||||
|
|
||||||
|
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||||
|
|
||||||
|
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Neue Seiten, Passwortänderung und der Abgleich mit einer konkreten Pilotwebsite bleiben nächste Schritte.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/config/bootstrap.php';
|
||||||
|
$input = new ArgvInput();
|
||||||
|
$env = $input->getParameterOption(['--env', '-e'], $_SERVER['APP_ENV']);
|
||||||
|
$debug = !$input->hasParameterOption('--no-debug') && (bool) $_SERVER['APP_DEBUG'];
|
||||||
|
$app = new Application(new Kernel($env, $debug));
|
||||||
|
$app->run($input);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Only creates local state; never replaces an existing environment file.
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
if (!is_dir($root.'/var')) {
|
||||||
|
mkdir($root.'/var', 0775, true);
|
||||||
|
}
|
||||||
|
if (!file_exists($root.'/.env.local')) {
|
||||||
|
file_put_contents($root.'/.env.local', 'APP_SECRET='.bin2hex(random_bytes(32)).PHP_EOL);
|
||||||
|
echo "Created .env.local with a local secret.\n";
|
||||||
|
} else {
|
||||||
|
echo "Existing .env.local preserved.\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use Doctrine\Migrations\Configuration\Connection\ExistingConnection;
|
||||||
|
use Doctrine\Migrations\Configuration\Migration\PhpFile;
|
||||||
|
use Doctrine\Migrations\DependencyFactory;
|
||||||
|
|
||||||
|
require __DIR__.'/config/bootstrap.php';
|
||||||
|
if (!is_dir(__DIR__.'/var')) {
|
||||||
|
mkdir(__DIR__.'/var', 0775, true);
|
||||||
|
}
|
||||||
|
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', __DIR__), $_SERVER['DATABASE_URL']);
|
||||||
|
|
||||||
|
return DependencyFactory::fromConnection(
|
||||||
|
new PhpFile(__DIR__.'/migrations.php'),
|
||||||
|
new ExistingConnection(ConnectionFactory::create($url)),
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "kant/cms",
|
||||||
|
"description": "Self-hosted block CMS for PHP web hosting",
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"doctrine/dbal": "^4.3",
|
||||||
|
"doctrine/migrations": "^3.9",
|
||||||
|
"symfony/console": "7.4.*",
|
||||||
|
"symfony/dotenv": "7.4.*",
|
||||||
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
"symfony/rate-limiter": "7.4.*",
|
||||||
|
"symfony/security-bundle": "7.4.*",
|
||||||
|
"symfony/security-csrf": "7.4.*",
|
||||||
|
"symfony/twig-bundle": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.0",
|
||||||
|
"phpunit/phpunit": "^11.5",
|
||||||
|
"symfony/browser-kit": "7.4.*",
|
||||||
|
"symfony/css-selector": "7.4.*"
|
||||||
|
},
|
||||||
|
"autoload": {"psr-4": {"App\\": "src/", "DoctrineMigrations\\": "migrations/"}},
|
||||||
|
"autoload-dev": {"psr-4": {"App\\Tests\\": "tests/"}},
|
||||||
|
"config": {"sort-packages": true, "platform": {"php": "8.2.12"}},
|
||||||
|
"scripts": {
|
||||||
|
"test": "phpunit",
|
||||||
|
"lint": "php-cs-fixer fix --dry-run --diff",
|
||||||
|
"format": "php-cs-fixer fix",
|
||||||
|
"build": "@php bin/console cache:warmup --env=prod --no-debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7766
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
// PHP web hosts may omit E from variables_order. Honor real process configuration.
|
||||||
|
foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY'] as $name) {
|
||||||
|
$value = getenv($name, true);
|
||||||
|
if (false !== $value && !isset($_SERVER[$name]) && !isset($_ENV[$name])) {
|
||||||
|
$_SERVER[$name] = $_ENV[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
csrf_protection: true
|
||||||
|
session:
|
||||||
|
handler_id: null
|
||||||
|
storage_factory_id: session.storage.factory.native
|
||||||
|
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
||||||
|
cookie_httponly: true
|
||||||
|
cookie_samesite: lax
|
||||||
|
cookie_secure: auto
|
||||||
|
cookie_path: /admin
|
||||||
|
name: KANTSESSION
|
||||||
|
router:
|
||||||
|
utf8: true
|
||||||
|
handle_all_throwables: true
|
||||||
|
http_method_override: false
|
||||||
|
disallow_search_engine_index: '%kernel.debug%'
|
||||||
|
php_errors:
|
||||||
|
log: true
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Domain\CmsUser: auto
|
||||||
|
providers:
|
||||||
|
cms_users:
|
||||||
|
id: App\Security\UserProvider
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_ADMIN: [ROLE_EDITOR, ROLE_PUBLISHER]
|
||||||
|
ROLE_PUBLISHER: ROLE_EDITOR
|
||||||
|
firewalls:
|
||||||
|
admin:
|
||||||
|
pattern: ^/admin(?:/|$)
|
||||||
|
lazy: true
|
||||||
|
provider: cms_users
|
||||||
|
form_login:
|
||||||
|
login_path: admin_login
|
||||||
|
check_path: admin_login
|
||||||
|
enable_csrf: true
|
||||||
|
default_target_path: admin_pages
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
interval: '1 minute'
|
||||||
|
logout:
|
||||||
|
path: admin_logout
|
||||||
|
target: admin_login
|
||||||
|
enable_csrf: true
|
||||||
|
access_control:
|
||||||
|
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/admin, roles: ROLE_EDITOR }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
framework:
|
||||||
|
test: true
|
||||||
|
session:
|
||||||
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Domain\CmsUser:
|
||||||
|
algorithm: bcrypt
|
||||||
|
cost: 4
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
twig:
|
||||||
|
default_path: '%kernel.project_dir%/templates'
|
||||||
|
strict_variables: true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
controllers:
|
||||||
|
resource: ../src/Controller/
|
||||||
|
type: attribute
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
_defaults:
|
||||||
|
autowire: true
|
||||||
|
autoconfigure: true
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
exclude: '../src/{Kernel.php,Domain/}'
|
||||||
|
Doctrine\DBAL\Connection:
|
||||||
|
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||||
|
arguments: ['%env(resolve:DATABASE_URL)%']
|
||||||
|
App\Controller\PageController:
|
||||||
|
arguments:
|
||||||
|
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||||
|
App\Content\MediaLibrary:
|
||||||
|
arguments:
|
||||||
|
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Architekturentscheidungen
|
||||||
|
|
||||||
|
Stand: 12.09.2026. Arbeitstitel Kant CMS; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 001 – PHP-Webhosting, Symfony 7.4
|
||||||
|
|
||||||
|
Umut hat im laufenden Auftrag ausdrücklich herkömmliches PHP-Webhosting gewählt. Deshalb PHP statt des zunächst aus dem Ordnernamen vermuteten Django-Stacks. Symfony 7.4 LTS benötigt PHP ab 8.2 und bringt Routing, Dependency Injection, Konfiguration und später anschließbare Sicherheitskomponenten mit. Twig maskiert Inhalte standardmäßig. Doctrine DBAL und Migrations übernehmen Datenbankzugriff und Migrationen. Keine eigene Authentifizierung erfinden: vor HTTP-Schreibzugriffen Symfony Security, CSRF-Schutz und serverseitige Rollen ergänzen.
|
||||||
|
|
||||||
|
Eine Kundeninstallation ist eine Anwendung mit eigenem Datenbestand. Es gibt keine Abhängigkeit von einem dauerhaften Node-, Python- oder Queue-Prozess. Öffentliche Dateien liegen in `public/`, der Projektstamm muss außerhalb des Webroots bleiben. Konkreter Hostinganbieter, PHP-Version, Datenbank und CLI-/Deploymentmöglichkeiten sind noch offen.
|
||||||
|
|
||||||
|
Alternativen: Django passt nicht zum gewählten PHP-Profil. Laravel ist möglich, Symfony wurde wegen der LTS-Linie und modularen Grundlage gewählt. Ein selbst geschriebenes Mini-Framework würde Standardfunktionen unnötig neu implementieren.
|
||||||
|
|
||||||
|
Quellen bei Einrichtung geprüft: [Symfony 7.4 Setup](https://symfony.com/doc/7.4/setup.html), [Symfony-Versionen](https://symfony.com/releases). Exakte installierte Versionen stehen im Composer-Lockfile; PHP-Untergrenze wird im Composer-Resolver auf 8.2.12 simuliert. Auf dem Server dennoch aktuellen Sicherheits-Patchstand verwenden.
|
||||||
|
|
||||||
|
## ADR 002 – Versionierte Inhalte und Veröffentlichung
|
||||||
|
|
||||||
|
Dokumentschema v1: `schemaVersion`, `title`, `description`, `blocks`. Jeder Block hat eine stabile `id`, einen `type`, inhaltsbezogene `props`, optionale tokenbasierte `layout`-Werte und bei Containern `children`. Globale Designwerte gehören zum Website-/Template-Vertrag, nicht zu Texten. Die erste feste CSS-Vorlage nutzt bereits getrennte Designvariablen; konfigurierbare Themes folgen später.
|
||||||
|
|
||||||
|
Unterstützte Blöcke: section, row, column, heading, text, image, button. Alle bilden einen Baum; Layoutwerte sind Allowlist-Tokens, kein beliebiges CSS. Texte sind Klartext. Links erlauben lokale absolute Pfade, Fragmente oder HTTPS. Bilder benötigen Abmessungen und eine explizite Aussage, ob sie dekorativ sind. Begrenzungen: 256 KiB Dokument, 300 Blöcke, Tiefe 10, eindeutige IDs, genau eine Hauptüberschrift. Die aktuellen Textgrenzen zählen UTF-8-Bytes. Medienoptimierung/Uploads sind noch nicht enthalten.
|
||||||
|
|
||||||
|
Unbekannte Schemaversionen und Blocktypen werden beim Schreiben abgewiesen; die ursprüngliche Revision bleibt erhalten. Der Renderer enthält einen Platzhalter, aber der derzeit strikte Lesevalidator lässt unbekannte Blöcke noch nicht bis dorthin gelangen. Ein erhaltender Plugin-Fallback samt Registry ist ausdrücklich Phase 4, nicht bereits funktionsfähig.
|
||||||
|
|
||||||
|
Revisionen sind über den Repository-Vertrag unveränderlich. Die Datenbank schützt die Referenzen Website→Seite und Seite→Revision mit Fremdschlüsseln. Revisionszeiger auf einer Seite werden wegen des Einfügezyklus im Repository geprüft; zusätzlich fordert der öffentliche JOIN dieselbe Seiten-ID. Externe direkte Datenbankänderungen sind kein unterstützter Schreibweg.
|
||||||
|
|
||||||
|
Jedes Speichern/Publizieren erhöht `page.version`. Ein bedingtes UPDATE mit erwarteter Version erkennt veraltete Schreibversuche; Revisionseinfügung und Zeigeränderung laufen in einer Transaktion. Dadurch bleibt bei einem Konflikt auch keine verwaiste Revision zurück. Publizieren einer historischen Revision stellt deren Inhalt wieder her, der aktuelle Entwurf bleibt unabhängig erhalten.
|
||||||
|
|
||||||
|
## ADR 003 – Datenmodell
|
||||||
|
|
||||||
|
| Entität | Bereits implementiert / vorgesehener Vertrag |
|
||||||
|
| --- | --- |
|
||||||
|
| Website | ID, Name, Standardsprache, JSON-Theme-Konfiguration; aktuell eine Installation mit ID `default`. |
|
||||||
|
| Seite | ID, Website-ID, Slug, Sprache, Entwurfs-/Veröffentlichungszeiger, Schreibversion; Slug je Website und Sprache eindeutig. |
|
||||||
|
| Revision | ID, Seiten-ID, vollständiges versioniertes Dokument, UTC-Erstellungszeit; nächste Ausbaustufe: Benutzer/Änderungsnotiz. |
|
||||||
|
| Block | Innerhalb des Dokuments mit stabiler ID; keine Einzelblocktabelle. |
|
||||||
|
| CMS-Benutzer | ID, eindeutiger normalisierter Benutzername, Passwort-Hash, Rollen; hinzugefügt durch Migration 20260912000200. |
|
||||||
|
| Medium (Entwurf) | ID, Originaldatei, MIME-Typ, Maße, Varianten, Prüfsumme; Alt-Text kontextbezogen pro Verwendung. Noch keine Tabelle. |
|
||||||
|
| Menü (Entwurf) | ID, Website, Sprache, stabile Menüpunkt-IDs und Seitenreferenzen statt hart codierter Ziel-URLs. Aktuell feste Navigation. |
|
||||||
|
| Template (Entwurf) | Manifest-ID, Version, unterstützte Block-/Schemaschnittstellen, Design-Tokens; derzeit feste Twig-/CSS-Vorlage. |
|
||||||
|
|
||||||
|
Sprachkennung und entsprechende Eindeutigkeit sind gespeichert; öffentliche Sprachwahl und Übersetzungsoberfläche fehlen. Seitentitel/Beschreibung sind Teil der Revision, damit Entwurfsmetadaten nicht öffentlich durchsickern. Sitemap, strukturierte Daten, Weiterleitungen und umfassende SEO-Einstellungen folgen später.
|
||||||
|
|
||||||
|
## ADR 004 – Ausgabe, Cache und Ressourcen
|
||||||
|
|
||||||
|
Twig rendert vollständiges semantisches HTML auf dem Server. Der öffentliche Pfad lädt nur lokales CSS und ein SVG-Favicon, kein JavaScript. Der Editor unter `/admin` hat separate CSS-/JS-Ressourcen. Sein geschützter Preview-Endpunkt rendert `page.html.twig` mit demselben validierten Blockbaum wie die öffentliche Seite.
|
||||||
|
|
||||||
|
Aktuell kein Inhaltscache: pro Anfrage wird die veröffentlichte Revision gelesen und `Cache-Control: no-store` gesetzt. Damit gibt es keine veralteten Seiten nach Veröffentlichung. Twig-/Containercache enthält nur Programmstruktur. Ein späterer Inhaltscache muss Revisions-IDs im Schlüssel und gezielte Invalidierung für Navigation/Sitemap berücksichtigen; die entsprechenden Tests sind dann auszubauen.
|
||||||
|
|
||||||
|
Canonical entsteht aus konfiguriertem `SITE_ORIGIN`, nicht dem ungeprüften Request-Host. `/home` leitet auf `/` um. Unbekannte oder unveröffentlichte Seiten liefern 404. Die öffentlichen Routen akzeptieren keine Schreibmethoden. Die geschützten Admin-Endpunkte sind in ADR 006 beschrieben.
|
||||||
|
|
||||||
|
## ADR 005 – Migrationen und Reproduzierbarkeit
|
||||||
|
|
||||||
|
`Version20260912000100` ist das erste Schema. Doctrine erzeugt plattformspezifisches SQL. SQLite lokal getestet; MySQL-Smokejob in CI vorbereitet, noch nicht tatsächlich ausgeführt. MariaDB ist noch nicht getestet. Keine Behauptung identischer Transaktions- oder Hostingbedingungen ohne Prüfung.
|
||||||
|
|
||||||
|
Alle künftigen Datenbankänderungen erhalten eine neue Migration; diese erste Migration später nicht nachträglich umschreiben. Dokumentänderungen brauchen explizite `vN → vN+1`-Konverter und Roundtrip-/Erhaltungstests. Die erste Migration verweigert ein zerstörerisches `down`: Rückkehr durch geprüftes Datenbankbackup und dazu passenden Code. MySQL-DDL ist nicht vollständig transaktional; vor Migrationen sichern und Wiederherstellung separat testen.
|
||||||
|
|
||||||
|
Lockfiles sind versionierbar, lokale DB/Secrets/Abhängigkeiten und Messartefakte ausgeschlossen. Kein Git-Remote, kein externer Dienst und keine Veröffentlichung eingerichtet.
|
||||||
|
|
||||||
|
## ADR 006 – Anmeldung und geschützter Editor (12.09.2026)
|
||||||
|
|
||||||
|
Symfony Security übernimmt Formularanmeldung, Passwortprüfung/-Upgrade, Session-Fixation-Schutz, Logout und Login-Throttling (fünf Versuche pro Minute gemäß Framework-Limiter). Der DBAL-UserProvider lädt lokale Benutzer. Beim automatischen Passwort-Rehash werden Datenbank und In-Memory-Benutzer synchron aktualisiert, damit die nächste Sessionprüfung nicht das gerade angemeldete Konto abmeldet. `EquatableInterface` vergleicht ID, Name, Passwort-Hash und Rollen; Änderungen der Berechtigungen oder Zugangsdaten invalidieren eine bestehende Sitzung.
|
||||||
|
|
||||||
|
Firewall nur für `/admin`: öffentliche Seiten starten keine Session und erhalten keinen Editorcode. Cookies gelten für `/admin`, mit HttpOnly, SameSite=Lax und Secure abhängig von HTTPS. Sessions liegen außerhalb von `public` unter `var/sessions/`. Alle Adminantworten, auch Fehler und Redirects, erhalten `no-store` und `noindex`. Ein initiales Konto entsteht über ein lokales CLI-Kommando mit verdeckter Passwortabfrage. Keine offene Registrierung, keine Default-Zugangsdaten, keine Browser-Passwortänderung oder vollständige Benutzerverwaltung.
|
||||||
|
|
||||||
|
Rollen: `ROLE_EDITOR` darf Entwürfe lesen, vorschauen und speichern. `ROLE_PUBLISHER` darf zusätzlich veröffentlichen. `ROLE_ADMIN` erbt beide Rollen. Aktuell alle Seiten einer einzelnen Installation, noch keine Rechte je Seite oder Website. HTTP-POSTs erfordern einen Symfony-CSRF-Token. Veröffentlichung prüft Rolle, erwartete Version und die konkrete gespeicherte Entwurfsrevision. Veraltete Stände liefern HTTP 409. Rückmeldungen enthalten die durch diese Anfrage geschriebene Revision, statt möglicherweise einen unmittelbar danach fremd gespeicherten Stand als eigenen Erfolg zurückzugeben.
|
||||||
|
|
||||||
|
Die neue Migration ergänzt `cms_user`, ohne Inhaltstabellen umzuschreiben. Der erste Editor nutzte ausschließlich Dokumentformat v1. Den nachfolgenden Ausbau zu v2 beschreibt ADR 008.
|
||||||
|
|
||||||
|
## ADR 007 – Editorzustand und Vorschau
|
||||||
|
|
||||||
|
Kleiner Editor als lokale JavaScript-Module ohne Framework. Dokumentzustand, Tree-Operationen und Undo/Redo stehen getrennt in `editor-model.js`, DOM/Netzwerk in `editor.js`. Bis zu 100 vollständige lokale Undo-Stände; aufeinanderfolgende Eingaben in dasselbe Feld werden zu einem Schritt zusammengefasst. Das gespeicherte Dokument wird inhaltlich verglichen; Undo über einen Speicherpunkt zeigt dadurch wieder „ungespeichert“. Neuladen beendet den lokalen Verlauf. Serverseitige Revisionen sind davon unabhängig.
|
||||||
|
|
||||||
|
Die HTML-Vorschau läuft in einem Iframe mit `sandbox="allow-same-origin"` ohne `allow-scripts`. Der Elterneditor darf die Auswahl markieren und Klicks zuordnen; Inhalte können keine eigenen Skripte, Navigationen oder Formulare ausführen. Preview-Antworten werden bei neueren Änderungen sofort als veraltet markiert, laufende Requests abgebrochen und Eingaben kurz zusammengefasst. Bei Validierungsfehlern bleibt die letzte gültige Vorschau stehen. Gespeichert wird nur nach serverseitiger Validierung. Während Speichern/Veröffentlichen sind Bearbeitungsaktionen gesperrt.
|
||||||
|
|
||||||
|
Bei HTTP-/Netzwerkfehlern oder 409 bleibt der lokale Dokumentzustand erhalten. JSON-Export und expliziter Server-Neuladedialog ermöglichen die Konfliktauflösung ohne stilles Überschreiben. Der Export liegt als nutzerinitiierter Download außerhalb des Repositories. Kein Autosave und kein automatischer Merge.
|
||||||
|
|
||||||
|
Die Browserprüfungen nutzen eine eigene Datenbank und zufällige Testzugänge. axe benötigt Skriptausführung im geprüften Frame, die Sandbox erlaubt dies absichtlich nicht: daher Adminoberfläche ohne Frameinhalt prüfen und den identischen veröffentlichten Renderer separat prüfen. Die Sandbox wird nicht für Tests abgeschwächt. Klickauswahl/Rendering im echten Frame werden durch Playwright funktional getestet.
|
||||||
|
|
||||||
|
## ADR 008 – Responsive Gestaltung und Dokumentformat v2
|
||||||
|
|
||||||
|
v2 ergänzt optional `block.layout.design` mit den Bereichen `desktop`, `tablet` und `mobile`. Die bestehenden Inhalte, IDs, Baumstruktur sowie `tone` und `width` bleiben unverändert. Erlaubte Eigenschaften, Blocktypen und Werte stehen zentral in `public/assets/design-tokens.json`: Ausrichtung, Schriftgröße/-stärke, Zeilenhöhe, Innen-/Außenabstände, Spaltenzahl/-abstand und Ecken. Der PHP-Validator und die Editorfelder lesen dasselbe Manifest. `npm run build:design` erzeugt daraus die eingecheckte `design.css`; ein Test erkennt abweichende generierte Dateien. Beliebige CSS-Werte und unbekannte Geräteschlüssel werden vor dem Speichern abgewiesen.
|
||||||
|
|
||||||
|
Vererbung: Desktop ist die Basis auf allen Breiten. Tablet überschreibt bis einschließlich 1024 px, Mobil bis einschließlich 650 px. Fehlende Eigenschaften übernehmen den Wert des größeren Geräts oder die ursprünglichen Theme-Regeln. „Übernehmen“ entfernt genau eine Überschreibung; „Werte zurücksetzen“ entfernt die Werte des gewählten Geräts am gewählten Block. Größere Geräte und andere Blöcke bleiben erhalten. Alle Aktionen nehmen an Undo/Redo teil. Farben und Sektionsbreite bleiben globale Blockeinstellungen im Inhaltbereich.
|
||||||
|
|
||||||
|
Migration v1 → v2: `upgradeDocument` in `editor-model.js` ist ein idempotenter Konverter innerhalb einer geklonten History-Transaktion. Er ändert ausschließlich die Versionsnummer; `setDesign` ergänzt anschließend den gewählten Wert. Die erste Designänderung oder eine Vorlage mit responsiven Werten löst die Umstellung aus. Öffnen, reine Texteingaben und unverändertes Speichern migrieren v1 nicht. Erst ein explizites erfolgreiches Speichern schreibt eine neue v2-Revision. Keine Datenbankmigration oder automatische Umschreibung bestehender Revisionen. Der neue Server liest, rendert und schreibt beide Versionen. Unbekannte Zukunftsversionen werden abgewiesen.
|
||||||
|
|
||||||
|
Rückweg: Undo der ersten Designänderung stellt auch das ursprüngliche v1-Dokument wieder her. Historische v1-Revisionen bleiben unverändert verfügbar. Ein Code-Downgrade auf einen reinen v1-Server darf erst nach Wiederherstellung passender v1-Revisionszeiger oder eines geprüften Backups erfolgen; v2-Dokumente niemals durch bloßes Entfernen der Versionsnummer als v1 ausgeben. Nach Reset aller Werte bleibt ein gespeichertes v2-Dokument v2.
|
||||||
|
|
||||||
|
Twig bildet validierte Tokens auf statische Klassen ab. Nur v2-Seiten laden die zusätzliche CSS-Datei; die öffentliche Ausgabe enthält weiter kein JavaScript oder Inline-CSS. Vorschau und Veröffentlichung haben denselben Renderer und dieselben Media Queries. Die Vorschau besitzt echte Layoutbreiten von 1240/768/390 px und wird bei kleiner Editorfläche visuell skaliert. Die Skalierung betrifft nur die Editoroberfläche und verändert die CSS-Breakpoints im Iframe nicht.
|
||||||
|
|
||||||
|
Drei lokale Abschnittsvorlagen erzeugen gewöhnliche, vollständig bearbeitbare Blöcke mit neuen IDs und H2/H3 statt einer zweiten H1. Das ist keine Plugin- oder globale Template-API. Werkzeugkasten und Struktur lassen sich einklappen; Suche zeigt passende Elemente einschließlich ihrer Vorfahren. Suchzustand, Panelwahl und eingeklappte Knoten sind reine Oberflächenzustände und werden nicht im Dokument gespeichert.
|
||||||
|
|
||||||
|
## ADR 009 – Bilder, Galerie und Effekte (Dokumentschema v3)
|
||||||
|
|
||||||
|
Migration `20260912000300` ergänzt ausschließlich die Tabelle `media`: stabile ID, Titel, vorgeschlagener Alt-Text, unveränderliche Varianten, Archivstatus, Schreibversion und UTC-Erstellungszeit. Die lokale SQLite-Datenbank wurde vor Anwendung über `VACUUM INTO` konsistent gesichert. Keine Seiten oder Benutzer wurden umgeschrieben. Ein Restore muss künftig Datenbank **und** Medienverzeichnis gemeinsam wiederherstellen.
|
||||||
|
|
||||||
|
Uploads und Metadatenänderungen verlangen `ROLE_EDITOR` und einen eigenen CSRF-Token. Metadaten nutzen erwartete Versionen; konkurrierende Änderungen liefern 409. Bildtitel/Alt-Text im Medienkatalog sind Vorlagen für künftiges Einfügen. Bereits gespeicherte Seiten behalten ihre kontextbezogenen Texte, Beschriftungen und Bildvarianten. Archivieren blendet Bilder aus der normalen Auswahl aus und ist umkehrbar; bestehende URLs, Seiten und Undo-Stände bleiben funktionsfähig. Es gibt noch keine physische Löschung, Nutzungsübersicht, Ordner oder Katalog-Paginierung.
|
||||||
|
|
||||||
|
Nur tatsächlich decodierbare JPEG-, PNG- und WebP-Dateien werden angenommen. Dateinamen und vom Browser gemeldete MIME-Typen bestimmen weder Speicherpfade noch Dateiformat. Maximal 8 MiB, 12 Millionen Pixel und 8000 px pro Kante; zusätzlich konservative Prüfung gegen das PHP-Speicherlimit. GD decodiert und erzeugt WebP-Varianten bis 480/960/1600 px Breite ohne Hochskalieren. Das hochgeladene Original wird nicht übernommen; Metadaten und angehängte Nutzdaten werden nicht in die Varianten kopiert. EXIF-Orientierung wird bei JPEG berücksichtigt, sofern die EXIF-Erweiterung vorhanden ist. Transparenz wird erhalten; animierte Bilder/Dateianhänge/SVG gehören nicht zum unterstützten Uploadvertrag. Grundlage: [PHP-Bilddecodierung](https://www.php.net/manual/en/function.imagecreatefromstring.php).
|
||||||
|
|
||||||
|
Dateien liegen unter `MEDIA_DIRECTORY`, standardmäßig `var/media` außerhalb des Webroots. Nur zufällige IDs und bekannte Variantengrößen gelangen über `/media/{id}/{width}` mit festem `image/webp`, `nosniff` und unveränderlichem Langzeitcache nach außen. Erweiterungslose URLs funktionieren auch über die Frontcontroller-Weiterleitung des lokalen PHP-Servers; Pfade mit nicht vorhandenen `.webp`-Dateien wurden dort als statische 404 behandelt. Bild-URLs sind öffentlich, auch vor Veröffentlichung einer Seite und nach Archivieren; der Uploaddialog benennt dies. Die Katalog-API bleibt geschützt. Dies ist keine private Dokumentablage.
|
||||||
|
|
||||||
|
Schema v3 ergänzt den Container `gallery`, dessen Kinder ausschließlich Bildblöcke sein dürfen. Bestehende Baumaktionen (Auswahl, Verschieben, Löschen, Duplizieren, Undo) gelten auch für Galerieelemente; unpassende Verschiebeziele werden bereits im Modell und zusätzlich vom Server abgewiesen. Bilder dürfen `mediaId`, `sources` und eine Klartext-`caption` enthalten. Quellen für `srcset` werden einzeln auf URL und Breite geprüft. Die Ausgabe verwendet `figure`/`figcaption`, reale Maße, verzögertes Laden und responsive Quellen. Galerie-Spalten und Abstände nutzen dieselben responsiven Tokens wie Zeilen. Keine Lightbox oder Slideshow in diesem Schritt.
|
||||||
|
|
||||||
|
`layout.effects` erlaubt nur vordefinierte Schatten, Hover-Anheben/Bildzoom sowie Einblenden beim Laden. Effekte gelten geräteübergreifend. Die CSS-Datei wird nur für v3-Seiten eingebunden; es wird kein öffentliches JavaScript ergänzt. Bewegungsregeln gelten ausschließlich bei `prefers-reduced-motion: no-preference`. Inhalte bleiben ohne Animation lesbar; Einblendung ist keine scrollgesteuerte Animation.
|
||||||
|
|
||||||
|
Der idempotente Konverter unterstützt nun v1/v2 → v3 und verhindert Downgrades. Erst Einfügen einer Galerie/eines Katalogbildes, eine Bildbeschriftung oder Effektänderung hebt den lokalen Entwurf auf v3. Die Umstellung ist Teil derselben Undo-Transaktion; Speichern erzeugt eine neue Revision. v1/v2 bleiben les- und schreibbar. Kein Massendatenupdate. Rückweg über alte Revisionen oder gemeinsames Backup plus passenden Code; ältere v1/v2-Software darf keine v3-Revisionen laden. Die neue Medientabelle darf beim Rückweg nicht unbedacht gelöscht werden.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||||
|
|
||||||
|
## Ergänzung: Medienverwaltung, Galerie und Effekte, 12.09.2026
|
||||||
|
|
||||||
|
- **41 PHP-Tests / 155 Assertions** unter PHP 8.2.12 und 8.4.22 sowie **10 JavaScript-Modelltests** bestanden. Neu: geschützte Medien-API/CSRF, als JPEG getarnte SVG-Abweisung, tatsächliche Bilddecodierung/Neuencodierung, entfernte angehängte Nutzdaten, drei WebP-Varianten, Metadata-CAS/409, Archivieren/Wiederherstellen und weiter erreichbare Bilddateien. Zusätzlich ungültige Effekt-/Schemaschlüssel, Galerie-Kindtypen und `srcset`-Quellen sowie v3-Undo und Galerie-Verschieberegeln geprüft.
|
||||||
|
- Isolierter Browserablauf mit eigenen Datenbanken/Medienordnern bestanden: Upload von zwei lokal erzeugten Testillustrationen, Titel und Alt-Texte speichern, Mehrfachauswahl per Tastatur mit Fokus-Erhalt, Galerieeinfügen, Bildbeschriftung, Sortieren/Undo, responsive Spalten, Schatten/Hover/Einblendung und Bewegungsreduktion. Archivieren erhält URLs, Wiederherstellen erlaubt erneute Auswahl. Bilder und Bildquellen bleiben nach Speichern/Neuladen/Publizieren funktionsfähig. Bestehende Rechte-, Netzwerk-/Konflikt- und Editorprüfungen weiterhin erfolgreich.
|
||||||
|
- Keine axe-Verstöße in Medienauswahl und mobiler Mediathek sowie den bisherigen Login-/Seiten-/Editor- und öffentlichen Prüfungen. Desktop- und Mobil-Mediathek und öffentliche Galerieseite anhand der Screenshots geprüft. Alle drei bestehenden öffentlichen Seiten erneut mit/ohne JavaScript und bei 1440/390 px geprüft. Sandbox-Prüfgrenze weiterhin wie unten beschrieben; keine vollständige manuelle Screenreader-Abnahme.
|
||||||
|
- **Lighthouse 13.4.1: drei mobile und drei echte Desktopläufe auf der veröffentlichten v3-Galerieseite; alle fünf Kategoriescores in jedem Lauf 100.** LCP-Median mobil **937 ms**, Desktop **279 ms**, CLS **0**, TBT **0 ms**. PHP 8.2.12, SQLite, lokaler Produktionsmodus, GD-generierte Landschaftsillustrationen mit WebP-Varianten statt realer Kundenfotos. Rohdaten `reports/lighthouse-editor`, Metadaten `docs/quality/editor-media-lighthouse.json`, Browserbericht `docs/quality/editor-media-browser.json`. Reproduktion über `npm run test:editor -- --audit`.
|
||||||
|
- Migration 003 zuerst in Tests, anschließend lokal nach konsistentem SQLite-Backup angewendet. Keine Umschreibung echter Seitendokumente, keine Testuploads in den echten Medienordner, kein Browser-Neuladen des Nutzerentwurfs. Lokaler PHP-Prozess anschließend mit 8M Upload-/10M POST-/256M Speicherlimit gestartet. PHP-Format-, Twig-/YAML-/Container-, Composer- und Produktionsbuild-Prüfungen bestanden; Composer meldet keine bekannten Advisories.
|
||||||
|
|
||||||
|
Die öffentlichen Medien-URLs waren im ersten Browserlauf wegen des lokalen PHP-Servers mit `.webp`-Suffix nicht an den Frontcontroller weitergegeben worden. Erweiterungslose Routen beheben dies; tatsächliches Laden der Bilddateien wird jetzt ausdrücklich im Browser geprüft. Keine Hosting-/MySQL-/Restore-Abnahme, keine Felddaten und keine Aussage über beliebige hochgeladene Bilder oder Effekte auf Kundenwebsites.
|
||||||
|
|
||||||
|
## Ergänzung: responsive Editorgestaltung, 12.09.2026
|
||||||
|
|
||||||
|
- **34 PHP-Tests / 120 Assertions** unter PHP 8.2.12 und 8.4.22 erfolgreich. Zusätzlich zu den Sicherheits-/Revisionsprüfungen: v1/v2 lesen und speichern, originale Veröffentlichung erhalten, v1 wiederherstellen, validierte Gestaltungsklassen ohne Inline-CSS/JavaScript sowie Zurückweisung falscher Geräte, ungeeigneter Blockeigenschaften, unbekannter Tokens und zukünftiger Versionen.
|
||||||
|
- **9 JavaScript-Modelltests** erfolgreich: ergänzt um idempotente, inhaltserhaltende Migration; Gerätevererbung/Reset/Undo; atomare Vorlagen mit neuen IDs und genau einer H1; Abgleich des generierten CSS mit dem zentralen Tokenmanifest.
|
||||||
|
- Erweiterter Playwright-Ablauf auf eigener Datenbank erfolgreich: einklappbare Struktur/Werkzeugkasten, Suche und Auswahl per Tastatur, Schriftgröße/Ausrichtung/Abstände auf Desktop/Tablet/Mobil, echte Iframebreiten, Vererbung und Zurücksetzen samt Undo, Zweispaltenvorlage mit mobilem Umbruch. Danach Speichern, Neuladen und Veröffentlichen; berechnete öffentliche Styles auf 1600/768/390 px gegen die Vorschauwerte geprüft. Bestehende Fehler-/Konflikt-/Drag-and-drop-Prüfungen bleiben Bestandteil des Ablaufs.
|
||||||
|
- Keine axe-Verstöße in Login, Seitenliste, Desktop-/Mobil-Editor und gestalteter öffentlicher Mobilseite. Screenshotkontrolle von Desktop-Editor, Mobil-Editor und veröffentlichter Mobilseite erfolgreich; kein horizontaler Überlauf. Die unten beschriebene Einschränkung der scriptgesperrten Frameprüfung gilt weiterhin. Kompaktbericht: `docs/quality/editor-design-browser.json`.
|
||||||
|
- Alle drei bestehenden öffentlichen v1-Beispielseiten erneut bei 1440/390 px geprüft; sechs erfolgreiche Accessibility-Prüfungen plus Tastatur- und Navigation ohne JavaScript.
|
||||||
|
- **Lighthouse 13.4.1 auf der isolierten, veröffentlichten v2-Seite**, einschließlich Design-CSS und eingefügter responsiver Abschnittsvorlage: drei mobile und drei echte Desktopläufe, alle fünf Kategoriescores in jedem Lauf **100**. LCP-Median mobil **901 ms**, Desktop **240 ms**, CLS **0**, TBT **0 ms**. Produktionsmodus, PHP 8.2.12, SQLite und lokaler PHP-Server; Chromium/Profilparameter im Bericht `docs/quality/editor-design-lighthouse.json`, Rohdaten unter `reports/lighthouse-editor`. Der Test läuft reproduzierbar mit `npm run test:editor -- --audit` und ist in CI vorgesehen; externe CI weiterhin nicht ausgeführt.
|
||||||
|
- PHP-Format-, Twig-/YAML-/Containerprüfungen, JavaScript-Syntaxprüfung, Diff-Prüfung und Produktionscacheaufbau erfolgreich. Die zusätzlichen Designressourcen werden nur für v2-Dokumente geladen. Auf dem PHP-Hosting ist kein Node-Prozess nötig.
|
||||||
|
|
||||||
|
Weiterhin kleine lokale Testinhalte: keine Pilotkundenabnahme, keine Felddaten/INP-Aussage, keine umfassende Screenreaderprüfung und kein Nachweis für beliebige Gestaltungsentscheidungen. PHP-Webhosting und MySQL/MariaDB bleiben ungeprüft. Der echte Seitenbestand und der bereits geöffnete Browserentwurf wurden nicht für Tests geändert.
|
||||||
|
|
||||||
|
Die folgenden Abschnitte dokumentieren die früheren Entwicklungsstände und deren damalige Messungen.
|
||||||
|
|
||||||
|
## Ergänzung: erster visueller Editor, 12.09.2026
|
||||||
|
|
||||||
|
Die folgenden Ergebnisse betreffen den neuen Editor; die ursprüngliche Phase-0-Messung bleibt darunter als Ausgangsstand erhalten.
|
||||||
|
|
||||||
|
- **27 PHP-Tests, 105 Assertions**, erfolgreich unter PHP 8.2.12 und 8.4.22. Ergänzt: geschützte Admin-/API-Routen, Login-CSRF und falsches Passwort, CSRF für alle Schreib-/Vorschauaktionen, Editor ohne Veröffentlichungsrecht, HTTP-422/409-Behandlung, konkrete Revisionsprüfung, privater Entwurf und anschließende Veröffentlichung, Logout-CSRF, Passwort-Rehash und Sessioninvalidierung bei Rollenänderung.
|
||||||
|
- **5 JavaScript-Modelltests** für Undo/Redo samt Speicherpunkt, Zusammenfassung von Texteingaben, unveränderte History-Snapshots, neue IDs beim Duplizieren, Zyklenschutz beim Verschieben und identischen JSON-Roundtrip.
|
||||||
|
- **End-to-End-Editorprüfung** mit Playwright: echte Formularanmeldung, Klickauswahl im Iframe, Eigenschaftenänderung und Live-Vorschau, Undo/Redo, Einfügen/Duplizieren/Verschieben einschließlich Drag-and-drop/Löschen, Speichern und identisches Neuladen, Entwurfsprivatheit, separate Veröffentlichung, Escape/Fokusrückkehr im Dialog, abgefangener Netzwerkabbruch mit erneutem Speichern, 409-Konflikt ohne Verlust lokaler Änderungen, JSON-Download und bestätigtes Laden des Serverstands. Eigene Testdatenbank und Zufallszugänge unter `var/browser-tests`; echte Seiten und Benutzer bleiben durch diese Tests unverändert.
|
||||||
|
- axe-core: keine gefundenen Verstöße in Login, Seitenübersicht, Desktop- und Mobil-Editor sowie separat in der bearbeiteten öffentlichen Ausgabe. Die scriptgesperrte Vorschau kann den axe-Frameworker nicht ausführen. Der Frameinhalt wird deshalb in der Editor-axe-Prüfung ausgeschlossen und seine identische öffentliche Ausgabe separat geprüft; die Frameauswahl und Bearbeitung sind weiterhin funktional im realen Sandbox-Iframe getestet. Diese Einschränkung ist keine vollständige Barrierefreiheitsabnahme.
|
||||||
|
- Visuell geprüft: Editor-Desktop bei 1600 × 1100 und Mobil bei 390 × 844, Screenshots unter `reports/editor`. Scrollbereich ist per Tastatur fokussierbar. Keine horizontalen Überläufe in der Mobilprüfung.
|
||||||
|
- Öffentliche Browser-Regression: alle drei unveränderten Beispielseiten bei 1440 und 390 px erfolgreich, kein Editor-JavaScript und kein zusätzliches Session-Cookie auf der öffentlichen Route.
|
||||||
|
- Erneute **Lighthouse-13.4.1-Messung** mit drei echten Mobil-/Desktop-Läufen im Produktionsmodus: jeweils 100 in allen fünf Kategorien. LCP-Mediane jetzt mobil 803 ms, Desktop 252 ms, CLS 0 und TBT 0 ms. Gleiches kleines lokales Testszenario und dieselben Grenzen wie unten; keine reale Kundenabnahme oder Felddaten. Metadaten: `docs/quality/editor-public-lighthouse.json`.
|
||||||
|
- Composer Audit ohne bekannte Advisories; Composer-Validierung, PHP-Formatierung, Twig-/YAML-/Containerprüfung, JavaScript-Syntaxprüfung erfolgreich. CI wurde um Modell- und Editorprüfungen ergänzt, aber nicht extern ausgeführt. MySQL/MariaDB/Hosting bleiben ungetestet.
|
||||||
|
|
||||||
|
Der lokale Benutzer `umut` wurde getrennt von den Tests auf ausdrücklichen Nutzerwunsch angelegt und im In-App-Browser angemeldet. Keine Zugangsdaten in Quellen oder Dokumentation abgelegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Datum: 12.09.2026. Ziel: `http://127.0.0.1:8080/`. Diese Messung betrifft ausschließlich die lokale Kant-CMS-Demoseite, keine bestehende Kundenwebsite und nicht `kant-it.de`.
|
||||||
|
|
||||||
|
## Tatsächlich ausgeführte Prüfungen
|
||||||
|
|
||||||
|
- PHPUnit 11.5.56: 19 Tests, 35 Assertions erfolgreich unter PHP 8.2.12 und PHP 8.4.22. Dokument-Roundtrip, Entwurfsprivatheit, abgewiesene veraltete Schreibzugriffe, Transaktionsrollback, fremde Revisionen, Wiederherstellung, XSS-Maskierung, ungültige URLs/Designwerte/Block-IDs, Canonical, 404 und verweigerte HTTP-Schreibmethoden.
|
||||||
|
- Composer-Validierung, PHP-Formatprüfung, Twig-/YAML-/Containerprüfung und produktiver Cacheaufbau erfolgreich. Composer Audit und npm install meldeten keine bekannten Paketlücken zum Prüfzeitpunkt.
|
||||||
|
- Frische Kopie der für Git vorgesehenen Quellen unter `var/fresh-check`: `composer install --no-dev`, Setup, erste Migration, Seeding zweimal, Produktionscache und Symfony-Request erfolgreich. Gespeicherter Inhalt erscheint mit HTTP 200; zweites Seeding verändert vorhandene Seiten nicht.
|
||||||
|
- Playwright 1.63.0 / Chromium 153.0.8010.12 und axe-core 4.13.0: `/`, `/grundlagen`, `/projektstand`, jeweils mit 1440 und 390 px Breite; keine axe-Verstöße in den ausgewählten WCAG-2-A/AA-, WCAG-2.1-AA- und Best-Practice-Regeln. Je Kombination 29 bestandene axe-Prüfungen. Kein horizontaler Überlauf oder JavaScript-Seitenfehler.
|
||||||
|
- Automatisierte Tastaturprüfung des Sprunglinks und Navigation, zusätzlich Navigation mit deaktiviertem JavaScript erfolgreich. Desktop- und Mobil-Screenshots der Startseite visuell durchgesehen.
|
||||||
|
|
||||||
|
## Lighthouse
|
||||||
|
|
||||||
|
Lighthouse 13.4.1, Chromium Headless 153.0.8010.12 unter Windows; PHP 8.4.22 mit `APP_ENV=prod`, `APP_DEBUG=0`, vorgewärmtem Symfony-Cache, SQLite, lokalem PHP-Entwicklungsserver. Vollständige gespeicherte HTML-Inhalte, keine externen Dienste, keine Editor-Ressourcen. Netzwerk-/CPU-Drosselung simuliert. Drei Läufe je Profil, Median:
|
||||||
|
|
||||||
|
| Profil | Leistung | Barrierefreiheit | Best Practices | SEO | Agentisches Browsing | LCP | CLS | TBT |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Mobil | 100 | 100 | 100 | 100 | 100 | 797 ms | 0 | 0 ms |
|
||||||
|
| Desktop | 100 | 100 | 100 | 100 | 100 | 261 ms | 0 | 0 ms |
|
||||||
|
|
||||||
|
Alle sechs endgültigen Läufe hatten diese Kategoriescores. Mobil: 412 × 823 px, DPR 1,75, RTT 150 ms, 1.638,4 Kbit/s Durchsatz, CPU-Faktor 4. Desktop: 1350 × 940 px, DPR 1, RTT 40 ms, 10.240 Kbit/s Durchsatz, CPU-Faktor 1. Detailparameter und Umgebungsmetadaten stehen in `docs/quality/lighthouse-summary.json`. Das Skript prüft die tatsächliche `formFactor`-Einstellung und lehnt indexierungsgesperrte Antworten vor dem Audit ab.
|
||||||
|
|
||||||
|
Agentisches Browsing: Die anwendbaren Prüfungen `agent-accessibility-tree` und `cumulative-layout-shift` bestanden. `webmcp-form-coverage`, `webmcp-registered-tools`, `webmcp-schema-validity` und `llms-txt` waren **nicht anwendbar**, nicht als implementierte Funktionen bestanden. Es gibt noch keine Formulare, WebMCP-Tools oder llms.txt.
|
||||||
|
|
||||||
|
## Grenzen und noch ausstehende Abnahme
|
||||||
|
|
||||||
|
- Die kleine Demoseite hat bewusst keine echten Kundenbilder, Blogbeiträge oder Formulare. Deshalb ist sie nur eine technische Regressionsbasis und erfüllt noch nicht die im Plan geforderte repräsentative Referenzsuite. Die Werte nicht als Leistungsnachweis für Kundenwebsites verwenden.
|
||||||
|
- INP und die Core-Web-Vitals-Feldwerte am 75. Perzentil sind unbekannt. Der gemessene TBT ist kein INP. Es gibt keine realen Nutzungsdaten.
|
||||||
|
- Manuelle Screenreader-Prüfung, vollständige manuelle Tastatur-/Kontrast-/Zoomprüfung, Dialoge, Formulare und responsive Editorabläufe fehlen. Visuelle Screenshotkontrolle und automatisierte axe-Prüfungen ersetzen sie nicht.
|
||||||
|
- MySQL-/MariaDB-Betrieb, Apache-Rewrites, tatsächliches PHP-Webhosting, HTTPS-Konfiguration, Restore und externe CI-Ausführung wurden nicht getestet. Ein MySQL-Smokejob ist vorbereitet.
|
||||||
|
- GEO, Auffindbarkeit und Zitierung in KI-Antworten wurden nicht geprüft. Keine Ranking- oder Zitiergarantie.
|
||||||
|
- Die rohe Auditserie und Browser-Screenshots liegen lokal in `reports/` und sind nicht für Git vorgesehen. Die kompakte Zusammenfassung und dieser Bericht werden mit den Quellen abgelegt.
|
||||||
|
|
||||||
|
## Im Verlauf behoben
|
||||||
|
|
||||||
|
Mobiler Textüberlauf bei langen deutschen Wörtern; fehlende Übernahme realer Prozessvariablen bei PHP-Konfiguration ohne `E` in `variables_order` (dadurch blieb Debug/noindex aktiv); anfänglich falsch übergebener Desktop-Preset an die Lighthouse-Programmierschnittstelle; unter Windows gesperrte temporäre Testdatenbank sowie Cleanup des anfänglichen Chrome-Launchers. Die endgültigen oben dokumentierten Prüfungen liefen nach diesen Korrekturen erfolgreich.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Anforderungen und Analyse des Entwicklungsplans
|
||||||
|
|
||||||
|
Stand: 12.09.2026.
|
||||||
|
|
||||||
|
Die beiden Dokumente sind unveränderte Planungsvorlagen aus Downloads. Aussagen wie „kein Repository eingerichtet“ oder „dieser Chat hat derzeit keinen Zugriff“ beschreiben ihren ursprünglichen Entstehungskontext. Der aktuelle Implementierungsstand wird in STATUS gepflegt. Aus dem mehrphasigen Plan folgt keine Behauptung, das vollständige CMS sei mit dem initialen Auftrag fertigzustellen oder fertiggestellt.
|
||||||
|
|
||||||
|
## Bestätigt
|
||||||
|
|
||||||
|
- Eigenständiges CMS für Kundenwebsites; ein visuell bedienbarer Editor als Kernziel.
|
||||||
|
- Explizite aktuelle Nutzerantwort: herkömmliches PHP-Webhosting.
|
||||||
|
- Öffentliche HTML-Ausgabe ohne Editorpaket; versionierte Blöcke, getrennte Entwürfe/Publikationen.
|
||||||
|
- Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing ab dem ersten Prototyp überprüfen.
|
||||||
|
- Noch keine externe Veröffentlichung beauftragt.
|
||||||
|
|
||||||
|
## Noch offen, aber kein Hindernis für das lokale Grundgerüst
|
||||||
|
|
||||||
|
- Pilotwebsite: `kant-it.de` wurde im Plan als Messbeispiel erwähnt, ist noch nicht als Pilot ausgewählt.
|
||||||
|
- Hostinganbieter/-tarif, PHP-Version, Datenbankversion, Dokumentenstamm und CLI-/Deploymentzugriff.
|
||||||
|
- Tatsächliche Flatsome-Referenz und häufig genutzte Workflows: keine Screenshots/Demo übergeben, keine Referenzanalyse behauptet.
|
||||||
|
- Produktname, echte Kundeninhalte, Menüs, Seitentypen, Formulare und Sprachumfang.
|
||||||
|
|
||||||
|
## Geplanter erster Editorablauf
|
||||||
|
|
||||||
|
1. Eine vorhandene Seite auswählen und den gespeicherten Entwurf laden.
|
||||||
|
2. Sektion, Zeile oder Spalte über die Elementbibliothek anlegen.
|
||||||
|
3. Überschrift, Text, Bild und Link einfügen; Auswahl in Baum und Vorschau synchron halten.
|
||||||
|
4. Eigenschaften im Panel ändern; mobile Vorschau und Vererbungsmodell sichtbar machen.
|
||||||
|
5. Elemente verschieben, duplizieren und löschen; zusätzlich tastaturbedienbare Schaltflächen bereitstellen.
|
||||||
|
6. Änderung rückgängig machen/wiederholen; gespeicherten Stand von lokalem Undo-Verlauf unterscheiden.
|
||||||
|
7. Speichern mit sichtbarem Zustand; Konflikt beim veralteten Versionsstand anzeigen.
|
||||||
|
8. Veröffentlichung gesondert auslösen; dieselben Blöcke auf der öffentlichen Route prüfen.
|
||||||
|
|
||||||
|
Das ist ein Arbeitsentwurf aus dem Plan, keine nachgewiesene Flatsome-Funktionsgleichheit. Vor Freischalten von Editor-Schreibwegen sind Symfony Security, CSRF und serverseitige Berechtigungen nötig, auch wenn der große CMS-Ausbau im Plan erst später folgt.
|
||||||
|
|
||||||
|
## Priorisierung
|
||||||
|
|
||||||
|
Der initiale Abschnitt schließt die ausführbare technische Grundlage von Phase 0 ab. Die Anforderungsabnahme dieser Phase bleibt wegen Pilot-/Hostingdetails und Referenz offen. Der Editor bietet kontrollierte Layout-/Typografiewerte, responsive Vererbung, Abschnittsvorlagen, Werkzeuge für lange Blockbäume sowie Bildmediathek, Galerien und Effekte. Seitenerstellung und vollständiger Referenzabgleich fehlen noch. Plugins, umfassendes SEO und WordPress-Migration sind keine bereits implementierten Funktionen.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Projektstatus
|
||||||
|
|
||||||
|
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Editor mit Gestaltung, responsiven Werten, Abschnittsvorlagen, Medienverwaltung, Galerien und Effekten. Die vollständige Phase-1-Abnahme bleibt wegen fehlender Seitenerstellung und Referenzabgleich offen.**
|
||||||
|
|
||||||
|
## Erledigt
|
||||||
|
|
||||||
|
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
|
||||||
|
- Leeres lokales Git-Projekt auf `main` initialisiert; Quellen und Lockfiles für den ersten Commit vorgemerkt. Noch kein Commit und kein Remote/Push.
|
||||||
|
- Nutzerentscheidung „herkömmliches PHP-Webhosting“ übernommen. Symfony 7.4 / Twig / Doctrine gewählt; kein Python-/Django-Anwendungscode.
|
||||||
|
- Start-/Build-/Prüfkommandos und Hostingvoraussetzungen in README, begründete Architektur und Datenmodell in ARCHITECTURE, Plananalyse/offene Anforderungen in REQUIREMENTS.
|
||||||
|
- Erstes Datenbankschema, versionierte Blockdokumente, unveränderliche Revisionen über den Repository-Vertrag, getrennte Entwürfe/Veröffentlichungen und optimistische Schreibkonflikterkennung.
|
||||||
|
- Drei gespeicherte öffentliche HTML-Demoseiten, lokale CSS-Ausgabe ohne JavaScript, Metadaten/Canonical, 404, Sprunglink und sichtbarer Fokus. `/home` leitet auf `/` um.
|
||||||
|
- Idempotentes Demo-Seeding und lokaler CLI-Import validierter Entwürfe. Keine ungeschützte HTTP-Schreibschnittstelle.
|
||||||
|
- Symfony-Security-Anmeldung, CSRF, Login-Throttling, geschützte Sessions und Rollen Editor/Publisher/Admin. Neue Migration ergänzt ausschließlich die Benutzertabelle. Passwort-Rehash hält Sessionbenutzer und Datenbank synchron; Rollenänderungen invalidieren bestehende Sitzungen.
|
||||||
|
- Seitenübersicht und visueller Editor: Elementbibliothek, Strukturbaum, gemeinsame serverseitige Live-Vorschau, Eigenschaften, Drag-and-drop und Schaltflächen zum Verschieben/Verschachteln, Duplizieren/Löschen, lokales Undo/Redo.
|
||||||
|
- Inhalt-/Designbereiche mit typabhängigen Abständen, Ausrichtung, Schriftgröße/-stärke, Zeilenhöhe, Spaltenaufteilung/-abstand und Ecken. Gerätewerte Desktop → Tablet → Mobil samt Vererbung, Einzel-/Gerätereset und Undo. Echte Vorschau-Layoutbreiten 1240/768/390 px mit visueller Skalierung.
|
||||||
|
- Drei bearbeitbare Abschnittsvorlagen (Intro, zwei Leistungen, Kontaktaufruf), einklappbarer Werkzeugkasten, Struktursuche mit Vorfahren, einklappbare Teilbäume, Pfadnavigation und dauerhaft erreichbare Blockaktionen.
|
||||||
|
- Dokumentformat v2, erhaltender idempotenter v1-Konverter, gemeinsame Tokenliste für Validator/Editor/CSS und dokumentierter Migrations-/Rückweg. Bestehende v1-Seiten werden beim Öffnen nicht geändert; keine neue DB-Migration erforderlich.
|
||||||
|
- Dokumentformat v3 für Galerie, Bildbeschriftungen/-varianten und Effekte, mit erhaltendem Upgrade ohne Downgrade und Undo. v1/v2 bleiben unterstützt. Schatten, Hover-Anheben/Bildzoom und Einblenden beim Laden ohne öffentliches JavaScript; Bewegungsreduktion wird berücksichtigt.
|
||||||
|
- Medienverwaltung im Editor und unter `/admin/media`: JPEG/PNG/WebP-Upload, echte Bildvalidierung, Größen-/Pixel-/Speichergrenzen, WebP-Varianten bis 480/960/1600 px, Suche, Titel/Alt-Text, Archivieren/Wiederherstellen und Schreibkonflikterkennung. Bildauswahl für einzelne Bilder und Galerien, kontextbezogene Alt-Texte/dekorative Verwendung und Beschriftungen.
|
||||||
|
- Neue Medientabelle durch Migration `20260912000300` lokal angewendet. Vorher konsistentes SQLite-Backup unter `var/backups/before-media-20260912-051258.db`. Bestehende Seiten/Benutzer nicht geändert. Lokal läuft PHP 8.4 mit 8M Upload-, 10M POST- und 256M Speicherlimit; originale Uploads werden nicht gespeichert.
|
||||||
|
- Speichern/Laden von Entwürfen, separates Veröffentlichen mit Bestätigung, sichtbare Speicher-/Fehler-/Konfliktzustände, JSON-Sicherung und bewusster Server-Neuladedialog. Keine Tests schreiben in den echten Seitenbestand.
|
||||||
|
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||||
|
- Lokale PHP-/Browser-/Lighthouse-Prüfungen und minimale GitHub-Actions-Konfiguration einschließlich vorbereitetem MySQL-Smokejob.
|
||||||
|
|
||||||
|
## Geprüft
|
||||||
|
|
||||||
|
- 41 PHP-Tests / 155 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 10 JavaScript-Modelltests. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||||
|
- Composer-/Format-/Twig-/YAML-/Containerprüfungen, Migration, wiederholtes Seeding und Produktionsbuild erfolgreich.
|
||||||
|
- Frische Quellkopie ohne bestehende DB/Secrets/Dev-Abhängigkeiten erfolgreich eingerichtet; gespeicherte Seite als HTTP 200 gerendert.
|
||||||
|
- Browserprüfung aller drei öffentlichen Seiten mobil und Desktop; keine axe-Verstöße, keine Überläufe, funktionierende Sprunglink-/Navigationsabläufe auch ohne JavaScript.
|
||||||
|
- Editor-End-to-End in isolierter Installation: Anmeldung, Auswahl im Iframe, Texteingabe, Undo/Redo, Einfügen/Duplizieren/Verschieben/Drag-and-drop/Löschen, identisches Neuladen, private Entwürfe, Veröffentlichung, Dialog-Fokus/Escape, simulierter Netzwerkfehler mit Wiederholung, 409-Konflikt mit JSON-Export und Server-Neuladen. Einschränkungen der axe-Frameprüfung sind in QUALITY dokumentiert.
|
||||||
|
- Ausbau im Browser geprüft: Struktursuche und Auswahl per Tastatur, Einklappen, Abstände, Schriftwerte und Vererbung in echten Gerätebreiten, Rücksetzen/Undo, responsive Zweispaltenvorlage und identische Styles nach Speichern/Publizieren auf allen drei Geräten.
|
||||||
|
- Editoroberfläche, Login und Seitenübersicht ohne axe-Verstöße; identisch gerenderter bearbeiteter öffentlicher Inhalt separat geprüft. Desktop- und Mobil-Screenshots visuell durchgesehen.
|
||||||
|
- Lighthouse-Startseite: jeweils drei echte mobile und Desktop-Läufe, Median in allen fünf Kategorien 100. Nur lokaler Prototyp; keine Kundenwebsite-Abnahme. Umfang und Grenzen siehe QUALITY.
|
||||||
|
- Gestaltete v2-Testseite mit Design-CSS und responsiver Abschnittsvorlage: zusätzliche sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 901 ms/Desktop 240 ms, CLS/TBT 0. Echte Seitenbestände und geöffnete Browserentwürfe unangetastet. Bericht: `docs/quality/editor-design-lighthouse.json`.
|
||||||
|
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
|
||||||
|
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||||
|
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||||
|
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Neue Seiten, Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar. Bestehende Seiten können bearbeitet und veröffentlicht werden.
|
||||||
|
- SQLite tatsächlich getestet. MySQL-/MariaDB-Kompatibilität und CI erst vorbereitet, nicht nachgewiesen; Anbieter-/Tarifdetails und Livebetrieb offen.
|
||||||
|
- Plugin-Fallback im Renderer noch nicht durch den strikten Lesevalidator erreichbar; unbekannte Blocktypen werden derzeit abgewiesen, nicht gelöscht. Ausbau in Phase 4.
|
||||||
|
- Kein Inhaltscache, nur Symfony-Programmcache. Seiten senden `no-store`; Veröffentlichung ist dadurch ohne Cacheinvalidierungsverzögerung sichtbar.
|
||||||
|
- Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung.
|
||||||
|
- Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert.
|
||||||
|
|
||||||
|
## Nächster konkreter Schritt
|
||||||
|
|
||||||
|
Neue Seiten über den Browser anlegen und mit einer Startvorlage aufbauen; danach Mediennutzung und Zuschnitte weiter ausbauen. Die wichtigsten Editorabläufe anhand einer konkreten UX-Builder-Referenz abgleichen. Für die Anmeldung eine reguläre Passwortänderungsmöglichkeit ergänzen, ohne Geheimnisse in Projektdateien zu speichern.
|
||||||
|
|
||||||
|
Parallel Pilotwebsite, reale UX-Builder-Referenz und Hostingausstattung konkretisieren. Noch keine Pilotwebsite ausgewählt; `kant-it.de` ist allein durch seine Erwähnung im Plan nicht beauftragt. Keine externe Veröffentlichung erfolgt.
|
||||||
|
|
||||||
|
## Lokale Vorschau
|
||||||
|
|
||||||
|
Öffentlich: `http://127.0.0.1:8080/`. Verwaltung: `http://127.0.0.1:8080/admin`. PHP 8.4.22 im Symfony-Produktionsmodus; der Browser wurde als `umut` angemeldet. Der Prozess ist sitzungsabhängig; zum erneuten Start README verwenden.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page list",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preview selection, property edit, undo and redo",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "network failure retains edits and allows retry",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "edited public renderer accessibility",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "add, duplicate, move, delete, undo, exact reload, separate publication, dialog focus",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conflict retains local edits, JSON export, explicit server reload",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop editor",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mobile editor",
|
||||||
|
"violations": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publicDataUntouched": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page list",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preview selection, property edit, undo and redo",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "network failure retains edits and allows retry",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "edited public renderer accessibility",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "add, duplicate, move, delete, undo, exact reload, separate publication, dialog focus",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conflict retains local edits, JSON export, explicit server reload",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop editor",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mobile editor",
|
||||||
|
"violations": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publicDataUntouched": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5250,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5297,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"name": "login",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page list",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preview selection, property edit, undo and redo",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "media picker",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "network failure retains edits and allows retry",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "edited public renderer accessibility",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "add, duplicate, move, delete, undo, exact reload, separate publication, dialog focus",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "conflict retains local edits, JSON export, explicit server reload",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop editor",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mobile editor",
|
||||||
|
"violations": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mobile media library",
|
||||||
|
"violations": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publicDataUntouched": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5314,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5319,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5286,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5331,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"profile": "mobile",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "mobile",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 150,
|
||||||
|
"throughputKbps": 1638.4,
|
||||||
|
"requestLatencyMs": 562.5,
|
||||||
|
"downloadThroughputKbps": 1474.5600000000002,
|
||||||
|
"uploadThroughputKbps": 675,
|
||||||
|
"cpuSlowdownMultiplier": 4
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": true,
|
||||||
|
"width": 412,
|
||||||
|
"height": 823,
|
||||||
|
"deviceScaleFactor": 1.75,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5274,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile": "desktop",
|
||||||
|
"scores": {
|
||||||
|
"performance": 1,
|
||||||
|
"accessibility": 1,
|
||||||
|
"best-practices": 1,
|
||||||
|
"seo": 1,
|
||||||
|
"agentic-browsing": 1
|
||||||
|
},
|
||||||
|
"lighthouseVersion": "13.4.1",
|
||||||
|
"settings": {
|
||||||
|
"output": "json",
|
||||||
|
"maxWaitForFcp": 30000,
|
||||||
|
"maxWaitForLoad": 45000,
|
||||||
|
"pauseAfterFcpMs": 1000,
|
||||||
|
"pauseAfterLoadMs": 1000,
|
||||||
|
"networkQuietThresholdMs": 1000,
|
||||||
|
"cpuQuietThresholdMs": 1000,
|
||||||
|
"formFactor": "desktop",
|
||||||
|
"throttling": {
|
||||||
|
"rttMs": 40,
|
||||||
|
"throughputKbps": 10240,
|
||||||
|
"requestLatencyMs": 0,
|
||||||
|
"downloadThroughputKbps": 0,
|
||||||
|
"uploadThroughputKbps": 0,
|
||||||
|
"cpuSlowdownMultiplier": 1
|
||||||
|
},
|
||||||
|
"throttlingMethod": "simulate",
|
||||||
|
"screenEmulation": {
|
||||||
|
"mobile": false,
|
||||||
|
"width": 1350,
|
||||||
|
"height": 940,
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"disabled": false
|
||||||
|
},
|
||||||
|
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
|
"auditMode": false,
|
||||||
|
"gatherMode": false,
|
||||||
|
"clearStorageTypes": [
|
||||||
|
"file_systems",
|
||||||
|
"shader_cache",
|
||||||
|
"service_workers",
|
||||||
|
"cache_storage"
|
||||||
|
],
|
||||||
|
"disableStorageReset": false,
|
||||||
|
"debugNavigation": false,
|
||||||
|
"channel": "node",
|
||||||
|
"usePassiveGathering": false,
|
||||||
|
"disableFullPageScreenshot": false,
|
||||||
|
"skipAboutBlank": false,
|
||||||
|
"blankPage": "about:blank",
|
||||||
|
"ignoreStatusCode": false,
|
||||||
|
"locale": "en-US",
|
||||||
|
"blockedUrlPatterns": null,
|
||||||
|
"additionalTraceCategories": null,
|
||||||
|
"extraHeaders": null,
|
||||||
|
"precomputedLanternData": null,
|
||||||
|
"onlyAudits": null,
|
||||||
|
"onlyCategories": null,
|
||||||
|
"skipAudits": null
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||||
|
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||||
|
"benchmarkIndex": 5264,
|
||||||
|
"credits": {
|
||||||
|
"axe-core": "4.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'table_storage' => ['table_name' => 'migration_versions'],
|
||||||
|
'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'],
|
||||||
|
'all_or_nothing' => false,
|
||||||
|
];
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260912000100 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Website, localized pages and immutable document revisions (schema v1).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTransactional(): bool
|
||||||
|
{
|
||||||
|
return false; // MySQL DDL commits implicitly. Backup before production migrations.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$site = $schema->createTable('website');
|
||||||
|
$site->addColumn('id', 'string', ['length' => 64]);
|
||||||
|
$site->addColumn('name', 'string', ['length' => 200]);
|
||||||
|
$site->addColumn('default_locale', 'string', ['length' => 12]);
|
||||||
|
$site->addColumn('theme', 'json');
|
||||||
|
$site->setPrimaryKey(['id']);
|
||||||
|
|
||||||
|
$page = $schema->createTable('page');
|
||||||
|
$page->addColumn('id', 'string', ['length' => 64]);
|
||||||
|
$page->addColumn('website_id', 'string', ['length' => 64]);
|
||||||
|
$page->addColumn('slug', 'string', ['length' => 160]);
|
||||||
|
$page->addColumn('locale', 'string', ['length' => 12]);
|
||||||
|
$page->addColumn('draft_revision_id', 'string', ['length' => 64, 'notnull' => false]);
|
||||||
|
$page->addColumn('published_revision_id', 'string', ['length' => 64, 'notnull' => false]);
|
||||||
|
$page->addColumn('version', 'integer', ['default' => 0]);
|
||||||
|
$page->setPrimaryKey(['id']);
|
||||||
|
$page->addUniqueIndex(['website_id', 'locale', 'slug']);
|
||||||
|
$page->addForeignKeyConstraint('website', ['website_id'], ['id']);
|
||||||
|
|
||||||
|
$revision = $schema->createTable('revision');
|
||||||
|
$revision->addColumn('id', 'string', ['length' => 64]);
|
||||||
|
$revision->addColumn('page_id', 'string', ['length' => 64]);
|
||||||
|
$revision->addColumn('document', 'json');
|
||||||
|
$revision->addColumn('created_at', 'datetime_immutable');
|
||||||
|
$revision->setPrimaryKey(['id']);
|
||||||
|
$revision->addForeignKeyConstraint('page', ['page_id'], ['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->throwIrreversibleMigrationException('Contains customer content. Restore a verified database backup and matching code.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260912000200 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add CMS users without changing existing pages or revisions.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTransactional(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$users = $schema->createTable('cms_user');
|
||||||
|
$users->addColumn('id', 'string', ['length' => 64]);
|
||||||
|
$users->addColumn('username', 'string', ['length' => 100]);
|
||||||
|
$users->addColumn('password_hash', 'string', ['length' => 255]);
|
||||||
|
$users->addColumn('roles', 'json');
|
||||||
|
$users->setPrimaryKey(['id']);
|
||||||
|
$users->addUniqueIndex(['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->throwIrreversibleMigrationException('Contains account data. Restore the verified database backup with matching code.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260912000300 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add image library with immutable image variants and reversible archiving.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTransactional(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$media = $schema->createTable('media');
|
||||||
|
$media->addColumn('id', 'string', ['length' => 32]);
|
||||||
|
$media->addColumn('title', 'string', ['length' => 160]);
|
||||||
|
$media->addColumn('alt', 'string', ['length' => 300]);
|
||||||
|
$media->addColumn('variants', 'json');
|
||||||
|
$media->addColumn('archived', 'boolean', ['default' => false]);
|
||||||
|
$media->addColumn('version', 'integer', ['default' => 1]);
|
||||||
|
$media->addColumn('created_at', 'datetime_immutable');
|
||||||
|
$media->setPrimaryKey(['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->throwIrreversibleMigrationException('Restore database and media files together from a verified backup.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1425
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "kant-cms-quality",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build:design": "node scripts/build-design.mjs",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"test:editor": "node scripts/editor-check.mjs",
|
||||||
|
"test:browser": "node scripts/browser-check.mjs",
|
||||||
|
"audit": "node scripts/audit.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.0",
|
||||||
|
"playwright": "^1.55.0",
|
||||||
|
"lighthouse": "^13.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit bootstrap="vendor/autoload.php" colors="true" cacheDirectory="phpunit.cache" failOnWarning="true" failOnRisky="true">
|
||||||
|
<testsuites><testsuite name="CMS"><directory>tests</directory></testsuite></testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DirectoryIndex index.php
|
||||||
|
Options -Indexes -MultiViews
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
:root{font-family:Arial,Helvetica,sans-serif;color:#203b34;background:#f6f7f4;--ink:#203b34;--muted:#5a6861;--line:#dce2dc;--paper:#fff;--mint:#e5f0b6;--accent:#315b49;font-synthesis:none}
|
||||||
|
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
||||||
|
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||||
|
.visually-hidden{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||||
|
.media-launch{margin-top:12px;white-space:normal}.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}.media-heading h2{margin:0}.media-heading .eyebrow{margin-bottom:8px}.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}.media-tools label{margin-top:10px}.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}.media-card strong{font-size:12px;overflow-wrap:anywhere}.media-card span{font-size:11px;color:var(--muted)}.media-details{padding-left:22px;border-left:1px solid var(--line)}.media-details button,.media-details a{margin-top:12px;display:block}.media-details input{font-size:13px}.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}.media-footer label{margin:0;font-size:12px}#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}#media-status{font-size:12px;margin-block:15px}.media-dialog> .panel-hint{margin-top:20px}.media-details>p{font-size:12px}
|
||||||
|
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||||
|
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,textarea,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.48}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,iframe:focus-visible{outline:3px solid #b55b12;outline-offset:3px}input,textarea,select{width:100%;border:1px solid #bcc9c0;border-radius:7px;background:#fff;color:var(--ink);padding:11px 12px;min-height:42px}textarea{resize:vertical;min-height:110px;line-height:1.6}label{display:block;margin:21px 0 8px;font-size:13px;font-weight:600}h1,h2,h3,p{margin-top:0}h1{font-size:38px;font-weight:500;letter-spacing:-1.5px;line-height:1.15}h2{font-size:18px;letter-spacing:-.4px}h3{font-size:16px}.muted{color:var(--muted);line-height:1.6}.eyebrow{font-size:10px;font-weight:700;letter-spacing:1.7px;color:var(--accent);margin-bottom:16px}.brand{display:inline-flex;align-items:center;font-size:25px;font-weight:700;text-decoration:none;letter-spacing:-1.3px;gap:3px}.brand>span:last-child{font-weight:400}.brand-icon{display:grid;place-items:center;width:33px;height:33px;background:var(--ink);color:var(--mint);border-radius:9px;margin-right:7px}.primary,.secondary,.subtle,.icon-button,.text-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}.primary{background:var(--ink);color:white;border-color:var(--ink)}.primary:hover:not(:disabled){background:#37604b}.secondary:hover:not(:disabled){background:#f0f4ed}.subtle,.text-button{border-color:transparent;background:transparent}.text-button{padding:4px 8px;min-height:28px}.full-width{width:100%}.skip-link{position:fixed;top:-100px;left:16px;z-index:100;background:var(--ink);color:white;padding:15px}.skip-link:focus{top:10px}.notice,.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}.error{background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7}.login-body{min-height:100vh;display:grid;place-items:center;background:radial-gradient(ellipse at top left,#e5f0b6,transparent 65%),#f6f7f4;padding:30px}.login-card{width:100%;max-width:450px;padding:44px;background:white;border:1px solid var(--line);border-radius:18px;box-shadow:0 20px 60px #203b3408}.login-card .brand{margin-bottom:44px}.login-card h1{font-size:31px}.login-card .primary{margin-top:26px}.quiet-link{display:block;font-size:12px;text-align:center;margin-top:25px}.admin-header{padding:24px 42px;background:white;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:20px}.header-actions{display:flex;align-items:center;gap:24px;font-size:12px}.user-name{padding-left:24px;border-left:1px solid var(--line)}.pages-main{max-width:1120px;margin:74px auto;padding:0 30px}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:50px;margin-bottom:20px}.section-heading h2{margin:0}.section-heading>span{font-size:12px}.count{font-size:12px;background:#e5ebdf;padding:4px 8px;border-radius:6px;margin-left:8px}.page-list{display:grid;gap:13px}.page-card{display:flex;gap:20px;align-items:center;background:white;padding:24px;border:1px solid var(--line);border-radius:10px}.page-icon{font-size:25px;width:44px;height:50px;background:#f0f4e9;border-radius:8px;display:grid;place-items:center}.page-info{flex:1;min-width:0}.page-info h3{margin:0 0 9px}.page-info h3 a{text-decoration:none}.page-info p{font-size:12px;color:var(--muted);margin:0}.badge{font-size:10px;letter-spacing:.2px;background:#fff0d5;color:#78551d;padding:7px 10px;border-radius:20px;white-space:nowrap}.published{background:#edf3dc;color:#315b49}.footnote{font-size:11px;color:var(--muted);margin-top:27px}.empty-state{padding:35px;border:1px dashed var(--line);border-radius:10px}.editor-body{overflow:hidden}#editor{height:100dvh;display:flex;flex-direction:column}.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.editor-page-name>span{font-size:10px;color:var(--muted)}.history-actions,.save-actions{display:flex;align-items:center;gap:8px}.icon-button{font-size:21px;min-width:36px;padding:3px 9px}.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}Line truncated
|
||||||
|
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
|
||||||
|
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.admin-header{padding:20px}.header-actions{gap:12px}.header-actions .user-name{display:none}.page-card{flex-wrap:wrap;padding:18px;gap:14px}.page-info{min-width:180px}.pages-main{margin:40px auto;padding:0 20px}.section-heading{align-items:flex-start;flex-direction:column}.login-card{padding:30px}.login-body{padding:20px}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
|
||||||
|
@media(prefers-reduced-motion:no-preference){button,a{transition:background-color .12s,border-color .12s}}
|
||||||
|
.left-panel{display:flex;flex-direction:column;overflow:hidden}.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}.structure-section .block-actions{flex-shrink:0;background:white}.structure-section .panel-hint{margin:8px 0}.search-label{font-size:11px;margin:12px 0 6px}#tree-search{font-size:11px;padding:9px;min-height:36px}.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}.tree-tools .text-button{font-size:10px;padding:5px}.structure-list li{position:relative}.tree-item{padding-left:23px}.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}.preset-heading{font-size:12px;margin:22px 0 8px}.preset-library{display:grid;gap:6px}.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}.preset-library button:hover:not(:disabled){border-color:var(--accent)}.preset-library span{font-size:18px}.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}.design-reset{margin-top:24px;white-space:normal;width:100%}.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}.breadcrumbs .text-button{font-size:10px;white-space:normal}.breadcrumbs button+button:before{content:'›';color:var(--muted)}.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}.canvas-scroll{display:block}.canvas-scroll #preview-stage{position:relative;margin:0 auto}#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}.right-panel select{padding-inline:7px;text-overflow:ellipsis}.right-panel .panel-section{min-width:0}
|
||||||
|
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"align": {
|
||||||
|
"label": "Ausrichtung",
|
||||||
|
"types": [
|
||||||
|
"section",
|
||||||
|
"row",
|
||||||
|
"column",
|
||||||
|
"heading",
|
||||||
|
"text"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"left": [
|
||||||
|
"Links",
|
||||||
|
"text-align:left;margin-inline:0"
|
||||||
|
],
|
||||||
|
"center": [
|
||||||
|
"Mittig",
|
||||||
|
"text-align:center;margin-inline:auto"
|
||||||
|
],
|
||||||
|
"right": [
|
||||||
|
"Rechts",
|
||||||
|
"text-align:right;margin-inline:auto 0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"label": "Schriftgröße",
|
||||||
|
"types": [
|
||||||
|
"heading",
|
||||||
|
"text",
|
||||||
|
"button"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"small": [
|
||||||
|
"Klein · 14 px",
|
||||||
|
"font-size:14px"
|
||||||
|
],
|
||||||
|
"body": [
|
||||||
|
"Fließtext · 18 px",
|
||||||
|
"font-size:18px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 24 px",
|
||||||
|
"font-size:24px"
|
||||||
|
],
|
||||||
|
"title": [
|
||||||
|
"Titel · 36 px",
|
||||||
|
"font-size:36px"
|
||||||
|
],
|
||||||
|
"display": [
|
||||||
|
"Display · 52 px",
|
||||||
|
"font-size:52px"
|
||||||
|
],
|
||||||
|
"hero": [
|
||||||
|
"Hero · 72 px",
|
||||||
|
"font-size:72px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fontWeight": {
|
||||||
|
"label": "Schriftstärke",
|
||||||
|
"types": [
|
||||||
|
"heading",
|
||||||
|
"text",
|
||||||
|
"button"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"regular": [
|
||||||
|
"Normal",
|
||||||
|
"font-weight:400"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel",
|
||||||
|
"font-weight:500"
|
||||||
|
],
|
||||||
|
"bold": [
|
||||||
|
"Fett",
|
||||||
|
"font-weight:700"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lineHeight": {
|
||||||
|
"label": "Zeilenhöhe",
|
||||||
|
"types": [
|
||||||
|
"heading",
|
||||||
|
"text",
|
||||||
|
"button"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"tight": [
|
||||||
|
"Kompakt · 1,15",
|
||||||
|
"line-height:1.15"
|
||||||
|
],
|
||||||
|
"normal": [
|
||||||
|
"Normal · 1,5",
|
||||||
|
"line-height:1.5"
|
||||||
|
],
|
||||||
|
"relaxed": [
|
||||||
|
"Luftig · 1,8",
|
||||||
|
"line-height:1.8"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paddingY": {
|
||||||
|
"label": "Innenabstand oben / unten",
|
||||||
|
"types": [
|
||||||
|
"section",
|
||||||
|
"column"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Keiner",
|
||||||
|
"padding-block:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Klein · 16 px",
|
||||||
|
"padding-block:16px"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel · 32 px",
|
||||||
|
"padding-block:32px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 64 px",
|
||||||
|
"padding-block:64px"
|
||||||
|
],
|
||||||
|
"huge": [
|
||||||
|
"Extra groß · 96 px",
|
||||||
|
"padding-block:96px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paddingX": {
|
||||||
|
"label": "Innenabstand links / rechts",
|
||||||
|
"types": [
|
||||||
|
"column"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Keiner",
|
||||||
|
"padding-inline:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Klein · 16 px",
|
||||||
|
"padding-inline:16px"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel · 32 px",
|
||||||
|
"padding-inline:32px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 64 px",
|
||||||
|
"padding-inline:64px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marginTop": {
|
||||||
|
"label": "Außenabstand oben",
|
||||||
|
"types": [
|
||||||
|
"section",
|
||||||
|
"row",
|
||||||
|
"column",
|
||||||
|
"heading",
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"button",
|
||||||
|
"gallery"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Keiner",
|
||||||
|
"margin-top:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Klein · 8 px",
|
||||||
|
"margin-top:8px"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel · 24 px",
|
||||||
|
"margin-top:24px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 48 px",
|
||||||
|
"margin-top:48px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marginBottom": {
|
||||||
|
"label": "Außenabstand unten",
|
||||||
|
"types": [
|
||||||
|
"section",
|
||||||
|
"row",
|
||||||
|
"column",
|
||||||
|
"heading",
|
||||||
|
"text",
|
||||||
|
"image",
|
||||||
|
"button",
|
||||||
|
"gallery"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Keiner",
|
||||||
|
"margin-bottom:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Klein · 8 px",
|
||||||
|
"margin-bottom:8px"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel · 24 px",
|
||||||
|
"margin-bottom:24px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 48 px",
|
||||||
|
"margin-bottom:48px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gap": {
|
||||||
|
"label": "Spaltenabstand",
|
||||||
|
"types": [
|
||||||
|
"row",
|
||||||
|
"gallery"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Keiner",
|
||||||
|
"gap:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Klein · 12 px",
|
||||||
|
"gap:12px"
|
||||||
|
],
|
||||||
|
"medium": [
|
||||||
|
"Mittel · 24 px",
|
||||||
|
"gap:24px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Groß · 48 px",
|
||||||
|
"gap:48px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"label": "Spaltenaufteilung",
|
||||||
|
"types": [
|
||||||
|
"row",
|
||||||
|
"gallery"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"auto": [
|
||||||
|
"Automatisch umbrechen",
|
||||||
|
"grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))"
|
||||||
|
],
|
||||||
|
"one": [
|
||||||
|
"1 Spalte",
|
||||||
|
"grid-template-columns:minmax(0,1fr)"
|
||||||
|
],
|
||||||
|
"two": [
|
||||||
|
"2 gleiche Spalten",
|
||||||
|
"grid-template-columns:repeat(2,minmax(0,1fr))"
|
||||||
|
],
|
||||||
|
"three": [
|
||||||
|
"3 gleiche Spalten",
|
||||||
|
"grid-template-columns:repeat(3,minmax(0,1fr))"
|
||||||
|
],
|
||||||
|
"four": [
|
||||||
|
"4 gleiche Spalten",
|
||||||
|
"grid-template-columns:repeat(4,minmax(0,1fr))"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"radius": {
|
||||||
|
"label": "Ecken",
|
||||||
|
"types": [
|
||||||
|
"column",
|
||||||
|
"image",
|
||||||
|
"button"
|
||||||
|
],
|
||||||
|
"values": {
|
||||||
|
"none": [
|
||||||
|
"Gerade",
|
||||||
|
"border-radius:0"
|
||||||
|
],
|
||||||
|
"small": [
|
||||||
|
"Leicht · 8 px",
|
||||||
|
"border-radius:8px"
|
||||||
|
],
|
||||||
|
"large": [
|
||||||
|
"Rund · 24 px",
|
||||||
|
"border-radius:24px"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/* Generated from design-tokens.json by npm run build:design. */
|
||||||
|
#main .d-desktop-align-left{text-align:left;margin-inline:0}
|
||||||
|
#main .d-desktop-align-center{text-align:center;margin-inline:auto}
|
||||||
|
#main .d-desktop-align-right{text-align:right;margin-inline:auto 0}
|
||||||
|
#main .d-desktop-fontSize-small{font-size:14px}
|
||||||
|
#main .d-desktop-fontSize-body{font-size:18px}
|
||||||
|
#main .d-desktop-fontSize-large{font-size:24px}
|
||||||
|
#main .d-desktop-fontSize-title{font-size:36px}
|
||||||
|
#main .d-desktop-fontSize-display{font-size:52px}
|
||||||
|
#main .d-desktop-fontSize-hero{font-size:72px}
|
||||||
|
#main .d-desktop-fontWeight-regular{font-weight:400}
|
||||||
|
#main .d-desktop-fontWeight-medium{font-weight:500}
|
||||||
|
#main .d-desktop-fontWeight-bold{font-weight:700}
|
||||||
|
#main .d-desktop-lineHeight-tight{line-height:1.15}
|
||||||
|
#main .d-desktop-lineHeight-normal{line-height:1.5}
|
||||||
|
#main .d-desktop-lineHeight-relaxed{line-height:1.8}
|
||||||
|
#main .d-desktop-paddingY-none{padding-block:0}
|
||||||
|
#main .d-desktop-paddingY-small{padding-block:16px}
|
||||||
|
#main .d-desktop-paddingY-medium{padding-block:32px}
|
||||||
|
#main .d-desktop-paddingY-large{padding-block:64px}
|
||||||
|
#main .d-desktop-paddingY-huge{padding-block:96px}
|
||||||
|
#main .d-desktop-paddingX-none{padding-inline:0}
|
||||||
|
#main .d-desktop-paddingX-small{padding-inline:16px}
|
||||||
|
#main .d-desktop-paddingX-medium{padding-inline:32px}
|
||||||
|
#main .d-desktop-paddingX-large{padding-inline:64px}
|
||||||
|
#main .d-desktop-marginTop-none{margin-top:0}
|
||||||
|
#main .d-desktop-marginTop-small{margin-top:8px}
|
||||||
|
#main .d-desktop-marginTop-medium{margin-top:24px}
|
||||||
|
#main .d-desktop-marginTop-large{margin-top:48px}
|
||||||
|
#main .d-desktop-marginBottom-none{margin-bottom:0}
|
||||||
|
#main .d-desktop-marginBottom-small{margin-bottom:8px}
|
||||||
|
#main .d-desktop-marginBottom-medium{margin-bottom:24px}
|
||||||
|
#main .d-desktop-marginBottom-large{margin-bottom:48px}
|
||||||
|
#main .d-desktop-gap-none{gap:0}
|
||||||
|
#main .d-desktop-gap-small{gap:12px}
|
||||||
|
#main .d-desktop-gap-medium{gap:24px}
|
||||||
|
#main .d-desktop-gap-large{gap:48px}
|
||||||
|
#main .d-desktop-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||||
|
#main .d-desktop-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||||
|
#main .d-desktop-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
#main .d-desktop-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
#main .d-desktop-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
#main .d-desktop-radius-none{border-radius:0}
|
||||||
|
#main .d-desktop-radius-small{border-radius:8px}
|
||||||
|
#main .d-desktop-radius-large{border-radius:24px}
|
||||||
|
@media(max-width:1024px){
|
||||||
|
#main .d-tablet-align-left{text-align:left;margin-inline:0}
|
||||||
|
#main .d-tablet-align-center{text-align:center;margin-inline:auto}
|
||||||
|
#main .d-tablet-align-right{text-align:right;margin-inline:auto 0}
|
||||||
|
#main .d-tablet-fontSize-small{font-size:14px}
|
||||||
|
#main .d-tablet-fontSize-body{font-size:18px}
|
||||||
|
#main .d-tablet-fontSize-large{font-size:24px}
|
||||||
|
#main .d-tablet-fontSize-title{font-size:36px}
|
||||||
|
#main .d-tablet-fontSize-display{font-size:52px}
|
||||||
|
#main .d-tablet-fontSize-hero{font-size:72px}
|
||||||
|
#main .d-tablet-fontWeight-regular{font-weight:400}
|
||||||
|
#main .d-tablet-fontWeight-medium{font-weight:500}
|
||||||
|
#main .d-tablet-fontWeight-bold{font-weight:700}
|
||||||
|
#main .d-tablet-lineHeight-tight{line-height:1.15}
|
||||||
|
#main .d-tablet-lineHeight-normal{line-height:1.5}
|
||||||
|
#main .d-tablet-lineHeight-relaxed{line-height:1.8}
|
||||||
|
#main .d-tablet-paddingY-none{padding-block:0}
|
||||||
|
#main .d-tablet-paddingY-small{padding-block:16px}
|
||||||
|
#main .d-tablet-paddingY-medium{padding-block:32px}
|
||||||
|
#main .d-tablet-paddingY-large{padding-block:64px}
|
||||||
|
#main .d-tablet-paddingY-huge{padding-block:96px}
|
||||||
|
#main .d-tablet-paddingX-none{padding-inline:0}
|
||||||
|
#main .d-tablet-paddingX-small{padding-inline:16px}
|
||||||
|
#main .d-tablet-paddingX-medium{padding-inline:32px}
|
||||||
|
#main .d-tablet-paddingX-large{padding-inline:64px}
|
||||||
|
#main .d-tablet-marginTop-none{margin-top:0}
|
||||||
|
#main .d-tablet-marginTop-small{margin-top:8px}
|
||||||
|
#main .d-tablet-marginTop-medium{margin-top:24px}
|
||||||
|
#main .d-tablet-marginTop-large{margin-top:48px}
|
||||||
|
#main .d-tablet-marginBottom-none{margin-bottom:0}
|
||||||
|
#main .d-tablet-marginBottom-small{margin-bottom:8px}
|
||||||
|
#main .d-tablet-marginBottom-medium{margin-bottom:24px}
|
||||||
|
#main .d-tablet-marginBottom-large{margin-bottom:48px}
|
||||||
|
#main .d-tablet-gap-none{gap:0}
|
||||||
|
#main .d-tablet-gap-small{gap:12px}
|
||||||
|
#main .d-tablet-gap-medium{gap:24px}
|
||||||
|
#main .d-tablet-gap-large{gap:48px}
|
||||||
|
#main .d-tablet-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||||
|
#main .d-tablet-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||||
|
#main .d-tablet-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
#main .d-tablet-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
#main .d-tablet-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
#main .d-tablet-radius-none{border-radius:0}
|
||||||
|
#main .d-tablet-radius-small{border-radius:8px}
|
||||||
|
#main .d-tablet-radius-large{border-radius:24px}
|
||||||
|
}
|
||||||
|
@media(max-width:650px){
|
||||||
|
#main .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||||
|
#main .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||||
|
#main .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||||
|
#main .d-mobile-fontSize-small{font-size:14px}
|
||||||
|
#main .d-mobile-fontSize-body{font-size:18px}
|
||||||
|
#main .d-mobile-fontSize-large{font-size:24px}
|
||||||
|
#main .d-mobile-fontSize-title{font-size:36px}
|
||||||
|
#main .d-mobile-fontSize-display{font-size:52px}
|
||||||
|
#main .d-mobile-fontSize-hero{font-size:72px}
|
||||||
|
#main .d-mobile-fontWeight-regular{font-weight:400}
|
||||||
|
#main .d-mobile-fontWeight-medium{font-weight:500}
|
||||||
|
#main .d-mobile-fontWeight-bold{font-weight:700}
|
||||||
|
#main .d-mobile-lineHeight-tight{line-height:1.15}
|
||||||
|
#main .d-mobile-lineHeight-normal{line-height:1.5}
|
||||||
|
#main .d-mobile-lineHeight-relaxed{line-height:1.8}
|
||||||
|
#main .d-mobile-paddingY-none{padding-block:0}
|
||||||
|
#main .d-mobile-paddingY-small{padding-block:16px}
|
||||||
|
#main .d-mobile-paddingY-medium{padding-block:32px}
|
||||||
|
#main .d-mobile-paddingY-large{padding-block:64px}
|
||||||
|
#main .d-mobile-paddingY-huge{padding-block:96px}
|
||||||
|
#main .d-mobile-paddingX-none{padding-inline:0}
|
||||||
|
#main .d-mobile-paddingX-small{padding-inline:16px}
|
||||||
|
#main .d-mobile-paddingX-medium{padding-inline:32px}
|
||||||
|
#main .d-mobile-paddingX-large{padding-inline:64px}
|
||||||
|
#main .d-mobile-marginTop-none{margin-top:0}
|
||||||
|
#main .d-mobile-marginTop-small{margin-top:8px}
|
||||||
|
#main .d-mobile-marginTop-medium{margin-top:24px}
|
||||||
|
#main .d-mobile-marginTop-large{margin-top:48px}
|
||||||
|
#main .d-mobile-marginBottom-none{margin-bottom:0}
|
||||||
|
#main .d-mobile-marginBottom-small{margin-bottom:8px}
|
||||||
|
#main .d-mobile-marginBottom-medium{margin-bottom:24px}
|
||||||
|
#main .d-mobile-marginBottom-large{margin-bottom:48px}
|
||||||
|
#main .d-mobile-gap-none{gap:0}
|
||||||
|
#main .d-mobile-gap-small{gap:12px}
|
||||||
|
#main .d-mobile-gap-medium{gap:24px}
|
||||||
|
#main .d-mobile-gap-large{gap:48px}
|
||||||
|
#main .d-mobile-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
|
||||||
|
#main .d-mobile-columns-one{grid-template-columns:minmax(0,1fr)}
|
||||||
|
#main .d-mobile-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
|
#main .d-mobile-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||||
|
#main .d-mobile-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||||
|
#main .d-mobile-radius-none{border-radius:0}
|
||||||
|
#main .d-mobile-radius-small{border-radius:8px}
|
||||||
|
#main .d-mobile-radius-large{border-radius:24px}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie' };
|
||||||
|
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦' };
|
||||||
|
export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type);
|
||||||
|
export const canContain = (parent, block) => isContainer(parent) && (parent.type !== 'gallery' || block.type === 'image');
|
||||||
|
export const clone = value => structuredClone(value);
|
||||||
|
|
||||||
|
export function locate(blocks, id, parent = null) {
|
||||||
|
for (let index = 0; index < blocks.length; index++) {
|
||||||
|
const block = blocks[index];
|
||||||
|
if (block.id === id) return { block, siblings: blocks, index, parent };
|
||||||
|
const child = locate(block.children || [], id, block);
|
||||||
|
if (child) return child;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBlock(type) {
|
||||||
|
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
||||||
|
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
||||||
|
if (isContainer(block)) block.children = [];
|
||||||
|
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
||||||
|
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
||||||
|
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
||||||
|
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBlock(document, type, selected) {
|
||||||
|
const block = createBlock(type);
|
||||||
|
if (type === 'gallery') upgradeDocument(document, 3);
|
||||||
|
let target = locate(document.blocks, selected);
|
||||||
|
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||||
|
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||||
|
if (!target) document.blocks.push(block);
|
||||||
|
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
||||||
|
else target.siblings.splice(target.index + 1, 0, block);
|
||||||
|
return block.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplicateBlock(document, selected) {
|
||||||
|
const target = locate(document.blocks, selected);
|
||||||
|
if (!target) return null;
|
||||||
|
const copy = clone(target.block);
|
||||||
|
const rekey = block => {
|
||||||
|
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||||
|
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
|
||||||
|
(block.children || []).forEach(rekey);
|
||||||
|
};
|
||||||
|
rekey(copy);
|
||||||
|
target.siblings.splice(target.index + 1, 0, copy);
|
||||||
|
return copy.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveBlock(document, selected, targetId, inside = false) {
|
||||||
|
const source = locate(document.blocks, selected);
|
||||||
|
const target = locate(document.blocks, targetId);
|
||||||
|
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||||
|
if (inside && !isContainer(target.block)) return false;
|
||||||
|
if (inside && !canContain(target.block, source.block)) return false;
|
||||||
|
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||||
|
source.siblings.splice(source.index, 1);
|
||||||
|
const destination = locate(document.blocks, targetId);
|
||||||
|
if (inside) (destination.block.children ??= []).push(source.block);
|
||||||
|
else destination.siblings.splice(destination.index, 0, source.block);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||||
|
|
||||||
|
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||||
|
export function upgradeDocument(document, version = 2) {
|
||||||
|
if (![1, 2, 3].includes(document.schemaVersion) || ![2, 3].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||||
|
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function effectiveDesign(block, device, key) {
|
||||||
|
let value, source;
|
||||||
|
for (const name of Object.keys(devices)) {
|
||||||
|
if (block.layout?.design?.[name]?.[key] !== undefined) {
|
||||||
|
value = block.layout.design[name][key]; source = name;
|
||||||
|
}
|
||||||
|
if (name === device) break;
|
||||||
|
}
|
||||||
|
return { value, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDesign(document, id, device, key, value) {
|
||||||
|
if (![1, 2, 3].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||||
|
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||||
|
const block = locate(document.blocks, id)?.block;
|
||||||
|
if (!block) return;
|
||||||
|
if (value === '') {
|
||||||
|
const design = block.layout?.design;
|
||||||
|
if (!design?.[device] || !Object.hasOwn(design[device], key)) return;
|
||||||
|
delete design[device][key];
|
||||||
|
if (!Object.keys(design[device]).length) delete design[device];
|
||||||
|
if (!Object.keys(design).length) delete block.layout.design;
|
||||||
|
} else {
|
||||||
|
upgradeDocument(document);
|
||||||
|
((block.layout ??= {}).design ??= {})[device] ??= {};
|
||||||
|
block.layout.design[device][key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockPath(blocks, id) {
|
||||||
|
const found = locate(blocks, id);
|
||||||
|
return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEffect(document, id, key, value) {
|
||||||
|
const block = locate(document.blocks, id)?.block;
|
||||||
|
if (!block) return;
|
||||||
|
upgradeDocument(document, 3);
|
||||||
|
((block.layout ??= {}).effects ??= {})[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaProps(media) {
|
||||||
|
const largest = media.variants.at(-1);
|
||||||
|
return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertMedia(document, selected, items) {
|
||||||
|
const target = locate(document.blocks, selected)?.block;
|
||||||
|
upgradeDocument(document, 3);
|
||||||
|
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||||
|
let parent = target;
|
||||||
|
if (target?.type !== 'gallery') {
|
||||||
|
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||||
|
}
|
||||||
|
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||||
|
return parent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertPreset(document, name, selected) {
|
||||||
|
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||||
|
const section = createBlock('section');
|
||||||
|
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
||||||
|
const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; };
|
||||||
|
section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' };
|
||||||
|
section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')];
|
||||||
|
if (name === 'columns') {
|
||||||
|
const row = createBlock('row');
|
||||||
|
row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } };
|
||||||
|
row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => {
|
||||||
|
const column = createBlock('column');
|
||||||
|
const title = heading(text); title.props.level = 3;
|
||||||
|
column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')];
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
section.children.push(row);
|
||||||
|
upgradeDocument(document);
|
||||||
|
} else section.children.push(createBlock('button'));
|
||||||
|
const top = blockPath(document.blocks, selected)[0];
|
||||||
|
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||||
|
document.blocks.splice(index, 0, section);
|
||||||
|
return section.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EditorHistory {
|
||||||
|
constructor(document) { this.reset(document); }
|
||||||
|
reset(document) {
|
||||||
|
this.document = clone(document);
|
||||||
|
this.past = [];
|
||||||
|
this.future = [];
|
||||||
|
this.mergeKey = null;
|
||||||
|
this.saved = JSON.stringify(document);
|
||||||
|
}
|
||||||
|
change(mutator, mergeKey = null) {
|
||||||
|
const next = clone(this.document);
|
||||||
|
const result = mutator(next);
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(this.document)) return result;
|
||||||
|
if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document);
|
||||||
|
if (this.past.length > 100) this.past.shift();
|
||||||
|
this.document = next;
|
||||||
|
this.future = [];
|
||||||
|
this.mergeKey = mergeKey;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
if (!this.past.length) return;
|
||||||
|
this.future.push(this.document);
|
||||||
|
this.document = this.past.pop();
|
||||||
|
this.mergeKey = null;
|
||||||
|
}
|
||||||
|
redo() {
|
||||||
|
if (!this.future.length) return;
|
||||||
|
this.past.push(this.document);
|
||||||
|
this.document = this.future.pop();
|
||||||
|
this.mergeKey = null;
|
||||||
|
}
|
||||||
|
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
|
||||||
|
get dirty() { return JSON.stringify(this.document) !== this.saved; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { EditorHistory, labels, icons, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||||
|
import { openMedia } from './media.js';
|
||||||
|
|
||||||
|
const root = document.querySelector('#editor');
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
|
||||||
|
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||||
|
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||||
|
let device = 'desktop', propertyPanel = 'content', designTokens;
|
||||||
|
const collapsed = new Set();
|
||||||
|
|
||||||
|
function notify(message = '', recover = false) {
|
||||||
|
$('#message').textContent = message;
|
||||||
|
$('#message').hidden = !message;
|
||||||
|
$('#recovery').hidden = !recover;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, body, signal) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(api + path, body === undefined ? { credentials: 'same-origin' } : {
|
||||||
|
method: 'POST', credentials: 'same-origin', signal,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') throw error;
|
||||||
|
throw new Error('Die Verbindung ist unterbrochen. Deine Änderungen bleiben im Editor. Bitte erneut versuchen oder als JSON sichern.');
|
||||||
|
}
|
||||||
|
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Sichere deine Änderungen und melde dich erneut an.');
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 409) conflict = true;
|
||||||
|
let message = response.status === 403 ? 'Für diese Aktion fehlt die Berechtigung oder die Sitzung ist abgelaufen.' : `Die Anfrage ist fehlgeschlagen (${response.status}). Deine Änderungen bleiben im Editor.`;
|
||||||
|
if (response.headers.get('content-type')?.includes('application/json')) message = (await response.json()).error || message;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return response.headers.get('content-type')?.includes('application/json') ? response.json() : response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function controls() {
|
||||||
|
const ready = Boolean(history);
|
||||||
|
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
|
||||||
|
$('#save').disabled = !ready || busy || conflict || !history.dirty;
|
||||||
|
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
|
||||||
|
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||||
|
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||||
|
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||||
|
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent']) $(`#${id}`).disabled = !current || busy;
|
||||||
|
if (current) {
|
||||||
|
$('#move-up').disabled ||= current.index === 0;
|
||||||
|
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||||
|
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||||
|
$('#outdent').disabled ||= !current.parent;
|
||||||
|
}
|
||||||
|
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||||
|
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||||
|
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||||
|
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||||
|
$('[data-panel="design"]').disabled ||= !current;
|
||||||
|
$('#select-page').disabled = !ready || busy;
|
||||||
|
$('#open-media-library').disabled = !ready || busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
|
||||||
|
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
|
||||||
|
const matches = block => `${labels[block.type]} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
|
||||||
|
const build = blocks => {
|
||||||
|
const list = document.createElement('ul'); list.className = 'structure-list';
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (query && !matches(block)) continue;
|
||||||
|
const item = document.createElement('li');
|
||||||
|
if (block.children?.length) {
|
||||||
|
const toggle = document.createElement('button'); toggle.className = 'tree-toggle'; toggle.type = 'button';
|
||||||
|
toggle.textContent = collapsed.has(block.id) && !query ? '▸' : '▾';
|
||||||
|
toggle.dataset.toggle = block.id;
|
||||||
|
toggle.setAttribute('aria-label', `${labels[block.type]} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`);
|
||||||
|
toggle.setAttribute('aria-expanded', String(!collapsed.has(block.id) || Boolean(query)));
|
||||||
|
toggle.disabled = Boolean(query); item.append(toggle);
|
||||||
|
}
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
|
||||||
|
button.dataset.blockId = block.id;
|
||||||
|
button.setAttribute('aria-pressed', String(block.id === selected));
|
||||||
|
button.setAttribute('aria-label', `${labels[block.type]}: ${block.props?.text || block.props?.alt || labels[block.type]}`);
|
||||||
|
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[block.type];
|
||||||
|
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || labels[block.type];
|
||||||
|
button.append(icon, text); item.append(button);
|
||||||
|
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
$('#structure').replaceChildren(build(history.document.blocks));
|
||||||
|
if (!$('#structure .tree-item')) { const empty = document.createElement('p'); empty.className = 'panel-hint'; empty.textContent = 'Keine passenden Elemente.'; $('#structure').append(empty); }
|
||||||
|
if (activeId) Array.from($('#structure').querySelectorAll('button')).find(button => button.dataset.blockId === activeId)?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(id, scroll = true) {
|
||||||
|
if (busy) return;
|
||||||
|
selected = locate(history.document.blocks, id) ? id : null;
|
||||||
|
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
|
||||||
|
if (!selected) propertyPanel = 'content';
|
||||||
|
history.mergeKey = null;
|
||||||
|
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(scroll = false) {
|
||||||
|
const doc = $('#preview').contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||||
|
const element = selected ? doc.getElementById(selected) : null;
|
||||||
|
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProperties() {
|
||||||
|
const block = locate(history.document.blocks, selected)?.block;
|
||||||
|
if (!block) propertyPanel = 'content';
|
||||||
|
$('#properties-title').textContent = block ? labels[block.type] : 'Seiteneinstellungen';
|
||||||
|
const panel = $('#properties'); panel.replaceChildren();
|
||||||
|
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
|
||||||
|
$('#breadcrumbs').replaceChildren();
|
||||||
|
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
|
||||||
|
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? labels[item.type] : 'Seite';
|
||||||
|
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
|
||||||
|
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||||||
|
}
|
||||||
|
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
|
||||||
|
const field = (label, key, value, options = {}) => {
|
||||||
|
const id = `property-${key}`;
|
||||||
|
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
|
||||||
|
let input;
|
||||||
|
if (options.choices) {
|
||||||
|
input = document.createElement('select');
|
||||||
|
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||||||
|
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||||
|
input.id = id; input.value = value ?? '';
|
||||||
|
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||||
|
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||||
|
if (options.maxLength) input.maxLength = options.maxLength;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
|
||||||
|
history.change(doc => {
|
||||||
|
const current = selected ? locate(doc.blocks, selected)?.block : doc;
|
||||||
|
if (!current) return;
|
||||||
|
if (options.layout) (current.layout ??= {})[key] = next;
|
||||||
|
else if (selected) { (current.props ??= {})[key] = next; if (key === 'decorative' && next) current.props.alt = ''; }
|
||||||
|
else current[key] = next;
|
||||||
|
if (key === 'caption') upgradeDocument(doc, 3);
|
||||||
|
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||||
|
}, `${selected || 'page'}:${key}`);
|
||||||
|
if (key === 'decorative') renderProperties();
|
||||||
|
renderTree(); controls(); schedulePreview();
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||||
|
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
|
||||||
|
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); }
|
||||||
|
};
|
||||||
|
if (!block) {
|
||||||
|
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||||
|
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||||
|
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||||
|
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||||
|
if (block.type === 'image') {
|
||||||
|
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||||
|
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||||
|
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
|
||||||
|
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
|
||||||
|
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
|
||||||
|
field('Originalbreite (px)', 'width', block.props.width, { number: true });
|
||||||
|
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
|
||||||
|
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||||
|
}
|
||||||
|
if (block.type === 'gallery') {
|
||||||
|
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||||
|
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||||
|
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||||
|
}
|
||||||
|
if (block.type === 'section') {
|
||||||
|
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
|
||||||
|
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal', narrow: 'Schmal' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDesign(block, panel) {
|
||||||
|
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||||||
|
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
|
||||||
|
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||||||
|
panel.append(hint, help);
|
||||||
|
for (const [key, token] of Object.entries(designTokens)) {
|
||||||
|
if (!token.types.includes(block.type)) continue;
|
||||||
|
const label = document.createElement('label'); label.textContent = token.label; label.htmlFor = `design-${key}`;
|
||||||
|
const select = document.createElement('select'); select.id = label.htmlFor;
|
||||||
|
const previous = device === 'mobile' ? effectiveDesign(block, 'tablet', key) : device === 'tablet' ? effectiveDesign(block, 'desktop', key) : {};
|
||||||
|
const inherited = previous.value ? `${token.values[previous.value][0]} von ${devices[previous.source]}` : 'Theme-Standard';
|
||||||
|
for (const [value, text] of [['', `Übernehmen: ${inherited}`], ...Object.entries(token.values).map(([value, [label]]) => [value, label])]) {
|
||||||
|
const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option);
|
||||||
|
}
|
||||||
|
select.value = block.layout?.design?.[device]?.[key] || '';
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
history.change(doc => setDesign(doc, selected, device, key, select.value));
|
||||||
|
controls(); schedulePreview();
|
||||||
|
});
|
||||||
|
panel.append(label, select);
|
||||||
|
}
|
||||||
|
const reset = document.createElement('button'); reset.className = 'secondary design-reset'; reset.textContent = `${devices[device]}-Werte zurücksetzen`;
|
||||||
|
reset.addEventListener('click', () => {
|
||||||
|
history.change(doc => { const current = locate(doc.blocks, selected).block; for (const key of Object.keys(current.layout?.design?.[device] || {})) setDesign(doc, selected, device, key, ''); });
|
||||||
|
changed();
|
||||||
|
}); panel.append(reset);
|
||||||
|
const heading = document.createElement('h3'); heading.className = 'preset-heading'; heading.textContent = 'Effekte · alle Geräte'; panel.append(heading);
|
||||||
|
for (const [key, label, choices] of [
|
||||||
|
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
|
||||||
|
['hover', 'Hover-Effekt', { none: 'Keiner', lift: 'Leicht anheben', zoom: 'Bild vergrößern' }],
|
||||||
|
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
|
||||||
|
]) {
|
||||||
|
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
|
||||||
|
const select = document.createElement('select'); select.id = labelEl.htmlFor;
|
||||||
|
for (const [value, title] of Object.entries(choices)) { const option = document.createElement('option'); option.value = value; option.textContent = title; select.append(option); }
|
||||||
|
select.value = block.layout?.effects?.[key] || 'none';
|
||||||
|
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 = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; panel.append(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseMedia(multiple) {
|
||||||
|
const target = selected;
|
||||||
|
openMedia({ multiple, onChoose: items => {
|
||||||
|
if (!items.length || busy) return;
|
||||||
|
selected = history.change(doc => insertMedia(doc, target, items)); changed();
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizePreview() {
|
||||||
|
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
|
||||||
|
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
|
||||||
|
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
|
||||||
|
const available = Math.max(100, container.clientWidth - padding);
|
||||||
|
const scale = Math.min(1, available / width);
|
||||||
|
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||||
|
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||||||
|
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||||||
|
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePreview() {
|
||||||
|
clearTimeout(previewTimer);
|
||||||
|
// Invalidate an in-flight response immediately, including during the debounce window.
|
||||||
|
previewSequence++;
|
||||||
|
previewController?.abort();
|
||||||
|
$('#preview-status').textContent = 'Vorschau wird aktualisiert …';
|
||||||
|
previewTimer = setTimeout(updatePreview, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePreview() {
|
||||||
|
const sequence = previewSequence;
|
||||||
|
const controller = new AbortController(); previewController = controller;
|
||||||
|
try {
|
||||||
|
const html = await request('/preview', { document: history.document }, controller.signal);
|
||||||
|
if (sequence !== previewSequence) return;
|
||||||
|
const frame = $('#preview');
|
||||||
|
const top = frame.contentWindow?.scrollY || 0;
|
||||||
|
frame.onload = () => {
|
||||||
|
if (sequence !== previewSequence) return;
|
||||||
|
const doc = frame.contentDocument;
|
||||||
|
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
|
||||||
|
doc.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const element = event.target.closest('[id]');
|
||||||
|
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
|
||||||
|
});
|
||||||
|
doc.addEventListener('submit', event => event.preventDefault());
|
||||||
|
frame.contentWindow.scrollTo(0, top); highlight();
|
||||||
|
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||||
|
};
|
||||||
|
frame.srcdoc = html;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError' || sequence !== previewSequence) return;
|
||||||
|
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
if (!locate(history.document.blocks, selected)) selected = null;
|
||||||
|
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
busy = true; controls();
|
||||||
|
try {
|
||||||
|
const data = await request('');
|
||||||
|
if (!designTokens) {
|
||||||
|
const response = await fetch('/assets/design-tokens.json');
|
||||||
|
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
|
||||||
|
designTokens = await response.json();
|
||||||
|
}
|
||||||
|
history = new EditorHistory(data.document);
|
||||||
|
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||||
|
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||||
|
} catch (error) { notify(error.message, true); }
|
||||||
|
finally { busy = false; controls(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(publish = false) {
|
||||||
|
if (!history || busy || conflict || (publish && history.dirty)) return;
|
||||||
|
const snapshot = clone(history.document);
|
||||||
|
busy = true; controls(); notify();
|
||||||
|
try {
|
||||||
|
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||||||
|
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||||
|
history.markSaved(snapshot);
|
||||||
|
notify();
|
||||||
|
} catch (error) { notify(error.message, true); }
|
||||||
|
finally { busy = false; controls(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#library').addEventListener('click', event => {
|
||||||
|
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||||
|
if (!type || busy || !history) return;
|
||||||
|
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||||
|
});
|
||||||
|
$('#presets').addEventListener('click', event => {
|
||||||
|
const name = event.target.closest('[data-preset]')?.dataset.preset;
|
||||||
|
if (!name || busy || !history) return;
|
||||||
|
selected = history.change(doc => insertPreset(doc, name, selected)); propertyPanel = 'content'; changed();
|
||||||
|
});
|
||||||
|
$('#structure').addEventListener('click', event => {
|
||||||
|
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
|
||||||
|
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
|
||||||
|
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
|
||||||
|
});
|
||||||
|
$('#tree-search').addEventListener('input', renderTree);
|
||||||
|
$('#toggle-library').addEventListener('click', () => {
|
||||||
|
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
|
||||||
|
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
|
||||||
|
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
|
||||||
|
$('#toggle-library').textContent = hidden ? '+' : '−';
|
||||||
|
});
|
||||||
|
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
|
||||||
|
$('#collapse-all').addEventListener('click', () => {
|
||||||
|
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
|
||||||
|
visit(history.document.blocks); renderTree();
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-panel]').forEach(button => button.addEventListener('click', () => { propertyPanel = button.dataset.panel; history.mergeKey = null; renderProperties(); controls(); }));
|
||||||
|
$('#structure').addEventListener('dragstart', event => {
|
||||||
|
if (busy) { event.preventDefault(); return; }
|
||||||
|
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||||
|
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
|
||||||
|
});
|
||||||
|
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||||||
|
$('#structure').addEventListener('drop', event => {
|
||||||
|
event.preventDefault(); if (busy) return;
|
||||||
|
const target = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||||
|
const source = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!target || !source) return;
|
||||||
|
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||||||
|
selected = source; changed();
|
||||||
|
});
|
||||||
|
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
|
||||||
|
$('#delete').addEventListener('click', () => {
|
||||||
|
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
|
||||||
|
});
|
||||||
|
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
|
||||||
|
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
|
||||||
|
});
|
||||||
|
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||||
|
$('#outdent').addEventListener('click', () => {
|
||||||
|
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||||
|
});
|
||||||
|
$('#select-page').addEventListener('click', () => choose(null));
|
||||||
|
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||||
|
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||||
|
$('#save').addEventListener('click', () => save());
|
||||||
|
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
|
||||||
|
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
|
||||||
|
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
|
||||||
|
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
|
||||||
|
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
|
||||||
|
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
|
||||||
|
$('#download').addEventListener('click', () => {
|
||||||
|
if (!history) return;
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
|
||||||
|
$('#preview').className = `viewport-${button.dataset.viewport}`;
|
||||||
|
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
|
||||||
|
}));
|
||||||
|
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
|
||||||
|
window.addEventListener('keydown', event => {
|
||||||
|
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === 's') { event.preventDefault(); save(); }
|
||||||
|
// Keep native text-field undo intact; the toolbar always undoes the complete document.
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
|
||||||
|
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
|
||||||
|
load();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||||
|
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||||
|
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 1000 600"><rect width="1000" height="600" fill="#e9efdf"/><circle cx="735" cy="160" r="70" fill="#b7ca91"/><path d="M0 520 310 200 610 520Z" fill="#7e9b77"/><path d="M370 600 720 280 1000 540V600Z" fill="#385c49"/><path d="M0 515Q450 400 1000 565V600H0Z" fill="#203b34"/></svg>
|
||||||
|
After Width: | Height: | Size: 357 B |
@@ -0,0 +1,101 @@
|
|||||||
|
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
||||||
|
|
||||||
|
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||||
|
const opener = document.activeElement;
|
||||||
|
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||||
|
dialog.setAttribute('aria-labelledby', 'media-title');
|
||||||
|
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">BILDER FÜR DEINE WEBSITE</p><h2 id="media-title">Medienverwaltung</h2></div><button class="secondary" id="media-close">Schließen</button></div>
|
||||||
|
<p class="panel-hint">JPEG, PNG oder WebP · bis 8 MB und 12 Megapixel. Bilder werden optimiert und sind über ihre Bildadresse öffentlich erreichbar. Archivieren erhält bestehende Verwendungen.</p>
|
||||||
|
<div class="media-tools"><div><label for="media-search">Bilder suchen</label><input id="media-search" type="search" placeholder="Titel oder Alternativtext"></div><div><label for="media-upload">Bilder hochladen</label><input id="media-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple></div></div>
|
||||||
|
<label class="check-label"><input id="media-archived" type="checkbox"> Archivierte Bilder anzeigen</label>
|
||||||
|
<p id="media-status" role="status" aria-live="polite">Wird geladen …</p><p id="media-error" role="alert" hidden></p>
|
||||||
|
<div class="media-layout"><div id="media-grid" class="media-grid" role="group" aria-label="Bilder auswählen"></div><div id="media-details" class="media-details"><p>Ein Bild auswählen, um seine Angaben zu bearbeiten.</p></div></div>
|
||||||
|
<div class="media-footer"><label class="check-label" id="decorative-choice"><input id="media-decorative" type="checkbox"> Auswahl in dieser Seite dekorativ verwenden</label><button id="media-insert" class="primary" disabled>Auswahl übernehmen</button></div>`;
|
||||||
|
document.body.append(dialog);
|
||||||
|
const $ = selector => dialog.querySelector(selector);
|
||||||
|
let items = [], selected = new Set(), active = null, token, working = false;
|
||||||
|
const error = message => { $('#media-error').textContent = message; $('#media-error').hidden = !message; };
|
||||||
|
const lock = state => {
|
||||||
|
working = state; dialog.querySelectorAll('button,input').forEach(node => node.disabled = state);
|
||||||
|
$('#media-insert').disabled = state || !selected.size;
|
||||||
|
};
|
||||||
|
async function api(path = '', body) {
|
||||||
|
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
|
||||||
|
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
const focusedMedia = dialog.contains(document.activeElement) ? document.activeElement.dataset.mediaId : null;
|
||||||
|
const query = $('#media-search').value.toLocaleLowerCase('de');
|
||||||
|
const visible = items.filter(item => ($('#media-archived').checked || !item.archived) && `${item.title} ${item.alt}`.toLocaleLowerCase('de').includes(query));
|
||||||
|
$('#media-grid').replaceChildren();
|
||||||
|
for (const item of visible) {
|
||||||
|
const card = element('button', '', 'media-card'); card.type = 'button'; card.dataset.mediaId = item.id;
|
||||||
|
card.setAttribute('aria-label', `${item.title}${item.archived ? ' · Archiviert' : ''}`);
|
||||||
|
card.setAttribute('aria-pressed', String(selected.has(item.id))); card.disabled = working;
|
||||||
|
const image = element('img'); image.src = item.variants[0].src; image.alt = ''; image.loading = 'lazy'; image.width = 180; image.height = 130;
|
||||||
|
card.append(image, element('strong', item.title), element('span', item.archived ? 'Archiviert' : `${item.variants.at(-1).width} × ${item.variants.at(-1).height} px`));
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
active = item;
|
||||||
|
if (!multiple) selected.clear();
|
||||||
|
if (selected.has(item.id)) selected.delete(item.id); else if (!item.archived) selected.add(item.id);
|
||||||
|
render(); details();
|
||||||
|
}); $('#media-grid').append(card);
|
||||||
|
}
|
||||||
|
if (!visible.length) $('#media-grid').append(element('p', 'Keine Bilder gefunden. Lade ein Bild hoch oder ändere den Suchbegriff.', 'muted'));
|
||||||
|
$('#media-status').textContent = `${items.filter(item => !item.archived).length} Bilder · ${selected.size} ausgewählt`;
|
||||||
|
$('#media-insert').textContent = `Auswahl übernehmen (${selected.size})`; $('#media-insert').disabled = working || !selected.size;
|
||||||
|
if (focusedMedia) dialog.querySelector(`[data-media-id="${focusedMedia}"]`)?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
function details() {
|
||||||
|
const panel = $('#media-details'); panel.replaceChildren(); if (!active) return;
|
||||||
|
const current = active;
|
||||||
|
panel.append(element('h3', 'Bildangaben'));
|
||||||
|
for (const [key, label] of [['title', 'Bildtitel'], ['alt', 'Alternativtext des Mediums']]) {
|
||||||
|
const caption = element('label', label); caption.htmlFor = key === 'title' ? 'media-title-field' : `media-${key}`;
|
||||||
|
const input = element('input'); input.id = caption.htmlFor; input.value = current[key]; input.maxLength = key === 'title' ? 160 : 300;
|
||||||
|
panel.append(caption, input);
|
||||||
|
}
|
||||||
|
panel.append(element('p', 'Alt-Text beschreibt den Bildinhalt. Änderungen hier gelten für zukünftiges Einfügen; vorhandene Seiten behalten ihre eigenen Texte.', 'field-help'));
|
||||||
|
const save = element('button', 'Bildangaben speichern', 'secondary'); save.id = 'media-save';
|
||||||
|
const archive = element('button', current.archived ? 'Wiederherstellen' : 'Archivieren', 'secondary'); archive.id = 'media-archive';
|
||||||
|
const update = async archived => {
|
||||||
|
const data = { title: $('#media-title-field').value, alt: $('#media-alt').value, archived, version: current.version };
|
||||||
|
lock(true); error('');
|
||||||
|
try { const result = await api('/' + current.id, data); Object.assign(current, result.item); if (archived) selected.delete(current.id); render(); details(); }
|
||||||
|
catch (err) { error(err.message || 'Verbindung unterbrochen. Bitte erneut versuchen.'); }
|
||||||
|
finally { lock(false); $('#media-save')?.focus({ preventScroll: true }); }
|
||||||
|
};
|
||||||
|
save.addEventListener('click', () => update(current.archived)); archive.addEventListener('click', () => update(!current.archived));
|
||||||
|
panel.append(save, archive);
|
||||||
|
const link = element('a', 'Bilddatei öffnen ↗'); link.href = current.variants.at(-1).src; link.target = '_blank'; link.rel = 'noopener'; panel.append(link);
|
||||||
|
}
|
||||||
|
$('#media-search').addEventListener('input', render); $('#media-archived').addEventListener('change', render);
|
||||||
|
$('#media-close').addEventListener('click', () => dialog.close());
|
||||||
|
dialog.addEventListener('cancel', event => { if (working) event.preventDefault(); });
|
||||||
|
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); else document.querySelector('#properties button')?.focus(); });
|
||||||
|
$('#media-upload').addEventListener('change', async () => {
|
||||||
|
const files = [...$('#media-upload').files]; if (!files.length) return;
|
||||||
|
lock(true); error(''); const failures = [];
|
||||||
|
for (let index = 0; index < files.length; index++) {
|
||||||
|
const file = files[index]; $('#media-status').textContent = `Upload ${index + 1} von ${files.length}: ${file.name}`;
|
||||||
|
if (file.size > 8 * 1024 * 1024) { failures.push(`${file.name}: größer als 8 MB`); continue; }
|
||||||
|
try { const form = new FormData(); form.append('file', file); const data = await api('/upload', form); items.unshift(data.item); active = data.item; }
|
||||||
|
catch (err) { failures.push(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||||
|
}
|
||||||
|
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
|
||||||
|
});
|
||||||
|
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
|
||||||
|
$('#media-insert').addEventListener('click', () => {
|
||||||
|
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
|
||||||
|
const decorative = $('#media-decorative').checked;
|
||||||
|
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||||
|
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
|
||||||
|
});
|
||||||
|
dialog.showModal(); lock(true);
|
||||||
|
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[id].editor-selected{outline:3px solid #437754!important;outline-offset:-3px;scroll-margin-top:14px}
|
||||||
|
[id].editor-hover:not(.editor-selected){outline:2px dashed #64864e;outline-offset:-2px}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@charset "UTF-8";
|
||||||
|
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
|
||||||
|
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||||
|
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||||
|
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{display:flex;justify-content:space-between;align-items:center;min-height:106px;border-bottom:1px solid var(--line);gap:24px}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;padding-block:34px;border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||||
|
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header{min-height:100px;align-items:flex-start;flex-direction:column;justify-content:center;gap:18px;padding-block:22px}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#182d2b"/><path d="M18 14h8v21l13-14h11L34 38l17 14H39L26 41v11h-8z" fill="#e5f0b6"/></svg>
|
||||||
|
After Width: | Height: | Size: 195 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/config/bootstrap.php';
|
||||||
|
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||||
|
$request = Request::createFromGlobals();
|
||||||
|
$response = $kernel->handle($request);
|
||||||
|
$response->send();
|
||||||
|
$kernel->terminate($request, $response);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import lighthouse from 'lighthouse';
|
||||||
|
import desktopConfig from 'lighthouse/core/config/desktop-config.js';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const origin = process.env.CMS_URL || 'http://127.0.0.1:8080';
|
||||||
|
const output = process.env.CMS_AUDIT_DIR || 'reports/lighthouse';
|
||||||
|
const preflight = await fetch(origin);
|
||||||
|
if (!preflight.ok || /noindex/i.test(preflight.headers.get('x-robots-tag') || '')) {
|
||||||
|
throw new Error('Audit requires an indexable HTTP 200 page in production mode. Check APP_ENV and APP_DEBUG.');
|
||||||
|
}
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
const portProbe = net.createServer();
|
||||||
|
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = portProbe.address().port;
|
||||||
|
await new Promise(resolve => portProbe.close(resolve));
|
||||||
|
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
||||||
|
const summary = [];
|
||||||
|
try {
|
||||||
|
for (const profile of ['mobile', 'desktop']) {
|
||||||
|
const runs = [];
|
||||||
|
for (let run = 1; run <= 3; run++) {
|
||||||
|
const result = await lighthouse(origin, {
|
||||||
|
port,
|
||||||
|
output: 'json',
|
||||||
|
logLevel: 'error',
|
||||||
|
}, profile === 'desktop' ? desktopConfig : undefined);
|
||||||
|
const lhr = result.lhr;
|
||||||
|
if (lhr.configSettings.formFactor !== profile) {
|
||||||
|
throw new Error(`Requested ${profile}, measured ${lhr.configSettings.formFactor}`);
|
||||||
|
}
|
||||||
|
await writeFile(`${output}/${profile}-${run}.json`, result.report);
|
||||||
|
runs.push(lhr);
|
||||||
|
console.log(profile, run, Object.fromEntries(Object.entries(lhr.categories).map(([key, value]) => [key, value.score])));
|
||||||
|
}
|
||||||
|
const scores = {};
|
||||||
|
for (const category of Object.keys(runs[0].categories)) {
|
||||||
|
const values = runs.map(r => r.categories[category].score).filter(v => v !== null).sort((a, b) => a - b);
|
||||||
|
scores[category] = values.length ? values[Math.floor(values.length / 2)] : null;
|
||||||
|
}
|
||||||
|
summary.push({ profile, scores, lighthouseVersion: runs[0].lighthouseVersion, settings: runs[0].configSettings, environment: runs[0].environment, note: 'Local prototype only. No customer-site acceptance or field-data claim.' });
|
||||||
|
}
|
||||||
|
await writeFile(`${output}/summary.json`, JSON.stringify(summary, null, 2));
|
||||||
|
const passed = summary.every(r => r.scores.performance >= .95 && ['accessibility', 'best-practices', 'seo'].every(c => r.scores[c] === 1) && (r.scores['agentic-browsing'] === undefined || r.scores['agentic-browsing'] === 1));
|
||||||
|
if (!passed) process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await chrome.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const origin = process.env.CMS_URL || 'http://127.0.0.1:8080';
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
await mkdir('reports', { recursive: true });
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
const context = await browser.newContext({ viewport: { width, height: 1000 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
for (const path of ['/', '/grundlagen', '/projektstand']) {
|
||||||
|
const response = await page.goto(origin + path);
|
||||||
|
assert.equal(response.status(), 200);
|
||||||
|
assert.equal(await page.locator('h1').count(), 1);
|
||||||
|
assert.equal(await page.locator('script').count(), 0);
|
||||||
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
|
||||||
|
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||||
|
assert.deepEqual(axe.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
|
||||||
|
results.push({ width, path, violations: axe.violations.length, checks: axe.passes.length });
|
||||||
|
await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
await page.goto(origin);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(await page.locator(':focus').getAttribute('id'), 'main');
|
||||||
|
await page.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/grundlagen');
|
||||||
|
await page.getByRole('link', { name: 'Projektstand' }).click();
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/projektstand');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
const noJs = await browser.newPage({ javaScriptEnabled: false });
|
||||||
|
await noJs.goto(origin);
|
||||||
|
assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/);
|
||||||
|
await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click();
|
||||||
|
assert.equal(new URL(noJs.url()).pathname, '/grundlagen');
|
||||||
|
await writeFile('reports/browser.json', JSON.stringify({ origin, results, keyboard: 'passed', noJavaScript: 'passed' }, null, 2));
|
||||||
|
console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
export function designCss(tokens) {
|
||||||
|
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `#main .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||||
|
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
|
||||||
|
}
|
||||||
|
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
|
||||||
|
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
||||||
|
await writeFile(new URL('../public/assets/design.css', import.meta.url), designCss(tokens));
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const php = process.env.PHP_BINARY || 'php';
|
||||||
|
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore' });
|
||||||
|
const fixture = JSON.parse(execFileSync(php, ['tests/browser-fixture.php'], { encoding: 'utf8' }));
|
||||||
|
const probe = net.createServer();
|
||||||
|
await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = probe.address().port;
|
||||||
|
await new Promise(resolve => probe.close(resolve));
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
server.on('error', error => { throw error; });
|
||||||
|
let browser;
|
||||||
|
let activePage;
|
||||||
|
await mkdir('reports/editor', { recursive: true });
|
||||||
|
const checks = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||||
|
if (i === 59) throw new Error('Isolated PHP server did not start.');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1600, height: 1100 }, acceptDownloads: true });
|
||||||
|
context.setDefaultTimeout(15000);
|
||||||
|
const page = await context.newPage();
|
||||||
|
activePage = page;
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
|
||||||
|
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
|
||||||
|
page.on('dialog', dialog => dialog.accept());
|
||||||
|
const axe = async name => {
|
||||||
|
console.log('Accessibility check:', name);
|
||||||
|
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
|
||||||
|
// published renderer separately; retain the sandbox in the real editor.
|
||||||
|
const result = await new AxeBuilder({ page }).exclude('#preview').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||||
|
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
|
||||||
|
checks.push({ name, violations: 0 });
|
||||||
|
};
|
||||||
|
await page.goto(origin + '/admin');
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/admin/login');
|
||||||
|
await axe('login');
|
||||||
|
await page.screenshot({ path: 'reports/editor/login.png', fullPage: true });
|
||||||
|
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');
|
||||||
|
console.log('Authenticated in isolated test installation.');
|
||||||
|
await axe('page list');
|
||||||
|
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
|
||||||
|
await page.locator('a[href="/admin/pages/test-home"]').first().click();
|
||||||
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||||
|
console.log('Editor data loaded.');
|
||||||
|
console.log('Preview status:', await page.locator('#preview-status').innerText());
|
||||||
|
const frame = page.frameLocator('#preview');
|
||||||
|
await frame.locator('h1').waitFor();
|
||||||
|
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||||
|
await frame.locator('h1').click();
|
||||||
|
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||||
|
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
|
||||||
|
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||||
|
assert.equal(await page.locator('#publish').isDisabled(), true);
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await frame.getByRole('heading', { name: /Deine Inhalte/, level: 1 }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
||||||
|
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||||
|
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
|
||||||
|
console.log('Preview edits and history passed.');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
||||||
|
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
||||||
|
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete');
|
||||||
|
assert.equal(await page.locator('#structure .tree-item').count(), 2);
|
||||||
|
await page.locator('#structure [data-block-id="headline"]').focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByLabel('Element suchen', { exact: true }).fill('');
|
||||||
|
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
|
||||||
|
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
|
await page.getByLabel('Schriftgröße', { exact: true }).selectOption('hero');
|
||||||
|
const frameValue = async (selector, property, expected) => {
|
||||||
|
await page.waitForFunction(({ selector, property, expected }) => {
|
||||||
|
const doc = document.querySelector('#preview').contentDocument;
|
||||||
|
const element = doc?.querySelector(selector);
|
||||||
|
return element && doc.defaultView.getComputedStyle(element)[property] === expected;
|
||||||
|
}, { selector, property, expected });
|
||||||
|
};
|
||||||
|
await frameValue('#headline', 'fontSize', '72px');
|
||||||
|
assert.equal(await page.locator('#preview').evaluate(element => element.contentWindow.innerWidth), 1240);
|
||||||
|
await page.getByRole('button', { name: 'Tablet', exact: true }).click();
|
||||||
|
await frameValue('#headline', 'fontSize', '72px');
|
||||||
|
await page.getByLabel('Schriftgröße', { exact: true }).selectOption('display');
|
||||||
|
await frameValue('#headline', 'fontSize', '52px');
|
||||||
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||||
|
await frameValue('#headline', 'fontSize', '52px');
|
||||||
|
await page.getByLabel('Schriftgröße', { exact: true }).selectOption('title');
|
||||||
|
await frameValue('#headline', 'fontSize', '36px');
|
||||||
|
await page.getByRole('button', { name: 'Mobil-Werte zurücksetzen', exact: true }).click();
|
||||||
|
await frameValue('#headline', 'fontSize', '52px');
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await frameValue('#headline', 'fontSize', '36px');
|
||||||
|
await page.getByLabel('Ausrichtung', { exact: true }).selectOption('center');
|
||||||
|
await frameValue('#headline', 'textAlign', 'center');
|
||||||
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||||
|
await frameValue('#headline', 'fontSize', '72px');
|
||||||
|
await frameValue('#headline', 'textAlign', 'start');
|
||||||
|
await page.locator('#structure [data-block-id="hero"]').click();
|
||||||
|
await page.getByLabel('Innenabstand oben / unten', { exact: true }).selectOption('huge');
|
||||||
|
await frameValue('#hero', 'paddingTop', '96px');
|
||||||
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||||
|
await page.getByLabel('Innenabstand oben / unten', { exact: true }).selectOption('medium');
|
||||||
|
await frameValue('#hero', 'paddingTop', '32px');
|
||||||
|
await page.locator('[data-preset="columns"]').click();
|
||||||
|
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
||||||
|
const presetRow = `#${presetSection} .block-row`;
|
||||||
|
await frame.locator(presetRow).waitFor();
|
||||||
|
const columnCount = async expected => {
|
||||||
|
await page.waitForFunction(({ selector, expected }) => {
|
||||||
|
const doc = document.querySelector('#preview').contentDocument;
|
||||||
|
const element = doc?.querySelector(selector);
|
||||||
|
return element && doc.defaultView.getComputedStyle(element).gridTemplateColumns.split(' ').length === expected;
|
||||||
|
}, { selector: presetRow, expected });
|
||||||
|
};
|
||||||
|
await columnCount(1);
|
||||||
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||||
|
await columnCount(2);
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await frame.locator(presetRow).waitFor({ state: 'detached' });
|
||||||
|
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
|
||||||
|
await columnCount(2);
|
||||||
|
await page.locator('#structure [data-block-id="headline"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
||||||
|
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
|
||||||
|
console.log('Responsive design, tree tools and presets passed.');
|
||||||
|
|
||||||
|
await page.locator('#library [data-add="gallery"]').click();
|
||||||
|
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
|
||||||
|
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
|
||||||
|
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
|
||||||
|
await page.locator('#media-upload').setInputFiles(fixture.images);
|
||||||
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
|
const mediaItems = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
|
||||||
|
const mediaResponse = await context.request.get(origin + mediaItems[0].variants[0].src);
|
||||||
|
assert.equal(mediaResponse.status(), 200, `Media delivery: ${(await mediaResponse.text()).slice(0, 160)}`);
|
||||||
|
for (const [index, item] of mediaItems.entries()) {
|
||||||
|
await page.locator(`[data-media-id="${item.id}"]`).focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await page.locator(`[data-media-id="${item.id}"]`).evaluate(button => button === document.activeElement), true);
|
||||||
|
await page.getByLabel('Bildtitel', { exact: true }).fill(index === 0 ? 'Bergblick' : 'Küstenblick');
|
||||||
|
await page.getByLabel('Alternativtext des Mediums', { exact: true }).fill(index === 0 ? 'Grüne Berglandschaft mit warmer Abendsonne' : 'Küstenlandschaft mit Hügeln und Abendsonne');
|
||||||
|
await page.getByRole('button', { name: 'Bildangaben speichern', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Bildangaben speichern', exact: true }).waitFor({ state: 'visible' });
|
||||||
|
await page.waitForFunction(() => !document.querySelector('#media-save').disabled);
|
||||||
|
}
|
||||||
|
await axe('media picker');
|
||||||
|
await page.screenshot({ path: 'reports/editor/media-library.png', fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'Auswahl übernehmen (2)', exact: true }).click();
|
||||||
|
await frame.locator(`#${galleryId} img`).first().waitFor();
|
||||||
|
assert.equal(await frame.locator(`#${galleryId} img`).count(), 2);
|
||||||
|
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
|
||||||
|
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
|
||||||
|
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
|
||||||
|
await page.locator(`#structure [data-block-id="${imageId}"]`).click();
|
||||||
|
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
|
||||||
|
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await page.locator(`#structure [data-block-id="${galleryId}"]`).click();
|
||||||
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
|
await page.getByLabel('Spaltenaufteilung', { exact: true }).selectOption('two');
|
||||||
|
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
|
||||||
|
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
|
||||||
|
await page.getByLabel('Einblendung beim Laden', { exact: true }).selectOption('rise');
|
||||||
|
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||||
|
await page.getByLabel('Spaltenaufteilung', { exact: true }).selectOption('one');
|
||||||
|
await frame.locator(`#${galleryId}.d-mobile-columns-one`).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
await frameValue(`#${galleryId}`, 'animationName', 'none');
|
||||||
|
await frameValue(`#${galleryId}`, 'transform', 'none');
|
||||||
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||||
|
await page.locator('#open-media-library').click();
|
||||||
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
|
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
|
||||||
|
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
|
||||||
|
await page.locator('#media-status').filter({ hasText: '1 Bilder' }).waitFor();
|
||||||
|
assert.equal((await context.request.get(origin + mediaItems[0].variants[0].src)).status(), 200);
|
||||||
|
await page.getByLabel('Archivierte Bilder anzeigen', { exact: true }).check();
|
||||||
|
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
|
||||||
|
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
|
||||||
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
|
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||||
|
await page.locator('#structure [data-block-id="headline"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
|
||||||
|
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
|
||||||
|
console.log('Media library, gallery and effects passed.');
|
||||||
|
|
||||||
|
await page.locator('#library [data-add="text"]').click();
|
||||||
|
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
|
||||||
|
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
|
||||||
|
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
|
||||||
|
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await page.locator('#toggle-library').click();
|
||||||
|
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
|
||||||
|
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
|
||||||
|
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
|
||||||
|
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
|
||||||
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||||
|
await page.locator('#toggle-library').click();
|
||||||
|
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
|
||||||
|
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
|
||||||
|
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
|
||||||
|
assert.equal(await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).isEnabled(), true);
|
||||||
|
checks.push({ name: 'network failure retains edits and allows retry', passed: true });
|
||||||
|
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
|
||||||
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||||
|
const stored = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
|
||||||
|
console.log('Draft stored.');
|
||||||
|
assert.equal((await (await context.request.get(origin)).text()).includes('Meine bearbeitete Website'), false);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
||||||
|
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||||
|
const reloaded = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
|
||||||
|
assert.deepEqual(reloaded.document, stored.document);
|
||||||
|
await page.getByRole('button', { name: 'Veröffentlichen', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen', exact: true }).first().focus();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
assert.equal(await page.locator('#publish-dialog').isVisible(), false);
|
||||||
|
assert.equal(await page.locator('#publish').evaluate(element => element === document.activeElement), true);
|
||||||
|
await page.getByRole('button', { name: 'Veröffentlichen', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Jetzt veröffentlichen', exact: true }).click();
|
||||||
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||||
|
assert.equal((await (await context.request.get(origin)).text()).includes('Meine bearbeitete Website'), true);
|
||||||
|
const publicPage = await context.newPage();
|
||||||
|
await publicPage.goto(origin);
|
||||||
|
assert.equal(await publicPage.locator(`#${galleryId} img`).count(), 2);
|
||||||
|
await publicPage.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
|
||||||
|
await publicPage.waitForFunction(id => document.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
|
||||||
|
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '72px');
|
||||||
|
await publicPage.setViewportSize({ width: 768, height: 1000 });
|
||||||
|
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '52px');
|
||||||
|
await publicPage.setViewportSize({ width: 390, height: 844 });
|
||||||
|
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '36px');
|
||||||
|
assert.equal(await publicPage.locator('#hero').evaluate(element => getComputedStyle(element).paddingTop), '32px');
|
||||||
|
assert.equal(await publicPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||||
|
await publicPage.screenshot({ path: 'reports/editor/designed-public-mobile.png', fullPage: true });
|
||||||
|
const contentAudit = await new AxeBuilder({ page: publicPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||||
|
assert.deepEqual(contentAudit.violations.map(v => v.id), []);
|
||||||
|
checks.push({ name: 'edited public renderer accessibility', violations: 0 });
|
||||||
|
await publicPage.close();
|
||||||
|
checks.push({ name: 'add, duplicate, move, delete, undo, exact reload, separate publication, dialog focus', passed: true });
|
||||||
|
console.log('Draft reload and publication passed.');
|
||||||
|
|
||||||
|
// A second server save must never be silently overwritten by the open editor.
|
||||||
|
const latest = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
|
||||||
|
const external = structuredClone(latest.document); external.title = 'Parallel gespeicherter Titel';
|
||||||
|
const externalSave = await context.request.post(origin + '/admin/api/pages/test-home/draft', { headers: { 'X-CSRF-Token': latest.csrfToken }, data: { document: external, expectedVersion: latest.version } });
|
||||||
|
assert.equal(externalSave.status(), 200);
|
||||||
|
await page.locator('#select-page').click();
|
||||||
|
await page.getByLabel('Seitentitel', { exact: true }).fill('Meine lokalen Änderungen');
|
||||||
|
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
|
||||||
|
await page.locator('#message').filter({ hasText: 'neuerer Stand' }).waitFor();
|
||||||
|
console.log('Conflict displayed.');
|
||||||
|
assert.equal(await page.getByLabel('Seitentitel', { exact: true }).inputValue(), 'Meine lokalen Änderungen');
|
||||||
|
const download = page.waitForEvent('download');
|
||||||
|
console.log('Testing conflict export.');
|
||||||
|
await page.getByRole('button', { name: 'Änderungen als JSON sichern' }).click();
|
||||||
|
assert.equal((await download).suggestedFilename(), 'kant-entwurf.json');
|
||||||
|
await page.getByRole('button', { name: 'Serverstand laden', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Serverstand übernehmen', exact: true }).click();
|
||||||
|
await page.getByLabel('Seitentitel', { exact: true }).filter({ visible: true }).waitFor();
|
||||||
|
await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel');
|
||||||
|
checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true });
|
||||||
|
|
||||||
|
await page.locator('#structure [data-block-id="headline"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||||
|
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||||
|
await axe('desktop editor');
|
||||||
|
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||||
|
assert.equal(await page.locator('#preview').evaluate(element => Math.round(element.getBoundingClientRect().width)), 390);
|
||||||
|
await page.screenshot({ path: 'reports/editor/editor-mobile-preview.png', fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||||
|
await axe('mobile editor');
|
||||||
|
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
|
||||||
|
await page.locator('#open-media-library').click();
|
||||||
|
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
|
||||||
|
await axe('mobile media library');
|
||||||
|
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
||||||
|
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2));
|
||||||
|
console.log('Editor end-to-end checks passed; isolated database, production data unchanged.');
|
||||||
|
if (process.argv.includes('--audit')) {
|
||||||
|
console.log('Auditing the published v3 document with responsive design, gallery images and effects.');
|
||||||
|
await browser.close(); browser = null;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const audit = spawn(process.execPath, ['scripts/audit.mjs'], { windowsHide: true, stdio: 'inherit', env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/lighthouse-editor' } });
|
||||||
|
audit.on('error', reject);
|
||||||
|
audit.on('exit', code => code === 0 ? resolve() : reject(new Error(`Designed-page audit failed (${code}).`)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check failed:', error.message);
|
||||||
|
await activePage?.screenshot({ path: 'reports/editor/failure.png', fullPage: true, timeout: 5000 }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Domain\CmsUser;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'cms:user:create', description: 'Create an account with a hidden password prompt. Existing users are never overwritten.')]
|
||||||
|
final class CreateUserCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $db, private UserPasswordHasherInterface $hasher)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('username', InputArgument::REQUIRED)
|
||||||
|
->addOption('role', null, InputOption::VALUE_REQUIRED, 'admin, publisher or editor', 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$username = strtolower(trim($input->getArgument('username')));
|
||||||
|
$roles = ['admin' => 'ROLE_ADMIN', 'publisher' => 'ROLE_PUBLISHER', 'editor' => 'ROLE_EDITOR'];
|
||||||
|
if (!preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username) || !isset($roles[$input->getOption('role')])) {
|
||||||
|
throw new \InvalidArgumentException('Benutzername: 3–100 Zeichen (a–z, 0–9, Punkt, Unterstrich, Bindestrich). Rolle: admin, publisher oder editor.');
|
||||||
|
}
|
||||||
|
if ($this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username])) {
|
||||||
|
throw new \InvalidArgumentException('Dieser Benutzer existiert bereits und bleibt unverändert.');
|
||||||
|
}
|
||||||
|
if (!$input->isInteractive()) {
|
||||||
|
throw new \InvalidArgumentException('Für das Passwort ist ein interaktives Terminal erforderlich.');
|
||||||
|
}
|
||||||
|
$password = $io->askHidden('Passwort (mindestens 14 Zeichen, maximal 72 Bytes)', static function ($password): string {
|
||||||
|
if (!is_string($password) || strlen($password) < 14 || strlen($password) > 72 || str_contains($password, "\0")) {
|
||||||
|
throw new \InvalidArgumentException('Bitte 14–72 Bytes verwenden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $password;
|
||||||
|
});
|
||||||
|
if (!hash_equals($password, (string) $io->askHidden('Passwort wiederholen'))) {
|
||||||
|
throw new \InvalidArgumentException('Die Passwörter stimmen nicht überein.');
|
||||||
|
}
|
||||||
|
$user = new CmsUser(bin2hex(random_bytes(16)), $username, '', [$roles[$input->getOption('role')]]);
|
||||||
|
$hash = $this->hasher->hashPassword($user, $password);
|
||||||
|
unset($password);
|
||||||
|
$this->db->insert('cms_user', ['id' => $user->id, 'username' => $username, 'password_hash' => $hash, 'roles' => json_encode($user->getRoles())]);
|
||||||
|
$io->success('Benutzer angelegt. Anmeldung unter /admin/login.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'cms:save-draft', description: 'Save a validated JSON document with optimistic locking (local CLI only).')]
|
||||||
|
final class SaveDocumentCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private PageRepository $pages)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('page-id', InputArgument::REQUIRED)->addArgument('file', InputArgument::REQUIRED)->addArgument('expected-version', InputArgument::REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$file = $input->getArgument('file');
|
||||||
|
if (!is_file($file) || !is_readable($file) || filesize($file) > 262144) {
|
||||||
|
throw new \InvalidArgumentException('A readable JSON file of at most 256 KiB is required.');
|
||||||
|
}
|
||||||
|
$version = filter_var($input->getArgument('expected-version'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]);
|
||||||
|
if (false === $version) {
|
||||||
|
throw new \InvalidArgumentException('Version must be a non-negative integer.');
|
||||||
|
}
|
||||||
|
$document = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($document)) {
|
||||||
|
throw new \InvalidArgumentException('Document must be an object.');
|
||||||
|
}
|
||||||
|
$output->writeln($this->pages->saveDraft($input->getArgument('page-id'), $document, $version));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Content\DemoContent;
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'cms:seed-demo', description: 'Create missing demo pages without overwriting existing content.')]
|
||||||
|
final class SeedDemoCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $db, private PageRepository $pages)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$this->db->transactional(function () use ($output): void {
|
||||||
|
if (!$this->db->fetchOne('SELECT id FROM website WHERE id = ?', ['default'])) {
|
||||||
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => json_encode(['template' => 'kant-starter', 'version' => 1])]);
|
||||||
|
}
|
||||||
|
foreach (DemoContent::pages() as $slug => $document) {
|
||||||
|
if ($this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND slug = ?', ['default', 'de', $slug])) {
|
||||||
|
$output->writeln('Unchanged: '.$slug);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$id = bin2hex(random_bytes(16));
|
||||||
|
$this->db->insert('page', ['id' => $id, 'website_id' => 'default', 'locale' => 'de', 'slug' => $slug, 'version' => 0]);
|
||||||
|
$revision = $this->pages->saveDraft($id, $document, 0);
|
||||||
|
$this->pages->publish($id, $revision, 1);
|
||||||
|
$output->writeln('Created: '.$slug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Content;
|
||||||
|
|
||||||
|
final class DemoContent
|
||||||
|
{
|
||||||
|
public static function pages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'home' => [
|
||||||
|
'schemaVersion' => 1,
|
||||||
|
'title' => 'Kant CMS · Raum für deine Website',
|
||||||
|
'description' => 'Ein eigenes CMS für individuelle Kundenwebsites. Entdecke die technische Grundlage für strukturierte Inhalte, schnelle Seiten und einen visuellen Editor.',
|
||||||
|
'blocks' => [
|
||||||
|
self::section('hero', [
|
||||||
|
self::text('eyebrow', 'KANT CMS / DER ANFANG IST GEMACHT'),
|
||||||
|
self::heading('headline', 'Deine Inhalte.\nDein Gestaltungsspielraum.', 1),
|
||||||
|
self::text('intro', 'Eine Website sollte zu dir passen. Wir entwickeln die Grundlage dafür: klar strukturierte Inhalte, unabhängiges Hosting und einen Editor, der Gestaltung einfach macht.'),
|
||||||
|
self::button('discover', 'Die Grundlage entdecken', '/grundlagen'),
|
||||||
|
]),
|
||||||
|
self::section('principles', [
|
||||||
|
self::text('principles-label', '01 / WAS UNS WICHTIG IST'),
|
||||||
|
self::heading('principles-heading', 'Ein guter Auftritt beginnt\nunter der Oberfläche.', 2),
|
||||||
|
['id' => 'principles-grid', 'type' => 'row', 'children' => [
|
||||||
|
self::card('content', '01 — INHALTE', 'Klar aufgebaut.', 'Texte und Gestaltung bleiben getrennt. Ein versionierter Blockbaum bildet die Grundlage, damit sich Inhalte später weiterentwickeln lassen.'),
|
||||||
|
self::card('delivery', '02 — AUSGABE', 'Direkt lesbar.', 'Der Hauptinhalt kommt als vollständiges HTML beim Browser an. Zum Lesen braucht diese Seite kein JavaScript und keine Editor-Bibliothek.'),
|
||||||
|
self::card('control', '03 — KONTROLLE', 'Bewusst veröffentlicht.', 'Entwurf und Veröffentlichung haben eigene Stände. Eine gespeicherte Änderung erscheint erst dann öffentlich, wenn sie veröffentlicht wird.'),
|
||||||
|
]],
|
||||||
|
], 'mint'),
|
||||||
|
self::section('next', [
|
||||||
|
self::text('next-label', '02 / VON DER GRUNDLAGE ZUM WERKZEUG'),
|
||||||
|
self::heading('next-heading', 'Schritt für Schritt.\nMit einem klaren Ziel.', 2),
|
||||||
|
self::text('next-text', 'Diese Seite ist der erste technische Prototyp von Kant CMS. Als Nächstes entsteht der visuelle Editor mit Strukturbaum, Vorschau und Eigenschaften. Das fertige CMS wächst an einer echten Pilotwebsite.'),
|
||||||
|
self::button('progress', 'Den Projektstand ansehen', '/projektstand'),
|
||||||
|
], 'ink'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'grundlagen' => self::article('Die Grundlage · Kant CMS', 'Wie Kant CMS Inhalte als Blöcke speichert und Seiten als HTML ausliefert.', 'Ein Fundament für\nveränderbare Websites.', [
|
||||||
|
['Inhalte mit Struktur', 'Eine Seite besteht aus einem versionierten Dokument. Sektionen, Zeilen und Spalten ordnen Überschriften, Texte, Bilder und Links. Jeder Block hat eine stabile Kennung. Text wird bei der Ausgabe automatisch maskiert; frei ausführbares HTML ist kein Inhaltsformat.'],
|
||||||
|
['Ein Entwurf bleibt ein Entwurf', 'Speichern erzeugt einen eigenen Stand. Die öffentliche Route liest ausschließlich die veröffentlichte Revision. Veraltete Schreibstände werden abgewiesen, damit eine ältere Bearbeitung neue Inhalte nicht unbemerkt überschreibt.'],
|
||||||
|
['PHP-Webhosting als Ziel', 'Der Prototyp nutzt Symfony, Twig und Doctrine. Die lokale Installation verwendet SQLite. Für den späteren Hostingbetrieb ist MySQL oder MariaDB vorgesehen. Der öffentlich erreichbare Dokumentenstamm ist ausschließlich das Verzeichnis public.'],
|
||||||
|
['Qualität von Anfang an', 'Semantische Elemente, sichtbarer Tastaturfokus und ein Sprunglink sind bereits Teil der Ausgabe. Automatische Prüfungen begleiten die Entwicklung. Belastbare Qualitätswerte für Kundenwebsites benötigen repräsentative Inhalte und Messungen auf dem tatsächlichen Hosting.'],
|
||||||
|
]),
|
||||||
|
'projektstand' => self::article('Projektstand · Kant CMS', 'Der aktuelle Entwicklungsstand von Kant CMS und die nächsten Schritte zum visuellen Editor.', 'Der erste Schritt\nist eine solide Basis.', [
|
||||||
|
['Jetzt: technische Grundlage', 'Projektregeln und Entwicklungsplan sind abgelegt. Das Grundgerüst speichert Seiten in einer Datenbank und liefert veröffentlichte Blockdokumente als HTML aus. Diese Demonstrationsinhalte gehören zum Prototyp; sie sind keine Kundenwebsite.'],
|
||||||
|
['Als Nächstes: visuell bearbeiten', 'Geplant sind eine Elementbibliothek, ein synchronisierter Strukturbaum, Live-Vorschau und ein Eigenschaftenpanel. Verschieben, Duplizieren, Löschen und Undo/Redo werden zusammen mit dem Speichern und Laden geprüft.'],
|
||||||
|
['Noch zu konkretisieren', 'Die Pilotwebsite, konkrete UX-Builder-Referenzen und die Ausstattung des PHP-Webhostings stehen noch aus. Anmeldung, Medienverwaltung, Formulare, Templates und Plugins folgen in den dafür vorgesehenen Ausbauschritten.'],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function article(string $title, string $description, string $heading, array $sections): array
|
||||||
|
{
|
||||||
|
$blocks = [self::section('article-hero', [self::text('article-label', 'KANT CMS / ENTWICKLUNG'), self::heading('article-heading', $heading, 1)], 'mint')];
|
||||||
|
$children = [];
|
||||||
|
foreach ($sections as $i => [$label, $text]) {
|
||||||
|
$children[] = self::heading('heading-'.$i, $label, 2);
|
||||||
|
$children[] = self::text('text-'.$i, $text);
|
||||||
|
}
|
||||||
|
$blocks[] = self::section('article-body', $children, 'paper', 'narrow');
|
||||||
|
|
||||||
|
return ['schemaVersion' => 1, 'title' => $title, 'description' => $description, 'blocks' => $blocks];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function section(string $id, array $children, string $tone = 'paper', string $width = 'normal'): array
|
||||||
|
{
|
||||||
|
return ['id' => $id, 'type' => 'section', 'layout' => ['tone' => $tone, 'width' => $width], 'children' => $children];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function heading(string $id, string $text, int $level): array
|
||||||
|
{
|
||||||
|
return ['id' => $id, 'type' => 'heading', 'props' => ['text' => str_replace('\\n', ' ', $text), 'level' => $level]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function text(string $id, string $text): array
|
||||||
|
{
|
||||||
|
return ['id' => $id, 'type' => 'text', 'props' => ['text' => $text]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function button(string $id, string $text, string $href): array
|
||||||
|
{
|
||||||
|
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function card(string $id, string $label, string $title, string $text): array
|
||||||
|
{
|
||||||
|
return ['id' => $id, 'type' => 'column', 'children' => [self::text($id.'-label', $label), self::heading($id.'-heading', $title, 3), self::text($id.'-text', $text)]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Content;
|
||||||
|
|
||||||
|
use App\Domain\InvalidDocument;
|
||||||
|
|
||||||
|
final class DocumentValidator
|
||||||
|
{
|
||||||
|
public function validate(array $document): void
|
||||||
|
{
|
||||||
|
$schemaVersion = $document['schemaVersion'] ?? null;
|
||||||
|
$this->require(in_array($schemaVersion, [1, 2, 3], true), 'Unsupported document schema.');
|
||||||
|
$this->text($document['title'] ?? null, 160);
|
||||||
|
$this->text($document['description'] ?? null, 320);
|
||||||
|
$this->require(is_array($document['blocks'] ?? null) && array_is_list($document['blocks']), 'Blocks must be a list.');
|
||||||
|
$this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.');
|
||||||
|
$ids = [];
|
||||||
|
$headings = 0;
|
||||||
|
foreach ($document['blocks'] as $block) {
|
||||||
|
$this->block($block, $ids, $headings, 0, $schemaVersion);
|
||||||
|
}
|
||||||
|
$this->require(1 === $headings, 'A page requires exactly one main heading.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion): void
|
||||||
|
{
|
||||||
|
$this->require(is_array($block) && $depth <= 10 && count($ids) < 300, 'Invalid block or document complexity limit exceeded.');
|
||||||
|
$id = $block['id'] ?? null;
|
||||||
|
$this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && !isset($ids[$id]), 'Block IDs must be stable and unique.');
|
||||||
|
$ids[$id] = true;
|
||||||
|
$type = $block['type'] ?? null;
|
||||||
|
$this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || (3 === $schemaVersion && 'gallery' === $type), 'Unknown block type. Preserve source until its plugin is available.');
|
||||||
|
$props = $block['props'] ?? [];
|
||||||
|
$layout = $block['layout'] ?? [];
|
||||||
|
$this->require(is_array($props) && is_array($layout), 'Props and layout must be objects.');
|
||||||
|
foreach ($layout as $key => $value) {
|
||||||
|
if ('effects' === $key && 3 === $schemaVersion) {
|
||||||
|
$this->require(is_array($value), 'Effects must be an object.');
|
||||||
|
$effects = ['shadow' => ['none', 'soft', 'strong'], 'hover' => ['none', 'lift', 'zoom'], 'entrance' => ['none', 'fade', 'rise']];
|
||||||
|
foreach ($value as $effect => $token) {
|
||||||
|
$this->require(isset($effects[$effect]) && in_array($token, $effects[$effect], true), 'Unknown effect.');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('design' === $key && $schemaVersion >= 2) {
|
||||||
|
$this->design($value, $type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->require(in_array($key, ['tone', 'width'], true), 'Unknown layout setting.');
|
||||||
|
$this->require(in_array($value, 'tone' === $key ? ['paper', 'ink', 'mint'] : ['normal', 'narrow'], true), 'Invalid design token.');
|
||||||
|
}
|
||||||
|
if (in_array($type, ['heading', 'text', 'button'], true)) {
|
||||||
|
$this->text($props['text'] ?? null, 'text' === $type ? 10000 : 300);
|
||||||
|
}
|
||||||
|
if ('heading' === $type) {
|
||||||
|
$this->require(is_int($props['level'] ?? null) && $props['level'] >= 1 && $props['level'] <= 6, 'Invalid heading level.');
|
||||||
|
$headings += 1 === $props['level'] ? 1 : 0;
|
||||||
|
}
|
||||||
|
if ('button' === $type) {
|
||||||
|
$this->url($props['href'] ?? null);
|
||||||
|
}
|
||||||
|
if ('image' === $type) {
|
||||||
|
$this->url($props['src'] ?? null);
|
||||||
|
$this->require(is_bool($props['decorative'] ?? null), 'Image purpose is required.');
|
||||||
|
$this->text($props['alt'] ?? null, 300, $props['decorative']);
|
||||||
|
$this->require(!$props['decorative'] || '' === $props['alt'], 'Decorative images need empty alternative text.');
|
||||||
|
foreach (['width', 'height'] as $dimension) {
|
||||||
|
$this->require(is_int($props[$dimension] ?? null) && $props[$dimension] > 0 && $props[$dimension] <= 8000, 'Image dimensions are required.');
|
||||||
|
}
|
||||||
|
foreach (['caption', 'mediaId', 'sources'] as $extra) {
|
||||||
|
if (isset($props[$extra])) {
|
||||||
|
$this->require(3 === $schemaVersion, 'Media properties require schema v3.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($props['caption'])) {
|
||||||
|
$this->text($props['caption'], 500, true);
|
||||||
|
}
|
||||||
|
if (isset($props['mediaId'])) {
|
||||||
|
$this->require(is_string($props['mediaId']) && 1 === preg_match('/^[a-f0-9]{32}$/D', $props['mediaId']), 'Invalid media ID.');
|
||||||
|
}
|
||||||
|
if (isset($props['sources'])) {
|
||||||
|
$this->require(is_array($props['sources']) && array_is_list($props['sources']) && count($props['sources']) <= 3, 'Invalid image variants.');
|
||||||
|
$widths = [];
|
||||||
|
foreach ($props['sources'] as $source) {
|
||||||
|
$this->require(is_array($source) && is_int($source['width'] ?? null) && $source['width'] > 0 && $source['width'] <= 8000 && !in_array($source['width'], $widths, true), 'Invalid image variant width.');
|
||||||
|
$this->url($source['src'] ?? null);
|
||||||
|
$this->require(!str_contains($source['src'], ','), 'Invalid srcset URL.');
|
||||||
|
$widths[] = $source['width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$children = $block['children'] ?? [];
|
||||||
|
$this->require(is_array($children) && array_is_list($children), 'Children must be a list.');
|
||||||
|
$container = in_array($type, ['section', 'row', 'column', 'gallery'], true);
|
||||||
|
$this->require($container || [] === $children, 'Leaf blocks cannot contain children.');
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if ('gallery' === $type) {
|
||||||
|
$this->require(is_array($child) && 'image' === ($child['type'] ?? null), 'Galleries only contain images.');
|
||||||
|
}
|
||||||
|
$this->block($child, $ids, $headings, $depth + 1, $schemaVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function design(mixed $design, string $type): void
|
||||||
|
{
|
||||||
|
static $tokens = null;
|
||||||
|
$tokens ??= json_decode(file_get_contents(__DIR__.'/../../public/assets/design-tokens.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->require(is_array($design), 'Design must be an object.');
|
||||||
|
foreach ($design as $device => $settings) {
|
||||||
|
$this->require(in_array($device, ['desktop', 'tablet', 'mobile'], true) && is_array($settings), 'Invalid responsive device.');
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
$this->require(isset($tokens[$key]) && in_array($type, $tokens[$key]['types'], true), 'Design setting does not apply to this block.');
|
||||||
|
$this->require(is_string($value) && isset($tokens[$key]['values'][$value]), 'Invalid responsive design token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function text(mixed $value, int $max, bool $emptyAllowed = false): void
|
||||||
|
{
|
||||||
|
$this->require(is_string($value) && strlen($value) <= $max && ($emptyAllowed || '' !== trim($value)), 'Missing or oversized text.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function url(mixed $url): void
|
||||||
|
{
|
||||||
|
$this->require(is_string($url) && strlen($url) <= 2048, 'Invalid URL.');
|
||||||
|
$this->require(!preg_match('/[\x00-\x20\\\\]/', $url), 'Unsafe URL characters.');
|
||||||
|
$local = str_starts_with($url, '/') && !str_starts_with($url, '//');
|
||||||
|
$external = str_starts_with($url, 'https://') && false !== filter_var($url, FILTER_VALIDATE_URL);
|
||||||
|
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function require(bool $condition, string $message): void
|
||||||
|
{
|
||||||
|
if (!$condition) {
|
||||||
|
throw new InvalidDocument($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Content;
|
||||||
|
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
final class MediaLibrary
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $db, private string $directory)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): array
|
||||||
|
{
|
||||||
|
return array_map($this->present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $id): ?array
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||||
|
|
||||||
|
return $row ? $this->present($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function present(array $row): array
|
||||||
|
{
|
||||||
|
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$row['version'] = (int) $row['version'];
|
||||||
|
$row['archived'] = (bool) $row['archived'];
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
if (!$file->isValid() || $file->getSize() > 8 * 1024 * 1024) {
|
||||||
|
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 8 MB pro Bild; das PHP-Hosting kann ein kleineres Limit setzen.');
|
||||||
|
}
|
||||||
|
if (!function_exists('imagewebp')) {
|
||||||
|
throw new \RuntimeException('Für Bildverarbeitung wird PHP GD mit WebP benötigt.');
|
||||||
|
}
|
||||||
|
$info = @getimagesize($file->getPathname());
|
||||||
|
if (!$info || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true) || $info[0] * $info[1] > 12000000 || max($info[0], $info[1]) > 8000) {
|
||||||
|
throw new \InvalidArgumentException('Bitte ein JPEG-, PNG- oder WebP-Bild mit maximal 12 Megapixeln und 8000 px Kantenlänge wählen.');
|
||||||
|
}
|
||||||
|
$memoryLimit = ini_parse_quantity(ini_get('memory_limit'));
|
||||||
|
if ($memoryLimit > 0 && memory_get_usage(true) + $info[0] * $info[1] * 12 + 16777216 > $memoryLimit) {
|
||||||
|
throw new \InvalidArgumentException('Dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt. Bitte vor dem Upload verkleinern.');
|
||||||
|
}
|
||||||
|
$source = @imagecreatefromstring(file_get_contents($file->getPathname()));
|
||||||
|
if (!$source) {
|
||||||
|
throw new \InvalidArgumentException('Die Bilddatei konnte nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
$paths = [];
|
||||||
|
try {
|
||||||
|
if ('image/jpeg' === $info['mime'] && function_exists('exif_read_data')) {
|
||||||
|
$orientation = (int) (@exif_read_data($file->getPathname())['Orientation'] ?? 1);
|
||||||
|
if (in_array($orientation, [2, 4, 5, 7], true)) {
|
||||||
|
imageflip($source, IMG_FLIP_HORIZONTAL);
|
||||||
|
}
|
||||||
|
$angle = match ($orientation) {
|
||||||
|
3, 4 => 180, 6, 7 => -90, 5, 8 => 90, default => 0,
|
||||||
|
};
|
||||||
|
if ($angle) {
|
||||||
|
$rotated = imagerotate($source, $angle, 0);
|
||||||
|
imagedestroy($source);
|
||||||
|
$source = $rotated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||||
|
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
|
||||||
|
}
|
||||||
|
$id = bin2hex(random_bytes(16));
|
||||||
|
$variants = [];
|
||||||
|
$width = imagesx($source);
|
||||||
|
$height = imagesy($source);
|
||||||
|
foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) as $targetWidth) {
|
||||||
|
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
|
||||||
|
$target = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||||
|
try {
|
||||||
|
imagealphablending($target, false);
|
||||||
|
imagesavealpha($target, true);
|
||||||
|
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
||||||
|
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
|
||||||
|
$paths[] = $path;
|
||||||
|
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
|
||||||
|
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
|
||||||
|
} finally {
|
||||||
|
imagedestroy($target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
|
||||||
|
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
|
||||||
|
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
|
||||||
|
$this->db->insert('media', $row);
|
||||||
|
|
||||||
|
return $this->present($row);
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (is_file($path)) {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw $error;
|
||||||
|
} finally {
|
||||||
|
imagedestroy($source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $id, array $data): array
|
||||||
|
{
|
||||||
|
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
|
||||||
|
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
|
||||||
|
}
|
||||||
|
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
|
||||||
|
if (1 !== $count) {
|
||||||
|
throw new StaleWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(string $id, int $width): ?string
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$media = $this->find($id);
|
||||||
|
if (!$media || !in_array($width, array_column($media['variants'], 'width'), true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$path = $this->directory.'/'.$id.'-'.$width.'.webp';
|
||||||
|
|
||||||
|
return is_file($path) ? $path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Content;
|
||||||
|
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
final class PageRepository
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDraft(string $pageId, array $document, int $expectedVersion): string
|
||||||
|
{
|
||||||
|
$this->validator->validate($document);
|
||||||
|
|
||||||
|
return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string {
|
||||||
|
$id = bin2hex(random_bytes(16));
|
||||||
|
$changed = $this->db->executeStatement(
|
||||||
|
'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?',
|
||||||
|
[$id, $pageId, $expectedVersion],
|
||||||
|
);
|
||||||
|
if (1 !== $changed) {
|
||||||
|
throw new StaleWrite('The page changed. Reload before saving.');
|
||||||
|
}
|
||||||
|
$this->db->insert('revision', [
|
||||||
|
'id' => $id, 'page_id' => $pageId,
|
||||||
|
'document' => json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
|
||||||
|
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(string $pageId, string $revisionId, int $expectedVersion): void
|
||||||
|
{
|
||||||
|
$this->db->transactional(function () use ($pageId, $revisionId, $expectedVersion): void {
|
||||||
|
$raw = $this->db->fetchOne('SELECT document FROM revision WHERE id = ? AND page_id = ?', [$revisionId, $pageId]);
|
||||||
|
if (false === $raw) {
|
||||||
|
throw new \InvalidArgumentException('Revision does not belong to this page.');
|
||||||
|
}
|
||||||
|
$this->validator->validate(json_decode($raw, true, 512, JSON_THROW_ON_ERROR));
|
||||||
|
$changed = $this->db->executeStatement(
|
||||||
|
'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?',
|
||||||
|
[$revisionId, $pageId, $expectedVersion],
|
||||||
|
);
|
||||||
|
if (1 !== $changed) {
|
||||||
|
throw new StaleWrite('The page changed. Reload before publishing.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findPublished(string $slug, string $locale = 'de'): ?array
|
||||||
|
{
|
||||||
|
$page = $this->db->fetchAssociative(
|
||||||
|
'SELECT p.id, p.slug, p.locale, r.document, r.id AS revision_id, w.name AS website_name FROM page p JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id JOIN website w ON w.id = p.website_id WHERE p.slug = ? AND p.locale = ? AND p.website_id = ?',
|
||||||
|
[$slug, $locale, 'default'],
|
||||||
|
);
|
||||||
|
if (false === $page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->validator->validate($page['document']);
|
||||||
|
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listForEditor(): array
|
||||||
|
{
|
||||||
|
return $this->db->fetchAllAssociative('SELECT p.id, p.slug, p.locale, p.version, p.published_revision_id, p.draft_revision_id, r.document FROM page p LEFT JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.website_id = ? ORDER BY p.slug', ['default']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findForEditor(string $id): ?array
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchAssociative('SELECT p.*, r.document FROM page p JOIN revision r ON r.id = p.draft_revision_id AND r.page_id = p.id WHERE p.id = ? AND p.website_id = ?', [$id, 'default']);
|
||||||
|
if (false === $row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$row['version'] = (int) $row['version'];
|
||||||
|
$row['document'] = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->validator->validate($row['document']);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Content\DocumentValidator;
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use App\Domain\InvalidDocument;
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||||
|
|
||||||
|
#[Route('/admin')]
|
||||||
|
final class EditorController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private CsrfTokenManagerInterface $csrf)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('', name: 'admin_pages', methods: ['GET'])]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$pages = $this->pages->listForEditor();
|
||||||
|
foreach ($pages as &$page) {
|
||||||
|
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/pages.html.twig', ['pages' => $pages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
|
||||||
|
public function editor(string $id): Response
|
||||||
|
{
|
||||||
|
$page = $this->page($id);
|
||||||
|
|
||||||
|
return $this->render('admin/editor.html.twig', ['page' => $page]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/pages/{id}', name: 'admin_document', methods: ['GET'])]
|
||||||
|
public function document(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->json($this->payload($this->page($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/pages/{id}/draft', name: 'admin_save', methods: ['POST'])]
|
||||||
|
#[Route('/api/pages/{id}/publish', name: 'admin_publish', methods: ['POST'])]
|
||||||
|
#[Route('/api/pages/{id}/preview', name: 'admin_preview', methods: ['POST'])]
|
||||||
|
public function write(Request $request, string $id): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||||
|
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||||
|
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||||
|
}
|
||||||
|
if (strlen($request->getContent()) > 270000) {
|
||||||
|
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||||
|
}
|
||||||
|
$page = $this->page($id);
|
||||||
|
try {
|
||||||
|
$body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
throw new \InvalidArgumentException();
|
||||||
|
}
|
||||||
|
$route = $request->attributes->get('_route');
|
||||||
|
if ('admin_preview' === $route) {
|
||||||
|
$document = $body['document'] ?? null;
|
||||||
|
if (!is_array($document)) {
|
||||||
|
throw new \InvalidArgumentException();
|
||||||
|
}
|
||||||
|
$this->validator->validate($document);
|
||||||
|
|
||||||
|
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '']);
|
||||||
|
}
|
||||||
|
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||||
|
throw new \InvalidArgumentException();
|
||||||
|
}
|
||||||
|
if ('admin_publish' === $route) {
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||||
|
// The user must publish the exact draft they reviewed, never whichever draft happens to be latest.
|
||||||
|
if (($body['revisionId'] ?? null) !== $page['draft_revision_id']) {
|
||||||
|
throw new StaleWrite();
|
||||||
|
}
|
||||||
|
$this->pages->publish($id, $body['revisionId'], $body['expectedVersion']);
|
||||||
|
$page['published_revision_id'] = $body['revisionId'];
|
||||||
|
} else {
|
||||||
|
if (!is_array($body['document'] ?? null)) {
|
||||||
|
throw new \InvalidArgumentException();
|
||||||
|
}
|
||||||
|
$page['draft_revision_id'] = $this->pages->saveDraft($id, $body['document'], $body['expectedVersion']);
|
||||||
|
$page['document'] = $body['document'];
|
||||||
|
}
|
||||||
|
$page['version'] = $body['expectedVersion'] + 1;
|
||||||
|
|
||||||
|
return $this->json($this->payload($page));
|
||||||
|
} catch (StaleWrite) {
|
||||||
|
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
|
||||||
|
} catch (InvalidDocument $e) {
|
||||||
|
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
|
||||||
|
} catch (\JsonException|\InvalidArgumentException) {
|
||||||
|
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function page(string $id): array
|
||||||
|
{
|
||||||
|
return $this->pages->findForEditor($id) ?? throw $this->createNotFoundException('Seite nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payload(array $page): array
|
||||||
|
{
|
||||||
|
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Content\MediaLibrary;
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||||
|
|
||||||
|
final class MediaController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
return $this->render('admin/media.html.twig');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/api/media', methods: ['GET'])]
|
||||||
|
public function list(): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||||
|
|
||||||
|
return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/api/media/upload', methods: ['POST'])]
|
||||||
|
#[Route('/admin/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||||
|
public function write(Request $request, ?string $id = null): Response
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||||
|
if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) {
|
||||||
|
return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (null === $id) {
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
if (!$file instanceof UploadedFile) {
|
||||||
|
throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(['item' => $this->media->upload($file)], 201);
|
||||||
|
}
|
||||||
|
if (!$this->media->find($id)) {
|
||||||
|
throw $this->createNotFoundException();
|
||||||
|
}
|
||||||
|
if (strlen($request->getContent()) > 4096) {
|
||||||
|
throw new \InvalidArgumentException('Medienangaben sind zu groß.');
|
||||||
|
}
|
||||||
|
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
throw new \InvalidArgumentException('Ungültige Medienangaben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(['item' => $this->media->update($id, $data)]);
|
||||||
|
} catch (StaleWrite) {
|
||||||
|
return $this->json(['error' => 'Das Bild wurde inzwischen geändert. Angaben kopieren und die Mediathek neu öffnen.'], 409);
|
||||||
|
} catch (\InvalidArgumentException|\JsonException $error) {
|
||||||
|
return $this->json(['error' => $error->getMessage()], 422);
|
||||||
|
} catch (\RuntimeException $error) {
|
||||||
|
return $this->json(['error' => 'Das Bild konnte nicht gespeichert werden. GD/WebP und Schreibrechte des Medienordners prüfen.'], 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
||||||
|
public function serve(string $id, int $width): Response
|
||||||
|
{
|
||||||
|
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
|
||||||
|
|
||||||
|
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
final class PageController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(private PageRepository $pages, private string $siteOrigin)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/', name: 'home', methods: ['GET'])]
|
||||||
|
#[Route('/{slug}', name: 'page', requirements: ['slug' => '[a-z0-9]+(?:-[a-z0-9]+)*'], methods: ['GET'])]
|
||||||
|
public function page(Request $request, string $slug = 'home'): Response
|
||||||
|
{
|
||||||
|
// One canonical address for the home page.
|
||||||
|
if ('home' === $slug && '/home' === $request->getPathInfo()) {
|
||||||
|
return $this->redirectToRoute('home', status: 301);
|
||||||
|
}
|
||||||
|
$page = $this->pages->findPublished($slug);
|
||||||
|
if (null === $page) {
|
||||||
|
$response = $this->render('not-found.html.twig', [], new Response(status: 404));
|
||||||
|
} else {
|
||||||
|
$response = $this->render('page.html.twig', [
|
||||||
|
'page' => $page,
|
||||||
|
'canonical' => rtrim($this->siteOrigin, '/').('home' === $slug ? '/' : '/'.$slug),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$response->headers->set('Cache-Control', 'no-store');
|
||||||
|
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||||
|
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
|
||||||
|
final class SecurityController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/admin/login', name: 'admin_login', methods: ['GET', 'POST'])]
|
||||||
|
public function login(AuthenticationUtils $auth): Response
|
||||||
|
{
|
||||||
|
if ($this->getUser()) {
|
||||||
|
return $this->redirectToRoute('admin_pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/login.html.twig', ['error' => $auth->getLastAuthenticationError(), 'username' => $auth->getLastUsername()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/logout', name: 'admin_logout', methods: ['POST'])]
|
||||||
|
public function logout(): never
|
||||||
|
{
|
||||||
|
throw new \LogicException('Handled by Symfony Security.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain;
|
||||||
|
|
||||||
|
use Symfony\Component\Security\Core\User\EquatableInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
final class CmsUser implements UserInterface, PasswordAuthenticatedUserInterface, EquatableInterface
|
||||||
|
{
|
||||||
|
public function __construct(public readonly string $id, private string $username, private string $password, private array $roles)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserIdentifier(): string
|
||||||
|
{
|
||||||
|
return $this->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPassword(): string
|
||||||
|
{
|
||||||
|
return $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPasswordHash(string $hash): void
|
||||||
|
{
|
||||||
|
$this->password = $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoles(): array
|
||||||
|
{
|
||||||
|
return $this->roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eraseCredentials(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEqualTo(UserInterface $user): bool
|
||||||
|
{
|
||||||
|
return $user instanceof self && $this->id === $user->id
|
||||||
|
&& $this->username === $user->username
|
||||||
|
&& hash_equals($this->password, $user->password)
|
||||||
|
&& $this->roles === $user->roles;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain;
|
||||||
|
|
||||||
|
final class InvalidDocument extends \InvalidArgumentException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain;
|
||||||
|
|
||||||
|
final class StaleWrite extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
use Symfony\Component\HttpKernel\KernelEvents;
|
||||||
|
|
||||||
|
final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onResponse(ResponseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!preg_match('#^/admin(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$headers = $event->getResponse()->headers;
|
||||||
|
$headers->set('Cache-Control', 'private, no-store');
|
||||||
|
$headers->set('X-Robots-Tag', 'noindex, nofollow');
|
||||||
|
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||||
|
$headers->set('Referrer-Policy', 'same-origin');
|
||||||
|
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Infrastructure;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
use Doctrine\DBAL\Tools\DsnParser;
|
||||||
|
|
||||||
|
final class ConnectionFactory
|
||||||
|
{
|
||||||
|
public static function create(string $url): Connection
|
||||||
|
{
|
||||||
|
$params = (new DsnParser(['sqlite' => 'pdo_sqlite', 'mysql' => 'pdo_mysql']))->parse($url);
|
||||||
|
$connection = DriverManager::getConnection($params);
|
||||||
|
if ('pdo_sqlite' === $params['driver']) {
|
||||||
|
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
||||||
|
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
class Kernel extends BaseKernel
|
||||||
|
{
|
||||||
|
use MicroKernelTrait;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Security;
|
||||||
|
|
||||||
|
use App\Domain\CmsUser;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
|
||||||
|
final class UserProvider implements UserProviderInterface, PasswordUpgraderInterface
|
||||||
|
{
|
||||||
|
public function __construct(private Connection $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadUserByIdentifier(string $identifier): CmsUser
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchAssociative('SELECT * FROM cms_user WHERE username = ?', [strtolower(trim($identifier))]);
|
||||||
|
if (false === $row) {
|
||||||
|
throw new UserNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CmsUser($row['id'], $row['username'], $row['password_hash'], json_decode($row['roles'], true, 512, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshUser(UserInterface $user): UserInterface
|
||||||
|
{
|
||||||
|
if (!$user instanceof CmsUser) {
|
||||||
|
throw new UnsupportedUserException();
|
||||||
|
}
|
||||||
|
$fresh = $this->loadUserByIdentifier($user->getUserIdentifier());
|
||||||
|
if ($fresh->id !== $user->id) {
|
||||||
|
throw new UserNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsClass(string $class): bool
|
||||||
|
{
|
||||||
|
return CmsUser::class === $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||||
|
{
|
||||||
|
if (!$user instanceof CmsUser) {
|
||||||
|
throw new UnsupportedUserException();
|
||||||
|
}
|
||||||
|
$changed = $this->db->executeStatement('UPDATE cms_user SET password_hash = ? WHERE id = ? AND password_hash = ?', [$newHashedPassword, $user->id, $user->getPassword()]);
|
||||||
|
if (1 === $changed) {
|
||||||
|
$user->setPasswordHash($newHashedPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{% block title %}Verwaltung{% endblock %} · Kant CMS</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/assets/admin.css">
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
|
<a class="skip-link" href="#admin-main">Zum Inhalt springen</a>
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends 'admin/base.html.twig' %}
|
||||||
|
{% block title %}{{ page.document.title }} bearbeiten{% endblock %}
|
||||||
|
{% block body_class %}editor-body{% endblock %}
|
||||||
|
{% block scripts %}<script type="module" src="/assets/editor.js"></script>{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div id="editor" data-page-id="{{ page.id }}" data-can-publish="{{ is_granted('ROLE_PUBLISHER') ? 'true' : 'false' }}">
|
||||||
|
<header class="editor-header">
|
||||||
|
<a class="back-link" href="{{ path('admin_pages') }}" aria-label="Zur Seitenübersicht">← <span>Seiten</span></a>
|
||||||
|
<div class="editor-page-name"><h1>{{ page.document.title }}</h1><span>/{{ page.slug == 'home' ? '' : page.slug }}</span></div>
|
||||||
|
<div class="history-actions"><button id="undo" class="icon-button" title="Rückgängig (Strg+Z)" aria-label="Rückgängig" disabled>↶</button><button id="redo" class="icon-button" title="Wiederholen (Strg+Umschalt+Z)" aria-label="Wiederholen" disabled>↷</button></div>
|
||||||
|
<div class="save-actions"><span id="save-status" role="status" aria-live="polite">Wird geladen …</span><button id="save" class="secondary" disabled>Entwurf speichern</button>{% if is_granted('ROLE_PUBLISHER') %}<button id="publish" class="primary" disabled>Veröffentlichen</button>{% endif %}</div>
|
||||||
|
</header>
|
||||||
|
<div id="message" class="editor-message" role="alert" hidden></div>
|
||||||
|
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||||
|
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||||
|
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||||
|
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||||
|
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element.</p>
|
||||||
|
<div id="library" class="library">
|
||||||
|
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||||
|
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||||
|
<div id="presets" class="preset-library"><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||||
|
</div></section>
|
||||||
|
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||||
|
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
<section class="canvas-panel" aria-label="Live-Vorschau">
|
||||||
|
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
|
||||||
|
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
|
||||||
|
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
|
||||||
|
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
|
||||||
|
</section>
|
||||||
|
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
|
||||||
|
</main>
|
||||||
|
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
|
||||||
|
<dialog id="publish-dialog" aria-labelledby="publish-title"><h2 id="publish-title">Seite veröffentlichen?</h2><p>Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.</p><div class="dialog-actions"><button id="cancel-publish" class="secondary">Abbrechen</button><button id="confirm-publish" class="primary">Jetzt veröffentlichen</button></div></dialog>
|
||||||
|
<dialog id="reload-dialog" aria-labelledby="reload-title"><h2 id="reload-title">Serverstand laden?</h2><p>Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.</p><div class="dialog-actions"><button id="cancel-reload" class="secondary">Abbrechen</button><button id="confirm-reload" class="primary">Serverstand übernehmen</button></div></dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends 'admin/base.html.twig' %}
|
||||||
|
{% block title %}Anmelden{% endblock %}
|
||||||
|
{% block body_class %}login-body{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<main id="admin-main" class="login-card" tabindex="-1">
|
||||||
|
<a class="brand" href="/" aria-label="Kant CMS – öffentliche Website"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
||||||
|
<p class="eyebrow">DEIN RAUM ZUM GESTALTEN</p>
|
||||||
|
<h1>Willkommen zurück.</h1>
|
||||||
|
<p class="muted">Melde dich an, um deine Seiten zu bearbeiten.</p>
|
||||||
|
{% if error %}<p class="notice error" role="alert">Anmeldung fehlgeschlagen. Prüfe deine Zugangsdaten und versuche es erneut. Nach mehreren Versuchen bitte eine Minute warten.</p>{% endif %}
|
||||||
|
<form method="post" action="{{ path('admin_login') }}">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input id="username" name="_username" value="{{ username }}" autocomplete="username" required maxlength="100" autofocus>
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="_password" type="password" autocomplete="current-password" required>
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||||
|
<button class="primary full-width" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||||
|
</form>
|
||||||
|
<a class="quiet-link" href="/">Zur öffentlichen Website</a>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends 'admin/base.html.twig' %}
|
||||||
|
{% block title %}Medienverwaltung{% endblock %}
|
||||||
|
{% block scripts %}<script type="module" src="/assets/media.js"></script>{% endblock %}
|
||||||
|
{% block body %}<header class="admin-header"><a href="/admin">← Seitenübersicht</a></header><main id="admin-main" class="pages-main"><p class="eyebrow">DEINE BILDER</p><h1>Medienverwaltung</h1><p class="muted">Bilder hochladen, Angaben pflegen und in deinen Seiten wiederverwenden.</p><button id="open-media-library" class="primary">Mediathek öffnen</button></main>{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'admin/base.html.twig' %}
|
||||||
|
{% block title %}Deine Seiten{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<header class="admin-header">
|
||||||
|
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
||||||
|
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
|
||||||
|
</header>
|
||||||
|
<main id="admin-main" class="pages-main" tabindex="-1">
|
||||||
|
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
|
||||||
|
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
|
||||||
|
<a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a>
|
||||||
|
<div class="section-heading"><h2>Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Entwürfe bleiben bis zur Veröffentlichung privat.</span></div>
|
||||||
|
<div class="page-list">
|
||||||
|
{% for page in pages %}
|
||||||
|
<article class="page-card">
|
||||||
|
<div class="page-icon" aria-hidden="true">▤</div>
|
||||||
|
<div class="page-info"><h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3><p>/{{ page.slug == 'home' ? '' : page.slug }} <span>· {{ page.locale|upper }}</span></p></div>
|
||||||
|
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert' }}</span>
|
||||||
|
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Die Beispielseiten können über das lokale Setup angelegt werden.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% set type = block.type %}
|
||||||
|
{% set props = block.props|default({}) %}
|
||||||
|
{% set layout = block.layout|default({}) %}
|
||||||
|
{% set designClasses = '' %}
|
||||||
|
{% for device, settings in layout.design|default({}) %}
|
||||||
|
{% for key, value in settings %}{% set designClasses = designClasses ~ ' d-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
|
||||||
|
{% if type in ['section', 'row', 'column'] %}
|
||||||
|
{% set tag = type == 'section' ? 'section' : 'div' %}
|
||||||
|
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
||||||
|
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
||||||
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %}
|
||||||
|
{% if type == 'section' %}</div>{% endif %}
|
||||||
|
</{{ tag }}>
|
||||||
|
{% elseif type == 'heading' %}
|
||||||
|
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
|
||||||
|
{% elseif type == 'text' %}
|
||||||
|
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
||||||
|
{% elseif type == 'button' %}
|
||||||
|
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
||||||
|
{% elseif type == 'image' %}
|
||||||
|
<figure id="{{ block.id }}" class="block-image{{ designClasses }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||||
|
{% elseif type == 'gallery' %}
|
||||||
|
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
||||||
|
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>Dieser Inhaltsblock ist derzeit nicht verfügbar.</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>Seite nicht gefunden · Kant CMS</title><link rel="stylesheet" href="/assets/site.css"><link rel="icon" href="/favicon.svg" type="image/svg+xml"></head>
|
||||||
|
<body><main class="shell error-page"><p>404 · Kant CMS</p><h1>Hier ist noch keine Seite.</h1><p>Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.</p><a class="button" href="/">Zur Startseite</a></main></body></html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="{{ page.locale }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ page.document.title }}</title>
|
||||||
|
<meta name="description" content="{{ page.document.description }}">
|
||||||
|
<link rel="canonical" href="{{ canonical }}">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" href="/assets/site.css">
|
||||||
|
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css">{% endif %}
|
||||||
|
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css">{% endif %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||||
|
<header class="site-header shell">
|
||||||
|
<a class="brand" href="/" aria-label="Kant CMS – Startseite"><span class="brand-icon" aria-hidden="true">k.</span> kant<span class="brand-light">cms</span></a>
|
||||||
|
<nav aria-label="Hauptnavigation">
|
||||||
|
<a href="/"{% if page.slug == 'home' %} aria-current="page"{% endif %}>Überblick</a>
|
||||||
|
<a href="/grundlagen"{% if page.slug == 'grundlagen' %} aria-current="page"{% endif %}>Grundlagen</a>
|
||||||
|
<a class="nav-link" href="/projektstand"{% if page.slug == 'projektstand' %} aria-current="page"{% endif %}>Projektstand <span aria-hidden="true">↗</span></a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main id="main" tabindex="-1">
|
||||||
|
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block} only %}{% endfor %}
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer shell"><a class="brand" href="/">kant<span class="brand-light">cms</span></a><p>Eine Grundlage. Viele Möglichkeiten.</p><span>Lokaler Entwicklungsprototyp · 2026</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Content\DemoContent;
|
||||||
|
use App\Content\DocumentValidator;
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use App\Domain\InvalidDocument;
|
||||||
|
use App\Domain\StaleWrite;
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use App\Kernel;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use DoctrineMigrations\Version20260912000100;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class CmsTest extends TestCase
|
||||||
|
{
|
||||||
|
private Connection $db;
|
||||||
|
private PageRepository $pages;
|
||||||
|
private string $path;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
||||||
|
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||||
|
$schema = new Schema();
|
||||||
|
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
||||||
|
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||||
|
$this->db->executeStatement($sql);
|
||||||
|
}
|
||||||
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
|
||||||
|
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||||||
|
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
||||||
|
}
|
||||||
|
$this->pages = new PageRepository($this->db, new DocumentValidator());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->db->close();
|
||||||
|
gc_collect_cycles();
|
||||||
|
unlink($this->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDocumentRoundTripAndDraftPrivacy(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
self::assertNull($this->pages->findPublished('home'));
|
||||||
|
self::assertSame($document, json_decode($this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$id]), true));
|
||||||
|
$this->pages->publish('page-1', $id, 1);
|
||||||
|
self::assertSame($document, $this->pages->findPublished('home')['document']);
|
||||||
|
$document['title'] = 'Private change';
|
||||||
|
$this->pages->saveDraft('page-1', $document, 2);
|
||||||
|
self::assertNotSame('Private change', $this->pages->findPublished('home')['document']['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStaleSaveRollsBackWithoutOrphanRevision(): void
|
||||||
|
{
|
||||||
|
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||||
|
try {
|
||||||
|
$this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||||
|
self::fail('Stale write was accepted.');
|
||||||
|
} catch (StaleWrite) {
|
||||||
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||||
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForeignRevisionCannotBePublished(): void
|
||||||
|
{
|
||||||
|
$id = $this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->pages->publish('page-1', $id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicationRejectsStaleVersion(): void
|
||||||
|
{
|
||||||
|
$id = $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0);
|
||||||
|
$this->expectException(StaleWrite::class);
|
||||||
|
$this->pages->publish('page-1', $id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublishedRevisionCanBeRestoredAndImmediatelyChangesOutput(): void
|
||||||
|
{
|
||||||
|
$original = DemoContent::pages()['home'];
|
||||||
|
$first = $this->pages->saveDraft('page-1', $original, 0);
|
||||||
|
$this->pages->publish('page-1', $first, 1);
|
||||||
|
$changed = $original;
|
||||||
|
$changed['title'] = 'Second revision';
|
||||||
|
$second = $this->pages->saveDraft('page-1', $changed, 2);
|
||||||
|
$this->pages->publish('page-1', $second, 3);
|
||||||
|
self::assertSame('Second revision', $this->pages->findPublished('home')['document']['title']);
|
||||||
|
$this->pages->publish('page-1', $first, 4);
|
||||||
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||||
|
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidDocuments(): iterable
|
||||||
|
{
|
||||||
|
$base = DemoContent::pages()['home'];
|
||||||
|
$bad = $base;
|
||||||
|
$bad['schemaVersion'] = 999;
|
||||||
|
yield 'future schema' => [$bad];
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][] = $base['blocks'][0];
|
||||||
|
yield 'duplicate IDs' => [$bad];
|
||||||
|
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
||||||
|
yield $url => [$bad];
|
||||||
|
}
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['layout']['tone'] = '" onclick="alert(1)';
|
||||||
|
yield 'style injection' => [$bad];
|
||||||
|
foreach ([['desktop' => ['paddingY' => 'url(evil)']], ['watch' => []], ['mobile' => ['columns' => 'two']], ['desktop' => ['unknown' => 'small']], ['mobile' => ['paddingY' => []]]] as $design) {
|
||||||
|
$bad = $base;
|
||||||
|
$bad['schemaVersion'] = 2;
|
||||||
|
$bad['blocks'][0]['layout']['design'] = $design;
|
||||||
|
yield 'invalid responsive design '.json_encode($design) => [$bad];
|
||||||
|
}
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'large']];
|
||||||
|
yield 'v1 cannot silently use v2 settings' => [$bad];
|
||||||
|
foreach ([2, 3] as $version) {
|
||||||
|
$bad = $base;
|
||||||
|
$bad['schemaVersion'] = $version;
|
||||||
|
$bad['blocks'][0]['layout']['effects'] = ['shadow' => 2 === $version ? 'soft' : 'evil'];
|
||||||
|
yield 'invalid effect version '.$version => [$bad];
|
||||||
|
}
|
||||||
|
$bad = $base;
|
||||||
|
$bad['schemaVersion'] = 3;
|
||||||
|
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => [$base['blocks'][0]]];
|
||||||
|
yield 'gallery forbids containers' => [$bad];
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][] = ['id' => 'gallery', 'type' => 'gallery', 'children' => []];
|
||||||
|
yield 'gallery requires v3' => [$bad];
|
||||||
|
foreach (['javascript:alert(1)', '/image,400w'] as $source) {
|
||||||
|
$bad = $base;
|
||||||
|
$bad['schemaVersion'] = 3;
|
||||||
|
$bad['blocks'][] = ['id' => 'unsafe-image', 'type' => 'image', 'props' => ['src' => '/safe.png', 'alt' => 'Test', 'decorative' => false, 'width' => 400, 'height' => 300, 'sources' => [['src' => $source, 'width' => 400]]]];
|
||||||
|
yield 'unsafe srcset '.$source => [$bad];
|
||||||
|
}
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['children'][1]['props']['level'] = 9;
|
||||||
|
yield 'invalid heading' => [$bad];
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['children'][0]['type'] = 'unknown-plugin';
|
||||||
|
yield 'missing plugin cannot be silently resaved' => [$bad];
|
||||||
|
$bad = $base;
|
||||||
|
$bad['blocks'][0]['children'][] = ['id' => 'image', 'type' => 'image', 'props' => ['src' => '/image.png', 'alt' => '', 'decorative' => false, 'width' => 100, 'height' => 100]];
|
||||||
|
yield 'missing meaningful alt' => [$bad];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidDocuments')]
|
||||||
|
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidDocument::class);
|
||||||
|
$this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInitialHtmlEscapesContentAndHasNoEditorScripts(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$document['blocks'][0]['children'][0]['props']['text'] = '<script>alert("xss")</script>';
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||||
|
$this->pages->publish('page-1', $id, 1);
|
||||||
|
$response = $this->request('/');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertStringContainsString('<script>', $response->getContent());
|
||||||
|
self::assertStringNotContainsString('<script', $response->getContent());
|
||||||
|
self::assertStringContainsString('<h1', $response->getContent());
|
||||||
|
self::assertStringContainsString('rel="canonical" href="https://cms.test/"', $response->getContent());
|
||||||
|
self::assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResponsiveRevisionPreservesOriginalAndRendersSafeClasses(): void
|
||||||
|
{
|
||||||
|
$original = DemoContent::pages()['home'];
|
||||||
|
$old = $this->pages->saveDraft('page-1', $original, 0);
|
||||||
|
$this->pages->publish('page-1', $old, 1);
|
||||||
|
$document = $original;
|
||||||
|
$document['schemaVersion'] = 2;
|
||||||
|
$document['blocks'][0]['layout']['design'] = ['desktop' => ['paddingY' => 'huge'], 'mobile' => ['paddingY' => 'small']];
|
||||||
|
$document['blocks'][0]['children'][1]['layout']['design'] = ['desktop' => ['fontSize' => 'hero'], 'mobile' => ['fontSize' => 'title']];
|
||||||
|
$new = $this->pages->saveDraft('page-1', $document, 2);
|
||||||
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||||
|
self::assertSame($document, $this->pages->findForEditor('page-1')['document']);
|
||||||
|
$this->pages->publish('page-1', $new, 3);
|
||||||
|
$html = $this->request('/')->getContent();
|
||||||
|
self::assertStringContainsString('d-mobile-paddingY-small', $html);
|
||||||
|
self::assertStringContainsString('d-desktop-fontSize-hero d-mobile-fontSize-title', $html);
|
||||||
|
self::assertStringContainsString('/assets/design.css', $html);
|
||||||
|
self::assertStringNotContainsString('style=', $html);
|
||||||
|
self::assertStringNotContainsString('<script', $html);
|
||||||
|
$this->pages->publish('page-1', $old, 4);
|
||||||
|
self::assertSame($original, $this->pages->findPublished('home')['document']);
|
||||||
|
self::assertStringNotContainsString('/assets/design.css', $this->request('/')->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftAndUnknownUrlsAre404AndWritesAreNotExposed(): void
|
||||||
|
{
|
||||||
|
$this->pages->saveDraft('page-2', DemoContent::pages()['home'], 0);
|
||||||
|
foreach (['/private', '/missing', '/editor'] as $url) {
|
||||||
|
self::assertSame(404, $this->request($url)->getStatusCode());
|
||||||
|
}
|
||||||
|
self::assertSame(405, $this->request('/', 'POST')->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHomeAliasRedirectsToCanonical(): void
|
||||||
|
{
|
||||||
|
$response = $this->request('/home');
|
||||||
|
self::assertSame(301, $response->getStatusCode());
|
||||||
|
self::assertSame('/', $response->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $url, string $method = 'GET'): Response
|
||||||
|
{
|
||||||
|
$previous = [];
|
||||||
|
foreach (['APP_SECRET' => 'test-only', 'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->path), 'SITE_ORIGIN' => 'https://cms.test'] as $key => $value) {
|
||||||
|
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
|
||||||
|
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||||
|
}
|
||||||
|
$kernel = new Kernel('test', false);
|
||||||
|
try {
|
||||||
|
return $kernel->handle(Request::create($url, $method));
|
||||||
|
} finally {
|
||||||
|
$kernel->shutdown();
|
||||||
|
foreach ($previous as $key => [$server, $env]) {
|
||||||
|
if (null === $server) {
|
||||||
|
unset($_SERVER[$key]);
|
||||||
|
} else {
|
||||||
|
$_SERVER[$key] = $server;
|
||||||
|
}
|
||||||
|
if (null === $env) {
|
||||||
|
unset($_ENV[$key]);
|
||||||
|
} else {
|
||||||
|
$_ENV[$key] = $env;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use App\Content\DemoContent;
|
||||||
|
use App\Content\DocumentValidator;
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use App\Kernel;
|
||||||
|
use App\Security\UserProvider;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use DoctrineMigrations\Version20260912000100;
|
||||||
|
use DoctrineMigrations\Version20260912000200;
|
||||||
|
use DoctrineMigrations\Version20260912000300;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
|
final class EditorSecurityTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private Connection $db;
|
||||||
|
private string $path;
|
||||||
|
private array $previous;
|
||||||
|
|
||||||
|
protected static function getKernelClass(): string
|
||||||
|
{
|
||||||
|
return Kernel::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->path = tempnam(sys_get_temp_dir(), 'kant-auth-');
|
||||||
|
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||||
|
$this->previous = [$_SERVER, $_ENV];
|
||||||
|
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||||
|
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||||
|
}
|
||||||
|
$this->db = ConnectionFactory::create($url);
|
||||||
|
$schema = new Schema();
|
||||||
|
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
||||||
|
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
|
||||||
|
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
|
||||||
|
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||||
|
$this->db->executeStatement($sql);
|
||||||
|
}
|
||||||
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||||
|
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
||||||
|
$pages = new PageRepository($this->db, new DocumentValidator());
|
||||||
|
$revision = $pages->saveDraft('p1', DemoContent::pages()['home'], 0);
|
||||||
|
$pages->publish('p1', $revision, 1);
|
||||||
|
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||||
|
$this->db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
$this->db->close();
|
||||||
|
gc_collect_cycles();
|
||||||
|
unlink($this->path);
|
||||||
|
foreach (glob($this->path.'-media/*') ?: [] as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
if (is_dir($this->path.'-media')) {
|
||||||
|
rmdir($this->path.'-media');
|
||||||
|
}
|
||||||
|
[$_SERVER, $_ENV] = $this->previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function login(string $username = 'admin'): KernelBrowser
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$crawler = $client->request('GET', '/admin/login');
|
||||||
|
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => 'Fixture-only-password']));
|
||||||
|
self::assertResponseRedirects('/admin');
|
||||||
|
$client->followRedirect();
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(KernelBrowser $client): array
|
||||||
|
{
|
||||||
|
$client->request('GET', '/admin/api/pages/p1');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
return json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function post(KernelBrowser $client, string $action, array $data, string $token = ''): void
|
||||||
|
{
|
||||||
|
$client->request('POST', '/admin/api/pages/p1/'.$action, server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnonymousCannotReadDraftOrWrite(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
foreach (['/admin', '/admin/pages/p1', '/admin/api/pages/p1', '/admin/media', '/admin/api/media'] as $url) {
|
||||||
|
$client->request('GET', $url);
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
self::assertStringNotContainsString('schemaVersion', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
foreach (['draft', 'publish', 'preview'] as $action) {
|
||||||
|
$this->post($client, $action, []);
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMediaUploadReencodingCsrfMetadataConflictAndArchive(): void
|
||||||
|
{
|
||||||
|
$client = $this->login('editor');
|
||||||
|
$client->request('GET', '/admin/api/media');
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = json_decode($client->getResponse()->getContent(), true)['csrfToken'];
|
||||||
|
$client->request('POST', '/admin/api/media/upload');
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'kant-image-');
|
||||||
|
try {
|
||||||
|
file_put_contents($path, '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
|
||||||
|
$client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'fake.jpg', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]);
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
|
||||||
|
$image = imagecreatetruecolor(1800, 1000);
|
||||||
|
imagepng($image, $path);
|
||||||
|
imagedestroy($image);
|
||||||
|
file_put_contents($path, '<?php SECRET_SOURCE_PAYLOAD ?>', FILE_APPEND);
|
||||||
|
$client->request('POST', '/admin/api/media/upload', files: ['file' => new UploadedFile($path, 'example.php', test: true)], server: ['HTTP_X_CSRF_TOKEN' => $token]);
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$item = json_decode($client->getResponse()->getContent(), true)['item'];
|
||||||
|
self::assertSame([480, 960, 1600], array_column($item['variants'], 'width'));
|
||||||
|
foreach ($item['variants'] as $variant) {
|
||||||
|
$file = $this->path.'-media/'.$item['id'].'-'.$variant['width'].'.webp';
|
||||||
|
self::assertSame('image/webp', getimagesize($file)['mime']);
|
||||||
|
self::assertStringNotContainsString('SECRET_SOURCE_PAYLOAD', file_get_contents($file));
|
||||||
|
}
|
||||||
|
$update = ['title' => 'Testbild', 'alt' => 'Dunkle Testfläche', 'version' => 1, 'archived' => true];
|
||||||
|
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
|
||||||
|
self::assertResponseStatusCodeSame(409);
|
||||||
|
$client->request('GET', $item['variants'][0]['src']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertResponseHeaderSame('Content-Type', 'image/webp');
|
||||||
|
self::assertResponseHeaderSame('X-Content-Type-Options', 'nosniff');
|
||||||
|
$client->request('GET', '/media/'.$item['id'].'/999.webp');
|
||||||
|
self::assertResponseStatusCodeSame(404);
|
||||||
|
$update['version'] = 2;
|
||||||
|
$update['archived'] = false;
|
||||||
|
$client->request('POST', '/admin/api/media/'.$item['id'], server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token], content: json_encode($update));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSame(0, (int) $this->db->fetchOne('SELECT archived FROM media'));
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPasswordUpgradeRefreshesTheSessionUser(): void
|
||||||
|
{
|
||||||
|
$provider = new UserProvider($this->db);
|
||||||
|
$user = $provider->loadUserByIdentifier('admin');
|
||||||
|
$hash = password_hash('Fixture-only-password', PASSWORD_BCRYPT, ['cost' => 5]);
|
||||||
|
$provider->upgradePassword($user, $hash);
|
||||||
|
self::assertSame($hash, $user->getPassword());
|
||||||
|
self::assertSame($hash, $provider->refreshUser($user)->getPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangedRolesInvalidateAnExistingSession(): void
|
||||||
|
{
|
||||||
|
$client = $this->login();
|
||||||
|
$this->db->executeStatement('UPDATE cms_user SET roles = ? WHERE username = ?', ['["ROLE_EDITOR"]', 'admin']);
|
||||||
|
$client->request('GET', '/admin/api/pages/p1');
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginCsrfAndPasswordAreRequired(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$client->request('POST', '/admin/login', ['_username' => 'admin', '_password' => 'Fixture-only-password']);
|
||||||
|
self::assertResponseRedirects('/admin/login');
|
||||||
|
$crawler = $client->followRedirect();
|
||||||
|
self::assertSelectorExists('[role="alert"]');
|
||||||
|
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => 'admin', '_password' => 'wrong']));
|
||||||
|
self::assertResponseRedirects('/admin/login');
|
||||||
|
$client->request('GET', '/admin');
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsrfProtectsEveryPostAndMalformedBodyIsRejected(): void
|
||||||
|
{
|
||||||
|
$client = $this->login();
|
||||||
|
$data = $this->data($client);
|
||||||
|
foreach (['draft', 'publish', 'preview'] as $action) {
|
||||||
|
$this->post($client, $action, ['document' => $data['document'], 'expectedVersion' => $data['version'], 'revisionId' => $data['revisionId']], 'invalid');
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => '2'], $data['csrfToken']);
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
$client->request('POST', '/admin/api/pages/p1/draft', server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']], content: '{bad-json');
|
||||||
|
self::assertResponseStatusCodeSame(422);
|
||||||
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditorCanSaveButCannotPublish(): void
|
||||||
|
{
|
||||||
|
$client = $this->login('editor');
|
||||||
|
$data = $this->data($client);
|
||||||
|
$data['document']['title'] = 'Private draft';
|
||||||
|
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$saved = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']);
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
$client->request('GET', '/');
|
||||||
|
self::assertStringNotContainsString('Private draft', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreviewSaveConflictAndPublishRoundTrip(): void
|
||||||
|
{
|
||||||
|
$client = $this->login();
|
||||||
|
$data = $this->data($client);
|
||||||
|
$document = $data['document'];
|
||||||
|
$document['blocks'][0]['children'][1]['props']['text'] = 'An edited heading';
|
||||||
|
$this->post($client, 'preview', ['document' => $document], $data['csrfToken']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertStringContainsString('An edited heading', $client->getResponse()->getContent());
|
||||||
|
self::assertStringContainsString('no-store', $client->getResponse()->headers->get('Cache-Control'));
|
||||||
|
self::assertStringContainsString('noindex', $client->getResponse()->headers->get('X-Robots-Tag'));
|
||||||
|
$this->post($client, 'draft', ['document' => $document, 'expectedVersion' => 2], $data['csrfToken']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$saved = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertSame($document, $saved['document']);
|
||||||
|
self::assertSame(3, $saved['version']);
|
||||||
|
$this->post($client, 'draft', ['document' => $data['document'], 'expectedVersion' => 2], $data['csrfToken']);
|
||||||
|
self::assertResponseStatusCodeSame(409);
|
||||||
|
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $data['revisionId']], $data['csrfToken']);
|
||||||
|
self::assertResponseStatusCodeSame(409);
|
||||||
|
$this->post($client, 'publish', ['expectedVersion' => 3, 'revisionId' => $saved['revisionId']], $data['csrfToken']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
self::assertStringContainsString('An edited heading', $client->getResponse()->getContent());
|
||||||
|
self::assertStringNotContainsString('editor.js', $client->getResponse()->getContent());
|
||||||
|
self::assertFalse($client->getResponse()->headers->has('Set-Cookie'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutInvalidatesSessionAndNeedsCsrf(): void
|
||||||
|
{
|
||||||
|
$client = $this->login();
|
||||||
|
$client->request('POST', '/admin/logout', ['_csrf_token' => 'wrong']);
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
$crawler = $client->request('GET', '/admin');
|
||||||
|
$client->submit($crawler->selectButton('Abmelden')->form());
|
||||||
|
self::assertResponseRedirects('/admin/login');
|
||||||
|
$client->request('GET', '/admin');
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Isolated test data only. Never touches the configured application database.
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
|
use App\Content\DemoContent;
|
||||||
|
use App\Content\DocumentValidator;
|
||||||
|
use App\Content\PageRepository;
|
||||||
|
use App\Infrastructure\ConnectionFactory;
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use DoctrineMigrations\Version20260912000100;
|
||||||
|
use DoctrineMigrations\Version20260912000200;
|
||||||
|
use DoctrineMigrations\Version20260912000300;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||||
|
if (!is_dir($directory)) {
|
||||||
|
mkdir($directory, 0775, true);
|
||||||
|
}
|
||||||
|
$path = $directory.'/'.bin2hex(random_bytes(12)).'.db';
|
||||||
|
$url = 'sqlite:///'.str_replace('\\', '/', $path);
|
||||||
|
$db = ConnectionFactory::create($url);
|
||||||
|
$schema = new Schema();
|
||||||
|
(new Version20260912000100($db, new NullLogger()))->up($schema);
|
||||||
|
(new Version20260912000200($db, new NullLogger()))->up($schema);
|
||||||
|
(new Version20260912000300($db, new NullLogger()))->up($schema);
|
||||||
|
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||||
|
$db->executeStatement($sql);
|
||||||
|
}
|
||||||
|
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||||
|
$pages = new PageRepository($db, new DocumentValidator());
|
||||||
|
foreach (DemoContent::pages() as $slug => $document) {
|
||||||
|
$id = 'test-'.$slug;
|
||||||
|
$db->insert('page', ['id' => $id, 'slug' => $slug, 'locale' => 'de', 'website_id' => 'default', 'version' => 0]);
|
||||||
|
$revision = $pages->saveDraft($id, $document, 0);
|
||||||
|
$pages->publish($id, $revision, 1);
|
||||||
|
}
|
||||||
|
$password = bin2hex(random_bytes(20));
|
||||||
|
$username = 'browser-'.bin2hex(random_bytes(6));
|
||||||
|
$db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => '["ROLE_ADMIN"]']);
|
||||||
|
$images = [];
|
||||||
|
foreach (['Berge', 'Küste'] as $index => $title) {
|
||||||
|
$image = imagecreatetruecolor(1800, 1200);
|
||||||
|
for ($y = 0; $y < 1200; ++$y) {
|
||||||
|
$color = imagecolorallocate($image, 110 + (int) ($y / 18), 150 + (int) ($y / 30), 155 + (int) ($y / 28));
|
||||||
|
imageline($image, 0, $y, 1799, $y, $color);
|
||||||
|
}
|
||||||
|
imagefilledellipse($image, 1300, 270, 210, 210, imagecolorallocate($image, 245, 223, 169));
|
||||||
|
imagefilledpolygon($image, [0, 1000, 420, 350 + $index * 150, 950, 980, 1400, 550, 1800, 1100, 1800, 1200, 0, 1200], imagecolorallocate($image, 42, 83 + $index * 20, 75));
|
||||||
|
$imagePath = $path.'-'.$title.'.png';
|
||||||
|
imagepng($image, $imagePath);
|
||||||
|
imagedestroy($image);
|
||||||
|
$images[] = $imagePath;
|
||||||
|
}
|
||||||
|
echo json_encode(['databaseUrl' => $url, 'mediaDirectory' => $path.'-media', 'images' => $images, 'username' => $username, 'password' => $password]);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, blockPath, insertMedia, setEffect } from '../public/assets/editor-model.js';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { designCss } from '../scripts/build-design.mjs';
|
||||||
|
|
||||||
|
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
|
||||||
|
|
||||||
|
test('typing coalesces into one undo and redo; saved state tracks content, not stack length', () => {
|
||||||
|
const history = new EditorHistory(fixture());
|
||||||
|
history.change(doc => doc.title = 'New', 'title');
|
||||||
|
history.change(doc => doc.title = 'New title', 'title');
|
||||||
|
assert.equal(history.past.length, 1);
|
||||||
|
history.undo(); assert.equal(history.document.title, 'Page'); assert.equal(history.dirty, false);
|
||||||
|
history.redo(); assert.equal(history.document.title, 'New title'); assert.equal(history.dirty, true);
|
||||||
|
history.markSaved(history.document); assert.equal(history.dirty, false);
|
||||||
|
history.undo(); assert.equal(history.dirty, true);
|
||||||
|
history.redo(); assert.equal(history.dirty, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new edit after undo drops redo and source snapshots remain immutable', () => {
|
||||||
|
const original = fixture(); const history = new EditorHistory(original);
|
||||||
|
history.change(doc => locate(doc.blocks, 't1').block.props.text = 'changed');
|
||||||
|
history.undo(); history.change(doc => doc.title = 'Branch');
|
||||||
|
assert.equal(history.future.length, 0); assert.equal(original.title, 'Page');
|
||||||
|
assert.equal(locate(original.blocks, 't1').block.props.text, 'Body');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add into selected container, add after leaf, and duplicate assigns fresh IDs recursively', () => {
|
||||||
|
const document = fixture();
|
||||||
|
const id = insertBlock(document, 'text', 's2'); assert.equal(locate(document.blocks, id).parent.id, 's2');
|
||||||
|
const after = insertBlock(document, 'button', 't1'); assert.equal(document.blocks[0].children[2].id, after);
|
||||||
|
const copy = duplicateBlock(document, 's1');
|
||||||
|
const copied = locate(document.blocks, copy).block;
|
||||||
|
assert.notEqual(copied.children[0].id, 'h1'); assert.equal(copied.children[0].props.level, 2);
|
||||||
|
assert.equal(document.blocks[0].children[0].props.level, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving prevents self/descendant cycles and supports cross-container moves', () => {
|
||||||
|
const document = fixture();
|
||||||
|
assert.equal(moveBlock(document, 's1', 'h1', true), false);
|
||||||
|
assert.equal(moveBlock(document, 's1', 's1', true), false);
|
||||||
|
assert.equal(moveBlock(document, 'h1', 't1', true), false);
|
||||||
|
assert.equal(moveBlock(document, 't1', 's2', true), true);
|
||||||
|
assert.equal(locate(document.blocks, 't1').parent.id, 's2');
|
||||||
|
assert.equal(moveBlock(document, 's2', 's1'), true);
|
||||||
|
assert.equal(document.blocks[0].id, 's2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history persists exact block document after add, move, delete, undo and JSON reload', () => {
|
||||||
|
const history = new EditorHistory(fixture());
|
||||||
|
const id = history.change(doc => insertBlock(doc, 'image', 's2'));
|
||||||
|
history.change(doc => moveBlock(doc, id, 't1'));
|
||||||
|
const saved = structuredClone(history.document);
|
||||||
|
history.change(doc => { const target = locate(doc.blocks, id); target.siblings.splice(target.index, 1); });
|
||||||
|
history.undo(); assert.deepEqual(history.document, saved);
|
||||||
|
const loaded = new EditorHistory(JSON.parse(JSON.stringify(history.document)));
|
||||||
|
assert.deepEqual(loaded.document, saved); assert.equal(loaded.dirty, false);
|
||||||
|
assert.throws(() => createBlock('unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v1 to v2 migration preserves all content and IDs, is idempotent and rejects future versions', () => {
|
||||||
|
const original = fixture(); const migrated = upgradeDocument(structuredClone(original));
|
||||||
|
assert.deepEqual(migrated, { ...original, schemaVersion: 2 });
|
||||||
|
assert.deepEqual(upgradeDocument(structuredClone(migrated)), migrated);
|
||||||
|
assert.throws(() => upgradeDocument({ ...original, schemaVersion: 999 }));
|
||||||
|
assert.equal(original.schemaVersion, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('responsive inheritance, reset, migration undo, and reload preserve distinct device values', () => {
|
||||||
|
const history = new EditorHistory(fixture());
|
||||||
|
history.change(doc => setDesign(doc, 'h1', 'desktop', 'fontSize', 'hero'));
|
||||||
|
assert.equal(history.document.schemaVersion, 2);
|
||||||
|
const heading = () => locate(history.document.blocks, 'h1').block;
|
||||||
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'hero', source: 'desktop' });
|
||||||
|
history.undo(); assert.deepEqual(history.document, fixture()); history.redo();
|
||||||
|
history.change(doc => setDesign(doc, 'h1', 'tablet', 'fontSize', 'display'));
|
||||||
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', 'title'));
|
||||||
|
assert.equal(effectiveDesign(heading(), 'desktop', 'fontSize').value, 'hero');
|
||||||
|
assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
||||||
|
history.change(doc => setDesign(doc, 'h1', 'mobile', 'fontSize', ''));
|
||||||
|
assert.deepEqual(effectiveDesign(heading(), 'mobile', 'fontSize'), { value: 'display', source: 'tablet' });
|
||||||
|
history.undo(); assert.equal(effectiveDesign(heading(), 'mobile', 'fontSize').value, 'title');
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(history.document)), history.document);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets insert after containing section, keep a single H1, unique IDs, and undo atomically', () => {
|
||||||
|
const history = new EditorHistory(fixture());
|
||||||
|
for (const name of ['intro', 'columns', 'cta']) {
|
||||||
|
const id = history.change(doc => insertPreset(doc, name, 'h1'));
|
||||||
|
assert.equal(history.document.blocks[1].id, id);
|
||||||
|
assert.deepEqual(blockPath(history.document.blocks, 'h1').map(block => block.id), ['s1', 'h1']);
|
||||||
|
}
|
||||||
|
const nodes = [];
|
||||||
|
const visit = blocks => blocks.forEach(block => { nodes.push(block); visit(block.children || []); });
|
||||||
|
visit(history.document.blocks);
|
||||||
|
assert.equal(new Set(nodes.map(block => block.id)).size, nodes.length);
|
||||||
|
assert.equal(nodes.filter(block => block.type === 'heading' && block.props.level === 1).length, 1);
|
||||||
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('committed CSS matches the shared validated token manifest', async () => {
|
||||||
|
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
||||||
|
assert.equal(await readFile(new URL('../public/assets/design.css', import.meta.url), 'utf8'), designCss(tokens));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v3 media and effects keep originals, gallery restrictions, and atomic undo', () => {
|
||||||
|
const history = new EditorHistory(fixture());
|
||||||
|
const media = { id: 'a'.repeat(32), alt: 'Landscape', variants: [{src: '/media/a/480.webp', width: 480, height: 320}] };
|
||||||
|
const gallery = history.change(doc => insertMedia(doc, 's2', [media, media]));
|
||||||
|
assert.equal(history.document.schemaVersion, 3);
|
||||||
|
const children = locate(history.document.blocks, gallery).block.children;
|
||||||
|
assert.equal(children.length, 2); assert.notEqual(children[0].id, children[1].id);
|
||||||
|
assert.equal(moveBlock(history.document, 'h1', gallery, true), false);
|
||||||
|
assert.equal(moveBlock(history.document, 'h1', children[0].id), false);
|
||||||
|
const text = history.change(doc => insertBlock(doc, 'text', children[0].id));
|
||||||
|
assert.notEqual(locate(history.document.blocks, text).parent?.id, gallery);
|
||||||
|
history.undo();
|
||||||
|
history.change(doc => setEffect(doc, gallery, 'shadow', 'soft'));
|
||||||
|
history.change(doc => setDesign(doc, gallery, 'mobile', 'columns', 'one'));
|
||||||
|
assert.equal(history.document.schemaVersion, 3);
|
||||||
|
history.undo(); history.undo(); history.undo(); assert.deepEqual(history.document, fixture());
|
||||||
|
});
|
||||||
Reference in new issue
Block a user