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:
1 parent
c7705b7083
commit
e15b2517b1
51 files changed
+3045
-219
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user