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>
145 lines
11 KiB
Twig
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 %}
|