Files
KantSites/templates/blocks/block.html.twig
T
kantarciandClaude Opus 5 790f42213c Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12)
Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv,
unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und
tiefgestellt, Absätze, Listen und Links.

Die Entscheidung, was erlaubt ist, fällt auf dem Server beim
Speichern - nicht im Editor und nicht beim Rendern. Was in der
Datenbank landet, ist damit schon sicher, also kann kein Renderer das
Filtern vergessen, und eine manipulierte Anfrage speichert nichts,
was der Editor nicht anbietet.

Nicht erlaubte Elemente werden ausgepackt, nicht gelöscht: der Text
bleibt, nur die Auszeichnung geht. script, style, iframe und
Verwandte verschwinden samt Inhalt, weil Auspacken ihren Code als
Text sichtbar machen würde. Attribute fallen weg außer href an a,
und auch das nur nach Adressprüfung.

Ausgegeben wird formatierter Text nur ab v12, dann in einem div statt
einem p, weil die Positivliste Absätze und Listen enthält. Ältere
Dokumente behalten Klartextsemantik und werden weiter maskiert.

Eine Inkonsistenz nebenbei behoben: ein Text mit einem < behielt
echte Zeilenumbrüche, während alles andere <br> benutzte.

Geprüft: 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22,
darunter dreizehn Fälle durch den Filter mit acht Angriffen, dazu
Editortest mit axe und öffentliche Browserprüfung. Die Werkzeugleiste
selbst ist nicht bedient und nicht mit einem Screenreader geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:54:35 +02:00

145 lines
11 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)} 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 }}"><img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="lazy" 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.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)} 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 %}
<nav id="{{ block.id }}" class="block-menu{{ designClasses }}" aria-label="{{ props.slot == 'footer' ? 'Fußzeile' : 'Hauptnavigation' }}">
{% include 'nav.html.twig' with {links: links} only %}
</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)} 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)} 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)} 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)} 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)} only %}{% endfor %}
</div>
</details>
{% elseif type == 'postlist' %}
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
{% include 'blocks/post-list.html.twig' with {posts: postLists[block.id]|default([])} 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)} 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 %}