Slider mit Punkten, Karten als Karten, hervorgehobenes Wort
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Umut hat den Nachbau Detail fuer Detail mit kant-it.de verglichen. Vier
Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.

Der Slider zeigte eine Rollleiste statt Punkte. Punkte zeichnet jetzt der
Browser selbst - scroll-marker-group und ::scroll-marker, ohne Skript, mit
erkennbarem aktivem Punkt. Wo ein Browser das noch nicht kennt, bleibt das
wischbare Schiebefeld. Pfeile gibt es bewusst nicht: Chrome legt
::scroll-button an den Seitenrand statt in den Slider. Und er rotiert
nicht von selbst, weil eine oeffentliche Seite kein JavaScript laedt.

Eine Beitragskarte war keine Karte, sondern eine lose Spalte. Jetzt weiss,
18px Ecken, Bild buendig oben im Verhaeltnis 16:10, Text darunter.

Die Theme-Schriftskala ueberschrieb die Groessen der Bausteine: jede
Kartenueberschrift war so gross wie eine Abschnittsueberschrift.

Eine Ueberschrift darf ab v17 ein Wort hervorheben. Dabei waere beinahe
eine Luecke entstanden: die Dokumentfassung war in der Schliessung des
Filters nicht gebunden, der Filter lief nie, die Ausgabe war schon
unescaped. Ein Test mit <script> in einer Ueberschrift hat es gezeigt.

Geprueft: 390 PHP-Tests unter 8.2 und 8.4, 18 JS-Tests, Editortest,
Browserpruefung, axe ohne Verstoss auf dem Nachbau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 00:23:02 +02:00
1 parent 70283db20a
commit e21dff91fe
12 files changed
+104 -27

No files matched your search

+16
View File
@@ -440,6 +440,22 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
## ADR 044 – Genauer hinsehen: Punkte statt Rollleiste, Karten statt Zeilen (13.09.2026)
Umut hat den Nachbau Detail für Detail mit dem Original verglichen, und er hatte in jedem Punkt recht. Was dabei herauskam, sind nicht Geschmacksfragen, sondern vier Stellen, an denen das CMS etwas nicht konnte oder etwas Falsches tat.
**Ein Slider ist kein Schiebefeld.** Der Slider war ein Scrollbereich mit sichtbarer Rollleiste – im Hero sah das aus wie ein Versehen. Das Original hat Punkte. Punkte gibt es jetzt, und zwar **ohne Skript**: `scroll-marker-group` und `::scroll-marker` lassen den Browser je Folie einen Punkt zeichnen, der aktive ist über `:target-current` erkennbar, und ein Klick scrollt. Wo ein Browser das noch nicht kennt (heute Firefox und Safari), bleibt ein wisch- und tastaturbedienbares Schiebefeld – also kein Ausfall, nur weniger Komfort. Die Rollleiste ist dort ausgeblendet, wo Punkte die Orientierung übernehmen.
**Pfeile gibt es bewusst nicht.** `::scroll-button()` existiert, aber Chrome legt die Knöpfe weder absolut noch klebend in den Slider, sondern an den Rand der Seite. Lieber keine Pfeile als ein Knopf, der am Seitenrand schwebt.
**Was der Slider nicht kann, steht hier:** er rotiert nicht von selbst. Eine öffentliche Seite lädt kein JavaScript, und eine CSS-Animation, die sich nicht anhalten lässt, wäre ein Barrierefreiheitsproblem statt einer Lösung.
**Eine Beitragskarte war keine Karte.** Sie war eine Spalte aus Bild, Überschrift und Text ohne Rahmen. Im Original ist sie eine Karte: weiß, 18 px Ecken, Bild bündig oben im Verhältnis 16:10, Text in einem eigenen Feld darunter. Genau so sieht sie jetzt aus – und zwar für jede Website, weil es die Darstellung des Bausteins ist und keine Einstellung dieser einen Seite.
**Die Schriftgrößen der Bausteine verloren gegen die Theme-Skala.** `.kant h3` aus dem Theme und `.block-iconcard h3` aus `site.css` hatten dieselbe Spezifität, und der Themeblock steht später im Dokument – also gewann er. Jede Kartenüberschrift war so groß wie eine Abschnittsüberschrift, auf jeder Website mit eigener Schriftskala. Die Bausteinregeln tragen jetzt `.kant` davor und gewinnen wieder.
**Eine Überschrift darf ein Wort hervorheben.** Im Original ist die zweite Zeile der Hauptüberschrift rot. Dafür dürfen Überschriften ab v17 Auszeichnungen tragen – durch denselben Filter wie jeder Text. **Dabei wäre beinahe eine Lücke entstanden:** die Fassung des Dokuments war in der Schliessung, die den Filter läuft, gar nicht vorhanden; der Filter lief nie, und der Inhalt wird unescaped ausgegeben. Der Test, der ein `<script>` in eine Überschrift schreibt, hat es sofort gezeigt – und er bleibt genau dafür stehen. Zusätzlich entscheidet die Ausgabe jetzt an einer eigenen Fahne (`richHeadings`), damit eine ältere Seite ihre Überschrift weiterhin wörtlich zeigt.
## ADR 043 – Was beim Nachbauen noch fehlte (Dokumentschema v17, 13.09.2026)
Umut hat den Nachbau angesehen und vier Dinge benannt: der Abstand zwischen den Lösungskarten und der Projektliste fehlt, die Karten sind zu hoch, die Beiträge sehen nicht aus wie im Original, und später: im Hero fehlen die letzten Projekte als Slider, der Kopfbereich klebt nicht beim Scrollen, das Logo verlinkt nicht auf die Startseite. Zwei davon waren mein Aufbau, vier waren fehlende Fähigkeiten.
+7
View File
@@ -1,5 +1,12 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Detailabgleich mit dem Original, 13.09.2026
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite und Handybreite des Nachbaus.
- **Der ernsteste Fund ist eine beinahe entstandene Lücke:** beim Freigeben von Auszeichnungen in Überschriften war die Dokumentfassung in der Schliessung des Filters nicht gebunden – der Filter lief nie, während die Ausgabe schon unescaped war. Ein Test, der `<script>alert(1)</script>` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht.
- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68).
- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht.
## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026
- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung.
+1
View File
@@ -27,6 +27,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen.
- **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt.
- **Installer (Arbeitspaket N, ADR 041):** `/install` führt in sechs Schritten von einer leeren Installation zu einer Website – Systemprüfung, Datenbank (SQLite oder MySQL, Verbindung sofort geprüft), Migration im Prozess ohne Konsole, Website mit Startseite, Kopf-/Fußbereich, Menü sowie Impressum und Datenschutzgerüst, erstes Administratorkonto, Abschlussliste. Solange keine Installation existiert, führt jede Adresse dorthin; sobald ein Konto existiert, antwortet `/install` mit 404. Davor liegt `scripts/install.php`: eine einzelne Datei, die das Releasearchiv mit fester Adresse und fester SHA-256 lädt, gegen ZIP-Slip gesichert entpackt und sich danach selbst löscht.
+1 -1
View File
@@ -52,7 +52,7 @@ export function createBlock(type) {
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true };
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
}
+1
View File
@@ -260,6 +260,7 @@ function renderProperties() {
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
}
if (block.type === 'slider') {
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
}
+10 -8
View File
@@ -21,10 +21,11 @@ p{hyphens:auto}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* Posts */
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
.post-card{display:flex;flex-direction:column;gap:14px}
.post-image img{width:100%;height:210px;object-fit:cover;border-radius:10px;display:block}
.post-card h3{margin:0;font-size:19px;line-height:1.35}
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
.post-image{display:block}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
.post-card h3 a{text-decoration:none}
.post-card h3 a:hover{text-decoration:underline}
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
@@ -38,7 +39,7 @@ p{hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard h3 a{text-decoration:none}
.block-iconcard h3 a:hover{text-decoration:underline}
.block-iconcard p{margin:0;line-height:1.65}
@@ -54,7 +55,7 @@ p{hyphens:auto}
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
.block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
.block-cta p{margin:0;line-height:1.6}
.block-contact{display:grid;gap:14px;font-style:normal}
.block-contact span{display:flex;align-items:center;gap:12px;line-height:1.5}
@@ -113,7 +114,7 @@ p{hyphens:auto}
stays readable while the decision is open, because a wall would make "accept" the only way
forward. Accepting and refusing are the same button in the same row. */
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
.consent-bar h2{font-size:16px;margin:0 0 8px}
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
.consent-bar p{margin:0 0 14px}
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
@@ -193,7 +194,8 @@ body.consent-open{padding-bottom:260px}
.block-slider.slider-snap{scroll-snap-type:x mandatory}
.slider-snap>.slider-slide{scroll-snap-align:start}
.slider-slide{min-width:0}
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
+8 -1
View File
@@ -116,7 +116,8 @@ final class DocumentValidator
}
if (in_array($type, ['heading', 'text', 'button'], true)) {
// From v12 a text block may carry the markup RichText allows, which needs more room.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : 300;
// From v17 a heading may too - one highlighted word is a design need, not a luxury.
$limit = 'text' === $type ? (RichText::supports($schemaVersion) ? RichText::MAX_LENGTH : 10000) : ('heading' === $type && RichText::headings($schemaVersion) ? 2000 : 300);
$this->text($props['text'] ?? null, $limit);
}
if ('heading' === $type) {
@@ -234,6 +235,12 @@ final class DocumentValidator
$this->require(is_int($props[$key] ?? null) && $props[$key] >= 1 && $props[$key] <= 6, sprintf('"%s" liegt zwischen 1 und 6.', $key));
}
$this->require(is_bool($props['snap'] ?? null), 'Das Einrasten ist anzugeben.');
if (isset($props['dots'])) {
// Punkte und Pfeile zeichnet der Browser aus dem Scrollbereich selbst; das
// geht erst ab v17, weil es vorher keine Eigenschaft dafür gab.
$this->require($schemaVersion >= 17, 'Punkte am Slider brauchen Schema v17.');
$this->require(is_bool($props['dots']), 'Die Punkte sind ja oder nein.');
}
}
if ('embed' === $type) {
// Everything about the embed is validated here, because the address ends up in an
+17 -1
View File
@@ -33,6 +33,12 @@ final class RichText
return is_int($schemaVersion) && $schemaVersion >= 12;
}
/** Ueberschriften duerfen erst ab v17 Auszeichnungen tragen. */
public static function headings(mixed $schemaVersion): bool
{
return is_int($schemaVersion) && $schemaVersion >= 17;
}
/**
* Returns the text with only allowed markup left. The result is safe to print unescaped.
*/
@@ -184,11 +190,21 @@ final class RichText
if (!self::supports($document['schemaVersion'] ?? null)) {
return $document;
}
$walk = function (array $blocks) use (&$walk): array {
// Die Fassung wird in die Schliessung gebunden. Ohne das war sie darin schlicht nicht
// vorhanden, der Filter fuer Ueberschriften lief nie - und ihr Inhalt wird unescaped
// ausgegeben. Der Test, der ein <script> in eine Ueberschrift schreibt, hat es gezeigt.
$headings = self::headings($document['schemaVersion'] ?? null);
$walk = function (array $blocks) use (&$walk, $headings): array {
foreach ($blocks as $index => $block) {
if ('text' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
// Ab v17 darf auch eine Ueberschrift Auszeichnungen tragen. Gebraucht wird das
// fuer ein hervorgehobenes Wort - im Original von kant-it.de ist die zweite
// Zeile der Hauptueberschrift rot. Durch denselben Filter wie jeder Text.
if ($headings && 'heading' === ($block['type'] ?? null) && is_string($block['props']['text'] ?? null)) {
$blocks[$index]['props']['text'] = $this->sanitise($block['props']['text']);
}
if (isset($block['children']) && is_array($block['children'])) {
$blocks[$index]['children'] = $walk($block['children']);
}
+2 -2
View File
@@ -18,7 +18,7 @@
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -44,7 +44,7 @@
</main>
{% if areas.footer|default(null) %}
<footer class="site-footer">
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, eagerImage: null} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: true, richHeadings: areas.footer.schemaVersion|default(1) >= 17, eagerImage: null} only %}{% endfor %}
</footer>
{% endif %}
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
+11 -11
View File
@@ -10,11 +10,11 @@
{% 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 %}
{% 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), richHeadings: richHeadings|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 }}>
<h{{ props.level }} id="{{ block.id }}" class="{{ designClasses|trim }}">{% if richHeadings|default(false) %}{{ props.text|raw }}{% else %}{{ props.text }}{% endif %}</h{{ props.level }}>
{% elseif type == 'text' %}
{% if richText|default(false) %}
{# Already filtered by RichText when it was stored, so printing it unescaped is safe.
@@ -45,7 +45,7 @@
</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 %}
{% 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), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}</li>{% endfor %}
</ul>
{% elseif type == 'cta' %}
<div id="{{ block.id }}" class="block-cta{{ designClasses }}">
@@ -88,7 +88,7 @@
{% 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 %}
{% 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), richHeadings: richHeadings|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>
@@ -117,22 +117,22 @@
<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 %}
{% 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), richHeadings: richHeadings|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 %}
{% 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), richHeadings: richHeadings|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 %}
{% 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), richHeadings: richHeadings|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 %}
{% 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), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
</details>
{% elseif type == 'icon' %}
@@ -158,10 +158,10 @@
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' : '' }}"
<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' : '' }}"
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>
<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), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}</div>
{% endfor %}
</div>
{% elseif type == 'embed' %}
@@ -199,7 +199,7 @@
</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 %}
{% 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), richHeadings: richHeadings|default(false), eagerImage: eagerImage|default(null)} only %}{% endfor %}
</div>
{% else %}
{#
+3 -3
View File
@@ -40,7 +40,7 @@
instead of being written into this template. #}
{% if areas.header|default(null) %}
<header class="site-header{{ siteSettings.load().stickyHeader ? ' is-sticky' : '' }}">
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, eagerImage: eagerImage|default(null)} only %}{% endfor %}
{% for block in areas.header.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.header.schemaVersion|default(1) >= 12, richHeadings: areas.header.schemaVersion|default(1) >= 17, eagerImage: eagerImage|default(null)} only %}{% endfor %}
</header>
{% endif %}
<main id="main" tabindex="-1">
@@ -52,11 +52,11 @@
{% 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, eagerImage: eagerImage|default(null)} only %}{% endfor %}
{% 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, 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: {}, forms: {}, breadcrumbs: [], navigation: navigation|default({}), sections: sections|default({}), richText: areas.footer.schemaVersion|default(1) >= 12, eagerImage: null} only %}{% endfor %}
{% for block in areas.footer.blocks %}{% include 'blocks/block.html.twig' with {block: block, editing: editing|default(false), postLists: {}, forms: {}, breadcrumbs: [], 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 %}
{# Privacy statement, imprint and the way back to the consent decision. All three have to be
+27
View File
@@ -599,6 +599,33 @@ final class CmsTest extends TestCase
self::assertSame(['count' => 5, 'category' => 'projekte', 'tag' => ''], $stored['blocks'][2]['props']);
}
public function testAHeadingCanHighlightOneWordAndAStoredHeadingStaysFiltered(): void
{
// Im Original von kant-it.de ist die zweite Zeile der Hauptüberschrift rot. Dafür darf
// eine Überschrift ab v17 Auszeichnungen tragen - durch denselben Filter wie jeder Text,
// damit über diesen Weg kein Skript in eine Seite gelangt.
$document = DemoContent::starter('Hervorhebung');
$document['schemaVersion'] = 17;
$document['blocks'][0]['children'][0]['props']['text'] = 'Ihr Partner für <mark>digitale Exzellenz</mark><script>alert(1)</script>';
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<mark>digitale Exzellenz</mark>', $html);
self::assertStringNotContainsString('alert(1)', $html);
self::assertStringNotContainsString('<script>alert', $html);
// Das hervorgehobene Wort nimmt die Akzentfarbe statt eines gelben Textmarkers.
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent)', $css);
// Vor v17 bleibt eine Überschrift wörtlich, damit nichts nachträglich zu Markup wird.
$document['schemaVersion'] = 16;
$document['blocks'][0]['children'][0]['props']['text'] = 'Ganz <mark>ohne</mark> Auszeichnung';
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
self::assertStringContainsString('Ganz &lt;mark&gt;ohne&lt;/mark&gt; Auszeichnung', $this->request('/')->getContent());
}
public function testALogoCanLinkToTheStartPageAndTheHeaderCanStick(): void
{
// Zwei Selbstverständlichkeiten, die fehlten: ein Bild ließ sich nicht verlinken - also