Files
KantSites/templates/page.html.twig
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

130 lines
12 KiB
Twig
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
{# Ohne Klasse spielt die Website Bewegung immer ab; mit ihr gilt die Browservorgabe. #}
<html lang="{{ page.locale }}"{{ page.locale in ['ar'] ? ' dir="rtl"' : '' }}{{ siteSettings.load().motion == 'respect' ? ' class="motion-respect"' : '' }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{# Der Titel fuer die Suche, sonst der Titel der Seite. #}
{% set seoTitle = page.seo_title|default('') ?: page.document.title %}
<title>{{ seoTitle }}</title>
<meta name="description" content="{{ page.document.description }}">
{% if canonical|default(null) %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# Dieselbe Seite in den anderen Sprachen (ADR 111). Nur, wenn es sie gibt; die Hauptsprache
ist zugleich die Vorgabe für alle, deren Sprache nicht dabei ist. #}
{% for alternate in alternates|default([]) %}<link rel="alternate" hreflang="{{ alternate.locale }}" href="{{ alternate.url }}">
{% endfor %}{% if alternates|default([]) %}<link rel="alternate" hreflang="x-default" href="{{ (alternates|first).url }}">
{% endif %}
{% if pagination|default(null) %}{% if pagination.page > 1 %}<link rel="prev" href="{{ pagination.base }}{{ pagination.page > 2 ? '?seite=' ~ (pagination.page - 1) : '' }}">{% endif %}{% if pagination.page < pagination.pages %}<link rel="next" href="{{ pagination.base }}?seite={{ pagination.page + 1 }}">{% endif %}{% endif %}
{% if page.robots|default('index') != 'index' %}<meta name="robots" content="noindex, follow">{% endif %}
<meta property="og:title" content="{{ seoTitle }}">
<meta property="og:description" content="{{ page.document.description }}">
<meta property="og:type" content="{{ page.type|default('page') == 'post' ? 'article' : 'website' }}">
{% if canonical|default(null) %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{# In der Vorschau des Editors gibt es den Websitenamen nicht - dort fehlt das Feld einfach. #}
{% if page.website_name|default('') %}<meta property="og:site_name" content="{{ page.website_name }}">{% endif %}
<meta property="og:locale" content="{{ kant_og_locale(page.locale) }}">
{% for alternate in alternates|default([]) %}{% if not alternate.current %}<meta property="og:locale:alternate" content="{{ kant_og_locale(alternate.locale) }}">
{% endif %}{% endfor %}
{% if socialImage|default(null) %}
<meta property="og:image" content="{{ socialImage.url }}">
{% if socialImage.alt %}<meta property="og:image:alt" content="{{ socialImage.alt }}">{% endif %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
{# Ohne Bild ist die große Karte eine leere Fläche; die kleine zeigt Titel und Text. #}
<meta name="twitter:card" content="summary">
{% endif %}
{% include 'favicon.html.twig' %}
{# Ein Bündel statt vier Dateien: jede einzelne hält das Zeichnen der Seite auf. Fehlt es
(kein Schreibrecht im Ordner), werden die Quellen einzeln geladen - langsamer, aber heil. #}
{# Das grosse Bild ueber der Falz, wenn es der Hintergrund des ersten Abschnitts ist: Es
steht sonst nur im Stylesheet und wird erst gefunden, wenn das geparst ist. Genau eine der
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
{% endfor %}
{# Und derselbe Dienst fuer das gewoehnliche Bild ueber der Falz: ohne diese Zeile faengt der
Browser es erst an zu holen, wenn er das Stylesheet geparst hat. #}
{% if heroImage|default(null) %}<link rel="preload" as="image" href="{{ heroImage.src }}"{% if heroImage.srcset %} imagesrcset="{{ heroImage.srcset }}" imagesizes="{{ heroImage.sizes }}"{% endif %} fetchpriority="high">
{% endif %}
{% if localFonts|default([]) %}
{# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst,
wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der
Ersatzschrift auf die richtige. Schriften kommen ausschliesslich vom eigenen Server;
ein Stylesheet von fonts.googleapis.com wird nie verlinkt. #}
{% for font in localFonts %}<link rel="preload" href="{{ font }}" as="font" type="font/woff2" crossorigin>{% endfor %}
{% endif %}
{% if styleBundle|default(null) %}
<link rel="stylesheet" href="{{ styleBundle }}">
{% else %}
<link rel="stylesheet" href="/assets/site.css?v={{ kant_version() }}">
<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}">
{% if localFonts|default([]) %}<link rel="stylesheet" href="/assets/fonts/fonts.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 2 %}<link rel="stylesheet" href="/assets/design.css?v={{ kant_version() }}">{% endif %}
{% if page.document.schemaVersion >= 3 %}<link rel="stylesheet" href="/assets/enhancements.css?v={{ kant_version() }}">{% endif %}
{% endif %}
{% for href in pluginStyles|default([]) %}<link rel="stylesheet" href="{{ href }}">{% endfor %}
{% if hasPosts|default(false) %} <link rel="alternate" type="application/rss+xml" title="Beiträge" href="/feed.xml">
{% endif %}
{% if needsTabs|default(false) %}<script src="/assets/tabs.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsFormScript|default(false) and not editing|default(false) %}<script src="/assets/form-steps.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if needsSlider|default(false) %}<script type="module" src="/assets/slider.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsMotion|default(false) %}<script type="module" src="/assets/motion.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsScrolled|default(false) %}<script type="module" src="/assets/scrolled.js?v={{ kant_version() }}"></script>{% endif %}
{% if needsZoom|default(false) or needsDocument|default(false) %}<script src="/assets/lightbox.js?v={{ kant_version() }}" defer></script>{% endif %}
{% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
{# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must
show the layout, not run code that could change the page under the editor's hands. #}
{% if pageScript|default(null) and not editing|default(false) %}<script src="{{ pageScript }}" defer></script>{% endif %}
{% if not editing|default(false) %}{% for src in areaScripts|default([]) %}<script src="{{ src }}" defer></script>{% endfor %}{% endif %}
{# A data block, not a script: CSP script-src does not apply to application/ld+json. #}
{% for block in structuredData|default([]) %}<script type="application/ld+json">{{ block|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_HEX_TAG') b-or constant('JSON_HEX_AMP'))|raw }}</script>
{% endfor %}
{% if pageStyles|default('') %}
{# In the editor the preview inherits the administration's policy, which forbids inline
styles. The stylesheet therefore travels as inert template text and the editor applies
it as a constructed stylesheet – same result, no policy violation and a clean console. #}
{% if editing|default(false) %}<template id="kant-styles">{{ pageStyles|raw }}</template>
{% else %}<style>{{ pageStyles|raw }}</style>{% endif %}
{% endif %}
</head>
{% set consentOpen = not editing|default(false) and consentGate.needsBanner(app.request) %}
<body class="kant{{ consentOpen ? ' consent-open' : '' }}">
<span id="seitenanfang" tabindex="-1"></span>
<a class="skip-link" href="#main">{{ 'Zum Inhalt springen'|kant_t }}</a>
{# Header and footer are block documents like any page, so they are built from elements
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}{{ siteSettings.load().headerOverlay ? ' is-overlay' : '' }}{{ kant_header_surface(areas.header) ? ' has-own-surface' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null), inHeader: true} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
{% if postMeta is defined and postMeta is not null and siteSettings.load().postMetaHeader %}
<p class="post-header shell">
{% if page.published_at %}<time datetime="{{ page.published_at|date('Y-m-d') }}">{{ page.published_at|date('d.m.Y') }}</time>{% endif %}
{% if page.author %}<span>{{ 'von'|kant_t }} {{ page.author }}</span>{% endif %}
{% for term in postMeta.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}
{% for term in postMeta.tag %}<a class="post-tag" href="{{ term.href }}">#{{ term.name }}</a>{% endfor %}
</p>
{% endif %}
{% for block in page.document.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: page.document.schemaVersion|default(1) >= 12, richHeadings: page.document.schemaVersion|default(1) >= 17, neighbours: neighbours|default({}), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: postLists|default({}), forms: forms|default({}), breadcrumbs: breadcrumbs|default([]), navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
</footer>
{% endif %}
{% if not editing|default(false) and consentGate.needsBanner(app.request) %}
{% include 'consent-banner.html.twig' with {consent: consentGate.state(app.request), back: app.request.pathInfo} only %}
{% endif %}
{# Der Knopf steht hinter der Einwilligungsleiste: Solange sie offen ist, setzt er sich per
Ankerpositionierung auf ihre Oberkante, und ein Anker muss im Dokument davor stehen. #}
{% set oben = siteSettings.load() %}
{% if oben.scrollTop|default(false) and not editing|default(false) %}
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
{% endif %}
</body>
</html>