Translate pages, areas, menus, forms and posts field by field instead of rebuilding them

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 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-09 16:56:52 +02:00
1 parent c7705b7083
commit e15b2517b1
51 files changed
+3045 -219

No files matched your search

+5 -2
View File
@@ -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'],
+61 -3
View File
@@ -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.
+145 -13
View File
@@ -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<string, int> 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<string, string>, errors: array<string, string>, 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];
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Content;
use App\Domain\InvalidPage;
use Doctrine\DBAL\Connection;
/**
* Die Sprachen einer Website (ADR 111).
*
* Es gibt genau eine Hauptsprache - die der Seiten selbst (`website.default_locale`, bei allen
* bisherigen Installationen Deutsch). Jede weitere Sprache ist eine Übersetzungsebene über
* denselben Seiten: Aufbau und Gestaltung bleiben einmal da, je Sprache kommen nur Texte und
* die Adresse dazu. Ohne weitere Sprache ist von alldem nichts zu sehen.
*/
final class Languages
{
/**
* Die Sprachen, die zur Wahl stehen: Name in der Sprache selbst (für die Sprachwahl auf der
* Website), deutscher Name (für die Verwaltung) und die Form, die Open Graph erwartet.
*/
public const CATALOG = [
'de' => ['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<string>
*/
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<string> 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<string> $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);
}
}
+22 -5
View File
@@ -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.');
}
+243 -2
View File
@@ -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<string, string> 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<string, string>
*/
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<string>
*/
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<string, string> 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<string, string> $slugs Sprache => Adresse
*
* @return array<string, array<string, array{slug: string, path: string, old: ?string}>> 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.
+2 -2
View File
@@ -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.
+40 -11
View File
@@ -114,7 +114,12 @@ final class PostRepository
/**
* @return array{posts: list<array>, 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<string, array<string, list<array{slug: string, name: string, href: string}>>>
*/
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<string, list<array{slug: string, name: string, href: string}>>
*/
@@ -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;
+76
View File
@@ -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<string> $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<string, array<string, string>> $names Sprache => "art:adresse" => Name
* @param array<string, array<string, string>> $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.
*
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Content;
/**
* Übersetzungen als Ebene über dem Blockbaum (ADR 111, Dokumentschema v27).
*
* Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations`
* nur die Texte, die sich unterscheiden - adressiert über die stabile ID des Blocks:
*
* translations.en = {
* slug: "services", title: "…", description: "…", seoTitle: "…",
* blocks: { "blk_a1": { "text": "Our services" } },
* source: { "blk_a1.text": "1a2b3c4d" } // so sah der Text der Hauptsprache aus
* }
*
* Aufbau, Gestaltung, Bilder und Links bleiben dieselben. Ausgeliefert wird eine Sprache, indem
* die Texte auf eine Kopie des Dokuments gelegt werden (apply) - danach läuft dieselbe Prüfung
* und dieselbe Ausgabe wie für die Hauptsprache, ohne dass ein Renderer davon wissen muss. Ein
* Feld ohne Übersetzung zeigt den Text der Hauptsprache.
*
* Weil die Übersetzungen im Dokument stehen, gehören sie zu jeder Fassung: Entwurf,
* Veröffentlichung, Wiederherstellung und Undo im Editor gelten für alle Sprachen zugleich.
*/
final class Translations
{
public const SCHEMA = 27;
/**
* Welche Eigenschaften eines Blocks Text sind, den eine Übersetzung ersetzt - mit derselben
* Höchstlänge wie im DocumentValidator. Dieselbe Liste steht in translate.js.
*/
public const FIELDS = [
'heading' => ['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<string> */
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;
}
}
+40 -11
View File
@@ -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<string, 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']),
],
];
}
}
+51 -7
View File
@@ -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_<sprache>_label, _help, _unit, _options am Feld, dazu
// success_<sprache> und buttonLabel_<sprache> 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]);
}
}
+18 -5
View File
@@ -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']),
+12 -1
View File
@@ -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_<sprache>; 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 {
+233 -20
View File
@@ -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<string, string>}|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('/<a\b[^>]*>/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<array{locale: string, native: string, href: string, url: string, current: bool}>
*/
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;
+28 -8
View File
@@ -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,
],
];
}
+20
View File
@@ -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];
+46 -1
View File
@@ -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
{
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace App\Twig;
use App\Content\Languages;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/** Sprachangaben für die Vorlagen (ADR 111). */
final class LanguageExtension extends AbstractExtension
{
/**
* Die festen Texte der öffentlichen Vorlagen, die kein Redakteur pflegt: Sprunglink,
* Beschriftungen von Navigationen, Einwilligung, Beitragslisten. Schlüssel ist der deutsche
* Text. Für Sprachen ohne eigene Liste gilt Englisch - auf einer französischen Seite ist
* „Skip to content“ näher am Besucher als „Zum Inhalt springen“.
*/
public const UI = [
'en' => [
'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;
}
}