Alles aus dem Nachbau von kant-it.de, den Umut durchgesehen hat. Eine Beitragsliste kann Datum, Autor, Auszug und Kategorie ausblenden und sieht damit aus wie ein Projektraster statt wie ein Blogarchiv. Beinahe lautlos danebengegangen: |default(true) macht in Twig aus einem gespeicherten false ein true - die Einstellung haette sich speichern lassen und nichts getan. Der Test prueft beide Richtungen. Ein Bild darf verlinken, damit ein Logo auf die Startseite zeigt; die Adresse geht durch dieselbe Pruefung wie jeder andere Link. Und der Kopfbereich kann beim Scrollen oben bleiben - als Einstellung der Website, weil nur das umschliessende Element kleben kann. Der groesste Fund steckt im Editor: die Liste bekannter Schemafassungen endete bei 12, waehrend Bausteine v13 bis v15 verlangen. In einem aktuellen Dokument liess sich damit kein Baustein mehr einfuegen, und Symbol, Fortschritt, Slider und externer Inhalt in gar keinem. Jetzt eine Zahl an einer Stelle, mit einem Test ueber jeden Typ und jede Fassung. Geprueft: 389 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest, Browserpruefung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
17 KiB
Twig
213 lines
17 KiB
Twig
{% set type = block.type %}
|
|
{% set props = block.props|default({}) %}
|
|
{% set layout = block.layout|default({}) %}
|
|
{% set designClasses = '' %}
|
|
{% for device, settings in layout.design|default({}) %}
|
|
{% for key, value in settings %}{% set designClasses = designClasses ~ ' d-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}
|
|
{% endfor %}
|
|
{% for key, value in layout.effects|default({}) %}{% set designClasses = designClasses ~ ' fx-' ~ key ~ '-' ~ value %}{% endfor %}
|
|
{% if type in ['section', 'row', 'column'] %}
|
|
{% set tag = type == 'section' ? 'section' : 'div' %}
|
|
<{{ tag }} id="{{ block.id }}" class="block-{{ type }} tone-{{ layout.tone|default('paper') }} width-{{ layout.width|default('normal') }}{{ designClasses }}">
|
|
{% if type == 'section' %}<div class="shell section-inner">{% endif %}
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
{% if type == 'section' %}</div>{% endif %}
|
|
</{{ tag }}>
|
|
{% elseif type == 'heading' %}
|
|
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{{ props.text }}</h{{ props.level }}>
|
|
{% elseif type == 'text' %}
|
|
{% if richText|default(false) %}
|
|
{# Already filtered by RichText when it was stored, so printing it unescaped is safe.
|
|
A div, not a p: the allowlist includes lists and paragraphs, which may not nest in one. #}
|
|
<div id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text|raw }}</div>
|
|
{% else %}
|
|
<p id="{{ block.id }}" class="block-text{{ designClasses }}">{{ props.text }}</p>
|
|
{% endif %}
|
|
{% elseif type == 'button' %}
|
|
<a id="{{ block.id }}" class="button{{ designClasses }}" href="{{ props.href }}">{{ props.text }} <span aria-hidden="true">↗</span></a>
|
|
{% elseif type == 'image' %}
|
|
<figure id="{{ block.id }}" class="block-image{{ designClasses }}">{% set eager = eagerImage|default(null) == block.id %}{% if props.href|default('') %}<a class="image-link" href="{{ props.href }}">{% endif %}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager ? '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="(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px"{% endif %}>{% if props.href|default('') %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
|
{% elseif type == 'iconcard' %}
|
|
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
|
{% include 'blocks/icon.html.twig' with {name: props.icon} only %}
|
|
<h3>{% if props.href|default('') %}<a href="{{ props.href }}">{{ props.title }}</a>{% else %}{{ props.title }}{% endif %}</h3>
|
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
|
</div>
|
|
{% elseif type == 'stat' %}
|
|
<p id="{{ block.id }}" class="block-stat{{ designClasses }}"><strong>{{ props.value }}</strong><span>{{ props.label }}</span></p>
|
|
{% elseif type == 'quote' %}
|
|
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
|
|
{% if props.rating|default(0) > 0 %}
|
|
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p>
|
|
{% endif %}
|
|
<blockquote><p>{{ props.text }}</p></blockquote>
|
|
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
|
|
</figure>
|
|
{% elseif type == 'logos' %}
|
|
<ul id="{{ block.id }}" class="block-logos{{ designClasses }}">
|
|
{% for child in block.children|default([]) %}<li>{% 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), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %}
|
|
</ul>
|
|
{% elseif type == 'cta' %}
|
|
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
|
|
<div>
|
|
<h2>{{ props.title }}</h2>
|
|
{% if props.text|default('') %}<p>{{ props.text }}</p>{% endif %}
|
|
</div>
|
|
<a class="button" href="{{ props.href }}">{{ props.buttonText }}</a>
|
|
</div>
|
|
{% elseif type == 'contact' %}
|
|
<address id="{{ block.id }}" class="block-contact{{ designClasses }}">
|
|
{% if props.phone|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'phone'} only %}<a href="tel:{{ props.phone|replace({' ': '', '(': '', ')': '', '/': '', '-': ''}) }}">{{ props.phone }}</a></span>{% endif %}
|
|
{% if props.email|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'mail'} only %}<a href="mailto:{{ props.email }}">{{ props.email }}</a></span>{% endif %}
|
|
{% if props.address|default('') %}<span>{% include 'blocks/icon.html.twig' with {name: 'pin'} only %}{{ props.address }}</span>{% endif %}
|
|
</address>
|
|
{% elseif type == 'divider' %}
|
|
<hr id="{{ block.id }}" class="block-divider{{ designClasses }}">
|
|
{% elseif type == 'spacer' %}
|
|
<div id="{{ block.id }}" class="block-spacer spacer-{{ props.size }}{{ designClasses }}" aria-hidden="true"></div>
|
|
{% elseif type == 'menu' %}
|
|
{% set links = navigation[props.slot]|default([]) %}
|
|
{% if links is not empty %}
|
|
{# The layout is a class, and the burger variant is a details element: a menu that needs a
|
|
script to open would be a menu that does not open on a page without one. #}
|
|
<nav id="{{ block.id }}" class="block-menu menu-{{ props.layout|default('horizontal') }}{{ designClasses }}"
|
|
aria-label="{{ {header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches'}[props.slot]|default('Navigation') }}">
|
|
{% if props.layout|default('horizontal') == 'burger' %}
|
|
<details class="menu-burger">
|
|
<summary aria-label="Menü öffnen"><span class="burger-lines" aria-hidden="true"></span><span class="burger-text">Menü</span></summary>
|
|
<div class="menu-burger-body">{% include 'nav.html.twig' with {links: links} only %}</div>
|
|
</details>
|
|
{% else %}
|
|
{% include 'nav.html.twig' with {links: links} only %}
|
|
{% endif %}
|
|
</nav>
|
|
{% endif %}
|
|
{% elseif type == 'partialref' %}
|
|
{# A linked section: its blocks come from the partial, resolved by the controller. One change
|
|
there reaches every page that links it, which is the difference to a copied section. #}
|
|
{% set section = sections[props.partial]|default(null) %}
|
|
{% if section %}
|
|
<div id="{{ block.id }}" class="block-partialref{{ designClasses }}">
|
|
{% for child in section.blocks %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</div>
|
|
{% elseif editing|default(false) %}
|
|
<p id="{{ block.id }}" class="block-unavailable" role="note">Die verknüpfte Sektion „{{ props.partial }}“ gibt es nicht mehr. Der Verweis bleibt gespeichert.</p>
|
|
{% else %}
|
|
<!-- Verknüpfte Sektion {{ props.partial }} ist nicht vorhanden. -->
|
|
{% endif %}
|
|
{% elseif type == 'breadcrumb' %}
|
|
{% if breadcrumbs|default([])|length > 1 %}
|
|
<nav id="{{ block.id }}" class="block-breadcrumb{{ designClasses }}" aria-label="Brotkrumen">
|
|
<ol>
|
|
<li><a href="/">Start</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 %}
|
|
</ol>
|
|
</nav>
|
|
{% endif %}
|
|
{% elseif type == 'form' %}
|
|
<div id="{{ block.id }}" class="block-formwrap{{ designClasses }}">
|
|
{% include 'blocks/form.html.twig' with (forms[block.id]|default({form: null, sent: false, errors: {}, values: {}, blockId: block.id, page: {path: ''}, issued: 0, issuedSignature: ''})) only %}
|
|
</div>
|
|
{% elseif type == 'tabs' %}
|
|
{# Without the script this is an anchor list followed by every panel: complete and readable.
|
|
tabs.js turns it into a tab widget; it only changes presentation, never content. #}
|
|
<div id="{{ block.id }}" class="block-tabs{{ designClasses }}" data-tabs>
|
|
<ul class="tabs-nav">
|
|
{% for child in block.children|default([]) %}<li><a href="#{{ child.id }}">{{ child.props.title }}</a></li>{% endfor %}
|
|
</ul>
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</div>
|
|
{% elseif type == 'tabitem' %}
|
|
<section id="{{ block.id }}" class="block-tabitem{{ designClasses }}">
|
|
<h3 class="tabs-heading">{{ props.title }}</h3>
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</section>
|
|
{% elseif type == 'accordion' %}
|
|
<div id="{{ block.id }}" class="block-accordion{{ designClasses }}">
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</div>
|
|
{% elseif type == 'accordionitem' %}
|
|
<details id="{{ block.id }}" class="block-accordionitem{{ designClasses }}"{% if props.open|default(false) %} open{% endif %}>
|
|
<summary>{{ props.title }}</summary>
|
|
<div class="accordion-body">
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% elseif type == 'icon' %}
|
|
{# A single icon. Decorative ones are hidden from assistive technology; one that means
|
|
something carries its label as text for screen readers. #}
|
|
{% set iconBody %}{% include 'blocks/icon.html.twig' with {name: props.icon} only %}{% if not props.decorative %}<span class="visually-hidden">{{ props.label }}</span>{% endif %}{% endset %}
|
|
{% if props.href|default('') %}
|
|
<a id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}" href="{{ props.href }}"{{ props.decorative ? ' aria-hidden="true" tabindex="-1"' : '' }}>{{ iconBody }}</a>
|
|
{% else %}
|
|
<span id="{{ block.id }}" class="block-icon icon-size-{{ props.size }}{{ designClasses }}"{{ props.decorative ? ' aria-hidden="true"' : '' }}>{{ iconBody }}</span>
|
|
{% endif %}
|
|
{% elseif type == 'progress' %}
|
|
{# A progress bar as a real meter: the number is in the markup, not only in the width of a
|
|
coloured div, so it is readable without seeing it. #}
|
|
<div id="{{ block.id }}" class="block-progress{{ designClasses }}">
|
|
<p class="progress-label">{{ props.label }}{% if props.showValue %} <span class="progress-value">{{ props.value }} %</span>{% endif %}</p>
|
|
<div class="progress-track" role="meter" aria-valuenow="{{ props.value }}" aria-valuemin="0" aria-valuemax="100" aria-label="{{ props.label }}">
|
|
<div class="progress-fill progress-fill-{{ props.value }}"></div>
|
|
</div>
|
|
</div>
|
|
{% elseif type == 'slider' %}
|
|
{# A slider without a script: a scroll container with scroll snapping. It works with a finger,
|
|
a trackpad, a mouse wheel and the keyboard, and it degrades to a scrollable strip on a
|
|
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' : '' }}"
|
|
tabindex="0" role="group" aria-label="Zum Blättern seitwärts scrollen">
|
|
{% for child in block.children|default([]) %}
|
|
<div class="slider-slide">{% 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), eagerImage: eagerImage|default(null)} only %}</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% elseif type == 'embed' %}
|
|
{# Content from a third party. Until the group is granted, nothing of it is requested - no
|
|
iframe, no image, no preconnect. That is the whole point of the consent question: the
|
|
placeholder is not a nicety, it is what keeps the promise.
|
|
In the editor it always stays a placeholder: the administration's policy forbids foreign
|
|
frames, and a preview must not load anything from outside either. #}
|
|
{% if not editing|default(false) and consentGate.granted(app.request, props.group) %}
|
|
<div id="{{ block.id }}" class="block-embed{{ designClasses }}">
|
|
<iframe src="{{ props.url }}" title="{{ props.title }}" height="{{ props.height }}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
|
</div>
|
|
{% else %}
|
|
<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>
|
|
{% if editing|default(false) %}
|
|
<p class="embed-hint">Wird erst auf der veröffentlichten Seite geladen, und dort erst nach Einwilligung.</p>
|
|
{% else %}
|
|
<form method="post" action="{{ path('consent_save') }}">
|
|
<input type="hidden" name="back" value="{{ app.request.pathInfo }}">
|
|
<input type="hidden" name="choice" value="some">
|
|
{# 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>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% elseif type == 'postlist' %}
|
|
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
|
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([]), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
|
</div>
|
|
{% elseif type == 'gallery' %}
|
|
<div id="{{ block.id }}" class="block-gallery{{ designClasses }}" role="group" aria-label="Bildergalerie">
|
|
{% for child in block.children|default([]) %}{% 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), eagerImage: eagerImage|default(null)} only %}{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
{#
|
|
A block whose plugin is not available. Its stored content stays in the document; the core
|
|
renders nothing from its unvalidated props. Visitors see no broken fragment, while the
|
|
editor preview shows a placeholder so the block can still be found and moved.
|
|
#}
|
|
<!-- Block {{ block.id }} ({{ type }}) wird von dieser Installation nicht dargestellt. -->
|
|
{% if editing|default(false) %}<p id="{{ block.id }}" class="block-unavailable" role="note">Baustein „{{ type }}“ ist in dieser Installation nicht verfügbar. Der Inhalt bleibt gespeichert.</p>{% endif %}
|
|
{% endif %}
|