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>
138 lines
5.4 KiB
PHP
138 lines
5.4 KiB
PHP
<?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);
|
|
}
|
|
}
|