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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user