From e15b2517b131988f7368305af3e5e25d86a8ce6c Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Fri, 9 Oct 2026 16:56:52 +0200 Subject: [PATCH] Translate pages, areas, menus, forms and posts field by field instead of rebuilding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/ARCHITECTURE.md | 32 +- docs/STATUS.md | 70 ++++ migrations/Version20261009000100.php | 44 +++ public/assets/admin.css | 57 +++- public/assets/editor-model.js | 12 +- public/assets/editor.css | 32 ++ public/assets/editor.js | 269 ++++++++++++++- public/assets/form-editor.js | 32 ++ public/assets/form-steps.js | 26 +- public/assets/library-previews.js | 1 + public/assets/menu-editor.js | 6 + public/assets/site.css | 6 + public/assets/translate.js | 160 +++++++++ scripts/admin-quality-check.mjs | 8 + scripts/editor-check.mjs | 3 +- src/Content/Consent.php | 7 +- src/Content/DocumentValidator.php | 64 +++- src/Content/FormRepository.php | 158 ++++++++- src/Content/Languages.php | 137 ++++++++ src/Content/MenuRepository.php | 27 +- src/Content/PageRepository.php | 245 +++++++++++++- src/Content/PartialRepository.php | 4 +- src/Content/PostRepository.php | 51 ++- src/Content/SiteSettings.php | 76 +++++ src/Content/Translations.php | 275 +++++++++++++++ src/Controller/EditorController.php | 51 ++- src/Controller/FormAdminController.php | 58 +++- src/Controller/FormController.php | 23 +- src/Controller/MenuController.php | 13 +- src/Controller/PageController.php | 253 ++++++++++++-- src/Controller/PartialController.php | 36 +- src/Controller/SeoController.php | 20 ++ src/Controller/SettingsController.php | 47 ++- src/Twig/LanguageExtension.php | 155 +++++++++ templates/admin/base.html.twig | 2 +- templates/admin/editor.html.twig | 5 +- templates/admin/forms.html.twig | 118 ++++--- templates/admin/menu-entry.html.twig | 3 + templates/admin/menus.html.twig | 2 +- templates/admin/settings.html.twig | 47 +++ templates/blocks/block.html.twig | 38 ++- templates/blocks/form.html.twig | 25 +- templates/blocks/post-list.html.twig | 14 +- templates/blocks/post-nav.html.twig | 6 +- templates/consent-banner.html.twig | 18 +- templates/page.html.twig | 17 +- tests/EditorSecurityTest.php | 8 +- tests/TestSchema.php | 2 + tests/TranslationTest.php | 444 +++++++++++++++++++++++++ tests/translate.test.mjs | 55 +++ 51 files changed, 3045 insertions(+), 219 deletions(-) create mode 100644 migrations/Version20261009000100.php create mode 100644 public/assets/translate.js create mode 100644 src/Content/Languages.php create mode 100644 src/Content/Translations.php create mode 100644 src/Twig/LanguageExtension.php create mode 100644 tests/TranslationTest.php create mode 100644 tests/translate.test.mjs diff --git a/README.md b/README.md index 6668021..29885d4 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv **Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis. -`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). 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. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore `. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. +`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). 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. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore `. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. ## Verwaltung ab 1.7.0 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f63fab..6ccb523 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,36 @@ # Architekturentscheidungen -Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. +Stand: 09.10.2026. Arbeitstitel KantSites; Produktname offen. + +## ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026) + +Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „`/en/`-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte `locale` an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht. + +**Entscheidung.** Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations.` nur Texte, adressiert über die stabile Block-ID: `{slug?, title?, description?, seoTitle?, blocks: {: {: text}}, source: {.: fnv1a32}}`. Welche Eigenschaften übersetzbar sind, steht einmal in `Translations::FIELDS` und gespiegelt in `translate.js` (ein Test vergleicht beide): Texte von Überschrift, Text, Button, Bild (Alt, Unterschrift), Symbolkarte, Kennzahl, Zitat, Aufruf-Band, Kontakt (Anschrift), Akkordeon, Tab, Rechtszeile, Beitragsnavigation, Symbol, Fortschritt, Externer Inhalt. Aufbau, Gestaltung, Bilder und Links gibt es nur einmal. + +**Warum im Dokument.** So gehören Übersetzungen zu jeder Fassung: Entwurf, Veröffentlichen, Wiederherstellen und Undo im Editor gelten für alle Sprachen zugleich, ohne zweiten Revisions- und Veröffentlichungsweg. Veraltete Schreibzugriffe erkennt dieselbe Versionsprüfung. Ein gelöschter Block nimmt beim Speichern seine Übersetzungen mit (`Translations::normalise`). Folge: Veröffentlichen gilt für alle Sprachen einer Seite gemeinsam; eine Sprache einzeln zurückhalten geht nur, indem man ihre Texte leert. + +**Ausgeliefert wird eine Sprache durch Überlagerung.** `Translations::apply` legt die Texte auf eine Kopie des Dokuments und entfernt `translations`; danach laufen dieselbe Prüfung (`DocumentValidator`, auch je Sprache beim Speichern) und dieselben Vorlagen wie für die Hauptsprache. Kein Renderer muss Sprachen kennen, und eine Übersetzung kann keine Länge, keine Auszeichnungsregel und keine H1-Regel umgehen; formatierter Text geht durch denselben `RichText`-Filter. Ein leeres Feld zeigt den Text der Hauptsprache. Ein dekoratives Bild bekommt auch übersetzt keinen Alternativtext. + +**Adressen.** `/en` ist die Startseite der Sprache, `/en/` eine übersetzte Seite. Der Pfad besteht aus den übersetzten Adressen der Seite und ihrer Vorfahren; fehlt einem Vorfahren die Übersetzung, steht seine Adresse der Hauptsprache dort. Die Tabelle `page_translation (page_id, locale, slug, path)` (Migration `20261009000100`, neue Tabelle, kein Eingriff in `page`) spiegelt genau den veröffentlichten Stand: Zeilen entstehen und verschwinden beim Veröffentlichen, beim Verschieben einer Seite werden alle Pfade neu berechnet. Doppelte Adressen werden beim Speichern des Entwurfs abgelehnt; eine geänderte veröffentlichte Adresse bekommt eine 301-Weiterleitung (`en/alt` → `en/neu`). Eine Wurzelseite darf nicht wie eine aktive Sprache heißen, und eine Sprache lässt sich nicht einschalten, solange eine Seite ihre Adresse belegt. + +**Links.** Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf deren Übersetzung – Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. Umgesetzt als eine Ersetzung über die fertige Ausgabe (`PageController::localiseLinks`, nur ``, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen `hreflang` und bleiben unberührt. + +**SEO.** ``, `og:locale` samt `og:locale:alternate`, Canonical auf die übersetzte Adresse, `hreflang` je veröffentlichter Sprache und `x-default` auf die Hauptsprache, die übersetzten Adressen in der Sitemap. Eine Seite ohne Übersetzung bekommt keine Alternativen. Ohne eigenen übersetzten Suchtitel nimmt die Seite den übersetzten Titel, nie den Suchtitel der Hauptsprache. + +**Einsprachig bleibt alles wie vorher.** Sprachen stehen unter Einstellungen → Sprachen (`settings.languages`, nur Administratoren). Ohne weitere Sprache gibt es keinen Umschalter, keine Felder, keine `/en`-Route, kein `hreflang`. Wird eine Sprache abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben im Dokument. + +**Editor.** Sprachumschalter DE | EN neben der Vorschaugröße. In einer weiteren Sprache zeigt die Seitenleiste die Liste aller Texte in Seitenreihenfolge, je mit Original, Feld und Zustand (fehlt / übersetzt / „Original geändert“, erkannt am FNV-1a-Vermerk), Filter, „Original übernehmen“ und „Übersetzung passt“. Dazu Adresse (mit Vorschlag aus dem übersetzten Titel) und Suchtitel. Die Vorschau zeigt die Seite in der Sprache, ein Klick springt zu den Texten des Blocks. Alles, was den Aufbau ändert (Werkzeugkasten, Struktur, Blockaktionen, Einfügepunkte, Ziehen, Spaltenbreiten, Doppelklick-Bearbeitung, Kontextmenü), ist in dieser Ansicht aus. + +**Baustein „Sprachwahl“ (`language`, Schema v27).** Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist, als Kürzel oder Name; auf einer nur einsprachigen Seite nichts. Kürzel tragen den Sprachnamen für Vorlesesoftware, jeder Link `lang` und `hreflang`, die aktuelle Sprache `aria-current`. + +**Stufe 2: Bereiche, Menüs, feste Texte (09.10.2026).** Kopf, Fuß und verknüpfte Sektionen tragen dieselbe Übersetzungsebene im Dokument und werden im selben Editor mit dem Umschalter übersetzt – ohne Adresse und Seitentitel. Da Bereiche keinen Entwurf haben, gilt eine gespeicherte Übersetzung sofort. Ausgeliefert werden sie wie Seiten: in einer weiteren Sprache überlagert, in der Hauptsprache ohne `translations`; ebenso in der Vorschau von Seite und Bereich. Menüeinträge haben keine stabile ID, deshalb trägt der Eintrag selbst seine Beschriftungen: `labels: {en: "Services"}` (je höchstens 80 Zeichen, bekannte Sprache, leer = Hauptsprache). Sie wandern so beim Umsortieren mit. Die Menüverwaltung zeigt je weitere Sprache ein Feld „Beschriftung Englisch“. Feste Texte der öffentlichen Vorlagen (Sprunglink, Menü-Knopf, Namen der Navigationen, Brotkrumen-Start, Rechtslinks, Einwilligung, Beitragslisten, „Nach oben“) gehen durch den Filter `kant_t` mit einem Wörterbuch in `LanguageExtension::UI` – Englisch vollständig, Französisch und Türkisch teilweise, sonst Englisch als Rückfall. Die Sprache der Anfrage steht im Anfrageattribut `kant_locale`. + +**Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026).** Formulare tragen ihre Übersetzungen in den Einstellungen des Formulars (`settings.translations.`: Erfolgsmeldung, Button, je Feld Beschriftung, Hilfetext, Einheit und Optionen als Zuordnung Original → Anzeige); gepflegt am Feld in der Formularverwaltung. Abgeschickt werden weiter die Optionen der Hauptsprache, damit Bedingungen, Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger unverändert bleiben. Ein verstecktes Feld `language` lässt die Prüfung ihre Meldungen in der Sprache geben (`FormRepository::MESSAGES`) und führt nach dem Absenden auf die übersetzte Seite zurück; das Schrittskript bekommt seine Texte als `data-texts`. Beiträge sind Seiten und werden wie Seiten übersetzt; Listen, Nachbarn und Archive zeigen übersetzte Titel und Auszüge, nicht übersetzte Beiträge in der Hauptsprache. Archive antworten unter `/en/beitraege`, `/en/kategorie/…`, `/en/schlagwort/…` (Präfixe bleiben deutsch). Namen von Kategorien und Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises stehen je Sprache in den Einstellungen (`termNames`, `consentTexts`, Einstellungen → Sprachen). Eine 404 unter `/en/…` antwortet auf Englisch: die eigene 404-Seite mit ihrer Übersetzung, die mitgelieferte über das Wörterbuch. + +**Grenzen.** Feed, Einwilligungsseite `/einwilligung`, Datumsformat und die Präfixe der Archivadressen bleiben in der Hauptsprache. Eigenes HTML und Plugin-Bausteine bleiben unübersetzt. Bilder sind bewusst nicht je Sprache austauschbar (Umut, 09.10.2026). Die Kennzeichnung einzelner nicht übersetzter Texte mit `lang="de"` fehlt. + +**Rückweg.** Ältere Kerne lehnen Dokumente mit Schema v27 ab (wie bei jeder Schemaerhöhung); Installationen zuerst aktualisieren. Die Migration lässt sich zurücknehmen (`down` löscht nur `page_translation`); die Übersetzungen bleiben dann im Dokument, sind aber nicht mehr erreichbar. ## ADR 110 – Menüeinträge als Button (07.10.2026) diff --git a/docs/STATUS.md b/docs/STATUS.md index 3e2c158..25bfa45 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,75 @@ # Projektstatus +## Formularverwaltung übersichtlicher (09.10.2026) + +**Anlass:** Umut: Formulare „sehr unübersichtlich … kompakter, nicht so wuchtig und so voll“; bei mehreren Formularen erst eine Übersicht ohne Vorschau, dann in die Seite rein. + +**Neu:** `/admin/forms` ist eine Übersicht – je Formular eine Zeile mit Feldern, Empfänger, Adresse, übersetzten Sprachen und Zahl der Einsendungen, darunter die Einsendungen. Bearbeitet wird auf `/admin/forms/` (neu: `/admin/forms/neu`), nach dem Speichern bleibt man dort. Im Editor ist jedes Feld eine Zeile (Beschriftung, Typ, Pflicht), alles Weitere unter „Details“ mit einer Kurzinfo darunter (technischer Name, Optionen, Hilfetext, Bedingung, „EN ✓“). Zustellung, Aussehen und je Sprache die Übersetzung sind zuklappbar mit Zusammenfassung; Erklärtexte gekürzt. Ohne JavaScript bleiben alle Felder sichtbar und bedienbar. Beim Testformular der Hüfner-Kopie: Seitenlänge von 4.913 px auf 1.274 px. + +**Robuster:** Ohne weitere Sprache fasst der Kern die Tabelle `page_translation` nicht an – Speichern und Veröffentlichen funktionieren so auch, wenn nach einem Update die Migration noch aussteht (gefunden über die Pilot-Fixture, die ohne Migrationen kopiert). + +**Geprüft:** PHPUnit vollständig 636 grün (vor dem Umbau), danach die betroffenen 59 (Editor-Sicherheit, Formulare, Übersetzung) grün; `npm test`, `lint:twig`, php-cs-fixer; `test:admin --admin-only` und `test:editor` grün inklusive axe auf allen Verwaltungsseiten und im Formulareditor; im Browser Übersicht, Bearbeiten, Speichern, Neu, 404 für unbekannte Adresse, Telefonbreite ohne waagrechtes Scrollen. + +**Bekannt:** `test:admin` ohne `--admin-only` scheitert im Slider-Teil der Pilotseite – ebenso auf dem unveränderten Stand 1.26.0 (eigens in einem separaten Arbeitsverzeichnis geprüft), also kein Folgefehler dieser Änderungen. + +## Mehrsprachigkeit, Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026) + +**Neu (ADR 111, keine Migration):** Formulare je Sprache übersetzbar (in der Formularverwaltung am Feld: Beschriftung, Hilfetext, Optionen, Einheit; am Formular Button und Erfolgsmeldung), Fehlermeldungen und Schrittnavigation in der Sprache, Rückweg nach dem Absenden auf die übersetzte Seite; Einsendungen und E-Mail bleiben deutsch. Beitragslisten und Archive (`/en/beitraege`, `/en/kategorie/…`) mit übersetzten Titeln; Kategorie-/Schlagwortnamen und Einwilligungstext unter Einstellungen → Sprachen. 404 unter `/en/…` auf Englisch. + +**Geprüft:** PHPUnit vollständig **636 grün** (neu: Formular in beiden Sprachen inkl. Absenden/Fehler/Weiterleitung; Beiträge, Archive, Kategorienamen, Einwilligung, 404), `npm test` 41, `lint:twig`, php-cs-fixer. Browser (Testkopie Hüfner): Formularübersetzung gespeichert, `/en` zeigt „Your name (EN) * (required)“ und „Send“, `/` unverändert. **Nicht geprüft:** Formularversand per E-Mail, Lighthouse/axe auf `/en`. + +**Bekannt:** Feed, `/einwilligung`, Datumsformat und Archiv-Präfixe bleiben deutsch. + +## Mehrsprachigkeit, Stufe 2: Kopf, Fuß, Menüs, feste Texte (09.10.2026) + +**Anlass:** Umut: „Bilder lassen wir, mach weiter.“ Bilder bleiben für alle Sprachen gleich. + +**Neu (ADR 111, keine weitere Migration):** + +- **Kopf, Fuß und Sektionen** übersetzen: im Bereichs-Editor derselbe Umschalter DE | EN, die Liste zeigt nur die Texte (keine Adresse, kein Seitentitel). Gespeichert gilt sofort. +- **Menüs:** je Eintrag ein Feld „Beschriftung Englisch“ (leer = deutsch). Auf `/en/…` zeigen die Einträge ihre englische Beschriftung und führen auf die englischen Seiten. +- **Feste Texte** wie „Zum Inhalt springen“, „Menü“, „Hauptnavigation“, „Impressum/Datenschutz“ in der Rechtszeile, Einwilligungsknöpfe, „Weiterlesen“, „Nach oben“ erscheinen auf englischen Seiten englisch (Französisch/Türkisch teilweise, sonst Englisch). + +**Geprüft:** PHPUnit vollständig **634 grün**, darunter neu ein Test für Kopfbereich, Menü und feste Texte in beiden Sprachen; `npm test` 41 grün; `lint:twig`; php-cs-fixer. Im Browser (Testkopie Hüfner, Port 8111): Menü mit „Services“/„References“ gespeichert, `/en` zeigt sie mit Zielen `/en#…`, „Skip to content“, „Main navigation“; Kopfbereich im Editor auf Englisch, „Request a quote“ in Vorschau und auf `/en`, `/` weiter „Angebot anfragen“. **Nicht geprüft:** Lighthouse/axe auf `/en`. + +**Bekannt:** Formulare, Beiträge/Archive, Einwilligungstext aus den Einstellungen und die 404-Seite noch einsprachig. Nach einem Kern-Update cached die App-Vorschau alte Editor-Skripte (gleiche Fassungsnummer) – erst nach Neuladen der Assets sieht man Änderungen. + +**Nächster Schritt:** Stufe 3 – Formulare (Felder, Meldungen, Bestätigungsmail) und Beiträge mit Archiven übersetzbar machen; danach Lighthouse/axe einer `/en`-Seite. + +## Mehrsprachigkeit, Stufe 1: Seiten übersetzen statt neu bauen (09.10.2026) + +**Anlass:** Umut: weitere Sprache hinzufügen und zu jedem Feld die 2. Sprache eintragen, ohne die Seite für die 2. Sprache neu zu bauen; Adressen mit `/en/`-Präfix und übersetzten Slugs. + +**Neu (Kern, Schema v27, Migration `20261009000100`, ADR 111):** + +- **Einstellungen → Sprachen:** Hauptsprache bleibt Deutsch, weitere Sprachen per Häkchen (10 zur Wahl, höchstens 5). Ohne Häkchen ist nirgends etwas zu sehen. +- **Editor:** Umschalter DE | EN oben. In EN zeigt die Seitenleiste alle Texte der Seite mit Original, Zustand (fehlt / übersetzt / Original geändert), Filter, „Original übernehmen“, „Übersetzung passt“; dazu Adresse und Suchtitel. Vorschau in der Sprache, Klick springt zum Text. Aufbau und Gestaltung sind in EN gesperrt. Undo, Speichern und Veröffentlichen wie gewohnt – für alle Sprachen gemeinsam. +- **Website:** `/en` und `/en/<übersetzte-adresse>`, `lang="en"`, Canonical, `hreflang` + `x-default`, `og:locale`, Sitemap. Links auf übersetzte Seiten führen zur Übersetzung. Nicht übersetzte Seiten: 404 unter `/en/…`. Neuer Baustein **„Sprachwahl“** (Kürzel oder Namen). + +**Geprüft:** PHPUnit vollständig (633, ein Fehler durch die Änderung gefunden und behoben, danach die betroffenen 426 Tests erneut grün), neu `tests/TranslationTest.php` (Überlagerung, Prüfung wie Hauptsprache, Adressen beim Veröffentlichen/Verschieben mit Weiterleitung, Kollisionen, HTTP mit hreflang/Links/Sitemap/404). `npm test` 41 grün, neu `tests/translate.test.mjs` (Fingerabdruck gleich PHP, Feldliste gleich Server, Undo). `lint:twig`, php-cs-fixer. Im Browser auf einer Kopie der Hüfner-Datenbank (`var/browser-tests/lang/`, Startkonfiguration `sprachen-test`, Port 8111): Sprache eingeschaltet, Startseite übersetzt, Undo/Redo, Speichern, Veröffentlichen, `/en` mit englischem Titel und `hreflang`, `/en/impressum` 404, Sitemap mit `/en`. **Nicht geprüft:** Lighthouse und axe einer `/en`-Seite und des Bausteins „Sprachwahl“, Tastaturfokus beim Klick in die Vorschau (das Browserfenster der Prüfung hatte keinen Fokus). + +**Bekannt (Stufe 2):** Kopf/Fuß, Menübeschriftungen, Formulare, Beiträge/Archive, Einwilligung, 404 und feste Vorlagentexte bleiben auf englischen Seiten deutsch. Eigenes HTML und Plugin-Bausteine unübersetzbar, Bilder nicht je Sprache. Ändert der Server beim Speichern formatierten Text der Hauptsprache, können Übersetzungen fälschlich als „Original geändert“ erscheinen. Dokumente mit Übersetzungen oder Sprachwahl brauchen einen Kern mit Schema v27. + +**Nächster Schritt:** Stufe 2 – Menüs und Kopf-/Fußbereiche übersetzen, danach Formulare und Beiträge. Vorher mit Umut klären, ob Bilder je Sprache austauschbar sein sollen. + +## Zehnte Installation „Kantarci Reisen“ (08.10.2026) + +**Anlass:** Umut: neue Website nach dem Mockup `Downloads/Kantarci-Reisen-Komplettpaket.zip`, DB und Medienordner nach gleichem Muster, Inhalte dürfen individualisiert werden. Inhaber I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden, info@kantarci-reisen.de. Für reiseSoft ein echter Screenshot aus `Downloads/reiseSoft`, Plattform unter reisesoft.kantarci-reisen.de. + +**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/kantarci-reisen.db`, Medien `var/kantarci-reisen-media`, Bildquellen `var/kantarci-reisen-assets`, Startkonfigurationen `kantarci-reisen` (Port 8104, dev) und `kantarci-reisen-prod` (8105). `sh scripts/reset-kantarci-reisen.sh ` schneidet Logo und Seitensymbol zu (`build-kantarci-reisen-assets.php`), baut mit `seed-kantarci-reisen.php` alles neu und holt DM Sans auf den eigenen Server. Ohne Argumente nimmt es die vorbereiteten Bilder. + +- **Startseite** wie das Mockup: geteilter Hero (Petrol/Küstenfoto), drei Angebote als ganz klickbare Karten (ohne Nummern, auf Umuts Wunsch), „Gute Reisen beginnen mit einem Gespräch“ mit Anschrift, neu „So läuft’s“ in drei Schritten, Anfrage auf Petrol mit CMS-Formular `reiseanfrage` (Versand an info@kantarci-reisen.de, dazu Telefon, Reisezeitraum, Personen), reiseSoft mit echtem Screenshot im Browserrahmen und Knopf zur Plattform. Dazu Impressum (Reisevermittlung), Datenschutz, 404, klebender Kopf, Einblendungen. +- **„Reise anfragen“** ist ein Menü-Button (ADR 110) mit Pfeil, auf dem Telefon neben dem Menü-Knopf. Eigenes Menü-CSS darf `.menu-list a` kein Polster geben, ohne es dem Button zurückzugeben (`.menu-list .menu-button`). +- **Screenshot reiseSoft:** gebaute Fassung von reiseSoft lokal (Port 4199) mit leerem Datenordner im Scratchpad gestartet, Ersteinrichtung mit Testzugang, `scripts/demodaten.mjs` (60 fiktive Vorgänge), Übersicht in 1440×900 @2x aufgenommen. Keine echten Kundendaten. + +**Geprüft:** `test:browser` auf 8104 grün (HTML 66 KB). Lighthouse auf 8105 (Produktion): `/` mobil 98/100/100/100/100, Desktop 100 überall; `/impressum` überall 100 (`reports/lighthouse-kantarci-reisen`). Ganzseitige Screenshots 1440 und 390 px ohne Konsolenfehler und ohne waagrechtes Scrollen. Nicht geprüft: Formularversand per E-Mail. + +**Nachtrag (08.10.2026):** Telefon 0170 2945935 (auch WhatsApp) von Umut; eine USt-IdNr. gibt es nicht. WhatsApp als Anfrageweg aus dem Baustein „Soziale Profile“ (Netzwerk in den Einstellungen, Link mit vorausgefülltem Text): Knopf unter „Über uns“ und neben dem Formular, Link im Fuß, runde Schaltfläche fest unten links (`position:fixed` im eigenen CSS). Datenschutz mit eigenem Punkt zu WhatsApp. „Inhaber“ steht nicht mehr im Fuß, nur im Impressum. Danach erneut `test:browser` grün (HTML 72 KB) und Lighthouse `/` mobil 98, sonst 100; `/datenschutz` überall 100. + +**Bekannt:** Öffnungszeiten und Hoster sind unbekannt und stehen nirgends (Datenschutz nennt den Hoster allgemein). **Die Domain kantarci-reisen.de existiert am 08.10.2026 im DNS nicht** (NXDOMAIN bei Fritz.box, 1.1.1.1 und 8.8.8.8) – damit kommen weder Mails an info@kantarci-reisen.de an noch ist reisesoft.kantarci-reisen.de erreichbar. Vor dem Livegang klären. Das Reisefoto ist laut Paket KI-generiert, so im Bildnachweis. Grenzen des Kerns beim Aufbau: Textblöcke geben `` nicht aus, eigenes CSS erlaubt keine Anführungszeichen in Werten, kein `calc()` in `maxWidth`, kein `>*` und keine `?` in Attributselektoren. + +**Nächster Schritt:** Inhalte mit Umut abstimmen, dann Livegang nach `docs/LIVEGANG.md`. + ## Fassung 1.26.0: Menüeinträge als Button (07.10.2026) **Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf. diff --git a/migrations/Version20261009000100.php b/migrations/Version20261009000100.php new file mode 100644 index 0000000..1d8e775 --- /dev/null +++ b/migrations/Version20261009000100.php @@ -0,0 +1,44 @@ +createTable('page_translation'); + $table->addColumn('page_id', 'string', ['length' => 32]); + $table->addColumn('website_id', 'string', ['length' => 32]); + $table->addColumn('locale', 'string', ['length' => 12]); + $table->addColumn('slug', 'string', ['length' => 80]); + $table->addColumn('path', 'string', ['length' => 400]); + $table->setPrimaryKey(['page_id', 'locale']); + $table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path'); + } + + public function down(Schema $schema): void + { + // Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur + // nicht mehr öffentlich erreichbar. + $schema->dropTable('page_translation'); + } +} diff --git a/public/assets/admin.css b/public/assets/admin.css index 5aaa708..77a1450 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -74,6 +74,9 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 } der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */ .check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% } .check-label input { width: 16px; min-height: 16px } +/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */ +.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 } +.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 } /* A grid of fields that becomes one column when there is no room. */ .fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px } .fields > div > label:first-child, .field > label:first-child { margin-top: 14px } @@ -864,12 +867,42 @@ details.card > summary:hover { background: #fafbf8 } .card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px } .form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) } .field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px } -.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 } +.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 } +.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 } .field-card-new { border-style: dashed; background: var(--paper) } -.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end } -.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 } +.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center } +.field-card-head input, .field-card-head select { min-height: 36px } +.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 } .field-card label { margin-top: 12px } -.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px } +.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap } +.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap } +.field-toggle:hover { background: #eef3ec } +.field-toggle .form-chevron { transition: transform .15s } +.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) } +.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap } +.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px } +.field-card-body[hidden] { display: none } +.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) } +.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 } +/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */ +.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none } +details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none } +.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none } +.form-card-name { font-size: 16px; font-weight: 700 } +.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) } +.form-list-name { margin: 0; font-size: 15px; font-weight: 650 } +.form-list-name a { text-decoration: none } +.form-list-name a:hover { text-decoration: underline } +.form-list .row { align-items: center } +.back-to-list { display: inline-block; margin-top: 4px } +.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap } +.form-chevron { flex: none; color: #52665a; transition: transform .15s } +.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) } +.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) } +.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px } +.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none } +.form-section > summary .form-part { margin: 0 } +.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap } .field-card-body .field-help { margin-top: 5px } .field-card-flags { align-self: end; padding-bottom: 11px } .field-card-flags .check-label { margin: 12px 0 0 } @@ -916,11 +949,16 @@ details.card > summary:hover { background: #fafbf8 } .form-look .color-control { margin-top: 0 } @media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } } @media (max-width: 700px) { - .field-card { padding-top: 8px } - .field-card-head { grid-template-columns: 26px minmax(0, 1fr) } - .field-card-title, .field-card-type { grid-column: 2 } + .field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px } + .field-card-title { grid-column: 2 / 4; padding-right: 36px } + .field-card-type { grid-column: 2 } + .field-card-head .field-card-required { grid-column: 3 } + .field-toggle { grid-column: 2 } .field-card-head .field-remove { position: absolute; top: 8px; right: 8px } + .field-card-meta { margin-left: 0 } .field-card-body { padding-left: 0 } + .form-card-facts { white-space: normal } + .form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px } } /* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */ @@ -1006,3 +1044,8 @@ details.card > summary:hover { background: #fafbf8 } .menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 } .menu-entry summary { padding-left: 58px; min-height: 60px } @media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } } +/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */ +.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 } +.language-texts h3 { margin: 0 0 6px; font-size: 15px } +.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 } +.language-text-group legend { font-weight: 600; padding: 0 4px } diff --git a/public/assets/editor-model.js b/public/assets/editor-model.js index ee12416..29cd6cb 100644 --- a/public/assets/editor-model.js +++ b/public/assets/editor-model.js @@ -1,5 +1,5 @@ -export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML' }; -export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '' }; +export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' }; +export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '', language: '文' }; // A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the // document, stay selectable and movable, but the editor never edits or fills them. /** @@ -12,7 +12,7 @@ export const CATEGORIES = [ { name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] }, { name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] }, { name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] }, - { name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed', 'html'] }, + { name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] }, ]; /** How many columns a row can be added with. */ @@ -128,6 +128,8 @@ export function createBlock(type) { if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' }; if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' }; if (type === 'legal') block.props = { label: 'Rechtliches' }; + // Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt. + if (type === 'language') block.props = { display: 'code' }; // Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt. if (type === 'html') block.props = { html: '' }; if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' }; @@ -180,6 +182,7 @@ export function repairSchema(document) { } }; walk(document.blocks); + if (document.translations) upgradeDocument(document, 27); return document; } @@ -194,6 +197,7 @@ export function upgradeForType(document, type) { if (type === 'postnav') upgradeDocument(document, 18); if (type === 'legal') upgradeDocument(document, 19); if (type === 'html') upgradeDocument(document, 24); + if (type === 'language') upgradeDocument(document, 27); if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6); if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7); if (type === 'form') upgradeDocument(document, 8); @@ -344,7 +348,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' } // blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument // liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt // nirgends. Eine Zahl, an einer Stelle. -export const SCHEMA_VERSION = 26; +export const SCHEMA_VERSION = 27; export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' }; diff --git a/public/assets/editor.css b/public/assets/editor.css index a43dc0e..8f6133a 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -285,3 +285,35 @@ html:has(>.editor-body){overflow:hidden;height:100%} .canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)} .library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))} } +/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus + verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */ +.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px} +.language-switch[hidden]{display:none} +.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em} +.language-switch button:hover:not(:disabled){color:#fff} +.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)} +#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b} +#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important} +.translate-head{display:grid;gap:8px;margin-bottom:16px} +.translate-head h2{margin:0;font-size:16px} +.translate-head h2:focus{outline:none} +.translate-head progress{width:100%;height:8px;accent-color:var(--ink)} +.translate-summary{margin:0;font-size:13px;font-weight:600} +.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px} +.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px} +.translate-group h3{margin:0 0 8px;font-size:13px} +.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer} +.translate-jump:hover{text-decoration:underline} +.translate-field{display:grid;gap:5px;margin-bottom:14px} +.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px} +.translate-field-head label{margin:0} +.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap} +.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00} +.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12} +.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233} +.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto} +.translate-actions{display:flex;gap:12px;flex-wrap:wrap} +.translate-slug{display:flex;align-items:center;gap:4px} +.translate-slug input{flex:1;min-width:0} +.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap} +.translate-filter{margin-top:4px} diff --git a/public/assets/editor.js b/public/assets/editor.js index b1fba01..07e3b0c 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -13,6 +13,7 @@ import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PR // Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS. import { renderIconField } from './icons.js'; +import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js'; const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240]; // Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings. const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' }; @@ -32,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d // same factor, so it is kept here instead of being recomputed from the transform string. let previewScale = 1, overlay = null, pendingInsert = null; const collapsed = new Set(); +// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die +// Hauptsprache - dann ist der Editor genau der von vorher. +let languageInfo = null, language = null, translateFilter = 'all'; let renderedProperties = null; let revealAfterPreview = null; let previewPlayback = false, stopPlayback = () => {}; @@ -119,7 +123,7 @@ function controls() { $('#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', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy; + for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language); if (current) { $('#move-up').disabled ||= current.index === 0; $('#move-down').disabled ||= current.index === current.siblings.length - 1; @@ -142,6 +146,8 @@ function controls() { $('[data-panel="design"]').disabled ||= !current; $('#open-media-library').disabled = !ready || busy; $('#preview-motion').disabled = !ready || busy; + $('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy); + $('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; }); } function renderTree() { @@ -203,6 +209,7 @@ function choose(id, scroll = true) { history.mergeKey = null; syncView(); renderTree(); renderProperties(); controls(); highlight(scroll); + if (language && selected) focusTranslation(selected); } /** @@ -210,6 +217,7 @@ function choose(id, scroll = true) { * the exception: there the selection only moves the highlight, editing is one more step. */ function syncView() { + if (language) return; if (selected && view !== 'structure') setView('element'); else if (!selected && view === 'element') setView('elements'); } @@ -594,6 +602,12 @@ function renderContent(block, panel) { if (block.type === 'legal') { field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' }); } + if (block.type === 'language') { + segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27); + const help = document.createElement('p'); help.className = 'field-help'; + help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.'; + panel.append(help); + } if (block.type === 'postnav') { field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 }); field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' }); @@ -978,7 +992,7 @@ async function updatePreview() { const sequence = previewSequence; const controller = new AbortController(); previewController = controller; try { - const html = await request('/preview', { document: history.document }, controller.signal); + const html = await request('/preview', { document: history.document, language }, controller.signal); if (sequence !== previewSequence) return; const frame = $('#preview'); const top = frame.contentWindow?.scrollY || 0; @@ -1019,7 +1033,7 @@ async function updatePreview() { menuSummary.parentElement.open = !menuSummary.parentElement.open; } const insert = event.target.closest('[data-editor-target]'); - if (insert && !busy && history) { + if (insert && !busy && history && !language) { stopInlineEdit(); const id = insert.dataset.editorTarget; if (!locate(history.document.blocks, id)) return; @@ -1044,12 +1058,13 @@ async function updatePreview() { const element = event.target.closest('[id]'); if (!element) return; const block = locate(history.document.blocks, element.id)?.block; + if (language) return; if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); } }); // Right click: the element's actions where the pointer is, as in Flatsome. doc.addEventListener('contextmenu', event => { const element = event.target.closest('[id]'); - if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return; + if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return; event.preventDefault(); choose(element.id, false); openContextMenu(element.id, event.clientX, event.clientY); @@ -1065,7 +1080,8 @@ async function updatePreview() { // The head may arrive before the blocks. Decorate only once the complete DOM exists. const finish = () => { if (sequence !== previewSequence || doc !== frame.contentDocument) return; - decorateContainers(doc, history.document); + // Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache. + if (!language) decorateContainers(doc, history.document); overlay?.attach(); styleReady.then(loaded => { if (sequence !== previewSequence || doc !== frame.contentDocument) return; @@ -1075,7 +1091,9 @@ async function updatePreview() { frame.style.visibility = ''; showMotionStatus(); playPreview(); - $('#preview-status').textContent = loaded + $('#preview-status').textContent = loaded && language + ? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.` + : loaded ? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.' : 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.'; }); @@ -1114,7 +1132,226 @@ function changed() { if (!locate(history.document.blocks, selected)) selected = null; if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected; syncView(); - renderTree(); renderProperties(); controls(); schedulePreview(); + renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview(); +} + +// --------------------------------------------------------------------------------------------- +// Übersetzen (ADR 111). +// +// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und +// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und +// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen. +// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und +// Veröffentlichen wie immer und für alle Sprachen zugleich. +// --------------------------------------------------------------------------------------------- +const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code; + +function renderLanguageSwitch() { + const holder = $('#language-switch'); + const extra = languageInfo?.extra ?? []; + holder.hidden = !extra.length; + $('#publish-languages').hidden = !extra.length; + holder.replaceChildren(); + if (!extra.length) return; + for (const item of [languageInfo.main, ...extra]) { + const button = document.createElement('button'); button.type = 'button'; + button.textContent = item.code.toUpperCase(); + button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`; + // Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet. + button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`); + const active = (language ?? languageInfo.main.code) === item.code; + button.setAttribute('aria-pressed', String(active)); + button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code)); + holder.append(button); + } +} + +function setLanguage(code) { + if (busy || !history || code === language) return; + stopInlineEdit(); closeContextMenu(); setPendingInsert(null); + language = code; + if (language) root.dataset.language = language; else delete root.dataset.language; + selected = null; history.mergeKey = null; translateFilter = 'all'; + setView(language ? 'translate' : 'elements'); + renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview(); + if (language) $('#translate-panel').querySelector('h2')?.focus(); +} + +/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */ +function focusTranslation(id) { + const panel = $('#translate-panel'); + const ids = []; + const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); }; + const block = locate(history.document.blocks, id)?.block; + if (!block) return; + collect(block); + const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean); + if (!group) return; + if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); } + panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); + group.classList.add('is-current'); + const body = $('.sidebar-body'); + body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12; + group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true }); +} + +const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' }; + +function renderTranslations() { + const panel = $('#translate-panel'); + if (!language || !history) { panel.replaceChildren(); return; } + const scroll = $('.sidebar-body').scrollTop; + const info = languageInfo.extra.find(item => item.code === language); + const doc = history.document; + const translation = doc.translations?.[language] ?? {}; + // Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken. + const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId); + const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length }; + const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; }; + panel.replaceChildren(); + + const head = el('div', 'translate-head'); + const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1; + const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt'); + const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`); + summary.setAttribute('role', 'status'); + const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`); + const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern'); + filter.style.gridTemplateColumns = 'repeat(3, 1fr)'; + for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) { + const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value)); + option.addEventListener('click', () => { translateFilter = value; renderTranslations(); }); + filter.append(option); + } + head.append(title, meter, summary, hint, filter); + panel.append(head); + + // Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht. + const page = el('section', 'translate-group translate-page'); page.dataset.blockId = ''; + if (languageInfo.area) page.hidden = true; + page.append(el('h3', '', 'Seite')); + const published = languageInfo.published?.[language]; + if (languageInfo.home) { + page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`)); + } else { + const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug'; + const wrap = el('div', 'translate-slug'); + const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true'); + const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? ''; + input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off'; + input.pattern = '[a-z0-9]+(-[a-z0-9]+)*'; + const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help'; + input.setAttribute('aria-describedby', help.id); + input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); }); + const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button'; + suggest.addEventListener('click', () => { + const proposal = slugify(history.document.translations?.[language]?.title ?? ''); + if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; } + input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls(); + }); + wrap.append(prefix, input); + page.append(label, wrap, help, suggest); + } + const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo'; + const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language; + seo.placeholder = 'Leer: der übersetzte Seitentitel'; + seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); }); + page.append(seoLabel, seo); + page.append(el('p', 'field-help', published + ? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.` + : `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`)); + panel.append(page); + + // Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn. + let group = null, groupId; + for (const entry of list) { + const id = entry.blockId ?? '__page'; + if (id !== groupId) { + groupId = id; + group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? ''; + const heading = el('h3'); + if (entry.blockId) { + const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button'; + jump.title = 'In der Vorschau zeigen'; + const current = group; + jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); }); + heading.append(jump); + } else heading.textContent = 'Seitentitel und Beschreibung'; + group.append(heading); + panel.append(group); + } + group.append(translationField(entry, info)); + } + for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) { + const fields = [...section.querySelectorAll('.translate-field')]; + for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter; + section.hidden = fields.every(field => field.hidden); + } + if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.')); + else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.')); + else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.')); + if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current'); + $('.sidebar-body').scrollTop = scroll; + controls(); +} + +/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */ +function describe(block) { + const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? ''); + return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block); +} + +function translationField(entry, info) { + const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; }; + const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key; + const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const label = el('label', '', entry.label); label.htmlFor = id; + const badge = el('span', 'translate-state', STATE_TEXT[entry.state]); + const top = el('div', 'translate-field-head'); top.append(label, badge); + const original = el('p', 'translate-original'); original.lang = languageInfo.main.code; + original.textContent = entry.field.rich ? plainText(entry.original) : entry.original; + const originalId = `${id}-original`; original.id = originalId; + const update = value => { + history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`); + const state = value.trim() === '' ? 'missing' : 'done'; + row.dataset.state = state; badge.textContent = STATE_TEXT[state]; + controls(); schedulePreview(); + }; + row.append(top, original); + let input; + const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false; + if (rich) { + const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` }); + editor.element.lang = language; + if (info.rtl) editor.element.dir = 'rtl'; + editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId); + // Das Label zeigt auf die Fläche, die man beschreibt. + const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); } + row.append(editor.element); + } else { + input = el(entry.field.multiline ? 'textarea' : 'input'); + input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language; + if (info.rtl) input.dir = 'rtl'; + if (entry.field.multiline) input.rows = 3; + input.setAttribute('aria-describedby', originalId); + input.addEventListener('input', () => update(input.value)); + row.append(input); + } + const actions = el('div', 'translate-actions'); + if (entry.state === 'missing') { + const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button'; + copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen'; + copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); }); + actions.append(copy); + } + if (entry.state === 'stale') { + const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button'; + ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem'; + ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); }); + actions.append(ok); + } + if (actions.childElementCount) row.append(actions); + return row; } async function loadDesignSchema() { @@ -1152,10 +1389,15 @@ async function load() { // der Server schon die Vorschau ab (siehe repairSchema). history = new EditorHistory(repairSchema(data.document)); ({ version, revisionId, publishedRevisionId, csrfToken } = data); + languageInfo = data.languages ?? null; + if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; } + renderLanguageSwitch(); // Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten // bekommt dafür eine eigene Gruppe. registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets(); - selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview(); + selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); + if (language) setView('translate'); + changed(); resizePreview(); } catch (error) { notify(error.message, true); } finally { busy = false; controls(); } } @@ -1167,13 +1409,15 @@ async function save(publish = false) { try { const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot }); ({ version, revisionId, publishedRevisionId, csrfToken } = data); + if (data.languages) languageInfo = data.languages; // Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der // Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren. if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) { history.adopt(data.document); history.markSaved(data.document); - renderTree(); renderProperties(); schedulePreview(); + renderTree(); renderProperties(); renderTranslations(); schedulePreview(); } else history.markSaved(snapshot); + if (publish && language) renderTranslations(); notify(); } catch (error) { notify(error.message, true); } finally { busy = false; controls(); } @@ -1514,12 +1758,13 @@ overlay = createCanvasOverlay({ getDocument: () => history.document, getSelected: () => selected, onAction: (action, id) => { - if (!id || busy || !history) return; + if (!id || busy || !history || language) return; if (action === 'up') actions.move(id, -1); else if (action === 'down') actions.move(id, 1); else actions[action]?.(id); }, onInsert: (id, where) => { + if (language) return; const item = locate(history.document.blocks, id); if (!item) return; setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) }); @@ -1527,12 +1772,12 @@ overlay = createCanvasOverlay({ // The border between two columns was dragged (or nudged with the arrow keys): the shares of // the whole row for the current device, one undo step per drag. onResize: (rowId, value) => { - if (busy || !history) return; + if (busy || !history || language) return; history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value)); changed(); }, onDrop: (payload, plan) => { - if (busy || !history) return; + if (busy || !history || language) return; if (payload.kind === 'add') { const id = history.change(doc => payload.columns ? insertRowAt(doc, payload.columns, plan.parentId, plan.index) diff --git a/public/assets/form-editor.js b/public/assets/form-editor.js index 79a5d15..3682951 100644 --- a/public/assets/form-editor.js +++ b/public/assets/form-editor.js @@ -250,10 +250,35 @@ if (root) { if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.'; }; + // Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was + // in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss. + const setOpen = (card, open) => { + card.querySelector('[data-field-body]').hidden = !open; + const toggle = card.querySelector('[data-field-toggle]'); + toggle.setAttribute('aria-expanded', String(open)); + card.classList.toggle('is-open', open); + }; + const meta = card => { + const type = card.querySelector('[data-field-type]').value; + const parts = [card.querySelector('[data-field-name]').value.trim()]; + const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean); + if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen'); + if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext'); + if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung'); + for (const translation of card.querySelectorAll('[data-field-translation]')) { + if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`); + } + return parts.filter(Boolean).join(' · '); + }; + const refresh = () => { cards().forEach((card, index) => { card.querySelector('[data-field-index]').textContent = String(index + 1); const type = card.querySelector('[data-field-type]').value; + const line = card.querySelector('[data-field-meta]'); + line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open'); + const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`; + card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`); card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type); card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type); if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card); @@ -276,12 +301,19 @@ if (root) { const empty = cards().filter(card => card.classList.contains('field-card-new')); if (cards().length > empty.length) empty.forEach(card => card.remove()); add.hidden = false; + for (const card of cards()) { + card.querySelector('[data-field-toggle]').hidden = false; + setOpen(card, card.classList.contains('field-card-new')); + } add.addEventListener('click', () => { list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++))); const card = list.lastElementChild; card.classList.remove('field-card-new'); + card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true); refresh(); card.querySelector('[data-field-label]').focus(); }); list.addEventListener('click', event => { + const toggle = event.target.closest('[data-field-toggle]'); + if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; } const button = event.target.closest('[data-remove-field]'); if (!button) return; button.closest('[data-field]').remove(); refresh(); add.focus(); diff --git a/public/assets/form-steps.js b/public/assets/form-steps.js index 3d3373d..54e670e 100644 --- a/public/assets/form-steps.js +++ b/public/assets/form-steps.js @@ -16,6 +16,10 @@ }; const setup = form => { + // Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111). + let texts = {}; + try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; } + const t = (key, fallback) => texts[key] || fallback; const rules = [...form.querySelectorAll('[data-show-if]')]; const steps = [...form.querySelectorAll('[data-step]')]; const submit = form.querySelector('.cms-button[type="submit"]'); @@ -54,27 +58,27 @@ if (wantsSummary) { summary = el('div', 'form-step form-summary'); summary.setAttribute('data-step', ''); - const title = el('p', 'form-step-title', 'Zusammenfassung'); + const title = el('p', 'form-step-title', t('summary', 'Zusammenfassung')); title.tabIndex = -1; - summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.')); + summary.append(title, el('p', 'form-step-intro', t('summaryIntro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'))); summary.append(el('div', 'form-summary-body')); steps[steps.length - 1].after(summary); steps.push(summary); } const progress = el('ol', 'form-progress'); - progress.setAttribute('aria-label', 'Schritte des Formulars'); + progress.setAttribute('aria-label', t('steps', 'Schritte des Formulars')); steps.forEach((step, index) => { const item = el('li', 'form-progress-step'); - item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`)); + item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`)); progress.append(item); }); form.insertBefore(progress, steps[0]); const nav = el('div', 'form-step-nav'); - const back = el('button', 'button cms-button form-step-back', 'Zurück'); + const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück')); back.type = 'button'; - const next = el('button', 'button cms-button form-step-next', 'Weiter'); + const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter')); next.type = 'button'; nav.append(back, next); (submit || form).before(nav); @@ -90,8 +94,10 @@ if (!picked.length) return ''; const unit = field.querySelector('.form-unit')?.textContent || ''; return picked.map(input => { - if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja'; + if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja'); if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value; + // Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte. + if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value; return input.value + (unit ? ` ${unit}` : ''); }).join(', '); }; @@ -103,8 +109,8 @@ if (step === summary || step.hasAttribute('data-inactive')) return; const block = el('section', 'form-summary-step'); const head = el('div', 'form-summary-head'); - head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`)); - const change = el('button', 'form-summary-change', 'Ändern'); + head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`)); + const change = el('button', 'form-summary-change', t('change', 'Ändern')); change.type = 'button'; change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`); change.addEventListener('click', () => show(index)); @@ -153,7 +159,7 @@ if (group.closest('[data-inactive]')) continue; const note = group.querySelector('.field-error[data-live]'); if (!group.querySelector('input:checked')) { - if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } })); + if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } })); else note.hidden = false; group.classList.add('has-error'); group.querySelector('input')?.focus(); diff --git a/public/assets/library-previews.js b/public/assets/library-previews.js index 6898490..2837e30 100644 --- a/public/assets/library-previews.js +++ b/public/assets/library-previews.js @@ -54,6 +54,7 @@ export const PREVIEWS = { postnav: '', breadcrumb: `${bar(2, 7.2, 5, 1.7)}${bar(11.6, 7.2, 4, 1.7)}${bar(20.2, 7.2, 2, 1.7)}`, menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`, + language: '', form: '', legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`, partialref: '', diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js index 16043e7..235202a 100644 --- a/public/assets/menu-editor.js +++ b/public/assets/menu-editor.js @@ -85,6 +85,12 @@ function createRow(data) { summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron); const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details); const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80; + // Je weitere Sprache eine eigene Beschriftung (ADR 111); leer zeigt die der Hauptsprache. + for (const language of JSON.parse(form.dataset.languages || '[]')) { + const translated = field(`label_${language.code}`, data[`label_${language.code}`] ?? data.labels?.[language.code] ?? '', `Beschriftung ${language.name}`); + translated.maxLength = 80; translated.lang = language.code; translated.placeholder = `Leer: wie „${data.label}“`; + if (language.rtl) translated.dir = 'rtl'; + } if (data.type === 'page') { const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready; const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || ''; diff --git a/public/assets/site.css b/public/assets/site.css index 0135ec0..8464218 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -570,6 +570,12 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran .block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px} .block-legal a{color:inherit;text-decoration:none} .block-legal a:hover{text-decoration:underline} +/* Die Sprachwahl (ADR 111): eine kurze Reihe, die gewählte Sprache fett. Jedes Ziel mindestens + 24 px, damit es mit dem Finger zu treffen ist. */ +.block-language ul{display:flex;flex-wrap:wrap;gap:2px;align-items:center;list-style:none;margin:0;padding:0} +.block-language a{display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:24px;padding:4px 8px;color:inherit;text-decoration:none;font-size:14px;line-height:1.2} +.block-language a:hover{text-decoration:underline} +.block-language a[aria-current]{font-weight:700} .image-zoom{cursor:zoom-in} .zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible} .zoom-dialog::backdrop{background:rgba(8,12,20,.88)} diff --git a/public/assets/translate.js b/public/assets/translate.js new file mode 100644 index 0000000..3f430e7 --- /dev/null +++ b/public/assets/translate.js @@ -0,0 +1,160 @@ +/** + * Übersetzen im Editor (ADR 111). + * + * Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur + * die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder, + * schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert + * nie Aufbau, Gestaltung oder die Texte der Hauptsprache. + */ +import { locate, upgradeDocument, blockLabel } from './editor-model.js'; + +/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */ +export const FIELDS = { + heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }], + text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }], + button: [{ prop: 'text', label: 'Beschriftung', max: 300 }], + image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }], + iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }], + stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }], + quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }], + cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }], + contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }], + accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }], + tabitem: [{ prop: 'title', label: 'Titel', max: 160 }], + legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }], + postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }], + icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }], + progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }], + embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }], +}; + +/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */ +export const PAGE_FIELDS = [ + { key: 'title', label: 'Seitentitel', max: 160 }, + { key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true }, +]; + +/** + * Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit, + * als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint). + */ +export function fingerprint(text) { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(text)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +const empty = value => typeof value !== 'string' || value.trim() === ''; + +/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */ +export function plainText(markup) { + const node = document.createElement('template'); + node.innerHTML = markup ?? ''; + return node.content.textContent.replace(/\s+/g, ' ').trim(); +} + +/** + * Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und + * Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done". + * + * @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>} + */ +export function entries(document, locale) { + const translation = document.translations?.[locale] ?? {}; + const list = []; + const push = (key, blockId, prop, field, original, value, block = null) => { + if (empty(original)) return; + const hash = translation.source?.[key]; + const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done'; + list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field }); + }; + for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]); + const walk = blocks => { + for (const block of blocks ?? []) { + for (const field of FIELDS[block.type] ?? []) { + // Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen. + if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue; + push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block); + } + walk(block.children); + } + }; + walk(document.blocks); + return list; +} + +/** Zählt, was übersetzt ist: { total, done, stale }. */ +export function progress(document, locale) { + const list = entries(document, locale); + return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length }; +} + +/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */ +function ensure(document, locale) { + upgradeDocument(document, 27); + document.translations ??= {}; + return (document.translations[locale] ??= {}); +} + +/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */ +function tidy(document, locale) { + const translation = document.translations?.[locale]; + if (!translation) return; + for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id]; + if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks; + if (translation.source && !Object.keys(translation.source).length) delete translation.source; + if (!Object.keys(translation).length) delete document.translations[locale]; + if (document.translations && !Object.keys(document.translations).length) delete document.translations; +} + +/** + * Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht - + * ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt. + * Läuft innerhalb einer Transaktion der History. + */ +export function setText(document, locale, key, value) { + const translation = ensure(document, locale); + const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key]; + const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop]; + if (blockId) { + translation.blocks ??= {}; + const fields = (translation.blocks[blockId] ??= {}); + if (empty(value)) delete fields[prop]; else fields[prop] = value; + } else if (empty(value)) delete translation[prop]; else translation[prop] = value; + translation.source ??= {}; + if (empty(value) || typeof original !== 'string') delete translation.source[key]; + else translation.source[key] = fingerprint(original); + tidy(document, locale); +} + +/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */ +export function confirmText(document, locale, key) { + const translation = document.translations?.[locale]; + const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key]; + const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop]; + if (!translation || typeof original !== 'string') return; + (translation.source ??= {})[key] = fingerprint(original); +} + +/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */ +export function setPageValue(document, locale, key, value) { + const translation = ensure(document, locale); + if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value; + tidy(document, locale); +} + +/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */ +export function slugify(text) { + return plainText(text).toLowerCase() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') + .normalize('NFKD').replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, ''); +} + +/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */ +export function describeBlock(block) { + return blockLabel(block); +} diff --git a/scripts/admin-quality-check.mjs b/scripts/admin-quality-check.mjs index 6a01ce6..b4ca8c4 100644 --- a/scripts/admin-quality-check.mjs +++ b/scripts/admin-quality-check.mjs @@ -46,6 +46,7 @@ try { 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'); await page.goto(origin + '/admin/forms'); + await page.locator('.form-list a').first().click(); await page.locator('.form-def').waitFor(); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' }); console.log('Public no-JavaScript baseline and visual captures passed.'); @@ -165,11 +166,18 @@ try { checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true }); await page.goto(origin + '/admin/forms'); + await page.locator('.form-list a').first().click(); const form = page.locator('.form-def').first(), message = form.locator('[name="success"]'); + // Der Abschnitt „Zustellung“ ist zugeklappt, bis man ihn öffnet. + const openDelivery = async () => { + await form.locator('details.form-section').first().evaluate(section => { section.open = true; }); + }; + await openDelivery(); const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth })); assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width'); await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); await form.getByRole('button', { name: 'Formular speichern', exact: true }).click(); + await page.waitForLoadState('load'); await openDelivery(); assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); checks.push({ name: 'wide multiline form response persists', passed: true }); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 730d931..7fd069c 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -1147,9 +1147,8 @@ try { assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving'); checks.push({ name: 'image fields in the content settings pick from the library', passed: true }); // Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt. - await page.goto(origin + '/admin/forms'); + await page.goto(origin + '/admin/forms/neu'); const neu = page.locator('#neues-formular'); - if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click(); await neu.locator('[name="name"]').fill('Anfrage Prüfung'); await neu.locator('[name="slug"]').fill('anfrage-pruefung'); await neu.locator('[name="recipient"]').fill('info@example.com'); diff --git a/src/Content/Consent.php b/src/Content/Consent.php index 4ecccfc..ec71c74 100644 --- a/src/Content/Consent.php +++ b/src/Content/Consent.php @@ -158,6 +158,9 @@ final class Consent { $settings = $this->settings->load(); $decision = $this->decision($request); + // Auf einer Seite in einer weiteren Sprache der Hinweis in dieser Sprache (ADR 111). + $locale = $request->attributes->get('kant_locale'); + $translated = is_string($locale) ? ($settings['consentTexts'][$locale] ?? []) : []; return [ 'groups' => array_map( @@ -171,8 +174,8 @@ final class Consent ), 'decided' => null !== $decision, 'decidedAt' => $decision['at'] ?? null, - 'text' => $settings['consentText'], - 'title' => $settings['consentTitle'], + 'text' => $translated['text'] ?? $settings['consentText'], + 'title' => $translated['title'] ?? $settings['consentTitle'], 'layout' => $settings['consentLayout'], 'privacyPath' => $settings['privacyPath'], 'imprintPath' => $settings['imprintPath'], diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index e077a0b..efe2e90 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -41,8 +41,10 @@ final class DocumentValidator /** Schema v23: die sozialen Profile aus den Einstellungen, als Symbolreihe an jeder Stelle. */ public const V23_TYPES = ['social']; public const V24_TYPES = ['html']; + /** Schema v27 (ADR 111): die Sprachwahl, gefüllt aus den Sprachen, in denen die Seite vorliegt. */ + public const V27_TYPES = ['language']; /** Die aktuelle Fassung des Dokumentschemas - dieselbe Zahl wie SCHEMA_VERSION in editor-model.js. */ - public const SCHEMA_VERSION = 26; + public const SCHEMA_VERSION = 27; /** * @param bool $requireHeading A page needs exactly one main heading. A header, a footer or a @@ -62,7 +64,10 @@ final class DocumentValidator $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.'); + // Die Grenze gilt für die Seite selbst; jede Übersetzung bringt ihre Texte noch einmal mit + // und bekommt deshalb insgesamt Platz für bis zu drei weitere Fassungen. + $this->require(strlen(json_encode(Translations::strip($document), JSON_THROW_ON_ERROR)) <= 262144, 'Document exceeds 256 KiB.'); + $this->require(strlen(json_encode($document, JSON_THROW_ON_ERROR)) <= 1048576, 'Die Seite ist mit ihren Übersetzungen größer als 1 MiB.'); $ids = []; $headings = 0; foreach ($document['blocks'] as $block) { @@ -82,6 +87,55 @@ final class DocumentValidator $requireHeading ? 1 === $headings : 0 === $headings, $requireHeading ? 'A page requires exactly one main heading.' : 'A shared area must not contain a main heading.', ); + if (array_key_exists('translations', $document)) { + $this->translations($document, $requireHeading); + } + } + + /** + * Die Übersetzungen eines Dokuments (ADR 111). Geprüft wird zuerst die Form, dann jede + * Sprache so, wie sie ausgeliefert wird: als Dokument mit den übersetzten Texten, durch + * dieselbe Prüfung wie die Hauptsprache. Eine Übersetzung kann so keine Länge, keine + * Auszeichnung und keine Überschriftenregel umgehen. + */ + private function translations(array $document, bool $requireHeading): void + { + $translations = $document['translations']; + $this->require(Translations::supports($document['schemaVersion']), 'Übersetzungen brauchen Dokumentschema v27.'); + $this->require(is_array($translations) && ([] === $translations || !array_is_list($translations)), 'Die Übersetzungen sind ein Objekt je Sprache.'); + foreach ($translations as $locale => $translation) { + $this->require(is_string($locale) && isset(Languages::CATALOG[$locale]), 'Unbekannte Sprache in den Übersetzungen.'); + $this->require(is_array($translation) && ([] === $translation || !array_is_list($translation)), 'Eine Übersetzung ist ein Objekt.'); + foreach ($translation as $key => $value) { + $this->require(in_array($key, ['slug', 'blocks', 'source', ...array_keys(Translations::PAGE_FIELDS)], true), sprintf('Unbekanntes Feld „%s“ in einer Übersetzung.', $key)); + } + foreach (Translations::PAGE_FIELDS as $field => $max) { + if (isset($translation[$field])) { + $this->text($translation[$field], $max, true); + } + } + if (isset($translation['slug'])) { + $this->require(is_string($translation['slug']) && strlen($translation['slug']) <= 80 && 1 === preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/D', $translation['slug']), 'Die übersetzte Adresse darf nur Kleinbuchstaben, Ziffern und einzelne Bindestriche enthalten.'); + } + $blocks = $translation['blocks'] ?? []; + $this->require(is_array($blocks) && count($blocks) <= Translations::MAX_BLOCKS && ([] === $blocks || !array_is_list($blocks)), 'Die übersetzten Texte sind ein Objekt je Block.'); + foreach ($blocks as $id => $fields) { + $this->require(is_string($id) && 1 === preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $id) && is_array($fields), 'Übersetzte Texte gehören zu einem Block.'); + foreach ($fields as $prop => $value) { + $this->require(is_string($prop) && 1 === preg_match('/^[a-zA-Z]{1,40}$/D', $prop) && is_string($value), 'Ein übersetzter Text ist Text.'); + } + } + $source = $translation['source'] ?? []; + $this->require(is_array($source) && count($source) <= Translations::MAX_BLOCKS * 3 + 2, 'Ungültige Vermerke zur Übersetzung.'); + foreach ($source as $key => $hash) { + $this->require(is_string($key) && strlen($key) <= 110 && is_string($hash) && 1 === preg_match('/^[a-f0-9]{8}$/D', $hash), 'Ungültiger Vermerk zur Übersetzung.'); + } + try { + $this->validate(Translations::apply($document, $locale), $requireHeading, $this->writing); + } catch (InvalidDocument $error) { + throw new InvalidDocument(sprintf('%s (%s): %s', Languages::CATALOG[$locale]['name'], $locale, $error->getMessage()), previous: $error); + } + } } private function block(mixed $block, array &$ids, int &$headings, int $depth, int $schemaVersion, ?string $parentType): void @@ -101,7 +155,7 @@ final class DocumentValidator // page: a deactivated plugin must never make stored content unreadable. Nothing from // props is rendered for such a block, so unvalidated values never reach the output. $plugin = $schemaVersion >= 4 && is_string($type) && 1 === preg_match('/^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/D', $type); - $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); + $this->require(in_array($type, ['section', 'row', 'column', 'heading', 'text', 'image', 'button'], true) || ($schemaVersion >= 3 && 'gallery' === $type) || ($schemaVersion >= 5 && 'postlist' === $type) || ($schemaVersion >= 6 && in_array($type, self::V6_TYPES, true)) || ($schemaVersion >= 7 && in_array($type, self::V7_TYPES, true)) || ($schemaVersion >= 8 && in_array($type, self::V8_TYPES, true)) || ($schemaVersion >= 9 && in_array($type, self::V9_TYPES, true)) || ($schemaVersion >= 11 && in_array($type, self::V11_TYPES, true)) || ($schemaVersion >= 14 && in_array($type, self::V14_TYPES, true)) || ($schemaVersion >= 15 && in_array($type, self::V15_TYPES, true)) || ($schemaVersion >= 18 && in_array($type, self::V18_TYPES, true)) || ($schemaVersion >= 19 && in_array($type, self::V19_TYPES, true)) || ($schemaVersion >= 23 && in_array($type, self::V23_TYPES, true)) || ($schemaVersion >= 24 && in_array($type, self::V24_TYPES, true)) || ($schemaVersion >= 27 && in_array($type, self::V27_TYPES, true)) || $plugin, 'Unknown block type. Preserve source until its plugin is available.'); // An accordion entry only ever renders inside an accordion; at the top level or anywhere // else it would produce a details element without its group. $this->require('accordionitem' !== $type || 'accordion' === $parentType, 'An accordion entry belongs into an accordion.'); @@ -333,6 +387,10 @@ final class DocumentValidator $this->require(in_array($props['layout'], ['horizontal', 'vertical', 'burger'], true), 'Unbekannte Menüart.'); } } + if ('language' === $type) { + // Welche Sprachen es gibt, weiß nur die Website - der Block sagt nur, wie er sie nennt. + $this->require(in_array($props['display'] ?? null, ['code', 'name'], true), 'Die Sprachwahl zeigt Kürzel („code“) oder Namen („name“).'); + } if ('partialref' === $type) { // Only the address of the section is content. Its blocks live in the partial, so one // change reaches every page that links it and a page cannot alter a shared section. diff --git a/src/Content/FormRepository.php b/src/Content/FormRepository.php index d73145d..f75e009 100644 --- a/src/Content/FormRepository.php +++ b/src/Content/FormRepository.php @@ -322,6 +322,10 @@ final class FormRepository // Skript steht das ganze Formular ohnehin auf einer Seite. 'summary' => (bool) ($settings['summary'] ?? false), ]; + $translations = $this->validateTranslations($settings['translations'] ?? [], $fields); + if ([] !== $translations) { + $clean['translations'] = $translations; + } // Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein // Hexwert - dieselbe Regel wie fuer jede Farbe im Editor. foreach (self::COLORS as $field => $label) { @@ -335,6 +339,130 @@ final class FormRepository return $clean; } + /** + * Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld + * Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was + * Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen, + * Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben. + */ + private function validateTranslations(mixed $input, array $fields): array + { + if (!is_array($input)) { + throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.'); + } + $byName = array_column($fields, null, 'name'); + $clean = []; + foreach ($input as $locale => $translation) { + if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) { + throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.'); + } + $out = []; + foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) { + $text = trim((string) ($translation[$key] ?? '')); + if ('' !== $text) { + $out[$key] = mb_substr($text, 0, $max); + } + } + foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) { + $field = $byName[$name] ?? null; + if (null === $field || !is_array($values)) { + continue; + } + $entry = []; + foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) { + $text = trim((string) ($values[$key] ?? '')); + if ('' !== $text) { + $entry[$key] = mb_substr($text, 0, $max); + } + } + foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) { + $text = trim((string) $text); + if (in_array((string) $original, $field['options'], true) && '' !== $text) { + $entry['options'][(string) $original] = mb_substr($text, 0, 120); + } + } + if ([] !== $entry) { + $out['fields'][$name] = $entry; + } + } + if ([] !== $out) { + $clean[$locale] = $out; + } + } + + return $clean; + } + + /** + * Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und + * Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die + * Anzeige steht in `optionLabels`. + */ + public static function localise(array $form, ?string $locale): array + { + $translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []); + foreach ($form['fields'] as $index => $field) { + $values = $translation['fields'][$field['name']] ?? []; + foreach (['label', 'help', 'unit'] as $key) { + if (isset($values[$key])) { + $form['fields'][$index][$key] = $values[$key]; + } + } + $form['fields'][$index]['optionLabels'] = $values['options'] ?? []; + } + foreach (['success', 'buttonLabel'] as $key) { + if (isset($translation[$key])) { + $form['settings'][$key] = $translation[$key]; + } + } + $form['locale'] = $locale; + + return $form; + } + + /** + * Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt + * Platzhaltern; ohne eigene Liste gilt Englisch. + */ + public const MESSAGES = [ + 'en' => [ + 'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.', + 'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.', + 'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.', + 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.', + 'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.', + 'Dieses Feld wird benötigt.' => 'This field is required.', + 'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.', + 'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.', + 'Bitte gib eine Zahl an.' => 'Please enter a number.', + 'Bitte gib höchstens %s an.' => 'Please enter at most %s.', + 'Bitte gib mindestens %s an.' => 'Please enter at least %s.', + 'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.', + ], + ]; + + private static function message(?string $locale, string $text, mixed ...$args): string + { + $template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text); + + return [] === $args ? $template : sprintf($template, ...$args); + } + + /** + * Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht. + * + * @return array Formular-ID => Anzahl + */ + public function submissionCounts(): array + { + $counts = []; + foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) { + $counts[(string) $row['form_id']] = (int) $row['n']; + } + + return $counts; + } + /** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */ public function needsScript(array $form): bool { @@ -401,8 +529,12 @@ final class FormRepository * * @return array{values: array, errors: array, consent: bool} */ - public function check(array $form, array $input): array + /** + * @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr + */ + public function check(array $form, array $input, ?string $locale = null): array { + $t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args); $values = []; $errors = []; $consent = false; @@ -427,10 +559,10 @@ final class FormRepository if ('checkboxes' === $field['type']) { $picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v)); if ([] !== array_diff($picked, $field['options'])) { - $errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.'; + $errors[$name] = $t('Bitte wähle nur aus den angebotenen Optionen.'); $picked = array_values(array_intersect($picked, $field['options'])); } elseif ([] === $picked && $field['required']) { - $errors[$name] = 'Bitte wähle mindestens eine Option.'; + $errors[$name] = $t('Bitte wähle mindestens eine Option.'); } $chosen[$name] = $picked; $values[$name] = implode(', ', $picked); @@ -439,39 +571,39 @@ final class FormRepository $value = is_string($raw) ? trim($raw) : ''; $limit = 'textarea' === $field['type'] ? 5000 : 200; if (mb_strlen($value) > $limit) { - $errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit); + $errors[$name] = $t('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit); $value = mb_substr($value, 0, $limit); } if ('consent' === $field['type']) { $consent = '' !== $value; if (!$consent) { - $errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.'; + $errors[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.'); } $values[$name] = $consent ? 'ja' : ''; continue; } if ('' === $value) { if ($field['required']) { - $errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.'; + $errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.'); } $values[$name] = ''; continue; } if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) { - $errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.'; + $errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.'); } if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) { - $errors[$name] = 'Bitte gib eine gültige Telefonnummer an.'; + $errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.'); } if ('number' === $field['type']) { $zahl = str_replace(',', '.', $value); if (!is_numeric($zahl)) { - $errors[$name] = 'Bitte gib eine Zahl an.'; + $errors[$name] = $t('Bitte gib eine Zahl an.'); } elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) { $errors[$name] = match (true) { - null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])), - null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])), - default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])), + null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])), + null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])), + default => $t('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])), }; } else { // Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt. @@ -480,7 +612,7 @@ final class FormRepository } if (in_array($field['type'], ['select', 'radio'], true)) { if (!in_array($value, $field['options'], true)) { - $errors[$name] = 'Bitte wähle eine der angebotenen Optionen.'; + $errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.'); } else { $chosen[$name] = [$value]; } diff --git a/src/Content/Languages.php b/src/Content/Languages.php new file mode 100644 index 0000000..2fcf21a --- /dev/null +++ b/src/Content/Languages.php @@ -0,0 +1,137 @@ + ['native' => 'Deutsch', 'name' => 'Deutsch', 'og' => 'de_DE'], + 'en' => ['native' => 'English', 'name' => 'Englisch', 'og' => 'en_US'], + 'fr' => ['native' => 'Français', 'name' => 'Französisch', 'og' => 'fr_FR'], + 'it' => ['native' => 'Italiano', 'name' => 'Italienisch', 'og' => 'it_IT'], + 'es' => ['native' => 'Español', 'name' => 'Spanisch', 'og' => 'es_ES'], + 'nl' => ['native' => 'Nederlands', 'name' => 'Niederländisch', 'og' => 'nl_NL'], + 'pl' => ['native' => 'Polski', 'name' => 'Polnisch', 'og' => 'pl_PL'], + 'tr' => ['native' => 'Türkçe', 'name' => 'Türkisch', 'og' => 'tr_TR'], + 'ru' => ['native' => 'Русский', 'name' => 'Russisch', 'og' => 'ru_RU'], + 'ar' => ['native' => 'العربية', 'name' => 'Arabisch', 'og' => 'ar_AR'], + ]; + + /** Sprachen, die von rechts nach links geschrieben werden. */ + public const RTL = ['ar']; + + /** Mehr Sprachen als diese braucht keine Handwerkerseite, und jede kostet Pflege. */ + public const MAX_EXTRA = 5; + + private ?string $main = null; + + public function __construct(private Connection $db, private SiteSettings $settings) + { + } + + /** Die Hauptsprache: die Sprache, in der Seiten gebaut werden. */ + public function main(): string + { + if (null !== $this->main) { + return $this->main; + } + try { + $locale = $this->db->fetchOne('SELECT default_locale FROM website WHERE id = ?', ['default']); + } catch (\Doctrine\DBAL\Exception) { + $locale = false; + } + + return $this->main = is_string($locale) && isset(self::CATALOG[$locale]) ? $locale : 'de'; + } + + /** + * Die weiteren Sprachen, in der eingestellten Reihenfolge. Leer bei einer einsprachigen + * Website - und dann zeigt weder der Editor noch die Website etwas davon. + * + * @return list + */ + public function extra(): array + { + $stored = $this->settings->load()['languages'] ?? []; + $main = $this->main(); + + return array_values(array_filter(is_array($stored) ? $stored : [], static fn (mixed $code): bool => is_string($code) && isset(self::CATALOG[$code]) && $code !== $main)); + } + + /** @return list Hauptsprache zuerst */ + public function all(): array + { + return [$this->main(), ...$this->extra()]; + } + + public function isExtra(string $code): bool + { + return in_array($code, $this->extra(), true); + } + + /** Für den Editor und die Einstellungen: Kennung, Name und Name in der Sprache selbst. */ + public function describe(string $code): array + { + return ['code' => $code, 'name' => self::CATALOG[$code]['name'] ?? $code, 'native' => self::CATALOG[$code]['native'] ?? $code, 'rtl' => in_array($code, self::RTL, true)]; + } + + public static function ogLocale(string $code): string + { + return self::CATALOG[$code]['og'] ?? $code; + } + + /** + * Legt die weiteren Sprachen fest. + * + * Eine Sprache wird zum ersten Teil der Adresse (/en/...). Gibt es schon eine Seite, die + * genau so heißt, wäre sie danach nicht mehr erreichbar - das wird abgelehnt statt still + * hingenommen. Eine entfernte Sprache verliert ihre Übersetzungen nicht: sie bleiben in den + * Seiten liegen und sind wieder da, wenn die Sprache zurückkommt. + * + * @param list $codes + */ + public function save(array $codes): void + { + $main = $this->main(); + $clean = []; + foreach ($codes as $code) { + $code = strtolower(trim((string) $code)); + if ('' === $code || $code === $main) { + continue; + } + if (!isset(self::CATALOG[$code])) { + throw new InvalidPage(sprintf('Die Sprache „%s“ steht nicht zur Wahl.', $code)); + } + $clean[$code] = true; + } + $clean = array_keys($clean); + if (count($clean) > self::MAX_EXTRA) { + throw new InvalidPage(sprintf('Mehr als %d weitere Sprachen sind nicht vorgesehen.', self::MAX_EXTRA)); + } + foreach ($clean as $code) { + if (in_array($code, PageRepository::RESERVED_SLUGS, true)) { + continue; + } + $taken = $this->db->fetchOne('SELECT path FROM page WHERE website_id = ? AND (path = ? OR path LIKE ?) AND deleted_at IS NULL', ['default', $code, $code.'/%']); + if (false !== $taken) { + throw new InvalidPage(sprintf('Die Seite /%s belegt die Adresse, unter der %s erscheinen würde. Benenne sie zuerst um.', $code, self::CATALOG[$code]['name'])); + } + } + $this->settings->saveLanguages($clean); + } +} diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index dee8fd4..742a8c4 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -95,7 +95,11 @@ final class MenuRepository */ private ?array $pages = null; - public function resolve(array $items, string $currentPath, string $locale = 'de'): array + /** + * @param string|null $language eine weitere Sprache (ADR 111): Einträge zeigen dann ihre + * Beschriftung in dieser Sprache, sofern sie eine haben + */ + public function resolve(array $items, string $currentPath, string $locale = 'de', ?string $language = null): array { if (null === $this->pages) { $this->pages = []; @@ -104,15 +108,16 @@ final class MenuRepository } } - return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath()); + return $this->resolveLevel($items, $this->pages, $currentPath, $this->settings->homePath(), $language); } - private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array + private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath, ?string $language = null): array { $links = []; foreach ($items as $item) { - $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath); - $label = $item['label'] ?? ''; + $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath, $language); + $translated = null === $language ? '' : trim((string) ($item['labels'][$language] ?? '')); + $label = '' !== $translated ? $translated : ($item['label'] ?? ''); if ('page' === $item['type']) { $page = $pages[$item['pageId']] ?? null; if (null === $page || null === $page['published_revision_id']) { @@ -159,6 +164,18 @@ final class MenuRepository if (!is_array($item) || !is_string($item['label'] ?? null) || '' === trim($item['label']) || mb_strlen($item['label']) > 80) { throw new InvalidMenu('Jeder Eintrag braucht eine Beschriftung mit höchstens 80 Zeichen.'); } + // Beschriftungen in weiteren Sprachen (ADR 111): je Sprache ein Text, wie die + // Beschriftung höchstens 80 Zeichen. Eine leere zeigt die Beschriftung der Hauptsprache. + if (array_key_exists('labels', $item)) { + if (!is_array($item['labels']) || ([] !== $item['labels'] && array_is_list($item['labels']))) { + throw new InvalidMenu('Die übersetzten Beschriftungen sind ungültig.'); + } + foreach ($item['labels'] as $code => $text) { + if (!is_string($code) || !isset(Languages::CATALOG[$code]) || !is_string($text) || '' === trim($text) || mb_strlen($text) > 80) { + throw new InvalidMenu('Eine übersetzte Beschriftung hat höchstens 80 Zeichen und gehört zu einer bekannten Sprache.'); + } + } + } if (array_key_exists('newTab', $item) && !is_bool($item['newTab'])) { throw new InvalidMenu('In neuem Tab öffnen ist an oder aus.'); } diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php index af06093..c9417ff 100644 --- a/src/Content/PageRepository.php +++ b/src/Content/PageRepository.php @@ -50,6 +50,8 @@ final class PageRepository $path = $parent['path'].'/'.$slug; } elseif (in_array($slug, self::RESERVED_SLUGS, true)) { throw new InvalidPage('Diese Adresse ist für das CMS reserviert.'); + } elseif (in_array($slug, $this->languageCodes(), true)) { + throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug)); } if (false !== $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND locale = ? AND path = ?', ['default', $locale, $path])) { throw new InvalidPage(sprintf('Die Adresse /%s ist bereits vergeben.', $path)); @@ -119,6 +121,8 @@ final class PageRepository throw new InvalidPage('Eine Seite kann nicht unter eine ihrer eigenen Unterseiten verschoben werden.'); } $newPath = $parent['path'].'/'.$slug; + } elseif (in_array($slug, $this->languageCodes(), true)) { + throw new InvalidPage(sprintf('Unter /%s erscheint die Website in einer weiteren Sprache. Wähle eine andere Adresse.', $slug)); } if ($newPath === $page['path']) { return 0; @@ -148,6 +152,11 @@ final class PageRepository $redirects->remember($row['path'], $moved); } $this->db->executeStatement('UPDATE page SET slug = ?, parent_id = ?, version = version + 1 WHERE id = ?', [$slug, $parentId, $pageId]); + // Die Übersetzungen hängen an derselben Hierarchie: unter einer verschobenen Seite + // ändern sich auch die Adressen der anderen Sprachen. + if ($this->multilingual()) { + $this->writeTranslationPaths($this->translationPaths(), $redirects); + } return count($affected); }); @@ -324,6 +333,9 @@ final class PageRepository // Detach both pointers first: the revision rows are referenced by the page row. $this->db->executeStatement('UPDATE page SET draft_revision_id = NULL, published_revision_id = NULL WHERE id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page_term WHERE page_id = ?', [$pageId]); + if ($this->multilingual() || $this->db->createSchemaManager()->tablesExist(['page_translation'])) { + $this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ?', [$pageId]); + } $this->db->executeStatement('DELETE FROM revision WHERE page_id = ?', [$pageId]); $this->db->executeStatement('DELETE FROM page WHERE id = ?', [$pageId]); }); @@ -333,10 +345,15 @@ final class PageRepository { // Sanitising happens before validating and before storing, so the database never holds // markup the allowlist would reject and no renderer has to remember to filter. - $document = $this->richText->normaliseDocument($document); + $document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText); $this->validator->validate($document, writing: true); return $this->db->transactional(function () use ($pageId, $document, $expectedVersion): string { + // Eine übersetzte Adresse, die schon vergeben ist, fällt hier auf und nicht erst beim + // Veröffentlichen. Geschrieben wird sie erst dann - bis dahin ist sie Entwurf. + if ($this->multilingual($document)) { + $this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)); + } $id = bin2hex(random_bytes(16)); $changed = $this->db->executeStatement( 'UPDATE page SET version = version + 1, draft_revision_id = ? WHERE id = ? AND version = ?', @@ -364,7 +381,8 @@ final class PageRepository 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)); + $document = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + $this->validator->validate($document); $changed = $this->db->executeStatement( 'UPDATE page SET published_revision_id = ?, version = version + 1 WHERE id = ? AND version = ?', [$revisionId, $pageId, $expectedVersion], @@ -372,6 +390,9 @@ final class PageRepository if (1 !== $changed) { throw new StaleWrite('The page changed. Reload before publishing.'); } + // Mit der Fassung gehen auch ihre Übersetzungen online - oder offline, wenn sie keine + // mehr hat. Die Adressen spiegeln damit genau den veröffentlichten Stand. + $this->publishTranslations($pageId, $document); // A post that goes public without its own date gets one, so the archive can order it. $this->db->executeStatement( "UPDATE page SET published_at = ? WHERE id = ? AND type = 'post' AND published_at IS NULL", @@ -391,10 +412,230 @@ final class PageRepository } $page['document'] = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR); $this->validator->validate($page['document']); + $page['document'] = Translations::strip($page['document']); return $page; } + /** + * Eine veröffentlichte Seite in einer weiteren Sprache, gefunden über ihre übersetzte + * Adresse. Das Dokument kommt mit den übersetzten Texten; `path` bleibt die Adresse der + * Hauptsprache, weil Navigation, Brotkrumen und Formulare an ihr hängen. + */ + public function findPublishedTranslation(string $path, string $locale): ?array + { + $page = $this->db->fetchAssociative( + 'SELECT p.id, p.slug, p.path, p.type, p.locale, p.author, p.excerpt, p.published_at, p.robots, p.seo_title, p.og_media_id, p.featured_media_id, p.header_area, p.footer_area, r.document, r.id AS revision_id, w.name AS website_name, t.path AS translated_path FROM page_translation t JOIN page p ON p.id = t.page_id 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 t.path = ? AND t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL', + [$path, $locale, 'default'], + ); + if (false === $page) { + return null; + } + $document = json_decode($page['document'], true, 512, JSON_THROW_ON_ERROR); + $this->validator->validate($document); + if (!in_array($locale, Translations::locales($document), true)) { + return null; + } + $translation = $document['translations'][$locale]; + $page['document'] = Translations::apply($document, $locale); + $page['main_locale'] = $page['locale']; + $page['locale'] = $locale; + // Ohne eigenen Suchtitel nimmt die Vorlage den übersetzten Seitentitel - nie den Suchtitel + // der Hauptsprache, der in der falschen Sprache in den Suchergebnissen stünde. + $page['seo_title'] = $translation['seoTitle'] ?? ''; + + return $page; + } + + /** + * Unter welchen Adressen eine Seite in den weiteren Sprachen veröffentlicht ist, als Pfad + * ohne Schrägstrich vorn. + * + * @return array Sprache => Pfad + */ + public function translatedPaths(string $pageId): array + { + $paths = []; + foreach ($this->db->fetchAllAssociative('SELECT locale, path FROM page_translation WHERE page_id = ? AND website_id = ? ORDER BY locale', [$pageId, 'default']) as $row) { + $paths[$row['locale']] = $row['path']; + } + + return $paths; + } + + /** + * Alle veröffentlichten Seiten einer Sprache: Pfad der Hauptsprache => übersetzter Pfad. + * Damit zeigen Links auf einer übersetzten Seite auf die übersetzten Fassungen. + * + * @return array + */ + public function translationMap(string $locale): array + { + $map = []; + foreach ($this->db->fetchAllAssociative( + 'SELECT p.path AS main_path, t.path FROM page_translation t JOIN page p ON p.id = t.page_id WHERE t.locale = ? AND t.website_id = ? AND p.deleted_at IS NULL AND p.published_revision_id IS NOT NULL', + [$locale, 'default'], + ) as $row) { + $map[$row['main_path']] = $row['path']; + } + + return $map; + } + + /** + * Die weiteren Sprachen, direkt gelesen: über SiteSettings::load() bliebe sonst eine ältere + * Startseite für den Rest der Anfrage zwischengespeichert. + * + * @return list + */ + private function languageCodes(): array + { + $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); + $settings = is_string($raw) ? json_decode($raw, true) : null; + + return is_array($settings['languages'] ?? null) ? $settings['languages'] : []; + } + + /** + * Ob die übersetzten Adressen überhaupt gepflegt werden müssen: nur bei einer Website mit + * weiteren Sprachen oder einem Dokument mit Übersetzungen. Eine einsprachige Website fasst + * die Tabelle so nie an - auch nicht, wenn nach einem Update die Migration noch aussteht. + * Abgewählte Sprachen holt das nächste Veröffentlichen nach: es rechnet alle Pfade neu. + */ + private function multilingual(?array $document = null): bool + { + return [] !== $this->languageCodes() || (null !== $document && [] !== Translations::locales($document)); + } + + /** @return array Sprache => übersetzte Adresse, aus dem Dokument */ + private function translatedSlugs(string $pageId, array $document): array + { + $slug = (string) $this->db->fetchOne('SELECT slug FROM page WHERE id = ?', [$pageId]); + $slugs = []; + foreach (Translations::locales($document) as $locale) { + $slugs[$locale] = (string) ($document['translations'][$locale]['slug'] ?? $slug); + } + + return $slugs; + } + + /** + * Schreibt die übersetzten Adressen einer gerade veröffentlichten Seite: neue Sprachen kommen + * dazu, entfernte fallen weg, und eine geänderte Adresse bekommt eine Weiterleitung. + */ + private function publishTranslations(string $pageId, array $document): void + { + if ($this->multilingual($document)) { + $this->writeTranslationPaths($this->translationPaths($pageId, $this->translatedSlugs($pageId, $document)), new RedirectRepository($this->db)); + } + } + + /** + * Rechnet die übersetzten Adressen aller Seiten aus. Eine Seite steht in einer Sprache unter + * dem übersetzten Pfad ihrer Elternseite - oder, wenn die nicht übersetzt ist, unter deren + * Pfad in der Hauptsprache - und dahinter ihre übersetzte Adresse. + * + * Mit $pageId ersetzt $slugs die Sprachen dieser einen Seite (Entwurf oder Veröffentlichung); + * sonst gilt der gespeicherte Stand (nach dem Verschieben). Doppelte Adressen werden + * abgelehnt, bevor irgendetwas geschrieben ist. + * + * @param array $slugs Sprache => Adresse + * + * @return array> Sprache => Seite => Zeile + */ + private function translationPaths(?string $pageId = null, array $slugs = []): array + { + $pages = []; + foreach ($this->db->fetchAllAssociative('SELECT id, slug, parent_id FROM page WHERE website_id = ?', ['default']) as $row) { + $pages[$row['id']] = $row; + } + $wanted = []; + $old = []; + foreach ($this->db->fetchAllAssociative('SELECT page_id, locale, slug, path FROM page_translation WHERE website_id = ?', ['default']) as $row) { + $wanted[$row['locale']][$row['page_id']] = $row['slug']; + $old[$row['locale']][$row['page_id']] = $row['path']; + } + if (null !== $pageId) { + foreach (array_keys($wanted) as $locale) { + unset($wanted[$locale][$pageId]); + } + foreach ($slugs as $locale => $slug) { + $wanted[$locale][$pageId] = $slug; + } + } + $result = []; + foreach ($wanted as $locale => $rows) { + $memo = []; + $pathOf = function (string $id, int $depth = 0) use (&$pathOf, &$memo, $pages, $rows): string { + if (isset($memo[$id])) { + return $memo[$id]; + } + $page = $pages[$id] ?? null; + if (null === $page || $depth > self::MAX_DEPTH) { + return ''; + } + $segment = $rows[$id] ?? $page['slug']; + $parent = null === $page['parent_id'] ? '' : $pathOf($page['parent_id'], $depth + 1); + + return $memo[$id] = '' === $parent ? $segment : $parent.'/'.$segment; + }; + $taken = []; + foreach ($rows as $id => $slug) { + $id = (string) $id; + if (!isset($pages[$id])) { + continue; + } + $path = $pathOf($id); + if (isset($taken[$path])) { + throw new InvalidPage(sprintf('Die Adresse /%s/%s ist in %s bereits vergeben. Wähle für die Übersetzung eine andere Adresse.', $locale, $path, Languages::CATALOG[$locale]['name'] ?? $locale)); + } + $taken[$path] = true; + $result[$locale][$id] = ['slug' => $slug, 'path' => $path, 'old' => $old[$locale][$id] ?? null]; + } + } + + return $result; + } + + /** + * Bringt die Tabelle auf den ausgerechneten Stand. Geänderte Pfade wandern in zwei Schritten, + * damit zwei Seiten, die ihre Adressen tauschen, nicht am eindeutigen Index scheitern. + */ + private function writeTranslationPaths(array $paths, RedirectRepository $redirects): void + { + $keep = []; + foreach ($paths as $locale => $rows) { + foreach (array_keys($rows) as $id) { + $keep[$id.'|'.$locale] = true; + } + } + foreach ($this->db->fetchAllAssociative('SELECT page_id, locale FROM page_translation WHERE website_id = ?', ['default']) as $row) { + if (!isset($keep[$row['page_id'].'|'.$row['locale']])) { + $this->db->executeStatement('DELETE FROM page_translation WHERE page_id = ? AND locale = ?', [$row['page_id'], $row['locale']]); + } + } + $moved = []; + foreach ($paths as $locale => $rows) { + foreach ($rows as $id => $row) { + if (null === $row['old']) { + $this->db->insert('page_translation', ['page_id' => $id, 'website_id' => 'default', 'locale' => $locale, 'slug' => $row['slug'], 'path' => '~'.$id]); + $moved[] = [$id, $locale, $row]; + } elseif ($row['old'] !== $row['path']) { + $this->db->executeStatement('UPDATE page_translation SET path = ? WHERE page_id = ? AND locale = ?', ['~'.$id, $id, $locale]); + $moved[] = [$id, $locale, $row]; + } else { + $this->db->executeStatement('UPDATE page_translation SET slug = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $id, $locale]); + } + } + } + foreach ($moved as [$id, $locale, $row]) { + $this->db->executeStatement('UPDATE page_translation SET slug = ?, path = ? WHERE page_id = ? AND locale = ?', [$row['slug'], $row['path'], $id, $locale]); + if (null !== $row['old']) { + $redirects->remember($locale.'/'.$row['old'], $locale.'/'.$row['path']); + } + } + } + /** * Which header and footer a page wants: null for the standard one, 'none' for no area at all, * or the address of another one. diff --git a/src/Content/PartialRepository.php b/src/Content/PartialRepository.php index 80cf3b6..72a8e47 100644 --- a/src/Content/PartialRepository.php +++ b/src/Content/PartialRepository.php @@ -98,7 +98,7 @@ final class PartialRepository if ('section' !== $kind && null === $this->findByKind($kind, 'default', $locale)) { $slug = 'default'; } - $document = $this->richText->normaliseDocument($document); + $document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText); $this->validator->validate($document, false, true); if (null !== $this->findByKind($kind, $slug, $locale)) { throw new InvalidPage(sprintf('„%s“ ist als Adresse bereits vergeben.', $slug)); @@ -116,7 +116,7 @@ final class PartialRepository public function save(string $id, array $document, int $expectedVersion): void { - $document = $this->richText->normaliseDocument($document); + $document = Translations::normalise($this->richText->normaliseDocument($document), $this->richText); $this->validator->validate($document, false, true); $this->db->transactional(function () use ($id, $document, $expectedVersion): void { // A section must not end up containing itself, directly or through another section. diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php index 0b3aa43..2532872 100644 --- a/src/Content/PostRepository.php +++ b/src/Content/PostRepository.php @@ -114,7 +114,12 @@ final class PostRepository /** * @return array{posts: list, total: int, pages: int, page: int} */ - public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de'): array + /** + * @param string|null $language eine weitere Sprache (ADR 111): Titel und Auszug kommen aus + * der Übersetzung des Beitrags, sofern er eine hat, die Namen der + * Kategorien aus den Einstellungen + */ + public function published(int $page = 1, ?string $termId = null, int $perPage = self::PER_PAGE, string $locale = 'de', ?string $language = null): array { $where = "p.website_id = ? AND p.locale = ? AND p.type = 'post' AND p.published_revision_id IS NOT NULL AND p.deleted_at IS NULL"; $params = ['default', $locale]; @@ -138,25 +143,33 @@ final class PostRepository // einzeln nach seinem Bild und seinen Begriffen, eine Liste mit zwölf Einträgen kostete // also 24 Abfragen statt zwei. Bei neighbours(), das 500 Beiträge lädt, war das absurd. $images = $this->media->findMany(array_map(static fn (array $row) => (string) $row['featured_media_id'], $rows)); - $terms = $this->termsForMany(array_column($rows, 'id')); + $terms = $this->termsForMany(array_column($rows, 'id'), $language); return [ - 'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []]), $rows), + 'posts' => array_map(fn (array $row) => $this->present($row, $images, $terms[$row['id']] ?? ['category' => [], 'tag' => []], $language), $rows), 'total' => $total, 'pages' => $pages, 'page' => $page, ]; } - private function present(array $row, array $images, array $terms): array + private function present(array $row, array $images, array $terms, ?string $language = null): array { $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $item = $images[$row['featured_media_id']] ?? null; + // Übersetzt ist ein Beitrag, wenn seine veröffentlichte Fassung die Sprache trägt. Sonst + // steht er in der Hauptsprache in der Liste - wie jedes nicht übersetzte Feld. + $translated = null !== $language && in_array($language, Translations::locales($document), true); + $excerpt = $row['excerpt'] ?? $document['description'] ?? ''; + if ($translated) { + $excerpt = $document['translations'][$language]['description'] ?? $excerpt; + $document = Translations::apply($document, $language); + } return [ 'title' => $document['title'] ?? $row['path'], 'href' => $this->settings->publicPath($row['path']), - 'excerpt' => $row['excerpt'] ?? $document['description'] ?? '', + 'excerpt' => $excerpt, 'author' => $row['author'], 'publishedAt' => $row['published_at'], 'image' => null === $item ? null : ['alt' => $item['alt'] ?? '', 'variants' => $item['variants']], @@ -171,7 +184,7 @@ final class PostRepository * * @return array>> */ - private function termsForMany(array $pageIds): array + private function termsForMany(array $pageIds, ?string $language = null): array { $pageIds = array_values(array_unique(array_filter($pageIds))); if ([] === $pageIds) { @@ -184,12 +197,28 @@ final class PostRepository $pageIds, ) as $row) { $terms[$row['page_id']] ??= ['category' => [], 'tag' => []]; - $terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; + $terms[$row['page_id']][$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; } return $terms; } + /** + * Der Name einer Kategorie oder eines Schlagworts in einer weiteren Sprache, gepflegt unter + * Einstellungen → Sprachen (ADR 111). Ohne Übersetzung der Name der Hauptsprache. + * + * @param array{kind: string, slug: string, name: string} $term + */ + public function termName(array $term, ?string $language): string + { + if (null === $language) { + return $term['name']; + } + $name = $this->settings->load()['termNames'][$language][$term['kind'].':'.$term['slug']] ?? ''; + + return is_string($name) && '' !== $name ? $name : $term['name']; + } + /** * @return array> */ @@ -203,14 +232,14 @@ final class PostRepository * * @return array{previous: ?array, next: ?array} */ - public function neighbours(string $pageId, string $locale = 'de'): array + public function neighbours(string $pageId, string $locale = 'de', ?string $language = null): array { $termId = (string) $this->db->fetchOne( 'SELECT t.id FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? AND t.kind = ? ORDER BY t.name LIMIT 1', [$pageId, 'category'], ); // Dieselbe Sortierung wie im Archiv, damit „vorheriges" dort dasselbe heißt wie hier. - $all = $this->published(1, '' === $termId ? null : $termId, 500, $locale)['posts']; + $all = $this->published(1, '' === $termId ? null : $termId, 500, $locale, $language)['posts']; // present() gibt bewusst keine Kennung heraus - in eine Liste gehören Titel und Adresse. // Verglichen wird deshalb über die Adresse, die innerhalb einer Website eindeutig ist. $own = $this->settings->publicPath((string) $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$pageId])); @@ -249,11 +278,11 @@ final class PostRepository private ?bool $anyPublished = null; - public function termsFor(string $pageId): array + public function termsFor(string $pageId, ?string $language = null): array { $terms = ['category' => [], 'tag' => []]; foreach ($this->db->fetchAllAssociative('SELECT t.kind, t.slug, t.name FROM term t JOIN page_term pt ON pt.term_id = t.id WHERE pt.page_id = ? ORDER BY t.name', [$pageId]) as $row) { - $terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $row['name'], 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; + $terms[$row['kind']][] = ['slug' => $row['slug'], 'name' => $this->termName($row, $language), 'href' => '/'.self::ARCHIVE_PREFIX[$row['kind']].'/'.$row['slug']]; } return $terms; diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index 3d77dc0..6596495 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -89,6 +89,13 @@ final class SiteSettings 'consentAccentInk' => '', 'privacyPath' => '', 'imprintPath' => '', + // Die weiteren Sprachen neben der Hauptsprache (ADR 111), gepflegt über Languages::save. + // Leer heißt: einsprachig - und dann ist nirgends etwas von Übersetzungen zu sehen. + 'languages' => [], + // Namen von Kategorien und Schlagwörtern je weiterer Sprache: {en: {"category:projekte": "Projects"}}. + 'termNames' => [], + // Überschrift und Text des Einwilligungshinweises je weiterer Sprache: {en: {title, text}}. + 'consentTexts' => [], ]; /** Die Wochentage, wie schema.org sie nennt, mit deutscher Beschriftung für das Formular. */ @@ -191,6 +198,10 @@ final class SiteSettings $values = self::DEFAULTS; // Assigned separately, so saving company data cannot clear the error page. $values['notFoundPageId'] = $this->load()['notFoundPageId']; + // Ebenso die Sprachen: sie haben einen eigenen Bildschirm mit eigener Prüfung. + $values['languages'] = $this->load()['languages']; + $values['termNames'] = $this->load()['termNames']; + $values['consentTexts'] = $this->load()['consentTexts']; foreach (['legalName' => 200, 'description' => 300, 'phone' => 40, 'email' => 120, 'street' => 120, 'postalCode' => 16, 'city' => 120] as $key => $max) { $values[$key] = mb_substr(trim((string) ($input[$key] ?? '')), 0, $max); } @@ -403,6 +414,71 @@ final class SiteSettings $this->loaded = null; } + /** + * Nur die Sprachen ändern, alles andere unberührt lassen. Geprüft wird in Languages::save. + * + * @param list $codes + */ + public function saveLanguages(array $codes): void + { + $this->db->transactional(function () use ($codes): void { + $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); + $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; + $stored = is_array($stored) ? $stored : []; + $stored['languages'] = array_values($codes); + $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ + json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', + ]); + }); + $this->loaded = null; + } + + /** + * Die Texte der Einstellungen in weiteren Sprachen (ADR 111): Namen von Kategorien und + * Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises. Alles andere bleibt. + * + * @param array> $names Sprache => "art:adresse" => Name + * @param array> $consent Sprache => {title, text} + */ + public function saveLanguageTexts(array $names, array $consent = []): void + { + $consentClean = []; + foreach ($consent as $locale => $texts) { + if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($texts)) { + continue; + } + foreach (['title' => 80, 'text' => 600] as $key => $max) { + $text = mb_substr(trim((string) ($texts[$key] ?? '')), 0, $max); + if ('' !== $text) { + $consentClean[$locale][$key] = $text; + } + } + } + $clean = []; + foreach ($names as $locale => $entries) { + if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($entries)) { + continue; + } + foreach ($entries as $key => $name) { + $name = mb_substr(trim((string) $name), 0, 120); + if ('' !== $name && 1 === preg_match('/^(category|tag):[a-z0-9-]{1,80}$/D', (string) $key)) { + $clean[$locale][(string) $key] = $name; + } + } + } + $this->db->transactional(function () use ($clean, $consentClean): void { + $raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']); + $stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : []; + $stored = is_array($stored) ? $stored : []; + $stored['termNames'] = $clean; + $stored['consentTexts'] = $consentClean; + $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ + json_encode($stored, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', + ]); + }); + $this->loaded = null; + } + /** * Which of the two mandatory pages are missing, by their settings key. * diff --git a/src/Content/Translations.php b/src/Content/Translations.php new file mode 100644 index 0000000..9931e51 --- /dev/null +++ b/src/Content/Translations.php @@ -0,0 +1,275 @@ + ['text'], + 'text' => ['text'], + 'button' => ['text'], + 'image' => ['alt', 'caption'], + 'iconcard' => ['title', 'text'], + 'stat' => ['value', 'label'], + 'quote' => ['text', 'author', 'role'], + 'cta' => ['title', 'text', 'buttonText'], + 'contact' => ['address'], + 'accordionitem' => ['title'], + 'tabitem' => ['title'], + 'legal' => ['label'], + 'postnav' => ['previousLabel', 'nextLabel'], + 'icon' => ['label'], + 'progress' => ['label'], + 'embed' => ['title', 'hint'], + ]; + + /** Felder der Seite selbst und ihre Höchstlänge. */ + public const PAGE_FIELDS = ['title' => 160, 'description' => 320, 'seoTitle' => 160]; + + /** Wie viele Einträge eine Sprache höchstens trägt - so viele, wie eine Seite Blöcke haben darf. */ + public const MAX_BLOCKS = 300; + + public static function supports(mixed $schemaVersion): bool + { + return is_int($schemaVersion) && $schemaVersion >= self::SCHEMA; + } + + /** Die Sprachen, für die das Dokument überhaupt etwas übersetzt hat. @return list */ + public static function locales(array $document): array + { + $locales = []; + foreach (($document['translations'] ?? []) as $locale => $translation) { + if (is_string($locale) && is_array($translation) && self::hasContent($translation)) { + $locales[] = $locale; + } + } + + return $locales; + } + + /** Eine Übersetzung ohne Titel und ohne einen einzigen Text ist keine - nur ein Anfang. */ + public static function hasContent(array $translation): bool + { + return '' !== trim((string) ($translation['title'] ?? '')) || [] !== array_filter($translation['blocks'] ?? [], static fn (mixed $fields): bool => is_array($fields) && [] !== $fields); + } + + /** Das Dokument ohne Übersetzungen - so, wie die Hauptsprache es ausliefert. */ + public static function strip(array $document): array + { + unset($document['translations']); + + return $document; + } + + /** + * Das Dokument in einer Sprache: die übersetzten Texte auf einer Kopie, die Übersetzungen + * selbst entfernt. Fehlt ein Text, bleibt der der Hauptsprache stehen. + */ + public static function apply(array $document, string $locale): array + { + $translation = $document['translations'][$locale] ?? []; + $document = self::strip($document); + if (!is_array($translation)) { + return $document; + } + foreach (['title', 'description'] as $field) { + if (is_string($translation[$field] ?? null) && '' !== trim($translation[$field])) { + $document[$field] = $translation[$field]; + } + } + $blocks = is_array($translation['blocks'] ?? null) ? $translation['blocks'] : []; + if ([] !== $blocks) { + $document['blocks'] = self::overlay($document['blocks'] ?? [], $blocks); + } + + return $document; + } + + private static function overlay(array $list, array $translated): array + { + foreach ($list as $index => $block) { + if (!is_array($block)) { + continue; + } + $fields = $translated[$block['id'] ?? ''] ?? null; + $type = $block['type'] ?? ''; + if (is_array($fields) && isset(self::FIELDS[$type])) { + foreach (self::FIELDS[$type] as $prop) { + $value = $fields[$prop] ?? null; + if (!is_string($value) || '' === trim($value) || !array_key_exists($prop, $block['props'] ?? [])) { + continue; + } + // Ein dekoratives Bild oder Symbol hat keinen Text - auch nicht übersetzt. + if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) { + continue; + } + $list[$index]['props'][$prop] = $value; + } + } + if (is_array($block['children'] ?? null)) { + $list[$index]['children'] = self::overlay($block['children'], $translated); + } + } + + return $list; + } + + /** + * Räumt die Übersetzungen vor dem Speichern auf: formatierter Text geht durch denselben + * Filter wie in der Hauptsprache, leere Felder fallen weg, und Einträge für Blöcke, die es + * nicht mehr gibt, auch. Was danach noch ungültig ist, lehnt der DocumentValidator ab. + */ + public static function normalise(array $document, RichText $richText): array + { + if (!isset($document['translations']) || !is_array($document['translations'])) { + return $document; + } + $types = []; + $collect = static function (array $blocks) use (&$collect, &$types): void { + foreach ($blocks as $block) { + if (is_array($block) && is_string($block['id'] ?? null)) { + $types[$block['id']] = (string) ($block['type'] ?? ''); + $collect(is_array($block['children'] ?? null) ? $block['children'] : []); + } + } + }; + $collect(is_array($document['blocks'] ?? null) ? $document['blocks'] : []); + $rich = RichText::supports($document['schemaVersion'] ?? null); + $richHeadings = RichText::headings($document['schemaVersion'] ?? null); + + foreach ($document['translations'] as $locale => $translation) { + if (!is_array($translation)) { + continue; + } + foreach (array_keys(self::PAGE_FIELDS) as $field) { + if (is_string($translation[$field] ?? null)) { + $translation[$field] = trim($translation[$field]); + if ('' === $translation[$field]) { + unset($translation[$field]); + } + } + } + if (is_string($translation['slug'] ?? null)) { + $translation['slug'] = strtolower(trim($translation['slug'])); + if ('' === $translation['slug']) { + unset($translation['slug']); + } + } + $blocks = []; + foreach ((is_array($translation['blocks'] ?? null) ? $translation['blocks'] : []) as $id => $fields) { + $type = $types[$id] ?? null; + if (null === $type || !isset(self::FIELDS[$type]) || !is_array($fields)) { + continue; + } + foreach ($fields as $prop => $value) { + if (!in_array($prop, self::FIELDS[$type], true) || !is_string($value) || '' === trim($value)) { + continue; + } + if ('text' === $prop && (('text' === $type && $rich) || ('heading' === $type && $richHeadings))) { + $value = $richText->sanitise($value); + } + $blocks[$id][$prop] = $value; + } + } + $translation['blocks'] = $blocks; + if ([] === $blocks) { + unset($translation['blocks']); + } + // Nur Vermerke zu Texten, die es gibt; der Rest wäre ein Vermerk ohne Gegenstand. + $source = []; + foreach ((is_array($translation['source'] ?? null) ? $translation['source'] : []) as $key => $hash) { + [$id, $prop] = array_pad(explode('.', (string) $key, 2), 2, null); + $present = null === $prop ? isset($translation[$id]) : isset($blocks[$id][$prop]); + if ($present && is_string($hash)) { + $source[$key] = $hash; + } + } + $translation['source'] = $source; + if ([] === $source) { + unset($translation['source']); + } + $document['translations'][$locale] = $translation; + } + + return $document; + } + + /** + * Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah. FNV-1a, 32 Bit - kurz, + * und in PHP wie in JavaScript (translate.js) ohne Bibliothek gleich zu rechnen. + */ + public static function fingerprint(string $text): string + { + return hash('fnv1a32', $text); + } + + /** + * Wie weit eine Sprache ist: übersetzbare Texte, davon übersetzt, davon seitdem in der + * Hauptsprache geändert. + * + * @return array{total: int, done: int, stale: int} + */ + public static function progress(array $document, string $locale): array + { + $translation = $document['translations'][$locale] ?? []; + $translation = is_array($translation) ? $translation : []; + $count = ['total' => 0, 'done' => 0, 'stale' => 0]; + $tally = static function (string $key, string $original, mixed $value) use (&$count, $translation): void { + if ('' === trim($original)) { + return; + } + ++$count['total']; + if (!is_string($value) || '' === trim($value)) { + return; + } + ++$count['done']; + $hash = $translation['source'][$key] ?? null; + if (is_string($hash) && $hash !== self::fingerprint($original)) { + ++$count['stale']; + } + }; + foreach (['title', 'description'] as $field) { + $tally($field, (string) ($document[$field] ?? ''), $translation[$field] ?? null); + } + $walk = static function (array $blocks) use (&$walk, $tally, $translation): void { + foreach ($blocks as $block) { + foreach (self::FIELDS[$block['type'] ?? ''] ?? [] as $prop) { + if (in_array($prop, ['alt', 'label'], true) && true === ($block['props']['decorative'] ?? false)) { + continue; + } + $tally($block['id'].'.'.$prop, (string) ($block['props'][$prop] ?? ''), $translation['blocks'][$block['id']][$prop] ?? null); + } + $walk($block['children'] ?? []); + } + }; + $walk($document['blocks'] ?? []); + + return $count; + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index a23272a..be4bff1 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -7,15 +7,18 @@ use App\Content\DemoContent; use App\Content\DocumentValidator; use App\Content\FontLibrary; use App\Content\FormRepository; +use App\Content\Languages; use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\PartialRepository; use App\Content\PostLayouts; use App\Content\PostRepository; use App\Content\RedirectRepository; +use App\Content\SiteSettings; use App\Content\StyleCompiler; use App\Content\StyleSchema; use App\Content\Theme; +use App\Content\Translations; use App\Domain\InvalidDocument; use App\Domain\InvalidPage; use App\Domain\StaleWrite; @@ -31,7 +34,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Route('/%kant.admin_path%')] final class EditorController extends AbstractController { - public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview) + public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview, private Languages $languages, private SiteSettings $siteSettings) { } @@ -440,8 +443,9 @@ final class EditorController extends AbstractController 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); + // Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111). + if (strlen($request->getContent()) > 1100000) { + return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413); } $page = $this->page($id); try { @@ -456,20 +460,30 @@ final class EditorController extends AbstractController throw new \InvalidArgumentException(); } $this->validator->validate($document, writing: true); + // Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten - + // genau so, wie die Website sie in dieser Sprache ausliefert. + $language = $body['language'] ?? null; + $locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main(); + $seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? ''); + $document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale); + $request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all())); + + $request->attributes->set('kant_locale', $locale); + $localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale); $theme = $this->theme->load(); $areas = []; foreach (['header', 'footer'] as $kind) { $area = $this->partials->findByKind($kind); if (null !== $area) { - $areas[$kind] = $area['document']; + $areas[$kind] = $localise($area['document']); } } $sections = []; foreach ($this->partials->referencedSlugs($document) as $slug) { $section = $this->partials->resolveSection($slug); if (null !== $section) { - $sections[$slug] = $section['document']; + $sections[$slug] = $localise($section['document']); } } // Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen. @@ -485,7 +499,7 @@ final class EditorController extends AbstractController } $navigation = []; foreach ($this->menus->all() as $slot => $menu) { - $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']); + $navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale); } // Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem @@ -496,14 +510,14 @@ final class EditorController extends AbstractController foreach ($documents as $doc) { $styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc); } - $forms = $this->previewForms($documents, $page); + $forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale); foreach ($forms as $state) { if (null !== $state['form']) { $styles .= $this->forms->css($state['form'], $theme); } } - return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]); + return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) { throw new \InvalidArgumentException(); @@ -536,6 +550,9 @@ final class EditorController extends AbstractController 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 (InvalidPage $e) { + // Etwa eine übersetzte Adresse, die eine andere Seite schon hat. + return $this->json(['error' => $e->getMessage()], 422); } catch (\JsonException|\InvalidArgumentException) { return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422); } @@ -549,13 +566,16 @@ final class EditorController extends AbstractController * * @return array */ - private function previewForms(array $documents, array $page): array + private function previewForms(array $documents, array $page, ?string $language = null): array { $states = []; - $walk = function (array $blocks) use (&$walk, &$states, $page): void { + $walk = function (array $blocks) use (&$walk, &$states, $page, $language): void { foreach ($blocks as $block) { if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) { $form = $this->forms->findBySlug((string) $block['props']['form']); + if (null !== $form && null !== $language) { + $form = FormRepository::localise($form, $language); + } $states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => '']; } $walk($block['children'] ?? []); @@ -575,6 +595,15 @@ final class EditorController extends AbstractController 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(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()]; + return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(), + // Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht. + 'languages' => [ + 'main' => $this->languages->describe($this->languages->main()), + 'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), + 'slug' => $page['slug'], + 'home' => $page['path'] === $this->siteSettings->homePath(), + 'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']), + ], + ]; } } diff --git a/src/Controller/FormAdminController.php b/src/Controller/FormAdminController.php index 3aefc60..6c49169 100644 --- a/src/Controller/FormAdminController.php +++ b/src/Controller/FormAdminController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Content\ButtonDesign; use App\Content\FormRepository; +use App\Content\Languages; use App\Content\MediaLibrary; use App\Content\Theme; use App\Domain\InvalidForm; @@ -15,25 +16,49 @@ use Symfony\Component\Routing\Attribute\Route; final class FormAdminController extends AbstractController { - public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media) + public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media, private Languages $languages) { } + /** + * Die Übersicht: jedes Formular eine Zeile, dazu die Einsendungen. Bearbeitet wird ein + * Formular auf seiner eigenen Seite - mit mehreren Formularen war die eine lange Seite mit + * allen Editoren und Vorschauen nicht mehr zu überblicken (Umut, 09.10.2026). + */ #[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])] public function index(Request $request): Response { // Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer // Rolle, die nicht einmal eine Seite veroeffentlichen darf. $this->denyAccessUnlessGranted('ROLE_PUBLISHER'); - $theme = $this->theme->load(); return $this->render('admin/forms.html.twig', [ 'forms' => $this->forms->all(), + 'counts' => $this->forms->submissionCounts(), + 'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)), + 'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), + ]); + } + + /** Ein Formular bearbeiten, oder mit der Adresse „neu“ eines anlegen. */ + #[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_edit', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['GET'])] + public function edit(string $slug): Response + { + $this->denyAccessUnlessGranted('ROLE_PUBLISHER'); + $current = 'neu' === $slug ? null : $this->forms->findBySlug($slug); + if ('neu' !== $slug && null === $current) { + throw $this->createNotFoundException('Dieses Formular gibt es nicht.'); + } + $theme = $this->theme->load(); + + return $this->render('admin/forms.html.twig', [ + 'editing' => true, + 'current' => $current, 'types' => FormRepository::TYPES, 'colors' => FormRepository::COLORS, // Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird. 'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']), - 'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)), + 'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), // Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und // kennt die Palette, damit eine Farbe gewaehlt statt getippt wird. 'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']], @@ -48,10 +73,27 @@ final class FormAdminController extends AbstractController return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } $fields = []; + // Je weitere Sprache: Felder tr__label, _help, _unit, _options am Feld, dazu + // success_ und buttonLabel_ am Formular (ADR 111). Die Optionen stehen + // als Kommaliste in derselben Reihenfolge wie die der Hauptsprache. + $translations = []; + foreach ($this->languages->extra() as $code) { + $translations[$code] = ['success' => $request->request->getString('success_'.$code), 'buttonLabel' => $request->request->getString('buttonLabel_'.$code), 'fields' => []]; + } foreach ($request->request->all('fields') as $row) { if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) { continue; } + $originals = array_map(trim(...), explode(',', (string) ($row['options'] ?? ''))); + foreach (array_keys($translations) as $code) { + $translatedOptions = array_map(trim(...), explode(',', (string) ($row['tr_'.$code.'_options'] ?? ''))); + $translations[$code]['fields'][(string) ($row['name'] ?? '')] = [ + 'label' => (string) ($row['tr_'.$code.'_label'] ?? ''), + 'help' => (string) ($row['tr_'.$code.'_help'] ?? ''), + 'unit' => (string) ($row['tr_'.$code.'_unit'] ?? ''), + 'options' => array_combine($originals, array_pad(array_slice($translatedOptions, 0, count($originals)), count($originals), '')), + ]; + } $fields[] = [ 'name' => (string) ($row['name'] ?? ''), 'type' => (string) ($row['type'] ?? 'text'), @@ -82,21 +124,23 @@ final class FormAdminController extends AbstractController 'replyToField' => $request->request->getString('replyToField'), 'buttonLabel' => $request->request->getString('buttonLabel'), 'summary' => '' !== $request->request->getString('summary'), + 'translations' => $translations, ...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))), ], $request->request->getInt('expectedVersion', -1), ); } catch (InvalidForm|StaleWrite $e) { - return $this->back($e->getMessage()); + return $this->back($e->getMessage(), 'error', $slug); } - return $this->back('Das Formular wurde gespeichert.', 'success'); + return $this->back('Das Formular wurde gespeichert.', 'success', 'neu' === $slug ? $request->request->getString('slug') : $slug); } - private function back(string $message, string $type = 'error'): Response + private function back(string $message, string $type = 'error', ?string $slug = null): Response { $this->addFlash($type, $message); - return $this->redirectToRoute('admin_forms'); + // Zurück zum bearbeiteten Formular; ohne Adresse (abgelaufene Sitzung) zur Übersicht. + return null === $slug ? $this->redirectToRoute('admin_forms') : $this->redirectToRoute('admin_form_edit', ['slug' => $slug]); } } diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index 4c2f9db..26fd0f5 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Content\FormRepository; +use App\Content\Languages; use App\Content\MenuRepository; use App\Content\PageRepository; use App\Content\SiteSettings; @@ -32,6 +33,7 @@ final class FormController extends AbstractController private Turnstile $turnstile, private string $secret, private string $siteOrigin, + private Languages $languages, ) { } @@ -56,10 +58,19 @@ final class FormController extends AbstractController // noch einmal. || !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp()); - $checked = $this->forms->check($form, $request->request->all()); + // Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der + // Weg zurück führt auf die übersetzte Seite - sofern es sie gibt. + $language = $request->request->getString('language'); + $translatedPath = null; + if ($this->languages->isExtra($language)) { + $translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null; + } + $language = null === $translatedPath ? null : $language; + + $checked = $this->forms->check($form, $request->request->all(), $language); if (!$silentlyDropped && [] !== $checked['errors']) { // Re-render the page the form sits on, with the entered values and the messages. - return $this->renderPage($request, $page, $form, $checked); + return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath)); } if (!$silentlyDropped) { $submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash); @@ -69,7 +80,9 @@ final class FormController extends AbstractController } // Post/Redirect/Get so a reload does not send the message twice. - return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); + $back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath); + + return $this->redirect($back.'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); } /** The signed timestamp makes the age of the form checkable without storing anything. */ @@ -128,7 +141,7 @@ final class FormController extends AbstractController * vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder * andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht. */ - private function renderPage(Request $request, array $page, array $form, array $checked): Response + private function renderPage(Request $request, array $page, array $form, array $checked, ?string $translatedAddress = null): Response { // Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer // es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion. @@ -137,7 +150,7 @@ final class FormController extends AbstractController // Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten, // und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung. - $adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path']; + $adresse = $translatedAddress ?? ($page['path'] === $this->settings->homePath() ? '' : (string) $page['path']); $antwort = $this->forward('App\Controller\PageController::page', [ 'path' => $adresse, 'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']), diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index c162c73..94334a5 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Content\ButtonDesign; +use App\Content\Languages; use App\Content\MenuButton; use App\Content\MenuRepository; use App\Content\PageRepository; @@ -17,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route; final class MenuController extends AbstractController { - public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme) + public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme, private Languages $languages) { } @@ -85,6 +86,7 @@ final class MenuController extends AbstractController 'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot], 'menu' => $menu, 'rows' => $rows, 'menuIcons' => \App\Content\DocumentValidator::ICONS, + 'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), // Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor. 'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE], ]); @@ -130,6 +132,7 @@ final class MenuController extends AbstractController 'newTab' => $item['newTab'] ?? false, 'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before', 'button' => $item['button'] ?? null, + 'labels' => $item['labels'] ?? [], ]; array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1)); } @@ -160,6 +163,11 @@ final class MenuController extends AbstractController 'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))), 'position' => (int) ($row['position'] ?? 0), 'button' => MenuButton::fromForm($row), + // Je weitere Sprache ein Feld label_; leer heißt: wie in der Hauptsprache. + 'labels' => array_filter( + array_combine($this->languages->extra(), array_map(static fn (string $code): string => mb_substr(trim((string) ($row['label_'.$code] ?? '')), 0, 80), $this->languages->extra())) ?: [], + static fn (string $text): bool => '' !== $text, + ), ]; } usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']); @@ -177,6 +185,9 @@ final class MenuController extends AbstractController if (null !== $row['button']) { $item['button'] = $row['button']; } + if ([] !== $row['labels']) { + $item['labels'] = $row['labels']; + } if ('url' === $row['type']) { $item['url'] = $row['url']; } else { diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 48e2644..359b0df 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -7,6 +7,7 @@ use App\Content\CustomCode; use App\Content\DocumentValidator; use App\Content\FontLibrary; use App\Content\FormRepository; +use App\Content\Languages; use App\Content\MediaLibrary; use App\Content\MenuRepository; use App\Content\NotFoundPage; @@ -18,6 +19,7 @@ use App\Content\SiteSettings; use App\Content\StyleBundle; use App\Content\StyleCompiler; use App\Content\Theme; +use App\Content\Translations; use App\Content\Turnstile; use App\Plugin\PluginRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -27,21 +29,40 @@ use Symfony\Component\Routing\Attribute\Route; final class PageController extends AbstractController { - public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins) + public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages) { } + /** + * Die Sprache dieser Anfrage, wenn es nicht die Hauptsprache ist: Kennung und die Zuordnung + * Pfad der Hauptsprache => übersetzter Pfad (ADR 111). Null für die Hauptsprache. + * + * @var array{locale: string, map: array}|null + */ + private ?array $translation = null; + /** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */ private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px'; // A segment is a slug; up to four of them form the public path. The leading lookahead keeps // the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them. - private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}'; + private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}'; #[Route('/', name: 'home', methods: ['GET'])] #[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])] public function page(Request $request, string $path = ''): Response { + $this->translation = null; + // Eine weitere Sprache steht vorn in der Adresse: /en ist ihre Startseite, /en/services + // eine übersetzte Seite. Ohne weitere Sprache gibt es diesen Weg gar nicht. + $segments = explode('/', $path, 2); + if ('' !== $path && $this->languages->isExtra($segments[0])) { + return $this->translatedPage($request, $segments[0], $segments[1] ?? ''); + } + // Mehr als vier Ebenen gibt es nur mit einer Sprache davor. + if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) { + return $this->notFound($request); + } $home = $this->settings->homePath(); $path = '' === $path ? $home : $path; // One canonical address for the root page: its own path redirects to "/". @@ -71,6 +92,134 @@ final class PageController extends AbstractController return $this->renderPage($request, $page, $path); } + /** + * Eine Seite in einer weiteren Sprache. Gefunden wird sie über ihre übersetzte Adresse; ihre + * Startseite antwortet unter /en und nicht zusätzlich unter /en/home. + */ + private function translatedPage(Request $request, string $locale, string $rest): Response + { + $map = $this->pages->translationMap($locale); + // Schon hier: auch eine 404 unter /en/ antwortet auf Englisch. + $this->translation = ['locale' => $locale, 'map' => $map]; + $homeTranslated = $map[$this->settings->homePath()] ?? null; + if ('' !== $rest && $rest === $homeTranslated && null === $request->attributes->get('kant_form_state')) { + return $this->shell($this->redirect('/'.$locale, 301)); + } + // Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache. + if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) { + $this->translation = ['locale' => $locale, 'map' => $map]; + + return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null)); + } + $target = '' === $rest ? $homeTranslated : $rest; + $page = null === $target ? null : $this->pages->findPublishedTranslation($target, $locale); + if (null === $page) { + $redirect = '' === $rest ? null : $this->redirects->find($locale.'/'.$rest); + if (null !== $redirect && 301 === $redirect['status']) { + return $this->shell($this->redirect('/'.$redirect['to'], 301)); + } + + return $this->notFound($request, null !== $redirect ? 410 : 404); + } + if ($page['id'] === $this->settings->load()['notFoundPageId']) { + return $this->notFound($request); + } + + return $this->renderPage($request, $page, $page['path']); + } + + /** + * Kopf, Fuß und Sektionen in der Sprache dieser Anfrage: übersetzt, wo es Übersetzungen gibt, + * und ohne die Übersetzungen selbst in der Hauptsprache. + */ + private function localise(array $document): array + { + return null === $this->translation ? Translations::strip($document) : Translations::apply($document, $this->translation['locale']); + } + + /** + * Die öffentliche Adresse eines Pfades der Hauptsprache in der Sprache dieser Anfrage - oder + * unverändert, wenn die Seite dort nicht übersetzt ist. + */ + private function localHref(string $href): string + { + if (null === $this->translation || !str_starts_with($href, '/') || str_starts_with($href, '//')) { + return $href; + } + $suffix = ''; + if (false !== ($cut = strcspn($href, '?#')) && $cut < strlen($href)) { + $suffix = substr($href, $cut); + $href = substr($href, 0, $cut); + } + $path = trim($href, '/'); + $home = $this->settings->homePath(); + $locale = $this->translation['locale']; + if ('' === $path || $path === $home) { + return isset($this->translation['map'][$home]) ? '/'.$locale.$suffix : $href.$suffix; + } + if ('beitraege' === $path || str_starts_with($path, 'kategorie/') || str_starts_with($path, 'schlagwort/')) { + return '/'.$locale.'/'.$path.$suffix; + } + + return isset($this->translation['map'][$path]) ? '/'.$locale.'/'.$this->translation['map'][$path].$suffix : $href.$suffix; + } + + /** + * Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf + * deren übersetzte Fassung - in Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. + * Ein Link auf eine nicht übersetzte Seite bleibt, wie er ist. Ausgenommen sind die Links + * der Sprachwahl, die mit hreflang bewusst auf eine bestimmte Sprache zeigen. + */ + private function localiseLinks(string $html): string + { + if (null === $this->translation) { + return $html; + } + + return preg_replace_callback('/]*>/i', function (array $tag): string { + if (str_contains($tag[0], 'hreflang=')) { + return $tag[0]; + } + + return preg_replace_callback('/\bhref="(\/[^"]*)"/', fn (array $href): string => 'href="'.$this->localHref($href[1]).'"', $tag[0]) ?? $tag[0]; + }, $html) ?? $html; + } + + /** + * Unter welchen Adressen diese Seite in welcher Sprache steht - für hreflang und die + * Sprachwahl. Leer, solange die Website nur eine Sprache hat. + * + * @return list + */ + private function alternates(array $page, int $status): array + { + $extra = $this->languages->extra(); + if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) { + return []; + } + $origin = rtrim($this->siteOrigin, '/'); + $home = $this->settings->homePath(); + $current = $this->translation['locale'] ?? $this->languages->main(); + $main = $this->languages->main(); + $list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]]; + $translated = $this->pages->translatedPaths($page['id']); + foreach ($extra as $locale) { + if (isset($translated[$locale])) { + $list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]]; + } + } + if (count($list) < 2) { + return []; + } + + return array_map(fn (array $entry): array => [ + ...$entry, + 'native' => $this->languages->describe($entry['locale'])['native'], + 'url' => $origin.$entry['href'], + 'current' => $entry['locale'] === $current, + ], $list); + } + /** * Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die * Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript. @@ -94,7 +243,45 @@ final class PageController extends AbstractController public function notFound(Request $request, int $status = 404): Response { - return $this->renderPage($request, $this->notFoundPage->page($status), '', $status); + $page = $this->notFoundPage->page($status); + $locale = $this->translation['locale'] ?? null; + // Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die + // mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111). + if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) { + $page['document'] = Translations::apply($page['document'], $locale); + } else { + $page['document'] = Translations::strip($page['document']); + if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) { + $page['document'] = $this->translateFixedTexts($page['document'], $locale); + } + } + if (null !== $locale) { + $page['locale'] = $locale; + } + + return $this->renderPage($request, $page, '', $status); + } + + /** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */ + private function translateFixedTexts(array $document, string $locale): array + { + $t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text; + foreach (['title', 'description'] as $field) { + $document[$field] = $t((string) ($document[$field] ?? '')); + } + $walk = function (array $blocks) use (&$walk, $t): array { + foreach ($blocks as $index => $block) { + if (is_string($block['props']['text'] ?? null)) { + $blocks[$index]['props']['text'] = $t($block['props']['text']); + } + $blocks[$index]['children'] = $walk($block['children'] ?? []); + } + + return $blocks; + }; + $document['blocks'] = $walk($document['blocks'] ?? []); + + return $document; } /** Published pages and error content share theme, areas, block rendering and CSP. */ @@ -102,10 +289,12 @@ final class PageController extends AbstractController { $styles = ''; // The page decides which header and footer it gets - including none. - $areas = $this->areas($status >= 400 ? null : $page); + $areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page)); $theme = $this->theme->load(); // Theme first, block styles after, so a block always wins over the site defaults. - $sections = $this->sections($page['document'], $areas); + $sections = array_map($this->localise(...), $this->sections($page['document'], $areas)); + // Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension). + $request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main()); // Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen // Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß. $pristine = $sections; @@ -176,20 +365,25 @@ final class PageController extends AbstractController break; } } - $adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath()); + $adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath())); + $alternates = $this->alternates($page, $status); + // Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die + // Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen. + $request->attributes->set('kant_alternates', $alternates); $canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : ''); $response = $this->render('page.html.twig', [ 'page' => $page, 'canonical' => $canonical, + 'alternates' => $alternates, 'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse], 'navigation' => $this->navigation($path), 'postLists' => $postLists, // Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte. 'hasPosts' => $this->posts->anyPublished(), - 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, + 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null, // Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den // Baustein wirklich enthält. - 'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [], + 'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [], 'needsTabs' => $needsTabs, 'needsFormScript' => $needsFormScript, 'pluginStyles' => $pluginAssets['styles'], @@ -244,6 +438,9 @@ final class PageController extends AbstractController if ($status >= 400) { $response->headers->set('X-Robots-Tag', 'noindex, follow'); } + if (null !== $this->translation) { + $response->setContent($this->localiseLinks((string) $response->getContent())); + } return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument); } @@ -317,6 +514,10 @@ final class PageController extends AbstractController if ('form' === ($block['type'] ?? null)) { $slug = $block['props']['form'] ?? ''; $form = $this->forms->findBySlug($slug); + // In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111). + if (null !== $form && null !== $this->translation) { + $form = FormRepository::localise($form, $this->translation['locale']); + } $states[$block['id']] = [ 'form' => $form, // Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt. @@ -420,9 +621,12 @@ final class PageController extends AbstractController $prefix = ''; foreach (explode('/', (string) $page['path']) as $segment) { $prefix = '' === $prefix ? $segment : $prefix.'/'.$segment; - $ancestor = $this->pages->findPublished($prefix); + // In einer weiteren Sprache heißt jede Stufe so, wie sie dort heißt - sofern es sie + // dort gibt. Sonst bleibt sie in der Hauptsprache stehen, mit ihrer Adresse. + $translated = null === $this->translation || !isset($this->translation['map'][$prefix]) ? null : $this->pages->findPublishedTranslation($this->translation['map'][$prefix], $this->translation['locale']); + $ancestor = $translated ?? $this->pages->findPublished($prefix); if (null !== $ancestor) { - $trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix]; + $trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => $this->localHref('/'.$prefix)]; } } @@ -505,7 +709,7 @@ final class PageController extends AbstractController '@context' => 'https://schema.org', '@type' => 'Article', 'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110), - 'mainEntityOfPage' => $origin.'/'.$page['path'], + 'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']), ]; foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) { if ('' !== (string) $value && null !== $value) { @@ -618,23 +822,32 @@ final class PageController extends AbstractController #[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])] public function archive(Request $request, ?string $slug = null): Response { - $kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag'; + $this->translation = null; + + return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug); + } + + private function renderArchive(Request $request, string $kind, ?string $slug): Response + { + $language = $this->translation['locale'] ?? null; + $t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text); $term = null; if (null !== $slug) { $term = $this->posts->findTerm($kind, $slug); if (null === $term) { return $this->notFound($request); } + $term['name'] = $this->posts->termName($term, $language); } $page = max(1, $request->query->getInt('seite', 1)); - $result = $this->posts->published($page, $term['id'] ?? null); + $result = $this->posts->published($page, $term['id'] ?? null, language: $language); // Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse - // egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist. if (0 === $result['total']) { return $this->notFound($request); } $base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug']; - $heading = null === $term ? 'Beiträge' : $term['name']; + $heading = null === $term ? $t('Beiträge') : $term['name']; // Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe // Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften, @@ -642,15 +855,15 @@ final class PageController extends AbstractController // eigene Vorlage, die von alledem nur die Palette kannte. $kopf = [ ['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]], - ['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]], + ['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]], ]; if (null !== $term) { - array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]); + array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('Schlagwort')], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]); } $document = [ 'schemaVersion' => DocumentValidator::SCHEMA_VERSION, 'title' => $heading, - 'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']), + 'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $term['name']), 'blocks' => [ ['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf], ['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [ @@ -659,7 +872,7 @@ final class PageController extends AbstractController ], ]; $seite = [ - 'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de', + 'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(), 'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document, ]; @@ -705,7 +918,7 @@ final class PageController extends AbstractController // immer die erste - sonst würden zwei Listen auf einer Seite einander // umblättern. $paginate = (bool) ($props['paginate'] ?? false); - $result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']); + $result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count'], language: $this->translation['locale'] ?? null); $lists[$block['id']] = $paginate ? $result : $result['posts']; } $walk($block['children'] ?? []); @@ -986,7 +1199,7 @@ final class PageController extends AbstractController { $links = []; foreach ($this->menus->all() as $slot => $menu) { - $links[$slot] = $this->menus->resolve($menu['items'], $currentPath); + $links[$slot] = $this->menus->resolve($menu['items'], $currentPath, language: $this->translation['locale'] ?? null); } return $links; diff --git a/src/Controller/PartialController.php b/src/Controller/PartialController.php index c0615b3..e669a6d 100644 --- a/src/Controller/PartialController.php +++ b/src/Controller/PartialController.php @@ -6,10 +6,12 @@ use App\Content\CustomCode; use App\Content\DocumentValidator; use App\Content\FontLibrary; use App\Content\FormRepository; +use App\Content\Languages; use App\Content\MenuRepository; use App\Content\PartialRepository; use App\Content\StyleCompiler; use App\Content\Theme; +use App\Content\Translations; use App\Domain\InvalidDocument; use App\Domain\InvalidPage; use App\Domain\StaleWrite; @@ -42,6 +44,7 @@ final class PartialController extends AbstractController private CsrfTokenManagerInterface $csrf, private PluginRegistry $plugins, private FormRepository $forms, + private Languages $languages, ) { } @@ -125,8 +128,8 @@ final class PartialController extends AbstractController 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' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413); + if (strlen($request->getContent()) > 1100000) { + return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413); } $partial = $this->partial($id); try { @@ -137,8 +140,9 @@ final class PartialController extends AbstractController } if ('admin_partial_preview' === $request->attributes->get('_route')) { $this->validator->validate($document, false, true); + $language = $body['language'] ?? null; - return $this->renderPreview($partial, $document); + return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) { throw new \InvalidArgumentException(); @@ -163,18 +167,25 @@ final class PartialController extends AbstractController } /** The area on its own, in the shell of a page, so the editor preview looks like the result. */ - private function renderPreview(array $partial, array $document): Response + private function renderPreview(array $partial, array $document, Request $request, ?string $language = null): Response { + // Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und + // die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite. + $localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language); + $document = $localise($document); + $locale = $language ?? $this->languages->main(); + $request->attributes->set('kant_locale', $locale); + $request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all())); $theme = $this->theme->load(); $navigation = []; foreach ($this->menus->all() as $slot => $menu) { - $navigation[$slot] = $this->menus->resolve($menu['items'], ''); + $navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language); } $sections = []; foreach ($this->partials->referencedSlugs($document) as $slug) { $section = $this->partials->resolveSection($slug); if (null !== $section) { - $sections[$slug] = $section['document']; + $sections[$slug] = $localise($section['document']); } } @@ -184,10 +195,13 @@ final class PartialController extends AbstractController $styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section); } $forms = []; - $walk = function (array $blocks) use (&$walk, &$forms): void { + $walk = function (array $blocks) use (&$walk, &$forms, $language): void { foreach ($blocks as $block) { if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) { $form = $this->forms->findBySlug((string) $block['props']['form']); + if (null !== $form && null !== $language) { + $form = FormRepository::localise($form, $language); + } $forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => '']; if (null !== $form) { $styles .= $this->forms->css($form, $theme); @@ -202,7 +216,7 @@ final class PartialController extends AbstractController } return $this->render('page.html.twig', [ - 'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document], + 'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, @@ -232,6 +246,12 @@ final class PartialController extends AbstractController 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(), + // Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel. + 'languages' => [ + 'main' => $this->languages->describe($this->languages->main()), + 'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()), + 'area' => true, + ], ]; } diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php index 3e8dcc3..0039ea2 100644 --- a/src/Controller/SeoController.php +++ b/src/Controller/SeoController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Content\Languages; use App\Content\PostRepository; use App\Content\SiteSettings; use Doctrine\DBAL\Connection; @@ -22,6 +23,7 @@ final class SeoController extends AbstractController private PostRepository $posts, private string $siteOrigin, private string $adminPath, + private Languages $languages, ) { } @@ -44,6 +46,24 @@ final class SeoController extends AbstractController 'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10), ]; } + // Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben + // Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als + // hreflang in den Seiten selbst. + $extra = $this->languages->extra(); + if ([] !== $extra) { + foreach ($this->db->fetchAllAssociative( + "SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path", + ['default', $this->settings->load()['notFoundPageId']], + ) as $row) { + if (!in_array($row['locale'], $extra, true)) { + continue; + } + $entries[] = [ + 'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']), + 'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10), + ]; + } + } // Archives exist only while they list something. if ([] !== $this->posts->published(1, null, 1)['posts']) { $entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null]; diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 16ed82c..9247dd5 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -4,8 +4,10 @@ namespace App\Controller; use App\Content\Consent; use App\Content\DemoContent; +use App\Content\Languages; use App\Content\NotFoundPage; use App\Content\PageRepository; +use App\Content\PostRepository; use App\Content\RedirectRepository; use App\Content\SiteSettings; use App\Content\SocialProfiles; @@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route; final class SettingsController extends AbstractController { - public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage) + public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts) { } @@ -67,9 +69,52 @@ final class SettingsController extends AbstractController 'socialNetworks' => SocialProfiles::NETWORKS, 'weekdays' => SiteSettings::DAYS, 'socialPlaceholders' => SocialProfiles::PLACEHOLDERS, + 'mainLanguage' => $this->languages->describe($this->languages->main()), + 'extraLanguages' => $this->languages->extra(), + 'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(), + 'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))), ]); } + /** + * Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts, + * solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre + * Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen. + */ + /** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */ + #[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])] + public function saveLanguageTexts(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + $all = $request->request->all(); + $this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []); + $this->addFlash('success', 'Die Übersetzungen wurden gespeichert.'); + + return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']); + } + + #[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])] + public function saveLanguages(Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_ADMIN'); + if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) { + return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + try { + $this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? [])))); + } catch (InvalidPage $e) { + $this->addFlash('error', $e->getMessage()); + + return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']); + } + $this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.'); + + return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']); + } + #[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])] public function save(Request $request): Response { diff --git a/src/Twig/LanguageExtension.php b/src/Twig/LanguageExtension.php new file mode 100644 index 0000000..d4c2331 --- /dev/null +++ b/src/Twig/LanguageExtension.php @@ -0,0 +1,155 @@ + [ + 'Zum Inhalt springen' => 'Skip to content', + 'Menü' => 'Menu', + 'Brotkrumen' => 'Breadcrumb', + 'Start' => 'Home', + 'Soziale Profile' => 'Social profiles', + 'Zum Blättern seitwärts scrollen' => 'Scroll sideways to browse', + 'Anbieter:' => 'Provider:', + 'Inhalt laden und einwilligen' => 'Load content and consent', + 'Einwilligung ändern' => 'Change consent', + 'Bildergalerie' => 'Image gallery', + 'Bild' => 'Image', + 'vergrößern' => 'enlarge', + 'Ansehen' => 'View', + 'Weiterlesen' => 'Read more', + 'Weitere Beiträge' => 'More posts', + 'Vorheriger Beitrag' => 'Previous post', + 'Nächster Beitrag' => 'Next post', + 'Alle akzeptieren' => 'Accept all', + 'Nur notwendige' => 'Necessary only', + 'Einzeln wählen' => 'Choose individually', + 'Auswahl speichern' => 'Save selection', + 'Datenschutz' => 'Privacy', + 'Impressum' => 'Legal notice', + 'Hauptnavigation' => 'Main navigation', + 'Zweite Navigation' => 'Secondary navigation', + 'Fußzeile' => 'Footer', + 'Rechtliches' => 'Legal', + 'Navigation' => 'Navigation', + 'Nach oben' => 'Back to top', + 'Sprache' => 'Language', + // Formulare + 'Dieses Formular ist nicht mehr vorhanden.' => 'This form is no longer available.', + 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check 1 entry. The field concerned is marked below.', + 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.' => 'Please check %d entries. The fields concerned are marked below.', + 'Pflichtfeld' => 'required', + 'in' => 'in', + 'bitte wählen' => 'please choose', + 'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.' => 'Sending this form requires JavaScript. Otherwise, feel free to email us directly.', + 'Zusammenfassung' => 'Summary', + 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.' => 'Please check your details. “Change” takes you back to the step.', + 'Schritte des Formulars' => 'Form steps', + 'Schritt' => 'Step', + 'Zurück' => 'Back', + 'Weiter' => 'Next', + 'Ändern' => 'Change', + 'Ja' => 'Yes', + 'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.', + 'Absenden' => 'Send', + 'Vielen Dank. Deine Nachricht ist angekommen.' => 'Thank you. Your message has arrived.', + // Beiträge und Archive + 'Beiträge' => 'Posts', + 'Beitrag' => 'post', + 'Kategorie' => 'Category', + 'Schlagwort' => 'Tag', + 'Alle Beiträge, die neuesten zuerst.' => 'All posts, newest first.', + 'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.', + 'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.', + 'von' => 'by', + '← Neuere' => '← Newer', + 'Ältere →' => 'Older →', + 'Seite %d von %d' => 'Page %d of %d', + 'Externe Inhalte und Einwilligung' => 'External content and consent', + 'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.', + 'Statistik' => 'Statistics', + 'Externe Inhalte' => 'External content', + 'Messung, wie die Website benutzt wird.' => 'Measuring how the website is used.', + 'Inhalte von anderen Anbietern, etwa Karten, Videos, Bewertungen oder ein Chat. Sie sehen die Adresse dieser Seite und deine IP-Adresse, sobald sie geladen werden.' => 'Content from other providers, such as maps, videos, reviews or a chat. They see the address of this page and your IP address as soon as they load.', + // 404 und Einwilligung + 'Seite nicht gefunden' => 'Page not found', + 'Diese Seite ist nicht mehr verfügbar.' => 'This page is no longer available.', + 'Die gesuchte Seite ist nicht verfügbar. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The page you are looking for is not available. The home page will take you back to the right content.', + 'Die Adresse ist möglicherweise veraltet oder wurde falsch eingegeben. Über die Startseite findest du wieder zum richtigen Inhalt.' => 'The address may be out of date or mistyped. The home page will take you back to the right content.', + 'Zur Startseite' => 'To the home page', + ], + 'fr' => [ + 'Zum Inhalt springen' => 'Aller au contenu', + 'Menü' => 'Menu', + 'Start' => 'Accueil', + 'Datenschutz' => 'Confidentialité', + 'Impressum' => 'Mentions légales', + 'Hauptnavigation' => 'Navigation principale', + 'Nach oben' => 'Haut de page', + 'Weiterlesen' => 'Lire la suite', + 'Alle akzeptieren' => 'Tout accepter', + 'Nur notwendige' => 'Uniquement nécessaires', + 'Sprache' => 'Langue', + ], + 'tr' => [ + 'Zum Inhalt springen' => 'İçeriğe geç', + 'Menü' => 'Menü', + 'Start' => 'Ana sayfa', + 'Datenschutz' => 'Gizlilik', + 'Impressum' => 'Künye', + 'Hauptnavigation' => 'Ana menü', + 'Nach oben' => 'Yukarı', + 'Weiterlesen' => 'Devamını oku', + 'Alle akzeptieren' => 'Tümünü kabul et', + 'Nur notwendige' => 'Yalnızca gerekli', + 'Sprache' => 'Dil', + ], + ]; + + public function __construct(private RequestStack $requests) + { + } + + public function getFunctions(): array + { + return [ + new TwigFunction('kant_og_locale', Languages::ogLocale(...)), + ]; + } + + public function getFilters(): array + { + return [ + new TwigFilter('kant_t', $this->translate(...)), + ]; + } + + /** + * Ein fester Text in der Sprache der Seite. Ohne weitere Sprache - oder in der Hauptsprache - + * bleibt er, wie er ist; ebenso jeder Text, der nicht in der Liste steht. + */ + public function translate(string $text): string + { + $locale = $this->requests->getCurrentRequest()?->attributes->get('kant_locale'); + if (!is_string($locale) || 'de' === $locale) { + return $text; + } + + return self::UI[$locale][$text] ?? self::UI['en'][$text] ?? $text; + } +} diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index 344f982..d364c84 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -18,7 +18,7 @@ Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie nicht mehr. #} diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 72e9e68..9fbebaf 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -22,6 +22,8 @@ + {# Der Sprachumschalter (ADR 111). Er erscheint nur, wenn die Website weitere Sprachen hat. #} +
@@ -71,6 +73,7 @@
+ @@ -90,7 +93,7 @@ -

Seite veröffentlichen?

Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.

+

Seite veröffentlichen?

Der gespeicherte Entwurf ersetzt den bisher sichtbaren Inhalt dieser Seite.

Gestaltung als Vorlage speichern

Die Vorlage steht danach für jeden Block auf jeder Seite zur Verfügung.

Serverstand laden?

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

diff --git a/templates/admin/forms.html.twig b/templates/admin/forms.html.twig index e44cba0..ad0ef38 100644 --- a/templates/admin/forms.html.twig +++ b/templates/admin/forms.html.twig @@ -4,35 +4,41 @@ Seite dessen Stylesheet, genau wie die Designseite. #} {% block styles %}{% endblock %} {% block scripts %}{% endblock %} -{% block title %}Formulare{% endblock %} -{% block heading %}Formulare{% endblock %} -{% block lead %}Felder, Empfänger, Einwilligungstext und Aussehen werden hier gepflegt. Auf einer Seite steht nur ein Formularblock mit der Adresse – so kann ein gespeicherter Seiteninhalt niemals ändern, wohin eine Anfrage geht.{% endblock %} -{% block actions %}
Neues Formular{% endblock %} +{% set editing = editing|default(false) %} +{% block title %}{{ editing ? (current ? current.name : 'Neues Formular') ~ ' · ' : '' }}Formulare{% endblock %} +{% block heading %}{{ editing ? (current ? current.name : 'Neues Formular') : 'Formulare' }}{% endblock %} +{% block lead %}{% if editing %}← Alle Formulare{% else %}Felder, Empfänger und Aussehen deiner Formulare. Auf einer Seite setzt du sie mit dem Baustein „Formular“ ein.{% endif %}{% endblock %} +{% block actions %}{% if not editing %}Neues Formular{% endif %}{% endblock %} {# Eine Feldkarte. Der Index steht in den Feldnamen; die Vorlage fuer neue Felder traegt an seiner Stelle den Platzhalter __n__, den das Skript beim Einfuegen ersetzt. #} -{% macro field(f, n, row, types) %} +{% macro field(f, n, row, types, languages = [], translations = {}) %} {% set id = 'f' ~ f ~ '-' ~ n %}
  • + {# Eine Zeile je Feld: Beschriftung, Typ, Pflicht. Alles Weitere steht in den Details, die das + Skript zuklappt - ohne Skript ist die Karte offen und vollständig bedienbar. #}
    - - + +
    - +
    + +
    -
    + +
    -

    Kleinbuchstaben, Ziffern, Unterstrich. Steht in der E-Mail vor der Antwort.

    +

    Steht in der E-Mail vor der Antwort.

    @@ -46,12 +52,9 @@ -

    Eine Zeile je Option: Option = Kennung aus der Mediathek. Sobald eine Option ein Bild hat, wird die Auswahl auf der Website zur Bildauswahl – anklickbare Karten statt Kästchen.

    +

    Mit Bild wird die Auswahl zu anklickbaren Karten. Ohne Skript: je Zeile Option = Kennung.

    -
    - -
    @@ -75,25 +78,41 @@
    -

    Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Bei einem Schritt gilt die Bedingung für alle seine Felder. Leer: das Feld gilt immer.

    +

    Technischer Name eines Auswahlfeldes weiter oben, Werte durch Komma. Leer: das Feld gilt immer.

    + {# Je weitere Sprache, was Besucher lesen (ADR 111). Leer heißt: wie in der Hauptsprache. #} + {% for sprache in languages %} + {% set tr = translations[sprache.code].fields[row.name]|default({}) %} +
    + {{ sprache.name }}{% if tr.label|default('') %} · {{ tr.label }}{% endif %} +
    +
    +
    +

    Nur bei Auswahlfeldern: in derselben Reihenfolge, durch Komma.

    +
    +
    +
    + {% endfor %}
  • {% endmacro %} {% block content %} {% import _self as karte %} +{% if editing %}
    -{% for form in forms|merge([null]) %} +{% for form in [current] %} {% set new = form is null %} {% set f = loop.index %} {% set rows = new ? [] : form.fields %} {% set settings = new ? {} : form.settings %} {% if new %} -
    - Neues Formular anlegen +
    {% else %} -
    +
    +
    + {{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ settings.recipient|default('–') }} · Adresse {{ form.slug }}{% for sprache in languages %}{% if settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }}{% endif %}{% endfor %} +
    {% endif %}
    @@ -102,15 +121,11 @@ {# Die Zusammenfassung des Details ist keine Ueberschrift; ohne diese springt die Gliederung von der Seitenueberschrift direkt zu den Abschnittstiteln. #}

    Neues Formular

    - {% else %} -
    -

    {{ form.name }} {{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}

    - Im Editor über den Formularblock wählbar · Adresse {{ form.slug }} -
    {% endif %}
    -

    Zustellung

    +
    +

    Zustellung

    {{ new ? '' : 'an ' ~ settings.recipient|default('') ~ ' · Betreff „' ~ settings.subject|default('') ~ '“' }}
    @@ -130,7 +145,7 @@
    -

    Leer: die Empfängeradresse. Viele Anbieter stellen nur zu, was von einer Adresse der eigenen Domain kommt.

    +

    Leer: die Empfängeradresse. Am besten eine Adresse der eigenen Domain.

    @@ -147,22 +162,24 @@
    -

    Wirkt bei Formularen mit Schritten: der letzte Schritt zeigt alle Angaben mit „Ändern“. Ohne Skript im Browser steht das Formular als Ganzes auf der Seite.

    +

    Nur bei Formularen mit Schritten.

    +

    Felder

      - {% for row in rows %}{{ karte.field(f, loop.index0, row, types) }}{% endfor %} - {{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types) }} + {% for row in rows %}{{ karte.field(f, loop.index0, row, types, languages, settings.translations|default({})) }}{% endfor %} + {{ karte.field(f, rows|length, {name: '', type: 'text', label: '', required: false, help: '', options: []}, types, languages) }}
    - -

    Ein Feld ohne Beschriftung wird nicht gespeichert – so lässt sich ein Feld auch ohne Skript entfernen. Ein Einwilligungsfeld ist immer ein Pflichtfeld; der Zeitpunkt der Einwilligung wird mitgespeichert. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung; alle Felder bis zum nächsten Schritt gehören dazu. Ein Feld oder ein Schritt kann von einer früheren Auswahl abhängen – dann verlangt der Server es nur, wenn die Bedingung zutrifft.

    + +

    Hilfetext, Optionen, Bedingungen und Übersetzungen stehen unter „Details“. Ein „Neuer Schritt“ beginnt einen Abschnitt; ein Feld ohne Beschriftung wird nicht gespeichert.

    -

    Aussehen

    +
    +

    Aussehen

    Button „{{ settings.buttonLabel|default('Absenden') }}“
    @@ -175,7 +192,19 @@
    {% endfor %}
    -

    Leer heißt: wie die Website. Sonst eine Farbe aus der Palette oder ein Wert wie #1a2b3c. Form und Größe des Buttons kommen aus dem Design.

    +

    Leer heißt: wie die Website. Form und Größe des Buttons kommen aus dem Design.

    +
    + {% for sprache in languages %} + {% set tr = settings.translations[sprache.code]|default({}) %} +
    +

    {{ sprache.name }}

    {{ tr ? (tr.fields|default({})|length ~ ' von ' ~ rows|length ~ ' Feldern übersetzt') : 'noch nicht übersetzt' }}
    +

    Button und Meldung auf {{ sprache.name }}; die Felder übersetzt du unter „Details“ am Feld. Die E-Mail an dich bleibt deutsch.

    +
    +
    +
    +
    +
    + {% endfor %}
    {# Die Vorschau: dieselben Felder, dieselben Farben, der Standardbutton der Website. @@ -212,13 +241,27 @@ {% if not new %}Änderungen gelten sofort auf jeder Seite, die dieses Formular zeigt.{% endif %}
    -{% if new %} -
    -{% else %} -{% endif %} {% endfor %}
    +{% else %} +{# Die Übersicht: eine Zeile je Formular, ohne Editor und ohne Vorschau. #} +{% if forms is empty %} +

    Noch kein Formular.

    Lege ein Formular an und setze es mit dem Baustein „Formular“ auf eine Seite.

    Neues Formular

    +{% else %} +
    + {% for form in forms %} +
    +
    +

    {{ form.name }}

    +

    {{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }} · an {{ form.settings.recipient|default('–') }} · Adresse {{ form.slug }}{% for sprache in languages %}{% if form.settings.translations[sprache.code]|default(null) %} · {{ sprache.code|upper }} übersetzt{% endif %}{% endfor %}

    +
    + {{ counts[form.id]|default(0) }} {{ counts[form.id]|default(0) == 1 ? 'Einsendung' : 'Einsendungen' }} + Bearbeiten +
    + {% endfor %} +
    +{% endif %}

    Einsendungen {{ submissions.total }}

    Neueste zuerst.
    {% if submissions.items is empty %} @@ -238,5 +281,6 @@ {% endif %} {% include 'admin/pager.html.twig' with {result: submissions, route: 'admin_forms', label: 'Seiten der Einsendungen'} only %} +{% endif %}

    KantSites · Arbeitsstand

    {% endblock %} diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig index d16e9ca..f7eaf4a 100644 --- a/templates/admin/menu-entry.html.twig +++ b/templates/admin/menu-entry.html.twig @@ -9,6 +9,9 @@ {{ row.label }}{{ row.type == 'page' ? 'Seitenlink' : 'Eigener Link' }}
    + {% for sprache in languages|default([]) %} + + {% endfor %} {% if row.type == 'page' %} {% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %} diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index fcf3669..fabe7f2 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -8,7 +8,7 @@ {% block content %} {% include 'admin/menu-icons.html.twig' %} -
    +
    +

    Sprachen

    Hauptsprache: {{ mainLanguage.name }}
    +
    +

    Jede weitere Sprache übersetzt dieselben Seiten: Aufbau und Gestaltung bleiben, je Sprache kommen nur die Texte und eine eigene Adresse dazu – zum Beispiel /en/services. Übersetzt wird im Editor über den Sprachumschalter. Eine Seite erscheint in einer Sprache erst, wenn ihre Übersetzung veröffentlicht ist.

    + {% if is_granted('ROLE_ADMIN') %} + + +
    + Weitere Sprachen + {% for sprache in languageCatalog %} + + {% endfor %} +
    +

    Ohne Häkchen bleibt die Website einsprachig, und nirgends erscheint etwas davon. Wird eine Sprache wieder abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben gespeichert. Damit Besucher die Sprache wechseln können, setze den Baustein „Sprachwahl“ in den Kopfbereich.

    + + + {% if extraLanguages %} +
    + +

    Texte in den weiteren Sprachen

    +

    Seiten, Kopf und Fuß übersetzt du im Editor, Menüs unter Menüs, Formulare unter Formulare. Hier stehen die Texte, die zur Website als Ganzes gehören. Leer heißt: wie in {{ mainLanguage.name }}.

    + {% for code in extraLanguages %} + {% set name = languageCatalog|filter(s => s.code == code)|first.name %} +
    + {{ name }} + {% if settings.consentBanner %} + + + + + {% endif %} + {% for term in terms %} + {% set key = term.kind ~ ':' ~ term.slug %} + + + {% endfor %} + {% if not settings.consentBanner and terms is empty %}

    Ohne Einwilligungshinweis und ohne Kategorien gibt es hier nichts zu übersetzen.

    {% endif %} +
    + {% endfor %} + +
    + {% endif %} + {% else %} +

    {{ extraLanguages ? 'Weitere Sprachen: ' ~ extraLanguages|map(c => c|upper)|join(', ') ~ '.' : 'Die Website ist einsprachig.' }} Ändern kann das ein Administrator.

    + {% endif %} +
    +

    Unternehmensdaten

    Grundlage für Organization und LocalBusiness
    {% if is_granted('ROLE_ADMIN') %} diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 6d227cd..6a5676b 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -58,7 +58,7 @@ {%- if klick == 'vergroessern' -%} {# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #} - + {%- elseif imageLinked -%} {%- endif -%}{{ props.alt }}{% if klick == 'vergroessern' or imageLinked %}{% endif %}{% if props.caption|default('') %}
    {{ props.caption }}
    {% endif %} @@ -112,7 +112,7 @@ {% set menuLayout = props.layout|default('horizontal') %} {% set collapse = menuLayout == 'horizontal' ? props.collapse|default('tablet') : 'never' %}