Files
KantSites/src/Content/Languages.php
T
kantarciandClaude Opus 5.5 e15b2517b1 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>
2026-10-09 16:56:52 +02:00

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);
}
}