Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c3c8671a7 | ||
|
|
9726af9254 |
No files matched your search
+76
-1
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 14.09.2026, Fassung **1.5.2**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 14.09.2026, Fassung **1.5.4**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -369,6 +369,81 @@ als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vors
|
||||
Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der
|
||||
Vorschau.
|
||||
|
||||
## 1.5.3: der Editor blieb nach dem Update schwarz
|
||||
|
||||
**Der Fehler.** Nach 1.5.2 meldete der Editor im Browser
|
||||
`The requested module './editor-model.js' does not provide an export named 'dropList'`, und der
|
||||
Werkzeugkasten war leer.
|
||||
|
||||
Ursache war nicht der Code, sondern das Zwischenspeichern. `public/.htaccess` setzt für **jede**
|
||||
`.js`-Datei `Cache-Control: public, max-age=31536000, immutable` – ein Jahr. Die Fassungskennung
|
||||
`?v=` hängt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fünf Module, die der
|
||||
Editor über `import './….js'` lädt, tragen keine: der Browser holte das neue `editor.js` und
|
||||
behielt das alte `editor-model.js`. Ein neues `canvas-overlay.js` traf damit auf ein Modell ohne
|
||||
`dropList`.
|
||||
|
||||
Beide gemeldeten Symptome hatten diese eine Wurzel: seit 1.5.1 baut das Skript den Werkzeugkasten
|
||||
auf (`buildLibrary()`), und `editor.js` importiert `canvas-overlay.js` in Zeile 5. Bricht der
|
||||
Import, läuft keine Zeile danach – und die Elementliste bleibt leer. Das gemeldete „Menü fehlt“
|
||||
war der Werkzeugkasten, nicht die Verwaltungsnavigation. Die wurde geprüft: in Chrome 152 stehen
|
||||
`::details-content`, ausgeblendeter Schalter und sichtbare Liste korrekt.
|
||||
|
||||
**Die Behebung.** Eine Importkarte in `base.html.twig` hängt die Fassung an die aufgelöste
|
||||
Adresse jedes Moduls, ohne dass eine einzige Importzeile sie kennen muss. Das behält das
|
||||
Zwischenspeichern über ein Jahr – die Leistung war nie das Problem, nur die fehlende Kennung.
|
||||
|
||||
Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter
|
||||
`script-src 'self'` wurde sie wortlos verworfen. `AdminHeadersSubscriber` erzeugt deshalb je
|
||||
Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; die
|
||||
Twig-Funktion `csp_nonce()` gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript –
|
||||
`'self'` gilt unverändert für alles andere.
|
||||
|
||||
**Geprüft.** Der Browsertest schreibt jede angeforderte `.js`-Adresse mit und verlangt an jeder
|
||||
eine Fassungskennung; vor der Behebung nennt er die fünf fehlenden beim Namen, danach trägt sie
|
||||
alle acht. Dazu ein Test, der jeden `./….js`-Import in `public/assets` gegen die Importkarte
|
||||
hält – ein neues Modul, das dort fehlt, fällt damit im Test auf und nicht erst beim nächsten
|
||||
Update beim Kunden. Gegenprobe gemacht: mit einem entfernten Eintrag schlägt er fehl.
|
||||
`npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig.
|
||||
|
||||
**Bekannte Grenze.** Ein Modulfehler legt den Editor weiterhin vollständig lahm, und der
|
||||
Werkzeugkasten ist seit 1.5.1 nichts ohne sein Skript. Für einen Editor ist das vertretbar – die
|
||||
öffentliche Website läuft ohne JavaScript –, aber die Fehleranzeige dafür ist noch die
|
||||
Browserkonsole und kein Satz in der Oberfläche.
|
||||
|
||||
## Arbeitspaket H, Teil 3b: der Werkzeugkasten bekommt Bilder und eine Suche
|
||||
|
||||
**Erledigt.** Die Elementwahl war eine Liste aus Schriftzeichen – „▣“ für Sektion, „☰“ für Zeile.
|
||||
Jetzt trägt jeder der 34 Einträge eine Strichzeichnung, die zeigt, was er baut: die Sektion ein
|
||||
gefülltes Band, „Zeile, 3 Spalten“ tatsächlich drei Kästen, das Bild einen Rahmen mit Bergzug.
|
||||
|
||||
**Zeichnungen statt Bilddateien.** Flatsome liefert je Element ein PNG mit. Gezeichnet ist hier
|
||||
besser: `library-previews.js` ist ein paar hundert Byte statt eines Ordners voller Bitmaps, die
|
||||
Formen nehmen die Farbe der Fläche an, auf der sie liegen – gesperrte Einträge werden damit von
|
||||
selbst blass – und sie bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet,
|
||||
nicht gezeichnet: zwei, drei oder vier Kästen aus derselben Funktion.
|
||||
|
||||
**Suche.** Ein Feld über der Liste filtert über alle Gruppen. Gesucht wird über den Namen, den
|
||||
internen Typ **und** den Gruppennamen – wer „slider“ eingibt, muss nicht wissen, dass er unter
|
||||
„Gruppen“ einsortiert ist. Gruppen ohne Treffer verschwinden, damit die Liste keine Lücken
|
||||
behält, und darunter steht, wie viele von wie vielen übrig sind.
|
||||
|
||||
**Dabei bemerkt:** Es gab damit zwei Suchfelder mit dem Namen „Element suchen“ auf einem
|
||||
Bildschirm – für eine Sprachausgabe nicht unterscheidbar. Sie heißen jetzt nach dem, was sie
|
||||
durchsuchen: „Element zum Einfügen suchen“ im Werkzeugkasten, „Element auf der Seite suchen“ im
|
||||
Strukturbaum.
|
||||
|
||||
**Geprüft.** Der Browsertest zählt die Vorschaubilder gegen die Einträge (alle 34 haben eines),
|
||||
sucht nach „galerie“ (ein Eintrag, eine Gruppe), nach „aufbau“ (fünf Einträge über den
|
||||
Gruppennamen), nach etwas Nichtvorhandenem und wieder nach nichts. `npm test` 28 von 28,
|
||||
`php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig ohne axe-Verstoß.
|
||||
|
||||
**Noch offen aus Teil 3.** Vorlagen je Element mit Vorschaubild. Und das Ziehen der
|
||||
Spaltenbreite: das geht heute nicht ohne einen Zwischenschritt, denn eine Zeile ist ein
|
||||
CSS-Raster mit **gleich breiten** Spalten (`repeat(n, minmax(0,1fr))`); ein `span` je Spalte
|
||||
gibt es nicht. Dafür braucht es erst ein Zwölf-Spalten-Modell im Designtoken-Manifest, und das
|
||||
berührt die öffentliche CSS und damit die Messwerte aus AGENTS.md. Das ist ein eigener Schritt,
|
||||
kein Nebenbei.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
|
||||
|
||||
@@ -270,13 +270,14 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
|
||||
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
|
||||
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.library-buttons button{display:flex;align-items:center;gap:7px;text-align:left;min-height:38px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
||||
.library-buttons button>span:first-child{flex:none;width:14px;text-align:center;font-size:13px;color:var(--accent)}
|
||||
.library-buttons button{display:flex;align-items:center;gap:8px;text-align:left;min-height:44px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
|
||||
.library-preview{flex:none;display:grid;place-items:center;width:34px;height:23px;color:var(--accent);opacity:.85}
|
||||
.library-buttons button:disabled .library-preview{color:var(--muted);opacity:.6}
|
||||
.library-name{min-width:0}
|
||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
|
||||
.library-buttons button:disabled>span:first-child{color:var(--muted)}
|
||||
/* A row is offered by column count, so its glyph shows the count - and needs the room for it,
|
||||
instead of the fixed single-glyph width the other entries share. */
|
||||
.library-buttons button>span.library-row-glyph{width:auto;letter-spacing:0;font-size:10px;color:var(--accent)}
|
||||
.library-buttons button[data-row]{grid-column:span 2}
|
||||
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
|
||||
|
||||
#library-search{font-size:11px;min-height:36px;padding:9px;margin-bottom:4px}
|
||||
#library-found{margin:0 0 10px;min-height:0}
|
||||
+32
-7
@@ -1,8 +1,9 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
import { preview, rowPreview } from './library-previews.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
@@ -68,7 +69,7 @@ function controls() {
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#select-page').disabled = !ready || busy;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
@@ -756,12 +757,18 @@ function buildLibrary() {
|
||||
button.type = 'button'; button.draggable = true;
|
||||
button.dataset.add = type;
|
||||
if (columns) button.dataset.row = String(columns);
|
||||
const glyph = document.createElement('span'); glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = columns ? '▥'.repeat(Math.min(columns, 4)) : icons[type];
|
||||
if (columns) glyph.className = 'library-row-glyph';
|
||||
const name = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
||||
// What is searched for: the name, the internal type and the group it sits in. Someone
|
||||
// looking for "slider" should not have to know it is filed under "Gruppen".
|
||||
button.dataset.search = `${name} ${type} ${category.name}`.toLocaleLowerCase('de');
|
||||
const picture = document.createElement('span');
|
||||
picture.className = 'library-preview';
|
||||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||||
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
|
||||
const text = document.createElement('span');
|
||||
text.textContent = columns ? `${labels[type]}, ${columns} Spalten` : labels[type];
|
||||
button.append(glyph, text);
|
||||
text.className = 'library-name';
|
||||
text.textContent = name;
|
||||
button.append(picture, text);
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
@@ -789,6 +796,24 @@ function updateLibrary() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters the toolbox. A group without a hit disappears, so the list does not keep its gaps. */
|
||||
function filterLibrary() {
|
||||
const term = $('#library-search').value.trim().toLocaleLowerCase('de');
|
||||
let hits = 0;
|
||||
for (const group of $('#library').querySelectorAll('.library-group')) {
|
||||
let visible = 0;
|
||||
for (const button of group.querySelectorAll('button')) {
|
||||
const hit = '' === term || (button.dataset.search ?? '').includes(term);
|
||||
button.hidden = !hit;
|
||||
if (hit) visible++;
|
||||
}
|
||||
group.hidden = 0 === visible;
|
||||
hits += visible;
|
||||
}
|
||||
$('#library-found').textContent = '' === term ? '' : (0 === hits ? 'Kein Element mit diesem Namen.' : `${hits} von ${$('#library').querySelectorAll('button').length}`);
|
||||
}
|
||||
$('#library-search').addEventListener('input', filterLibrary);
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || button.disabled) return;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Line art for the toolbox.
|
||||
*
|
||||
* Flatsome ships a PNG per element. Drawings are better here: they are a few hundred bytes
|
||||
* instead of a folder of bitmaps, they take the colour of the surface they sit on, and they stay
|
||||
* sharp on a screen of any density. Every shape lives in the same 24x16 field so the entries
|
||||
* line up in the grid.
|
||||
*
|
||||
* Each value is the inside of an `<svg>`; `stroke` and `fill` come from the wrapper, so a shape
|
||||
* only says what it is, not what colour it has.
|
||||
*/
|
||||
|
||||
/** A filled bar, the building block most of these are made of. */
|
||||
const bar = (x, y, width, height = 2) => `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${Math.min(height, 2) / 2}" fill="currentColor" stroke="none"/>`;
|
||||
/** Several bars under each other, as a paragraph of text. */
|
||||
const lines = (...widths) => widths.map((width, index) => bar(3, 4 + index * 3.4, width, 1.7)).join('');
|
||||
|
||||
export const PREVIEWS = {
|
||||
// Aufbau
|
||||
section: '<rect x="1" y="3" width="22" height="10" rx="1.5" fill="currentColor" opacity=".14" stroke="none"/><rect x="1" y="3" width="22" height="10" rx="1.5"/>',
|
||||
row: '<rect x="1" y="3" width="22" height="10" rx="1.5"/><path d="M12 3.6v8.8"/>',
|
||||
column: '<rect x="8" y="1.5" width="8" height="13" rx="1.5"/>',
|
||||
|
||||
// Inhalt
|
||||
heading: `${bar(3, 3.6, 18, 3)}${bar(3, 9, 11, 2)}`,
|
||||
text: lines(18, 16, 11),
|
||||
image: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><circle cx="7.5" cy="6.5" r="1.4" fill="currentColor" stroke="none"/><path d="M3.2 12l5-4.4 3.6 3.1 3-2.6 5 3.9"/>',
|
||||
button: '<rect x="4" y="4.5" width="16" height="7" rx="2"/>',
|
||||
icon: '<path d="M12 2.6l2.7 5.6 6 .8-4.4 4.1 1.1 5.8" transform="scale(.86) translate(2 .4)"/><path d="M12 2.6l-2.7 5.6-6 .8 4.4 4.1-1.1 5.8" transform="scale(.86) translate(2 .4)"/>',
|
||||
divider: '<path d="M2 8h20"/>',
|
||||
spacer: '<path d="M3 3h18M3 13h18"/><path d="M12 5.6v4.8M10.4 6.8L12 5.2l1.6 1.6M10.4 9.2L12 10.8l1.6-1.6"/>',
|
||||
|
||||
// Bausteine
|
||||
iconcard: `<rect x="3" y="2.5" width="4.5" height="4.5" rx="1.2"/>${bar(3, 9, 12, 1.9)}${bar(3, 12, 8, 1.5)}`,
|
||||
stat: `${bar(3, 3, 9, 5)}${bar(3, 10.5, 14, 1.7)}`,
|
||||
quote: `<path d="M3.4 7.6c0-2 1.3-3.2 2.9-3.4M8.4 7.6c0-2 1.3-3.2 2.9-3.4"/><path d="M3.4 7.6h2.9v3.2H3.4zM8.4 7.6h2.9v3.2H8.4z"/>${bar(14, 5.6, 7, 1.6)}${bar(14, 9, 5, 1.6)}`,
|
||||
cta: `${bar(2.5, 4, 12, 2.4)}<rect x="2.5" y="8.6" width="9" height="5" rx="1.6"/><rect x="14.5" y="8.6" width="7" height="5" rx="1.6" fill="currentColor" stroke="none"/>`,
|
||||
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
|
||||
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
|
||||
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
|
||||
|
||||
// Gruppen
|
||||
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
|
||||
accordion: '<rect x="2.5" y="2" width="19" height="4" rx="1.2"/><path d="M18 3.6l1.3 1.3 1.3-1.3"/><rect x="2.5" y="7.5" width="19" height="3" rx="1.2"/><rect x="2.5" y="12" width="19" height="3" rx="1.2"/>',
|
||||
accordionitem: '<rect x="2.5" y="5.5" width="19" height="5" rx="1.2"/><path d="M17.6 7.4l1.3 1.3 1.3-1.3"/>',
|
||||
tabs: '<rect x="2.5" y="2" width="6" height="3.5" rx="1" fill="currentColor" stroke="none"/><rect x="9.5" y="2" width="6" height="3.5" rx="1"/><rect x="2.5" y="6.5" width="19" height="7.5" rx="1.2"/>',
|
||||
tabitem: '<rect x="7" y="2.5" width="10" height="4" rx="1" fill="currentColor" stroke="none"/><rect x="2.5" y="7.5" width="19" height="6" rx="1.2"/>',
|
||||
slider: '<rect x="5.5" y="3" width="13" height="10" rx="1.5"/><path d="M3.4 6.6L1.6 8l1.8 1.4M20.6 6.6L22.4 8l-1.8 1.4"/>',
|
||||
|
||||
// Seite und Daten
|
||||
postlist: '<rect x="2.5" y="2.5" width="7" height="5" rx="1.2"/><rect x="2.5" y="8.5" width="7" height="5" rx="1.2"/>'
|
||||
+ `${bar(11.5, 3.2, 10, 1.6)}${bar(11.5, 6, 7, 1.4)}${bar(11.5, 9.2, 10, 1.6)}${bar(11.5, 12, 7, 1.4)}`,
|
||||
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
|
||||
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
|
||||
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
|
||||
};
|
||||
|
||||
/** The picture for one element type, ready to drop into the markup. */
|
||||
export function preview(type) {
|
||||
const shape = PREVIEWS[type];
|
||||
if (!shape) return '';
|
||||
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${shape}</svg>`;
|
||||
}
|
||||
|
||||
/** A row of columns, for the entries that add a row with a column count. */
|
||||
export function rowPreview(columns) {
|
||||
const gap = 1.4;
|
||||
const inner = (22 - gap * (columns - 1)) / columns;
|
||||
const cells = Array.from({ length: columns }, (_, index) =>
|
||||
`<rect x="${(1 + index * (inner + gap)).toFixed(2)}" y="3" width="${inner.toFixed(2)}" height="10" rx="1.2"/>`).join('');
|
||||
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" aria-hidden="true" focusable="false">${cells}</svg>`;
|
||||
}
|
||||
@@ -39,6 +39,14 @@ try {
|
||||
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
|
||||
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
|
||||
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
|
||||
// fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...".
|
||||
const skripte = [];
|
||||
page.on('request', anfrage => {
|
||||
const url = new URL(anfrage.url());
|
||||
if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url);
|
||||
});
|
||||
const axe = async name => {
|
||||
console.log('Accessibility check:', name);
|
||||
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
|
||||
@@ -61,6 +69,13 @@ try {
|
||||
await page.locator('a[href="/admin/pages/test-home"]').first().click();
|
||||
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
||||
console.log('Editor data loaded.');
|
||||
// Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die
|
||||
// Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache.
|
||||
assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen');
|
||||
const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))];
|
||||
assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`);
|
||||
checks.push({ name: 'every module is requested with a version key', passed: true });
|
||||
console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`);
|
||||
console.log('Preview status:', await page.locator('#preview-status').innerText());
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
@@ -155,6 +170,24 @@ try {
|
||||
// Differenz.
|
||||
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
|
||||
const mitBild = await page.locator('#library button .library-preview svg').count();
|
||||
const knoepfe = await page.locator('#library button').count();
|
||||
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
|
||||
|
||||
// Die Suche greift ueber alle Gruppen und blendet leere Gruppen aus.
|
||||
await page.locator('#library-search').fill('galerie');
|
||||
await page.locator('#library-found').filter({ hasText: 'von' }).waitFor();
|
||||
assert.equal(await page.locator('#library button:visible').count(), 1, 'genau die Galerie bleibt');
|
||||
assert.equal(await page.locator('#library .library-group:visible').count(), 1, 'nur ihre Gruppe bleibt');
|
||||
// Auch der Gruppenname findet: "Aufbau" gehoert zu Sektion, Zeile und Spalte.
|
||||
await page.locator('#library-search').fill('aufbau');
|
||||
assert.equal(await page.locator('#library button:visible').count(), 5, 'Sektion, drei Zeilen, Spalte');
|
||||
await page.locator('#library-search').fill('gibtesnicht');
|
||||
await page.locator('#library-found').filter({ hasText: 'Kein Element' }).waitFor();
|
||||
await page.locator('#library-search').fill('');
|
||||
assert.equal(await page.locator('#library button:visible').count(), knoepfe, 'leere Suche zeigt wieder alles');
|
||||
|
||||
checks.push({ name: 'the toolbox follows the structure', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/toolbox.png' });
|
||||
console.log('Context aware toolbox passed.');
|
||||
@@ -204,11 +237,11 @@ try {
|
||||
|
||||
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
||||
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
||||
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete');
|
||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
|
||||
assert.equal(await page.locator('#structure .tree-item').count(), 2);
|
||||
await page.locator('#structure [data-block-id="headline"]').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByLabel('Element suchen', { exact: true }).fill('');
|
||||
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
|
||||
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
|
||||
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
@@ -14,12 +15,34 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
return [KernelEvents::REQUEST => ['onRequest', 300], KernelEvents::RESPONSE => ['onResponse', -300]];
|
||||
}
|
||||
|
||||
private function isAdmin(string $path): bool
|
||||
{
|
||||
return 1 === preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* One value per administration page, for the single inline script there is: the import map.
|
||||
*
|
||||
* It has to be inline - the specification knows no external import maps - and it has to work
|
||||
* under `script-src 'self'`. Without it the map is dropped without a word, the modules load
|
||||
* without a version key, and a year-old `editor-model.js` out of the cache meets a new
|
||||
* `editor.js`. That is not a theory: it broke the editor after 1.5.0 went out.
|
||||
*/
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
if (!$event->isMainRequest() || !$this->isAdmin($request->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$request->attributes->set('csp_nonce', rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='));
|
||||
}
|
||||
|
||||
public function onResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!preg_match('#^/'.preg_quote($this->adminPath, '#').'(?:/|$)#', $event->getRequest()->getPathInfo())) {
|
||||
if (!$this->isAdmin($event->getRequest()->getPathInfo())) {
|
||||
return;
|
||||
}
|
||||
$headers = $event->getResponse()->headers;
|
||||
@@ -30,6 +53,10 @@ final class AdminHeadersSubscriber implements EventSubscriberInterface
|
||||
$headers->set('Permissions-Policy', 'accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()');
|
||||
// The preview must show the same typeface as the published page, so the font hosts are
|
||||
// allowed here as well. Everything else stays as narrow as on the public side.
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// The nonce only ever adds the one script it names; 'self' keeps applying to everything
|
||||
// else, so nothing is loosened by it.
|
||||
$nonce = $event->getRequest()->attributes->get('csp_nonce');
|
||||
$script = is_string($nonce) && '' !== $nonce ? sprintf("'self' 'nonce-%s'", $nonce) : "'self'";
|
||||
$headers->set('Content-Security-Policy', "default-src 'self'; script-src {$script}; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* The nonce for the one inline script the administration needs.
|
||||
*
|
||||
* `script-src 'self'` has no room for an inline script, and the import map has to be one: the
|
||||
* specification knows no external import maps. AdminHeadersSubscriber puts a fresh value into the
|
||||
* request for every administration page and names it in the policy; this hands the same value to
|
||||
* the template. Outside the administration there is none, and the function then returns an empty
|
||||
* string instead of inventing one that no policy allows.
|
||||
*/
|
||||
final class CspExtension extends AbstractExtension
|
||||
{
|
||||
public function __construct(private RequestStack $requests)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [new TwigFunction('csp_nonce', $this->nonce(...))];
|
||||
}
|
||||
|
||||
public function nonce(): string
|
||||
{
|
||||
$value = $this->requests->getCurrentRequest()?->attributes->get('csp_nonce');
|
||||
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,20 @@
|
||||
<title>{% block title %}Verwaltung{% endblock %} · KantSites</title>
|
||||
{% include 'favicon.html.twig' %}
|
||||
<link rel="stylesheet" href="/assets/admin.css?v={{ kant_version() }}">
|
||||
{# Die Fassungskennung fuer die Module untereinander.
|
||||
|
||||
Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'`
|
||||
darin nicht - und `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable`
|
||||
zwischenspeichern. Nach einem Update lud der Browser damit das neue `editor.js`, behielt
|
||||
aber das alte `editor-model.js` und brach mit "does not provide an export named ..." ab.
|
||||
Die Importkarte haengt die Fassung an die aufgeloeste Adresse, ohne dass eine einzige
|
||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||
nicht mehr. #}
|
||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews'] %}
|
||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||
{%- endfor %}
|
||||
}}</script>
|
||||
<script src="/assets/color-field.js?v={{ kant_version() }}" defer></script>
|
||||
{% block styles %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken. Der Werkzeugkasten zeigt an, was an der gewählten Stelle hineinpasst — was dort nicht hingehört, sagt beim Überfahren warum.</p>
|
||||
<label class="search-label" for="library-search">Element zum Einfügen suchen</label>
|
||||
<input id="library-search" type="search" placeholder="z. B. Galerie, Zeile, Formular …" autocomplete="off">
|
||||
<p id="library-found" class="field-help" role="status"></p>
|
||||
<div id="library" class="library"></div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
<div id="presets" class="preset-library"><button data-preset="hero" disabled><span aria-hidden="true">▮</span> Vollbild-Hero</button><button data-preset="intro" disabled><span aria-hidden="true">▤</span> Intro mit Button</button><button data-preset="columns" disabled><span aria-hidden="true">▥</span> Zwei Leistungen</button><button data-preset="cta" disabled><span aria-hidden="true">↗</span> Kontaktaufruf</button></div>
|
||||
</div></section>
|
||||
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||
<section class="panel-section structure-section"><div class="panel-title"><h2>Struktur</h2><button id="select-page" class="text-button">Seite</button></div><label class="search-label" for="tree-search">Element auf der Seite suchen</label><input id="tree-search" type="search" placeholder="Text oder Elementtyp …"><div class="tree-tools"><button id="expand-all" class="text-button">Alle öffnen</button><button id="collapse-all" class="text-button">Einklappen</button></div><p class="panel-hint">Ziehen oder mit den Schaltflächen verschieben.</p><nav id="structure" aria-label="Blöcke auswählen"></nav>
|
||||
<div class="block-actions"><button id="move-up" title="Nach oben verschieben" aria-label="Nach oben verschieben">↑</button><button id="move-down" title="Nach unten verschieben" aria-label="Nach unten verschieben">↓</button><button id="nest" title="In vorherigen Container verschachteln" aria-label="In vorherigen Container verschachteln">⇥</button><button id="outdent" title="Eine Ebene herausheben" aria-label="Eine Ebene herausheben">⇤</button><button id="duplicate">Duplizieren</button><button id="delete" class="danger-text">Löschen</button></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, insertionTarget, insertRowAt, CATEGORIES, labels as blockLabels, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
@@ -492,3 +492,27 @@ test('a row dropped at an exact position brings its columns along', () => {
|
||||
// Und in eine Zeile hinein gibt es keine zweite Zeile.
|
||||
assert.equal(insertRowAt(document, 2, row.id, 0), null);
|
||||
});
|
||||
|
||||
// --- Fassungskennung an den Modulen ---------------------------------------------------------
|
||||
|
||||
test('every module imported by another one is listed in the import map', () => {
|
||||
// `public/.htaccess` laesst jede .js-Datei ein Jahr lang als `immutable` zwischenspeichern.
|
||||
// Die Einstiegsdatei bekommt ihr `?v=` aus der Vorlage, ein `import './editor-model.js'` darin
|
||||
// nicht - der Browser behielte das alte Modul und der Editor braeche mit "does not provide an
|
||||
// export named ..." ab. Genau das ist nach 1.5.0 passiert. Die Importkarte haengt die Fassung
|
||||
// an; ein neues Modul, das dort fehlt, faellt hier auf und nicht erst beim naechsten Update.
|
||||
const quellen = readdirSync('public/assets').filter(name => name.endsWith('.js'));
|
||||
const importiert = new Set();
|
||||
for (const name of quellen) {
|
||||
for (const treffer of readFileSync(`public/assets/${name}`, 'utf8').matchAll(/from '\.\/([\w-]+\.js)'/g)) {
|
||||
importiert.add(treffer[1]);
|
||||
}
|
||||
}
|
||||
assert.ok(importiert.size > 0, 'es gibt ueberhaupt Modulimporte');
|
||||
const vorlage = readFileSync('templates/admin/base.html.twig', 'utf8');
|
||||
const karte = /<script type="importmap"[\s\S]*?<\/script>/.exec(vorlage);
|
||||
assert.ok(karte, 'base.html.twig traegt eine Importkarte');
|
||||
const eingetragen = new Set([...karte[0].matchAll(/'([\w-]+)'/g)].map(treffer => `${treffer[1]}.js`));
|
||||
const fehlend = [...importiert].filter(name => !eingetragen.has(name)).sort();
|
||||
assert.deepEqual(fehlend, [], `ohne Fassungskennung in der Importkarte: ${fehlend.join(', ')}`);
|
||||
});
|
||||
Reference in new issue
Block a user