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
@@ -7,15 +7,18 @@ use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\StyleSchema;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -31,7 +34,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private FontLibrary $fonts, private CsrfTokenManagerInterface $csrf, private RequestStack $requests, private PluginRegistry $plugins, private FormRepository $forms, private PostRepository $postsForPreview, private Languages $languages, private SiteSettings $siteSettings)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -440,8 +443,9 @@ final class EditorController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
if (strlen($request->getContent()) > 270000) {
|
||||
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||
// Eine Seite darf 256 KiB haben, mit ihren Übersetzungen zusammen 1 MiB (ADR 111).
|
||||
if (strlen($request->getContent()) > 1100000) {
|
||||
return $this->json(['error' => 'Die Seite ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
|
||||
}
|
||||
$page = $this->page($id);
|
||||
try {
|
||||
@@ -456,20 +460,30 @@ final class EditorController extends AbstractController
|
||||
throw new \InvalidArgumentException();
|
||||
}
|
||||
$this->validator->validate($document, writing: true);
|
||||
// Die Vorschau einer Übersetzung ist dieselbe Seite mit den übersetzten Texten -
|
||||
// genau so, wie die Website sie in dieser Sprache ausliefert.
|
||||
$language = $body['language'] ?? null;
|
||||
$locale = is_string($language) && $this->languages->isExtra($language) ? $language : $this->languages->main();
|
||||
$seoTitle = $locale === $this->languages->main() ? ($page['seo_title'] ?? null) : ($document['translations'][$locale]['seoTitle'] ?? '');
|
||||
$document = $locale === $this->languages->main() ? Translations::strip($document) : Translations::apply($document, $locale);
|
||||
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
||||
|
||||
$request->attributes->set('kant_locale', $locale);
|
||||
$localise = fn (array $doc): array => $locale === $this->languages->main() ? Translations::strip($doc) : Translations::apply($doc, $locale);
|
||||
|
||||
$theme = $this->theme->load();
|
||||
$areas = [];
|
||||
foreach (['header', 'footer'] as $kind) {
|
||||
$area = $this->partials->findByKind($kind);
|
||||
if (null !== $area) {
|
||||
$areas[$kind] = $area['document'];
|
||||
$areas[$kind] = $localise($area['document']);
|
||||
}
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
$sections[$slug] = $localise($section['document']);
|
||||
}
|
||||
}
|
||||
// Wie auf der Website: die zweite Verknüpfung derselben Sektion bekommt eigene Kennungen.
|
||||
@@ -485,7 +499,7 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path'], language: $locale === $this->languages->main() ? null : $locale);
|
||||
}
|
||||
|
||||
// Die Vorschau zeigt dieselbe Ausgabe wie die Website - einschliesslich eigenem
|
||||
@@ -496,14 +510,14 @@ final class EditorController extends AbstractController
|
||||
foreach ($documents as $doc) {
|
||||
$styles .= $this->styles->compileDocument($doc).$this->code->compileDocument($doc);
|
||||
}
|
||||
$forms = $this->previewForms($documents, $page);
|
||||
$forms = $this->previewForms($documents, $page, $locale === $this->languages->main() ? null : $locale);
|
||||
foreach ($forms as $state) {
|
||||
if (null !== $state['form']) {
|
||||
$styles .= $this->forms->css($state['form'], $theme);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document, 'locale' => $locale, 'seo_title' => $seoTitle], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => $areas, 'sections' => $sections, 'needsTabs' => false, 'forms' => $forms, 'pluginStyles' => $this->plugins->assetsFor($documents)['styles'], 'pageStyles' => $styles, 'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($theme) : []]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
@@ -536,6 +550,9 @@ final class EditorController extends AbstractController
|
||||
return $this->json(['error' => 'Inzwischen wurde ein neuerer Stand gespeichert. Deine Änderungen bleiben im Editor. Lade sie als JSON herunter, bevor du den aktuellen Serverstand lädst.', 'conflict' => true], 409);
|
||||
} catch (InvalidDocument $e) {
|
||||
return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422);
|
||||
} catch (InvalidPage $e) {
|
||||
// Etwa eine übersetzte Adresse, die eine andere Seite schon hat.
|
||||
return $this->json(['error' => $e->getMessage()], 422);
|
||||
} catch (\JsonException|\InvalidArgumentException) {
|
||||
return $this->json(['error' => 'Die Anfrage enthält kein gültiges Seitendokument oder keine gültige Version.'], 422);
|
||||
}
|
||||
@@ -549,13 +566,16 @@ final class EditorController extends AbstractController
|
||||
*
|
||||
* @return array<string, array>
|
||||
*/
|
||||
private function previewForms(array $documents, array $page): array
|
||||
private function previewForms(array $documents, array $page, ?string $language = null): array
|
||||
{
|
||||
$states = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page): void {
|
||||
$walk = function (array $blocks) use (&$walk, &$states, $page, $language): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
if (null !== $form && null !== $language) {
|
||||
$form = FormRepository::localise($form, $language);
|
||||
}
|
||||
$states[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => $page, 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
@@ -575,6 +595,15 @@ final class EditorController extends AbstractController
|
||||
|
||||
private function payload(array $page): array
|
||||
{
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor()];
|
||||
return ['document' => $page['document'], 'version' => $page['version'], 'revisionId' => $page['draft_revision_id'], 'publishedRevisionId' => $page['published_revision_id'], 'csrfToken' => $this->csrf->getToken('editor')->getValue(), 'plugins' => $this->plugins->forEditor(), 'presets' => $this->plugins->presetsForEditor(),
|
||||
// Für den Sprachumschalter (ADR 111). Ohne weitere Sprache leer - dann gibt es ihn nicht.
|
||||
'languages' => [
|
||||
'main' => $this->languages->describe($this->languages->main()),
|
||||
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
'slug' => $page['slug'],
|
||||
'home' => $page['path'] === $this->siteSettings->homePath(),
|
||||
'published' => [] === $this->languages->extra() ? [] : $this->pages->translatedPaths($page['id']),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
@@ -15,25 +16,49 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class FormAdminController extends AbstractController
|
||||
{
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media)
|
||||
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Übersicht: jedes Formular eine Zeile, dazu die Einsendungen. Bearbeitet wird ein
|
||||
* Formular auf seiner eigenen Seite - mit mehreren Formularen war die eine lange Seite mit
|
||||
* allen Editoren und Vorschauen nicht mehr zu überblicken (Umut, 09.10.2026).
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
|
||||
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'forms' => $this->forms->all(),
|
||||
'counts' => $this->forms->submissionCounts(),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Ein Formular bearbeiten, oder mit der Adresse „neu“ eines anlegen. */
|
||||
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_edit', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['GET'])]
|
||||
public function edit(string $slug): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||||
$current = 'neu' === $slug ? null : $this->forms->findBySlug($slug);
|
||||
if ('neu' !== $slug && null === $current) {
|
||||
throw $this->createNotFoundException('Dieses Formular gibt es nicht.');
|
||||
}
|
||||
$theme = $this->theme->load();
|
||||
|
||||
return $this->render('admin/forms.html.twig', [
|
||||
'editing' => true,
|
||||
'current' => $current,
|
||||
'types' => FormRepository::TYPES,
|
||||
'colors' => FormRepository::COLORS,
|
||||
// Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird.
|
||||
'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']),
|
||||
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
|
||||
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
|
||||
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
|
||||
@@ -48,10 +73,27 @@ final class FormAdminController extends AbstractController
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$fields = [];
|
||||
// Je weitere Sprache: Felder tr_<sprache>_label, _help, _unit, _options am Feld, dazu
|
||||
// success_<sprache> und buttonLabel_<sprache> am Formular (ADR 111). Die Optionen stehen
|
||||
// als Kommaliste in derselben Reihenfolge wie die der Hauptsprache.
|
||||
$translations = [];
|
||||
foreach ($this->languages->extra() as $code) {
|
||||
$translations[$code] = ['success' => $request->request->getString('success_'.$code), 'buttonLabel' => $request->request->getString('buttonLabel_'.$code), 'fields' => []];
|
||||
}
|
||||
foreach ($request->request->all('fields') as $row) {
|
||||
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
|
||||
continue;
|
||||
}
|
||||
$originals = array_map(trim(...), explode(',', (string) ($row['options'] ?? '')));
|
||||
foreach (array_keys($translations) as $code) {
|
||||
$translatedOptions = array_map(trim(...), explode(',', (string) ($row['tr_'.$code.'_options'] ?? '')));
|
||||
$translations[$code]['fields'][(string) ($row['name'] ?? '')] = [
|
||||
'label' => (string) ($row['tr_'.$code.'_label'] ?? ''),
|
||||
'help' => (string) ($row['tr_'.$code.'_help'] ?? ''),
|
||||
'unit' => (string) ($row['tr_'.$code.'_unit'] ?? ''),
|
||||
'options' => array_combine($originals, array_pad(array_slice($translatedOptions, 0, count($originals)), count($originals), '')),
|
||||
];
|
||||
}
|
||||
$fields[] = [
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'type' => (string) ($row['type'] ?? 'text'),
|
||||
@@ -82,21 +124,23 @@ final class FormAdminController extends AbstractController
|
||||
'replyToField' => $request->request->getString('replyToField'),
|
||||
'buttonLabel' => $request->request->getString('buttonLabel'),
|
||||
'summary' => '' !== $request->request->getString('summary'),
|
||||
'translations' => $translations,
|
||||
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
|
||||
],
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
} catch (InvalidForm|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
return $this->back($e->getMessage(), 'error', $slug);
|
||||
}
|
||||
|
||||
return $this->back('Das Formular wurde gespeichert.', 'success');
|
||||
return $this->back('Das Formular wurde gespeichert.', 'success', 'neu' === $slug ? $request->request->getString('slug') : $slug);
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
private function back(string $message, string $type = 'error', ?string $slug = null): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_forms');
|
||||
// Zurück zum bearbeiteten Formular; ohne Adresse (abgelaufene Sitzung) zur Übersicht.
|
||||
return null === $slug ? $this->redirectToRoute('admin_forms') : $this->redirectToRoute('admin_form_edit', ['slug' => $slug]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
@@ -32,6 +33,7 @@ final class FormController extends AbstractController
|
||||
private Turnstile $turnstile,
|
||||
private string $secret,
|
||||
private string $siteOrigin,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -56,10 +58,19 @@ final class FormController extends AbstractController
|
||||
// noch einmal.
|
||||
|| !$this->turnstile->passed($request->request->getString('cf-turnstile-response'), $request->getClientIp());
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all());
|
||||
// Abgeschickt von einer übersetzten Seite (ADR 111): Meldungen in dieser Sprache, und der
|
||||
// Weg zurück führt auf die übersetzte Seite - sofern es sie gibt.
|
||||
$language = $request->request->getString('language');
|
||||
$translatedPath = null;
|
||||
if ($this->languages->isExtra($language)) {
|
||||
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
|
||||
}
|
||||
$language = null === $translatedPath ? null : $language;
|
||||
|
||||
$checked = $this->forms->check($form, $request->request->all(), $language);
|
||||
if (!$silentlyDropped && [] !== $checked['errors']) {
|
||||
// Re-render the page the form sits on, with the entered values and the messages.
|
||||
return $this->renderPage($request, $page, $form, $checked);
|
||||
return $this->renderPage($request, $page, $form, $checked, null === $language ? null : ($page['path'] === $this->settings->homePath() ? $language : $language.'/'.$translatedPath));
|
||||
}
|
||||
if (!$silentlyDropped) {
|
||||
$submission = $this->forms->store($form['id'], $checked['values'], $checked['consent'], $senderHash);
|
||||
@@ -69,7 +80,9 @@ final class FormController extends AbstractController
|
||||
}
|
||||
|
||||
// Post/Redirect/Get so a reload does not send the message twice.
|
||||
return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
$back = null === $language ? $this->settings->publicPath($path) : '/'.$language.($page['path'] === $this->settings->homePath() ? '' : '/'.$translatedPath);
|
||||
|
||||
return $this->redirect($back.'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303);
|
||||
}
|
||||
|
||||
/** The signed timestamp makes the age of the form checkable without storing anything. */
|
||||
@@ -128,7 +141,7 @@ final class FormController extends AbstractController
|
||||
* vergass, sah eine Seite ohne Gestaltung. Jetzt geht der Weg durch dieselbe Stelle wie jeder
|
||||
* andere Aufruf, und die Formularzustaende werden als Anfrageattribut mitgereicht.
|
||||
*/
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked): Response
|
||||
private function renderPage(Request $request, array $page, array $form, array $checked, ?string $translatedAddress = null): Response
|
||||
{
|
||||
// Nur das abgeschickte Formular zeigt Meldungen und behaelt seine Eingaben - wo auch immer
|
||||
// es auf der Seite steht: im Inhalt, im Kopf, im Fuss oder in einer verknuepften Sektion.
|
||||
@@ -137,7 +150,7 @@ final class FormController extends AbstractController
|
||||
|
||||
// Die Startseite antwortet unter "/" - mit ihrem eigenen Pfad wuerde sie dorthin umleiten,
|
||||
// und aus der Fehlermeldung wuerde eine Weiterleitung ohne Meldung.
|
||||
$adresse = $page['path'] === $this->settings->homePath() ? '' : (string) $page['path'];
|
||||
$adresse = $translatedAddress ?? ($page['path'] === $this->settings->homePath() ? '' : (string) $page['path']);
|
||||
$antwort = $this->forward('App\Controller\PageController::page', [
|
||||
'path' => $adresse,
|
||||
'request' => $request->duplicate(null, null, ['kant_form_state' => $zustaende, '_route' => 'page']),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\ButtonDesign;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuButton;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
@@ -17,7 +18,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MenuController extends AbstractController
|
||||
{
|
||||
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme)
|
||||
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ final class MenuController extends AbstractController
|
||||
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
|
||||
'menu' => $menu, 'rows' => $rows,
|
||||
'menuIcons' => \App\Content\DocumentValidator::ICONS,
|
||||
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
// Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor.
|
||||
'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE],
|
||||
]);
|
||||
@@ -130,6 +132,7 @@ final class MenuController extends AbstractController
|
||||
'newTab' => $item['newTab'] ?? false,
|
||||
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
|
||||
'button' => $item['button'] ?? null,
|
||||
'labels' => $item['labels'] ?? [],
|
||||
];
|
||||
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
|
||||
}
|
||||
@@ -160,6 +163,11 @@ final class MenuController extends AbstractController
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
'button' => MenuButton::fromForm($row),
|
||||
// Je weitere Sprache ein Feld label_<sprache>; leer heißt: wie in der Hauptsprache.
|
||||
'labels' => array_filter(
|
||||
array_combine($this->languages->extra(), array_map(static fn (string $code): string => mb_substr(trim((string) ($row['label_'.$code] ?? '')), 0, 80), $this->languages->extra())) ?: [],
|
||||
static fn (string $text): bool => '' !== $text,
|
||||
),
|
||||
];
|
||||
}
|
||||
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
|
||||
@@ -177,6 +185,9 @@ final class MenuController extends AbstractController
|
||||
if (null !== $row['button']) {
|
||||
$item['button'] = $row['button'];
|
||||
}
|
||||
if ([] !== $row['labels']) {
|
||||
$item['labels'] = $row['labels'];
|
||||
}
|
||||
if ('url' === $row['type']) {
|
||||
$item['url'] = $row['url'];
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\NotFoundPage;
|
||||
@@ -18,6 +19,7 @@ use App\Content\SiteSettings;
|
||||
use App\Content\StyleBundle;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Content\Turnstile;
|
||||
use App\Plugin\PluginRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -27,21 +29,40 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private PostRepository $posts, private FormRepository $forms, private RedirectRepository $redirects, private SiteSettings $settings, private PartialRepository $partials, private CustomCode $code, private Consent $consent, private Theme $theme, private StyleCompiler $styles, private MediaLibrary $media, private FontLibrary $fonts, private StyleBundle $bundle, private Turnstile $turnstile, private string $secret, private string $siteOrigin, private NotFoundPage $notFoundPage, private PluginRegistry $plugins, private Languages $languages)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Sprache dieser Anfrage, wenn es nicht die Hauptsprache ist: Kennung und die Zuordnung
|
||||
* Pfad der Hauptsprache => übersetzter Pfad (ADR 111). Null für die Hauptsprache.
|
||||
*
|
||||
* @var array{locale: string, map: array<string, string>}|null
|
||||
*/
|
||||
private ?array $translation = null;
|
||||
|
||||
/** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */
|
||||
private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px';
|
||||
|
||||
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
|
||||
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
|
||||
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
|
||||
|
||||
#[Route('/', name: 'home', methods: ['GET'])]
|
||||
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
|
||||
public function page(Request $request, string $path = ''): Response
|
||||
{
|
||||
$this->translation = null;
|
||||
// Eine weitere Sprache steht vorn in der Adresse: /en ist ihre Startseite, /en/services
|
||||
// eine übersetzte Seite. Ohne weitere Sprache gibt es diesen Weg gar nicht.
|
||||
$segments = explode('/', $path, 2);
|
||||
if ('' !== $path && $this->languages->isExtra($segments[0])) {
|
||||
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
|
||||
}
|
||||
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
|
||||
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$home = $this->settings->homePath();
|
||||
$path = '' === $path ? $home : $path;
|
||||
// One canonical address for the root page: its own path redirects to "/".
|
||||
@@ -71,6 +92,134 @@ final class PageController extends AbstractController
|
||||
return $this->renderPage($request, $page, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Seite in einer weiteren Sprache. Gefunden wird sie über ihre übersetzte Adresse; ihre
|
||||
* Startseite antwortet unter /en und nicht zusätzlich unter /en/home.
|
||||
*/
|
||||
private function translatedPage(Request $request, string $locale, string $rest): Response
|
||||
{
|
||||
$map = $this->pages->translationMap($locale);
|
||||
// Schon hier: auch eine 404 unter /en/ antwortet auf Englisch.
|
||||
$this->translation = ['locale' => $locale, 'map' => $map];
|
||||
$homeTranslated = $map[$this->settings->homePath()] ?? null;
|
||||
if ('' !== $rest && $rest === $homeTranslated && null === $request->attributes->get('kant_form_state')) {
|
||||
return $this->shell($this->redirect('/'.$locale, 301));
|
||||
}
|
||||
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
|
||||
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
|
||||
$this->translation = ['locale' => $locale, 'map' => $map];
|
||||
|
||||
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
|
||||
}
|
||||
$target = '' === $rest ? $homeTranslated : $rest;
|
||||
$page = null === $target ? null : $this->pages->findPublishedTranslation($target, $locale);
|
||||
if (null === $page) {
|
||||
$redirect = '' === $rest ? null : $this->redirects->find($locale.'/'.$rest);
|
||||
if (null !== $redirect && 301 === $redirect['status']) {
|
||||
return $this->shell($this->redirect('/'.$redirect['to'], 301));
|
||||
}
|
||||
|
||||
return $this->notFound($request, null !== $redirect ? 410 : 404);
|
||||
}
|
||||
if ($page['id'] === $this->settings->load()['notFoundPageId']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, $page['path']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kopf, Fuß und Sektionen in der Sprache dieser Anfrage: übersetzt, wo es Übersetzungen gibt,
|
||||
* und ohne die Übersetzungen selbst in der Hauptsprache.
|
||||
*/
|
||||
private function localise(array $document): array
|
||||
{
|
||||
return null === $this->translation ? Translations::strip($document) : Translations::apply($document, $this->translation['locale']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die öffentliche Adresse eines Pfades der Hauptsprache in der Sprache dieser Anfrage - oder
|
||||
* unverändert, wenn die Seite dort nicht übersetzt ist.
|
||||
*/
|
||||
private function localHref(string $href): string
|
||||
{
|
||||
if (null === $this->translation || !str_starts_with($href, '/') || str_starts_with($href, '//')) {
|
||||
return $href;
|
||||
}
|
||||
$suffix = '';
|
||||
if (false !== ($cut = strcspn($href, '?#')) && $cut < strlen($href)) {
|
||||
$suffix = substr($href, $cut);
|
||||
$href = substr($href, 0, $cut);
|
||||
}
|
||||
$path = trim($href, '/');
|
||||
$home = $this->settings->homePath();
|
||||
$locale = $this->translation['locale'];
|
||||
if ('' === $path || $path === $home) {
|
||||
return isset($this->translation['map'][$home]) ? '/'.$locale.$suffix : $href.$suffix;
|
||||
}
|
||||
if ('beitraege' === $path || str_starts_with($path, 'kategorie/') || str_starts_with($path, 'schlagwort/')) {
|
||||
return '/'.$locale.'/'.$path.$suffix;
|
||||
}
|
||||
|
||||
return isset($this->translation['map'][$path]) ? '/'.$locale.'/'.$this->translation['map'][$path].$suffix : $href.$suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf
|
||||
* deren übersetzte Fassung - in Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen.
|
||||
* Ein Link auf eine nicht übersetzte Seite bleibt, wie er ist. Ausgenommen sind die Links
|
||||
* der Sprachwahl, die mit hreflang bewusst auf eine bestimmte Sprache zeigen.
|
||||
*/
|
||||
private function localiseLinks(string $html): string
|
||||
{
|
||||
if (null === $this->translation) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return preg_replace_callback('/<a\b[^>]*>/i', function (array $tag): string {
|
||||
if (str_contains($tag[0], 'hreflang=')) {
|
||||
return $tag[0];
|
||||
}
|
||||
|
||||
return preg_replace_callback('/\bhref="(\/[^"]*)"/', fn (array $href): string => 'href="'.$this->localHref($href[1]).'"', $tag[0]) ?? $tag[0];
|
||||
}, $html) ?? $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unter welchen Adressen diese Seite in welcher Sprache steht - für hreflang und die
|
||||
* Sprachwahl. Leer, solange die Website nur eine Sprache hat.
|
||||
*
|
||||
* @return list<array{locale: string, native: string, href: string, url: string, current: bool}>
|
||||
*/
|
||||
private function alternates(array $page, int $status): array
|
||||
{
|
||||
$extra = $this->languages->extra();
|
||||
if ([] === $extra || $status >= 400 || !isset($page['id']) || 'archive' === ($page['type'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
$origin = rtrim($this->siteOrigin, '/');
|
||||
$home = $this->settings->homePath();
|
||||
$current = $this->translation['locale'] ?? $this->languages->main();
|
||||
$main = $this->languages->main();
|
||||
$list = [['locale' => $main, 'href' => $this->settings->publicPath($page['path'], $home)]];
|
||||
$translated = $this->pages->translatedPaths($page['id']);
|
||||
foreach ($extra as $locale) {
|
||||
if (isset($translated[$locale])) {
|
||||
$list[] = ['locale' => $locale, 'href' => $page['path'] === $home ? '/'.$locale : '/'.$locale.'/'.$translated[$locale]];
|
||||
}
|
||||
}
|
||||
if (count($list) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn (array $entry): array => [
|
||||
...$entry,
|
||||
'native' => $this->languages->describe($entry['locale'])['native'],
|
||||
'url' => $origin.$entry['href'],
|
||||
'current' => $entry['locale'] === $current,
|
||||
], $list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine eigene Adresse eines Plugins: die Vorlage bekommt die Einstellungen des Plugins und die
|
||||
* Abfrageparameter, sonst nichts. Dieselben Schutzkopfzeilen wie jede Seite, kein Skript.
|
||||
@@ -94,7 +243,45 @@ final class PageController extends AbstractController
|
||||
|
||||
public function notFound(Request $request, int $status = 404): Response
|
||||
{
|
||||
return $this->renderPage($request, $this->notFoundPage->page($status), '', $status);
|
||||
$page = $this->notFoundPage->page($status);
|
||||
$locale = $this->translation['locale'] ?? null;
|
||||
// Die eigene 404-Seite ist eine Seite wie jede andere und wird im Editor übersetzt; die
|
||||
// mitgelieferte bekommt ihre Texte aus dem Wörterbuch der festen Texte (ADR 111).
|
||||
if (null !== $locale && in_array($locale, Translations::locales($page['document']), true)) {
|
||||
$page['document'] = Translations::apply($page['document'], $locale);
|
||||
} else {
|
||||
$page['document'] = Translations::strip($page['document']);
|
||||
if (null !== $locale && str_starts_with((string) $page['id'], 'error-')) {
|
||||
$page['document'] = $this->translateFixedTexts($page['document'], $locale);
|
||||
}
|
||||
}
|
||||
if (null !== $locale) {
|
||||
$page['locale'] = $locale;
|
||||
}
|
||||
|
||||
return $this->renderPage($request, $page, '', $status);
|
||||
}
|
||||
|
||||
/** Titel, Beschreibung und Texte eines mitgelieferten Dokuments über das Wörterbuch. */
|
||||
private function translateFixedTexts(array $document, string $locale): array
|
||||
{
|
||||
$t = static fn (string $text): string => \App\Twig\LanguageExtension::UI[$locale][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text;
|
||||
foreach (['title', 'description'] as $field) {
|
||||
$document[$field] = $t((string) ($document[$field] ?? ''));
|
||||
}
|
||||
$walk = function (array $blocks) use (&$walk, $t): array {
|
||||
foreach ($blocks as $index => $block) {
|
||||
if (is_string($block['props']['text'] ?? null)) {
|
||||
$blocks[$index]['props']['text'] = $t($block['props']['text']);
|
||||
}
|
||||
$blocks[$index]['children'] = $walk($block['children'] ?? []);
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
};
|
||||
$document['blocks'] = $walk($document['blocks'] ?? []);
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
/** Published pages and error content share theme, areas, block rendering and CSP. */
|
||||
@@ -102,10 +289,12 @@ final class PageController extends AbstractController
|
||||
{
|
||||
$styles = '';
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($status >= 400 ? null : $page);
|
||||
$areas = array_map($this->localise(...), $this->areas($status >= 400 ? null : $page));
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$sections = array_map($this->localise(...), $this->sections($page['document'], $areas));
|
||||
// Die festen Texte der Vorlagen folgen der Sprache der Seite (LanguageExtension).
|
||||
$request->attributes->set('kant_locale', $this->translation['locale'] ?? $this->languages->main());
|
||||
// Dieselbe Sektion zweimal auf der Seite: ab der zweiten Verknüpfung eine Kopie mit eigenen
|
||||
// Kennungen, in Renderreihenfolge Kopf, Inhalt, Fuß.
|
||||
$pristine = $sections;
|
||||
@@ -176,20 +365,25 @@ final class PageController extends AbstractController
|
||||
break;
|
||||
}
|
||||
}
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $this->settings->homePath());
|
||||
$adresse = $status >= 400 ? null : rtrim($this->siteOrigin, '/').$this->localHref($this->settings->publicPath($path, $this->settings->homePath()));
|
||||
$alternates = $this->alternates($page, $status);
|
||||
// Die Sprachwahl ist ein Baustein und kann im Kopf, im Fuß oder im Inhalt stehen; die
|
||||
// Vorlage holt die Liste von hier, statt sie durch jede Einbindung zu reichen.
|
||||
$request->attributes->set('kant_alternates', $alternates);
|
||||
$canonical = null === $adresse ? null : $adresse.(null !== $blaettern && $blaettern['page'] > 1 ? '?seite='.$blaettern['page'] : '');
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => $canonical,
|
||||
'alternates' => $alternates,
|
||||
'pagination' => null === $blaettern || null === $adresse ? null : ['page' => $blaettern['page'], 'pages' => $blaettern['pages'], 'base' => $adresse],
|
||||
'navigation' => $this->navigation($path),
|
||||
'postLists' => $postLists,
|
||||
// Ohne Beiträge gibt es keinen Feed, auf den der Kopf verweisen könnte.
|
||||
'hasPosts' => $this->posts->anyPublished(),
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null,
|
||||
'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id'], $this->translation['locale'] ?? null) : null,
|
||||
// Die Nachbarn kosten eine Abfrage; sie fällt nur an, wenn die Seite den
|
||||
// Baustein wirklich enthält.
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id']) : [],
|
||||
'neighbours' => 'post' === $page['type'] && $this->hasType($page['document'], 'postnav') ? $this->posts->neighbours($page['id'], language: $this->translation['locale'] ?? null) : [],
|
||||
'needsTabs' => $needsTabs,
|
||||
'needsFormScript' => $needsFormScript,
|
||||
'pluginStyles' => $pluginAssets['styles'],
|
||||
@@ -244,6 +438,9 @@ final class PageController extends AbstractController
|
||||
if ($status >= 400) {
|
||||
$response->headers->set('X-Robots-Tag', 'noindex, follow');
|
||||
}
|
||||
if (null !== $this->translation) {
|
||||
$response->setContent($this->localiseLinks((string) $response->getContent()));
|
||||
}
|
||||
|
||||
return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument);
|
||||
}
|
||||
@@ -317,6 +514,10 @@ final class PageController extends AbstractController
|
||||
if ('form' === ($block['type'] ?? null)) {
|
||||
$slug = $block['props']['form'] ?? '';
|
||||
$form = $this->forms->findBySlug($slug);
|
||||
// In einer weiteren Sprache mit übersetzten Beschriftungen (ADR 111).
|
||||
if (null !== $form && null !== $this->translation) {
|
||||
$form = FormRepository::localise($form, $this->translation['locale']);
|
||||
}
|
||||
$states[$block['id']] = [
|
||||
'form' => $form,
|
||||
// Die Bilder einer Bildauswahl, einmal fuer alle Optionen aller Felder geholt.
|
||||
@@ -420,9 +621,12 @@ final class PageController extends AbstractController
|
||||
$prefix = '';
|
||||
foreach (explode('/', (string) $page['path']) as $segment) {
|
||||
$prefix = '' === $prefix ? $segment : $prefix.'/'.$segment;
|
||||
$ancestor = $this->pages->findPublished($prefix);
|
||||
// In einer weiteren Sprache heißt jede Stufe so, wie sie dort heißt - sofern es sie
|
||||
// dort gibt. Sonst bleibt sie in der Hauptsprache stehen, mit ihrer Adresse.
|
||||
$translated = null === $this->translation || !isset($this->translation['map'][$prefix]) ? null : $this->pages->findPublishedTranslation($this->translation['map'][$prefix], $this->translation['locale']);
|
||||
$ancestor = $translated ?? $this->pages->findPublished($prefix);
|
||||
if (null !== $ancestor) {
|
||||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => '/'.$prefix];
|
||||
$trail[] = ['name' => $ancestor['document']['title'] ?? $prefix, 'href' => $this->localHref('/'.$prefix)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +709,7 @@ final class PageController extends AbstractController
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => mb_substr((string) ($page['document']['title'] ?? ''), 0, 110),
|
||||
'mainEntityOfPage' => $origin.'/'.$page['path'],
|
||||
'mainEntityOfPage' => $origin.$this->localHref('/'.$page['path']),
|
||||
];
|
||||
foreach ([['description', $page['excerpt'] ?? $page['document']['description'] ?? ''], ['datePublished', $page['published_at'] ?? '']] as [$key, $value]) {
|
||||
if ('' !== (string) $value && null !== $value) {
|
||||
@@ -618,23 +822,32 @@ final class PageController extends AbstractController
|
||||
#[Route('/schlagwort/{slug}', name: 'tag', requirements: ['slug' => '[a-z0-9-]{1,80}'], methods: ['GET'])]
|
||||
public function archive(Request $request, ?string $slug = null): Response
|
||||
{
|
||||
$kind = 'category' === $request->attributes->get('_route') ? 'category' : 'tag';
|
||||
$this->translation = null;
|
||||
|
||||
return $this->renderArchive($request, 'category' === $request->attributes->get('_route') ? 'category' : 'tag', $slug);
|
||||
}
|
||||
|
||||
private function renderArchive(Request $request, string $kind, ?string $slug): Response
|
||||
{
|
||||
$language = $this->translation['locale'] ?? null;
|
||||
$t = static fn (string $text): string => null === $language ? $text : (\App\Twig\LanguageExtension::UI[$language][$text] ?? \App\Twig\LanguageExtension::UI['en'][$text] ?? $text);
|
||||
$term = null;
|
||||
if (null !== $slug) {
|
||||
$term = $this->posts->findTerm($kind, $slug);
|
||||
if (null === $term) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$term['name'] = $this->posts->termName($term, $language);
|
||||
}
|
||||
$page = max(1, $request->query->getInt('seite', 1));
|
||||
$result = $this->posts->published($page, $term['id'] ?? null);
|
||||
$result = $this->posts->published($page, $term['id'] ?? null, language: $language);
|
||||
// Ein Archiv ohne Beitrag ist kein Inhalt, sondern eine Baustelle mit eigener Adresse -
|
||||
// egal ob die Website noch gar keinen Blog hat oder ob diese eine Kategorie leer ist.
|
||||
if (0 === $result['total']) {
|
||||
return $this->notFound($request);
|
||||
}
|
||||
$base = null === $term ? '/beitraege' : '/'.PostRepository::ARCHIVE_PREFIX[$kind].'/'.$term['slug'];
|
||||
$heading = null === $term ? 'Beiträge' : $term['name'];
|
||||
$heading = null === $term ? $t('Beiträge') : $term['name'];
|
||||
|
||||
// Das Archiv ist eine Seite wie jede andere: ein Dokument aus Bausteinen, das durch dieselbe
|
||||
// Aufbaukette läuft - Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, Schriften,
|
||||
@@ -642,15 +855,15 @@ final class PageController extends AbstractController
|
||||
// eigene Vorlage, die von alledem nur die Palette kannte.
|
||||
$kopf = [
|
||||
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? 'Beitrag' : 'Beiträge')]],
|
||||
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
|
||||
];
|
||||
if (null !== $term) {
|
||||
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? 'Kategorie' : 'Schlagwort'], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
|
||||
array_unshift($kopf, ['id' => 'archiv-art', 'type' => 'text', 'props' => ['text' => 'category' === $kind ? $t('Kategorie') : $t('Schlagwort')], 'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.08em', 'textTransform' => 'uppercase', 'margin' => ['bottom' => '8px']]]]);
|
||||
}
|
||||
$document = [
|
||||
'schemaVersion' => DocumentValidator::SCHEMA_VERSION,
|
||||
'title' => $heading,
|
||||
'description' => null === $term ? 'Alle Beiträge, die neuesten zuerst.' : sprintf('Beiträge %s „%s“.', 'category' === $kind ? 'in der Kategorie' : 'zum Schlagwort', $term['name']),
|
||||
'description' => null === $term ? $t('Alle Beiträge, die neuesten zuerst.') : sprintf($t('category' === $kind ? 'Beiträge in der Kategorie „%s“.' : 'Beiträge zum Schlagwort „%s“.'), $term['name']),
|
||||
'blocks' => [
|
||||
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
|
||||
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
|
||||
@@ -659,7 +872,7 @@ final class PageController extends AbstractController
|
||||
],
|
||||
];
|
||||
$seite = [
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => 'de',
|
||||
'id' => 'archiv-'.(null === $term ? 'beitraege' : $kind.'-'.$term['slug']), 'type' => 'archive', 'path' => ltrim($base, '/'), 'locale' => $language ?? $this->languages->main(),
|
||||
'website_name' => '', 'seo_title' => '', 'robots' => 'index', 'published_at' => null, 'author' => null, 'document' => $document,
|
||||
];
|
||||
|
||||
@@ -705,7 +918,7 @@ final class PageController extends AbstractController
|
||||
// immer die erste - sonst würden zwei Listen auf einer Seite einander
|
||||
// umblättern.
|
||||
$paginate = (bool) ($props['paginate'] ?? false);
|
||||
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count']);
|
||||
$result = $this->posts->published($paginate ? $page : 1, $term['id'] ?? null, $props['count'], language: $this->translation['locale'] ?? null);
|
||||
$lists[$block['id']] = $paginate ? $result : $result['posts'];
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
@@ -986,7 +1199,7 @@ final class PageController extends AbstractController
|
||||
{
|
||||
$links = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath, language: $this->translation['locale'] ?? null);
|
||||
}
|
||||
|
||||
return $links;
|
||||
|
||||
@@ -6,10 +6,12 @@ use App\Content\CustomCode;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FontLibrary;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
use App\Content\Theme;
|
||||
use App\Content\Translations;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Domain\StaleWrite;
|
||||
@@ -42,6 +44,7 @@ final class PartialController extends AbstractController
|
||||
private CsrfTokenManagerInterface $csrf,
|
||||
private PluginRegistry $plugins,
|
||||
private FormRepository $forms,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -125,8 +128,8 @@ final class PartialController extends AbstractController
|
||||
if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) {
|
||||
return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403);
|
||||
}
|
||||
if (strlen($request->getContent()) > 270000) {
|
||||
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413);
|
||||
if (strlen($request->getContent()) > 1100000) {
|
||||
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 413);
|
||||
}
|
||||
$partial = $this->partial($id);
|
||||
try {
|
||||
@@ -137,8 +140,9 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
if ('admin_partial_preview' === $request->attributes->get('_route')) {
|
||||
$this->validator->validate($document, false, true);
|
||||
$language = $body['language'] ?? null;
|
||||
|
||||
return $this->renderPreview($partial, $document);
|
||||
return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
|
||||
throw new \InvalidArgumentException();
|
||||
@@ -163,18 +167,25 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
|
||||
/** The area on its own, in the shell of a page, so the editor preview looks like the result. */
|
||||
private function renderPreview(array $partial, array $document): Response
|
||||
private function renderPreview(array $partial, array $document, Request $request, ?string $language = null): Response
|
||||
{
|
||||
// Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und
|
||||
// die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite.
|
||||
$localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language);
|
||||
$document = $localise($document);
|
||||
$locale = $language ?? $this->languages->main();
|
||||
$request->attributes->set('kant_locale', $locale);
|
||||
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
||||
$theme = $this->theme->load();
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], '');
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language);
|
||||
}
|
||||
$sections = [];
|
||||
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
||||
$section = $this->partials->resolveSection($slug);
|
||||
if (null !== $section) {
|
||||
$sections[$slug] = $section['document'];
|
||||
$sections[$slug] = $localise($section['document']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +195,13 @@ final class PartialController extends AbstractController
|
||||
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
|
||||
}
|
||||
$forms = [];
|
||||
$walk = function (array $blocks) use (&$walk, &$forms): void {
|
||||
$walk = function (array $blocks) use (&$walk, &$forms, $language): void {
|
||||
foreach ($blocks as $block) {
|
||||
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
||||
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
||||
if (null !== $form && null !== $language) {
|
||||
$form = FormRepository::localise($form, $language);
|
||||
}
|
||||
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
||||
if (null !== $form) {
|
||||
$styles .= $this->forms->css($form, $theme);
|
||||
@@ -202,7 +216,7 @@ final class PartialController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', [
|
||||
'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
||||
'canonical' => '',
|
||||
'editing' => true,
|
||||
'navigation' => $navigation,
|
||||
@@ -232,6 +246,12 @@ final class PartialController extends AbstractController
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
'plugins' => $this->plugins->forEditor(),
|
||||
'presets' => $this->plugins->presetsForEditor(),
|
||||
// Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel.
|
||||
'languages' => [
|
||||
'main' => $this->languages->describe($this->languages->main()),
|
||||
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
||||
'area' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\Languages;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use Doctrine\DBAL\Connection;
|
||||
@@ -22,6 +23,7 @@ final class SeoController extends AbstractController
|
||||
private PostRepository $posts,
|
||||
private string $siteOrigin,
|
||||
private string $adminPath,
|
||||
private Languages $languages,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,6 +46,24 @@ final class SeoController extends AbstractController
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
// Jede weitere Sprache mit ihren veröffentlichten Seiten (ADR 111), unter denselben
|
||||
// Bedingungen wie die Hauptsprache. Die Zuordnung der Sprachen zueinander steht als
|
||||
// hreflang in den Seiten selbst.
|
||||
$extra = $this->languages->extra();
|
||||
if ([] !== $extra) {
|
||||
foreach ($this->db->fetchAllAssociative(
|
||||
"SELECT t.locale, t.path AS translated, p.path, p.published_at, r.created_at FROM page_translation t JOIN page p ON p.id = t.page_id JOIN revision r ON r.id = p.published_revision_id AND r.page_id = p.id WHERE t.website_id = ? AND p.robots = 'index' AND p.deleted_at IS NULL AND p.id <> ? ORDER BY t.locale, t.path",
|
||||
['default', $this->settings->load()['notFoundPageId']],
|
||||
) as $row) {
|
||||
if (!in_array($row['locale'], $extra, true)) {
|
||||
continue;
|
||||
}
|
||||
$entries[] = [
|
||||
'loc' => $origin.'/'.$row['locale'].($row['path'] === $home ? '' : '/'.$row['translated']),
|
||||
'lastmod' => substr(max((string) ($row['published_at'] ?? ''), (string) ($row['created_at'] ?? '')), 0, 10),
|
||||
];
|
||||
}
|
||||
}
|
||||
// Archives exist only while they list something.
|
||||
if ([] !== $this->posts->published(1, null, 1)['posts']) {
|
||||
$entries[] = ['loc' => $origin.'/beitraege', 'lastmod' => null];
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\Consent;
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\Languages;
|
||||
use App\Content\NotFoundPage;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\SocialProfiles;
|
||||
@@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
|
||||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -67,9 +69,52 @@ final class SettingsController extends AbstractController
|
||||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||||
'weekdays' => SiteSettings::DAYS,
|
||||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||||
'mainLanguage' => $this->languages->describe($this->languages->main()),
|
||||
'extraLanguages' => $this->languages->extra(),
|
||||
'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(),
|
||||
'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts,
|
||||
* solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre
|
||||
* Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen.
|
||||
*/
|
||||
/** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */
|
||||
#[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])]
|
||||
public function saveLanguageTexts(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$all = $request->request->all();
|
||||
$this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []);
|
||||
$this->addFlash('success', 'Die Übersetzungen wurden gespeichert.');
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])]
|
||||
public function saveLanguages(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
try {
|
||||
$this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? []))));
|
||||
} catch (InvalidPage $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
$this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.');
|
||||
|
||||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||||
public function save(Request $request): Response
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user