From 82b07820bc58c2596db2cca2965c837e5b1dcaca Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Sat, 12 Sep 2026 10:19:05 +0200 Subject: [PATCH] =?UTF-8?q?Kant=20CMS:=20Grundger=C3=BCst,=20visueller=20E?= =?UTF-8?q?ditor,=20Mediathek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env | 5 + .gitattributes | 3 + .github/workflows/ci.yml | 85 + .gitignore | 11 + .php-cs-fixer.dist.php | 5 + AGENTS.md | 51 + ENTWICKLUNGSPLAN.md | 163 + README.md | 112 + bin/console | 13 + bin/setup.php | 13 + cli-config.php | 17 + composer.json | 38 + composer.lock | 7766 +++++++++++++++++ config/bootstrap.php | 13 + config/bundles.php | 7 + config/packages/framework.yaml | 19 + config/packages/security.yaml | 29 + config/packages/test/framework.yaml | 4 + config/packages/test/security.yaml | 5 + config/packages/twig.yaml | 3 + config/routes.yaml | 3 + config/services.yaml | 16 + docs/ARCHITECTURE.md | 106 + docs/QUALITY.md | 78 + docs/REQUIREMENTS.md | 37 + docs/STATUS.md | 59 + docs/quality/editor-browser.json | 41 + docs/quality/editor-design-browser.json | 45 + docs/quality/editor-design-lighthouse.json | 144 + docs/quality/editor-media-browser.json | 57 + docs/quality/editor-media-lighthouse.json | 144 + docs/quality/editor-public-lighthouse.json | 144 + docs/quality/lighthouse-summary.json | 144 + migrations.php | 7 + migrations/Version20260912000100.php | 54 + migrations/Version20260912000200.php | 35 + migrations/Version20260912000300.php | 37 + package-lock.json | 1425 +++ package.json | 17 + phpunit.xml.dist | 4 + public/.htaccess | 5 + public/assets/admin.css | 12 + public/assets/design-tokens.json | 295 + public/assets/design.css | 137 + public/assets/editor-model.js | 194 + public/assets/editor.js | 403 + public/assets/enhancements.css | 5 + public/assets/image-placeholder.svg | 1 + public/assets/media.js | 101 + public/assets/preview.css | 2 + public/assets/site.css | 6 + public/favicon.svg | 1 + public/index.php | 11 + scripts/audit.mjs | 49 + scripts/browser-check.mjs | 48 + scripts/build-design.mjs | 10 + scripts/editor-check.mjs | 324 + src/Command/CreateUserCommand.php | 62 + src/Command/SaveDocumentCommand.php | 43 + src/Command/SeedDemoCommand.php | 42 + src/Content/DemoContent.php | 89 + src/Content/DocumentValidator.php | 138 + src/Content/MediaLibrary.php | 140 + src/Content/PageRepository.php | 87 + src/Controller/EditorController.php | 115 + src/Controller/MediaController.php | 80 + src/Controller/PageController.php | 41 + src/Controller/SecurityController.php | 27 + src/Domain/CmsUser.php | 46 + src/Domain/InvalidDocument.php | 7 + src/Domain/StaleWrite.php | 7 + .../AdminHeadersSubscriber.php | 28 + src/Infrastructure/ConnectionFactory.php | 22 + src/Kernel.php | 11 + src/Security/UserProvider.php | 58 + templates/admin/base.html.twig | 16 + templates/admin/editor.html.twig | 42 + templates/admin/login.html.twig | 21 + templates/admin/media.html.twig | 4 + templates/admin/pages.html.twig | 27 + templates/blocks/block.html.twig | 30 + templates/not-found.html.twig | 3 + templates/page.html.twig | 29 + tests/CmsTest.php | 248 + tests/EditorSecurityTest.php | 259 + tests/browser-fixture.php | 55 + tests/editor-model.test.mjs | 123 + 87 files changed, 14463 insertions(+) create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 AGENTS.md create mode 100644 ENTWICKLUNGSPLAN.md create mode 100644 README.md create mode 100644 bin/console create mode 100644 bin/setup.php create mode 100644 cli-config.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/bootstrap.php create mode 100644 config/bundles.php create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/test/framework.yaml create mode 100644 config/packages/test/security.yaml create mode 100644 config/packages/twig.yaml create mode 100644 config/routes.yaml create mode 100644 config/services.yaml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/QUALITY.md create mode 100644 docs/REQUIREMENTS.md create mode 100644 docs/STATUS.md create mode 100644 docs/quality/editor-browser.json create mode 100644 docs/quality/editor-design-browser.json create mode 100644 docs/quality/editor-design-lighthouse.json create mode 100644 docs/quality/editor-media-browser.json create mode 100644 docs/quality/editor-media-lighthouse.json create mode 100644 docs/quality/editor-public-lighthouse.json create mode 100644 docs/quality/lighthouse-summary.json create mode 100644 migrations.php create mode 100644 migrations/Version20260912000100.php create mode 100644 migrations/Version20260912000200.php create mode 100644 migrations/Version20260912000300.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml.dist create mode 100644 public/.htaccess create mode 100644 public/assets/admin.css create mode 100644 public/assets/design-tokens.json create mode 100644 public/assets/design.css create mode 100644 public/assets/editor-model.js create mode 100644 public/assets/editor.js create mode 100644 public/assets/enhancements.css create mode 100644 public/assets/image-placeholder.svg create mode 100644 public/assets/media.js create mode 100644 public/assets/preview.css create mode 100644 public/assets/site.css create mode 100644 public/favicon.svg create mode 100644 public/index.php create mode 100644 scripts/audit.mjs create mode 100644 scripts/browser-check.mjs create mode 100644 scripts/build-design.mjs create mode 100644 scripts/editor-check.mjs create mode 100644 src/Command/CreateUserCommand.php create mode 100644 src/Command/SaveDocumentCommand.php create mode 100644 src/Command/SeedDemoCommand.php create mode 100644 src/Content/DemoContent.php create mode 100644 src/Content/DocumentValidator.php create mode 100644 src/Content/MediaLibrary.php create mode 100644 src/Content/PageRepository.php create mode 100644 src/Controller/EditorController.php create mode 100644 src/Controller/MediaController.php create mode 100644 src/Controller/PageController.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/Domain/CmsUser.php create mode 100644 src/Domain/InvalidDocument.php create mode 100644 src/Domain/StaleWrite.php create mode 100644 src/EventSubscriber/AdminHeadersSubscriber.php create mode 100644 src/Infrastructure/ConnectionFactory.php create mode 100644 src/Kernel.php create mode 100644 src/Security/UserProvider.php create mode 100644 templates/admin/base.html.twig create mode 100644 templates/admin/editor.html.twig create mode 100644 templates/admin/login.html.twig create mode 100644 templates/admin/media.html.twig create mode 100644 templates/admin/pages.html.twig create mode 100644 templates/blocks/block.html.twig create mode 100644 templates/not-found.html.twig create mode 100644 templates/page.html.twig create mode 100644 tests/CmsTest.php create mode 100644 tests/EditorSecurityTest.php create mode 100644 tests/browser-fixture.php create mode 100644 tests/editor-model.test.mjs diff --git a/.env b/.env new file mode 100644 index 0000000..2cfdf64 --- /dev/null +++ b/.env @@ -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" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d65d98b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +AGENTS.md -text +ENTWICKLUNGSPLAN.md -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a6e7ccf --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9062ba9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/.venv/ +/vendor/ +/var/ +/node_modules/ +/.env.local +/.env.*.local +/phpunit.cache/ +/reports/ +/.php-cs-fixer.cache +*.log +/config/reference.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..2af461d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,5 @@ +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'])); diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b34a1b6 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/ENTWICKLUNGSPLAN.md b/ENTWICKLUNGSPLAN.md new file mode 100644 index 0000000..7fbea3d --- /dev/null +++ b/ENTWICKLUNGSPLAN.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b11835d --- /dev/null +++ b/README.md @@ -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. diff --git a/bin/console b/bin/console new file mode 100644 index 0000000..c9ae763 --- /dev/null +++ b/bin/console @@ -0,0 +1,13 @@ +#!/usr/bin/env php +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); diff --git a/bin/setup.php b/bin/setup.php new file mode 100644 index 0000000..96e7d84 --- /dev/null +++ b/bin/setup.php @@ -0,0 +1,13 @@ +=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" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..fbce3e3 --- /dev/null +++ b/composer.lock @@ -0,0 +1,7766 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "89e340d3971805cede2826b7283eb1f2", + "packages": [ + { + "name": "doctrine/dbal", + "version": "4.4.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "reference": "fb9e0ffe15e1590e24dc61c0c0a23f9a33ee42ce", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.4" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-07-21T14:34:40+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/migrations", + "version": "3.9.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/dbal": "^3.6 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2.0", + "php": "^8.1", + "psr/log": "^1.1.3 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/orm": "<2.12 || >=4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.13 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", + "doctrine/sql-formatter": "^1.0", + "ext-pdo_sqlite": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." + }, + "bin": [ + "bin/doctrine-migrations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Migrations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" + } + ], + "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", + "keywords": [ + "database", + "dbal", + "migrations" + ], + "support": { + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.7" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", + "type": "tidelift" + } + ], + "time": "2026-04-23T19:33:20+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "6c521e19a99e8ae57e22aa85b9271534513ee850" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/6c521e19a99e8ae57e22aa85b9271534513ee850", + "reference": "6c521e19a99e8ae57e22aa85b9271534513ee850", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "^1.0.3", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2", + "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e", + "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-20T09:55:18+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "reference": "f318ac9da5aba0be2cf43ecdd562c05a33bc11c0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/99b5b14953237c89ed24f1af5ba34225caf598a4", + "reference": "99b5b14953237c89ed24f1af5ba34225caf598a4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/process": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-26T10:22:28+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:09:28+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf", + "reference": "45d6d66b6ea1ef7a3c4193dfe606370f0f80c9bf", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.15|~8.0.15|^8.1.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4.43|>=7.0,<7.4.15|>=8.0,<8.0.15", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4.44|>=7.0,<7.4.17|>=8.0,<8.1.5", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4.43|^7.4.15|^8.0.15", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.4|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", + "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T21:24:29+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/password-hasher", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/18a7d92126c95962f7efbcc9e421ba710a366847", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/property-access", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3", + "reference": "c3dce76a6697c6428f7d80d5cd1cf0aa5d9679f3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:37:26+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, + { + "name": "symfony/rate-limiter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/6703d040ab83401b27f3c7b29ff4c8c8106fedba", + "reference": "6703d040ab83401b27f3c7b29ff4c8c8106fedba", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T15:34:54+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:12:36+00:00" + }, + { + "name": "symfony/security-bundle", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/caaa8b92f2657a6b99057f4da36ee89f3baab506", + "reference": "caaa8b92f2657a6b99057f4da36ee89f3baab506", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/console": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-client": "<6.4", + "symfony/ldap": "<6.4", + "symfony/serializer": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.15|^4.0", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SecurityBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-bundle/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T17:23:54+00:00" + }, + { + "name": "symfony/security-core", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "4fac226354298b8d66f680275b0daef1d3f580d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/4fac226354298b8d66f680275b0daef1d3f580d2", + "reference": "4fac226354298b8d66f680275b0daef1d3f580d2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/ldap": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Core\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:47:26+00:00" + }, + { + "name": "symfony/security-csrf", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-csrf.git", + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/security-core": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Csrf\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - CSRF Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-csrf/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/security-http", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "ca575e23a2b5e068ce0cdf157767b63b438125e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/ca575e23a2b5e068ce0cdf157767b63b438125e0", + "reference": "ca575e23a2b5e068ce0cdf157767b63b438125e0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/clock": "<6.4", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T17:23:54+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "413485ee177ad5b321ad9a689a90fd3f2d3fefd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/413485ee177ad5b321ad9a689a90fd3f2d3fefd7", + "reference": "413485ee177ad5b321ad9a689a90fd3f2d3fefd7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.21|^4.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<6.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/workflow": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:04:42+00:00" + }, + { + "name": "symfony/twig-bundle", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "e3d2bea0b594aa50c3482a278a5cd09d83d94517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e3d2bea0b594aa50c3482a278a5cd09d83d94517", + "reference": "e3d2bea0b594aa50c3482a278a5cd09d83d94517", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/twig-bridge": "^7.3|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "conflict": { + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "reference": "62d0ad6995630a4f7ea8fd270aadb3e2545c8e31", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T15:52:48+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:52+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T00:47:26+00:00" + }, + { + "name": "twig/twig", + "version": "v3.28.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-07-03T20:44:34+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.25", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", + "reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-09-08T11:11:37+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" + }, + "time": "2026-08-18T06:18:41+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "7aff5c4b17a7b04ea6a8390773a93148d15a40b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/7aff5c4b17a7b04ea6a8390773a93148d15a40b7", + "reference": "7aff5c4b17a7b04ea6a8390773a93148d15a40b7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:03:40+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v7.4.17", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "6b037d62595666cf65de867073c9a1f00184be0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/6b037d62595666cf65de867073c9a1f00184be0c", + "reference": "6b037d62595666cf65de867073c9a1f00184be0c", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.17" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "058d17fc284cce14efb2385783b55014a461b176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-iconv": "*", + "ext-gd": "*", + "ext-pdo": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2.12" + }, + "plugin-api-version": "2.9.0" +} diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..9fd5e5c --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 0000000..f434e09 --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,7 @@ + ['all' => true], + Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], +]; diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 0000000..8479d6c --- /dev/null +++ b/config/packages/framework.yaml @@ -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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..48cc485 --- /dev/null +++ b/config/packages/security.yaml @@ -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 } diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml new file mode 100644 index 0000000..ee44ae5 --- /dev/null +++ b/config/packages/test/framework.yaml @@ -0,0 +1,4 @@ +framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/test/security.yaml b/config/packages/test/security.yaml new file mode 100644 index 0000000..5cc7445 --- /dev/null +++ b/config/packages/test/security.yaml @@ -0,0 +1,5 @@ +security: + password_hashers: + App\Domain\CmsUser: + algorithm: bcrypt + cost: 4 diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..22bc8e3 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,3 @@ +twig: + default_path: '%kernel.project_dir%/templates' + strict_variables: true diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 0000000..9286e81 --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1,3 @@ +controllers: + resource: ../src/Controller/ + type: attribute diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000..ad42fc0 --- /dev/null +++ b/config/services.yaml @@ -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)%' diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f6bd32c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/QUALITY.md b/docs/QUALITY.md new file mode 100644 index 0000000..b4861c9 --- /dev/null +++ b/docs/QUALITY.md @@ -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-Regr​ession: 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. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md new file mode 100644 index 0000000..93c956c --- /dev/null +++ b/docs/REQUIREMENTS.md @@ -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. diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..1ab57c0 --- /dev/null +++ b/docs/STATUS.md @@ -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. diff --git a/docs/quality/editor-browser.json b/docs/quality/editor-browser.json new file mode 100644 index 0000000..6286987 --- /dev/null +++ b/docs/quality/editor-browser.json @@ -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 +} \ No newline at end of file diff --git a/docs/quality/editor-design-browser.json b/docs/quality/editor-design-browser.json new file mode 100644 index 0000000..f691165 --- /dev/null +++ b/docs/quality/editor-design-browser.json @@ -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 +} \ No newline at end of file diff --git a/docs/quality/editor-design-lighthouse.json b/docs/quality/editor-design-lighthouse.json new file mode 100644 index 0000000..7d5edd9 --- /dev/null +++ b/docs/quality/editor-design-lighthouse.json @@ -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." + } +] \ No newline at end of file diff --git a/docs/quality/editor-media-browser.json b/docs/quality/editor-media-browser.json new file mode 100644 index 0000000..de1195e --- /dev/null +++ b/docs/quality/editor-media-browser.json @@ -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 +} \ No newline at end of file diff --git a/docs/quality/editor-media-lighthouse.json b/docs/quality/editor-media-lighthouse.json new file mode 100644 index 0000000..1c97f00 --- /dev/null +++ b/docs/quality/editor-media-lighthouse.json @@ -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." + } +] \ No newline at end of file diff --git a/docs/quality/editor-public-lighthouse.json b/docs/quality/editor-public-lighthouse.json new file mode 100644 index 0000000..829de51 --- /dev/null +++ b/docs/quality/editor-public-lighthouse.json @@ -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." + } +] \ No newline at end of file diff --git a/docs/quality/lighthouse-summary.json b/docs/quality/lighthouse-summary.json new file mode 100644 index 0000000..c338b87 --- /dev/null +++ b/docs/quality/lighthouse-summary.json @@ -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." + } +] \ No newline at end of file diff --git a/migrations.php b/migrations.php new file mode 100644 index 0000000..b53804d --- /dev/null +++ b/migrations.php @@ -0,0 +1,7 @@ + ['table_name' => 'migration_versions'], + 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], + 'all_or_nothing' => false, +]; diff --git a/migrations/Version20260912000100.php b/migrations/Version20260912000100.php new file mode 100644 index 0000000..74b5aaf --- /dev/null +++ b/migrations/Version20260912000100.php @@ -0,0 +1,54 @@ +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.'); + } +} diff --git a/migrations/Version20260912000200.php b/migrations/Version20260912000200.php new file mode 100644 index 0000000..d9cb98c --- /dev/null +++ b/migrations/Version20260912000200.php @@ -0,0 +1,35 @@ +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.'); + } +} diff --git a/migrations/Version20260912000300.php b/migrations/Version20260912000300.php new file mode 100644 index 0000000..20cd92e --- /dev/null +++ b/migrations/Version20260912000300.php @@ -0,0 +1,37 @@ +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.'); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c28011c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1425 @@ +{ + "name": "kant-cms-quality", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kant-cms-quality", + "devDependencies": { + "@axe-core/playwright": "^4.10.0", + "lighthouse": "^13.0.0", + "playwright": "^1.55.0" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@paulirish/trace_engine": { + "version": "0.0.65", + "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.65.tgz", + "integrity": "sha512-Qsm6F5C8xf6ZzQXbQc2+wcpe6sggfs/gvc/ytqSurdvYg3kyW0ECHCqE0CWBKZpqgjVfPNX9c7SCS3r2nEIRGg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "legacy-javascript": "latest", + "third-party-web": "latest" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.74.0.tgz", + "integrity": "sha512-u9rY8vcZfktccwm6LznfCZlqP5C9A+p76r4/pFS1grqpuTO0m21Cl8rosnlESrDGP/Xd9tfr91rWYk0jPH8jeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.74.0.tgz", + "integrity": "sha512-u1wsarTOWHn9CCev81Da5T4IQHZgdcosXRfX2+4DMII/lVJMBYesKixTQuMwXexjVG2+pkf16zTmgjZDa+75jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0", + "@sentry/node-core": "10.74.0", + "@sentry/opentelemetry": "10.74.0", + "@sentry/server-utils": "10.74.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.74.0.tgz", + "integrity": "sha512-btgZXcGmkOGgojbxHo/gfGiyqYpzC9E8zVR78Q3MtM6Xnemk9gwJiQlhNmEX/FM+C36WBRPZrdcZcnMaJhbfJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0", + "@sentry/opentelemetry": "10.74.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.74.0.tgz", + "integrity": "sha512-ua5mt0NDBfye+/ACKjAw9Ad2i+y42lOLvhxXZepZXjszblMz80MEsIZflMB7uLZUCTNH7MbZN8tlzCy8KsJKmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.74.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.74.0.tgz", + "integrity": "sha512-AHmPIGE8yVRyywpZRhAkm/H0cHTgsQSPhFZFbaeQLcYHFE/eOP9bAMh8Nj/Apr7WMagFXYHd04tR/63BxtLgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.74.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", + "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/csp_evaluator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.8.tgz", + "integrity": "sha512-EwOnfYuNbTytvbMKsLixTrRgnjOa0WZCxGy8A9nnSYAicrdwn+T/epU/yjgymmOxlgKnvH+8wXt+7p/8ak5Feg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1663043", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1663043.tgz", + "integrity": "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/es-module-lexer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.2.tgz", + "integrity": "sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-link-header": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.4.tgz", + "integrity": "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/image-ssim": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/image-ssim/-/image-ssim-0.2.0.tgz", + "integrity": "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-in-the-middle": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.1.tgz", + "integrity": "sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-library-detector": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/js-library-detector/-/js-library-detector-6.7.0.tgz", + "integrity": "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/legacy-javascript": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", + "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lighthouse": { + "version": "13.4.1", + "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-13.4.1.tgz", + "integrity": "sha512-fDu8lt3QLK/lTqIxtp1HkzQNJ32rsFHhbadYOepcMZFLgA8oINhxutMbMv8XXnpTOvZ0TXCo4JCk1LDTWaRLnA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@paulirish/trace_engine": "0.0.65", + "@sentry/node": "^10.0.0", + "axe-core": "^4.12.1", + "chrome-launcher": "^1.2.1", + "configstore": "^7.0.0", + "csp_evaluator": "1.1.8", + "devtools-protocol": "0.0.1663043", + "enquirer": "^2.3.6", + "http-link-header": "^1.1.1", + "intl-messageformat": "^10.5.3", + "jpeg-js": "^0.4.4", + "js-library-detector": "^6.7.0", + "lighthouse-logger": "^2.0.2", + "lighthouse-stack-packs": "1.12.3", + "lodash-es": "^4.17.21", + "lookup-closest-locale": "6.2.0", + "open": "^8.4.0", + "puppeteer-core": "^25.3.0", + "robots-parser": "^3.0.1", + "speedline-core": "^1.4.3", + "third-party-web": "^0.29.2", + "tldts-icann": "^7.4.9", + "web-features": "^3.34.0", + "ws": "^7.0.0", + "yargs": "^17.3.1", + "yargs-parser": "^21.0.0" + }, + "bin": { + "chrome-debug": "core/scripts/manual-chrome-launcher.js", + "lighthouse": "cli/index.js", + "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js" + }, + "engines": { + "node": ">=22.19" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-stack-packs": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/lighthouse-stack-packs/-/lighthouse-stack-packs-1.12.3.tgz", + "integrity": "sha512-d8IsOpE83kbANgnM+Tp8+x6HcMpX9o2ITBiUERssgzAIFdZCQzs/f4k6D0DLQTE59enml9mbAOU52Wu35exWtg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lookup-closest-locale": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", + "integrity": "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", + "integrity": "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/puppeteer-core": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core/node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/robots-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz", + "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/speedline-core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/speedline-core/-/speedline-core-1.4.3.tgz", + "integrity": "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "image-ssim": "^0.2.0", + "jpeg-js": "^0.4.1" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, + "node_modules/third-party-web": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.29.2.tgz", + "integrity": "sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts-icann": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-7.4.12.tgz", + "integrity": "sha512-KYMFombFttNwGKI9GhqlHBn/+QROOk2bvh5p+aVmRSF8VH+YYVS9fEq0ux099jzw0JXmk2yRErCiviveSL/QhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-features": { + "version": "3.38.0", + "resolved": "https://registry.npmjs.org/web-features/-/web-features-3.38.0.tgz", + "integrity": "sha512-FfexTigrIL9tVr/JgX9kBlzIlMfBG4eD38crZQiJPIo46PJ3Qj8RVUM7zmpsozooq2ImHM7xiUY6gIv5mo/7rQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e8f34d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..2ed5662 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,4 @@ + + + tests + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..d221f02 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,5 @@ +DirectoryIndex index.php +Options -Indexes -MultiViews +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^ index.php [L] diff --git a/public/assets/admin.css b/public/assets/admin.css new file mode 100644 index 0000000..b20f590 --- /dev/null +++ b/public/assets/admin.css @@ -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}.left-panel,.right-panel{background:white;overflow:auto}.left-panel{border-right:1px solid var(--line)}.right-panel{border-left:1px solid var(--line)}.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}.panel-section .eyebrow{margin-bottom:10px}.panel-section h2{font-size:14px;margin-bottom:12px}.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}.library button span{font-size:19px;line-height:1}.panel-title{display:flex;justify-content:space-between;align-items:center}.panel-title h2{margin-bottom:0}.panel-title+.panel-hint{margin-top:10px}.structure-list{list-style:none;margin:0;padding:0}.structure-list .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:5px;padding:9px 7px;font-size:11px;min-height:34px}.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item:hover{background:#f3f6ef}.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}.tree-icon{font-size:12px;min-width:15px}.drop-target{outline:2px dashed #315b49}.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}.block-actions .danger-text{color:#943a2e}.canvas-panel{min-width:0;display:flex;flex-direction:column;background:#e9ede7;overflow:hidden}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 20px;background:#f6f8f2;border-bottom:1px solid var(--line);min-height:59px}.canvas-label{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#57655d}.viewport-controls{display:flex;gap:3px;background:#e8eee1;border-radius:7px;padding:3px}.viewport-controls button{padding:5px 9px;font-size:10px;min-height:28px;border:0;background:transparent}.viewport-controls [aria-pressed=true]{background:white;box-shadow:0 1px 3px #00000012}.canvas-toolbar>a{text-decoration:none;font-size:18px}.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:22px;display:flex;justify-content:center;align-items:flex-start}#preview{background:white;border:0;box-shadow:0 3px 20px #203b3410;min-height:600px;height:100%;flex-shrink:0}.viewport-desktop{width:100%}.viewport-tablet{width:768px;max-width:100%}.viewport-mobile{width:390px;max-width:100%}.preview-status{font-size:10px;padding:9px 20px;color:#52614f;margin:0;min-height:30px;background:#f3f5ed}.right-panel label{font-size:11px}.right-panel input,.right-panel select,.right-panel textarea{font-size:12px}.right-panel textarea{min-height:150px}.field-help{font-size:10px;line-height:1.5;color:var(--muted);margin-top:7px}.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:20px}.check-label{display:flex;align-items:center;gap:8px}.check-label input{width:16px;min-height:16px}.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 20px;font-size:12px}.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}[hidden]{display:none!important}dialog{max-width:450px;width:calc(100% - 40px);padding:28px;border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #203b3433}dialog::backdrop{background:#172e2966}dialog p{font-size:14px;line-height:1.7;color:var(--muted)}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px} +@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}} diff --git a/public/assets/design-tokens.json b/public/assets/design-tokens.json new file mode 100644 index 0000000..44f9f3f --- /dev/null +++ b/public/assets/design-tokens.json @@ -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" + ] + } + } +} diff --git a/public/assets/design.css b/public/assets/design.css new file mode 100644 index 0000000..b745967 --- /dev/null +++ b/public/assets/design.css @@ -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} +} diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js new file mode 100644 index 0000000..2bf5abc --- /dev/null +++ b/public/assets/editor-model.js @@ -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; } +} diff --git a/public/assets/editor.js b/public/assets/editor.js new file mode 100644 index 0000000..e658ff3 --- /dev/null +++ b/public/assets/editor.js @@ -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(); diff --git a/public/assets/enhancements.css b/public/assets/enhancements.css new file mode 100644 index 0000000..52f68c2 --- /dev/null +++ b/public/assets/enhancements.css @@ -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)}}} + diff --git a/public/assets/image-placeholder.svg b/public/assets/image-placeholder.svg new file mode 100644 index 0000000..ad0b248 --- /dev/null +++ b/public/assets/image-placeholder.svg @@ -0,0 +1 @@ + diff --git a/public/assets/media.js b/public/assets/media.js new file mode 100644 index 0000000..db04aa6 --- /dev/null +++ b/public/assets/media.js @@ -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 = `

BILDER FÜR DEINE WEBSITE

Medienverwaltung

+

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.

+
+ +

Wird geladen …

+

Ein Bild auswählen, um seine Angaben zu bearbeiten.

+ `; + 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()); diff --git a/public/assets/preview.css b/public/assets/preview.css new file mode 100644 index 0000000..95329bc --- /dev/null +++ b/public/assets/preview.css @@ -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} diff --git a/public/assets/site.css b/public/assets/site.css new file mode 100644 index 0000000..9d39f15 --- /dev/null +++ b/public/assets/site.css @@ -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}} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..d6620cb --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..a88c923 --- /dev/null +++ b/public/index.php @@ -0,0 +1,11 @@ +handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/scripts/audit.mjs b/scripts/audit.mjs new file mode 100644 index 0000000..9198c83 --- /dev/null +++ b/scripts/audit.mjs @@ -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(); +} diff --git a/scripts/browser-check.mjs b/scripts/browser-check.mjs new file mode 100644 index 0000000..f5347df --- /dev/null +++ b/scripts/browser-check.mjs @@ -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(); +} diff --git a/scripts/build-design.mjs b/scripts/build-design.mjs new file mode 100644 index 0000000..11fa831 --- /dev/null +++ b/scripts/build-design.mjs @@ -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)); +} diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs new file mode 100644 index 0000000..08dd576 --- /dev/null +++ b/scripts/editor-check.mjs @@ -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(); +} diff --git a/src/Command/CreateUserCommand.php b/src/Command/CreateUserCommand.php new file mode 100644 index 0000000..5e21919 --- /dev/null +++ b/src/Command/CreateUserCommand.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/src/Command/SaveDocumentCommand.php b/src/Command/SaveDocumentCommand.php new file mode 100644 index 0000000..0ad1349 --- /dev/null +++ b/src/Command/SaveDocumentCommand.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/src/Command/SeedDemoCommand.php b/src/Command/SeedDemoCommand.php new file mode 100644 index 0000000..fbf0325 --- /dev/null +++ b/src/Command/SeedDemoCommand.php @@ -0,0 +1,42 @@ +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; + } +} diff --git a/src/Content/DemoContent.php b/src/Content/DemoContent.php new file mode 100644 index 0000000..66bdb84 --- /dev/null +++ b/src/Content/DemoContent.php @@ -0,0 +1,89 @@ + [ + '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)]]; + } +} diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php new file mode 100644 index 0000000..e602e82 --- /dev/null +++ b/src/Content/DocumentValidator.php @@ -0,0 +1,138 @@ +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); + } + } +} diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php new file mode 100644 index 0000000..d999ccd --- /dev/null +++ b/src/Content/MediaLibrary.php @@ -0,0 +1,140 @@ +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; + } +} diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php new file mode 100644 index 0000000..4563098 --- /dev/null +++ b/src/Content/PageRepository.php @@ -0,0 +1,87 @@ +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; + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php new file mode 100644 index 0000000..72f0e38 --- /dev/null +++ b/src/Controller/EditorController.php @@ -0,0 +1,115 @@ +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()]; + } +} diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php new file mode 100644 index 0000000..f224416 --- /dev/null +++ b/src/Controller/MediaController.php @@ -0,0 +1,80 @@ +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'"]); + } +} diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php new file mode 100644 index 0000000..ef89685 --- /dev/null +++ b/src/Controller/PageController.php @@ -0,0 +1,41 @@ + '[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; + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..1ea0e8a --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,27 @@ +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.'); + } +} diff --git a/src/Domain/CmsUser.php b/src/Domain/CmsUser.php new file mode 100644 index 0000000..21e4c86 --- /dev/null +++ b/src/Domain/CmsUser.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/src/Domain/InvalidDocument.php b/src/Domain/InvalidDocument.php new file mode 100644 index 0000000..e047993 --- /dev/null +++ b/src/Domain/InvalidDocument.php @@ -0,0 +1,7 @@ + ['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'"); + } +} diff --git a/src/Infrastructure/ConnectionFactory.php b/src/Infrastructure/ConnectionFactory.php new file mode 100644 index 0000000..bf1c0d3 --- /dev/null +++ b/src/Infrastructure/ConnectionFactory.php @@ -0,0 +1,22 @@ + '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; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..779cd1f --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,11 @@ +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); + } + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig new file mode 100644 index 0000000..e663b3c --- /dev/null +++ b/templates/admin/base.html.twig @@ -0,0 +1,16 @@ + + + + + + + {% block title %}Verwaltung{% endblock %} · Kant CMS + + + {% block scripts %}{% endblock %} + + + + {% block body %}{% endblock %} + + diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig new file mode 100644 index 0000000..a53f8ac --- /dev/null +++ b/templates/admin/editor.html.twig @@ -0,0 +1,42 @@ +{% extends 'admin/base.html.twig' %} +{% block title %}{{ page.document.title }} bearbeiten{% endblock %} +{% block body_class %}editor-body{% endblock %} +{% block scripts %}{% endblock %} +{% block body %} +
+
+ ← Seiten +

{{ page.document.title }}

/{{ page.slug == 'home' ? '' : page.slug }}
+
+
Wird geladen …{% if is_granted('ROLE_PUBLISHER') %}{% endif %}
+
+ + +
+ +
+
LIVE-VORSCHAU
↗
+

Vorschau wird geladen …

+ +
+
+ +
+ +

Seite veröffentlichen?

Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.

+

Serverstand laden?

Lokale Änderungen und dein Undo-Verlauf werden ersetzt. Sichere deine Änderungen vorher als JSON.

+
+{% endblock %} diff --git a/templates/admin/login.html.twig b/templates/admin/login.html.twig new file mode 100644 index 0000000..8dff3a4 --- /dev/null +++ b/templates/admin/login.html.twig @@ -0,0 +1,21 @@ +{% extends 'admin/base.html.twig' %} +{% block title %}Anmelden{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+ kantcms +

DEIN RAUM ZUM GESTALTEN

+

Willkommen zurück.

+

Melde dich an, um deine Seiten zu bearbeiten.

+ {% if error %}{% endif %} +
+ + + + + + +
+ Zur öffentlichen Website +
+{% endblock %} diff --git a/templates/admin/media.html.twig b/templates/admin/media.html.twig new file mode 100644 index 0000000..ffd2f62 --- /dev/null +++ b/templates/admin/media.html.twig @@ -0,0 +1,4 @@ +{% extends 'admin/base.html.twig' %} +{% block title %}Medienverwaltung{% endblock %} +{% block scripts %}{% endblock %} +{% block body %}
← Seitenübersicht

DEINE BILDER

Medienverwaltung

Bilder hochladen, Angaben pflegen und in deinen Seiten wiederverwenden.

{% endblock %} diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig new file mode 100644 index 0000000..411ee43 --- /dev/null +++ b/templates/admin/pages.html.twig @@ -0,0 +1,27 @@ +{% extends 'admin/base.html.twig' %} +{% block title %}Deine Seiten{% endblock %} +{% block body %} +
+ kantcms +
Website ansehen ↗{{ app.user.userIdentifier }}
+
+
+

DEINE WEBSITE

Gute Inhalte beginnen hier.

+

Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.

+ Medienverwaltung +

Seiten {{ pages|length }}

Entwürfe bleiben bis zur Veröffentlichung privat.
+
+ {% for page in pages %} +
+ +

{{ page.title }}

/{{ page.slug == 'home' ? '' : page.slug }} · {{ page.locale|upper }}

+ {{ page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert' }} + Bearbeiten +
+ {% else %} +

Noch keine Seiten vorhanden.

Die Beispielseiten können über das lokale Setup angelegt werden.

+ {% endfor %} +
+

Kant CMS · Visueller Editor, erster Prototyp

+
+{% endblock %} diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig new file mode 100644 index 0000000..95e1940 --- /dev/null +++ b/templates/blocks/block.html.twig @@ -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' %}
{% endif %} + {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %} + {% if type == 'section' %}
{% endif %} + +{% elseif type == 'heading' %} + {{ props.text }} +{% elseif type == 'text' %} +

{{ props.text }}

+{% elseif type == 'button' %} + {{ props.text }} +{% elseif type == 'image' %} +
{{ props.alt }}{% if props.caption|default('') %}
{{ props.caption }}
{% endif %}
+{% elseif type == 'gallery' %} +
+ {% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child} only %}{% endfor %} +
+{% else %} +

Dieser Inhaltsblock ist derzeit nicht verfügbar.

+{% endif %} diff --git a/templates/not-found.html.twig b/templates/not-found.html.twig new file mode 100644 index 0000000..8078f04 --- /dev/null +++ b/templates/not-found.html.twig @@ -0,0 +1,3 @@ + +Seite nicht gefunden · Kant CMS +

404 · Kant CMS

Hier ist noch keine Seite.

Die Adresse existiert nicht oder die Seite ist noch nicht veröffentlicht.

Zur Startseite
diff --git a/templates/page.html.twig b/templates/page.html.twig new file mode 100644 index 0000000..50aaeb3 --- /dev/null +++ b/templates/page.html.twig @@ -0,0 +1,29 @@ + + + + + + {{ page.document.title }} + + + + + {% if page.document.schemaVersion >= 2 %}{% endif %} + {% if page.document.schemaVersion >= 3 %}{% endif %} + + + + +
+ {% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block} only %}{% endfor %} +
+
kantcms

Eine Grundlage. Viele Möglichkeiten.

Lokaler Entwicklungsprototyp · 2026
+ + diff --git a/tests/CmsTest.php b/tests/CmsTest.php new file mode 100644 index 0000000..3e1d14a --- /dev/null +++ b/tests/CmsTest.php @@ -0,0 +1,248 @@ +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'] = ''; + $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('getContent()); + self::assertStringContainsString('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('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; + } + } + } + } +} diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php new file mode 100644 index 0000000..5018ce0 --- /dev/null +++ b/tests/EditorSecurityTest.php @@ -0,0 +1,259 @@ +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, ''); + $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, '', 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'); + } +} diff --git a/tests/browser-fixture.php b/tests/browser-fixture.php new file mode 100644 index 0000000..599a42c --- /dev/null +++ b/tests/browser-fixture.php @@ -0,0 +1,55 @@ +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]); diff --git a/tests/editor-model.test.mjs b/tests/editor-model.test.mjs new file mode 100644 index 0000000..5c859e4 --- /dev/null +++ b/tests/editor-model.test.mjs @@ -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()); +});