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

A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

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

No files matched your search

+145 -13
View File
@@ -322,6 +322,10 @@ final class FormRepository
// Skript steht das ganze Formular ohnehin auf einer Seite.
'summary' => (bool) ($settings['summary'] ?? false),
];
$translations = $this->validateTranslations($settings['translations'] ?? [], $fields);
if ([] !== $translations) {
$clean['translations'] = $translations;
}
// Das Aussehen: leer heisst "wie die Website", sonst ein Palettenschluessel oder ein
// Hexwert - dieselbe Regel wie fuer jede Farbe im Editor.
foreach (self::COLORS as $field => $label) {
@@ -335,6 +339,130 @@ final class FormRepository
return $clean;
}
/**
* Die Übersetzungen eines Formulars (ADR 111): je Sprache Erfolgsmeldung, Button und je Feld
* Beschriftung, Hilfetext, Einheit und die angezeigten Optionen. Übersetzt wird nur, was
* Besucher lesen. Abgeschickt werden weiter die Optionen der Hauptsprache - Bedingungen,
* Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger bleiben dieselben.
*/
private function validateTranslations(mixed $input, array $fields): array
{
if (!is_array($input)) {
throw new InvalidForm('Die Übersetzungen des Formulars sind ungültig.');
}
$byName = array_column($fields, null, 'name');
$clean = [];
foreach ($input as $locale => $translation) {
if (!is_string($locale) || !isset(Languages::CATALOG[$locale]) || !is_array($translation)) {
throw new InvalidForm('Eine Übersetzung des Formulars gehört zu einer unbekannten Sprache.');
}
$out = [];
foreach (['success' => 300, 'buttonLabel' => 40] as $key => $max) {
$text = trim((string) ($translation[$key] ?? ''));
if ('' !== $text) {
$out[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($translation['fields'] ?? null) ? $translation['fields'] : []) as $name => $values) {
$field = $byName[$name] ?? null;
if (null === $field || !is_array($values)) {
continue;
}
$entry = [];
foreach (['label' => 120, 'help' => 200, 'unit' => 12] as $key => $max) {
$text = trim((string) ($values[$key] ?? ''));
if ('' !== $text) {
$entry[$key] = mb_substr($text, 0, $max);
}
}
foreach ((is_array($values['options'] ?? null) ? $values['options'] : []) as $original => $text) {
$text = trim((string) $text);
if (in_array((string) $original, $field['options'], true) && '' !== $text) {
$entry['options'][(string) $original] = mb_substr($text, 0, 120);
}
}
if ([] !== $entry) {
$out['fields'][$name] = $entry;
}
}
if ([] !== $out) {
$clean[$locale] = $out;
}
}
return $clean;
}
/**
* Das Formular in einer weiteren Sprache: Beschriftungen, Hilfetexte, Einheiten, Button und
* Erfolgsmeldung übersetzt, wo es Übersetzungen gibt. Optionen behalten ihren Wert; nur die
* Anzeige steht in `optionLabels`.
*/
public static function localise(array $form, ?string $locale): array
{
$translation = null === $locale ? [] : ($form['settings']['translations'][$locale] ?? []);
foreach ($form['fields'] as $index => $field) {
$values = $translation['fields'][$field['name']] ?? [];
foreach (['label', 'help', 'unit'] as $key) {
if (isset($values[$key])) {
$form['fields'][$index][$key] = $values[$key];
}
}
$form['fields'][$index]['optionLabels'] = $values['options'] ?? [];
}
foreach (['success', 'buttonLabel'] as $key) {
if (isset($translation[$key])) {
$form['settings'][$key] = $translation[$key];
}
}
$form['locale'] = $locale;
return $form;
}
/**
* Die Meldungen der Prüfung in weiteren Sprachen. Schlüssel ist die deutsche Vorlage samt
* Platzhaltern; ohne eigene Liste gilt Englisch.
*/
public const MESSAGES = [
'en' => [
'Bitte wähle nur aus den angebotenen Optionen.' => 'Please choose only from the options offered.',
'Bitte wähle mindestens eine Option.' => 'Please choose at least one option.',
'Bitte kürze die Eingabe auf höchstens %d Zeichen.' => 'Please shorten your entry to at most %d characters.',
'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.' => 'We cannot process your request without this consent.',
'Bitte wähle eine der angebotenen Optionen.' => 'Please choose one of the options offered.',
'Dieses Feld wird benötigt.' => 'This field is required.',
'Bitte gib eine gültige E-Mail-Adresse an.' => 'Please enter a valid email address.',
'Bitte gib eine gültige Telefonnummer an.' => 'Please enter a valid phone number.',
'Bitte gib eine Zahl an.' => 'Please enter a number.',
'Bitte gib höchstens %s an.' => 'Please enter at most %s.',
'Bitte gib mindestens %s an.' => 'Please enter at least %s.',
'Bitte gib einen Wert zwischen %s und %s an.' => 'Please enter a value between %s and %s.',
],
];
private static function message(?string $locale, string $text, mixed ...$args): string
{
$template = null === $locale || 'de' === $locale ? $text : (self::MESSAGES[$locale][$text] ?? self::MESSAGES['en'][$text] ?? $text);
return [] === $args ? $template : sprintf($template, ...$args);
}
/**
* Wie viele Einsendungen je Formular eingegangen sind, für die Übersicht.
*
* @return array<string, int> Formular-ID => Anzahl
*/
public function submissionCounts(): array
{
$counts = [];
foreach ($this->db->fetchAllAssociative('SELECT form_id, COUNT(*) AS n FROM form_submission GROUP BY form_id') as $row) {
$counts[(string) $row['form_id']] = (int) $row['n'];
}
return $counts;
}
/** Ob dieses Formular auf der Seite das Skript braucht: Schritte, Bedingungen oder die Zusammenfassung. */
public function needsScript(array $form): bool
{
@@ -401,8 +529,12 @@ final class FormRepository
*
* @return array{values: array<string, string>, errors: array<string, string>, consent: bool}
*/
public function check(array $form, array $input): array
/**
* @param string|null $locale eine weitere Sprache: die Meldungen stehen dann in ihr
*/
public function check(array $form, array $input, ?string $locale = null): array
{
$t = static fn (string $text, mixed ...$args): string => self::message($locale, $text, ...$args);
$values = [];
$errors = [];
$consent = false;
@@ -427,10 +559,10 @@ final class FormRepository
if ('checkboxes' === $field['type']) {
$picked = array_values(array_filter(array_map(static fn ($v): string => is_string($v) ? trim($v) : '', is_array($raw) ? $raw : [$raw]), static fn (string $v): bool => '' !== $v));
if ([] !== array_diff($picked, $field['options'])) {
$errors[$name] = 'Bitte wähle nur aus den angebotenen Optionen.';
$errors[$name] = $t('Bitte wähle nur aus den angebotenen Optionen.');
$picked = array_values(array_intersect($picked, $field['options']));
} elseif ([] === $picked && $field['required']) {
$errors[$name] = 'Bitte wähle mindestens eine Option.';
$errors[$name] = $t('Bitte wähle mindestens eine Option.');
}
$chosen[$name] = $picked;
$values[$name] = implode(', ', $picked);
@@ -439,39 +571,39 @@ final class FormRepository
$value = is_string($raw) ? trim($raw) : '';
$limit = 'textarea' === $field['type'] ? 5000 : 200;
if (mb_strlen($value) > $limit) {
$errors[$name] = sprintf('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$errors[$name] = $t('Bitte kürze die Eingabe auf höchstens %d Zeichen.', $limit);
$value = mb_substr($value, 0, $limit);
}
if ('consent' === $field['type']) {
$consent = '' !== $value;
if (!$consent) {
$errors[$name] = 'Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.';
$errors[$name] = $t('Ohne diese Einwilligung können wir die Anfrage nicht bearbeiten.');
}
$values[$name] = $consent ? 'ja' : '';
continue;
}
if ('' === $value) {
if ($field['required']) {
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? 'Bitte wähle eine der angebotenen Optionen.' : 'Dieses Feld wird benötigt.';
$errors[$name] = in_array($field['type'], self::CHOICE_TYPES, true) ? $t('Bitte wähle eine der angebotenen Optionen.') : $t('Dieses Feld wird benötigt.');
}
$values[$name] = '';
continue;
}
if ('email' === $field['type'] && false === filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$name] = 'Bitte gib eine gültige E-Mail-Adresse an.';
$errors[$name] = $t('Bitte gib eine gültige E-Mail-Adresse an.');
}
if ('phone' === $field['type'] && 1 !== preg_match('/^\+?[0-9 ()\/-]{3,32}$/D', $value)) {
$errors[$name] = 'Bitte gib eine gültige Telefonnummer an.';
$errors[$name] = $t('Bitte gib eine gültige Telefonnummer an.');
}
if ('number' === $field['type']) {
$zahl = str_replace(',', '.', $value);
if (!is_numeric($zahl)) {
$errors[$name] = 'Bitte gib eine Zahl an.';
$errors[$name] = $t('Bitte gib eine Zahl an.');
} elseif ((null !== $field['min'] && (float) $zahl < $field['min']) || (null !== $field['max'] && (float) $zahl > $field['max'])) {
$errors[$name] = match (true) {
null === $field['min'] => sprintf('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => sprintf('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => sprintf('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
null === $field['min'] => $t('Bitte gib höchstens %s an.', self::zahl($field['max'])),
null === $field['max'] => $t('Bitte gib mindestens %s an.', self::zahl($field['min'])),
default => $t('Bitte gib einen Wert zwischen %s und %s an.', self::zahl($field['min']), self::zahl($field['max'])),
};
} else {
// Gespeichert wird die Zahl, wie sie ist - ohne führende Nullen, mit Punkt.
@@ -480,7 +612,7 @@ final class FormRepository
}
if (in_array($field['type'], ['select', 'radio'], true)) {
if (!in_array($value, $field['options'], true)) {
$errors[$name] = 'Bitte wähle eine der angebotenen Optionen.';
$errors[$name] = $t('Bitte wähle eine der angebotenen Optionen.');
} else {
$chosen[$name] = [$value];
}