Compare commits

..
14 Commits
Author SHA1 Message Date
kantarci 6a044e59e9 Fassung 1.6.2: sichtbare leere Layouts und direktes Befuellen im Editor
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
2026-09-14 09:22:23 +02:00
kantarci 149e1fe520 Dokumentiert die gepruefte Veroeffentlichung von 1.6.1
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
2026-09-14 09:00:06 +02:00
kantarci b783d9d1e5 Fassung 1.6.1: flexible Editorflaeche und passende Blockeigenschaften
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
2026-09-14 08:57:42 +02:00
kantarciandClaude Opus 5 05ea703655 Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
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
Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:30:13 +02:00
kantarciandClaude Opus 5 d1ac9d760b Fassung 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
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
In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation - Marke oben, Fusszeile unten, dazwischen nichts.

Die Navigation steckt in einem <details>: auf dem Telefon ein Klappmenue, am
Schreibtisch die stehende Spalte. Am Schreibtisch wurde der Schalter mit
display:none ausgeblendet - aber das Element trug kein open, und ein
geschlossenes details versteckt seinen Inhalt selbst. Schalter weg und Inhalt
weg.

Behoben mit ::details-content, das den Inhalt am Schreibtisch zurueckholt,
waehrend der Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht,
faellt ueber @supports die ganze Gruppe weg: dann steht der Schalter da und die
Liste ist einen Klick entfernt statt gar nicht da.

Der Code dafuer lag bereits im Arbeitsverzeichnis - eine parallel laufende
Sitzung hatte ihn geschrieben, aber nicht committet, und damit war er in keinem
Release. Uebernommen ist hier nur diese Korrektur, nicht die uebrige Arbeit
jener Sitzung.

Wie das durchrutschen konnte: beim vorigen Bericht wurde die Navigation fuer in
Ordnung erklaert, nachdem eine Nachbildung der Regel in einer leeren Seite
geprueft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte
Datei nicht - der Befund war wertlos. Jetzt prueft der Browsertest die echte
Verwaltungsseite, auf beiden Groessen. Gegenprobe gemacht: gegen die
ausgelieferte admin.css schlaegt er fehl.

Geprueft: npm run test:editor vollstaendig, npm test 28/28, phpunit 452/452.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:17:56 +02:00
kantarciandClaude Opus 5 7c3c8671a7 Fassung 1.5.4: der Werkzeugkasten bekommt Bilder und eine Suche
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
Die Elementwahl war eine Liste aus Schriftzeichen - "▣" fuer Sektion, "☰" fuer
Zeile. Jetzt traegt jeder der 34 Eintraege eine Strichzeichnung, die zeigt, was
er baut: die Sektion ein gefuelltes Band, "Zeile, 3 Spalten" tatsaechlich drei
Kaesten, 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 Flaeche an, auf
der sie liegen - gesperrte Eintraege werden damit von selbst blass - und sie
bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet: zwei,
drei oder vier Kaesten aus derselben Funktion.

Suche. Ein Feld ueber der Liste filtert ueber alle Gruppen, gesucht wird ueber
Namen, internen Typ und Gruppennamen - wer "slider" eingibt, muss nicht wissen,
dass er unter "Gruppen" steht. Gruppen ohne Treffer verschwinden, darunter
steht wie viele von wie vielen uebrig sind.

Dabei bemerkt: es gab zwei Suchfelder namens "Element suchen" auf einem
Bildschirm, fuer eine Sprachausgabe nicht unterscheidbar. Sie heissen jetzt
nach dem, was sie durchsuchen.

Geprueft: der Browsertest zaehlt die Vorschaubilder gegen die Eintraege und
sucht nach Name, Gruppenname und etwas Nichtvorhandenem. npm test 28/28,
phpunit 452/452, npm run test:editor vollstaendig ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:08:31 +02:00
kantarciandClaude Opus 5 9726af9254 Fassung 1.5.3: der Editor blieb nach dem Update schwarz
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
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 fuer jede .js-Datei Cache-Control: immutable, max-age=31536000 - ein
Jahr. Die Fassungskennung haengt aber nur an den Einstiegsdateien, die die
Vorlage schreibt. Die fuenf Module, die der Editor ueber import './...js'
laedt, 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, und editor.js importiert canvas-overlay.js in Zeile 5;
bricht der Import, laeuft keine Zeile danach und die Elementliste bleibt leer.
Das gemeldete fehlende Menue war der Werkzeugkasten, nicht die
Verwaltungsnavigation - die wurde geprueft und ist in Chrome 152 in Ordnung.

Behebung: eine Importkarte in base.html.twig haengt die Fassung an die
aufgeloeste Adresse jedes Moduls, ohne dass eine Importzeile sie kennen muss.
Das Zwischenspeichern ueber ein Jahr bleibt - 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; csp_nonce() gibt ihn der Karte mit. Der Nonce
erlaubt genau dieses eine Skript, 'self' gilt unveraendert fuer alles andere.

Geprueft: der Browsertest verlangt an jeder angeforderten .js-Adresse eine
Fassungskennung (vorher fehlten fuenf, jetzt tragen alle acht), dazu ein Test,
der jeden Modulimport gegen die Importkarte haelt - Gegenprobe gemacht.
npm test 28/28, phpunit 452/452, npm run test:editor vollstaendig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:00:52 +02:00
kantarciandClaude Opus 5 7225ae052e Fassung 1.5.2: die Optionen selbst
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
Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.

Geraeteschalter je Option. Bisher entschied ein einziger Schalter ueber der
Vorschau, welches Geraet das ganze Bedienfeld bearbeitet; "ist die Ueberschrift
auf dem Telefon kleiner" liess sich nur durch Umschalten und Hinsehen
beantworten. Jetzt traegt jede Option drei Chips (D, T, M): sie zeigen, wo ein
eigener Wert liegt, und ein Klick wechselt dorthin. Bedienfeld und Vorschau
wechseln gemeinsam - einen Wert fuer ein Geraet zu setzen, das man nicht
ansieht, ist blindes Aendern.

Abstand als Rahmen. Vier Felder untereinander, beschriftet "Oben, Rechts,
Unten, Links", zwangen zum Lesen. Jetzt sitzt jedes Feld an seiner Kante, die
Lage ist die Beschriftung; in der Mitte verkettet ein Schalter alle vier. Er
startet aus: er ersetzt den frueheren Knopf "Alle Seiten gleich setzen", und
das war eine bewusste Handlung. Seiten mit unterschiedlichen Einheiten gelten
als "gemischt" und bleiben unangetastet - ein gespeichertes 2rem wird nicht
still zu 2px.

Ziehfelder. Zahlenfelder lassen sich seitwaerts ziehen, Umschalt grob, Alt
fein. Tippen und Pfeiltasten bleiben unveraendert. Nur fuer Maus und Stift:
auf einem Tippgeraet ist ein seitliches Ziehen ueber ein Feld die Art, wie das
Bedienfeld gescrollt wird.

Dabei behoben: der Zug war nach einem Pixel zu Ende, weil jede Aenderung das
Bedienfeld neu zeichnete und damit das Feld ersetzte, an dem der Zeiger hing
(keepPanel). Der Kettenschalter verlor aus demselben Grund seinen Zustand. Und
das Gleichziehen nahm den Wert des ersten Feldes, auch wenn es leer war - ein
Druck auf die Kette loeschte damit die Seiten, die einen Wert hatten.

Geprueft: npm test 27/27, phpunit 452/452, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:34:37 +02:00
kantarciandClaude Opus 5 0e2334d30f Fassung 1.5.1: der Werkzeugkasten kennt den Zusammenhang
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
Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als waeren sie
Seitenanfaenge. Sie gehoeren in den Inhalt - erst eine Sektion, dann eine Zeile
darin. Das war nicht nur eine Anzeigefrage: eine Zeile nahm bisher jedes Kind
an, eine Ueberschrift konnte neben den Spalten landen, und eine Spalte durfte
ueberall stehen. Die Struktur, die der Editor in seinem eigenen Hinweistext
beschreibt, galt fuer die Schaltflaechen nicht.

Uebernommen ist Flatsomes Regelmodell (Elements.php): ALLOW - eine Zeile nimmt
nur Spalten; REQUIRE - eine Spalte lebt nur in einer Zeile. Dazu ein Zusatz,
den Flatsome nicht hat: ROOT_NEEDS_CONTAINER - eine Zeile ist kein
Seitenanfang. In jedem Behaelter bleibt sie erlaubt, auch in einem Tab.

Daraus die Bedienung:

- Werkzeugkasten nach Aufbau gruppiert statt flacher Liste aus 31 Typen.
- Was nicht hingehoert, ist gesperrt und sagt beim Ueberfahren warum. Eine
  Regel, die man erklaert bekommt, lernt man; eine Schaltflaeche, die nur
  nicht mehr reagiert, drueckt man weiter.
- Was moeglich ist, sagt wohin es kommt.
- Die Zeile wird nach Spaltenzahl angeboten und bringt ihre Spalten mit; der
  separate Zeilenbereich ist entfallen.
- insertionTarget beantwortet "wohin kaeme das?", ohne etwas einzufuegen.
  Knopf, Einfuegepunkt, Ziehen und die Sperrlogik nutzen dieselbe Funktion -
  vorher entschied insertBlock allein, und insertRow hatte eine zweite Kopie
  derselben Platzierungslogik.

Ein Element, das der gewaehlte Behaelter nicht nimmt, wird nicht gesperrt,
sondern steigt auf, bis ein Elternteil es annimmt.

Nebenbei behoben: postnav fehlte seit Schema v18 in labels und icons, der
Strukturbaum zeigte dafuer "Unbekannt" und ein Fragezeichen.

Geprueft: npm test 27/27, phpunit 446/446, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:13:25 +02:00
kantarciandClaude Opus 5 22e4ada830 Fassung 1.5.0: die Vorschau wird zur Arbeitsflaeche
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
Abgleich mit dem Flatsome UX Builder (docs/FLATSOME-ABGLEICH.md): die Gestaltung
ist Flatsome voraus, die Luecken liegen beim Elementkatalog und bei der
Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.

Erste Haelfte der Bedienung:

- Werkzeugleiste am Element in der Vorschau. Sie nennt das Element, auf das sie
  wirkt - die Seitenleiste hat dieselben fuenf Aktionen, und zwei gleichnamige
  Bedienelemente auf einem Bildschirm sind fuer eine Sprachausgabe nicht
  unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
- Auswahl- und Ueberfahrrahmen mit Beschriftung.
- Einfuegepunkte an Ober- und Unterkante: das naechste Element landet dort
  statt am Ende.
- Ziehen aus dem Werkzeugkasten in die Vorschau, mit Ablegemarke zwischen den
  Elementen und Rahmen um einen aufnehmenden Behaelter. Der Strukturbaum zieht
  in dieselbe Flaeche.

Die Ebene liegt ueber dem Rahmen, nicht darin: die Vorschau wird bei jeder
Aenderung ersetzt, eingezeichnete Bedienelemente waeren weg und stuenden in der
Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Leiste und
Pluszeichen werden gegenskaliert und bleiben in der Mobilvorschau fingergross.

Im Modell neu: insertBlockAt, moveBlockTo, dropList.

Dabei behoben: die Ebene war aria-hidden und enthielt fokussierbare
Schaltflaechen; canContain zerbrach an einem fehlenden Block beim Ziehen aus
dem Werkzeugkasten; beim Verschieben ans Listenende wurde der Index begrenzt,
bevor die Entnahme verrechnet war.

Geprueft: npm test 22/22, phpunit 434/434, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:53:23 +02:00
kantarciandClaude Opus 5 af5a2cef86 Fassung 1.4.6: das Update schreibt mit, und die Ergebnisseite ist nachsichtig
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
Zwei Nachtraege zu 1.4.5, beide aus der Generalprobe.

- Jeder Schritt des Archiv-Updaters geht sofort nach var/updates.log,
  waehrend des Laufs und nicht am Ende. Auf Umuts Hosting war die
  Ergebnisseite tot, und damit war auch verloren, was der Updater
  berichtet hatte. Genau so wurde der naechste Fehlschlag dann doch
  lesbar: "bin liess sich nicht beiseitelegen - der alte Stand wurde
  zurueckgeschoben" (ein Windows-Artefakt der Testumgebung, und
  zugleich der Beweis, dass der Rueckweg funktioniert).
- Die Ergebnisseite versteht auch die Schreibweise bis 1.4.4. Das ist
  kein Nachgeben, sondern noetig: Beim Update schreibt der alte Code
  die Schritte in die Sitzung und der neue zeigt sie an. Eine Fassung,
  die nur die neue Schreibweise kennt, koennte das Update, das sie
  selbst einspielt, nicht berichten.

Nachgestellt mit einer echten Installation aus dem Releasearchiv, die
ueber die Oberflaeche aktualisiert wurde - nicht ueber die Konsole.

Geprueft: 434 Tests auf PHP 8.2 und 8.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:04:37 +02:00
kantarciandClaude Opus 5 d295757ce0 Fassung 1.4.5: drei Fehler, die nur auf fremden Servern auftraten
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
Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf
meinem Rechner sichtbar.

- Der Updateknopf endete mit 500. Das Update lief vollstaendig durch -
  alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -,
  aber die Ergebnisseite starb daran: der Updater ueber Git nennt
  einen Schritt "step", der ueber das Archiv nannte ihn "label", beide
  legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage
  liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn
  Tests prueften das Rueckgabe-Array, keiner die Seite, die es
  anzeigt; der neue Test rendert sie (ADR 071).
- Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git.
  "git ist installiert" und "das hier ist eine Arbeitskopie" waren
  derselbe Schalter; auf dem Hosting ist Git vorhanden, die
  Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git
  repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie
  verschwinden die Git-Karten jetzt (ADR 072).
- "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit
  SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer
  die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt
  unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler
  ab statt mit einer 500-Seite zu enden (ADR 072).

Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem
Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater
laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/
und .env unberuehrt.

Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:54:13 +02:00
kantarciandClaude Opus 5 4153ba0f65 Fassung 1.4.4: Migrationen im Browser, und ein Fehlschlag sagt warum
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
Zwei Luecken, die erst das Veroeffentlichen von 1.4.3 gezeigt hat.

- Es gab keinen Weg, ausstehende Migrationen im Browser auszufuehren.
  Wer die Dateien per FTP hochlaedt - und das muss genau einmal jeder
  tun, dessen Installation die Updatekarte noch nicht kennt -, hatte
  danach neuen Code und eine alte Datenbank, und dafuer nur die
  Konsole. Also genau das, was auf geteiltem Hosting fehlt. Der
  Bildschirm "System" zeigt jetzt eine Karte, sobald etwas aussteht,
  nennt die Namen und fuehrt sie auf Knopfdruck aus.
- "Die Releaseseite antwortet nicht" waren drei Ursachen in einem
  Satz. Beim Gegenpruefen des frischen Releases holte der Updater von
  hier gar nichts - nicht weil die Leitung zu waere, sondern weil
  diesem PHP der Zertifikatsspeicher fehlt. Auf einem Webhosting ist
  das der haeufigste Grund und der einzige, den man in einer Minute
  behebt; er steht jetzt in der Meldung. Bei genau diesem Fehler wird
  zusaetzlich der Weg ueber den Dateizeiger versucht - damit
  funktioniert der Abruf auf diesem Rechner. Abgeschaltet wird die
  Pruefung nicht: ein Update ohne Zertifikatspruefung koennte
  unterwegs ausgetauscht worden sein.

Dazu der manuelle Weg Schritt fuer Schritt in docs/UPDATE.md und ein
Test, der beides festhaelt: dass die Karte auffaellt und dass nur ein
angemeldeter Administrator mit gueltigem Formular migrieren kann.

Geprueft: 431 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und
Linkpruefung, Editor-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:15:37 +02:00
kantarciandClaude Opus 5 d6d476177a Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
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
Vier Wuensche und ein Fund beim Nachmessen.

- Farbwaehler im Designbereich. Das Textfeld bleibt massgeblich, damit
  "@akzent" ein Verweis auf die Palette bleibt und nicht beim ersten
  Klick zu einem festen Hexwert erstarrt (ADR 066).
- Die Verwaltung auf dem Telefon: das Menue klappt unter 760 px zu und
  nennt den Bereich, in dem man steht; Ziele 44 px, kein Skript.
- Eigene SVG-Symbole statt Unicode-Zeichen - ein Telefon ersetzt die
  sonst durch Emoji in fremdem Stil. Zwei Eintraege trugen dabei
  denselben Pfad; ein Test findet beides (ADR 067).
- Update aus dem Releasearchiv: der Weg fuer Installationen ohne Git.
  Zwei Schritte, weil ein Prozess, der sich selbst ausgetauscht hat,
  mit dem alten Code weiterarbeitet. var/, .env, hochgeladene
  Schriften und das erzeugte Buendel bleiben unberuehrt; public/ wird
  dazu Datei fuer Datei zusammengefuehrt. Geprueft ohne Netz gegen
  eine Releaseseite im Speicher (ADR 068).

Beim Nachmessen der echten Startseite statt einer Testseite: das
groesste Element ueber der Falz ist dort kein <img>, sondern der
Hintergrund des ersten Abschnitts - und der steht nur im Stylesheet.
Drei Vorabverweise mit denselben Geraetegrenzen wie der Compiler holen
genau die Variante, die die Regel auch nimmt. LCP 1,3 s -> 1,1 s.
Dabei gefunden: dasselbe Bild wurde je Geraeteregel neu abgefragt -
Startseite 19 -> 15 Abfragen (ADR 069).

Geprueft: 428 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und
Linkpruefung, Lighthouse auf drei echten Seiten (alles ausser der
Leistung der Startseite 100).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:01:32 +02:00
71 changed files with 7066 additions and 175 deletions

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
- run: php bin/console cms:seed-demo
- run: php bin/console cms:seed-demo
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
- run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container
- run: composer build
browser:
runs-on: ubuntu-latest
+31 -2
View File
@@ -42,7 +42,17 @@ Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern u
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
### Platz und Blockgestaltung im Editor (1.6.1)
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
## Produktionsmodus lokal prüfen
@@ -104,6 +114,25 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
## Wiederkehrende Aufgaben
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
Vier Auslöser, in aufsteigender Verlässlichkeit:
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an.
3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/<Schlüssel>`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten.
4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute:
```
* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run
```
`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft.
**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab.
## PHP-Webhosting
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
@@ -118,4 +147,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
+1 -1
View File
@@ -1 +1 @@
1.4.2
1.6.2
+28
View File
@@ -15,6 +15,12 @@ services:
_defaults:
autowire: true
autoconfigure: true
# Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task
# implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine
# neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt.
_instanceof:
App\Ops\Tasks\Task:
tags: ['kant.task']
App\:
resource: '../src/'
exclude: '../src/{Kernel.php,Domain/}'
@@ -62,6 +68,9 @@ services:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
$environment: '%kernel.environment%'
App\Ops\ArchiveUpdate:
arguments:
$root: '%kernel.project_dir%'
App\Ops\Updater:
arguments:
$root: '%kernel.project_dir%'
@@ -83,6 +92,25 @@ services:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
# jedes Mal mitwaechst.
App\Ops\Scheduler:
arguments:
$tasks: !tagged_iterator kant.task
$projectDir: '%kernel.project_dir%'
App\Ops\Tasks\CleanupTask:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\TaskWorkerSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
App\Controller\TaskController:
arguments:
$secret: '%kernel.secret%'
App\Controller\CronController:
arguments:
$secret: '%kernel.secret%'
App\Infrastructure\QueryCounter: ~
App\EventSubscriber\QueryHeaderSubscriber:
arguments:
+142 -2
View File
@@ -2,6 +2,28 @@
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte `maxWidth`-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. `auto` beziehungsweise `none` sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
## ADR 001 – PHP-Webhosting, Symfony 7.4
Umut hat im laufenden Auftrag ausdrücklich herkömmliches PHP-Webhosting gewählt. Deshalb PHP statt des zunächst aus dem Ordnernamen vermuteten Django-Stacks. Symfony 7.4 LTS benötigt PHP ab 8.2 und bringt Routing, Dependency Injection, Konfiguration und später anschließbare Sicherheitskomponenten mit. Twig maskiert Inhalte standardmäßig. Doctrine DBAL und Migrations übernehmen Datenbankzugriff und Migrationen. Keine eigene Authentifizierung erfinden: vor HTTP-Schreibzugriffen Symfony Security, CSRF-Schutz und serverseitige Rollen ergänzen.
@@ -281,7 +303,7 @@ Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `re
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
**Geplante Veröffentlichung** setzt `publish_at`. Die Aufgabe `publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – bleibt die Seite ein Entwurf, die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
@@ -438,7 +460,125 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
**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.
**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; im Normalbetrieb erledigt das die Aufgabe `stats-prune` des Plans (ADR 073).
## ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026)
Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern **läuft nie**: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst.
**Die Entscheidung: Was ein Hosting immer hat, sind Anfragen.** Also arbeitet eine Anfrage die Aufgaben ab – in `kernel.terminate`, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort `fastcgi_finish_request()` auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (`wp-cron`) und Shopware gehen, und aus demselben Grund.
Vier Auslöser, eine Mechanik:
| Auslöser | Wer stößt an | Grenzen je Durchlauf |
| --- | --- | --- |
| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s |
| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s |
| URL-Cron | der Hoster ruft `/cms-cron/<Schlüssel>` | 20 Aufgaben, 240 s |
| Konsole | `cms:tasks:run`, auch mit `--loop` | frei |
**Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten:** Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht.
Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt:
**1. Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem `file_get_contents` fertig. Fehlt die Datei – frische Installation, geleertes `var/`, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes `var/` macht das System langsamer, aber nicht falsch.
**2. Zwei Besucher führen eine Aufgabe nicht zweimal aus.** Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt)`. Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine **Tabelle** und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen.
**3. Eine Aufgabe darf niemals die Website mitreißen.** Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen *kann*, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts.
**Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist.** Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein `fastcgi_finish_request()` gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das.
**Der Schlüssel des URL-Cronjobs steht in keiner Datenbank.** Er wird aus `APP_SECRET` abgeleitet (`hash_hmac`, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt.
**Eine neue Aufgabe ist eine Datei.** Wer `App\Ops\Tasks\Task` implementiert, bekommt über `_instanceof` ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich).
## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: **Jeder einzelne war auf meinem Rechner unsichtbar.**
**1. `git` installiert ist nicht dasselbe wie „Arbeitskopie".** `Updater::state()` gab unter dem Schlüssel `git` zurück, ob der *Befehl* `git` aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: `fatal: Not a git repository`. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt `worktree` und wird getrennt von `git` erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
**2. `VACUUM INTO` gibt es erst seit SQLite 3.27.** Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – `PRAGMA wal_checkpoint`, `BEGIN IMMEDIATE`, kopieren, `COMMIT` –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
**3. Ein unerwarteter Fehler darf keine 500-Seite sein.** Der Sicherungsknopf fing `InvalidPage` ab, also die *erwarteten* Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
**Was ich daraus mitnehme:** Jeder dieser Fehler war eine *Umgebungsdifferenz*, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung **niemals** mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
## ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine **500-Seite** bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
```
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
```
Der Updater über Git nennt einen Schritt `step`, der neue über das Archiv nannte ihn `label`, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest `step`. **Das Update lief vollständig durch** – alle neun Schritte in Ordnung, Dateien getauscht, `var/` und `.env` unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
**Warum kein Test das gefunden hat.** Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das `og:image`, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). **Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht.** Der Test dazu rendert jetzt `/admin/system` mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
Der Schlüssel heißt jetzt in beiden Updatern `step`. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
**Und der zweite Teil von Umuts Satz.** Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
## ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
**Erstens: Es gab keinen Weg, Migrationen im Browser auszuführen.** Wer die Dateien über FTP hochlädt – und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt –, hat danach neuen Code und eine alte Datenbank. Dafür gab es nur die Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm „System" zeigt jetzt eine Karte, **sobald etwas aussteht**, nennt die Namen der Migrationen und führt sie auf Knopfdruck aus. Das Lesen der Liste ist gekapselt: Eine Datenbank, die die Migrationstabelle noch nicht hat, darf nicht der Grund sein, dass dieser Bildschirm gar nicht mehr erscheint.
**Zweitens: „Die Releaseseite antwortet nicht" war drei Fehler in einem Satz.** Beim Gegenprüfen des frischen Releases holte der Updater von diesem Rechner gar nichts – nicht weil die Leitung zu wäre, sondern weil diesem PHP ein **Zertifikatsspeicher** fehlt (`curl.cainfo` leer). Auf einem Webhosting ist das der häufigste der drei Gründe und der einzige, den man in einer Minute behebt; er muss also dastehen. `Download` merkt sich seither, woran es lag, und der Schritt in der Verwaltung trägt den Grund mit.
**Und daraus folgte gleich noch etwas:** Bei genau diesem Fehler lohnt der zweite Weg. Der Dateizeiger benutzt die Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet – auf diesem Rechner **funktioniert der Abruf dadurch jetzt**. Was nicht passiert, ist das Naheliegende: Die Prüfung wird nicht abgeschaltet. Ein Update, das ohne Zertifikatsprüfung geladen wird, kann unterwegs ausgetauscht worden sein, und dann installiert der Knopf fremden Code.
## ADR 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: **Leistung 96 (Desktop) und 98 (Mobil)**, alles andere 100. Der Grund stand im Bericht: `LCP request discovery` – das größte Element über der Falz ist dort kein `<img>`, sondern der **Hintergrund des ersten Abschnitts**.
Das ist ein struktureller Unterschied, kein Zufall: Ein `<img>` steht im HTML, der Browser sieht es beim ersten Durchlesen und beginnt zu laden. Ein Hintergrundbild steht im Stylesheet – der Browser erfährt erst von ihm, wenn er das CSS geholt und geparst hat. Die Regel aus ADR 039 („das erste Bild einer Seite lädt sofort") griff genau deshalb nicht: Sie sucht nach einem Bildbaustein, und es gab keinen.
**Drei Vorabverweise mit Medienabfragen, nicht ein `imagesrcset`.** Das war die einzige Entscheidung, die wirklich eine war. Ein `srcset` wählt nach Breite, die Regeln des Compilers wählen nach Gerät – bei einem 1200 px breiten Fenster nähme das eine die 1600er Datei und das andere die 1920er, und der Browser lüde **zwei** Bilder statt einem. Dann kostet das Vorabholen mehr, als es bringt – genau der Fehler, der in diesem Projekt schon einmal fünf Punkte gekostet hat. Die drei Verweise tragen deshalb dieselben Grenzen wie der Compiler (`StyleCompiler::PRELOAD_DEVICES`), und die Variantenwahl ist eine einzige öffentliche Methode, die beide Seiten benutzen. Der Test vergleicht nicht nur, dass Verweise da sind, sondern dass **jeder Verweis dieselbe Datei meint wie die Regel**.
**Gemessen danach: Desktop 97, Mobil 99**, LCP 1,3 s → 1,1 s. Kein Sprung auf 100 – was bleibt, ist das blockierende Stylesheet (300 ms FCP), und das ist zum großen Teil der Entwicklungsserver ohne Kompression, siehe `docs/QUALITY.md`.
**Und ein Nebenfund, der mehr brachte als der Hauptpunkt.** Der Kopf fragte die Mediathek nach demselben Bild, das der Compiler ohnehin schon dreimal gesucht hatte – je Gerät eine Regel, je Regel eine Abfrage. `MediaLibrary::find()` merkt sich jetzt, wonach es in dieser Anfrage schon gefragt wurde: **Startseite 19 → 15 Abfragen**, und zwar einschließlich der Abfrage, die der Vorabverweis neu gebracht hat. Der Merkzettel lebt nur so lange wie die Anfrage, also kann er nicht veralten; beim Hochladen und Ändern wird er trotzdem geleert, weil derselbe Aufruf danach noch liest.
## ADR 068 – Update aus dem Releasearchiv, in zwei Schritten (13.09.2026)
Umut: „Dann Updater für Hoster optimieren?!" Der Updater aus ADR 035 setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über das Releasearchiv installiert, und die Verwaltung sagte danach „Dieses Verzeichnis ist keine Git-Arbeitskopie". Also kein Update, außer von Hand über FTP. Das ist genau der Fall, für den dieses CMS gedacht ist.
`ArchiveUpdate` geht denselben Weg wie die Erstinstallation: Archiv laden, Prüfsumme vergleichen, entpacken, Dateien ersetzen, migrieren. Drei Unterschiede trennen ein Update von einer Installation, und jeder davon ist eine Entscheidung:
**Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben stehen. Deshalb wird `public/` als einziges Verzeichnis **Datei für Datei zusammengeführt** statt ersetzt: dort liegen die Dateien der Anwendung und die des Betreibers nebeneinander. Ein `rename()` des ganzen Ordners wäre eine Zeile weniger Code und hätte die Schriften gelöscht.
**Der alte Stand wird beiseitegelegt, nicht gelöscht.** Er liegt unter `var/update/<Zeitstempel>/alt/`, bis jemand ihn entfernt. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er schon bewegt hat, und nimmt den Wartungszustand wieder heraus.
**Es sind zwei Knöpfe, und das ist kein Umweg.** Der erste ersetzt die Dateien, der zweite führt die Migrationen aus. Dazwischen liegt eine neue Anfrage – denn ein PHP-Prozess, der sich selbst ausgetauscht hat, arbeitet mit den Klassen weiter, die er beim Start geladen hat. Migrationen im selben Durchgang liefen mit dem Code der alten Fassung. Der Wartungszustand bleibt zwischen beiden Schritten an, und die Oberfläche sagt, dass er es tut.
**Geprüft wird das ohne Netz.** Das Herunterladen ist in `Download` gewandert – eine Klasse, die genau eine Sache tut und die HTTPS-Pflicht an einer einzigen Stelle durchsetzt. Der Test stellt eine Releaseseite in den Speicher und lässt den Rest echt laufen: echte Zip-Archive, echte Prüfsummen, echte Umbenennungen. Die Frage, um die es dabei geht, ist nicht „läuft der Download", sondern: Steht die hochgeladene Schrift danach noch da? Ein Test, der wirklich `git.kant-it.de` abfragt, wäre rot, wenn der Server umzieht, und grün, wenn im Archiv Unsinn steht.
Dazu kommt ein Test, der die Ersetzungsliste des Updaters gegen die Mitnahmeliste von `scripts/build-release.php` hält. Denselben Fehler gab es schon einmal andersherum: `migrations.php` fehlte im Archiv, und es fiel erst auf Umuts Hosting auf.
## ADR 067 – Die Verwaltung auf dem Telefon, und eigene Symbole (13.09.2026)
Umut: „Bitte Backend mobiloptimieren, das Menü ist schlecht." Und kurz darauf: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem Smartphone kommen sonst Emojis."
Zwei Beobachtungen, eine Ursache: Die Verwaltung war für einen breiten Bildschirm gebaut. Zwölf Navigationspunkte nebeneinander ergeben auf 390 px eine Zeile, die seitlich hinausläuft.
**Das Menü ist jetzt ein `<details>`**, das ab 760 px Breite aufgeklappt und darunter zugeklappt startet; die Zusammenfassung nennt den Bereich, in dem man gerade steht. Kein Skript, kein Zustand, der verloren gehen kann – und mit Tastatur bedienbar, weil der Browser das mitbringt. Die Ziele sind 44 px hoch, was keine Gestaltungsfrage ist, sondern die Größe einer Fingerkuppe.
**Die Symbole waren Unicode-Zeichen** (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie aus wie Symbole; ein Telefon ersetzt sie durch **Emoji** – buntes Zahnrad, bunter Briefumschlag, jedes in einem anderen Stil, und nichts davon in der Schrift der Oberfläche. Jetzt sind es zwölf eigene SVG-Pfade in einer Vorlage: eine Linienstärke, `currentColor`, kein zusätzlicher Request, keine Symbolschrift. Ein Test sucht in der Navigationsvorlage nach Zeichen aus den Emoji-Bereichen und hält jeden verwendeten Namen gegen die Sammlung – ein fehlender Name würde sonst still das Ersatzsymbol zeichnen, und zwei Einträge sähen gleich aus. Genau das war der Fall: „Formulare" und „Benutzer" trugen denselben Pfad.
## ADR 066 – Ein Farbfeld, das beides kann (13.09.2026)
Umut: „Wieso kein Colorpicker?" Berechtigte Frage. Die Farbfelder im Designbereich waren Textfelder – wer Grün wollte, musste `#2e7d5b` wissen.
Der Grund dafür war echt: Ein `<input type="color">` kann **nur** eine Farbe. Die Gestaltung dieses CMS kennt aber auch `@akzent` – einen Verweis auf die Palette, der sich ändert, wenn die Palette sich ändert. Ein Farbwähler hätte daraus beim ersten Klick einen festen Wert gemacht und die Verbindung stillschweigend gekappt.
**Das Textfeld bleibt deshalb maßgeblich**, und daneben stehen ein Farbfeld und eine Auswahl der Palettenfarben. Wer im Farbfeld zieht, schreibt einen Hexwert in das Textfeld; wer `@akzent` tippt, behält den Verweis, und das Farbfeld zeigt daneben nur an, wie diese Farbe gerade aussieht. Aufgebaut wird das von einem kleinen Skript über das vorhandene Feld: Ohne JavaScript bleibt das Textfeld übrig und die Seite vollständig bedienbar.
## ADR 056 – Das Vorschaubild beim Teilen, und die robots.txt als Bot-Steuerung (13.09.2026)
+117
View File
@@ -0,0 +1,117 @@
# Abgleich mit dem Flatsome UX Builder
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
| --- | --- |
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
Bedienbarkeit übernehmen und verbessern, die Oberfläche mobil nutzbar machen — der Builder
bleibt dabei Desktop-first.
## 1. Wo KantSites heute schon besser ist
Diese Punkte sind bereits gelöst und dürfen beim Nachbau nicht verschlechtert werden.
| Thema | Flatsome | KantSites heute |
| --- | --- | --- |
| Datenhaltung | Shortcode-Text im `post_content`, per Regex geparst | Validierter Blockbaum mit stabilen IDs, versioniert (v19), Migrationsweg |
| Gestaltung | Feste Optionslisten je Element, wenige Werte je Gerät | 93 freie Eigenschaften, je Block, je Gerät **und** für Hover |
| Öffentliche Ausgabe | jQuery, mehrere Bundles, Slider/Tabs brauchen JS | Tabs/Slider/Fortschritt ohne JavaScript, Editorcode nicht im Frontend |
| Entwurf/Live | Revisionen von WordPress | Entwurf und Veröffentlichung getrennt, Fassungsverlauf, geplante Veröffentlichung, Papierkorb |
| Recht | Plugins nötig | Einwilligung mit echtem Zurückhalten, Statistik ohne Wiedererkennung, Pflichtseiten |
| Eigener Code | `ux_html`-Element | CSS je Element für alle, Skript nur für Administratoren, als eigene Datei |
**Folgerung:** Der Gestaltungsteil muss nicht nachgebaut werden. Zu tun sind der
Elementkatalog und die Bedienung.
## 2. Elementkatalog — Lücke
Flatsome hat 69 Builder-Elemente, davon 12 reine Shop-Elemente (WooCommerce).
KantSites hat 31 Blocktypen.
### Bereits abgedeckt (21)
`section` `row` `col` `title` `text` `ux_image` `button` `ux_gallery` `blog_posts`
`featured_box` `testimonial` `accordion` `accordion_item` `tabgroup` `tab` `contact`
`divider` `gap` `block` `ux_menu` `ux_slider`
### Offen, ohne Shop (20)
| Flatsome | Was es kann | Bewertung |
| --- | --- | --- |
| `ux_banner` | Bild/Video-Hintergrund, Overlay, Höhe, Parallax, Textposition | **hoch** — das meistgenutzte Element überhaupt |
| `text_box` | frei positionierter Textkasten über einem Banner | **hoch** — gehört zu `ux_banner` |
| `ux_stack` | Elemente nebeneinander, mit Abstand und Ausrichtung | **hoch** — löst viele Zeile/Spalte-Verrenkungen |
| `ux_image_box` | Bild über Text als Karte | hoch |
| `col_grid` / `ux_banner_grid` | Rasterlayout mit ungleichen Kacheln | mittel |
| `message_box` | Hinweiskasten mit Symbol und Farbe | mittel |
| `price_table` | Preistabelle | mittel |
| `team_member` | Person mit Bild, Rolle, Sozialsymbolen | mittel |
| `ux_pages` | Unterseiten dieser Seite auflisten | mittel |
| `ux_video` | eingebettetes Video | teilweise über `embed` |
| `video_button` | Video im Leuchtkasten öffnen | mittel |
| `map` | Karte | teilweise über `embed` |
| `follow` | Sozialsymbole zum Folgen | mittel |
| `share` | Teilen-Symbole | niedrig |
| `search` | Suchfeld | mittel |
| `logo` | Logo der Website als Block | hoch (Kopfbereich) |
| `scroll_to` | Ankerziel und Sprunglink | mittel |
| `ux_countdown` | Countdown | niedrig |
| `ux_hotspot` | Punkte auf einem Bild | niedrig |
| `ux_lottie` / `ux_instagram_feed` / `ux_portfolio` | Animation, Instagram, Portfolio | niedrig |
| `ux_the_title` | dynamischer Seitentitel | mittel (Vorlagen) |
### Bewusst ausgelassen (12)
Alle WooCommerce-Elemente (`products`, `ux_products`, `ux_products_list`,
`ux_product_categories`, `ux_product_flip`, `ux_bestseller_products`, `ux_payment_icons`,
`ux_product_gallery`, `woocommerce_cart`, `woocommerce_checkout`, `ux_badge`,
`custom-product`). KantSites hat keinen Shop; ohne Warenkorb, Bestellung und Zahlung
wären diese Blöcke leere Hüllen.
## 3. Bedienung — die eigentliche Lücke
Der Auftrag nennt die Bedienbarkeit ausdrücklich. Hier ist Flatsome heute voraus.
| Flatsome | KantSites heute | Verbesserung |
| --- | --- | --- |
| Element aus der Liste **in die Vorschau ziehen**, mit Einfügemarke | Einfügen „hinter das gewählte Element“ | Ziehen mit sichtbarer Marke, **zusätzlich** die heutige Tastaturbedienung behalten |
| **Werkzeugleiste am Element** in der Vorschau (bearbeiten, duplizieren, löschen, Elternelement) | nur über den Strukturbaum links | Leiste am Element, Auswahl bleibt mit dem Baum verbunden |
| **Pluszeichen zwischen Elementen** | — | Einfügepunkt genau dort, wo geklickt wird |
| Elementwahl als **Bilderraster** mit Suche | Textliste mit Symbolzeichen | Raster mit echten Vorschaubildern, Suche, Kategorien |
| **Vorlagen je Element** mit Vorschaubild (Sektion: 20+) | 4 Textvorlagen gesamt | Vorlagen je Element, mit Bild, erweiterbar |
| **Je Option ein Geräteschalter** | ein globaler Geräteschalter | Schalter direkt an der Option, zeigt wo ein abweichender Wert gesetzt ist |
| **Zahl durch Ziehen ändern** (`scrubfield`) | Eingabefeld | Ziehen plus Eingabe plus Pfeiltasten |
| **Innen-/Außenabstand als Rahmenbild** | vier Felder | Rahmenbild mit verbundenen Seiten |
| **Spaltenbreite in der Vorschau ziehen** | Zahl eingeben | Ziehgriff zwischen Spalten |
| Vorschau **sofort** (clientseitige Vorlagen) | Server-Neuladen, entprellt | Sofortige optimistische Anzeige, Server bleibt die Wahrheit |
| Kopieren/Einfügen zwischen Seiten | — | Zwischenablage für Blöcke, seitenübergreifend |
| Mobil | Builder nicht bedienbar | Verwaltung vollständig mobil; Builder mobil im Lesemodus mit Inhaltsbearbeitung |
## 4. Vorgeschlagene Reihenfolge
1. **Arbeitspaket H — Bedienung des Builders.** Ziehen und Ablegen, Werkzeugleiste am
Element, Einfügepunkte, Bilderraster, optimistische Vorschau. Größter Nutzen je Aufwand,
betrifft jedes vorhandene Element sofort.
2. **Arbeitspaket I — Elementkatalog Stufe 1.** `banner`, `textbox`, `stack`, `imagebox`,
`logo`, `messagebox`. Die Blöcke, ohne die eine Startseite Umwege braucht.
3. **Arbeitspaket J — Optionsbedienung.** Geräteschalter je Option, Ziehfelder,
Abstandsrahmen, Spaltenbreite ziehen, Vorlagen je Element.
4. **Arbeitspaket K — Elementkatalog Stufe 2.** Raster, Preistabelle, Team, Unterseiten,
Video-Leuchtkasten, Suche, Sozialsymbole, Ankersprung.
5. **Arbeitspaket L — Mobile Oberfläche.** Verwaltung und Builder auf dem Telefon.
Jede Stufe endet mit einem prüfbaren Ergebnis und darf die Qualitätsziele aus AGENTS.md
nicht verschlechtern.
+1 -1
View File
@@ -23,7 +23,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten.
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
+27
View File
@@ -34,6 +34,10 @@ DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media"
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
Datenschutzgerüst gleich mit an.
**Die Verwaltung muss nicht `/admin` heißen.** `ADMIN_PATH` in `.env` setzt die Adresse; `php
bin/console cms:admin:path <neu>` prüft, ändert und sagt, welcher Zwischenspeicher danach zu leeren
ist. Bei einer Kundenwebsite lohnt das: `/admin` ist die erste Adresse, die ein Scanner probiert.
Zum Ansehen ein eigener Server auf einem eigenen Port:
```bash
@@ -96,6 +100,10 @@ Dokument nichts einfügen.
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
Jedes Farbfeld hat einen Farbwähler und die Palettenfarben zur Auswahl. **Das Textfeld daneben
bleibt maßgeblich**: wer dort `@accent` stehen lässt, behält den Verweis auf die Palette; wer im
Farbwähler zieht, schreibt einen festen Wert hinein und kappt ihn.
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
hier schon zweimal die Ursache eines „geht nicht".
@@ -115,6 +123,9 @@ falsch – nicht „nur die Vorschau".
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
960er Datei.
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
- „Beim Klick" entscheidet je Bild, ob nichts passiert, ob es sich vergrößert oder ob es zu einer
Adresse führt; eine Galerie hat dafür einen Schalter. Zugrunde liegt ein gewöhnlicher Verweis
auf die große Fassung – ohne JavaScript öffnet ein Klick sie, nichts ist kaputt.
---
@@ -184,6 +195,22 @@ Adressen prüfen.
---
## 11. Updates dieser Installation
Zwei Wege, und welcher gilt, entscheidet das Hosting:
- **Mit Git** (eigener Server, Shell-Zugriff): Bildschirm „System" oder `php bin/console cms:update`.
Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg.
- **Ohne Git** (geteiltes Hosting, über `install.php` installiert): im selben Bildschirm die Karte
„Update aus dem Releasearchiv" – zwei Knöpfe, der zweite führt die Migrationen aus. `var/`, `.env`,
hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt.
Beides steht ausführlich in `docs/UPDATE.md`. Wichtig für den, der eine Website baut: **nichts von
dem, was diese Website ausmacht, liegt im Code**, also überlebt es beide Wege. Wer dafür doch eine
Twig-Datei angefasst hat, verliert die Änderung beim ersten Update – siehe Abschnitt 0.
---
## Was in diesem Projekt immer wieder schiefging
Wer diese fünf kennt, spart sich die Suche:
+14
View File
@@ -1,5 +1,19 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
- **395 PHP-Tests / 1672 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, Beitrag.
+328 -2
View File
@@ -1,17 +1,45 @@
# Projektstatus
Stand: 13.09.2026, Fassung **1.4.0**. 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.
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
**Geprüft:** `npm.cmd test`: 30/30; PHP 8.4.22 `vendor/bin/phpunit`: 452/452, 3476 Assertions. JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig; Produktionscache erfolgreich aufgewärmt, `git diff --check` sauber. `npm.cmd run test:editor -- --audit` vollständig bestanden: Seitenebenen/Undo/Redo, Panelbreiten per Maus/Tastatur und nach Neuladen, 390/850/1100/1920-px-Fenster, kontextbezogene Eigenschaften und bewusst gesetzte Textbreiten; auch Speichern, Neuladen, Veröffentlichen und Konfliktbehandlung. Sämtliche enthaltenen axe-Prüfungen ohne Verstoß. Desktop-, Bild- und Telefonansicht anhand der Browser-Screenshots visuell geprüft. Lighthouse **13.4.1**, isolierte öffentliche Editortestseite im Produktionsmodus: alle fünf Kategorien **100**, jeweils drei Läufe mobil und Desktop, auch jeder Einzellauf 100. Kein Nachweis für beliebige Kundeninhalte oder Feldwerte. Berichte: `reports/editor/result.json`, `reports/lighthouse-editor/summary.json`; Ansichten: `reports/editor/flexible-workspace.png`, `context-image.png`, `editor-phone.png`.
**Bekannte Grenzen:** Die geänderten Standardbreiten können den Zeilenumbruch bestehender Seiten verändern. Sektions- und ausdrücklich gespeicherte Elementbreiten bleiben wirksam. Kein neuer Screenreader- oder Felddatennachweis. **Nächster Schritt:** Umuts tatsächliche Editorabläufe mit realen Seiten durchgehen, danach den verbliebenen Ausbau gemäß Entwicklungsplan fortsetzen.
## Vorheriger Stand und bisherige Arbeitspakete
Stand: 14.09.2026, Fassung **1.6.0**. 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.
**Für den Livegang vorbereitet (13.09.2026):** `docs/LIVEGANG.md` trennt, was geprüft ist, von dem, was nur auf dem echten Hosting geprüft werden kann, und nennt die Reihenfolge am Umzugstag. **14 Weiterleitungen** von den alten WordPress-Adressen auf die neuen sind angelegt und beantwortet (`/productsolutions` → `/projekte`, die gekürzten Beitragsadressen, `/category/allgemein`); ohne sie hätte jede bei Google stehende Adresse nach dem Umzug mit 404 geantwortet. In der Datenschutzerklärung wurde der Abschnitt über Google Fonts an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server) und die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist – eine Erklärung, die Datenflüsse beschreibt, die es nicht gibt, ist so falsch wie eine, die welche verschweigt. **Nicht geprüft und nicht prüfbar von hier: der E-Mail-Versand.**
**Verwaltung und Updateweg (1.4.3, 13.09.2026):** Die Farbfelder im Designbereich haben einen **Farbwähler** samt Palettenauswahl; das Textfeld bleibt maßgeblich, damit `@akzent` ein Verweis bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). Die Verwaltung ist **auf dem Telefon bedienbar**: das Menü ist unter 760 px ein zugeklapptes `<details>`, das den aktuellen Bereich nennt, mit 44 px hohen Zielen – ohne Skript. Die zwölf Navigationssymbole sind **eigene SVG-Pfade** statt Unicode-Zeichen, die ein Telefon zu bunten Emoji macht (ADR 067). Und es gibt ein **Update ohne Git**: `ArchiveUpdate` lädt das Releasearchiv, vergleicht die Prüfsumme, sichert, ersetzt die Dateien und führt die Migrationen in einer zweiten Anfrage aus – der einzige Updateweg auf geteiltem Hosting (ADR 068, `docs/UPDATE.md`).
**Nachbau nachgezogen (13.09.2026):** Der Fußbereich ist jetzt vierspaltig wie im Original – Zeichen und Satz, „Leistungen", „Unternehmen", „Kontakt", darunter die Zeile mit Copyright. Die beiden Linklisten sind **Menüs**, keine Dokumentinhalte: sie lassen sich in der Verwaltung pflegen. Alle **29 Beiträge haben jetzt Text** – die elf fehlenden sind neu geschrieben, denn auch im Original stehen dort nur Bildergalerien ohne Beschreibung. Die Karte „Bereit für den nächsten Schritt" hat 34 px Abstand zu den Kontaktzeilen darunter. **Nicht erledigt: das TeamViewer-Logo** im Fernwartungsbereich – dazu unten.
**Vom Original übernommen (13.09.2026):** die Meta-Beschreibung der Startseite im Wortlaut von kant-it.de, das Symbol der Website (dort 192 × 192) und die Bot-Regeln der robots.txt. Titel, Canonical, Open Graph, strukturierte Daten (`LocalBusiness` statt Yoasts `ProfessionalService`), Sitemap, robots.txt und `llms.txt` entstehen in KantSites aus dem Inhalt und stimmen mit dem Original überein. **Beim Abgleich aufgefallen:** das `og:image` des echten kant-it.de zeigt auf `wp-content/uploads/kant-it-share.png` – die Datei antwortet mit 404, beim Teilen erscheint dort also kein Bild.
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
**Wiederkehrende Aufgaben ohne Cronjob (14.09.2026, ADR 073):** Bis hierher stand in vier Dokumenten „gehört in einen Cronjob“ – auf geteiltem Hosting oft nicht einlösbar, und die Folge war nicht „läuft seltener“, sondern **läuft nie**. Jetzt gibt es einen Aufgabenplan mit vier Auslösern: **jeder Seitenaufruf** (nach der ausgelieferten Antwort, in `kernel.terminate`), das **geöffnete Backend** (im Minutentakt, nur solange der Reiter sichtbar ist), ein **URL-Cronjob** unter `/cms-cron/<Schlüssel>` für Hoster, die nur eine Adresse aufrufen können, und die **Konsole** (`cms:tasks:run`, auch als Dauerläufer mit `--loop`). Fünf Aufgaben sind angeschlossen: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). Der Bildschirm **Aufgaben** zeigt Plan, letztes Ergebnis, Dauer, Auslöser und Protokoll, schaltet jede Aufgabe einzeln und ändert ihren Abstand. **Die Einschränkung steht auf diesem Bildschirm:** ohne Besuch und ohne geöffnetes Backend passiert nichts – eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr.
## Erledigt
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
@@ -34,9 +62,21 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- 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.
- **Aufgabenplan ohne Cronjob** (ADR 073, Migration `20260914000100`): eine Zeile je Aufgabe mit Fälligkeit, Sperre und letztem Ergebnis. Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE` – auf MySQL wie auf SQLite atomar, damit zwei gleichzeitige Besucher eine Aufgabe nicht zweimal starten; die Sperre verfällt nach 15 Minuten von selbst. Der Normalfall kostet **keine Datenbankabfrage**: in `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht. Ein Fehlschlag ist ein sichtbarer Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden), und eine scheiternde Aufgabe reißt nie eine Seite mit. Neue Aufgaben sind eine Datei: wer `App\Ops\Tasks\Task` implementiert, steht beim nächsten Abgleich im Plan. Befehle: `cms:tasks`, `cms:tasks:run` (`--task`, `--limit`, `--budget`, `--loop`, `--max-time`).
- **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.
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
- **Update aus dem Releasearchiv** (1.4.3, ADR 068): der Weg für Installationen ohne Git – bisher stand dort nur „Dieses Verzeichnis ist keine Git-Arbeitskopie". Zwei Knöpfe im Bildschirm „System": der erste lädt, prüft die Prüfsumme, sichert und ersetzt die Dateien, der zweite führt die Migrationen aus – in einer eigenen Anfrage, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. `var/`, `.env`, hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt, `public/` wird dazu Datei für Datei zusammengeführt. Der alte Stand liegt danach unter `var/update/`. Geprüft mit acht Tests gegen eine Releaseseite im Speicher (`tests/ArchiveUpdateTest.php`), einer davon hält die Ersetzungsliste gegen die Mitnahmeliste des Releaseskripts.
- **Eigene Symbole in der Verwaltung** (1.4.3, ADR 067): zwölf SVG-Pfade statt Unicode-Zeichen, die auf dem Telefon als Emoji in fremdem Stil erscheinen. Ein Test findet Emoji-Zeichen in der Navigationsvorlage und zwei Einträge mit demselben Pfad – beides war vorhanden.
- **Verwaltung auf dem Telefon** (1.4.3, ADR 067): das Menü klappt unter 760 px zu und nennt in der Zusammenfassung den Bereich, in dem man steht; Ziele 44 px hoch, kein Skript, kein seitliches Scrollen. Geprüft bei 390 px.
- **Farbwähler im Designbereich** (1.4.3, ADR 066): Farbfeld und Palettenauswahl neben dem Textfeld, das maßgeblich bleibt – ein `<input type="color">` allein hätte `@akzent` in einen festen Wert verwandelt und die Verbindung zur Palette gekappt. Ohne JavaScript bleibt das Textfeld.
- **Behoben in 1.4.2: die Systemprüfung nennt jetzt die SQLite-Fassung.** Auf dem Hosting scheiterte die Installation im dritten Schritt mit „near \"(\": syntax error" – dort läuft SQLite in einer Fassung vor 3.16, und Doctrine liest das Schema mit Tabellenfunktionen (`pragma_table_info(...)`), die es erst seit 3.16 gibt. Die Prüfung nennt die gefundene Fassung vor der Wahl der Datenbank, und der Datenbankschritt bietet SQLite gar nicht erst an, wenn sie zu alt ist – mit dem Hinweis auf MySQL. Eine Voraussetzung, die erst mitten in der Installation auffällt, ist keine Prüfung wert.
- **Behoben in 1.4.1: dem Releasearchiv fehlten `migrations.php` und `cli-config.php`.** Auf dem Hosting brach die Installation deshalb im dritten Schritt ab – „The configuration file does not exist", genau dort, wo die Tabellen entstehen sollten. Die beiden Dateien standen weder in der Mitnahme- noch in der Ausschlussliste des Releaseskripts, also fiel niemandem auf, dass sie fehlen. Ein Test ordnet jetzt **jeden** Eintrag im Projektstamm einer der beiden Listen zu; was neu dazukommt und unbedacht bleibt, lässt ihn fehlschlagen. Der Installationsweg wurde anschließend vollständig durchgespielt: 3504 Dateien entpackt, 14 Migrationen ausgeführt, 15 Tabellen angelegt.
- **Fassung 1.4.0 ist veröffentlicht** und der Installationsweg einmal vollständig durchgespielt: `install.php` aus dem Release geladen (anonym, ohne Anmeldung), in ein leeres Verzeichnis gelegt, aufgerufen – es lädt `kantsites-1.4.0.zip` (4,7 MB, 3502 Dateien mit `vendor/`), prüft die SHA-256-Summe, entpackt und löscht sich selbst. Die frische Installation antwortet mit der Systemprüfung unter `/install`. **Das Repository ist dafür öffentlich** – ein privates liefert die Downloadadresse ohne Anmeldung nicht aus.
@@ -128,6 +168,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
- **Aufgabenplan geprüft** (14.09.2026): 12 Tests auf die Mechanik (nur Fälliges läuft; eine gesperrte Aufgabe wird übergangen, eine abgelaufene Sperre übernommen; ein Fehlschlag wird festgehalten und der nächste Versuch rückt weiter weg; eine abgeschaltete Aufgabe läuft nicht, von Hand gestartet doch; eine Anfrage arbeitet nicht alles auf einmal ab; eine fehlende Tabelle ist kein Fehler) und 5 über HTTP (der Bildschirm nennt jede Aufgabe und die Adresse des URL-Cronjobs; anonym führt niemand etwas aus, eine Redakteurin bekommt 403, ohne gültiges Formular passiert nichts; der Takt des Backends antwortet mit JSON; ein falscher Cron-Schlüssel bekommt 404). Von Hand auf der Entwicklungsinstallation **alle vier Auslöser ausgeführt**: alle fünf Aufgaben über die Konsole (Sicherung 36 ms, Updateprüfung 318 ms gegen das echte Repository), ein Seitenaufruf hat eine fällige Aufgabe abgearbeitet (Auslöser `besuch` im Protokoll), das geöffnete Backend hat getaktet (`POST /admin/aufgaben/takt` → 200), und der URL-Cronjob hat mit dem richtigen Schlüssel Text geliefert und mit einem falschen 404. Der Bildschirm wurde im Browser bedient (Abstand und Schalter gespeichert und wieder zurückgesetzt); dabei fiel eine Inline-Stilangabe auf, die die CSP der Verwaltung blockiert hat – sie ist jetzt eine Klasse.
- Ladeverhalten geprüft: drei Tests – 30 Fassungen ergeben zwei Seiten mit richtiger Reihenfolge und richtigem Rest, eine Seite jenseits des Endes zeigt die letzte statt einer leeren; das erste Bild trägt `loading="eager"` und `fetchpriority="high"`, das zweite `lazy`; und die Startseite bleibt unter zwölf Abfragen. Dazu das Ladebudget in der Browserprüfung.
- **Wiederherstellung nachgewiesen**, nicht nur gebaut – das war der älteste offene Punkt aus Arbeitspaket I. Acht Tests gegen eine wegwerfbare Installation, darunter der eigentliche: Sicherung anlegen, Seite und Bilddatei löschen, zurückspielen, beides ist wieder da. Dazu von Hand auf der Entwicklungsinstallation: Testseite angelegt (5 Seiten), gesichert, gelöscht (4), zurückgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Ebenfalls geprüft: eine manipulierte Sicherung wird abgewiesen, eine aus einer neueren Fassung nur mit `--force`, Rotation behält die neuesten, und eine nach der Sicherung hochgeladene Datei überlebt die Wiederherstellung.
- Seitenbereiche geprüft: Standard, anderer Bereich, „keiner“ (dann steht weder `site-header` noch `site-footer` im HTML, die Seite selbst aber schon), ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, eine veraltete Fassungsnummer und eine ungültige Adresse werden abgewiesen. Menüarten: drei angenommen, eine erfundene abgewiesen, Burger als `details` – und die Seite behält `script-src 'none'`. Seitenbreite: die Regel in `site.css` nimmt den Theme-Wert, und ein größerer Wert erscheint als `--k-container` im Seiten-Stylesheet.
@@ -179,11 +220,14 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- v3-Galerieseite mit echten optimierten Testbildern und Effekten: sechs Lighthouse-Läufe, alle fünf Kategorien jeweils 100; LCP-Median mobil 937 ms/Desktop 279 ms, CLS/TBT 0. Upload, Metadaten, Mehrfachauswahl per Tastatur, Bildbeschriftung, Sortier-Undo, Archivieren/Wiederherstellen, Bewegungsreduktion und öffentliche Bilder im isolierten Browserablauf bestanden. Keine axe-Verstöße in Desktop-/Mobil-Mediathek; Screenshots geprüft. Berichte: `docs/quality/editor-media-browser.json` und `editor-media-lighthouse.json`.
- Keine bekannten Dependency-Advisories im ausgeführten Composer-Audit/npm-Installationsaudit.
**Gemessen am Nachbau selbst (13.09.2026, Fassung 1.4.3):** je drei mobile und drei Desktop-Läufe auf `/`, `/projekte` und `/impressum` im Produktionsmodus, Median. Barrierefreiheit, Best Practices, SEO und agentisches Browsen überall **100**. Leistung: `/projekte` und `/impressum` **100/100**, die Startseite **99 mobil / 97 Desktop** – dort bleibt das blockierende Stylesheet (rund 300 ms erste Anzeige) das Letzte, und das ist zu einem guten Teil der Entwicklungsserver ohne Kompression und Cache-Kopfzeilen. Bericht: `docs/quality/kantit-lighthouse.json`. **Auf dem echten Hosting nachmessen** – dort greifen die Regeln aus `public/.htaccess`.
## Bekannte Einschränkungen
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): die Neugestaltung der Verwaltung.
- Eigenes CSS kennt keine Gerätevarianten – dafür sind die Vorschaugrößen im Designbereich da. Eigenes Skript hat keine Fassungsverwaltung außer der Seitenhistorie und keine Prüfung auf Syntaxfehler; ein fehlerhaftes Skript schreibt in die Browserkonsole und lässt die Seite sonst unangetastet.
@@ -205,9 +249,291 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung.
- Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert.
## Arbeitspaket H, Teil 1: die Vorschau wird zur Arbeitsfläche
**Erledigt.** Der Abgleich mit dem Flatsome UX Builder steht in `docs/FLATSOME-ABGLEICH.md`:
die Gestaltung ist Flatsome bereits voraus (93 freie Eigenschaften je Gerät und für Hover gegen
feste Optionslisten), die Lücken liegen beim Elementkatalog (20 offene Elemente ohne Shop) und
bei der Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.
Umgesetzt ist die erste Hälfte der Bedienung:
- **Werkzeugleiste am Element** in der Vorschau: Name des Elements, Elternelement auswählen,
nach oben, nach unten, duplizieren, löschen. Sie trägt den Namen des Elements
(„Sektion duplizieren“) statt des nackten Verbs – die Seitenleiste hat dieselben fünf
Aktionen, und zwei gleichnamige Bedienelemente auf einem Bildschirm sind für eine
Sprachausgabe nicht unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
- **Auswahl- und Überfahrrahmen** mit Beschriftung direkt in der Vorschau.
- **Einfügepunkte**: ein Pluszeichen an der Ober- und Unterkante des gewählten Elements setzt
eine Marke; das nächste Element aus dem Werkzeugkasten landet genau dort statt am Ende.
- **Ziehen aus dem Werkzeugkasten in die Vorschau**, mit Ablegemarke zwischen den Elementen und
Rahmen um einen Behälter, der das Element aufnimmt. Der Strukturbaum zieht in dieselbe Fläche.
- Im Modell dafür neu: `insertBlockAt`, `moveBlockTo` und `dropList` – punktgenaues Einfügen
und Verschieben statt „hinter das Gewählte“.
Die Ebene liegt im Editordokument über dem Rahmen, nicht darin: die Vorschau wird bei jeder
Änderung vollständig ersetzt, und eingezeichnete Bedienelemente wären damit weg und stünden
außerdem in der Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Sie trägt denselben
Skalierungsfaktor wie der Rahmen; Leiste und Pluszeichen werden gegenskaliert und bleiben in der
Tablet- und Mobilvorschau fingergroß.
**Geprüft.** `npm test` 22 von 22 (vier neue Tests für Einfügen, Verschieben, Behälterregeln und
die oberste Ebene). `php vendor/bin/phpunit` 434 von 434. `npm run test:editor` vollständig,
mit einem neuen Abschnitt für Werkzeugleiste, Einfügepunkt und Ziehen; alle axe-Prüfungen ohne
Verstoß, auch die mobile Editoransicht. Screenshot unter `reports/editor/canvas-overlay.png`.
**Dabei gefunden und behoben:** die ganze Ebene war zunächst `aria-hidden` und enthielt
fokussierbare Schaltflächen – axe hat das gemeldet. `canContain` las `block.type` an einem
fehlenden Block und zerbrach beim Ziehen aus dem Werkzeugkasten, wo der Block noch nicht
existiert. Beim Verschieben ans Listenende wurde der Index begrenzt, bevor die Entnahme
verrechnet war; der Block landete eine Stelle zu früh.
**Bekannte Grenzen.** Die Vorschau wird weiterhin nach 250 ms serverseitig neu erzeugt – das
Ablegen fühlt sich dadurch nicht so unmittelbar an wie bei Flatsome, die Wahrheit bleibt aber
der Server. Die Werkzeugleiste ist nicht mit einem Screenreader erprobt, nur mit axe. Spalten
lassen sich noch nicht in der Vorschau breiter ziehen, der Werkzeugkasten ist weiterhin eine
Textliste ohne Vorschaubilder, und es gibt je Option noch keinen eigenen Geräteschalter.
## Arbeitspaket H, Teil 2: der Werkzeugkasten kennt den Zusammenhang
**Erledigt.** Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als wären sie
Seitenanfänge. Sie gehören in den Inhalt – erst eine Sektion, dann eine Zeile darin. Das war
nicht nur eine Anzeigefrage: eine Zeile nahm bisher **jedes** Kind an, eine Überschrift konnte
neben den Spalten landen, und eine Spalte durfte überall stehen. Die Struktur, die der Editor in
seinem eigenen Hinweistext beschreibt – Sektion → Zeile → Spalte → Inhalt –, galt für die
Schaltflächen nicht.
Übernommen ist Flatsomes Regelmodell, weil es die Fälle abdeckt und weil es das ist, was Umut in
den Händen hat (`inc/builder/core/server/src/Collections/Elements.php`):
| Regel | Flatsome | KantSites jetzt |
| --- | --- | --- |
| `allow` | `row => ['col']`, `accordion => ['accordion-item']` | `ALLOW` – Zeile nimmt nur Spalten |
| `require` | `col => ['row']`, `tab => ['tabgroup']` | `REQUIRE` – Spalte lebt nur in einer Zeile |
| — | (Flatsome kennt das nicht) | `ROOT_NEEDS_CONTAINER` – eine Zeile ist kein Seitenanfang |
Der dritte Punkt ist der Zusatz: Flatsome lässt eine Zeile auch lose am Dokumentanfang zu. Genau
das ist das „aus dem Nichts“, das Umut stört, also wird es unterbunden – in jedem Behälter
dagegen bleibt die Zeile erlaubt, auch in einem Tab oder einem Akkordeon-Eintrag.
Daraus folgt die Bedienung:
- **Der Werkzeugkasten ist nach Aufbau gruppiert** – Aufbau, Inhalt, Bausteine, Gruppen, Seite
und Daten – statt einer flachen Liste aus 31 Typen.
- **Was an der gewählten Stelle nicht hingehört, ist gesperrt und sagt beim Überfahren warum**
(„Spalte: gehört in Zeile. Wähle dort zuerst ein Element aus.“). Eine Regel, die man erklärt
bekommt, lernt man; eine Schaltfläche, die nur nicht mehr reagiert, drückt man weiter.
- **Was möglich ist, sagt wohin es kommt** („Wird in Zeile eingefügt.“).
- **Die Zeile wird nach Spaltenzahl angeboten** (2, 3, 4) und bringt ihre Spalten mit. Der
separate Zeilenbereich über der Liste ist entfallen.
- **Eine Regel für alle Wege:** `insertionTarget` beantwortet „wohin käme das?“, ohne etwas
einzufügen. Knopf, Einfügepunkt, Ziehen und die Sperrlogik des Werkzeugkastens nutzen
dieselbe Funktion – vorher entschied `insertBlock` allein, niemand konnte vorher fragen, und
`insertRow` hatte eine eigene zweite Kopie derselben Platzierungslogik.
Ein Element, das der gewählte Behälter nicht nimmt, wird **nicht** gesperrt, sondern steigt auf,
bis ein Elternteil es annimmt: eine Überschrift bei gewählter Zeile landet hinter der Zeile in
der Sektion. Die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
**Nebenbei behoben:** `postnav` fehlte seit Schema v18 in `labels` und `icons` – der
Strukturbaum zeigte für die Beitragsnavigation „Unbekannt“ und ein Fragezeichen.
**Geprüft.** `npm test` 27 von 27 (fünf neue: Zeilen-/Spaltenregel, oberste Ebene, Übereinstimmung
von Auskunft und Tat, Kategorie-Vollständigkeit, Zeile mit Spalten an fester Stelle).
`php vendor/bin/phpunit` 446 von 446. `npm run test:editor` vollständig mit neuem Abschnitt
„the toolbox follows the structure“; alle axe-Prüfungen ohne Verstoß. Screenshot unter
`reports/editor/toolbox.png`.
**Bekannte Grenzen.** Inhalt darf weiterhin lose am Dokumentanfang stehen – der Validator
erlaubt es, und eine weitere Einschränkung wäre erfunden statt übernommen. Bestehende Dokumente
werden nicht nachträglich geprüft: eine Überschrift, die früher direkt in einer Zeile gelandet
ist, wird weiterhin ausgegeben und lässt sich auswählen und löschen, nur nicht mehr dorthin
verschieben. Der Werkzeugkasten hat weiterhin keine Vorschaubilder und keine Suche.
## Arbeitspaket H, Teil 3a: die Optionen selbst
**Erledigt.** Drei Bedienungen, die Flatsome an jeder Option hat und die hier fehlten.
**Geräteschalter je Option.** Bisher entschied ein einziger Schalter über der Vorschau, welches
Gerät das ganze Bedienfeld bearbeitet. „Ist die Überschrift auf dem Telefon kleiner?“ ließ sich
nur durch Umschalten und Hinsehen beantworten. Jetzt trägt jede Option drei Chips (D, T, M): sie
zeigen, **wo ein eigener Wert liegt**, und ein Klick wechselt dorthin. Das ist mehr als Flatsome
bietet – dort schaltet man um, hier sieht man vorher, ob es überhaupt etwas zu sehen gibt.
Bedienfeld und Vorschau wechseln gemeinsam: einen Wert für ein Gerät zu setzen, das man nicht
ansieht, ist blindes Ändern.
**Abstand als Rahmen.** Innenabstand, Außenabstand, Rahmenstärke und Ecken standen als vier
Felder untereinander, beschriftet „Oben, Rechts, Unten, Links“ – man musste lesen, um zu wissen,
welche Kante man setzt. Jetzt sitzt jedes Feld an seiner Kante, die Lage ist die Beschriftung.
In der Mitte verkettet ein Schalter alle vier. Er startet **aus**: er ersetzt den früheren Knopf
„Alle Seiten gleich setzen“, und das war eine bewusste Handlung. Die Einheit bleibt eine Wahl für
die ganze Eigenschaft – vier Einheitenmenüs passen nicht in ein 240 Pixel breites Feld. Seiten
mit unterschiedlichen Einheiten werden als „gemischt“ geführt und bleiben unangetastet, bis eine
Einheit tatsächlich gewählt wird; ein gespeichertes 2rem wird nicht still zu 2px.
**Ziehfelder.** Zahlenfelder lassen sich seitwärts ziehen. „24“ tippen, schauen, „26“ tippen,
schauen, „22“ tippen ist drei Anläufe für eine Entscheidung; Ziehen beantwortet sie in einer
Bewegung. Umschalt grob, Alt fein. Tippen und Pfeiltasten bleiben unverändert. Nur für Maus und
Stift: auf einem Tippgerät ist ein seitliches Ziehen über ein Feld die Art, wie das Bedienfeld
gescrollt wird.
**Dabei gefunden und behoben:** Der Zug war zunächst nach einem Pixel zu Ende – jede Änderung
zeichnete das Bedienfeld neu und ersetzte damit genau das Feld, an dem der Zeiger hing. Das
Bedienfeld bleibt während eines Zuges stehen und wird einmal neu gezeichnet, wenn losgelassen
wird (`keepPanel`). Der Kettenschalter verlor aus demselben Grund seinen Zustand, sobald er
gedrückt wurde; er liegt jetzt außerhalb der Darstellung. Und das Gleichziehen nahm den Wert des
ersten Feldes, auch wenn es leer war – ein Druck auf die Kette löschte damit die Seiten, die
einen Wert hatten. Es nimmt jetzt den ersten vorhandenen Wert.
**Geprüft.** `npm test` 27 von 27. `php vendor/bin/phpunit` 452 von 452. `npm run test:editor`
vollständig mit neuem Abschnitt „device chips, spacing box and scrub fields“: Chips zeigen den
richtigen Zustand und wechseln Bedienfeld und Vorschau zusammen, der Rahmen ändert ohne Kette
nur eine Seite und mit Kette alle vier, die Einheitenumstellung erhält die Zahlen, und ein Zug
über 20 Pixel hebt 72 auf 92 und ist **ein** Undo-Schritt. Alle axe-Prüfungen ohne Verstoß.
Screenshot unter `reports/editor/options.png`.
**Bekannte Grenzen.** Die Chips zeigen einen eigenen Wert, aber nicht, *welchen* – dafür muss
man weiterhin hinwechseln. Auf Tippgeräten gibt es kein Ziehen. Der Rahmen zeigt geerbte Werte
als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vorschaubilder und
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.
## 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
**Der Fehler.** In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation – Marke oben, Fußzeile unten, dazwischen nichts. Kein Weg zu Seiten,
Mediathek, Einstellungen außer über die Adresszeile.
Die Navigation steckt in einem `<details>`: auf dem Telefon ein Klappmenü, am Schreibtisch die
stehende Spalte. Am Schreibtisch wurde der Schalter mit `.shell-menu > summary { display: none }`
ausgeblendet – aber das Element trug kein `open`, und ein geschlossenes `details` versteckt seinen
Inhalt selbst. Schalter weg **und** Inhalt weg.
**Die Behebung.** `::details-content` holt den Inhalt am Schreibtisch zurück, während der
Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht, fällt über `@supports` die ganze
Gruppe weg: dann steht der Schalter da und die Liste ist einen Klick entfernt – statt gar nicht
da. Auf dem Telefon bleibt es beim Klappmenü.
Der Code dafür lag bereits im Arbeitsverzeichnis: eine parallel laufende Sitzung hatte ihn
geschrieben, aber nicht committet, und damit war er in keinem Release. Übernommen ist hier nur
diese Korrektur, nicht die übrige Arbeit jener Sitzung.
**Wie das durchrutschen konnte – und was dagegen getan ist.** Beim vorigen Bericht wurde die
Navigation für in Ordnung erklärt, nachdem eine **Nachbildung** der Regel in einer leeren Seite
geprüft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte Datei nicht; der
Befund war damit wertlos. Jetzt prüft der Browsertest die **echte** Verwaltungsseite: am
Schreibtisch kein Schalter, Navigation sichtbar, der Eintrag „Seiten“ mit messbarer Fläche; auf
390 px Breite ein Schalter, Liste zunächst zu, ein Klick öffnet sie. Gegenprobe gemacht – gegen
die ausgelieferte `admin.css` schlägt der Test fehl.
**Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28,
`php vendor/bin/phpunit` 452 von 452.
## 1.6.0: der Aufgabenplan wird veröffentlicht
Diese Fassung bringt den **Aufgabenplan ohne Cronjob** (ADR 073, oben beschrieben) in ein
Release. Er entstand in einer parallel laufenden Sitzung und lag bis hierher unveröffentlicht im
Arbeitsverzeichnis – die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut
und enthielten ihn deshalb nicht.
**Vor der Veröffentlichung geprüft, nicht bloß eingepackt:**
- `php vendor/bin/phpunit` 452 von 452, `npm test` 28 von 28, `npm run test:editor` vollständig.
- Die Migration `20260914000100` legt **nur** die Tabelle `scheduled_task` an und nimmt sie beim
Zurückrollen wieder weg. Sie fasst keine bestehenden Daten an – das ist die risikoarme Sorte.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr; der Stand ist ruhig.
**Dabei zwei Fehler behoben:**
- `config/services.yaml` verwendet `!tagged_iterator`. Das ist gültiges Symfony, aber
`php bin/console lint:yaml config` kennt eigene Tags nur mit `--parse-tags` und gab 1 zurück –
der CI-Lauf in `.github/workflows/ci.yml` wäre daran gescheitert. Das Flag ist ergänzt.
- `src/EventSubscriber/AdminHeadersSubscriber.php` (aus 1.5.3) entsprach nicht dem Stil von
`php-cs-fixer`; `composer lint` gab 8 zurück. Behoben, jetzt 0 von 128 Dateien zu ändern.
## Nächster konkreter Schritt
Den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
Vorschaubild und das Ziehen der Spaltenbreite in der Vorschau. Danach
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
Begründung in `docs/FLATSOME-ABGLEICH.md`.
Unverändert offen: den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
+35
View File
@@ -62,6 +62,41 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
## Der zweite Weg: Update aus dem Releasearchiv
Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über `install.php` aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". **`src/Ops/ArchiveUpdate.php` ist der Weg für genau diese Installationen** (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.
Der Ablauf ist derselbe wie oben, nur ohne `git`:
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
7. **Zwischenspeicher leeren.**
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
## Der dritte Weg: von Hand über FTP
Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: **das Update auf die Fassung, die sie mitbringt.** Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.
1. **Sichern.** Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP `var/` und `.env` herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
2. **Archiv holen und auspacken.** `kantsites-<fassung>.zip` von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner `kantsites-<fassung>/`.
3. **Wartungszustand einschalten.** Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
4. **Diese Einträge hochladen und ersetzen:** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
5. **`public/` Datei für Datei** – und **`public/assets/fonts` und `public/assets/build` auslassen**. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
6. **Nicht anfassen:** `var/`, `.env`, `.env.local`. Dort stecken Inhalte, Medien und `APP_SECRET`.
7. **`var/cache` vollständig löschen.** Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
8. **Migrationen ausführen.** Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen `php vendor/bin/doctrine-migrations migrate --no-interaction` aufrufen.
9. **Wartungszustand aus**, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
## Ein Release veröffentlichen
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
+434
View File
@@ -0,0 +1,434 @@
[
{
"path": "/",
"profile": "mobile",
"scores": {
"performance": 0.99,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5208.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/",
"profile": "desktop",
"scores": {
"performance": 0.97,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5277.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/projekte",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5231,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/projekte",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5272.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/impressum",
"profile": "mobile",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "mobile",
"throttling": {
"rttMs": 150,
"throughputKbps": 1638.4,
"requestLatencyMs": 562.5,
"downloadThroughputKbps": 1474.5600000000002,
"uploadThroughputKbps": 675,
"cpuSlowdownMultiplier": 4
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": true,
"width": 412,
"height": 823,
"deviceScaleFactor": 1.75,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5205,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
},
{
"path": "/impressum",
"profile": "desktop",
"scores": {
"performance": 1,
"accessibility": 1,
"best-practices": 1,
"seo": 1,
"agentic-browsing": 1
},
"lighthouseVersion": "13.4.1",
"settings": {
"output": "json",
"maxWaitForFcp": 30000,
"maxWaitForLoad": 45000,
"pauseAfterFcpMs": 1000,
"pauseAfterLoadMs": 1000,
"networkQuietThresholdMs": 1000,
"cpuQuietThresholdMs": 1000,
"formFactor": "desktop",
"throttling": {
"rttMs": 40,
"throughputKbps": 10240,
"requestLatencyMs": 0,
"downloadThroughputKbps": 0,
"uploadThroughputKbps": 0,
"cpuSlowdownMultiplier": 1
},
"throttlingMethod": "simulate",
"screenEmulation": {
"mobile": false,
"width": 1350,
"height": 940,
"deviceScaleFactor": 1,
"disabled": false
},
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
"auditMode": false,
"gatherMode": false,
"clearStorageTypes": [
"file_systems",
"shader_cache",
"service_workers",
"cache_storage"
],
"disableStorageReset": false,
"debugNavigation": false,
"channel": "node",
"usePassiveGathering": false,
"disableFullPageScreenshot": false,
"skipAboutBlank": false,
"blankPage": "about:blank",
"ignoreStatusCode": false,
"locale": "en-US",
"blockedUrlPatterns": null,
"additionalTraceCategories": null,
"extraHeaders": null,
"precomputedLanternData": null,
"onlyAudits": null,
"onlyCategories": null,
"skipAudits": null
},
"environment": {
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
"benchmarkIndex": 5179.5,
"credits": {
"axe-core": "4.13.0"
}
},
"note": "Local prototype only. No customer-site acceptance or field-data claim."
}
]
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260914000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Der Aufgabenplan: eine Zeile je wiederkehrender Aufgabe, mit Faelligkeit, Sperre und letztem Ergebnis.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Warum eine Tabelle und keine Datei: die Sperre. Zwei Besucher koennen im selben
// Moment dieselbe Aufgabe faellig sehen, und nur einer darf sie ausfuehren. Ein
// bedingtes UPDATE ist auf MySQL wie auf SQLite atomar; zwei Prozesse, die eine Datei
// sperren, sind es auf geteiltem Hosting nicht zuverlaessig.
//
// Die Tabelle bleibt klein - eine Zeile je registrierter Aufgabe, heute fuenf. Deshalb
// steht hier kein Index: ein voller Durchlauf ueber fuenf Zeilen ist billiger als ein
// Index, den jede Aenderung mitpflegen muss.
$tasks = $schema->createTable('scheduled_task');
// Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel:
// verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich.
$tasks->addColumn('name', 'string', ['length' => 64]);
// Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der
// Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation
// richtig und auf der naechsten zu selten ist.
$tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]);
$tasks->addColumn('enabled', 'boolean', ['default' => true]);
// Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt.
$tasks->addColumn('next_run_at', 'datetime_immutable');
$tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]);
// Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer
// Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen
// hat, kann seine Sperre nicht mehr selbst aufraeumen.
$tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]);
$tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]);
$tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']);
$tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]);
// Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand.
// Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der
// Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt?
$tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']);
// Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten
// Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf
// wieder anfaengt.
$tasks->addColumn('failures', 'integer', ['default' => 0]);
$tasks->setPrimaryKey(['name']);
}
public function down(Schema $schema): void
{
// Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt,
// kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an.
$schema->dropTable('scheduled_task');
}
}
+52 -1
View File
@@ -144,7 +144,7 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.nav-item:hover { background: #21362d }
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
.nav-icon { width: 19px; text-align: center; font-size: 14px; color: var(--shell-muted); flex-shrink: 0 }
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
@@ -446,6 +446,11 @@ details.card > summary:hover { background: #fafbf8 }
.theme-table td input[type=color] { width: 52px }
.theme-table .weights-cell { white-space: nowrap }
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
.tasks-table td input[type=number] { width: 92px }
.tasks-table td .field-help { margin-top: 4px }
.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap }
/* Headings inside a card separate its parts without a second border. */
.card-body .section-head { margin: 28px 0 12px }
.card-body .section-head:first-child { margin-top: 14px }
@@ -654,3 +659,49 @@ details.card > summary:hover { background: #fafbf8 }
.favicon-field{display:flex;align-items:center;gap:12px}
.favicon-field select{flex:1}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.color-field { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
.color-field-swatch { width: 46px; min-height: 38px; padding: 2px; flex: 0 0 auto; cursor: pointer }
.color-field-palette { flex: 0 0 auto; max-width: 40% }
/* Die Verwaltung auf dem Telefon.
Bis 1000px stand die Navigation als waagerechte Wischleiste da - zwei Listen untereinander,
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
/* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
.shell-menu > summary {
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
min-height: 44px; padding: 0 14px; border-radius: 10px;
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
}
.shell-menu > summary::-webkit-details-marker { display: none }
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
.shell-menu[open] > summary { margin-bottom: 8px }
.shell-menu-icon { font-size: 17px }
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
statt gar nicht da. */
@supports selector(::details-content) {
.shell-menu > summary { display: none }
.shell-menu::details-content { content-visibility: visible; block-size: auto }
}
@media (max-width: 760px) {
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
.shell-menu { flex: 1 1 100%; order: 3 }
.shell-menu > summary { display: flex }
.shell-menu nav { display: block }
.shell-menu:not([open]) nav { display: none }
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
.shell-version { display: none }
.shell-user strong { display: none }
}
+387
View File
@@ -0,0 +1,387 @@
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
// ---------------------------------------------------------------------------------------------
// The layer above the preview.
//
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
// The outline, element toolbar, insert points and drop marker live in the editor document, in an
// absolutely positioned layer that sits exactly on top of the frame.
//
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
// rectangle measured inside the frame can be used here unchanged. Measurements come from
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
// the preview only needs a re-measure and no offset arithmetic.
// ---------------------------------------------------------------------------------------------
/** Diameter of a plus sign, in the editor's own pixels. */
const INSERT_SIZE = 22;
/**
* What the toolbar offers, in the order it appears.
*
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
* sidebar carries the same five actions, and two controls with the same name on one screen are
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
* than the generic verb did: the toolbar floats next to the element, the announcement should
* carry that context instead of assuming it was seen.
*/
const TOOLS = [
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
];
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
const layer = document.createElement('div');
layer.className = 'canvas-overlay';
const hover = element('div', 'ov-box ov-hover');
const hoverLabel = element('span', 'ov-label');
hover.append(hoverLabel);
const select = element('div', 'ov-box ov-select');
const selectName = document.createElement('span');
selectName.className = 'ov-toolbar-name';
selectName.setAttribute('aria-hidden', 'true');
const toolbar = element('div', 'ov-toolbar');
toolbar.removeAttribute('aria-hidden');
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
const drop = element('div', 'ov-drop');
const dropLabel = element('div', 'ov-drop-label');
const insertBefore = insertButton('before', 'Element davor einfügen');
const insertAfter = insertButton('after', 'Element dahinter einfügen');
toolbar.append(selectName);
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
stage.append(layer);
for (const tool of TOOLS) {
const button = document.createElement('button');
button.type = 'button';
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
button.dataset.action = tool.action;
button.textContent = tool.glyph;
button.addEventListener('mousedown', event => event.preventDefault());
button.addEventListener('click', () => onAction(tool.action, getSelected()));
toolbar.append(button);
}
/**
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
*/
function element(tag, className) {
const node = document.createElement(tag);
node.className = className;
node.setAttribute('aria-hidden', 'true');
node.hidden = true;
return node;
}
function insertButton(where, title) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ov-insert';
button.title = title;
button.setAttribute('aria-label', title);
button.textContent = '+';
button.hidden = true;
button.addEventListener('mousedown', event => event.preventDefault());
button.addEventListener('click', () => onInsert(getSelected(), where));
return button;
}
let hovered = null, dragging = null, dropPlan = null;
const doc = () => frame.contentDocument;
/** Is this id a block of the current document rather than some id in the markup? */
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
/** The nearest element that stands for a block of the document. */
function blockElement(node) {
for (let current = node; current && current !== doc(); current = current.parentElement) {
if (current.id && known(current.id)) return current;
}
return null;
}
function rectOf(id) {
const node = id ? doc()?.getElementById(id) : null;
if (!node) return null;
const box = node.getBoundingClientRect();
return box.width || box.height ? box : null;
}
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
function place(node, box, inset = 0) {
if (!box) { node.hidden = true; return false; }
node.hidden = false;
node.style.left = `${box.left + inset}px`;
node.style.top = `${box.top + inset}px`;
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
return true;
}
/**
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
* would fall under the size a finger or an eye can deal with.
*/
function counterScale(node, originX, originY) {
const scale = getScale();
node.style.transformOrigin = `${originX} ${originY}`;
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
}
function positionToolbar(box, hasInsert) {
if (!box) { toolbar.hidden = true; return; }
toolbar.hidden = false;
const scale = getScale();
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
// inside the frame's coordinates shrinks as the preview shrinks.
const needed = toolbar.offsetHeight / scale;
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
const above = box.top >= needed + clearance + 2;
toolbar.style.left = `${box.left}px`;
toolbar.style.top = `${above
? box.top - needed - clearance - 2
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
counterScale(toolbar, 'left', 'top');
}
function positionInsert(button, box, where) {
if (!box) { button.hidden = true; return; }
const scale = getScale();
const size = INSERT_SIZE / scale;
button.hidden = false;
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
button.style.width = `${size}px`;
button.style.height = `${size}px`;
counterScale(button, 'center', 'center');
}
/** Renames the toolbar for whatever is selected right now. */
function nameTools(id) {
const block = locate(getDocument().blocks, id)?.block;
const label = block ? blockLabel(block) : 'Element';
selectName.textContent = label;
for (const button of toolbar.children) {
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
if (!tool) continue;
button.title = tool.title(label);
button.setAttribute('aria-label', tool.title(label));
}
}
function paintLabel(node, id) {
const block = locate(getDocument().blocks, id)?.block;
node.textContent = block ? blockLabel(block) : '';
counterScale(node, 'left', 'bottom');
}
/** Re-measures everything. Cheap enough to call on every scroll frame. */
function sync() {
const scale = getScale();
layer.style.width = `${frame.clientWidth}px`;
layer.style.height = `${frame.clientHeight}px`;
layer.style.transform = `scale(${scale})`;
if (!doc()) return;
const selectedId = getSelected();
const selectedBox = rectOf(selectedId);
place(select, selectedBox);
if (selectedBox) nameTools(selectedId);
// Insert points only make sense for a block that has somewhere to sit next to.
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
// now is it settled whether there is one.
positionToolbar(selectedBox, Boolean(sibling));
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
if (dragging) {
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
paintDrop();
}
}
// -------------------------------------------------------------------------------------------
// Dropping.
// -------------------------------------------------------------------------------------------
/** The child elements of a block, in document order, that stand for its children. */
function childElements(id) {
const block = id ? locate(getDocument().blocks, id)?.block : null;
const children = id ? (block?.children ?? []) : getDocument().blocks;
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
}
/**
* Where a drop would land inside a container. Children that sit side by side - the columns of
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
* so the insertion line always runs across the gap the pointer is actually in.
*/
function indexInside(parentId, x, y) {
const kids = childElements(parentId);
if (!kids.length) return { index: 0, line: null };
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
for (let index = 0; index < kids.length; index++) {
const box = kids[index].box;
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
}
const last = kids[kids.length - 1].box;
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
}
/**
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
* inside the hovered block when it is a container that takes this kind of child, otherwise
* next to it, otherwise try the same two answers one level up.
*/
function planDrop(x, y, block) {
const document_ = getDocument();
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
const start = blockElement(doc()?.elementFromPoint(x, y));
for (let node = start; ; ) {
const id = node?.id ?? null;
const item = id ? locate(document_.blocks, id) : null;
// Never offer to drop a block into itself or into its own subtree.
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
const inside = indexInside(id, x, y);
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
}
const parentId = item?.parent?.id ?? null;
if (id && !ownBranch && dropList(document_, parentId, block)) {
const box = rectOf(id);
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
}
if (!node) break;
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
if (!node) {
// Fell out of the tree: the top level is the last thing left to try.
if (!dropList(document_, null, block)) break;
const inside = indexInside(null, x, y);
return { parentId: null, index: inside.index, line: inside.line };
}
}
return null;
}
function paintDrop() {
const line = dropPlan?.line;
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
drop.hidden = false;
const scale = getScale();
const thickness = 3 / scale;
const { box, edge } = line;
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
dropLabel.hidden = false;
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
counterScale(dropLabel, 'left', 'top');
drop.style.marginLeft = drop.style.marginTop = '0';
drop.classList.toggle('ov-drop-inside', edge === 'inside');
if (edge === 'inside') {
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
return;
}
const vertical = edge === 'left' || edge === 'right';
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
drop.style.width = `${vertical ? thickness : box.width}px`;
drop.style.height = `${vertical ? box.height : thickness}px`;
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
}
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
function attach() {
const document_ = doc();
if (!document_) return;
document_.addEventListener('mousemove', event => {
if (dragging) return;
const node = blockElement(event.target);
const id = node?.id ?? null;
if (id === hovered) return;
hovered = id;
sync();
});
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
document_.addEventListener('scroll', sync, { passive: true, capture: true });
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
// Inside the frame an event already carries frame coordinates, and those are exactly the
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
// the editor viewport and back through the scale factor would add.
document_.addEventListener('dragover', event => {
if (!dragging) return;
event.preventDefault();
dragPoint = { x: event.clientX, y: event.clientY };
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
paintDrop();
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
});
document_.addEventListener('dragleave', event => {
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
});
document_.addEventListener('drop', event => {
if (!dragging) return;
event.preventDefault();
// Both are read before the clean-up clears them.
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
endDrag();
if (plan) onDrop(payload, plan);
});
sync();
}
function startDrag(payload) {
dragging = payload;
layer.classList.add('is-dragging');
doc()?.documentElement.classList.add('editor-dragging');
hovered = null;
sync();
}
function endDrag() {
dragging = null;
dragPoint = null;
cancelAnimationFrame(scrollFrame); scrollFrame = null;
dropPlan = null;
drop.hidden = true;
dropLabel.hidden = true;
layer.classList.remove('is-dragging');
doc()?.documentElement.classList.remove('editor-dragging');
sync();
}
let dragPoint = null, scrollFrame = null;
function scrollDrag() {
scrollFrame = null;
if (!dragging || !dragPoint) return;
const edge = 60 / getScale();
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
if (amount) {
doc()?.defaultView?.scrollBy(0, amount / getScale());
sync();
scrollFrame = requestAnimationFrame(scrollDrag);
}
}
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
}
+74
View File
@@ -0,0 +1,74 @@
/*
* Farbfelder in den Verwaltungsformularen.
*
* Eine Farbe tippt man nicht, man wählt sie. Im Editor gibt es dafür längst ein Bedienelement aus
* Farbwähler, Textfeld und Palettenliste - in den Formularen unter „Design" und „Einstellungen"
* stand bisher nur ein nacktes Textfeld.
*
* Das Textfeld bleibt die Wahrheit: Es nimmt einen Wert wie `#1a2b3c` **oder** einen Schlüssel aus
* der Palette (`@accent`), und das kann ein Farbwähler nicht. Deshalb wird es nicht ersetzt,
* sondern ergänzt. Ohne JavaScript bleibt das Feld genau das, was es vorher war.
*/
(() => {
const felder = [...document.querySelectorAll('input[data-color]')];
if (0 === felder.length) {
return;
}
for (const feld of felder) {
const palette = JSON.parse(feld.dataset.palette || '[]');
const reihe = document.createElement('div');
reihe.className = 'color-field';
feld.parentNode.insertBefore(reihe, feld);
const waehler = document.createElement('input');
waehler.type = 'color';
waehler.className = 'color-field-swatch';
waehler.setAttribute('aria-label', 'Farbwähler für ' + (feld.labels?.[0]?.textContent.trim() || 'diese Farbe'));
// Der Wähler kennt nur Hexwerte. Steht im Feld ein Palettenschlüssel, zeigt er die Farbe,
// für die der Schlüssel steht - sonst bliebe er auf Schwarz stehen und sähe falsch aus.
const alsHex = wert => {
if (/^#[0-9a-f]{6}$/i.test(wert)) {
return wert;
}
const treffer = palette.find(eintrag => '@' + eintrag.key === wert);
return treffer ? treffer.value : '#000000';
};
waehler.value = alsHex(feld.value || feld.placeholder || '#000000');
waehler.addEventListener('input', () => {
feld.value = waehler.value;
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
reihe.append(waehler, feld);
if (palette.length > 0) {
const auswahl = document.createElement('select');
auswahl.className = 'color-field-palette';
auswahl.setAttribute('aria-label', 'Farbe aus der Palette');
auswahl.append(new Option('Aus der Palette …', ''));
for (const eintrag of palette) {
auswahl.append(new Option(`${eintrag.name} (${eintrag.value})`, '@' + eintrag.key));
}
auswahl.value = palette.some(eintrag => '@' + eintrag.key === feld.value) ? feld.value : '';
auswahl.addEventListener('change', () => {
if ('' === auswahl.value) {
return;
}
feld.value = auswahl.value;
waehler.value = alsHex(auswahl.value);
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
reihe.append(auswahl);
}
// Tippt jemand von Hand, folgt der Wähler mit - solange der Wert einer ist, den er kennt.
feld.addEventListener('input', () => {
const hex = alsHex(feld.value);
if (waehler.value !== hex) {
waehler.value = hex;
}
});
}
})();
+258 -39
View File
@@ -5,7 +5,7 @@
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { openMedia } from './media.js';
export const GROUPS = {
@@ -44,6 +44,58 @@ export function contrastRatio(foreground, background) {
return (light + 0.05) / (dark + 0.05);
}
/**
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
*
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
* working untouched - this is added, nothing is taken away.
*
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
* scrolled, and stealing that would make the panel unusable on a phone.
*/
function makeScrubbable(input, { apply, step, mergeKey }) {
const size = 'any' === step || !step ? 1 : Number(step);
input.classList.add('scrubbable');
let startX = 0, startValue = 0, active = false, moved = false;
input.addEventListener('pointerdown', event => {
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
startX = event.clientX;
startValue = '' === input.value ? 0 : Number(input.value);
active = Number.isFinite(startValue);
moved = false;
});
input.addEventListener('pointermove', event => {
if (!active) return;
const distance = event.clientX - startX;
// A few pixels of slack, so a plain click still lands in the field to type in.
if (!moved && Math.abs(distance) < 3) return;
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
event.preventDefault();
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
const raw = startValue + distance * size * scale;
const rounded = Math.round(raw / (size * scale)) * (size * scale);
const min = '' === input.min ? -Infinity : Number(input.min);
const max = '' === input.max ? Infinity : Number(input.max);
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
if (String(value) === input.value) return;
input.value = String(value);
apply(String(value), mergeKey);
});
const stop = event => {
if (!active) return;
active = false;
if (!moved) return;
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
// The drag was one decision, so it closes as one entry in the history.
apply(input.value, null);
};
input.addEventListener('pointerup', stop);
input.addEventListener('pointercancel', stop);
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -51,10 +103,28 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); };
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
/**
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
*
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
* which replaces the very field the pointer is holding on to. The drag would end after its
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
* standing; it is redrawn once, when the pointer is released.
*/
const change = (mutate, mergeKey, keepPanel = false) => {
getHistory().change(mutate, mergeKey);
onChange({ keepPanel });
};
// Whether the sides of a property move together. This has to outlive the panel: switching the
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
// would be gone by the time the next keystroke arrives.
const linkedSides = new Map();
// Lives as long as the editor session, so a look can travel between blocks and pages.
let clipboard = null;
let showAll = false, lastBlockId = null;
const openGroups = new Map();
const renderedGroups = new Map();
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
const applyStyle = (doc, id, style) => {
@@ -73,13 +143,54 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
}
};
/** Short marks for the chips; the full device name is in the title and the accessible name. */
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
/** Does this device carry an own value for this property - as a plain value or per side? */
const hasOwn = (block, deviceName, key, hover) => {
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const value = bucket?.[key];
if (value === undefined) return false;
// A per-side property stores an object; an empty one is a leftover, not a value.
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
};
/**
* Three chips per option showing which devices carry an own value, and switching to one.
*
* Until now a single switch above the preview decided which device the whole panel edited,
* so "is the heading smaller on the phone" could only be answered by switching and looking.
* The chips answer it where the question comes up. Clicking one switches the preview as well:
* editing a value for a device you are not looking at is editing blind.
*/
const deviceChips = (block, key, labelText, hover) => {
const current = getDevice();
const group = element('div', 'device-chips');
group.setAttribute('role', 'group');
group.setAttribute('aria-label', `Gerät für ${labelText}`);
for (const [name, deviceLabel] of Object.entries(devices)) {
const own = hasOwn(block, name, key, hover);
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
chip.type = 'button';
chip.dataset.device = name;
chip.setAttribute('aria-pressed', String(name === current));
const state = own ? 'eigener Wert' : 'erbt';
chip.title = `${deviceLabel} – ${state}`;
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
group.append(chip);
}
return group;
};
/** One labelled row with a reset button that appears as soon as this device has an own value. */
const row = (id, labelText, control, { own, inherited, onReset }) => {
const row = (id, labelText, control, { own, inherited, onReset, chips }) => {
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
const head = element('div', 'design-row-head');
const label = element('label', null, labelText);
label.htmlFor = id;
head.append(label);
if (chips) head.append(chips);
if (own) {
const reset = element('button', 'text-button design-reset-one', '⟲');
reset.type = 'button';
@@ -119,7 +230,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return wrap;
};
const lengthControl = (id, value, apply, placeholder) => {
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
const wrap = element('div', 'length-control');
const parts = splitLength(value);
const number = document.createElement('input');
@@ -132,10 +243,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
unit.setAttribute('aria-label', 'Einheit');
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = parts.unit;
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
number.addEventListener('change', push);
const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
number.addEventListener('change', () => push());
unit.addEventListener('change', () => { if (number.value !== '') push(); });
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
wrap.append(number, unit);
if (keywords.length) {
const mode = document.createElement('select');
mode.className = 'length-mode';
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
mode.value = ['auto', 'none'].includes(value) ? value : '';
number.hidden = unit.hidden = mode.value !== '';
mode.addEventListener('change', () => apply(mode.value));
wrap.prepend(mode);
}
return wrap;
};
@@ -155,6 +278,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
slider.addEventListener('input', () => { exact.value = slider.value; });
slider.addEventListener('change', () => apply(slider.value));
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
makeScrubbable(exact, {
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
step: property.step,
mergeKey: `scrub-${id}`,
});
wrap.append(slider, exact);
return wrap;
};
@@ -179,35 +307,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return select;
};
/** Four inputs for padding, margin, border width and corners. */
/**
* Padding, margin, border width and corners as a box.
*
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
* edge you were setting. In a box each field sits on the edge it controls, so the position is
* the label. The chain in the middle keeps all four equal, which is what most of them are.
*
* The unit stays a single choice for the whole property, because four unit menus do not fit a
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
*/
const sideControl = (key, block, deviceName, property) => {
const grid = element('div', 'side-control');
const parts = property.corners ? schema.corners : schema.sides;
for (const [side, label] of Object.entries(parts)) {
const cell = element('div', 'side-cell');
const id = `style-${key}-${side}`;
const caption = element('label', null, label);
caption.htmlFor = id;
const corners = Boolean(property.corners);
const parts = corners ? schema.corners : schema.sides;
const names = Object.keys(parts);
const wrap = element('div', 'side-wrap');
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
// What is stored right now, per side, split into number and unit.
const stored = Object.fromEntries(names.map(side => {
const current = effectiveStyle(block, deviceName, key, side);
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
}, current.value ? String(current.value) : 'erbt');
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
cell.append(caption, control);
grid.append(cell);
}
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
all.type = 'button';
all.addEventListener('click', () => {
const first = grid.querySelector('input[type=number]');
const unit = grid.querySelector('select');
if (first?.value === '') return;
const own = current.source === deviceName ? current.value : '';
return [side, { own, inherited: current.value, parts: splitLength(own) }];
}));
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
const unit = document.createElement('select');
unit.id = `style-${key}-unit`;
unit.className = 'side-unit-select';
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = commonUnit || 'px';
const linked = element('button', 'side-link', '⛓');
linked.type = 'button';
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
// deliberate act: typing into one field must not quietly rewrite the three others.
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
linked.title = 'Alle Seiten gemeinsam ändern';
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
/** The unit a side should be written with: its own, else the chosen one. */
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
const write = (side, number, mergeKey) => {
const targets = linkedSides.get(key) ? names : [side];
change(doc => {
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
for (const target of targets) {
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
}
}, mergeKey, Boolean(mergeKey));
};
for (const side of names) {
const id = `style-${key}-${side}`;
const field = document.createElement('input');
field.type = 'number';
field.step = 'any';
field.id = id;
field.className = 'side-field';
field.value = stored[side].parts.number;
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
field.addEventListener('change', () => write(side, field.value, null));
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
const cell = element('div', `side-cell side-${side}`);
cell.append(field);
box.append(cell);
}
box.append(linked);
linked.addEventListener('click', () => {
const now = !linkedSides.get(key);
linkedSides.set(key, now);
linked.setAttribute('aria-pressed', String(now));
if (!now) return;
// Switching the chain on levels the sides - otherwise it would claim a state the box does
// not show. The value taken is the first one that exists: starting from an empty top field
// would wipe the sides that do have a value.
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
if (undefined === set) return;
change(doc => {
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
});
});
const wrap = element('div', 'side-wrap');
wrap.append(grid, all);
unit.addEventListener('change', () => {
if ('' === unit.value) return;
change(doc => {
for (const side of names) {
const number = stored[side].own ? stored[side].parts.number : '';
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
}
});
});
const unitRow = element('div', 'side-unit');
const unitLabel = element('label', null, 'Einheit');
unitLabel.htmlFor = unit.id;
unitRow.append(unitLabel, unit);
wrap.append(box, unitRow);
return wrap;
};
@@ -257,14 +460,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
renderedGroups.clear();
const block = getBlock();
const deviceName = getDevice();
if (!block) return;
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
const scope = element('p', 'design-scope');
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
panel.append(scope);
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
panel.append(all);
// A filter over the properties. With more than ninety of them across eight collapsed groups,
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
@@ -295,11 +506,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
});
panel.append(searchLabel, search, found);
for (const [group, groupLabel] of Object.entries(GROUPS)) {
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
for (const group of orderedGroups) {
const groupLabel = GROUPS[group];
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
if (properties.length === 0) continue;
const details = element('details', 'design-group');
if (group === 'text' || group === 'spacing') details.open = true;
const groupKey = `${block.type}:${group}`;
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
renderedGroups.set(groupKey, details);
details.append(element('summary', null, groupLabel));
const body = element('div', 'design-group-body');
@@ -307,7 +523,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const id = `style-${key}`;
const current = effectiveStyle(block, deviceName, key);
const own = block.style?.[deviceName]?.[key] !== undefined;
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
const apply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
const inheritedText = current.value !== undefined && !own
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
@@ -315,23 +531,25 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
let control;
if (property.sides || property.corners) {
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
const sideHead = element('div', 'design-row-head design-side-head');
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
body.append(sideHead, sideControl(key, block, deviceName, property));
continue;
}
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key);
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
const hoverControl = property.type === 'color'
? colorControl(hoverId, hoverValue ?? '', hoverApply)
: property.type === 'number'
@@ -341,6 +559,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
own: hoverValue !== undefined,
inherited: 'Kein eigener Hover-Wert',
onReset: () => hoverApply(''),
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
}));
}
}
+178 -21
View File
@@ -1,7 +1,34 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
/**
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
* nothing about which of them belong together or where they may go; the first group is the
* scaffolding, the rest is what goes inside it.
*/
export const CATEGORIES = [
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
];
/** How many columns a row can be added with. */
export const ROW_COLUMNS = [2, 3, 4];
/**
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
* working is one you keep pressing.
*/
export function whyNot(type) {
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
}
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
@@ -9,11 +36,37 @@ export const blockIcon = block => icons[block?.type] ?? '❔';
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
// Containers that accept only one kind of child, mirrored by the server side validator.
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
// ---------------------------------------------------------------------------------------------
// What may sit inside what.
//
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
// because they are the ones people already have in their hands:
//
// ALLOW a container that takes only these children and nothing else
// REQUIRE a block that exists only inside one of these parents
// ROOT_NEEDS_CONTAINER
// a block that may sit in any container but not loose at the top of a document
//
// Until now a row took anything - a heading could land directly in it, next to the columns -
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
// nowhere. The structure the editor has always described in its own hint text is
// section -> row -> column -> content, and these rules are that sentence made binding.
// ---------------------------------------------------------------------------------------------
/** Containers that take one kind of child and nothing else. */
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
/** Blocks that exist only inside one of these parents. */
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
/**
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
* hold one - but it is not a thing a document starts with. At the top level a page begins with
* a section, and that is where a row belongs.
*/
export const ROOT_NEEDS_CONTAINER = ['row'];
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
&& (block.type !== 'tabitem' || parent.type === 'tabs');
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
export const clone = value => structuredClone(value);
export function locate(blocks, id, parent = null) {
@@ -59,8 +112,8 @@ export function createBlock(type) {
return block;
}
export function insertBlock(document, type, selected) {
const block = createBlock(type);
/** Raises the document schema to the version that knows this block type. */
export function upgradeForType(document, type) {
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
if (type === 'postnav') upgradeDocument(document, 18);
@@ -72,13 +125,42 @@ export function insertBlock(document, type, selected) {
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
if (type === 'embed') upgradeDocument(document, 14);
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
if (!target) document.blocks.push(block);
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
else target.siblings.splice(target.index + 1, 0, block);
return block.id;
}
/**
* Where clicking a type in the toolbox would put it - without putting it anywhere.
*
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
* the answer has to be the one the click will actually follow, or the two drift apart.
*
* The search goes from the inside out: into the selection when it is a container that takes
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
* level. Returns null when the type cannot go anywhere from this selection.
*
* @return {{parentId: string|null, index: number}|null}
*/
export function insertionTarget(document, selected, type) {
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
if (type === 'section') {
const top = blockPath(document.blocks, selected)[0];
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
}
const probe = { type };
let item = locate(document.blocks, selected);
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
while (item) {
const parentId = item.parent?.id ?? null;
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
item = item.parent ? locate(document.blocks, item.parent.id) : null;
}
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
}
export function insertBlock(document, type, selected) {
const target = insertionTarget(document, selected, type);
if (!target) return null;
return insertBlockAt(document, type, target.parentId, target.index);
}
export function duplicateBlock(document, selected) {
@@ -109,6 +191,60 @@ export function moveBlock(document, selected, targetId, inside = false) {
return true;
}
/**
* Where a block may land. `parentId` of null means the top level. Returns the list that would
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
* and nothing else, an accordion takes entries.
*/
export function dropList(document, parentId, block) {
if (parentId === null) {
const type = block?.type;
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
return loose ? null : document.blocks;
}
const parent = locate(document.blocks, parentId)?.block;
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
}
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
function place(document, block, parentId, index) {
const list = dropList(document, parentId, block);
if (!list) return null;
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
return block.id;
}
/** Inserts a new block at an exact position instead of behind the selection. */
export function insertBlockAt(document, type, parentId, index) {
upgradeForType(document, type);
return place(document, createBlock(type), parentId, index);
}
/**
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
* block dragged between two others lands between them and not at the end of the container.
*/
export function moveBlockTo(document, selected, parentId, index) {
const source = locate(document.blocks, selected);
if (!source) return false;
// Dropping a container into itself or into its own child would detach the branch.
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
if (!dropList(document, parentId, source.block)) return false;
const from = source.siblings, fromIndex = source.index;
from.splice(fromIndex, 1);
// Re-read: removing the block may have shifted the target list or emptied a parent.
const list = dropList(document, parentId, source.block);
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
// The index still counts in the list as it was before the block was taken out, so the shift
// has to be settled first and only then clamped - the other way round an index pointing past
// the end is clamped to the old length and then pulled one step too far forward.
let at = index;
if (list === from && fromIndex < index) at -= 1;
at = Math.max(0, Math.min(at, list.length));
list.splice(at, 0, source.block);
return true;
}
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
// Called only inside a history transaction: all other fields and IDs stay untouched.
@@ -227,18 +363,29 @@ export function insertHero(document, selected) {
return section.id;
}
export function insertRow(document, columns, selected) {
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
function buildRow(columns) {
const count = Math.max(1, Math.min(6, Number(columns) || 2));
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
const row = createBlock('row');
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
row.children = Array.from({ length: count }, () => createBlock('column'));
return row;
}
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
export function insertRowAt(document, columns, parentId, index) {
upgradeDocument(document);
const target = locate(document.blocks, selected);
if (!target) document.blocks.push(row);
else if (canContain(target.block, row)) (target.block.children ??= []).push(row);
else target.siblings.splice(target.index + 1, 0, row);
return row.id;
return place(document, buildRow(columns), parentId, index);
}
export function insertRow(document, columns, selected) {
const row = buildRow(columns);
upgradeDocument(document);
// Same placement rule as a single element, so the row does not land somewhere else than the
// toolbox promised.
const target = insertionTarget(document, selected, 'row');
return target ? place(document, row, target.parentId, target.index) : null;
}
export function insertPreset(document, name, selected) {
@@ -391,3 +538,13 @@ export function copyStyle(document, fromId, toId) {
target.style = structuredClone(source.style);
return true;
}
/** UI relevance only. Existing values remain editable, including on older documents. */
export function relevantProperty(block, key, property) {
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined)) return true;
const container = isContainer(block);
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
return true;
}
+115 -15
View File
@@ -50,10 +50,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.panel-section .eyebrow{margin-bottom:10px}
.panel-section h2{font-size:14px;margin-bottom:12px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
.library button span{font-size:19px;line-height:1}
.panel-title{display:flex;justify-content:space-between;align-items:center}
.panel-title h2{margin-bottom:0}
.panel-title+.panel-hint{margin-top:10px}
@@ -150,10 +146,33 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:6px}
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
.side-wrap{margin-bottom:10px}
/* Each field sits on the edge it controls, so the position is the label. */
.side-box{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto auto;gap:4px;align-items:center;justify-items:center;padding:7px;border:1px dashed var(--line);border-radius:8px;background:#fbfcf8}
.side-top{grid-area:1/2}.side-left{grid-area:2/1}.side-right{grid-area:2/3}.side-bottom{grid-area:3/2}
.side-box-corners .side-topLeft{grid-area:1/1}.side-box-corners .side-topRight{grid-area:1/3}
.side-box-corners .side-bottomLeft{grid-area:3/1}.side-box-corners .side-bottomRight{grid-area:3/3}
.side-cell{width:100%}
.side-field{width:100%;min-width:0;text-align:center;padding:5px 2px;font-size:11px;min-height:30px}
.side-field.is-inherited{color:var(--muted)}
.side-link{grid-area:2/2;width:26px;height:26px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px;line-height:1;color:var(--muted);cursor:pointer}
.side-link[aria-pressed=true]{border-color:var(--accent);background:#eef5e6;color:var(--accent)}
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
.side-unit-select{flex:1;min-width:0}
/* The device chips on a single option: which devices carry an own value, and switch to one. */
.device-chips{display:flex;gap:2px;margin-left:auto}
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
.device-chip:hover{border-color:var(--accent)}
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
.design-side-head{margin-bottom:5px}
.design-side-head .design-group-label{margin:0}
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
.scrubbable{cursor:ew-resize}
.scrubbable:focus{cursor:text}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
@@ -188,9 +207,6 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.editor-page-name h1{font-size:14px}
.panel-section h2{font-size:13px;letter-spacing:0}
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
.library{gap:8px}
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
.library button span{font-size:20px}
.tree-item{font-size:12px;border-radius:6px}
.right-panel label{font-size:12px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
@@ -204,8 +220,92 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.canvas-panel{background:#e7ebe5}
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
.icon-button{border-radius:8px}
.row-library{display:grid;gap:6px;margin-bottom:14px}
.row-library button{display:flex;align-items:center;gap:10px;min-height:38px;padding:8px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
.row-library button:hover:not(:disabled){border-color:var(--accent)}
.row-library span{letter-spacing:-2px;font-size:13px}
#design-search{font-size:12px;min-height:36px;padding:9px}
/* --------------------------------------------------------------------------------------------
The working layer above the preview.
It sits in the editor document, not in the preview, and carries the same scale transform as
the frame - so a rectangle measured inside the frame can be used here without conversion. The
layer itself lets the mouse through; only the controls on it catch a click, and each of them
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
-------------------------------------------------------------------------------------------- */
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
.ov-box{position:absolute;pointer-events:none}
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
.ov-label{background:#5c6b62;font-weight:600}
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
.ov-tool:hover:not(:disabled){background:#3b4c42}
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
/* While something is being dragged the outlines would only add noise. */
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
#library button[draggable=true]{cursor:grab}
#library button[draggable=true]:active{cursor:grabbing}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
fade on the insert points is a transition and is switched off with the rest. */
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* --------------------------------------------------------------------------------------------
The toolbox, grouped the way a page is built.
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
element would land is disabled and carries the reason in its title, so the rule is learned
rather than merely enforced.
-------------------------------------------------------------------------------------------- */
.library-group + .library-group{margin-top:16px}
.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: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[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}
/* Flexible workspace: both panels use their full width, with accessible resize handles. */
.editor-layout{grid-template-columns:var(--panel-left,280px) 6px minmax(280px,1fr) 6px var(--panel-right,320px)}
.left-panel,.right-panel{min-width:0;overflow-x:hidden}
.left-panel{container-type:inline-size}
.left-panel>.section-launch{flex:none;padding:12px;border-bottom:1px solid var(--line)}
.section-launch button{white-space:normal;font-size:12px;min-height:40px}
.section-launch .field-help{margin:6px 0 0}
.left-panel>.panel-section:not(.structure-section){max-height:55%;overflow:auto;flex-shrink:0}
.panel-section{padding:16px 12px}
.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}
.library-buttons button{min-width:0;white-space:normal;overflow-wrap:anywhere;gap:6px}
.library-name{overflow-wrap:anywhere}
.library-preview{width:28px}
@container(max-width:270px){.library-buttons{grid-template-columns:minmax(0,1fr)}.library-buttons button[data-row]{grid-column:auto}}
.canvas-scroll{padding:0}
.panel-resizer{background:#e4e9df;cursor:col-resize;touch-action:none;position:relative;z-index:4}
.panel-resizer::after{content:'';position:absolute;top:45%;left:1px;width:4px;height:36px;border-radius:4px;background:#7b8d80}
.panel-resizer:hover,.panel-resizer:focus-visible{background:#b7cbbd;outline:2px solid var(--accent);outline-offset:-2px}
.resizing-panels{cursor:col-resize;user-select:none}
.resizing-panels iframe{pointer-events:none}
.length-control,.number-control{grid-template-columns:minmax(0,1fr) 74px}
.right-panel input,.right-panel select,.right-panel textarea{min-width:0;max-width:100%}
.length-mode{grid-column:1/-1}
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
@media(max-width:850px){.panel-resizer{display:none}.editor-layout{display:flex}.left-panel,.right-panel{width:50%}.canvas-scroll{padding:0}.left-panel>.panel-section:not(.structure-section){max-height:50%}}
@media(max-width:560px){.left-panel,.right-panel{width:100%;height:620px;max-height:620px}.canvas-toolbar{padding-inline:8px}.canvas-label{display:none}}
+356 -33
View File
@@ -1,7 +1,10 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } 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';
import { decorateContainers } from './preview-containers.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'];
@@ -17,7 +20,12 @@ const mayScript = 'true' === root.dataset.canScript;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
// same factor, so it is kept here instead of being recomputed from the transform string.
let previewScale = 1, overlay = null, pendingInsert = null;
const collapsed = new Set();
let renderedProperties = null;
let revealAfterPreview = null;
function notify(message = '', recover = false) {
$('#message').textContent = message;
@@ -61,13 +69,13 @@ function controls() {
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
}
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#rows').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
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;
$('#add-root-section').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy;
}
@@ -108,6 +116,7 @@ function renderTree() {
function choose(id, scroll = true) {
if (busy) return;
if (id !== selected && pendingInsert) setPendingInsert(null);
selected = locate(history.document.blocks, id) ? id : null;
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
if (!selected) propertyPanel = 'content';
@@ -121,11 +130,15 @@ function highlight(scroll = false) {
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
const element = selected ? doc.getElementById(selected) : null;
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
overlay?.sync();
}
function renderProperties() {
const block = locate(history.document.blocks, selected)?.block;
if (!block) propertyPanel = 'content';
const context = `${selected ?? 'page'}:${propertyPanel}`;
if (context !== renderedProperties) $('.right-panel').scrollTop = 0;
renderedProperties = context;
$('#properties-title').textContent = block ? blockLabel(block) : 'Seiteneinstellungen';
const panel = $('#properties'); panel.replaceChildren();
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
@@ -320,7 +333,7 @@ function renderProperties() {
}
if (block.type === 'section') {
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal (1180 px)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos heißt ohne Rand und ohne Begrenzung – für ein Bild oder eine Farbfläche von Kante zu Kante.' });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Websitebreite (aus dem Theme)', narrow: 'Schmal (800 px)', wide: 'Breit (1600 px)', full: 'Randlos (volle Breite)' }, help: 'Randlos nutzt die gesamte Sektion. Text und Überschriften füllen ihren Container; eine eigene Begrenzung stellst du unter Design → Größe ein.' });
}
}
@@ -540,14 +553,16 @@ function stopInlineEdit() {
function resizePreview() {
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
const available = Math.max(100, container.clientWidth - padding);
const width = { desktop: Math.max(1240, available), tablet: 768, mobile: 390 }[device];
const scale = Math.min(1, available / width);
const height = Math.max(550, (container.clientHeight - padding) / scale);
previewScale = scale;
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
frame.title = `Vorschau ${devices[device]} · ${width} px`;
overlay?.sync();
}
function schedulePreview() {
@@ -583,7 +598,13 @@ async function updatePreview() {
const doc = frame.contentDocument;
if (!doc || doc === altesDokument || !doc.getElementById('kant-styles')) return;
angewandt = true;
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = `/assets/preview.css?v=${new URL(import.meta.url).searchParams.get('v') ?? 'dev'}`;
// Register before insertion: cached loads (or errors) can precede DOMContentLoaded.
const styleReady = new Promise(resolve => {
style.addEventListener('load', () => resolve(true), { once: true });
style.addEventListener('error', () => resolve(false), { once: true });
});
doc.head.append(style);
// The preview delivers the generated stylesheet as inert template text (see page.html.twig).
// Applying it as a constructed stylesheet is not a source in the sense of the policy, so the
// preview shows exactly what the published page shows, without a single blocked style.
@@ -595,6 +616,22 @@ async function updatePreview() {
}
doc.addEventListener('click', event => {
event.preventDefault();
const insert = event.target.closest('[data-editor-target]');
if (insert && !busy && history) {
stopInlineEdit();
const id = insert.dataset.editorTarget;
if (!locate(history.document.blocks, id)) return;
choose(id, false);
if (insert.hasAttribute('data-editor-row')) {
setPendingInsert(null);
selected = history.change(content => insertRowAt(content, Number(insert.dataset.editorRow), id, 0));
propertyPanel = 'content'; changed();
} else if (locate(history.document.blocks, id).block.type === 'row') {
selected = history.change(content => insertBlockAt(content, 'column', id, 0));
changed();
} else setPendingInsert({ parentId: id, index: 0 });
return;
}
if (inlineEdit) return;
const element = event.target.closest('[id]');
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
@@ -606,10 +643,23 @@ async function updatePreview() {
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
});
doc.addEventListener('submit', event => event.preventDefault());
frame.contentWindow.scrollTo(0, top); highlight();
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
frame.style.visibility = '';
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
const finish = () => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
decorateContainers(doc, history.document);
overlay?.attach();
styleReady.then(loaded => {
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
frame.contentWindow.scrollTo(0, top);
highlight(revealAfterPreview === selected); revealAfterPreview = null;
markInsertTarget();
frame.style.visibility = '';
$('#preview-status').textContent = loaded
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
});
};
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', finish, { once: true }); else finish();
};
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
frame.style.visibility = 'hidden';
@@ -640,6 +690,7 @@ async function updatePreview() {
function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
renderTree(); renderProperties(); controls(); schedulePreview();
}
@@ -650,10 +701,11 @@ async function loadDesignSchema() {
designSchema = await response.json();
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
onChange: options => { controls(); renderTree(); schedulePreview(); if (!options?.keepPanel) renderProperties(); },
onSavePreset: style => { pendingPreset = style; $('#preset-error').hidden = true; $('#preset-name').value = ''; $('#preset-dialog').showModal(); $('#preset-name').focus(); },
getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device,
onDevice: setViewport,
getSelection: () => selected,
getHistory: () => history,
});
@@ -692,16 +744,151 @@ async function save(publish = false) {
finally { busy = false; controls(); }
}
/**
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
* selection. With one, set by the plus signs in the preview, it lands exactly there.
*/
function addElement(type) {
if (busy || !history) return;
const at = pendingInsert;
setPendingInsert(null);
selected = history.change(doc => at
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
: insertBlock(doc, type, selected));
changed();
}
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
function setPendingInsert(target) {
pendingInsert = target;
updateLibrary();
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
$('#insert-hint').hidden = !target;
markInsertTarget();
if (target) {
if ($('#toolbox').hidden) $('#toggle-library').click();
$('#library-search').value = ''; filterLibrary();
const name = target.parentId ? $('#preview').contentDocument?.getElementById(target.parentId)?.dataset.editorLabel || blockLabel(locate(history.document.blocks, target.parentId)?.block) : 'Seite';
$('#insert-hint').firstChild.textContent = `Einfügen in ${name}. Wähle jetzt ein Element. `;
$('#library-search').focus({ preventScroll: true });
$('#library-search').scrollIntoView({ block: 'nearest' });
}
}
function markInsertTarget() {
const doc = $('#preview').contentDocument;
doc?.querySelectorAll('.editor-insert-target').forEach(node => node.classList.remove('editor-insert-target'));
if (pendingInsert?.parentId) doc?.getElementById(pendingInsert.parentId)?.classList.add('editor-insert-target');
}
// ---------------------------------------------------------------------------------------------
// The toolbox.
//
// It is built here rather than listed in the template because it has to answer a question that
// only the current selection can answer: what fits where the next element would go. A flat list
// of every type offered a row on an empty page and a column next to nothing - the structure the
// editor describes in its own hint was not the structure the buttons followed.
// ---------------------------------------------------------------------------------------------
function buildLibrary() {
const library = $('#library');
library.replaceChildren();
for (const category of CATEGORIES) {
const group = document.createElement('div'); group.className = 'library-group';
const heading = document.createElement('h4'); heading.className = 'library-group-title'; heading.textContent = category.name;
group.append(heading);
if (category.hint) {
const hint = document.createElement('p'); hint.className = 'library-group-hint'; hint.textContent = category.hint;
group.append(hint);
}
const list = document.createElement('div'); list.className = 'library-buttons';
for (const type of category.types) {
// A row without columns is an empty box, so it is offered by column count instead - the
// shape people actually want when they reach for a row.
for (const columns of 'row' === type ? ROW_COLUMNS : [null]) {
const button = document.createElement('button');
button.type = 'button'; button.draggable = true;
button.dataset.add = type;
if (columns) button.dataset.row = String(columns);
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.className = 'library-name';
text.textContent = name;
button.append(picture, text);
list.append(button);
}
}
group.append(list);
library.append(list.children.length ? group : document.createComment('leer'));
}
}
/**
* Greys out what cannot go where the next element would land, and says where it will land when
* it can. The answer comes from the same function the click follows, so the two cannot disagree.
*/
function updateLibrary() {
const ready = Boolean(history);
for (const button of $('#library').querySelectorAll('button')) {
const type = button.dataset.add;
if (!ready || busy) { button.disabled = true; button.removeAttribute('title'); continue; }
const target = pendingInsert
? (dropList(history.document, pendingInsert.parentId, { type }) ? pendingInsert : null)
: insertionTarget(history.document, selected, type);
button.disabled = !target;
if (!target) { button.title = whyNot(type); continue; }
const parent = target.parentId ? locate(history.document.blocks, target.parentId)?.block : null;
button.title = `Wird in ${parent ? blockLabel(parent) : 'die Seite'} eingefügt.`;
}
}
/** 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 type = event.target.closest('[data-add]')?.dataset.add;
if (!type || busy || !history) return;
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
const button = event.target.closest('[data-add]');
if (!button || button.disabled) return;
if (button.dataset.row) {
if (busy || !history) return;
const at = pendingInsert;
setPendingInsert(null);
const id = history.change(doc => at
? insertRowAt(doc, button.dataset.row, at.parentId, at.index)
: insertRow(doc, button.dataset.row, selected));
if (id) { selected = id; propertyPanel = 'design'; changed(); }
return;
}
addElement(button.dataset.add);
});
// A row with columns, in one click: the piece people actually look for when they want a row.
$('#rows').addEventListener('click', event => {
const columns = event.target.closest('[data-row]')?.dataset.row;
if (!columns || busy || !history) return;
selected = history.change(doc => insertRow(doc, columns, selected)); propertyPanel = 'design'; changed();
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
$('#add-root-section').addEventListener('click', () => {
if (busy || !history) return;
stopInlineEdit();
setPendingInsert(null);
selected = history.change(doc => insertBlock(doc, 'section', selected));
propertyPanel = 'content'; changed();
});
$('#presets').addEventListener('click', event => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
@@ -729,8 +916,13 @@ document.querySelectorAll('[data-panel]').forEach(button => button.addEventListe
$('#structure').addEventListener('dragstart', event => {
if (busy) { event.preventDefault(); return; }
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
if (!id) return;
event.dataTransfer.setData('text/plain', id);
event.dataTransfer.effectAllowed = 'move';
const block = locate(history.document.blocks, id)?.block;
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
});
$('#structure').addEventListener('dragend', () => overlay.endDrag());
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
$('#structure').addEventListener('drop', event => {
event.preventDefault(); if (busy) return;
@@ -740,17 +932,86 @@ $('#structure').addEventListener('drop', event => {
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
selected = source; changed();
});
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
$('#delete').addEventListener('click', () => {
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
});
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
});
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
// into the same functions, so the two can never drift apart.
const actions = {
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
delete: id => {
selected = history.change(doc => {
const item = locate(doc.blocks, id);
if (!item) return null;
item.siblings.splice(item.index, 1);
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
});
changed();
},
move: (id, direction) => {
history.change(doc => {
const item = locate(doc.blocks, id);
const next = item.index + direction;
if (next < 0 || next >= item.siblings.length) return;
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
});
selected = id; changed();
},
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
};
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
$('#delete').addEventListener('click', () => actions.delete(selected));
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
$('#outdent').addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
});
// ---------------------------------------------------------------------------------------------
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
// ---------------------------------------------------------------------------------------------
overlay = createCanvasOverlay({
stage: $('#preview-stage'),
frame: $('#preview'),
getScale: () => previewScale,
getDocument: () => history.document,
getSelected: () => selected,
onAction: (action, id) => {
if (!id || busy || !history) return;
if (action === 'up') actions.move(id, -1);
else if (action === 'down') actions.move(id, 1);
else actions[action]?.(id);
},
onInsert: (id, where) => {
const item = locate(history.document.blocks, id);
if (!item) return;
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
},
onDrop: (payload, plan) => {
if (busy || !history) return;
if (payload.kind === 'add') {
const id = history.change(doc => payload.columns
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
: insertBlockAt(doc, payload.type, plan.parentId, plan.index));
if (id) { selected = id; changed(); }
return;
}
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
if (moved) { selected = moved; changed(); }
},
});
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
// toolbox remains usable with the keyboard alone.
$('#library').addEventListener('dragstart', event => {
const button = event.target.closest('[data-add]');
if (!button || button.disabled || busy || !history) { event.preventDefault(); return; }
const type = button.dataset.add;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('text/plain', type);
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
// one does not exist until it lands.
overlay.startDrag({ kind: 'add', type, columns: button.dataset.row ?? null, block: { type } });
});
$('#library').addEventListener('dragend', () => overlay.endDrag());
$('#select-page').addEventListener('click', () => choose(null));
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
@@ -787,12 +1048,73 @@ $('#download').addEventListener('click', () => {
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
});
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
$('#preview').className = `viewport-${button.dataset.viewport}`;
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
}));
/**
* Switches the preview size and the device the design panel edits. Both move together on
* purpose: editing the mobile value while the preview shows desktop means changing something
* you cannot see.
*/
function setViewport(name) {
if (!devices[name]) return;
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.viewport === name)));
$('#preview').className = `viewport-${name}`;
device = name;
if (history) history.mergeKey = null;
resizePreview(); renderProperties(); controls();
}
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => setViewport(button.dataset.viewport)));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
// Panel sizes are workspace preferences, independent of document history and publication.
const panelSizes = { left: 280, right: 320 };
try {
const saved = JSON.parse(localStorage.getItem('kantsites.panel-widths'));
for (const side of ['left', 'right']) if (Number.isFinite(saved?.[side])) panelSizes[side] = saved[side];
} catch { /* Storage can be unavailable; resizing still works for this session. */ }
const panelMinimum = { left: 240, right: 280 };
function layoutPanels() {
let left = Math.max(240, Math.min(560, panelSizes.left));
let right = Math.max(280, Math.min(560, panelSizes.right));
const budget = Math.max(520, $('.editor-layout').clientWidth - 292);
const excess = Math.max(0, left + right - budget);
const spare = left + right - 520;
if (excess && spare) { left -= excess * (left - 240) / spare; right -= excess * (right - 280) / spare; }
for (const [side, size] of Object.entries({ left, right })) {
root.style.setProperty(`--panel-${side}`, `${size}px`);
const handle = $(`[data-resize="${side}"]`);
handle.setAttribute('aria-valuenow', String(Math.round(size)));
handle.setAttribute('aria-valuemax', String(Math.floor(Math.min(560, budget - (side === 'left' ? right : left)))));
handle.setAttribute('aria-valuetext', `${Math.round(size)} Pixel`);
}
}
const persistPanels = () => { try { localStorage.setItem('kantsites.panel-widths', JSON.stringify(panelSizes)); } catch {} };
for (const handle of document.querySelectorAll('[data-resize]')) {
const side = handle.dataset.resize, direction = side === 'left' ? 1 : -1;
let drag = null;
const setSize = value => {
panelSizes[side] = Math.max(panelMinimum[side], Math.min(Number(handle.getAttribute('aria-valuemax')), value));
layoutPanels();
};
handle.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
event.preventDefault(); handle.focus();
drag = { x: event.clientX, size: Number(handle.getAttribute('aria-valuenow')) };
handle.setPointerCapture(event.pointerId); root.classList.add('resizing-panels');
});
handle.addEventListener('pointermove', event => { if (drag) setSize(drag.size + direction * (event.clientX - drag.x)); });
const stop = () => { drag = null; root.classList.remove('resizing-panels'); persistPanels(); };
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
handle.addEventListener('lostpointercapture', stop);
handle.addEventListener('dblclick', () => { panelSizes[side] = side === 'left' ? 280 : 320; layoutPanels(); persistPanels(); });
handle.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const current = Number(handle.getAttribute('aria-valuenow'));
setSize(event.key === 'Home' ? panelMinimum[side] : event.key === 'End' ? 560 : current + (event.key === 'ArrowRight' ? 1 : -1) * direction * (event.shiftKey ? 40 : 10));
persistPanels();
});
}
new ResizeObserver(layoutPanels).observe($('.editor-layout'));
layoutPanels();
window.addEventListener('keydown', event => {
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
@@ -802,4 +1124,5 @@ window.addEventListener('keydown', event => {
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
});
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
buildLibrary();
load();
+76
View File
@@ -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>`;
}
+45
View File
@@ -0,0 +1,45 @@
import { blockLabel } from './editor-model.js';
/** Editing aids are created only in the preview DOM, never in a saved block or public HTML. */
export function decorateContainers(preview, content) {
const visit = (blocks, parent = null) => {
blocks.forEach((block, index) => {
const node = preview.getElementById(block.id);
if (node && ['section', 'row', 'column'].includes(block.type)) {
const label = block.type === 'column' && parent?.type === 'row'
? `Spalte ${index + 1} von ${blocks.length}` : blockLabel(block);
node.classList.add('editor-container');
node.dataset.editorLabel = label;
if (!block.children?.length) {
node.classList.add('editor-empty-container');
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner') : node;
if (!host) return;
const empty = preview.createElement('div');
empty.className = 'editor-empty';
const name = preview.createElement('span');
name.className = 'editor-empty-name'; name.textContent = label;
const hint = preview.createElement('span');
hint.className = 'editor-empty-hint';
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen' : 'Element hierher ziehen oder hinzufügen';
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
const add = (text, attribute, value, accessibleName) => {
const button = preview.createElement('button');
button.type = 'button'; button.textContent = text;
button.dataset.editorTarget = block.id;
button.setAttribute(attribute, value);
button.setAttribute('aria-label', accessibleName);
actions.append(button);
};
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
if (block.type === 'section') {
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
}
empty.append(name, hint, actions);
host.append(empty);
}
}
visit(block.children ?? [], block);
});
};
visit(content.blocks);
}
+14
View File
@@ -7,3 +7,17 @@
/* Inline editing. Only ever active in the editor preview, never on a published page. */
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
/* Empty layout areas must have a real hit area, including when sibling columns have content. */
.editor-empty-container{min-height:130px!important}
.editor-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:130px;width:100%;padding:22px 14px;border:2px dashed #93aa9a;border-radius:7px;background:#f3f8f4;color:#264f3d;text-align:center;font:14px/1.5 system-ui,sans-serif;box-sizing:border-box;grid-column:1/-1}
.editor-empty-name{font-size:12px;font-weight:700;letter-spacing:.04em}
.editor-empty-hint{font-size:13px;color:#496351}
.editor-empty-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
.editor-empty button{appearance:none;font:600 13px/1.3 system-ui,sans-serif;color:#264f3d;background:white;border:1px solid #94ad9d;border-radius:6px;min-height:40px;padding:9px 13px;cursor:pointer;letter-spacing:normal;text-transform:none;box-shadow:none;margin:0}
.editor-empty button:hover,.editor-empty button:focus-visible{background:#deeee2;border-color:#32644a;outline:2px solid #32644a;outline-offset:2px}
.editor-empty button[data-editor-insert]{background:#2c634a;color:white;border-color:#2c634a}
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
+1 -1
View File
@@ -6,7 +6,7 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
p{hyphens:auto}
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
+45
View File
@@ -0,0 +1,45 @@
/*
* Der Takt des geöffneten Backends.
*
* Auf einer Website ohne Besucher gibt es keinen Auslöser für wiederkehrende Aufgaben - außer
* dem Menschen, der gerade in der Verwaltung arbeitet. Dieses Skript klopft in seinem Namen alle
* 60 Sekunden an; der Server entscheidet, ob überhaupt etwas fällig ist.
*
* Drei Eigenschaften, die es unauffällig machen:
*
* - Es fragt nur, während der Reiter sichtbar ist. Ein vergessener Hintergrundreiter soll keine
* Sitzung endlos offen halten und keine Anfragen erzeugen, die niemand sehen will.
* - Es fragt nie zwei Mal gleichzeitig: eine laufende Anfrage blockiert die nächste.
* - Ein Fehlschlag ist kein Ereignis. Kein Hinweis, keine Meldung - beim nächsten Takt wieder.
*/
(function () {
var endpoint = document.querySelector('meta[name="kant-tick"]');
var token = document.querySelector('meta[name="kant-tick-token"]');
if (!endpoint || !token || !window.fetch) {
return;
}
var busy = false;
function tick() {
if (busy || document.visibilityState !== 'visible') {
return;
}
busy = true;
var body = new FormData();
body.append('_csrf_token', token.content);
fetch(endpoint.content, { method: 'POST', body: body, credentials: 'same-origin' })
.catch(function () {})
.then(function () {
busy = false;
});
}
// Der erste Takt kommt verzögert: beim Seitenaufbau hat der Browser Wichtigeres zu tun.
window.setTimeout(tick, 5000);
window.setInterval(tick, 60000);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
tick();
}
});
})();
+390 -5
View File
@@ -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
@@ -56,15 +64,218 @@ try {
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin');
console.log('Authenticated in isolated test installation.');
// --- Die Verwaltungsnavigation ------------------------------------------------------------
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
// worden, und genau das faellt hier auf.
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
const schalter = page.locator('.shell-menu > summary');
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
const flaeche = await eintrag.boundingBox();
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
await schalter.click();
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
await page.setViewportSize({ width: 1600, height: 1100 });
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
console.log('Administration navigation passed.');
await axe('page list');
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
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();
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
// Flexible panels, contextual fields and a root insertion from a deeply selected element.
const leftHandle = page.locator('[data-resize="left"]'), rightHandle = page.locator('[data-resize="right"]');
await leftHandle.focus(); await page.keyboard.press('ArrowRight');
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
const rightBox = await rightHandle.boundingBox();
await page.mouse.move(rightBox.x + 3, rightBox.y + 100); await page.mouse.down();
await page.mouse.move(rightBox.x - 57, rightBox.y + 100, { steps: 6 }); await page.mouse.up();
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
await page.reload();
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
assert.equal(await leftHandle.getAttribute('aria-valuenow'), '290');
assert.equal(await rightHandle.getAttribute('aria-valuenow'), '380');
for (const width of [1920, 1100, 850, 390]) {
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(100);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
for (const selector of ['.left-panel', '.right-panel', '.library-buttons']) {
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
}
}
await page.setViewportSize({ width: 1600, height: 1100 });
await frame.locator('h1').click();
const sectionCount = await frame.locator('main > .block-section').count();
await page.locator('#add-root-section').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
await page.locator('#redo').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await frame.locator('h1').click();
await page.locator('#library [data-add="image"]').click();
await page.locator('[data-panel="design"]').click();
assert.equal(await page.locator('#style-fontSize').count(), 0);
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
assert.equal(await page.locator('#style-fontSize').count(), 1);
await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('main .block-text').first().click();
await page.locator('[data-panel="design"]').click();
assert.equal(await page.locator('#style-objectFit').count(), 0);
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
await sizeGroup.locator('summary').click();
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
await page.locator('#style-maxWidth').fill('60');
await page.locator('#style-maxWidth').blur();
await page.waitForFunction(() => {
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
});
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
await page.waitForFunction(() => {
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
});
await page.locator('#undo').click();
await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('.right-panel').evaluate(el => el.scrollTop = 0);
await page.locator('.left-panel > .panel-section').first().evaluate(el => el.scrollTop = 0);
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
await leftHandle.dblclick(); await rightHandle.dblclick();
await page.locator('[data-panel="content"]').click();
checks.push({ name: 'resizable persistent panels, root sections, contextual image/text properties and full text width', passed: true });
// A layout starts empty: it must be visible, reachable and editable before it has any text.
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('h1').click();
await page.locator('#add-root-section').click();
await previewReady();
const emptySection = frame.locator('main > .editor-empty-container');
await emptySection.waitFor();
const newSectionId = await emptySection.getAttribute('id');
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
await previewReady();
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
assert.equal(await emptyColumns.count(), 2);
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
const firstColumnId = await emptyColumns.first().getAttribute('id');
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
await page.locator('#library-search').fill('Text');
await page.locator('#library [data-add="text"]').click();
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
await page.locator('#library-search').fill('');
const dragSource = page.locator('#library [data-add="button"]');
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
await dragSource.scrollIntoViewIfNeeded();
const sourceBox = await dragSource.boundingBox();
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
const frameBox = await page.locator('#preview').boundingBox();
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.mouse.up();
await frame.locator(`#${firstColumnId} .button`).waitFor();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
await page.locator('#redo').click(); await previewReady();
await frame.locator(`#${firstColumnId} .button`).waitFor();
await page.locator('#undo').click(); await previewReady();
await page.locator('#undo').click(); await previewReady();
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
for (const device of ['Desktop', 'Mobil']) {
await page.getByRole('button', { name: device, exact: true }).click();
const destination = frame.locator(`#${secondColumnId}`);
await destination.scrollIntoViewIfNeeded();
const box = await destination.boundingBox();
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
});
await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
}
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
const publicBefore = await (await fetch(origin)).text();
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const publishedEmpty = await (await fetch(origin)).text();
assert.ok(publishedEmpty.includes(newSectionId));
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
await page.locator(`#structure [data-block-id="${newSectionId}"]`).click();
await page.locator('#delete').click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
await page.reload(); await previewReady();
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
await frame.locator('h1').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
@@ -76,6 +287,113 @@ try {
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
// --- Die Vorschau als Arbeitsflaeche -------------------------------------------------------
// Rahmen, Werkzeugleiste am Element, Einfuegepunkte und Ziehen aus dem Werkzeugkasten.
const inPreview = selector => page.evaluate(
sel => document.querySelector('#preview').contentDocument?.querySelector(sel)?.id ?? null, selector);
await frame.locator('h1').click();
await page.locator('.ov-select').waitFor({ state: 'visible' });
assert.equal(await page.locator('.ov-toolbar-name').innerText(), 'Überschrift');
await page.getByRole('toolbar', { name: 'Aktionen für das gewählte Element' }).waitFor();
// Die Leiste benennt das Element, auf das sie wirkt - sonst haette sie denselben Namen wie
// die gleichnamige Schaltflaeche in der Seitenleiste.
await page.getByRole('button', { name: 'Überschrift duplizieren', exact: true }).waitFor();
await page.getByRole('button', { name: 'Element über Überschrift auswählen', exact: true }).click();
assert.notEqual(await page.locator('#properties-title').innerText(), 'Überschrift');
// Einfuegepunkt: das naechste Element landet genau dort und nicht am Ende.
await frame.locator('h1').click();
const headingId = await inPreview('h1');
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
await page.locator('#insert-hint').waitFor({ state: 'visible' });
await page.locator('#library button[data-add="text"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
assert.equal(await page.locator('#insert-hint').isVisible(), false);
await page.waitForFunction(id => {
const next = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.nextElementSibling;
return Boolean(next?.classList.contains('block-text'));
}, headingId);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
// Ziehen aus dem Werkzeugkasten auf die Seite.
await frame.locator('h1').waitFor();
const beforeButtons = await page.locator('#structure [data-block-id]').count();
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
// Oben abgelegt heisst davor, nicht dahinter.
await page.waitForFunction(id => {
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
return Boolean(previous?.classList.contains('button'));
}, headingId);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
// Eine Zeile kommt nicht aus dem Nichts, und eine Spalte gehoert in eine Zeile.
const toolboxState = type => page.evaluate(selector => {
const button = document.querySelector(selector);
return button ? { disabled: button.disabled, title: button.title } : null;
}, `#library button[data-add="${type}"]:not([data-row])`);
const rowState = () => page.evaluate(() => {
const button = document.querySelector('#library button[data-row="2"]');
return { disabled: button.disabled, title: button.title };
});
await page.locator('#select-page').click();
assert.equal((await toolboxState('column')).disabled, true, 'Spalte ohne Zeile gesperrt');
assert.match((await toolboxState('column')).title, /gehört in Zeile/);
assert.equal((await rowState()).disabled, true, 'Zeile ohne Sektion gesperrt');
assert.match((await rowState()).title, /gehört in eine Sektion/);
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
// 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.');
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
// setzen eine bestehende Auswahl voraus.
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
await page.locator('#duplicate:not([disabled])').waitFor();
console.log('Canvas overlay, insert points and drag and drop passed.');
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
@@ -109,17 +427,17 @@ try {
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
await page.click('#undo');
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
checks.push({ name: 'preview never shows unstyled content', passed: true });
console.log('Preview edits and history passed.');
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();
@@ -184,6 +502,73 @@ try {
await setLength('style-padding-top', 32);
await frameValue('#hero', 'paddingTop', '32px');
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
// --- Die Optionen selbst ------------------------------------------------------------------
// Geraetechips je Option, Abstandsrahmen mit Kette, Ziehfeld.
// Der Chip zeigt, wo ein abweichender Wert liegt: Innenabstand hat oben gerade Desktop und
// Mobil bekommen, Tablet nicht.
const chip = device => page.locator(`#style-padding-unit`).locator('xpath=ancestor::div[contains(@class,"side-wrap")]/preceding-sibling::div[contains(@class,"design-side-head")][1]')
.locator(`.device-chip[data-device="${device}"]`);
await chip('desktop').waitFor();
assert.equal(await chip('desktop').evaluate(el => el.classList.contains('device-chip-own')), true, 'Desktop hat einen eigenen Innenabstand');
assert.equal(await chip('mobile').evaluate(el => el.classList.contains('device-chip-own')), true, 'Mobil hat einen eigenen Innenabstand');
assert.equal(await chip('tablet').evaluate(el => el.classList.contains('device-chip-own')), false, 'Tablet erbt');
assert.equal(await chip('desktop').getAttribute('aria-pressed'), 'true', 'Desktop ist die bearbeitete Groesse');
// Ein Klick auf den Chip wechselt Bedienfeld und Vorschau zusammen.
await chip('mobile').click();
assert.equal(await page.locator('[data-viewport="mobile"]').getAttribute('aria-pressed'), 'true');
await chip('desktop').click();
assert.equal(await page.locator('[data-viewport="desktop"]').getAttribute('aria-pressed'), 'true');
// Der Rahmen aendert ohne Kette nur die eine Seite.
await setLength('style-padding-left', 24);
await frameValue('#hero', 'paddingLeft', '24px');
await frameValue('#hero', 'paddingRight', '0px');
// Mit Kette werden alle vier gleichgezogen.
await page.locator('.side-link').first().click();
assert.equal(await page.locator('.side-link').first().getAttribute('aria-pressed'), 'true');
await setLength('style-padding-top', 40);
for (const seite of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) await frameValue('#hero', seite, '40px');
await page.locator('.side-link').first().click();
// Die gemeinsame Einheit schreibt die gesetzten Seiten um, ohne Zahlen zu verlieren.
await page.locator('#style-padding-unit').selectOption('rem');
await frameValue('#hero', 'paddingTop', '640px');
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
await page.locator('#structure [data-block-id="headline"]').click();
await openGroup('Typografie');
const scrub = page.locator('#style-fontSize');
await scrub.scrollIntoViewIfNeeded();
const box = await scrub.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
const gezogen = Number(await scrub.inputValue());
assert.equal(gezogen, 92, `Ziehen um 20 px erhoeht 72 auf 92, gemessen: ${gezogen}`);
await frameValue('#headline', 'fontSize', '92px');
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await frameValue('#headline', 'fontSize', '72px');
checks.push({ name: 'device chips, spacing box and scrub fields', passed: true });
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
await page.locator('#structure [data-block-id="hero"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#style-padding-unit').selectOption('px');
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
await frameValue('#hero', 'paddingLeft', '0px');
await openGroup('Abstand');
await page.locator('#style-padding-top').scrollIntoViewIfNeeded();
await page.screenshot({ path: 'reports/editor/options.png' });
console.log('Per option device chips, spacing box and scrub fields passed.');
// The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into.
await openGroup('Hintergrund');
@@ -473,7 +858,7 @@ try {
await page.locator('#structure [data-block-id="headline"]').waitFor();
await page.locator('#structure [data-block-id="headline"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await axe('desktop editor');
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Command;
use App\Ops\Scheduler;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Der dritte Weg: ein echter Cronjob oder ein Dauerläufer.
*
* Für Installationen, die eine Konsole haben. Er tut dasselbe wie der Besuchs-Auslöser, nur ohne
* dessen enge Grenzen – und er läuft auch nachts um drei, wenn die Website leer ist.
*
* * * * * * cd /pfad && php bin/console cms:tasks:run (jede Minute, Standardweg)
* php bin/console cms:tasks:run --loop=60 --max-time=3600 (Dauerläufer, z. B. unter Supervisor)
* php bin/console cms:tasks:run --task=backup (eine bestimmte, sofort)
*/
#[AsCommand(name: 'cms:tasks:run', description: 'Führt fällige Aufgaben aus. Für einen echten Cronjob oder als Dauerläufer.')]
final class RunTasksCommand extends Command
{
public function __construct(private Scheduler $scheduler)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10')
->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300')
->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.')
->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (null !== ($name = $input->getOption('task'))) {
$result = $this->scheduler->runNow((string) $name, 'konsole');
if (null === $result) {
$io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.');
return Command::FAILURE;
}
$this->report($io, [$result]);
return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE;
}
$limit = max(1, (int) $input->getOption('limit'));
$budget = max(1, (int) $input->getOption('budget'));
$loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null;
$deadline = microtime(true) + max(0, (int) $input->getOption('max-time'));
$failed = false;
while (true) {
$results = $this->scheduler->runDue('konsole', $limit, $budget);
$failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);
$this->report($io, $results);
if (null === $loop) {
break;
}
// Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung
// über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu.
if (microtime(true) + $loop > $deadline) {
$io->writeln(sprintf('<comment>Ende nach %d Sekunden – neu starten lassen.</comment>', (int) $input->getOption('max-time')));
break;
}
sleep($loop);
}
return $failed ? Command::FAILURE : Command::SUCCESS;
}
/** @param list<array{name: string, label: string, status: string, message: string, ms: int}> $results */
private function report(SymfonyStyle $io, array $results): void
{
if ([] === $results) {
$io->writeln(sprintf('<info>%s</info> nichts fällig.', gmdate('H:i:s')));
return;
}
foreach ($results as $result) {
$io->writeln(sprintf(
'<info>%s</info> %s %s (%d ms): %s',
gmdate('H:i:s'),
'ok' === $result['status'] ? '<info>ok</info> ' : '<error>fehler</error>',
$result['name'],
$result['ms'],
$result['message'],
));
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Command;
use App\Ops\Scheduler;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Was der Plan vorhat und was zuletzt passiert ist.
*
* Dieselbe Auskunft wie der Bildschirm „Aufgaben“, nur für den, der ohnehin auf der Konsole
* steht – und für den Fall, dass die Verwaltung gerade nicht erreichbar ist.
*/
#[AsCommand(name: 'cms:tasks', description: 'Zeigt den Aufgabenplan: Abstand, Fälligkeit und das letzte Ergebnis.')]
final class TasksCommand extends Command
{
public function __construct(private Scheduler $scheduler)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('log', null, InputOption::VALUE_NONE, 'Zusätzlich die letzten Protokollzeilen.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Erst der Abgleich (er legt fehlende Zeilen an und schreibt die Zustandsdatei neu), dann
// die Auskunft daraus - sonst zeigt der erste Aufruf nach einem Update einen Zeitpunkt,
// der eine Sekunde spaeter schon nicht mehr stimmt.
$tasks = $this->scheduler->overview();
$state = $this->scheduler->fastState();
$io->writeln(sprintf(
'Auslöser: Besuch <info>%s</info>, Verwaltung <info>%s</info>. Konsole und URL-Cron laufen immer.',
$state['traffic'] ? 'an' : 'aus',
$state['admin'] ? 'an' : 'aus',
));
$io->writeln(sprintf(
'Jetzt ist %s UTC; das nächste Mal steht %s an.',
gmdate('Y-m-d H:i:s'),
null === $state['next'] ? 'unbekannt' : gmdate('Y-m-d H:i:s', $state['next']),
));
$rows = [];
foreach ($tasks as $task) {
$rows[] = [
$task['name'],
$task['enabled'] ? ($task['due'] ? 'fällig' : 'an') : 'aus',
$this->minutes($task['interval']),
$task['next'],
$task['running'] ? 'läuft' : ($task['status'] ?? '–'),
mb_substr($task['message'], 0, 60),
];
}
if ([] === $rows) {
$io->warning('Keine Aufgaben. Fehlt die Migration? `php vendor/bin/doctrine-migrations migrate`');
} else {
$io->table(['Name', 'Zustand', 'Abstand', 'Nächster Lauf (UTC)', 'Zuletzt', 'Meldung'], $rows);
}
if ($input->getOption('log')) {
$io->section('Protokoll');
$lines = $this->scheduler->protocol(30);
$io->writeln([] === $lines ? 'Noch nichts protokolliert.' : $lines);
}
return Command::SUCCESS;
}
private function minutes(int $seconds): string
{
return $seconds >= 86400 && 0 === $seconds % 86400
? sprintf('%d Tag(e)', intdiv($seconds, 86400))
: sprintf('%d Min.', max(1, intdiv($seconds, 60)));
}
}
+21 -1
View File
@@ -14,6 +14,13 @@ final class MediaLibrary
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
public const WIDTHS = [480, 960, 1600, 2400];
/**
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
*
* @var array<string, ?array>
*/
private array $gemerkt = [];
public function __construct(private Connection $db, private string $directory)
{
}
@@ -53,11 +60,22 @@ final class MediaLibrary
];
}
/**
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
*
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
*/
public function find(string $id): ?array
{
if (array_key_exists($id, $this->gemerkt)) {
return $this->gemerkt[$id];
}
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
return $row ? $this->present($row) : null;
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
}
/**
@@ -163,6 +181,7 @@ final class MediaLibrary
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
$this->db->insert('media', $row);
$this->gemerkt = [];
return $this->present($row);
} catch (\Throwable $error) {
@@ -183,6 +202,7 @@ final class MediaLibrary
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
}
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
unset($this->gemerkt[$id]);
if (1 !== $count) {
throw new StaleWrite();
}
+24 -2
View File
@@ -16,6 +16,18 @@ final class StyleCompiler
{
private const BREAKPOINTS = ['tablet' => 1024, 'mobile' => 767];
/**
* Dieselben Grenzen, als Medienabfragen fuer einen Verweis im Seitenkopf.
*
* Genau eine trifft zu, und sie trifft dieselbe Variante wie die Regel weiter unten - sonst
* holte ein Vorabverweis ein zweites Bild.
*/
public const PRELOAD_DEVICES = [
'desktop' => '(min-width:1025px)',
'tablet' => '(min-width:768px) and (max-width:1024px)',
'mobile' => '(max-width:767px)',
];
public function __construct(private ?MediaLibrary $media = null)
{
}
@@ -226,7 +238,7 @@ final class StyleCompiler
if (null !== $item && [] !== $item['variants']) {
// A phone should not download the 2400 pixel variant of a background. CSS has no
// srcset, but the rules are already written per device, so the device decides.
$largest = $this->variantFor($item['variants'], $device);
$largest = self::variantFor($item['variants'], $device);
// The URL comes from the media library, never from the document.
$layers[] = sprintf('url("%s")', $largest['src']);
}
@@ -356,7 +368,17 @@ final class StyleCompiler
return $style;
}
private function variantFor(array $variants, string $device): array
/**
* Die Variante, die ein Hintergrundbild auf diesem Geraet bekommt.
*
* Oeffentlich und statisch, weil der Seitenkopf dieselbe Wahl treffen muss, wenn er das Bild
* vorab holt. Zwei Stellen mit derselben Regel waeren zwei Stellen, die auseinanderlaufen.
*
* @param list<array{src: string, width: int}> $variants
*
* @return array{src: string, width: int}
*/
public static function variantFor(array $variants, string $device): array
{
$wanted = ['mobile' => 960, 'tablet' => 1600, 'desktop' => 4000][$device] ?? 4000;
$chosen = $variants[0];
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Controller;
use App\EventSubscriber\TaskWorkerSubscriber;
use App\Ops\Scheduler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Der Cronjob für Hostings, die nur eine URL aufrufen können.
*
* Viele Tarife bieten keinen Shell-Cron, aber einen „URL-Cronjob": der Hoster ruft alle paar
* Minuten eine Adresse auf. Das genügt – und ist gegenüber dem Besuchs-Auslöser der bessere Weg,
* weil er auch dann läuft, wenn niemand auf der Website ist.
*
* Drei Entscheidungen:
*
* 1. **Der Schlüssel steht in der Adresse**, weil ein URL-Cronjob nichts anderes mitschicken
* kann als eine Adresse. Er ist aus dem Anwendungsgeheimnis abgeleitet, 40 Zeichen lang und
* auf jeder Installation ein anderer.
* 2. **Ein falscher Schlüssel bekommt 404**, nicht 403. Wer probiert, soll nicht erfahren, dass
* es hier überhaupt etwas gibt.
* 3. **Die Antwort ist Text**, nicht HTML: sie landet im Protokoll des Hosters, und dort ist
* eine Zeile je Aufgabe das Nützlichste, was ankommen kann.
*/
final class CronController extends AbstractController
{
public function __construct(private Scheduler $scheduler, private string $secret)
{
}
#[Route('/cms-cron/{token}', name: 'cms_cron', requirements: ['token' => '[a-f0-9]{40}'], methods: ['GET', 'POST'])]
public function run(Request $request, string $token): Response
{
if (!hash_equals(Scheduler::cronToken($this->secret), $token)) {
throw $this->createNotFoundException();
}
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
// Hier wartet kein Mensch: ein Aufruf darf alles abarbeiten, was ansteht.
@ignore_user_abort(true);
@set_time_limit(0);
$began = microtime(true);
$results = $this->scheduler->runDue('cron', 20, 240);
$lines = [sprintf('%s UTC', gmdate('Y-m-d H:i:s'))];
foreach ($results as $result) {
$lines[] = sprintf('%-14s %-7s %6d ms %s', $result['name'], $result['status'], $result['ms'], $result['message']);
}
$lines[] = sprintf('%d Aufgabe(n) in %.1f s.', count($results), microtime(true) - $began);
return new Response(implode("\n", $lines)."\n", Response::HTTP_OK, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
'X-Robots-Tag' => 'noindex, nofollow',
]);
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ final class EditorController extends AbstractController
return $this->back('' === trim($request->request->getString('publishAt'))
? 'Die geplante Veröffentlichung wurde entfernt.'
: 'Die Veröffentlichung ist geplant. Sie erfolgt, sobald cms:publish-due läuft.', 'success');
: 'Die Veröffentlichung ist geplant. Sie erfolgt beim ersten Aufruf der Website nach diesem Zeitpunkt – siehe „Aufgaben“.', 'success');
}
#[Route('/pages/{id}/restore', name: 'admin_restore_page', methods: ['POST'])]
+46
View File
@@ -106,6 +106,9 @@ final class PageController extends AbstractController
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
'localFonts' => $localFonts,
@@ -539,6 +542,49 @@ final class PageController extends AbstractController
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
*/
/**
* Das Hintergrundbild des ersten Abschnitts, um es vorab zu holen.
*
* Gemessen an der Startseite des Piloten: Das groesste Element ueber der Falz ist dort kein
* `<img>`, sondern der Hintergrund des Heros. Ein Hintergrundbild steht im Stylesheet, und
* das kommt nach dem HTML - der Browser erfaehrt also erst vom Bild, wenn er das CSS geparst
* hat. Lighthouse nennt das „LCP request discovery"; gemessen 1,3 s statt 0,9 s.
*
* Vorab geholt wird **genau die Variante, die das Stylesheet auch nimmt**: drei Verweise mit
* denselben Grenzen wie der Compiler, nicht ein `imagesrcset`. Ein `srcset` waehlt nach
* Breite, die Regeln waehlen nach Geraet; wo beide auseinanderlaufen, laedt der Browser zwei
* Bilder statt einem - und dann kostet das Vorabholen mehr, als es bringt.
*
* @return list<array{src: string, media: string}>
*/
private function heroBackground(?array $header, array $document): array
{
// Steht ueber dem Inhalt ein Kopfbereich mit eigenem Hintergrundbild, ist dessen Bild das
// obere. Sonst der erste Abschnitt des Dokuments.
foreach ([$header, $document] as $kandidat) {
$block = ($kandidat['blocks'] ?? [])[0] ?? null;
$id = $block['style']['desktop']['backgroundImage'] ?? null;
if (!is_string($id) || '' === $id) {
continue;
}
$item = $this->media->find($id);
if (null === $item || [] === ($item['variants'] ?? [])) {
continue;
}
$verweise = [];
foreach (StyleCompiler::PRELOAD_DEVICES as $device => $media) {
$verweise[] = [
'src' => StyleCompiler::variantFor($item['variants'], $device)['src'],
'media' => $media,
];
}
return $verweise;
}
return [];
}
private function firstImage(array ...$documents): ?string
{
foreach ($documents as $document) {
+4 -1
View File
@@ -8,6 +8,7 @@ use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Domain\InvalidPage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -16,7 +17,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme)
{
}
@@ -41,6 +42,8 @@ final class SettingsController extends AbstractController
'consentGroupHelp' => Consent::GROUP_HELP,
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
'images' => $this->media->page(1, false, 96)['items'],
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
]);
}
+92 -1
View File
@@ -3,7 +3,9 @@
namespace App\Controller;
use App\Domain\InvalidPage;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\Installation;
use App\Ops\Updater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -19,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
*/
final class SystemController extends AbstractController
{
public function __construct(private Updater $updater, private Backup $backup, private string $adminPath)
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
{
}
@@ -39,9 +41,92 @@ final class SystemController extends AbstractController
'steps' => $request->getSession()->remove('update_steps') ?: [],
'backups' => $this->backup->all(),
'adminPath' => $this->adminPath,
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
'archivePending' => $this->archive->pending(),
// Wer die Dateien von Hand hochgeladen hat - über FTP, ohne Git und ohne das
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
// ist die einzige Stelle, an der das jemand merken kann.
'pendingMigrations' => $this->pendingMigrations(),
]);
}
/**
* Update über das Releasearchiv - für Installationen, die kein Git kennen.
*
* Zwei Schritte, und zwar mit Absicht: Der erste ersetzt die Dateien, der zweite führt die
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
*/
/**
* Ausstehende Migrationen ausführen - ohne Git, ohne Archiv, ohne Konsole.
*
* Der Weg für ein Update, das von Hand eingespielt wurde: Dateien liegen neu, die Datenbank
* ist alt. Ohne diesen Knopf bräuchte es eine Konsole, und genau die gibt es auf geteiltem
* Hosting nicht.
*/
#[Route('/%kant.admin_path%/system/migrationen', name: 'admin_system_migrate', methods: ['POST'])]
public function migrate(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
try {
$namen = $this->installation->migrate();
$this->addFlash('success', [] === $namen
? 'Es stand keine Migration an - die Datenbank war bereits auf dem Stand des Codes.'
: sprintf('%d Migration(en) ausgeführt: %s.', count($namen), implode(', ', $namen)));
} catch (\Throwable $fehler) {
$this->addFlash('error', 'Die Migration ist fehlgeschlagen: '.$fehler->getMessage());
}
return $this->redirectToRoute('admin_system');
}
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
private function pendingMigrations(): array
{
try {
return $this->installation->pendingMigrations();
} catch (\Throwable) {
return [];
}
}
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
public function archive(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
if ($request->request->getBoolean('finish')) {
$ergebnis = $this->archive->finish($this->installation);
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
: 'Die Migrationen sind fehlgeschlagen. Die Website bleibt im Wartungszustand.');
return $this->redirectToRoute('admin_system');
}
$ergebnis = $this->archive->run($request->request->getString('target') ?: null);
$request->getSession()->set('update_steps', $ergebnis['steps']);
if ($ergebnis['migrate'] ?? false) {
$this->addFlash('success', 'Die Dateien sind ersetzt. Jetzt noch die Migrationen - der Knopf steht oben.');
return $this->redirectToRoute('admin_system');
}
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Es war nichts einzuspielen.'
: 'Das Update ist nicht durchgelaufen. Die Schritte stehen unten.');
return $this->redirectToRoute('admin_system');
}
#[Route('/%kant.admin_path%/system/update', name: 'admin_system_update', methods: ['POST'])]
public function update(Request $request): Response
{
@@ -82,6 +167,12 @@ final class SystemController extends AbstractController
$result = $this->backup->create('Verwaltung');
} catch (InvalidPage $error) {
return $this->back($error->getMessage());
} catch (\Throwable $error) {
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
// lag.
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
}
$removed = $this->backup->rotate();
+147
View File
@@ -0,0 +1,147 @@
<?php
namespace App\Controller;
use App\EventSubscriber\TaskWorkerSubscriber;
use App\Ops\Scheduler;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Der Bildschirm „Aufgaben“ und der Takt des geöffneten Backends.
*
* Zwei verschiedene Dinge in einer Datei, weil sie dasselbe Thema haben: hier sieht der Betreiber,
* was der Plan vorhat und was zuletzt passiert ist – und hier klopft die geöffnete Verwaltung im
* Minutentakt an, damit auf einer Website ohne Besucher trotzdem etwas läuft.
*/
final class TaskController extends AbstractController
{
public function __construct(private Scheduler $scheduler, private string $secret)
{
}
#[Route('/%kant.admin_path%/aufgaben', name: 'admin_tasks', methods: ['GET'])]
public function index(): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$state = $this->scheduler->fastState();
return $this->render('admin/tasks.html.twig', [
'tasks' => $this->scheduler->overview(),
'traffic' => $state['traffic'],
'admin' => $state['admin'],
'next' => $state['next'],
'now' => gmdate('Y-m-d H:i:s'),
'protocol' => $this->scheduler->protocol(30),
// Der Schlüssel für einen URL-Cronjob. Er steht in keiner Datenbank: er wird aus dem
// Anwendungsgeheimnis abgeleitet, ist damit auf jedem Server ein anderer und ändert
// sich, wenn das Geheimnis gewechselt wird.
'cronUrl' => $this->generateUrl('cms_cron', ['token' => Scheduler::cronToken($this->secret)], UrlGeneratorInterface::ABSOLUTE_URL),
]);
}
#[Route('/%kant.admin_path%/aufgaben/ausfuehren', name: 'admin_tasks_run', methods: ['POST'])]
public function run(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
@set_time_limit(0);
$result = $this->scheduler->runNow($request->request->getString('name'));
if (null === $result) {
return $this->back('Diese Aufgabe gibt es nicht oder sie läuft gerade schon.');
}
$this->addFlash('ok' === $result['status'] ? 'success' : 'error', sprintf(
'%s: %s (%d ms)',
$result['label'],
$result['message'],
$result['ms'],
));
return $this->redirectToRoute('admin_tasks');
}
/**
* Abstand, Ein/Aus je Aufgabe und die beiden Auslöser, die in Anfragen stecken.
*
* Ein Formular für alles, weil es eine Entscheidung ist: wie dieser Server seine Aufgaben
* abarbeitet. Zwei getrennte Formulare hätten zwei Speicherknöpfe und dieselbe Wirkung.
*/
#[Route('/%kant.admin_path%/aufgaben/einstellen', name: 'admin_tasks_configure', methods: ['POST'])]
public function configure(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->scheduler->setWorkers(
$request->request->getBoolean('traffic'),
$request->request->getBoolean('admin'),
);
$enabled = $request->request->all('enabled');
foreach ($request->request->all('interval') as $name => $minutes) {
$name = (string) $name;
$this->scheduler->setInterval($name, max(1, (int) $minutes) * 60);
$this->scheduler->enable($name, isset($enabled[$name]));
}
$this->addFlash('success', 'Der Plan ist gespeichert.');
return $this->redirectToRoute('admin_tasks');
}
/** Eine Sperre, die stehen geblieben ist, weil das Hosting den Prozess mitten im Lauf beendet hat. */
#[Route('/%kant.admin_path%/aufgaben/freigeben', name: 'admin_tasks_release', methods: ['POST'])]
public function release(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('tasks', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->scheduler->release($request->request->getString('name'));
$this->addFlash('success', 'Die Sperre ist aufgehoben. Die Aufgabe darf wieder starten.');
return $this->redirectToRoute('admin_tasks');
}
/**
* Der Takt der geöffneten Verwaltung.
*
* Damit läuft der Plan auch auf einer Website, die gerade niemand besucht – solange jemand im
* Backend arbeitet. Das Skript fragt nur, während der Reiter sichtbar ist; ein im Hintergrund
* vergessener Reiter soll keine Sitzung endlos offen halten.
*
* Die Grenzen sind hier großzügiger als beim Besuch: hier wartet niemand auf eine Seite.
*/
#[Route('/%kant.admin_path%/aufgaben/takt', name: 'admin_tasks_tick', methods: ['POST'])]
public function tick(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_EDITOR');
if (!$this->isCsrfTokenValid('tasks-tick', $request->request->getString('_csrf_token'))) {
return new JsonResponse(['error' => 'token'], Response::HTTP_FORBIDDEN);
}
$request->attributes->set(TaskWorkerSubscriber::HANDLED, true);
$state = $this->scheduler->fastState();
if (!$state['admin']) {
return new JsonResponse(['off' => true, 'ran' => []]);
}
@set_time_limit(120);
$ran = $this->scheduler->runDue('verwaltung', 3, 45);
return new JsonResponse([
'ran' => $ran,
'next' => $this->scheduler->fastState()['next'],
]);
}
private function back(string $error): Response
{
$this->addFlash('error', $error);
return $this->redirectToRoute('admin_tasks');
}
}
+30 -3
View File
@@ -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,84 @@
<?php
namespace App\EventSubscriber;
use App\Ops\Scheduler;
use App\Ops\Updater;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Der Auslöser, den ein Hosting ohne Cronjob hat: die Anfrage selbst.
*
* Nach `kernel.terminate`, also nachdem die Antwort das Haus verlassen hat. Unter PHP-FPM ruft
* Symfony dafür `fastcgi_finish_request()` auf - der Browser ist fertig, während hier noch
* gearbeitet wird. Unter Apache mit mod_php gibt es das nicht: die Seite steht dann zwar schon
* im Browser, der Prozess bleibt aber belegt, bis die Aufgabe fertig ist. Deshalb sind die
* Grenzen klein - zwei Aufgaben, zwanzig Sekunden -, und deshalb lässt sich dieser Auslöser
* abschalten, wenn ein echter Cronjob die Arbeit übernimmt.
*
* Der Normalfall kostet nichts: eine Datei lesen, Zeitpunkt vergleichen, fertig. Erst wenn
* wirklich etwas ansteht, wird die Datenbank gefragt.
*/
final class TaskWorkerSubscriber implements EventSubscriberInterface
{
/** Anfragen, hinter denen kein Mensch und keine Seite steht. */
private const IGNORED = '#^/(assets/|media/|favicon)#';
/** Ein Aufruf, der die Aufgaben schon selbst abgearbeitet hat, setzt dieses Attribut. */
public const HANDLED = 'kant_tasks_handled';
public function __construct(
private Scheduler $scheduler,
private string $projectDir,
private string $adminPath,
private int $limit = 2,
private int $budget = 20,
) {
}
public static function getSubscribedEvents(): array
{
// Nach dem Zähler der Statistik (Standardpriorität 0): der schreibt eine Zeile, das hier
// kann Sekunden dauern.
return [KernelEvents::TERMINATE => ['onTerminate', -64]];
}
public function onTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
if ($request->attributes->getBoolean(self::HANDLED)) {
return;
}
$path = $request->getPathInfo();
if (1 === preg_match(self::IGNORED, $path)) {
return;
}
// Während eines Updates läuft nichts: die Dateien sind vielleicht schon die neuen und
// die Datenbank noch die alte. Und vor der Installation gibt es keine Tabelle.
if (is_file($this->projectDir.'/'.Updater::MAINTENANCE_FILE) || !is_file($this->projectDir.'/'.InstallSubscriber::MARKER)) {
return;
}
$admin = str_starts_with($path, '/'.$this->adminPath);
$state = $this->scheduler->fastState();
if (!($admin ? $state['admin'] : $state['traffic'])) {
return;
}
if (null !== $state['next'] && $state['next'] > time()) {
return;
}
try {
// Die Antwort ist raus; ein abgebrochener Aufruf soll die Arbeit trotzdem zu Ende
// bringen, sonst bliebe eine Aufgabe auf halbem Weg mit gesetzter Sperre liegen.
@ignore_user_abort(true);
@set_time_limit($this->budget + 60);
$this->scheduler->runDue($admin ? 'verwaltung' : 'besuch', $this->limit, $this->budget);
} catch (\Throwable) {
// Eine Aufgabe darf niemals eine Website kaputtmachen. Was schiefging, steht in der
// Zeile der Aufgabe und in var/tasks.log - hier ist niemand mehr, dem man es sagen
// könnte: die Antwort ist längst verschickt.
}
}
}
+359
View File
@@ -0,0 +1,359 @@
<?php
namespace App\Ops;
/**
* Ein Update für Installationen ohne Git.
*
* Der Updater über Git setzt eine Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht:
* dort wird über das Releasearchiv installiert, und danach steht in der Verwaltung „Dieses
* Verzeichnis ist keine Git-Arbeitskopie" - also kein Update, außer von Hand über FTP. Das ist
* genau der Fall, für den dieses CMS gedacht ist.
*
* Hier läuft derselbe Weg wie bei der Erstinstallation: Archiv laden, Prüfsumme vergleichen,
* entpacken, Dateien ersetzen, Migrationen ausführen. Mit drei Unterschieden, die ein Update von
* einer Installation trennen:
*
* 1. **Was dem Betreiber gehört, wird nicht angefasst.** `var/`, `.env`, `.env.local`, die
* hochgeladenen Schriften und das erzeugte Stylesheet-Bündel bleiben, wie sie sind.
* 2. **Der alte Stand wird beiseitegelegt, nicht gelöscht.** Geht etwas schief, steht er unter
* `var/update/` und lässt sich zurückschieben.
* 3. **Die Website ist währenddessen im Wartungszustand** - eine halb ersetzte Anwendung darf
* niemand zu sehen bekommen.
*/
final class ArchiveUpdate
{
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
/** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */
private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build'];
/**
* Die Marke zwischen den beiden Schritten.
*
* Sie liegt auf der Platte und nicht in der Sitzung: Fällt die Sitzung zwischen „Dateien
* ersetzt" und „Migrationen" aus - Zeitüberschreitung, anderer Browser, anderer Rechner -,
* bliebe die Website sonst im Wartungszustand und der Knopf, der sie herausholt, wäre weg.
*/
public const MARKE = 'var/update/offen.json';
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
private const PROTOKOLL = 'var/updates.log';
public function __construct(
private string $root,
private Updater $updater,
private Backup $backup,
private Download $download,
private string $releaseBase = 'https://git.kant-it.de/kantarci/KantSites/releases/download',
private string $apiBase = 'https://git.kant-it.de/api/v1/repos/kantarci/KantSites',
) {
}
/**
* Die neueste veröffentlichte Fassung, oder null wenn sie sich nicht ermitteln lässt.
*
* @return array{version: string, url: string, checksum: ?string}|null
*/
public function latest(): ?array
{
$antwort = $this->download->get($this->apiBase.'/releases/latest');
if (null === $antwort) {
return null;
}
try {
$daten = json_decode($antwort, true, 16, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
$tag = (string) ($daten['tag_name'] ?? '');
if (1 !== preg_match('/^v(\d+\.\d+\.\d+)$/D', $tag, $treffer)) {
return null;
}
$version = $treffer[1];
return [
'version' => $version,
'url' => sprintf('%s/%s/kantsites-%s.zip', $this->releaseBase, $tag, $version),
'checksum' => $this->checksumOf(sprintf('%s/%s/kantsites-%s.zip.sha256', $this->releaseBase, $tag, $version)),
];
}
/** Gibt es etwas Neueres als das, was hier läuft? */
public function updateAvailable(): ?string
{
$neueste = $this->latest();
return null !== $neueste && version_compare($neueste['version'], $this->updater->version(), '>')
? $neueste['version']
: null;
}
/**
* Spielt die angegebene (oder die neueste) Fassung ein.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function run(?string $version = null): array
{
$schritte = [];
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
//
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
// Einzige, was von einem gescheiterten Update übrig bleibt.
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
return $ok;
};
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
$neueste = $this->latest();
if (null === $neueste) {
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
$ziel = $version ?? $neueste['version'];
$melden($schritte, 'Fassung ermitteln', true, sprintf('Installiert: %s, verfügbar: %s.', $this->updater->version(), $neueste['version']));
if (version_compare($ziel, $this->updater->version(), '<=')) {
$melden($schritte, 'Nichts zu tun', true, 'Diese Installation ist bereits auf dem neuesten Stand.');
return ['ok' => true, 'steps' => $schritte];
}
$arbeit = $this->root.'/var/update/'.gmdate('Ymd-His');
if (!@mkdir($arbeit, 0o775, true) && !is_dir($arbeit)) {
$melden($schritte, 'Arbeitsordner', false, $arbeit.' ließ sich nicht anlegen. Fehlt das Schreibrecht auf var/?');
return ['ok' => false, 'steps' => $schritte];
}
// Erst sichern, dann anfassen. Eine Sicherung nach dem Ersetzen wäre keine.
try {
$sicherung = $this->backup->create('vor dem Update');
$melden($schritte, 'Sicherung', true, sprintf('Datenbank und Medien liegen unter var/backups/%s.', (string) ($sicherung['name'] ?? 'sicherung')));
} catch (\Throwable $fehler) {
$melden($schritte, 'Sicherung', false, 'Die Sicherung ist fehlgeschlagen: '.$fehler->getMessage().' Ohne sie wird nichts ersetzt.');
return ['ok' => false, 'steps' => $schritte];
}
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
if (null === $inhalt || '' === $inhalt) {
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
return ['ok' => false, 'steps' => $schritte];
}
file_put_contents($archiv, $inhalt);
$melden($schritte, 'Archiv laden', true, sprintf('%d KB geladen.', (int) round(strlen($inhalt) / 1024)));
$erwartet = $ziel === $neueste['version'] ? $neueste['checksum'] : $this->checksumOf(sprintf('%s/v%s/kantsites-%s.zip.sha256', $this->releaseBase, $ziel, $ziel));
if (null === $erwartet) {
$melden($schritte, 'Prüfsumme', false, 'Zu diesem Archiv gibt es keine Prüfsumme. Ohne sie wird nichts entpackt.');
return ['ok' => false, 'steps' => $schritte];
}
$tatsaechlich = hash_file('sha256', $archiv);
if (!hash_equals($erwartet, (string) $tatsaechlich)) {
$melden($schritte, 'Prüfsumme', false, 'Die Prüfsumme stimmt nicht. Das Archiv wird nicht verwendet.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Prüfsumme', true, 'Stimmt mit der Angabe auf der Releaseseite überein.');
$entpackt = $arbeit.'/neu';
$zip = new \ZipArchive();
if (true !== $zip->open($archiv) || !$zip->extractTo($entpackt)) {
$melden($schritte, 'Entpacken', false, 'Das Archiv ließ sich nicht entpacken.');
return ['ok' => false, 'steps' => $schritte];
}
$zip->close();
$quelle = $entpackt.'/kantsites-'.$ziel;
if (!is_file($quelle.'/VERSION') || !is_file($quelle.'/vendor/autoload.php')) {
$melden($schritte, 'Entpacken', false, 'Im Archiv fehlen VERSION oder vendor/ - das ist kein vollständiges Release.');
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Entpacken', true, 'Vollständig: VERSION und vendor/ sind vorhanden.');
$this->updater->maintenance(true);
$melden($schritte, 'Wartungszustand', true, 'Die Website antwortet währenddessen mit 503.');
$alt = $arbeit.'/alt';
@mkdir($alt, 0o775, true);
$ersetzt = 0;
try {
foreach (self::ERSETZEN as $eintrag) {
$ziant = $this->root.'/'.$eintrag;
if (file_exists($ziant) && !@rename($ziant, $alt.'/'.$eintrag)) {
throw new \RuntimeException($eintrag.' ließ sich nicht beiseitelegen.');
}
if (file_exists($quelle.'/'.$eintrag) && !@rename($quelle.'/'.$eintrag, $ziant)) {
throw new \RuntimeException($eintrag.' ließ sich nicht ersetzen.');
}
++$ersetzt;
}
// public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem
// Betreiber und wären sonst weg.
$this->mergePublic($quelle.'/public', $this->root.'/public');
} catch (\Throwable $fehler) {
foreach (self::ERSETZEN as $eintrag) {
if (is_dir($alt.'/'.$eintrag) || is_file($alt.'/'.$eintrag)) {
@rename($this->root.'/'.$eintrag, $quelle.'/'.$eintrag);
@rename($alt.'/'.$eintrag, $this->root.'/'.$eintrag);
}
}
$melden($schritte, 'Dateien ersetzen', false, $fehler->getMessage().' Der alte Stand wurde zurückgeschoben.');
$this->updater->maintenance(false);
return ['ok' => false, 'steps' => $schritte];
}
$melden($schritte, 'Dateien ersetzen', true, sprintf('%d Einträge ersetzt, var/ und .env unberührt.', $ersetzt));
// Der Zwischenspeicher gehört zum alten Stand.
$this->removeDirectory($this->root.'/var/cache');
$melden($schritte, 'Zwischenspeicher', true, 'Geleert - sonst liefe die neue Fassung mit den Wegweisern der alten.');
// Hier endet dieser Durchgang mit Absicht. Der laufende Prozess hat den alten Code im
// Speicher - Klassen, die PHP schon geladen hat, laedt es nicht neu. Die Migrationen
// gehoeren deshalb in eine eigene Anfrage, die mit der neuen Fassung startet. Der
// Wartungszustand bleibt so lange an.
@file_put_contents($this->root.'/'.self::MARKE, json_encode([
'fassung' => $ziel,
'seit' => gmdate('c'),
'alt' => basename($arbeit),
], JSON_UNESCAPED_SLASHES));
$melden($schritte, 'Dateien sind ersetzt', true, sprintf(
'Fassung %s liegt bereit. Jetzt fehlen noch die Migrationen - sie laufen im nächsten Schritt mit dem neuen Code.',
$ziel,
));
return ['ok' => true, 'steps' => $schritte, 'migrate' => true];
}
/**
* Steht ein zweiter Schritt aus? Gibt Fassung und Zeitpunkt zurück, sonst null.
*
* @return array{fassung: string, seit: string, alt: string}|null
*/
public function pending(): ?array
{
$inhalt = @file_get_contents($this->root.'/'.self::MARKE);
if (!is_string($inhalt)) {
return null;
}
try {
$daten = json_decode($inhalt, true, 8, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
return is_array($daten) && isset($daten['fassung']) ? $daten : null;
}
/**
* Der zweite Schritt: Migrationen ausführen und die Website wieder freigeben.
*
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
*
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
*/
public function finish(Installation $installation): array
{
$schritte = [];
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
try {
$namen = $installation->migrate();
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
} catch (\Throwable $fehler) {
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
return ['ok' => false, 'steps' => $schritte];
}
@unlink($this->root.'/'.self::MARKE);
$this->updater->maintenance(false);
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
$this->updater->version(),
)];
return ['ok' => true, 'steps' => $schritte];
}
/**
* Eine Zeile ins Protokoll, und zwar sofort.
*
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
* schreibbar ist, wäre absurd.
*/
private function protokoll(string $zeile): void
{
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
}
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
private function mergePublic(string $von, string $nach): void
{
if (!is_dir($von)) {
return;
}
foreach (scandir($von) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$quelle = $von.'/'.$eintrag;
$ziel = $nach.'/'.$eintrag;
$relativ = ltrim(str_replace($this->root.'/public', '', $ziel), '/');
if (in_array($relativ, self::EIGENES_IN_PUBLIC, true)) {
continue;
}
if (is_dir($quelle)) {
@mkdir($ziel, 0o775, true);
$this->mergePublic($quelle, $ziel);
continue;
}
@copy($quelle, $ziel);
}
}
/** Liest die Prüfsumme aus der Datei neben dem Archiv. */
private function checksumOf(string $url): ?string
{
$inhalt = $this->download->get($url);
if (null === $inhalt || 1 !== preg_match('/^([a-f0-9]{64})/', trim($inhalt), $treffer)) {
return null;
}
return $treffer[1];
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' === $eintrag || '..' === $eintrag) {
continue;
}
$voll = $pfad.'/'.$eintrag;
is_dir($voll) ? $this->removeDirectory($voll) : @unlink($voll);
}
@rmdir($pfad);
}
}
+67 -3
View File
@@ -35,6 +35,14 @@ final class Backup
private string $mediaDirectory,
private Connection $db,
private CommandRunner $runner,
/**
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
*
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
* ungeprueftes Versprechen.
*/
private string $vacuumSince = '3.27.0',
) {
}
@@ -214,9 +222,19 @@ final class Backup
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
}
$target = $directory.'/'.basename($file);
// VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and
// it would not say so until the day it is needed. The path is ours, not user input.
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
//
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
} else {
$this->copyUnderLock($file, $target);
}
if (!is_readable($target)) {
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
}
@@ -224,6 +242,52 @@ final class Backup
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
}
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
private function sqliteVersion(): string
{
try {
return (string) $this->db->fetchOne('SELECT sqlite_version()');
} catch (\Throwable) {
return '0';
}
}
/**
* Eine konsistente Kopie ohne VACUUM INTO.
*
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
*
* @throws InvalidPage
*/
private function copyUnderLock(string $file, string $target): void
{
try {
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
} catch (\Throwable) {
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
}
try {
$this->db->executeStatement('BEGIN IMMEDIATE');
} catch (\Throwable $fehler) {
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
}
try {
if (!@copy($file, $target)) {
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
}
} finally {
try {
$this->db->executeStatement('COMMIT');
} catch (\Throwable) {
// Die Sperre faellt spaetestens mit der Verbindung.
}
}
}
/** @return array{count: int, bytes: int} */
private function copyMedia(string $target): array
{
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Ops;
/**
* Holt eine Datei über HTTPS - über curl, ersatzweise über den Dateizeiger.
*
* Zwei Wege, weil auf einem Webhosting mal das eine da ist und mal das andere: curl fehlt auf
* manchen Paketen, `allow_url_fopen` ist auf anderen abgeschaltet. Dieselbe Doppelung steht im
* Installer und beim Laden der Schriften.
*
* Eine eigene Klasse ist das aus zwei Gründen: Die Prüfung des Zertifikats und die Sperre für
* alles außer HTTPS stehen an genau einer Stelle - und der Updater lässt sich prüfen, ohne dass
* ein Test ins Netz geht.
*/
class Download
{
/**
* Die curl-Fehler, die „ich kann das Zertifikat nicht prüfen" heißen.
*
* Als Zahlen und nicht als Konstanten: `CURLE_PEER_FAILED_VERIFICATION` gibt es nicht in
* jedem PHP-Bau, und ein fehlender Konstantenname wäre ein Absturz genau an der Stelle, an
* der eigentlich eine hilfreiche Meldung stehen soll.
*/
private const ZERTIFIKATSFEHLER = [51, 60, 77];
/** Warum der letzte Versuch nichts gebracht hat - in einem Satz, der jemandem weiterhilft. */
private string $grund = '';
public function __construct(
private int $connectTimeout = 15,
private int $timeout = 900,
) {
}
/**
* Der Grund des letzten Fehlschlags.
*
* Ohne ihn steht in der Verwaltung „die Releaseseite antwortet nicht", und das ist in drei
* Fällen dieselbe Meldung: kein Weg nach draußen, kein Zertifikatsspeicher, falsche Adresse.
* Der mittlere ist auf einem Webhosting der häufigste und der einzige, den man in einer
* Minute behebt - er muss also dastehen.
*/
public function reason(): string
{
return $this->grund;
}
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
public function get(string $url): ?string
{
$this->grund = '';
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
// kommt, kann unterwegs ausgetauscht worden sein.
if (!str_starts_with($url, 'https://')) {
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
return null;
}
if (function_exists('curl_init')) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => 'KantSites-Updater',
]);
$antwort = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$fehler = curl_errno($curl);
$text = curl_error($curl);
curl_close($curl);
if (is_string($antwort) && 200 === $status) {
return $antwort;
}
// Ein fehlender Zertifikatsspeicher ist keine kaputte Leitung, sondern eine
// Einstellung - und er sieht sonst genauso aus wie eine gesperrte Verbindung.
$this->grund = match (true) {
in_array($fehler, self::ZERTIFIKATSFEHLER, true) => 'Das Zertifikat der Gegenstelle lässt sich nicht prüfen: Diesem PHP fehlt ein Zertifikatsspeicher (curl.cainfo in der php.ini). Geholt wird trotzdem nur mit Prüfung - ein Update ohne sie könnte unterwegs ausgetauscht worden sein.',
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
0 === $status => 'Der Server hat nicht geantwortet.',
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
};
// Bei einem Zertifikatsproblem lohnt der zweite Weg: Der Dateizeiger benutzt die
// Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet.
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
return null;
}
}
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
$this->grund = '' !== $this->grund ? $this->grund : 'Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.';
return null;
}
$context = stream_context_create([
'http' => ['timeout' => $this->timeout, 'follow_location' => 1, 'max_redirects' => 3, 'header' => "User-Agent: KantSites-Updater\r\n"],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$antwort = @file_get_contents($url, false, $context);
if (is_string($antwort)) {
$this->grund = '';
return $antwort;
}
if ('' === $this->grund) {
$this->grund = 'Die Adresse war über den Dateizeiger nicht erreichbar.';
}
return null;
}
}
+498
View File
@@ -0,0 +1,498 @@
<?php
namespace App\Ops;
use App\Ops\Tasks\Task;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\TableNotFoundException;
/**
* Wiederkehrende Aufgaben auf einem Hosting, das keinen Cronjob hat.
*
* Das Problem ist nicht, *was* zu tun ist, sondern *wer es anstoesst*. Auf geteiltem Webhosting
* gibt es oft weder SSH noch Cron; es gibt nur Anfragen. Also uebernehmen die Anfragen die
* Arbeit - nach der Antwort, in `kernel.terminate`, damit kein Besucher darauf wartet. Vier
* Ausloeser, dieselbe Mechanik:
*
* - **Besuch:** jeder Seitenaufruf schaut nach, ob etwas faellig ist (`TaskWorkerSubscriber`).
* - **Verwaltung:** das geoeffnete Backend fragt im Takt nach, auch ohne Besucher.
* - **URL-Cron:** ein Aufruf von `/cms-cron/<Schluessel>`, wenn der Hoster das anbietet.
* - **Konsole:** `cms:tasks:run`, auch als Dauerlaeufer mit `--loop`.
*
* Was das *nicht* kann, und das gehoert an diese Stelle und nicht ins Kleingedruckte: **ohne
* Anfrage passiert nichts.** Eine Aufgabe fuer 03:00 Uhr laeuft beim ersten Aufruf danach, auf
* einer stillen Website also vielleicht um 07:15 Uhr. Fuer Sitemap, Aufraeumen, Updatepruefung
* und Sicherung ist das brauchbar; fuer etwas, das minutengenau sein muss, ist es das nicht.
*
* Zwei Eigenschaften machen das im Betrieb tragfaehig:
*
* 1. **Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann das
* naechste Mal ueberhaupt etwas ansteht. Solange dieser Zeitpunkt in der Zukunft liegt, ist
* ein Seitenaufruf mit einem `file_get_contents` fertig.
* 2. **Zwei Besucher fuehren eine Aufgabe nicht zweimal aus.** Wer sie ausfuehren darf, wird mit
* einem bedingten UPDATE entschieden - das ist auf MySQL wie auf SQLite atomar. Wer keine
* Zeile veraendert hat, war zu spaet und geht weiter.
*/
final class Scheduler
{
public const STATE_FILE = 'var/scheduler.json';
public const LOG_FILE = 'var/tasks.log';
/**
* Wie lange eine begonnene Aufgabe als laufend gilt.
*
* Es gibt keinen Weg, das Ende eines Prozesses zu erfahren, den das Hosting mitten im Lauf
* abgeschossen hat. Also verfaellt die Sperre von selbst. Fuenfzehn Minuten sind lang genug
* fuer eine Sicherung und kurz genug, dass eine haengengebliebene Aufgabe denselben Tag
* wieder mitmacht.
*/
public const LOCK_SECONDS = 900;
/** Obergrenze fuer den wachsenden Abstand nach Fehlschlaegen: sechs Stunden. */
private const MAX_BACKOFF = 21600;
/** So viele Zeilen behaelt das Protokoll beim Kuerzen. */
private const LOG_LINES = 200;
/** @var array<string, Task>|null */
private ?array $registry = null;
/** @param iterable<Task> $tasks */
public function __construct(
private Connection $db,
private iterable $tasks,
private string $projectDir,
) {
}
// ---------------------------------------------------------------- Ausfuehren
/**
* Fuehrt aus, was faellig ist - hoechstens `$limit` Aufgaben und hoechstens `$budget` Sekunden.
*
* Beide Grenzen sind da, weil dieser Aufruf in einer Webanfrage stecken kann. Sie werden
* *zwischen* zwei Aufgaben geprueft, nicht innerhalb einer: eine laufende Sicherung
* abzubrechen waere schlimmer, als sie zu Ende laufen zu lassen.
*
* @return list<array{name: string, label: string, status: string, message: string, ms: int}>
*/
public function runDue(string $trigger, int $limit = 3, int $budget = 20, ?\DateTimeImmutable $now = null): array
{
$now ??= self::now();
$registry = $this->registry();
$rows = $this->rows();
// Nach einem Update kennt der Code eine Aufgabe mehr oder eine weniger als die Datenbank.
// Das faellt hier auf, und zwar ohne eine zusaetzliche Abfrage.
if (array_keys($rows) !== array_keys($registry)) {
if (!$this->sync($now)) {
return [];
}
$rows = $this->rows();
}
$started = microtime(true);
$results = [];
foreach ($this->due($rows, $now) as $row) {
if (count($results) >= $limit || microtime(true) - $started > $budget) {
break;
}
if (!$this->claim((string) $row['name'], $now)) {
// Ein anderer Prozess war schneller. Das ist kein Fehler, das ist der Zweck.
continue;
}
$results[] = $this->execute($registry[(string) $row['name']], $row, $trigger, self::now());
}
$this->refreshState();
return $results;
}
/**
* Fuehrt eine Aufgabe sofort aus, unabhaengig von ihrem Zeitplan.
*
* Der Knopf "Jetzt ausfuehren" in der Verwaltung. Abgeschaltete Aufgaben laufen so trotzdem -
* wer sie von Hand startet, meint sie auch. Die Sperre gilt aber weiter: zweimal derselbe
* Klick soll nicht zwei Sicherungen anlegen.
*
* Gibt null zurueck, wenn es die Aufgabe nicht gibt oder sie gerade schon laeuft.
*
* @return array{name: string, label: string, status: string, message: string, ms: int}|null
*/
public function runNow(string $name, string $trigger = 'hand'): ?array
{
$now = self::now();
$registry = $this->registry();
if (!isset($registry[$name])) {
return null;
}
$this->sync($now);
$rows = $this->rows();
if (!isset($rows[$name]) || !$this->claim($name, $now, false)) {
return null;
}
$result = $this->execute($registry[$name], $rows[$name], $trigger, self::now());
$this->refreshState();
return $result;
}
/**
* Sichert sich eine Aufgabe.
*
* Der ganze Schutz gegen doppelte Ausfuehrung steckt in dieser einen Anweisung: die
* Bedingungen stehen im WHERE, nicht in PHP. Zwischen einem `SELECT ... WHERE faellig` und
* einem `UPDATE` liegt ein Moment, in dem ein zweiter Prozess dasselbe liest - hier nicht.
* Wer 1 zurueckbekommt, hat sie; wer 0 zurueckbekommt, war zu spaet.
*/
private function claim(string $name, \DateTimeImmutable $now, bool $onlyWhenDue = true): bool
{
$stamp = $now->format('Y-m-d H:i:s');
$until = $now->modify('+'.self::LOCK_SECONDS.' seconds')->format('Y-m-d H:i:s');
$sql = 'UPDATE scheduled_task SET locked_until = ?, last_run_at = ? WHERE name = ? AND (locked_until IS NULL OR locked_until <= ?)';
$parameters = [$until, $stamp, $name, $stamp];
if ($onlyWhenDue) {
$sql .= ' AND next_run_at <= ?';
$parameters[] = $stamp;
}
try {
return 1 === $this->db->executeStatement($sql, $parameters);
} catch (\Throwable) {
return false;
}
}
/**
* @param array<string, mixed> $row
*
* @return array{name: string, label: string, status: string, message: string, ms: int}
*/
private function execute(Task $task, array $row, string $trigger, \DateTimeImmutable $now): array
{
$begin = microtime(true);
try {
$message = trim($task->run());
$status = 'ok';
$failures = 0;
} catch (\Throwable $error) {
// Auch ein Fehler ist ein Ergebnis. Er steht in der Zeile, im Protokoll und auf dem
// Bildschirm "Aufgaben" - und er verlaengert den Abstand bis zum naechsten Versuch,
// damit eine Aufgabe, die nicht laufen *kann*, nicht bei jedem Aufruf neu anfaengt.
$status = 'fehler';
$message = sprintf('%s (%s:%d)', $error->getMessage(), basename($error->getFile()), $error->getLine());
$failures = (int) $row['failures'] + 1;
}
$ms = (int) round((microtime(true) - $begin) * 1000);
$interval = max(60, (int) $row['interval_seconds']);
$delay = 'ok' === $status
? $interval
: (int) min(self::MAX_BACKOFF, $interval * 2 ** min($failures, 6));
$message = '' === $message ? 'Erledigt.' : mb_substr($message, 0, 480);
try {
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, last_status = ?, last_message = ?, last_duration_ms = ?, last_trigger = ?, failures = ?, locked_until = NULL WHERE name = ?',
[
$now->modify('+'.$delay.' seconds')->format('Y-m-d H:i:s'),
$status, $message, $ms, $trigger, $failures, $task->name(),
],
);
} catch (\Throwable) {
// Die Aufgabe ist gelaufen; nur das Nachtragen ging schief. Die Sperre verfaellt von
// selbst, und das Protokoll unten haelt fest, was passiert ist.
}
$this->append(sprintf(
'[%s] %-14s %-7s %6d ms %-11s %s',
$now->format('Y-m-d H:i:s'),
$task->name(),
$status,
$ms,
$trigger,
str_replace(["\r", "\n"], ' ', $message),
));
return ['name' => $task->name(), 'label' => $task->label(), 'status' => $status, 'message' => $message, 'ms' => $ms];
}
// ---------------------------------------------------------------- Zustand und Verwaltung
/**
* Der schnelle Blick, ohne Datenbank: darf der Besuchs- beziehungsweise Backend-Ausloeser
* arbeiten, und wann steht ueberhaupt das naechste Mal etwas an?
*
* Fehlt die Datei - frische Installation, geleertes `var/`, kein Schreibrecht -, ist die
* Antwort "ja, und der Zeitpunkt ist unbekannt". Dann fragt der naechste Aufruf die
* Datenbank und schreibt sie neu. Der teure Weg ist der Ausnahmefall, nicht der Normalfall.
*
* @return array{traffic: bool, admin: bool, next: ?int}
*/
public function fastState(): array
{
$raw = @file_get_contents($this->projectDir.'/'.self::STATE_FILE);
$data = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($data)) {
return ['traffic' => true, 'admin' => true, 'next' => null];
}
return [
'traffic' => (bool) ($data['traffic'] ?? true),
'admin' => (bool) ($data['admin'] ?? true),
'next' => isset($data['next']) ? (int) $data['next'] : null,
];
}
/**
* Der Schluessel fuer den URL-Cronjob, abgeleitet aus dem Anwendungsgeheimnis.
*
* Nicht in der Datenbank, weil er dann eine Migration, eine Oberflaeche zum Wechseln und
* einen Weg braeuchte, ihn nach einem Restore wiederzufinden. So ist er auf jeder
* Installation ein anderer, ueberlebt jedes Update und wechselt genau dann, wenn das
* Geheimnis wechselt - also dann, wenn man ihn wechseln will.
*/
public static function cronToken(string $secret): string
{
return substr(hash_hmac('sha256', 'cms-cron', $secret), 0, 40);
}
/** Schaltet die beiden Ausloeser, die in Anfragen stecken. Konsole und URL-Cron laufen unabhaengig davon weiter. */
public function setWorkers(bool $traffic, bool $admin): void
{
$this->writeState(['traffic' => $traffic, 'admin' => $admin, 'next' => $this->fastState()['next']]);
}
/**
* Legt fehlende Zeilen an und entfernt Zeilen ohne Aufgabe im Code.
*
* Neue Aufgaben starten mit einem Abstand von zwei Minuten und nicht sofort: sonst faellt
* direkt nach einem Update alles gleichzeitig an, und zwar im ersten Seitenaufruf danach.
*
* @return bool false, wenn die Tabelle (noch) nicht da ist - vor der Installation und
* zwischen hochgeladenem Code und ausgefuehrter Migration
*/
public function sync(?\DateTimeImmutable $now = null): bool
{
$now ??= self::now();
$registry = $this->registry();
try {
$existing = $this->rows();
foreach ($registry as $name => $task) {
if (isset($existing[$name])) {
continue;
}
$this->db->insert('scheduled_task', [
'name' => $name,
'interval_seconds' => $task->interval(),
'enabled' => true,
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
'last_message' => '',
'last_duration_ms' => 0,
'last_trigger' => '',
'failures' => 0,
]);
}
foreach (array_keys($existing) as $name) {
if (!isset($registry[$name])) {
$this->db->executeStatement('DELETE FROM scheduled_task WHERE name = ?', [$name]);
}
}
} catch (TableNotFoundException) {
// Vor der Installation und zwischen hochgeladenem Code und ausgefuehrter Migration.
return false;
} catch (\Throwable) {
return false;
}
$this->refreshState();
return true;
}
/**
* Alles, was der Bildschirm "Aufgaben" zeigt.
*
* @return list<array{name: string, label: string, explanation: string, interval: int,
* enabled: bool, next: string, last: ?string, status: ?string, message: string,
* ms: int, trigger: string, failures: int, running: bool, due: bool}>
*/
public function overview(?\DateTimeImmutable $now = null): array
{
$now ??= self::now();
$this->sync($now);
$stamp = $now->format('Y-m-d H:i:s');
$overview = [];
foreach ($this->rows() as $name => $row) {
$task = $this->registry()[$name] ?? null;
if (null === $task) {
continue;
}
$locked = (string) ($row['locked_until'] ?? '');
$overview[] = [
'name' => $name,
'label' => $task->label(),
'explanation' => $task->explanation(),
'interval' => (int) $row['interval_seconds'],
'enabled' => (bool) $row['enabled'],
'next' => (string) $row['next_run_at'],
'last' => null !== $row['last_run_at'] ? (string) $row['last_run_at'] : null,
'status' => null !== $row['last_status'] ? (string) $row['last_status'] : null,
'message' => (string) $row['last_message'],
'ms' => (int) $row['last_duration_ms'],
'trigger' => (string) $row['last_trigger'],
'failures' => (int) $row['failures'],
'running' => '' !== $locked && $locked > $stamp,
'due' => (bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp,
];
}
return $overview;
}
public function enable(string $name, bool $on): void
{
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]);
$this->refreshState();
}
/** Der Abstand, in Sekunden. Unter einer Minute ergibt er keinen Sinn, ueber einem Monat auch nicht. */
public function setInterval(string $name, int $seconds): void
{
$this->db->executeStatement(
'UPDATE scheduled_task SET interval_seconds = ? WHERE name = ?',
[max(60, min(2592000, $seconds)), $name],
);
$this->refreshState();
}
/** Gibt eine haengende Sperre frei - fuer den Fall, dass ein Prozess mitten im Lauf abgebrochen wurde. */
public function release(string $name): void
{
$this->db->executeStatement('UPDATE scheduled_task SET locked_until = NULL WHERE name = ?', [$name]);
$this->refreshState();
}
/**
* Die letzten Zeilen des Protokolls, neueste zuerst.
*
* @return list<string>
*/
public function protocol(int $lines = 40): array
{
$raw = @file_get_contents($this->projectDir.'/'.self::LOG_FILE);
if (!is_string($raw) || '' === trim($raw)) {
return [];
}
return array_slice(array_reverse(array_values(array_filter(explode("\n", trim($raw))))), 0, $lines);
}
// ---------------------------------------------------------------- Innenleben
/** @return array<string, Task> */
private function registry(): array
{
if (null !== $this->registry) {
return $this->registry;
}
$registry = [];
foreach ($this->tasks as $task) {
$registry[$task->name()] = $task;
}
ksort($registry);
return $this->registry = $registry;
}
/** @return array<string, array<string, mixed>> */
private function rows(): array
{
try {
$rows = $this->db->fetchAllAssociative('SELECT * FROM scheduled_task');
} catch (\Throwable) {
return [];
}
$indexed = [];
foreach ($rows as $row) {
$indexed[(string) $row['name']] = $row;
}
ksort($indexed);
return $indexed;
}
/**
* @param array<string, array<string, mixed>> $rows
*
* @return list<array<string, mixed>>
*/
private function due(array $rows, \DateTimeImmutable $now): array
{
$stamp = $now->format('Y-m-d H:i:s');
$due = [];
foreach ($rows as $row) {
$locked = (string) ($row['locked_until'] ?? '');
if ((bool) $row['enabled'] && (string) $row['next_run_at'] <= $stamp && ('' === $locked || $locked <= $stamp)) {
$due[] = $row;
}
}
// Die am laengsten ueberfaellige zuerst - sonst kommt eine Aufgabe mit kurzem Abstand
// immer wieder dran und eine mit langem nie.
usort($due, static fn (array $a, array $b): int => strcmp((string) $a['next_run_at'], (string) $b['next_run_at']));
return $due;
}
/**
* Schreibt in die Zustandsdatei, wann das naechste Mal etwas ansteht.
*
* Eine laufende Aufgabe zaehlt dabei erst ab dem Ende ihrer Sperre. Ohne das waere waehrend
* einer langen Sicherung jeder Seitenaufruf wieder bei der Datenbank: faellig ist sie ja,
* sie ist nur schon vergeben. Mit dem Ende der Sperre steht in der Datei ein Zeitpunkt in
* der Zukunft, und die uebrigen Anfragen sind nach einem Dateizugriff fertig.
*/
private function refreshState(): void
{
$next = null;
foreach ($this->rows() as $row) {
if (!(bool) $row['enabled']) {
continue;
}
$when = max((string) $row['next_run_at'], (string) ($row['locked_until'] ?? ''));
$stamp = strtotime($when.' UTC');
if (false !== $stamp && (null === $next || $stamp < $next)) {
$next = $stamp;
}
}
$state = $this->fastState();
$this->writeState(['traffic' => $state['traffic'], 'admin' => $state['admin'], 'next' => $next]);
}
/** @param array{traffic: bool, admin: bool, next: ?int} $state */
private function writeState(array $state): void
{
$file = $this->projectDir.'/'.self::STATE_FILE;
if (!is_dir(dirname($file))) {
@mkdir(dirname($file), 0o775, true);
}
// Schlaegt das fehl - schreibgeschuetztes var/ -, fragt jeder Aufruf wieder die
// Datenbank. Langsamer, aber richtig; ein Fehler ist es nicht.
@file_put_contents($file, json_encode($state, JSON_PRETTY_PRINT), LOCK_EX);
}
private function append(string $line): void
{
$file = $this->projectDir.'/'.self::LOG_FILE;
if (!is_dir(dirname($file))) {
@mkdir(dirname($file), 0o775, true);
}
@file_put_contents($file, $line."\n", FILE_APPEND | LOCK_EX);
// Selbst begrenzend, damit das Protokoll auch ohne die Aufraeumaufgabe nie gross wird.
if ((int) @filesize($file) > 100000) {
$lines = array_values(array_filter(explode("\n", (string) @file_get_contents($file))));
@file_put_contents($file, implode("\n", array_slice($lines, -self::LOG_LINES))."\n", LOCK_EX);
}
}
private static function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Ops\Tasks;
use App\Ops\Backup;
/**
* Die tägliche Sicherung von Datenbank und Medien.
*
* Die schwerste Aufgabe im Plan, und die einzige, bei der das Ausführen in einer Webanfrage
* wirklich etwas kostet: sie kopiert die Datenbank und den Medienordner. Der Besucher wartet
* trotzdem nicht darauf – sie läuft nach der ausgelieferten Antwort. Was sie belegt, ist ein
* PHP-Prozess des Hostings, einmal am Tag, für die Dauer einer Kopie.
*
* Wer einen echten Cronjob hat, sollte sie dort laufen lassen und hier abschalten. Wer keinen
* hat, hat mit dieser Aufgabe eine tägliche Sicherung statt gar keiner – und das ist der Grund,
* warum sie trotz ihres Gewichts im Plan steht.
*/
final class BackupTask implements Task
{
public function __construct(private Backup $backup)
{
}
public function name(): string
{
return 'backup';
}
public function label(): string
{
return 'Sicherung anlegen';
}
public function explanation(): string
{
return 'Ohne sie gibt es keinen Rückweg außer der von Hand angelegten Sicherung. Sie rotiert selbst und enthält Datenbank und Medien, aber weder .env noch vendor/.';
}
public function interval(): int
{
return 86400;
}
public function run(): string
{
$result = $this->backup->create('automatisch');
$removed = $this->backup->rotate();
return sprintf(
'%s angelegt: %d Seite(n), %d Mediendatei(en).%s',
$result['name'],
$result['manifest']['inhalt']['seiten'],
$result['manifest']['medien']['dateien'],
[] === $removed ? '' : sprintf(' %d alte entfernt.', count($removed)),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Ops\Tasks;
use App\Ops\ArchiveUpdate;
use App\Ops\Scheduler;
use App\Ops\Updater;
/**
* Räumt in `var/` auf, was mit der Zeit liegen bleibt.
*
* Drei Dinge, und ausdrücklich nur diese drei: abgelaufene Sitzungsdateien, alte Arbeitsordner
* eines Updates und zu lang gewordene Protokolle. Nichts davon ist Inhalt, nichts davon lässt
* sich vermissen.
*
* Was diese Aufgabe **nicht** anfasst: Sicherungen (die rotiert `cms:backup` selbst und nach
* einer anderen Regel), den Cache (den baut Symfony auf, wenn er fehlt, aber ihn mitten im
* Betrieb zu löschen macht die nächste Anfrage langsam ohne Grund) und den Ordner eines Updates,
* das noch auf seinen zweiten Schritt wartet.
*/
final class CleanupTask implements Task
{
/** Sitzungen und Update-Arbeitsordner, die älter sind, werden entfernt. */
private const KEEP_DAYS = 30;
/** So viele Zeilen bleiben in den Protokollen stehen. */
private const KEEP_LINES = 200;
public function __construct(private string $projectDir)
{
}
public function name(): string
{
return 'cleanup';
}
public function label(): string
{
return 'Aufräumen';
}
public function explanation(): string
{
return sprintf(
'Entfernt Sitzungsdateien und Update-Arbeitsordner, die älter als %d Tage sind, und kürzt die Protokolle. Sicherungen und Inhalte bleiben unberührt.',
self::KEEP_DAYS,
);
}
public function interval(): int
{
return 86400;
}
public function run(): string
{
$limit = time() - self::KEEP_DAYS * 86400;
return sprintf(
'%d Sitzungsdatei(en), %d Update-Ordner entfernt, %d Protokoll(e) gekürzt.',
$this->sessions($limit),
$this->updateFolders($limit),
$this->logs(),
);
}
/** Sitzungsdateien. Der Garbage Collector von PHP ist auf vielen Hostings abgeschaltet. */
private function sessions(int $limit): int
{
$removed = 0;
foreach (glob($this->projectDir.'/var/sessions/*/sess_*') ?: [] as $file) {
if (is_file($file) && (int) @filemtime($file) < $limit && @unlink($file)) {
++$removed;
}
}
return $removed;
}
/**
* Die Arbeitsordner des Archiv-Updates.
*
* Der jüngste ist der Rückweg aus dem letzten Update, und den lässt diese Aufgabe einen
* Monat lang stehen. Wartet ein Update noch auf seinen zweiten Schritt, wird gar nichts
* angefasst: dann ist einer dieser Ordner der Stand, auf den zurückgegangen wird.
*/
private function updateFolders(int $limit): int
{
if (is_file($this->projectDir.'/'.ArchiveUpdate::MARKE)) {
return 0;
}
$removed = 0;
foreach (glob($this->projectDir.'/var/update/*', GLOB_ONLYDIR) ?: [] as $folder) {
if ((int) @filemtime($folder) < $limit && $this->removeDirectory($folder)) {
++$removed;
}
}
return $removed;
}
private function logs(): int
{
$shortened = 0;
foreach ([Updater::LOG_FILE, Scheduler::LOG_FILE] as $relative) {
$file = $this->projectDir.'/'.$relative;
$raw = @file_get_contents($file);
if (!is_string($raw)) {
continue;
}
$lines = array_values(array_filter(explode("\n", $raw)));
if (count($lines) <= self::KEEP_LINES) {
continue;
}
if (false !== @file_put_contents($file, implode("\n", array_slice($lines, -self::KEEP_LINES))."\n", LOCK_EX)) {
++$shortened;
}
}
return $shortened;
}
/** Rekursiv löschen, aber nur unterhalb von `var/` – geprüft, nicht angenommen. */
private function removeDirectory(string $path): bool
{
$real = realpath($path);
$var = realpath($this->projectDir.'/var');
if (false === $real || false === $var || !str_starts_with($real, $var.DIRECTORY_SEPARATOR)) {
return false;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
return @rmdir($real);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Ops\Tasks;
use App\Content\Statistics;
/**
* Verdichtet und löscht die Zähler der Statistik.
*
* Einmal täglich reicht: die Tabelle wächst mit Seiten und Stunden, nicht mit Besuchen, und ein
* Tag Verzug ändert an ihrer Größe nichts.
*/
final class PruneStatisticsTask implements Task
{
public function __construct(private Statistics $statistics)
{
}
public function name(): string
{
return 'stats-prune';
}
public function label(): string
{
return 'Statistik verdichten';
}
public function explanation(): string
{
return sprintf(
'Ohne sie behält die Statistik jede Stunde einzeln, für immer. Verdichtet wird nach %d Tagen, gelöscht nach %d.',
Statistics::COMPACT_AFTER_DAYS,
Statistics::KEEP_DAYS,
);
}
public function interval(): int
{
return 86400;
}
public function run(): string
{
$result = $this->statistics->prune();
return sprintf(
'%d Tag(e) zusammengefasst, %d Zeile(n) jenseits von %d Tagen entfernt.',
$result['compacted'],
$result['removed'],
Statistics::KEEP_DAYS,
);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Ops\Tasks;
use App\Content\PageRepository;
/**
* Veröffentlicht Seiten, deren geplanter Zeitpunkt vergangen ist.
*
* Die Aufgabe mit dem kürzesten Abstand, weil sie die einzige ist, die jemand *bemerkt*: wer eine
* Seite für Montag 9 Uhr plant, schaut Montag um 9 Uhr nach. Fünf Minuten sind der Kompromiss
* zwischen „wirkt sofort" und „fällt bei jedem zweiten Seitenaufruf an".
*/
final class PublishDueTask implements Task
{
public function __construct(private PageRepository $pages)
{
}
public function name(): string
{
return 'publish-due';
}
public function label(): string
{
return 'Geplante Veröffentlichungen';
}
public function explanation(): string
{
return 'Ohne sie bleibt eine geplante Seite ein Entwurf – die sichere Richtung, aber nicht die gewollte.';
}
public function interval(): int
{
return 300;
}
public function run(): string
{
$published = $this->pages->publishDue();
return [] === $published
? 'Nichts fällig.'
: sprintf('%d Seite(n) veröffentlicht: /%s', count($published), implode(', /', $published));
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Ops\Tasks;
/**
* Eine wiederkehrende Aufgabe.
*
* Der Vertrag ist absichtlich winzig, weil der Ausloeser nicht feststeht: dieselbe Aufgabe laeuft
* je nach Hosting im Seitenaufruf eines Besuchers, im geoeffneten Backend, ueber einen
* URL-Cronjob oder auf der Konsole. Was sie tut, darf davon nicht abhaengen.
*
* Drei Regeln, die aus dem Ausfuehrungsort folgen:
*
* 1. **Kurz sein oder unterbrechbar.** Ein Lauf haengt an einem PHP-Prozess, den das Hosting
* nach seiner eigenen Zeit abbricht. Wer viel zu tun hat, arbeitet portionsweise und
* berichtet, was offen blieb - der naechste Lauf macht weiter.
* 2. **Fehler werfen, nicht schlucken.** Eine geworfene Ausnahme wird zum sichtbaren Fehler mit
* wachsendem Abstand bis zum naechsten Versuch. Ein stilles `return` sieht aus wie Erfolg.
* 3. **Nichts ausgeben.** Es gibt keine Konsole; die Rueckgabe ist der ganze Bericht.
*/
interface Task
{
/** Technischer, gleichbleibender Name, zum Beispiel `publish-due`. Er ist der Schluessel in der Datenbank. */
public function name(): string;
/** Die Bezeichnung in der Verwaltung. */
public function label(): string;
/** Ein Satz darueber, was ausbleibt, wenn diese Aufgabe nicht laeuft. */
public function explanation(): string;
/** Vorgabe fuer den Abstand zwischen zwei Laeufen, in Sekunden. In der Verwaltung aenderbar. */
public function interval(): int;
/**
* Fuehrt die Aufgabe aus.
*
* @return string eine kurze Meldung fuer den Betreiber, die auch dann etwas sagt, wenn nichts
* zu tun war ("Nichts faellig." ist eine Antwort, "" ist keine)
*
* @throws \Throwable wenn die Aufgabe ihr Ziel nicht erreicht hat
*/
public function run(): string;
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Ops\Tasks;
use App\Ops\ArchiveUpdate;
use App\Ops\Updater;
/**
* Fragt einmal täglich, ob es eine neuere Fassung gibt.
*
* Sie prüft nur und installiert nichts. Ein Update tauscht Dateien und führt Migrationen aus;
* das gehört an einen Knopf, den ein Mensch drückt, und nicht in den Seitenaufruf eines
* Besuchers.
*
* Ist die Fassungsliste nicht erreichbar, ist das ein Fehler und keine Entwarnung: „keine
* Antwort" und „kein Update" sehen sonst gleich aus, und das eine ist eine Nachricht, das andere
* eine ausgefallene Prüfung.
*/
final class UpdateCheckTask implements Task
{
public function __construct(private ArchiveUpdate $archive, private Updater $updater)
{
}
public function name(): string
{
return 'update-check';
}
public function label(): string
{
return 'Nach Updates sehen';
}
public function explanation(): string
{
return 'Fragt beim Repository nach der neuesten Fassung. Sie installiert nichts – das Ergebnis steht hier und auf dem Bildschirm „System".';
}
public function interval(): int
{
return 86400;
}
public function run(): string
{
$latest = $this->archive->latest();
if (null === $latest) {
throw new \RuntimeException('Die Fassungsliste war nicht erreichbar. Erlaubt dieses Hosting ausgehende Verbindungen?');
}
$installed = $this->updater->version();
return version_compare($latest['version'], $installed, '>')
? sprintf('Fassung %s ist verfügbar; installiert ist %s.', $latest['version'], $installed)
: sprintf('Aktuell: Fassung %s.', $installed);
}
}
+12 -3
View File
@@ -46,14 +46,23 @@ final class Updater
public function state(): array
{
$git = $this->runner->available() && $this->runner->has('git', $this->root);
$status = $git ? $this->git(['status', '--porcelain']) : null;
// Zwei verschiedene Fragen, und die Verwechslung war ein echter Fehler: Ob der Befehl
// `git` existiert, sagt nichts darueber, ob dieses Verzeichnis eine Arbeitskopie ist. Ein
// Hosting hat oft beides - git installiert, aber die Installation kam aus dem
// Releasearchiv. Dann lief jeder Aufruf ins Leere, und die Verwaltung zeigte dem
// Betreiber „fatal: Not a git repository“ an einer Stelle, an der eine Fassungsnummer
// stehen sollte.
$arbeitskopie = $git && is_dir($this->root.'/.git');
$status = $arbeitskopie ? $this->git(['status', '--porcelain']) : null;
return [
'version' => $this->version(),
'commit' => $git ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
'describe' => $git ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
'commit' => $arbeitskopie ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
'describe' => $arbeitskopie ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
'git' => $git,
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
'worktree' => $arbeitskopie,
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
'log' => $this->log(),
];
+35
View File
@@ -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 : '';
}
}
+18
View File
@@ -7,8 +7,26 @@
<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', 'preview-containers'] %}
"/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 %}
{# Eigener Block, nicht Teil von `scripts`: Bildschirme wie die Mediathek ersetzen `scripts`
vollstaendig, und der Takt der Verwaltung darf davon nicht abhaengen. #}
{% block worker %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
<a class="skip-link" href="#admin-main">Zum Inhalt springen</a>
+10 -11
View File
@@ -19,30 +19,29 @@
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
<main id="admin-main" class="editor-layout" tabindex="-1">
<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"><h3 class="preset-heading">Element hinzufügen</h3>
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
<div id="rows" class="row-library">
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
{% endfor %}
</div>
<div id="library" class="library">
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
</div>
<div class="section-launch"><button id="add-root-section" class="secondary full-width" disabled>+ Neue Sektion auf Seitenebene</button><p class="field-help">Nach dem aktuellen Abschnitt, außerhalb des gewählten Inhalts.</p></div>
<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>
<div class="panel-resizer" data-resize="left" role="separator" tabindex="0" aria-label="Linke Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="240" aria-valuemax="560" aria-valuenow="280" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<section class="canvas-panel" aria-label="Live-Vorschau">
<div class="canvas-toolbar"><span class="canvas-label">LIVE-VORSCHAU</span><div class="viewport-controls" role="group" aria-label="Vorschaugröße"><button data-viewport="desktop" aria-pressed="true">Desktop</button><button data-viewport="tablet" aria-pressed="false">Tablet</button><button data-viewport="mobile" aria-pressed="false">Mobil</button></div><a href="{{ page.slug == 'home' ? '/' : '/' ~ page.slug }}" target="_blank" rel="noopener" aria-label="Öffentliche Seite öffnen">↗</a></div>
<p id="preview-status" class="preview-status" role="status">Vorschau wird geladen …</p>
<nav id="breadcrumbs" class="breadcrumbs" aria-label="Ausgewähltes Element"></nav>
<div class="canvas-scroll" tabindex="0" role="region" aria-label="Vorschau scrollen"><div id="preview-stage"><iframe id="preview" title="Vorschau der bearbeiteten Seite" sandbox="allow-same-origin" class="viewport-desktop"></iframe></div></div>
</section>
<div class="panel-resizer" data-resize="right" role="separator" tabindex="0" aria-label="Rechte Seitenleiste vergrößern oder verkleinern" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="560" aria-valuenow="320" title="Ziehen oder Pfeiltasten · Doppelklick setzt zurück"></div>
<aside class="right-panel" aria-label="Eigenschaften"><div class="panel-section"><p class="eyebrow">FEINSCHLIFF</p><h2 id="properties-title">Seiteneinstellungen</h2><div class="property-tabs" role="group" aria-label="Eigenschaftenbereich"><button data-panel="content" aria-pressed="true">Inhalt</button><button data-panel="design" aria-pressed="false">Design</button><button data-panel="advanced" aria-pressed="false">Erweitert</button></div><div id="properties"><p class="muted">Inhalte werden geladen …</p></div></div><div class="panel-note">Die Vorschau zeigt deinen Entwurf. Besucher sehen weiterhin den veröffentlichten Stand.</div></aside>
</main>
<noscript><p class="notice error">Zum Bearbeiten muss JavaScript aktiviert sein. Die öffentliche Website funktioniert ohne JavaScript.</p></noscript>
+21
View File
@@ -0,0 +1,21 @@
{# Die Symbole der Verwaltung, als Inline-SVG.
Vorher standen hier Unicode-Zeichen (▤ ◫ ☰ ▩ ⚙). Auf dem Schreibtisch sehen sie wie Symbole
aus, auf einem Telefon ersetzt das Betriebssystem sie durch Emoji - buntes Zahnrad, buntes
Briefsymbol, jedes in einem anderen Stil. Eigene Pfade lösen das endgültig: eine Linienstärke,
eine Farbe (currentColor), kein zusätzlicher Request, keine Schriftdatei. #}
{% set paths = {
pages: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6M9 16h6',
partials: 'M4 5h16v14H4Zm0 5h16M9 10v9',
menus: 'M4 6h16M4 12h16M4 18h10',
media: 'M4 5h16v14H4Zm0 10 4-4 4 4 3-3 5 5M8.5 9.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z',
forms: 'M3 6h18v12H3Zm0 1 9 7 9-7',
statistics: 'M4 20h16M7 20v-6m5 6V6m5 14v-9',
theme: 'M12 3a9 9 0 1 0 0 18Zm0 0a9 9 0 0 1 0 18',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm8-3a8 8 0 0 0-.2-1.7l2-1.5-2-3.5-2.3 1a8 8 0 0 0-3-1.7L14 2h-4l-.5 2.6a8 8 0 0 0-3 1.7l-2.3-1-2 3.5 2 1.5a8 8 0 0 0 0 3.4l-2 1.5 2 3.5 2.3-1a8 8 0 0 0 3 1.7L10 22h4l.5-2.6a8 8 0 0 0 3-1.7l2.3 1 2-3.5-2-1.5c.1-.6.2-1.1.2-1.7Z',
system: 'M8 8h8v8H8Zm-4 2h4m-4 4h4m8-4h4m-4 4h4M10 4v4m4-4v4m-4 8v4m4-4v4',
tasks: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13.5V12l3.5 2.2',
users: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8c0-3.3 3.6-5 8-5s8 1.7 8 5',
external: 'M14 4h6v6m0-6-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5'
} %}
<svg class="nav-icon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name]|default(paths.pages) }}"/></svg>
+1 -1
View File
@@ -201,7 +201,7 @@
<div class="field">
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Die Veröffentlichung erfolgt, sobald <code>cms:publish-due</code> läuft.</p>
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
</div>
<button class="secondary" type="submit">Zeitpunkt speichern</button>
</form>
+2 -2
View File
@@ -109,11 +109,11 @@
</div>
<div>
<label for="scrollTopColor">Farbe</label>
<input id="scrollTopColor" name="scrollTopColor" value="{{ settings.scrollTopColor }}" placeholder="@accent">
<input id="scrollTopColor" name="scrollTopColor" value="{{ settings.scrollTopColor }}" placeholder="@accent" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
</div>
<div>
<label for="scrollTopInk">Farbe des Pfeils</label>
<input id="scrollTopInk" name="scrollTopInk" value="{{ settings.scrollTopInk }}" placeholder="#ffffff">
<input id="scrollTopInk" name="scrollTopInk" value="{{ settings.scrollTopInk }}" placeholder="#ffffff" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
</div>
<div>
<label for="scrollTopLabel">Beschriftung für Vorlesesoftware</label>
+36 -13
View File
@@ -2,34 +2,57 @@
click away and the current one is visible. Screens fill in heading, lead, actions and content. #}
{% extends 'admin/base.html.twig' %}
{% block body_class %}admin-shell{% endblock %}
{# Solange jemand hier arbeitet, laeuft der Aufgabenplan - auch auf einer Website ohne Besucher.
Das Skript fragt im Minutentakt an; was faellig ist, entscheidet der Server. #}
{% block worker %}
<meta name="kant-tick" content="{{ path('admin_tasks_tick') }}">
<meta name="kant-tick-token" content="{{ csrf_token('tasks-tick') }}">
<script src="/assets/task-worker.js?v={{ kant_version() }}" defer></script>
{% endblock %}
{% block body %}
<header class="shell-nav">
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>sites</span></a>
{# Auf dem Telefon ein Klappmenue, am Schreibtisch die gewohnte Spalte. Ein details-Element
kann beides, ohne eine Zeile JavaScript: die Zusammenfassung wird auf grossen Bildschirmen
ausgeblendet und der Inhalt dauerhaft gezeigt. Zwoelf Eintraege in einer Wischleiste zu
verstecken war die schlechtere Loesung - man sieht nie, was man nicht trifft. #}
<details class="shell-menu">
<summary>
<svg class="shell-menu-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<span class="shell-menu-label">{{ {
'pages': 'Seiten', 'partials': 'Bereiche', 'menus': 'Menüs', 'media': 'Mediathek',
'forms': 'Formulare', 'statistics': 'Statistik', 'theme': 'Design',
'settings': 'Einstellungen', 'system': 'System', 'tasks': 'Aufgaben', 'users': 'Benutzer',
'account': 'Mein Konto'
}[nav|default('')]|default('Verwaltung') }}</span>
</summary>
<nav aria-label="Verwaltung">
<p class="nav-label" id="nav-content">INHALT</p>
<ul class="nav-list" aria-labelledby="nav-content">
{% set entries = [
{id: 'pages', route: 'admin_pages', icon: '▤', label: 'Seiten'},
{id: 'partials', route: 'admin_partials', icon: '◫', label: 'Bereiche'},
{id: 'menus', route: 'admin_menus', icon: '☰', label: 'Menüs'},
{id: 'media', route: 'admin_media', icon: '▩', label: 'Mediathek'},
{id: 'forms', route: 'admin_forms', icon: '✉', label: 'Formulare'},
{id: 'statistics', route: 'admin_statistics', icon: '▁', label: 'Statistik'}
{id: 'pages', route: 'admin_pages', icon: 'pages', label: 'Seiten'},
{id: 'partials', route: 'admin_partials', icon: 'partials', label: 'Bereiche'},
{id: 'menus', route: 'admin_menus', icon: 'menus', label: 'Menüs'},
{id: 'media', route: 'admin_media', icon: 'media', label: 'Mediathek'},
{id: 'forms', route: 'admin_forms', icon: 'forms', label: 'Formulare'},
{id: 'statistics', route: 'admin_statistics', icon: 'statistics', label: 'Statistik'}
] %}
{% for entry in entries %}
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">{{ entry.icon }}</span>{{ entry.label }}</a></li>
<li><a class="nav-item" href="{{ path(entry.route) }}"{{ nav|default('') == entry.id ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: entry.icon} only %}{{ entry.label }}</a></li>
{% endfor %}
</ul>
<p class="nav-label" id="nav-site">WEBSITE</p>
<ul class="nav-list" aria-labelledby="nav-site">
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⌬</span>System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'users'} only %}Benutzer</a></li>{% endif %}
<li><a class="nav-item nav-out" href="/">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Website ansehen</a></li>
<li><a class="nav-item nav-out" href="/beitraege">{% include 'admin/icon.html.twig' with {name: 'external'} only %}Beitragsübersicht</a></li>
</ul>
</nav>
</details>
<div class="shell-foot">
<p class="shell-version">Fassung {{ kant_version() }}</p>
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
+81 -3
View File
@@ -2,7 +2,7 @@
{% set nav = 'system' %}
{% block title %}System{% endblock %}
{% block heading %}System und Update{% endblock %}
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Code wechseln, migrieren, Rauchtest.{% endblock %}
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %}
{% block content %}
<div class="card">
@@ -22,7 +22,13 @@
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
{% endif %}
</dd>
{% if state.worktree %}
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
{% else %}
<dt>Eingerichtet über</dt>
<dd>das <strong>Releasearchiv</strong> <span class="muted">– kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.</span></dd>
{% endif %}
{% if state.worktree %}
<dt>Verfügbar</dt>
<dd>
{% if available %}
@@ -33,6 +39,7 @@
<a href="{{ path('admin_system', {pruefen: 1}) }}">Beim Repository nachfragen</a>
{% endif %}
</dd>
{% endif %}
</dl>
{% if state.maintenance %}
@@ -49,6 +56,11 @@
</div>
</div>
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
vollkommen in Ordnung ist. #}
{% if state.worktree %}
<div class="card">
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">Jede Bedingung wird geprüft, nicht angenommen</span></div>
<div class="card-body">
@@ -62,7 +74,67 @@
</ul>
</div>
</div>
{% endif %}
{# Waehrend das Archiv-Update auf seinen zweiten Schritt wartet, gehoert der Knopf dorthin -
zwei Knoepfe, die dasselbe tun, sind eine Frage zu viel. #}
{% if pendingMigrations|default([]) and not archivePending %}
<div class="card">
<div class="card-head">
<h2>Migrationen stehen aus</h2>
<span class="muted">{{ pendingMigrations|length }} Stück</span>
</div>
<div class="card-body">
<p class="notice">Der Code ist neuer als die Datenbank. Das passiert, wenn ein Update von Hand über FTP eingespielt wurde – dann fehlt der Schritt, den sonst der Updater übernimmt.</p>
<ul class="log-list">{% for name in pendingMigrations %}<li><code>{{ name }}</code></li>{% endfor %}</ul>
<p class="field-help">Lege vorher eine Sicherung an: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
<form action="{{ path('admin_system_migrate') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="primary" type="submit">Migrationen jetzt ausführen →</button>
</form>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-head">
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }}</span>
</div>
<div class="card-body">
{% if archivePending %}
<p class="notice">Die Dateien der Fassung <strong>{{ archivePending.fassung }}</strong> liegen bereits (seit {{ archivePending.seit|slice(0, 16)|replace({"T": " "}) }} UTC). <strong>Es fehlen noch die Migrationen</strong> – bis dahin bleibt die Website im Wartungszustand.</p>
<form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<input type="hidden" name="finish" value="1">
<button class="primary" type="submit">Migrationen ausführen und freigeben →</button>
</form>
{% else %}
<p class="muted">Dieser Weg braucht kein Git: Er lädt das Archiv der neuen Fassung von der Releaseseite, vergleicht die Prüfsumme, sichert Datenbank und Medien, schaltet den Wartungszustand ein und ersetzt die Dateien der Anwendung. <strong>Unberührt bleiben <code>var/</code>, <code>.env</code>, <code>.env.local</code>, die geladenen Schriften und das erzeugte Stylesheet-Bündel.</strong> Der vorherige Stand wird beiseitegelegt, nicht gelöscht.</p>
<p class="field-help">Danach folgt ein zweiter Knopf für die Migrationen. Das ist kein Umweg: Erst eine neue Anfrage startet mit dem Code der neuen Fassung – ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.</p>
{% if archiveVersion %}
<p class="notice done">Verfügbar: <strong>{{ archiveVersion }}</strong> (installiert: {{ state.version }}).</p>
{% elseif asked %}
<p class="field-help">Die Releaseseite nennt nichts Neueres als {{ state.version }}.</p>
{% else %}
<p class="field-help"><a href="{{ path('admin_system', {pruefen: 1}) }}">Nachsehen, ob es etwas Neues gibt</a> – das fragt die Releaseseite und dauert einen Moment.</p>
{% endif %}
<form action="{{ path('admin_system_archive') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="fields">
<div>
<label for="archive-target">Fassung</label>
<input id="archive-target" name="target" maxlength="20" placeholder="{{ archiveVersion ?: '1.4.2' }}" pattern="[0-9]+\.[0-9]+\.[0-9]+">
<p class="field-help">Leer lassen für die neueste veröffentlichte Fassung.</p>
</div>
</div>
<button class="primary" type="submit">Update aus dem Archiv einspielen →</button>
</form>
{% endif %}
</div>
</div>
{% if state.worktree %}
<div class="card">
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
<div class="card-body">
@@ -87,6 +159,7 @@
</form>
</div>
</div>
{% endif %}
{% if steps is not empty %}
<div class="card">
@@ -96,7 +169,12 @@
{% for step in steps %}
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
<span><strong>{{ step.step }}</strong> {{ step.detail }}</span>
{# `step.label` ist die Schreibweise bis 1.4.4. Sie muss hier stehen bleiben: Beim
Update von 1.4.4 auf 1.4.5 schreibt der **alte** Code die Schritte in die
Sitzung und der **neue** zeigt sie an - eine Fassung, die nur die neue
Schreibweise kennt, könnte das Update, das sie selbst einspielt, nicht
berichten. #}
<span><strong>{{ step.step|default(step.label|default('Schritt')) }}</strong> {{ step.detail }}</span>
</li>
{% endfor %}
</ol>
@@ -108,7 +186,7 @@
<div class="card-head"><h2>Sicherungen</h2><span class="muted">{{ backups|length }} vorhanden · var/backups</span></div>
<div class="card-body">
<p class="muted">Eine Sicherung enthält die Datenbank als vollständige Kopie – nicht als Dateikopie während des Schreibens, die unbrauchbar sein kann – und den Medienordner. <strong>Nicht enthalten sind <code>.env</code>, <code>vendor/</code> und der Quellcode</strong>: die kommen aus dem Repository, und <code>.env</code> enthält das Geheimnis dieser Installation.</p>
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Für den täglichen Cronjob: <code>php bin/console cms:backup</code>.</p>
<p class="field-help">Zurückspielen geht bewusst nur auf der Konsole: <code>php bin/console cms:restore &lt;name&gt;</code>. Ein Knopf, der die ganze Datenbank ersetzt, gehört nicht neben einen, der eine Seite speichert. Die tägliche Sicherung erledigt der <a href="{{ path('admin_tasks') }}">Aufgabenplan</a> – auch ohne Cronjob.</p>
<form action="{{ path('admin_system_backup') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="primary" type="submit">Sicherung jetzt anlegen</button>
+131
View File
@@ -0,0 +1,131 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'tasks' %}
{% block title %}Aufgaben{% endblock %}
{% block heading %}Wiederkehrende Aufgaben{% endblock %}
{% block lead %}Was regelmäßig laufen soll, wer es anstößt und was zuletzt dabei herauskam. Alle Zeiten in UTC.{% endblock %}
{% block content %}
{# Der wichtigste Satz zuerst, und zwar als Einschränkung und nicht als Werbung: ohne Anfrage
passiert nichts. Wer das nicht weiß, sucht später den Fehler an der falschen Stelle. #}
<div class="card">
<div class="card-head">
<h2>Wer die Arbeit anstößt</h2>
<span class="muted">{{ next is null ? 'Zeitpunkt noch unbekannt' : 'Nächster Lauf: ' ~ next|date('Y-m-d H:i:s', 'UTC') ~ ' UTC' }}</span>
</div>
<div class="card-body">
<p class="muted">Ein Webhosting ohne Cronjob hat nur eines: Anfragen. Deshalb erledigt eine Anfrage die Arbeit – nach der ausgelieferten Antwort, sodass niemand darauf wartet. <strong>Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.</strong> Eine Aufgabe für 03:00 Uhr läuft dann beim ersten Aufruf danach, auf einer stillen Website vielleicht um 07:15 Uhr. Wer es genauer braucht, richtet einen der beiden unteren Wege ein.</p>
<form action="{{ path('admin_tasks_configure') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<fieldset class="field-set">
<legend>Auslöser in Anfragen</legend>
<label class="check-label"><input type="checkbox" name="traffic" value="1"{{ traffic ? ' checked' : '' }}> Besuche der Website</label>
<p class="field-help">Jeder Seitenaufruf schaut nach, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei und keine Datenbankabfrage. Ausschalten, wenn ein echter Cronjob die Arbeit übernimmt.</p>
<label class="check-label"><input type="checkbox" name="admin" value="1"{{ admin ? ' checked' : '' }}> Geöffnete Verwaltung</label>
<p class="field-help">Solange dieser Bildschirm offen und sichtbar ist, fragt er jede Minute nach. So läuft der Plan auch auf einer Website, die gerade niemand besucht.</p>
</fieldset>
<div class="section-head"><h2>Der Plan</h2><span class="muted">Abstand in Minuten</span></div>
<table class="table tasks-table">
<thead>
<tr><th scope="col">Aufgabe</th><th scope="col" class="check-cell">An</th><th scope="col">Abstand</th><th scope="col">Nächster Lauf</th><th scope="col">Zuletzt</th><th scope="col"></th></tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<strong>{{ task.label }}</strong>
<p class="field-help">{{ task.explanation }}</p>
<p class="field-help"><code>{{ task.name }}</code></p>
</td>
<td class="check-cell">
<input type="checkbox" name="enabled[{{ task.name }}]" value="1"{{ task.enabled ? ' checked' : '' }} aria-label="{{ task.label }} ausführen">
</td>
<td><input type="number" name="interval[{{ task.name }}]" value="{{ (task.interval / 60)|round }}" min="1" max="43200" step="1" aria-label="Abstand für {{ task.label }} in Minuten"></td>
<td>
{% if not task.enabled %}<span class="pill pill-off">aus</span>
{% elseif task.running %}<span class="pill pill-draft">läuft</span>
{% elseif task.due %}<span class="pill pill-draft">fällig</span>
{% else %}<span class="muted">{{ task.next }}</span>{% endif %}
</td>
<td>
{% if task.status is null %}
<span class="muted">noch nie</span>
{% else %}
<span class="pill {{ task.status == 'ok' ? 'pill-live' : 'pill-off' }}">{{ task.status }}</span>
<p class="field-help">{{ task.last }} · {{ task.ms }} ms{% if task.trigger %} · durch {{ task.trigger }}{% endif %}</p>
<p class="field-help{{ task.status == 'ok' ? '' : ' danger-text' }}">{{ task.message }}</p>
{% if task.failures > 1 %}<p class="field-help danger-text">{{ task.failures }} Fehlschläge nacheinander – der Abstand bis zum nächsten Versuch wächst.</p>{% endif %}
{% endif %}
</td>
<td>
<button class="secondary" type="submit" form="run-{{ task.name }}">Jetzt ausführen</button>
{% if task.running %}<button class="text-button" type="submit" form="release-{{ task.name }}">Sperre aufheben</button>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6">Keine Aufgaben. Fehlt die Migration? Der Bildschirm <a href="{{ path('admin_system') }}">System</a> führt sie aus.</td></tr>
{% endfor %}
</tbody>
</table>
<button class="primary" type="submit">Plan speichern</button>
</form>
{# Die Formulare der Knöpfe stehen außerhalb der Tabelle: ein Formular im Formular gibt es
in HTML nicht, und `form="..."` verbindet Knopf und Formular über die Tabelle hinweg. #}
{% for task in tasks %}
<form id="run-{{ task.name }}" action="{{ path('admin_tasks_run') }}" method="post" hidden>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<input type="hidden" name="name" value="{{ task.name }}">
</form>
{% if task.running %}
<form id="release-{{ task.name }}" action="{{ path('admin_tasks_release') }}" method="post" hidden>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('tasks') }}">
<input type="hidden" name="name" value="{{ task.name }}">
</form>
{% endif %}
{% endfor %}
</div>
</div>
<div class="card">
<div class="card-head"><h2>Der zuverlässige Weg</h2><span class="muted">falls dieses Hosting ihn anbietet</span></div>
<div class="card-body">
<p class="muted">Beide Wege sind unabhängig von den Schaltern oben – sie laufen auch dann, wenn Besuch und Verwaltung ausgeschaltet sind. Wer einen davon einrichtet, sollte den Besuchs-Auslöser abschalten: die Arbeit ist dann schon getan, und die Website antwortet ein wenig schneller.</p>
<dl class="facts">
<dt>URL-Cronjob</dt>
<dd>
<code>{{ cronUrl }}</code>
<p class="field-help">Diese Adresse beim Hoster als Cronjob eintragen, zum Beispiel alle fünf Minuten. Der Schlüssel ist aus dem Anwendungsgeheimnis abgeleitet, steht in keiner Datenbank und ändert sich, wenn das Geheimnis gewechselt wird. Er gehört nicht in eine E-Mail und nicht in ein Ticket.</p>
</dd>
<dt>Konsole</dt>
<dd>
<code>php bin/console cms:tasks:run</code>
<p class="field-help">Für einen echten Cronjob, jede Minute. <code>cms:tasks</code> zeigt denselben Plan wie dieser Bildschirm, <code>cms:tasks:run --loop=60</code> läuft dauerhaft.</p>
</dd>
</dl>
</div>
</div>
<div class="card">
<div class="card-head"><h2>Protokoll</h2><span class="muted">die letzten Läufe, neueste zuerst</span></div>
<div class="card-body">
{% if protocol is empty %}
<p class="muted">Noch nichts gelaufen. Steht in der Spalte „Nächster Lauf“ ein Zeitpunkt, ist alles in Ordnung – der Plan wartet.</p>
{% else %}
<div class="table-wrap">
<table class="table">
<tbody>
{% for line in protocol %}
<tr><td><code>{{ line }}</code></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="field-help">Aus <code>var/tasks.log</code>. Die Datei kürzt sich selbst; die Aufgabe „Aufräumen“ tut es ebenfalls.</p>
{% endif %}
</div>
</div>
{% endblock %}
+3 -3
View File
@@ -96,15 +96,15 @@
</div>
<div>
<label for="base-color">Textfarbe</label>
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink">
<input id="base-color" name="base[color]" value="{{ theme.base.color }}" placeholder="@ink" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
</div>
<div>
<label for="base-bg">Hintergrundfarbe</label>
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper">
<input id="base-bg" name="base[backgroundColor]" value="{{ theme.base.backgroundColor }}" placeholder="@paper" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
</div>
<div>
<label for="base-link">Linkfarbe</label>
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent">
<input id="base-link" name="base[linkColor]" value="{{ theme.base.linkColor }}" placeholder="@accent" data-color data-palette="{{ theme.colors|json_encode|e('html_attr') }}">
</div>
</div>
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>.</p>
+1 -1
View File
@@ -13,7 +13,7 @@
<ul class="check-list">
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Impressum und Datenschutzerklärung ausfüllen.</strong> Beide Seiten sind angelegt und veröffentlicht; die Pflichtangaben stehen als <code>NOCH AUSZUFÜLLEN</code> darin. Solange sie so dastehen, sind sie eine Baustelle mit Ansage – aber keine Erklärung.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Die Datei <code>install.php</code> löschen</strong>, falls du über sie installiert hast. Sie löscht sich selbst, aber nachsehen kostet nichts.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Einen täglichen Cronjob einrichten:</strong> <code>php bin/console cms:backup</code> für die Sicherung und <code>php bin/console cms:stats:prune</code> für die Statistik. Ohne Sicherung gibt es keinen Rückweg.</span></li>
<li class="check-ok"><span class="check-mark" aria-hidden="true">✓</span><span><strong>Sicherung und Aufräumen laufen bereits.</strong> Der Aufgabenplan unter „Aufgaben“ wird von Seitenaufrufen und vom geöffneten Backend angestoßen – ohne Cronjob. Genau, aber nicht minutengenau: ohne Besuch passiert nichts. Wenn dein Hosting einen Cronjob anbietet, trage dort <code>php bin/console cms:tasks:run</code> ein (jede Minute) oder die Adresse des URL-Cronjobs vom Bildschirm „Aufgaben“.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>HTTPS prüfen.</strong> Eine Anmeldung über <code>http://</code> überträgt das Passwort im Klartext.</span></li>
<li class="check-warn"><span class="check-mark" aria-hidden="true">·</span><span><strong>Inhalt und Gestaltung:</strong> Startseite bearbeiten, unter „Design“ Farben und Schriften setzen, unter „Einstellungen“ die Angaben zum Unternehmen ergänzen.</span></li>
</ul>
+5
View File
@@ -27,6 +27,11 @@
{% include 'favicon.html.twig' %}
{# Ein Bündel statt vier Dateien: jede einzelne hält das Zeichnen der Seite auf. Fehlt es
(kein Schreibrecht im Ordner), werden die Quellen einzeln geladen - langsamer, aber heil. #}
{# Das grosse Bild ueber der Falz, wenn es der Hintergrund des ersten Abschnitts ist: Es
steht sonst nur im Stylesheet und wird erst gefunden, wenn das geparst ist. Genau eine der
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
{% endfor %}
{% if styleBundle|default(null) %}
<link rel="stylesheet" href="{{ styleBundle }}">
{% else %}
+362
View File
@@ -0,0 +1,362 @@
<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
*
* Auf geteiltem Hosting gibt es keine Arbeitskopie, also auch kein `git fetch`. Dort wird über
* das Archiv installiert und über das Archiv aktualisiert, und das ist der Weg, den die meisten
* Installationen dieses CMS nehmen werden. Er war bis hierhin ungeprüft.
*
* Geprüft wird gegen eine Installation in einem Wegwerfverzeichnis und eine Releaseseite, die im
* Speicher liegt (@see ReleaseOnDisk): echte Archive, echte Prüfsummen, echtes Entpacken, echte
* Umbenennungen - nur eben ohne Netz. Die Frage, um die es geht, ist nicht „läuft der Download",
* sondern: Bleibt stehen, was dem Betreiber gehört?
*/
final class ArchiveUpdateTest extends TestCase
{
private const BASE = 'https://releases.example.invalid/download';
private const API = 'https://releases.example.invalid/api';
private string $root;
private Connection $db;
private ReleaseOnDisk $releases;
protected function setUp(): void
{
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-archiv-'.bin2hex(random_bytes(6));
// Eine Installation, wie sie nach dem Entpacken eines Releases aussieht.
foreach (['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'var/media', 'public/assets/fonts', 'public/assets/build'] as $verzeichnis) {
mkdir($this->root.'/'.$verzeichnis, 0o775, true);
}
$this->write('VERSION', "1.0.0\n");
$this->write('vendor/autoload.php', "<?php // alt\n");
$this->write('src/Alt.php', "<?php // wird ersetzt\n");
$this->write('composer.json', '{"name":"alt"}');
$this->write('composer.lock', '{"packages":[]}');
$this->write('migrations.php', "<?php return [];\n");
$this->write('cli-config.php', "<?php // alt\n");
$this->write('public/index.php', "<?php // alt\n");
$this->write('public/assets/site.css', 'body{color:alt}');
// Was dem Betreiber gehört - hochgeladene Schriften, das erzeugte Bündel, seine Medien,
// seine Datenbank, sein Geheimnis. Nichts davon steht im Archiv.
$this->write('public/assets/fonts/eigene.woff2', 'SCHRIFT');
$this->write('public/assets/build/bundle.css', 'body{color:erzeugt}');
$this->write('var/media/bild.jpg', 'BILD');
$this->write('.env', "APP_SECRET=geheim\n");
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
TestSchema::build($this->db);
$this->releases = new ReleaseOnDisk();
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
$this->removeDirectory($this->root);
}
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
{
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
self::assertSame(
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
array_column($ergebnis['steps'], 'step'),
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
);
// Die Anwendung ist die neue.
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileExists($this->root.'/src/Neu.php');
self::assertFileDoesNotExist($this->root.'/src/Alt.php', 'src/ wird ersetzt, nicht ergänzt.');
self::assertStringContainsString('neu', (string) file_get_contents($this->root.'/public/index.php'));
self::assertSame('body{color:neu}', file_get_contents($this->root.'/public/assets/site.css'));
// Und alles, was dem Betreiber gehört, steht noch da.
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2', 'Schriften aus dem Archiv würden die hochgeladenen überschreiben.');
self::assertSame('body{color:erzeugt}', file_get_contents($this->root.'/public/assets/build/bundle.css'));
self::assertSame('BILD', file_get_contents($this->root.'/var/media/bild.jpg'));
self::assertStringContainsString('APP_SECRET=geheim', (string) file_get_contents($this->root.'/.env'));
self::assertFileExists($this->root.'/var/cms.db');
// Die Website bleibt bis zu den Migrationen im Wartungszustand, und der alte Stand liegt
// noch da, falls doch etwas zurückgeschoben werden muss.
self::assertFileExists($this->root.'/'.Updater::MAINTENANCE_FILE);
self::assertCount(1, glob($this->root.'/var/backups/sicherung-*') ?: []);
self::assertCount(1, glob($this->root.'/var/update/*/alt/VERSION') ?: []);
// Der zweite Schritt ist auf der Platte vermerkt, nicht in der Sitzung: fällt sie aus,
// bliebe die Website sonst im Wartungszustand und der Knopf wäre weg.
$offen = $this->updater()->pending();
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
self::assertSame('1.1.0', $offen['fassung']);
}
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
{
self::assertNull($this->updater()->pending());
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
self::assertNull($this->updater()->pending(), 'Ein Update, das nicht ersetzt hat, lässt nichts offen.');
}
public function testAnArchiveWithTheWrongChecksumIsNeverUnpacked(): void
{
// Der Fall, für den die Prüfsumme da ist: unterwegs ausgetauscht oder halb übertragen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['step']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
self::assertFileExists($this->root.'/src/Alt.php');
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
}
public function testAnIncompleteArchiveIsRefusedBeforeTheSiteGoesIntoMaintenance(): void
{
// Ein Archiv ohne vendor/ ist auf einem Hosting ohne Composer wertlos - das muss auffallen,
// bevor die halbe Anwendung ersetzt ist.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0', false));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['step']);
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testAnUnreachableReleasePageChangesNothingAtAll(): void
{
// Viele Hoster lassen ausgehende Verbindungen nicht zu. Dann passiert hier gar nichts -
// insbesondere wird nicht gesichert, nicht ersetzt und kein Arbeitsordner angelegt.
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertCount(1, $ergebnis['steps']);
self::assertStringContainsString('Releaseseite', $ergebnis['steps'][0]['detail']);
self::assertSame([], glob($this->root.'/var/update/*') ?: []);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
{
$this->releases->release(self::BASE, self::API, '1.0.0', $this->archive('1.0.0'));
$updater = $this->updater();
self::assertNull($updater->updateAvailable());
$ergebnis = $updater->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertFalse($ergebnis['migrate'] ?? false);
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['step']);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
}
public function testTheAvailableVersionIsOnlyReportedWhenItIsNewer(): void
{
self::assertNull($this->updater()->updateAvailable(), 'Ohne Antwort gibt es keine neue Fassung.');
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
self::assertSame('1.1.0', $this->updater()->updateAvailable());
// Etwas, das keine Fassung ist, wird nicht angeboten - sonst lädt der Updater irgendetwas.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'nightly']));
self::assertNull($this->updater()->updateAvailable());
}
public function testTheDownloaderRefusesEverythingThatIsNotHttps(): void
{
// Kein Netz nötig, um das zu prüfen: die Sperre greift vor dem ersten Byte. Ein Update
// über eine unverschlüsselte Verbindung könnte unterwegs ausgetauscht worden sein.
$download = new Download();
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
self::assertStringContainsString('HTTPS', $download->reason(), 'Und es muss dastehen, woran es lag.');
}
}
public function testAFailedDownloadSaysWhatWentWrong(): void
{
// Drei Ursachen sehen in der Verwaltung sonst gleich aus: kein Weg nach draußen, kein
// Zertifikatsspeicher, falsche Adresse. Die mittlere ist auf einem Webhosting die
// häufigste und in einer Minute behoben - wenn jemand sie liest.
$download = new Download(connectTimeout: 2, timeout: 5);
self::assertNull($download->get('https://kein-server.invalid/datei.zip'));
self::assertNotSame('', $download->reason(), 'Ein Fehlschlag ohne Grund ist eine Sackgasse.');
// Und der Weg durch den Updater: Die Meldung im Schritt trägt den Grund mit.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'v1.1.0']));
$this->releases->serve(sprintf('%s/v1.1.0/kantsites-1.1.0.zip.sha256', self::BASE), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
$gescheitert = $this->firstFailed($ergebnis);
self::assertSame('Archiv laden', $gescheitert['step']);
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
}
public function testEveryStepIsWrittenToTheLogWhileItHappens(): void
{
// Auf Umuts Hosting endete die Ergebnisseite mit 500, und damit war auch verloren, was
// der Updater berichtet hatte. Eine Zeile in einer Datei überlebt das - und auf einem
// Hosting ohne Konsole ist sie manchmal das Einzige, was von einem gescheiterten Update
// übrig bleibt. Geschrieben wird während des Laufs, nicht am Ende: Ein Lauf, der
// mittendrin abbricht, hat sonst nichts hinterlassen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
$protokoll = (string) file_get_contents($this->root.'/var/updates.log');
self::assertStringContainsString('Update aus dem Archiv', $protokoll);
self::assertStringContainsString('ok Fassung ermitteln', $protokoll);
self::assertStringContainsString('FEHLER Prüfsumme', $protokoll, 'Gerade der gescheiterte Schritt muss dort stehen.');
}
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
{
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
// Datei neu ins Release, ohne dass sie hier in der Ersetzungsliste steht, bleibt sie beim
// Update auf dem alten Stand - und das fällt erst auf, wenn etwas nicht mehr passt.
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
$quelle = (string) file_get_contents(__DIR__.'/../src/Ops/ArchiveUpdate.php');
preg_match('/ERSETZEN = \[(.+?)\];/s', $quelle, $ersetzt);
self::assertNotEmpty($ersetzt, 'Die Ersetzungsliste des Updaters ist nicht auffindbar.');
$liste = static fn (string $roh): array => array_filter(array_map(
static fn (string $eintrag): string => trim($eintrag, " '\r\n\t"),
explode(',', $roh),
));
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
// Beiwerk, das eine laufende Installation nicht liest.
$anders = ['public', 'README.md', 'LICENSE', '.env.example'];
foreach ($liste($mit[1]) as $eintrag) {
self::assertContains(
$eintrag,
[...$liste($ersetzt[1]), ...$anders],
sprintf('„%s" liegt im Releasearchiv, wird beim Update aber weder ersetzt noch bewusst ausgelassen.', $eintrag),
);
}
}
private function updater(): ArchiveUpdate
{
$runner = new CommandRunner();
$url = 'sqlite:///'.$this->root.'/var/cms.db';
return new ArchiveUpdate(
$this->root,
new Updater($this->root, $url, $runner, $this->db),
new Backup($this->root, $url, $this->root.'/var/media', $this->db, $runner),
$this->releases,
self::BASE,
self::API,
);
}
/**
* Baut ein Releasearchiv, wie `scripts/build-release.php` es baut: ein Verzeichnis
* `kantsites-<Fassung>/` im Zip, darin die Anwendung.
*/
private function archive(string $version, bool $vollstaendig = true): string
{
$pfad = $this->root.'/var/release-'.$version.'.zip';
@mkdir(dirname($pfad), 0o775, true);
$zip = new \ZipArchive();
self::assertTrue($zip->open($pfad, \ZipArchive::CREATE | \ZipArchive::OVERWRITE));
$inhalt = [
'VERSION' => $version."\n",
'src/Neu.php' => "<?php // neu\n",
'bin/console' => "<?php // neu\n",
'config/services.yaml' => "services:\n",
'templates/base.html.twig' => 'neu',
'migrations.php' => "<?php return ['neu'];\n",
'cli-config.php' => "<?php // neu\n",
'composer.json' => '{"name":"neu"}',
'composer.lock' => '{"packages":["neu"]}',
'public/index.php' => "<?php // neu\n",
'public/assets/site.css' => 'body{color:neu}',
// Das Archiv bringt eigene Schriften und ein eigenes Bündel mit - beides darf die
// Installation nicht überschreiben.
'public/assets/fonts/andere.woff2' => 'FREMD',
'public/assets/build/bundle.css' => 'body{color:fremd}',
];
if ($vollstaendig) {
$inhalt['vendor/autoload.php'] = "<?php // neu\n";
}
foreach ($inhalt as $name => $text) {
$zip->addFromString('kantsites-'.$version.'/'.$name, $text);
}
$zip->close();
$roh = (string) file_get_contents($pfad);
unlink($pfad);
return $roh;
}
private function write(string $relativ, string $inhalt): void
{
@mkdir(dirname($this->root.'/'.$relativ), 0o775, true);
file_put_contents($this->root.'/'.$relativ, $inhalt);
}
/** @param array{steps: list<array{step: string, ok: bool, detail: string}>} $ergebnis */
private function firstFailed(array $ergebnis): array
{
foreach ($ergebnis['steps'] as $schritt) {
if (!$schritt['ok']) {
return $schritt;
}
}
self::fail('Kein gescheiterter Schritt: '.$this->explain($ergebnis));
}
private function explain(array $ergebnis): string
{
return implode("\n", array_map(
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['step'], $schritt['detail']),
$ergebnis['steps'],
));
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
@unlink($pfad);
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' !== $eintrag && '..' !== $eintrag) {
@chmod($pfad.'/'.$eintrag, 0o777);
$this->removeDirectory($pfad.'/'.$eintrag);
}
}
@rmdir($pfad);
}
}
+30
View File
@@ -40,6 +40,36 @@ final class BackupTest extends TestCase
$this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner());
}
public function testABackupWorksOnAnOlderSqliteToo(): void
{
// Auf Umuts Hosting endete „Sicherung anlegen" mit 500. Der Grund: `VACUUM INTO` gibt es
// erst seit SQLite 3.27, und dort läuft eine ältere Fassung - alt genug für die
// Installation (die braucht 3.16), zu alt für die Sicherung. Statt VACUUM wird dann unter
// einer Schreibsperre kopiert. Geprüft wird das hier wirklich und nicht nur versprochen:
// die Schwelle wird so hoch gesetzt, dass der Ersatzweg läuft.
$url = 'sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db';
$alt = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner(), '99.0.0');
$sicherung = $alt->create('auf altem SQLite');
$datei = $sicherung['path'].'/cms.db';
self::assertFileExists($datei);
self::assertGreaterThan(0, (int) filesize($datei));
// Und es ist eine vollständige, lesbare Datenbank - nicht ein halb geschriebener Torso.
$kopie = new \PDO('sqlite:'.$datei);
self::assertSame(
(int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
(int) $kopie->query('SELECT COUNT(*) FROM revision')->fetchColumn(),
'Die Kopie muss denselben Inhalt haben wie das Original.',
);
self::assertSame(
$sicherung['manifest']['datenbank']['sha256'],
hash_file('sha256', $datei),
'Die Prüfsumme im Manifest gehört zu genau dieser Datei.',
);
}
protected function tearDown(): void
{
$this->db->close();
+89
View File
@@ -1077,6 +1077,30 @@ final class CmsTest extends TestCase
}
}
public function testTheAdministrationDrawsItsOwnIconsInsteadOfBorrowingCharacters(): void
{
// Umut: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem
// Smartphone kommen sonst Emojis.“ Genau so ist es: ein Telefon ersetzt ein Zeichen wie
// das Zahnrad durch ein buntes Emoji in fremdem Stil, und die Navigation sieht aus wie
// zusammengewürfelt. Eigene Pfade sind eine Linienstärke, eine Farbe und kein Request.
$shell = (string) file_get_contents(__DIR__.'/../templates/admin/shell.html.twig');
self::assertDoesNotMatchRegularExpression(
'/[\\x{2190}-\\x{2BFF}\\x{1F300}-\\x{1FAFF}]/u',
$shell,
'In der Navigation steht ein Zeichen, das ein Telefon zum Emoji macht.',
);
self::assertStringContainsString('admin/icon.html.twig', $shell);
$icons = (string) file_get_contents(__DIR__.'/../templates/admin/icon.html.twig');
// Jedes verwendete Symbol muss es geben - sonst zeichnet die Vorlage stillschweigend das
// Ersatzsymbol, und zwei Einträge sehen gleich aus.
foreach (['pages', 'partials', 'menus', 'media', 'forms', 'statistics', 'theme', 'settings', 'system', 'users', 'external'] as $name) {
self::assertStringContainsString($name.": 'M", $icons, $name.' fehlt in der Symbolsammlung.');
}
preg_match_all("/'(M[^']+)'/", $icons, $pfade);
self::assertSame(count($pfade[1]), count(array_unique($pfade[1])), 'Zwei Symbole tragen denselben Pfad.');
}
public function testNoStylesheetHasADanglingSelector(): void
{
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
@@ -2913,6 +2937,71 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('fetchpriority', $second);
}
public function testTheBackgroundOfTheFirstSectionIsFetchedBeforeTheStylesheetFindsIt(): void
{
// Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern
// der Hintergrund des ersten Abschnitts. Ein Hintergrundbild steht nur im Stylesheet, und
// das kommt nach dem HTML - der Browser erfährt also erst vom Bild, wenn er das CSS
// geparst hat. Gemessen: 1,3 s statt 0,9 s bis zum größten Element.
$media = new MediaLibrary($this->db, $this->path.'-media');
$datei = tempnam(sys_get_temp_dir(), 'kant-hero-');
$limit = ini_get('memory_limit');
ini_set('memory_limit', '512M');
try {
$bild = imagecreatetruecolor(2600, 1600);
imagejpeg($bild, $datei, 80);
imagedestroy($bild);
$item = $media->upload(new UploadedFile($datei, 'hero.jpg', test: true));
$version = static fn (Connection $db): int => (int) $db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
$ohne = DemoContent::starter('Ohne Hintergrund');
$entwurf = $this->pages->saveDraft('page-1', $ohne, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
$sparsam = (int) $this->request('/')->headers->get('X-Kant-Queries');
$mit = DemoContent::starter('Mit Hintergrund');
$mit['schemaVersion'] = max(10, $mit['schemaVersion'] ?? 10);
$mit['blocks'][0]['style']['desktop']['backgroundImage'] = $item['id'];
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
$this->pages->publish('page-1', $entwurf, $version($this->db));
$antwort = $this->request('/');
$html = (string) $antwort->getContent();
// Drei Verweise, einer je Gerät - und genau einer trifft zu.
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', $html, $treffer, PREG_SET_ORDER);
self::assertCount(3, $treffer, 'Der Hintergrund des ersten Abschnitts wird nicht vorab geholt.');
self::assertSame(
['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)', '(max-width:767px)'],
array_column($treffer, 2),
);
self::assertSame(
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
array_column($treffer, 1),
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
);
foreach ($treffer as $verweis) {
// Der Punkt der Übung: Vorabverweis und Regel müssen dieselbe Datei meinen. Sonst
// lädt der Browser zwei Bilder statt einem, und das Vorabholen kostet mehr als es
// bringt.
self::assertStringContainsString('url("'.$verweis[1].'")', $html, 'Der Vorabverweis meint eine andere Datei als die Regel.');
self::assertStringContainsString('fetchpriority="high"', $verweis[0]);
}
// Dasselbe Bild wird während einer Ausgabe mehrfach gesucht: je Gerät eine Regel, dazu
// der Kopf. Gefragt wird trotzdem nur einmal.
$mitBild = (int) $antwort->headers->get('X-Kant-Queries');
self::assertLessThanOrEqual($sparsam + 1, $mitBild, sprintf(
'Ein Hintergrundbild kostet eine Abfrage, nicht vier (%d statt %d).',
$mitBild,
$sparsam,
));
} finally {
@unlink($datei);
ini_set('memory_limit', (string) $limit);
}
}
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
{
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Tests;
use App\Ops\Download;
/**
* Eine Releaseseite, die im Dateisystem liegt statt im Netz.
*
* Ein Test, der wirklich nach git.kant-it.de greift, prüft die Leitung und nicht den Updater: er
* schlägt fehl, wenn der Server umzieht, und er ist grün, wenn im Archiv Unsinn steht. Diese
* Attrappe beantwortet dieselben drei Adressen - Releaseliste, Archiv, Prüfsumme - aus dem
* Speicher und merkt sich, was abgefragt wurde.
*/
final class ReleaseOnDisk extends Download
{
/** @var array<string, string> */
private array $antworten = [];
/** @var list<string> */
public array $abgefragt = [];
public function serve(string $url, string $inhalt): void
{
$this->antworten[$url] = $inhalt;
}
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */
public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void
{
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR));
$this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv);
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
"%s kantsites-%s.zip\n",
$checksum ?? hash('sha256', $archiv),
$version,
));
}
public function get(string $url): ?string
{
$this->abgefragt[] = $url;
return $this->antworten[$url] ?? null;
}
}
+259
View File
@@ -0,0 +1,259 @@
<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Scheduler;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
/**
* Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern
* würde: läuft eine Aufgabe zweimal, wenn zwei Besucher gleichzeitig kommen? Reißt eine kaputte
* Aufgabe die Website mit? Fängt eine, die nicht laufen kann, bei jedem Seitenaufruf neu an?
*/
final class SchedulerTest extends TestCase
{
private Connection $db;
private string $databaseFile;
private string $projectDir;
protected function setUp(): void
{
$this->databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-');
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile));
TestSchema::build($this->db);
$this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6));
mkdir($this->projectDir.'/var', 0o775, true);
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
@unlink($this->databaseFile);
foreach (glob($this->projectDir.'/var/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->projectDir.'/var');
@rmdir($this->projectDir);
}
/** @param list<SpyTask> $tasks */
private function scheduler(array $tasks): Scheduler
{
return new Scheduler($this->db, $tasks, $this->projectDir);
}
private function makeDue(string $name): void
{
$this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]);
}
public function testTheCodeDecidesWhichTasksExist(): void
{
$this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync();
self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name'));
// Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit.
$this->scheduler([new SpyTask('a')])->sync();
self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task'));
}
public function testANewTaskDoesNotRunInTheVeryNextRequest(): void
{
// Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach.
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame(0, $task->runs);
}
public function testWhatIsDueRunsAndIsThenRescheduled(): void
{
$task = new SpyTask('a', 600);
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertCount(1, $results);
self::assertSame('ok', $results[0]['status']);
self::assertSame('Lauf 1', $results[0]['message']);
self::assertSame(1, $task->runs);
// Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe.
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame('besuch', $row['last_trigger']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']);
self::assertNull($row['locked_until']);
}
/**
* Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment.
*
* Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet -
* faellig, aber gesperrt. Der zweite muss daran vorbeigehen.
*/
public function testALockedTaskIsLeftAloneByTheNextRequest(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'],
);
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
}
/** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */
public function testAnExpiredLockIsTakenOver(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'],
);
self::assertCount(1, $scheduler->runDue('besuch'));
self::assertSame(1, $task->runs);
}
public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void
{
$task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.'));
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$this->makeDue('a');
$results = $scheduler->runDue('besuch');
self::assertSame('fehler', $results[0]['status']);
self::assertStringContainsString('Kein Netz.', $results[0]['message']);
$first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(1, (int) $first['failures']);
// 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der
// gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest.
self::assertNull($first['locked_until']);
self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']);
$this->makeDue('a');
$scheduler->runDue('besuch');
$second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
self::assertSame(2, (int) $second['failures']);
self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']);
}
public function testASwitchedOffTaskNeverRuns(): void
{
$task = new SpyTask('a');
$scheduler = $this->scheduler([$task]);
$scheduler->sync();
$scheduler->enable('a', false);
$this->makeDue('a');
self::assertSame([], $scheduler->runDue('besuch'));
self::assertSame(0, $task->runs);
// Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch.
$result = $scheduler->runNow('a');
self::assertNotNull($result);
self::assertSame(1, $task->runs);
}
public function testOneRequestDoesNotRunEverythingAtOnce(): void
{
$tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')];
$scheduler = $this->scheduler($tasks);
$scheduler->sync();
foreach (['a', 'b', 'c'] as $name) {
$this->makeDue($name);
}
self::assertCount(2, $scheduler->runDue('besuch', 2));
self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks)));
}
/**
* Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt.
*
* Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das,
* was auf geteiltem Hosting am meisten weh tut.
*/
public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void
{
$scheduler = $this->scheduler([new SpyTask('a', 600)]);
$scheduler->sync();
$state = $scheduler->fastState();
self::assertTrue($state['traffic']);
self::assertNotNull($state['next']);
self::assertGreaterThan(time(), $state['next']);
self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE);
// Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts.
$scheduler->setWorkers(false, true);
$this->makeDue('a');
$scheduler->runDue('besuch');
self::assertFalse($scheduler->fastState()['traffic']);
self::assertTrue($scheduler->fastState()['admin']);
}
/**
* Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg.
*
* Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank:
* faellig ist sie ja, sie ist nur schon vergeben.
*/
public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$lockEnds = gmdate('Y-m-d H:i:s', time() + 600);
$this->db->executeStatement(
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
['2020-01-01 00:00:00', $lockEnds, 'a'],
);
$scheduler->runDue('besuch');
self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']);
}
/** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */
public function testAMissingTableIsNotAnError(): void
{
$this->db->executeStatement('DROP TABLE scheduled_task');
$task = new SpyTask('a');
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
self::assertSame([], $this->scheduler([$task])->overview());
self::assertSame(0, $task->runs);
}
public function testTheProtocolKeepsWhatHappened(): void
{
$scheduler = $this->scheduler([new SpyTask('a')]);
$scheduler->sync();
$scheduler->runNow('a');
$lines = $scheduler->protocol();
self::assertCount(1, $lines);
self::assertStringContainsString('a', $lines[0]);
self::assertStringContainsString('ok', $lines[0]);
self::assertStringContainsString('hand', $lines[0]);
}
public function testTheCronKeyDependsOnTheApplicationSecret(): void
{
$one = Scheduler::cronToken('geheimnis-eins');
$two = Scheduler::cronToken('geheimnis-zwei');
self::assertSame(40, strlen($one));
self::assertNotSame($one, $two);
self::assertSame($one, Scheduler::cronToken('geheimnis-eins'));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Tests;
use App\Ops\Tasks\Task;
/**
* Eine Aufgabe, die nichts tut außer mitzählen - oder auf Wunsch zu scheitern.
*
* Der Plan lässt sich nur dann ehrlich prüfen, wenn die Aufgabe selbst nichts kann: was hier
* gemessen wird, ist die Mechanik ringsum - Fälligkeit, Sperre, Abstand nach einem Fehlschlag.
*/
final class SpyTask implements Task
{
public int $runs = 0;
public function __construct(
private string $name,
private int $interval = 300,
private ?\Throwable $error = null,
) {
}
public function name(): string
{
return $this->name;
}
public function label(): string
{
return 'Testaufgabe '.$this->name;
}
public function explanation(): string
{
return 'Nur für Tests.';
}
public function interval(): int
{
return $this->interval;
}
public function run(): string
{
++$this->runs;
if (null !== $this->error) {
throw $this->error;
}
return 'Lauf '.$this->runs;
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Updater;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Der Bildschirm „System“ - die einzige Stelle, an der eine Installation ohne Konsole erfährt,
* dass etwas mit ihr nicht stimmt.
*
* Geprüft wird hier nicht das Aussehen, sondern zwei Eigenschaften, die schweigend verschwinden
* können: dass ausstehende Migrationen dort **auffallen**, und dass niemand sie auslösen kann,
* der nicht angemeldeter Administrator ist. Beides fiel beim Veröffentlichen von 1.4.3 auf -
* siehe ADR 070.
*/
final class SystemScreenTest extends WebTestCase
{
private const FIXTURE_PASSWORD = 'Fixture-only-password';
private Connection $db;
private string $path;
private array $previous;
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
// Die Tabellen entstehen hier über das Testschema und nicht über die Migrationen - genau
// die Lage einer Installation, deren Dateien von Hand hochgeladen wurden: Der Code ist
// neu, die Migrationsliste der Datenbank kennt ihn nicht.
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
$pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
$partials = new PartialRepository($this->db, new DocumentValidator());
foreach (DemoContent::areas() as $kind => $area) {
$partials->create($kind, 'default', $area['name'], $area['document']);
}
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', [
'id' => bin2hex(random_bytes(8)),
'username' => $username,
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
'created_at' => gmdate('Y-m-d H:i:s'),
'password_changed_at' => gmdate('Y-m-d H:i:s'),
]);
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
foreach (glob($this->path.'-media/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->path.'-media')) {
rmdir($this->path.'-media');
}
[$_SERVER, $_ENV] = $this->previous;
}
public function testOutstandingMigrationsAreImpossibleToOverlookAndCanBeRunFromThere(): void
{
$client = $this->login();
$client->request('GET', '/admin/system');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
// Vorher gab es dafür nur die Konsole - also genau das, was auf geteiltem Hosting fehlt.
self::assertStringContainsString('Migrationen stehen aus', $html);
self::assertStringContainsString('Version20260912000100', $html, 'Die Namen gehören dazu: sie sagen, was passieren wird.');
self::assertStringContainsString('/admin/system/migrationen', $html);
// Eine Sicherung vorher ist keine Höflichkeit: Migrationen sind nicht umkehrbar.
self::assertStringContainsString('Sicherung', $html);
}
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
{
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
// Schritte ab – und die Seite, die sie anzeigen soll, starb daran. Der Updater über Git
// nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, und die Vorlage
// liest `step`. Ergebnis: 500 statt Ergebnis, und der zweite Knopf unerreichbar. Ein
// Test, der nur das Ergebnis-Array prüft, findet so etwas nie – dieser rendert die Seite.
$archiv = new ArchiveUpdate(
dirname(__DIR__),
new Updater(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], new CommandRunner(), $this->db),
new Backup(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], $this->path.'-media', $this->db, new CommandRunner()),
new Download(connectTimeout: 1, timeout: 2),
// Eine Adresse, die es nicht gibt: Der Lauf endet nach dem ersten Schritt, ohne
// irgendetwas anzufassen – und genau dieser eine Schritt muss auf der Seite stehen.
'https://kein-server.invalid/download',
'https://kein-server.invalid/api',
);
$ergebnis = $archiv->run();
self::assertFalse($ergebnis['ok']);
self::assertSame(
['step', 'ok', 'detail'],
array_keys($ergebnis['steps'][0]),
'Beide Updater beschreiben ihre Schritte gleich - die Vorlage kennt nur eine Form.',
);
$client = $this->login();
$client->request('GET', '/admin/system');
$sitzung = $client->getRequest()->getSession();
$sitzung->set('update_steps', $ergebnis['steps']);
$sitzung->save();
$client->request('GET', '/admin/system');
self::assertSame(200, $client->getResponse()->getStatusCode(), 'Die Ergebnisseite darf an den eigenen Schritten nicht sterben.');
self::assertStringContainsString('Fassung ermitteln', (string) $client->getResponse()->getContent());
}
public function testNobodyButAnAdministratorCanStartAMigration(): void
{
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
self::ensureKernelShutdown();
$anonym = self::createClient();
$anonym->followRedirects(false);
$anonym->request('POST', '/admin/system/migrationen');
self::assertSame('http://localhost/admin/login', $anonym->getResponse()->headers->get('Location'));
$redaktion = $this->login('editor');
$redaktion->request('POST', '/admin/system/migrationen');
self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Eine Redakteurin darf das nicht.');
// Und selbst als Administrator nicht ohne gültiges Formular aus dieser Sitzung.
$admin = $this->login();
$admin->request('POST', '/admin/system/migrationen', ['_csrf_token' => 'erfunden']);
$admin->followRedirect();
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
}
private function login(string $username = 'admin'): KernelBrowser
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->followRedirects(false);
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
$client->followRedirect();
return $client;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use App\Ops\Scheduler;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Die drei Auslöser, wie sie über HTTP erreichbar sind.
*
* Geprüft wird nicht das Aussehen, sondern wer was darf: Der Bildschirm gehört Administratoren,
* der Takt der Verwaltung braucht eine Anmeldung und ein gültiges Formular, und der URL-Cronjob
* hängt an einem Schlüssel, der sich nicht erraten lässt. Jede dieser Stellen startet Arbeit auf
* dem Server – eine offene wäre eine Einladung.
*/
final class TaskScreenTest extends WebTestCase
{
private const FIXTURE_PASSWORD = 'Fixture-only-password';
private const SECRET = 'isolated-test-secret';
private Connection $db;
private string $path;
private array $previous;
protected static function getKernelClass(): string
{
return Kernel::class;
}
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'kant-tasks-web-');
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
$this->previous = [$_SERVER, $_ENV];
foreach (['APP_SECRET' => self::SECRET, 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
$_SERVER[$key] = $_ENV[$key] = $value;
}
$this->db = ConnectionFactory::create($url);
TestSchema::build($this->db);
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
$this->db->insert('cms_user', [
'id' => bin2hex(random_bytes(8)),
'username' => $username,
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
'created_at' => gmdate('Y-m-d H:i:s'),
'password_changed_at' => gmdate('Y-m-d H:i:s'),
]);
}
}
protected function tearDown(): void
{
parent::tearDown();
$this->db->close();
gc_collect_cycles();
@unlink($this->path);
[$_SERVER, $_ENV] = $this->previous;
}
public function testTheScreenNamesEveryTaskAndTheWayWithoutVisitors(): void
{
$client = $this->login();
$client->request('GET', '/admin/aufgaben');
self::assertSame(200, $client->getResponse()->getStatusCode());
$html = (string) $client->getResponse()->getContent();
foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup'] as $task) {
self::assertStringContainsString($task, $html);
}
// Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie
// nicht kennt, sucht den Fehler später an der falschen Stelle.
self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html);
self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html);
// Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr.
self::assertSame(5, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task'));
}
public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void
{
self::ensureKernelShutdown();
$anonymous = self::createClient();
$anonymous->followRedirects(false);
$anonymous->request('POST', '/admin/aufgaben/ausfuehren');
self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location'));
$editor = $this->login('editor');
$editor->request('GET', '/admin/aufgaben');
self::assertSame(403, $editor->getResponse()->getStatusCode(), 'Eine Redakteurin verwaltet den Plan nicht.');
$admin = $this->login();
$admin->request('POST', '/admin/aufgaben/ausfuehren', ['_csrf_token' => 'erfunden', 'name' => 'publish-due']);
$admin->followRedirect();
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
self::assertNull($this->db->fetchOne('SELECT last_status FROM scheduled_task WHERE name = ?', ['publish-due']));
}
public function testAnAdministratorCanRunATaskFromTheScreen(): void
{
$client = $this->login();
$crawler = $client->request('GET', '/admin/aufgaben');
$client->submit($crawler->filter('#run-publish-due')->form());
$client->followRedirect();
self::assertStringContainsString('Geplante Veröffentlichungen', (string) $client->getResponse()->getContent());
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['publish-due']);
self::assertSame('ok', $row['last_status']);
self::assertSame('hand', $row['last_trigger']);
self::assertNull($row['locked_until'], 'Nach dem Lauf ist die Sperre weg - sonst blockiert ein Klick die Aufgabe für eine Viertelstunde.');
}
/** Der zweite Auslöser: das geöffnete Backend. Er gehört jedem angemeldeten Konto, nicht nur der Verwaltung. */
public function testTheOpenBackendDrivesThePlan(): void
{
$editor = $this->login('editor');
$editor->request('GET', '/admin');
$token = $this->tokenFrom((string) $editor->getResponse()->getContent());
self::assertNotSame('', $token, 'Jeder Bildschirm der Verwaltung bringt den Takt mit.');
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => $token]);
self::assertSame(200, $editor->getResponse()->getStatusCode());
$answer = json_decode((string) $editor->getResponse()->getContent(), true);
self::assertIsArray($answer);
self::assertArrayHasKey('ran', $answer);
// Ohne gültiges Formular passiert nichts, auch für ein angemeldetes Konto nicht.
$editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => 'erfunden']);
self::assertSame(403, $editor->getResponse()->getStatusCode());
}
/** Der dritte Auslöser: ein Aufruf von außen, der nur mit dem richtigen Schlüssel etwas tut. */
public function testTheUrlCronAnswersOnlyToItsKey(): void
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->request('GET', '/cms-cron/'.str_repeat('a', 40));
self::assertSame(404, $client->getResponse()->getStatusCode(), 'Ein falscher Schlüssel erfährt nicht einmal, dass es die Stelle gibt.');
$client->request('GET', '/cms-cron/'.Scheduler::cronToken(self::SECRET));
self::assertSame(200, $client->getResponse()->getStatusCode());
self::assertStringContainsString('text/plain', (string) $client->getResponse()->headers->get('Content-Type'));
self::assertStringContainsString('noindex', (string) $client->getResponse()->headers->get('X-Robots-Tag'));
self::assertStringContainsString('Aufgabe(n)', (string) $client->getResponse()->getContent());
}
private function tokenFrom(string $html): string
{
return 1 === preg_match('#<meta name="kant-tick-token" content="([^"]+)"#', $html, $match) ? $match[1] : '';
}
private function login(string $username = 'admin'): KernelBrowser
{
self::ensureKernelShutdown();
$client = self::createClient();
$client->followRedirects(false);
$crawler = $client->request('GET', '/admin/login');
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
$client->followRedirect();
return $client;
}
}
+2
View File
@@ -18,6 +18,7 @@ use DoctrineMigrations\Version20260912001100;
use DoctrineMigrations\Version20260912001200;
use DoctrineMigrations\Version20260912001300;
use DoctrineMigrations\Version20260913000100;
use DoctrineMigrations\Version20260914000100;
use Psr\Log\NullLogger;
/**
@@ -47,6 +48,7 @@ final class TestSchema
Version20260912001200::class,
Version20260912001300::class,
Version20260913000100::class,
Version20260914000100::class,
] as $migration) {
(new $migration($db, new NullLogger()))->up($schema);
}
+178 -2
View File
@@ -1,9 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { EditorHistory, createBlock, locate, insertBlock, 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 { 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';
import { relevantProperty } from '../public/assets/editor-model.js';
test('sections start after the containing root, explicit placement can nest, undo restores the tree', () => {
const original = fixture();
const history = new EditorHistory(original);
const id = history.change(doc => insertBlock(doc, 'section', 't1'));
assert.equal(history.document.blocks[1].id, id);
assert.equal(locate(history.document.blocks, id).parent, null);
history.undo(); assert.deepEqual(history.document, original);
history.redo(); assert.equal(history.document.blocks[1].id, id);
const nested = history.change(doc => insertBlockAt(doc, 'section', 's1', 0));
assert.equal(locate(history.document.blocks, nested).parent.id, 's1');
});
test('design fields follow block type without hiding existing stored values', () => {
const text = createBlock('text'), image = createBlock('image');
assert.equal(relevantProperty(text, 'objectFit', { group: 'image' }), false);
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), false);
assert.equal(relevantProperty(image, 'objectFit', { group: 'image' }), true);
assert.equal(relevantProperty(text, 'fontSize', { group: 'text' }), true);
assert.equal(relevantProperty(text, 'gridColumns', { group: 'layout' }), false);
image.style = { mobile: { fontSize: '14px' } };
assert.equal(relevantProperty(image, 'fontSize', { group: 'text' }), true);
});
function fixture() { return { schemaVersion: 1, title: 'Page', description: 'Description', blocks: [{ id: 's1', type: 'section', children: [{ id: 'h1', type: 'heading', props: { text: 'Hello', level: 1 } }, { id: 't1', type: 'text', props: { text: 'Body' } }] }, { id: 's2', type: 'section', children: [] }] }; }
@@ -364,3 +388,155 @@ test('a current document still accepts new blocks, and every block type can be i
assert.throws(() => upgradeDocument({ schemaVersion: 99, blocks: [] }, 6), /Unbekanntes Dokumentschema/);
assert.throws(() => upgradeDocument({ schemaVersion: 5, blocks: [] }, SCHEMA_VERSION + 1), /Unbekanntes Dokumentschema/);
});
// --- Punktgenaues Einfuegen und Verschieben -------------------------------------------------
// Die Grundlage fuer das Ablegen in der Vorschau: dort zaehlt die Stelle, nicht die Auswahl.
test('a drop lands at the exact position, also at the very end of a list', () => {
const document = fixture();
const section = locate(document.blocks, 's1').block;
insertBlockAt(document, 'text', 's1', 0);
assert.deepEqual(section.children.map(block => block.type), ['text', 'heading', 'text']);
insertBlockAt(document, 'button', 's1', section.children.length);
assert.equal(section.children.at(-1).type, 'button');
// Ein Index jenseits des Endes haengt an, statt zu scheitern.
assert.ok(insertBlockAt(document, 'button', 's1', 99));
assert.equal(section.children.length, 5);
});
test('moving to an exact position settles the shift before it clamps', () => {
const order = document => document.blocks[0].children.map(block => block.id);
const document = fixture();
insertBlockAt(document, 'button', 's1', 2);
const ids = order(document);
// Nach hinten: die Entnahme verschiebt jede spaetere Stelle um eins.
assert.equal(moveBlockTo(document, ids[0], 's1', 2), true);
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
// Ganz ans Ende. Ohne die richtige Reihenfolge von Verrechnen und Begrenzen landete der
// Block hier eine Stelle zu frueh.
assert.equal(moveBlockTo(document, ids[1], 's1', 3), true);
assert.deepEqual(order(document), [ids[0], ids[2], ids[1]]);
// Nach vorne: dort wird nichts verrechnet.
assert.equal(moveBlockTo(document, ids[1], 's1', 0), true);
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
});
test('a drop never detaches a branch and never breaks a container rule', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
// Eine Sektion in ihr eigenes Kind: das wuerde den Ast abtrennen.
assert.equal(moveBlockTo(document, 's1', 'h1', 0), false);
assert.equal(moveBlockTo(document, 's1', 's1', 0), false);
assert.equal(document.blocks.length, 2);
// Eine Galerie nimmt nur Bilder.
const galleryId = insertBlock(document, 'gallery', null);
assert.equal(moveBlockTo(document, 'h1', galleryId, 0), false);
assert.equal(locate(document.blocks, 'h1').parent.id, 's1');
assert.equal(insertBlockAt(document, 'text', galleryId, 0), null);
assert.ok(insertBlockAt(document, 'image', galleryId, 0));
});
test('the top level takes everything the validator takes there, and nothing else', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
// Ein Akkordeon-Eintrag und ein Tab gehoeren in ihre Gruppe - der Validator lehnt sie sonst ab.
assert.equal(dropList(document, null, { type: 'accordionitem' }), null);
assert.equal(dropList(document, null, { type: 'tabitem' }), null);
// Alles andere darf dort stehen, genau wie ueber die Schaltflaechen.
for (const type of ['section', 'heading', 'image', 'text']) {
assert.equal(dropList(document, null, { type }), document.blocks, type);
}
// Beim Ziehen aus dem Werkzeugkasten gibt es den Block noch nicht.
assert.doesNotThrow(() => dropList(document, 's1', null));
});
// --- Sektion, Zeile, Spalte, Inhalt ----------------------------------------------------------
// Die Struktur, die der Editor in seinem eigenen Hinweistext beschreibt, gilt jetzt auch fuer
// die Schaltflaechen: eine Zeile nahm bisher jedes Kind, eine Spalte stand ueberall.
test('a row takes columns and nothing else, a column only lives in a row', () => {
const document = fixture();
const rowId = insertRow(document, 3, 's2');
const row = locate(document.blocks, rowId).block;
assert.equal(row.children.length, 3);
assert.ok(row.children.every(child => child.type === 'column'));
// Eine Ueberschrift gehoert nicht neben die Spalten, sondern in eine davon.
assert.equal(insertBlockAt(document, 'heading', rowId, 0), null);
assert.equal(row.children.length, 3);
assert.ok(insertBlockAt(document, 'heading', row.children[0].id, 0));
// Eine Spalte gehoert nicht in eine Sektion.
assert.equal(insertBlockAt(document, 'column', 's2', 0), null);
assert.ok(insertBlockAt(document, 'column', rowId, 3));
});
test('nothing that needs a container is offered at the top of a document', () => {
const document = fixture();
for (const type of ['row', 'column', 'accordionitem', 'tabitem']) {
assert.equal(insertionTarget(document, null, type), null, type);
assert.equal(insertBlock(document, type, null), null, type);
}
// Eine Sektion dagegen schon - damit faengt eine Seite an.
assert.ok(insertBlock(document, 'section', null));
});
test('the toolbox is told the same place the click will use', () => {
const document = fixture();
upgradeDocument(document, SCHEMA_VERSION);
const rowId = insertRow(document, 2, 's2');
const columnId = locate(document.blocks, rowId).block.children[0].id;
// In einer Zeile ist nur eine Spalte moeglich, und sie landet in der Zeile.
assert.deepEqual(insertionTarget(document, rowId, 'column'), { parentId: rowId, index: 2 });
// Eine Ueberschrift nimmt die Zeile nicht - sie steigt auf und landet hinter der Zeile in der
// Sektion. Sperren waere unnoetig: die Absicht ist eindeutig, nur die Zeile ist der falsche Ort.
assert.deepEqual(insertionTarget(document, rowId, 'heading'), { parentId: 's2', index: 1 });
// In einer Spalte ist Inhalt moeglich, und er landet in der Spalte.
assert.equal(insertionTarget(document, columnId, 'heading').parentId, columnId);
// Eine weitere Spalte neben der gewaehlten: eine Ebene hoeher, direkt dahinter.
assert.deepEqual(insertionTarget(document, columnId, 'column'), { parentId: rowId, index: 1 });
// Und die Auskunft stimmt mit der Tat ueberein.
for (const [selection, type] of [[rowId, 'column'], [columnId, 'heading'], [columnId, 'column'], ['s2', 'row']]) {
const promised = insertionTarget(document, selection, type);
const id = insertBlock(document, type, selection);
assert.equal(locate(document.blocks, id).parent?.id ?? null, promised.parentId, `${type} aus ${selection}`);
}
});
test('every block type appears in exactly one toolbox group', () => {
const listed = CATEGORIES.flatMap(category => category.types);
assert.equal(listed.length, new Set(listed).size, 'kein Typ doppelt');
assert.deepEqual([...listed].sort(), Object.keys(blockLabels).sort());
});
test('a row dropped at an exact position brings its columns along', () => {
const document = fixture();
assert.ok(insertRowAt(document, 4, 's1', 0));
const row = locate(document.blocks, 's1').block.children[0];
assert.equal(row.type, 'row');
assert.equal(row.children.length, 4);
// 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(', ')}`);
});