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

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

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

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

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

No files matched your search

+25 -13
View File
@@ -58,7 +58,7 @@
{%- if klick == 'vergroessern' -%}
{# Ohne Skript öffnet dieser Verweis schlicht das große Bild. Mit Skript fängt der
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild') }} vergrößern">
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild'|kant_t) }} {{ 'vergrößern'|kant_t }}">
{%- elseif imageLinked -%}
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
@@ -112,7 +112,7 @@
{% set menuLayout = props.layout|default('horizontal') %}
{% set collapse = menuLayout == 'horizontal' ? props.collapse|default('tablet') : 'never' %}
<nav id="{{ block.id }}" class="block-menu menu-{{ menuLayout }} menu-collapse-{{ collapse }}{{ designClasses }}"
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation')|kant_t }}">
{% if menuLayout == 'burger' or collapse != 'never' %}
{# Buttons, die auf dem Telefon neben dem Menü-Knopf stehen bleiben (ADR 110). Sichtbar nur,
solange das Menü zusammengeklappt ist; im aufgeklappten Menü fehlen sie dann. #}
@@ -121,7 +121,7 @@
<details class="menu-burger">
{# Nur Symbol behält den Namen für Vorlesesoftware; sichtbar ist dann nur der Strich. #}
{% set burger = props.burgerLabel|default('both') %}
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">Menü</span></summary>
<summary>{% if burger != 'text' %}<span class="burger-lines" aria-hidden="true"></span>{% endif %}<span class="burger-text{{ burger == 'icon' ? ' visually-hidden' : '' }}">{{ 'Menü'|kant_t }}</span></summary>
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
</details>
{% endif %}
@@ -147,9 +147,9 @@
{% endif %}
{% elseif type == 'breadcrumb' %}
{% if breadcrumbs|default([])|length > 1 %}
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="{{ 'Brotkrumen'|kant_t }}">
<ol>
<li><a href="/">Start</a></li>
<li><a href="/">{{ 'Start'|kant_t }}</a></li>
{% for crumb in breadcrumbs %}
<li>{% if loop.last %}<span aria-current="page">{{ crumb.name }}</span>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %}</li>
{% endfor %}
@@ -208,7 +208,7 @@
{% if profiles[key]|default('') and (chosen is empty or key in chosen) %}{% set shown = shown|merge([{key: key, name: name, url: profiles[key]}]) %}{% endif %}
{% endfor %}
{% if shown is not empty %}
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="Soziale Profile">
<ul id="{{ block.id }}" class="block-social social-{{ props.layout|default('horizontal') }} social-shape-{{ props.shape|default('plain') }} social-size-{{ props.size|default(24) }}{{ props.labels|default(false) ? ' social-labels' : '' }}{{ designClasses }}" aria-label="{{ 'Soziale Profile'|kant_t }}">
{% for profile in shown %}
<li><a href="{{ profile.url }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab is defined ? props.newTab : true} only %}>{% include 'blocks/social-icon.html.twig' with {network: profile.key} only %}<span{{ props.labels|default(false) ? '' : ' class="visually-hidden"' }}>{{ profile.name }}</span></a></li>
{% endfor %}
@@ -233,7 +233,7 @@
browser that ignores snapping. No autoplay - a moving carousel is something people ask to
stop, not something a page should start on its own. #}
<div id="{{ block.id }}" class="block-slider{{ designClasses }} slide-desktop-{{ props.desktopVisible }} slide-tablet-{{ props.tabletVisible }} slide-mobile-{{ props.mobileVisible }}{{ props.snap ? ' slider-snap' : '' }}{{ props.dots ?? false ? ' slider-dots' : '' }}"{% if props.autoplay|default(0) >= 3 %} data-autoplay="{{ props.autoplay }}"{% endif %}
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
tabindex="0" role="group" aria-label="{{ 'Zum Blättern seitwärts scrollen'|kant_t }}">
{% for child in block.children|default([]) %}
<div class="slider-slide visibility-{{ child.layout.visibility|default('all') }}">{% include 'blocks/block.html.twig' with {block: child, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}</div>
{% endfor %}
@@ -255,7 +255,7 @@
<div id="{{ block.id }}" class="block-embed embed-blocked{{ designClasses }}">
<p class="embed-title">{{ props.title }}</p>
<p class="embed-hint">{{ props.hint ?: 'Dieser Inhalt wird von einem anderen Anbieter geladen. Dabei erfährt er deine IP-Adresse und welche Seite du ansiehst.' }}</p>
<p class="embed-provider">Anbieter: <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
<p class="embed-provider">{{ 'Anbieter:'|kant_t }} <strong>{{ props.provider ?: props.url|split('/')[2] }}</strong></p>
{% if editing|default(false) %}
<p class="embed-hint">Wird erst auf der veröffentlichten Seite geladen, und dort erst nach Einwilligung.</p>
{% else %}
@@ -265,7 +265,7 @@
{# The groups already granted stay granted; this button adds this one. #}
{% for granted in consentGate.state(app.request).groups %}{% if granted.granted %}<input type="hidden" name="groups[]" value="{{ granted.key }}">{% endif %}{% endfor %}
<input type="hidden" name="groups[]" value="{{ props.group }}">
<button class="consent-button" type="submit">Inhalt laden und einwilligen</button>
<button class="consent-button" type="submit">{{ 'Inhalt laden und einwilligen'|kant_t }}</button>
</form>
{% endif %}
</div>
@@ -294,12 +294,24 @@
erbt statt als heller Streifen unter einem dunklen Fuß zu kleben. #}
{% set rechtliches = consentGate.legalLinks() %}
{% set widerruflich = not editing|default(false) and consentGate.askedGroups is not empty and not consentGate.needsBanner(app.request) %}
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches') }}">
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label }}</a>{% endfor %}
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">Einwilligung ändern</a>{% endif %}
<nav id="{{ block.id }}" class="block-legal{{ designClasses }}" aria-label="{{ props.label|default('Rechtliches'|kant_t) }}">
{% for path, label in rechtliches %}{% if not loop.first %}<span aria-hidden="true"> · </span>{% endif %}<a href="/{{ path }}">{{ label|kant_t }}</a>{% endfor %}
{% if widerruflich %}{% if rechtliches is not empty %}<span aria-hidden="true"> · </span>{% endif %}<a href="/einwilligung">{{ 'Einwilligung ändern'|kant_t }}</a>{% endif %}
</nav>
{% elseif type == 'language' %}
{# Die Sprachwahl (ADR 111): dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist.
Gibt es die Seite nur in einer Sprache, steht hier nichts - eine Wahl mit einer Möglichkeit
ist keine. hreflang hält die Links aus der Umstellung auf übersetzte Adressen heraus. #}
{% set sprachen = app.request.attributes.get('kant_alternates')|default([]) %}
{% if editing|default(false) and sprachen|length < 2 %}{% set sprachen = [{locale: 'de', native: 'Deutsch', href: '#', current: true}, {locale: 'en', native: 'English', href: '#', current: false}] %}{% endif %}
{% if sprachen|length > 1 %}
<nav id="{{ block.id }}" class="block-language{{ designClasses }}" aria-label="{{ 'Sprache'|kant_t }}">
<ul>{% for sprache in sprachen %}<li><a href="{{ sprache.href }}" hreflang="{{ sprache.locale }}" lang="{{ sprache.locale }}"{% if sprache.current %} aria-current="page"{% endif %}>{% if props.display|default('code') == 'name' %}{{ sprache.native }}{% else %}{{ sprache.locale|upper }}<span class="visually-hidden"> {{ sprache.native }}</span>{% endif %}</a></li>{% endfor %}</ul>
</nav>
{% elseif editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable{{ designClasses }}" role="note">Sprachwahl</p>
{% endif %}
{% elseif type == 'gallery' %}
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="{{ 'Bildergalerie'|kant_t }}"{% if props.lightbox|default(false) %} data-zoom-group{% endif %}>
{% for child in block.children|default([]) %}{% include 'blocks/block.html.twig' with {block: child, galleryZoom: props.lightbox|default(false), editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: richText|default(false), richHeadings: richHeadings|default(false), neighbours: neighbours|default({}), eagerImage: eagerImage|default(null), inHeader: inHeader|default(false)} only %}{% endfor %}
</div>
{% elseif kant_plugin_block(type) %}