Compare commits
8
Commits
d885b3ddb5
...
v1.21.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e0365f841 | ||
|
|
ff2b57db58 | ||
|
|
741eaa80f7 | ||
|
|
23fdca4edb | ||
|
|
a8368068aa | ||
|
|
f4ec0d9877 | ||
|
|
d08d6f648a | ||
|
|
4539f357c1 |
No files matched your search
@@ -4,6 +4,14 @@ Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Bildecken unter Design (ab 1.21.0)
|
||||
|
||||
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
|
||||
|
||||
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
|
||||
|
||||
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
|
||||
|
||||
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
|
||||
|
||||
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Projektstatus
|
||||
|
||||
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
|
||||
|
||||
**Was los war.** `DemoContent::starterPage()` lieferte zwei Abschnitte mit Arbeitsanweisungen an die Betreiberin oder den Betreiber: „Melde dich an, öffne sie im Editor …“ und „Die nächsten Schritte“ mit Design, Menüs und Einstellungen. Das stand auf der einzigen Seite, die bis zum ersten Bearbeiten **öffentlich** erreichbar ist – gelesen hat es also der Besucher, nicht der Betreiber. Für den stehen dieselben Schritte längst auf dem letzten Installerbildschirm (`templates/install/done.html.twig`) und in der Verwaltung.
|
||||
|
||||
**Neu.** Eine Baustellenseite, die genau das sagt und sonst nichts: das KantSites-Zeichen (`public/assets/kantsites-logo.svg`, dieselbe Form wie das Favicon), die Kleinzeile „In Arbeit“ in der Akzentfarbe, als einzige H1 der Name der Website, darunter „Hier entsteht eine neue Webpräsenz.“, ein kurzer Trennstrich und die Zeile „Erstellt mit KantSites“. Der Abschnitt ist ein Flex-Container mit `min-height: 70vh` und einem Verlauf von `@paper` nach `@mint`, stellt den Inhalt also mittig auf den Bildschirm; die Farben sind Palettenverweise und folgen jedem Designwechsel. Alles sind gewöhnliche Bausteine mit gewöhnlichen Stilwerten – im Editor also vollständig änder- und löschbar. Die Seitenbeschreibung ist jetzt ein echter Satz statt „NOCH AUSZUFÜLLEN“, damit eine frisch installierte Website in Suchergebnissen nicht mit einem Platzhalter steht.
|
||||
|
||||
**Unverändert:** Kopf- und Fußbereich, die beiden Rechtsseiten als Gerüst mit markierten Pflichtangaben, Menüs.
|
||||
|
||||
**Geprüft:** `tests/InstallFlowTest.php` (Installation über die sechs Bildschirme, danach liefert `/` die neue Seite), `php vendor/bin/phpunit` 579 von 579. Im Browser angesehen: eine frisch migrierte Datenbank mit dem Inhalt einer Neuinstallation, Desktop und 375 px.
|
||||
|
||||
## Fassung 1.21.0: Bildecken gehören dem Designbereich (16.09.2026)
|
||||
|
||||
**Anlass:** Umut zum Hero-Bild von DEEG: „Das sollte abgerundet sein, aber wurde von irgendwo gemacht, und die Ecken sehen komisch aus. Es gibt auch im Editor gar nicht Bildecken abrunden, oder?“
|
||||
|
||||
**Was los war.** `.block-image{border-radius:12px}` stand fest in `enhancements.css` – jedes Bild jeder Installation war gerundet, ohne dass ein Feld das gesagt hätte. Auf DEEG (eine durchgehend eckige Vorlage) fiel es auf, weil über dem gerundeten Bild eckige Elemente liegen: der dunkle Verlauf, das Schild, die blaue Aufruf-Fläche. An den Ecken schaute deshalb die eckige Fläche aus der Rundung heraus.
|
||||
|
||||
**Behoben.** Die Ecken sind jetzt eine Einstellung unter **Design → Bildecken** (`theme.base.imageRadius`, geprüft wie die Inhaltsbreite, ausgegeben als `--k-image-radius`). Vorgabe bleibt `12px`, damit bestehende Websites gleich aussehen; `0px` macht alle Bilder eckig. Für ein einzelnes Bild gab es die Einstellung schon immer – im Editor unter **Design → Bild → Bildecken** (`imageRadius` im Stilschema, für Bild, Galerie, Logoleiste und Beitragsliste); sie überschreibt den Wert der Website.
|
||||
|
||||
**DEEG:** `imageRadius` auf `0px`, im Aufbauskript und in der Installation. Das Hero-Bild ist damit eckig wie die Vorlage, und die Ecken stimmen wieder.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/CmsTest.php`: Vorgabe ist `12px`, eine Seite mit Bild trägt `--k-image-radius:12px`, nach dem Speichern von `0px` trägt sie `--k-image-radius:0px`, und „rund“ wird abgewiesen wie jede andere Nicht-Größenangabe.
|
||||
|
||||
## Fassung 1.20.1: das kaufmännische Und, das bei jedem Speichern wuchs (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus.“
|
||||
|
||||
**Ursache.** `RichText::sanitise()` hat Text ohne ein einziges Tag als reinen Text behandelt und nur escaped. Was der Editor schickt, ist aber HTML – auch ohne Tag. Aus `Freudenstadt & Umgebung` wurde so bei **jedem** Speichern eine Stufe mehr: `&amp;`, `&amp;amp;` … bis die Entity sichtbar auf der Seite stand. Auf kant-it.de war der Entwurf der Startseite schon bei drei Stufen. Derselbe Zweig hatte einen zweiten Fehler gleicher Art: `nl2br()` ließ den Zeilenumbruch hinter dem `<br>` stehen, und der nächste Durchlauf machte über `breakLines()` ein zweites `<br>` daraus – aus einem Absatz wurden zwei.
|
||||
|
||||
**Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern.
|
||||
|
||||
**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live.
|
||||
|
||||
**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt.
|
||||
|
||||
**Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt.
|
||||
|
||||
## Baustein „Eigenes HTML“, DEEG-Angebotsseite (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann … z. B. eine neue Angebotsseite bei DEEG, da soll ein iframe rein, im gleichen Stil wie das Impressum.“
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:var(--k-image-radius,12px)}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"><rect width="128" height="128" rx="32" fill="#182d2b"/><path d="M36 28h16v42l26-28h22L68 76l34 28H78L52 82v22H36z" fill="#e5f0b6"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -283,7 +283,7 @@ $theme->save(
|
||||
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
|
||||
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif'],
|
||||
],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false],
|
||||
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'],
|
||||
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
|
||||
);
|
||||
echo "Farben, Schriften und Buttons gesetzt\n";
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Baut das Favicon der DEEG-Installation aus dem Logo und setzt es.
|
||||
*
|
||||
* Bisher zeigte das Symbol auf das Logo selbst. Das Logo ist 209x100 – der Kern schneidet daraus
|
||||
* mittig ein Quadrat, und übrig blieb das „EE“ aus dem Schriftzug. Ein Favicon wird bei 16 px
|
||||
* gelesen, also braucht es ein eigenes, quadratisches Zeichen: ein Dachgiebel aus dem Logo, weiß
|
||||
* auf Logo-Blau. Das Dach ist das Markenzeichen und bleibt auch klein eine Silhouette; der
|
||||
* flächige blaue Grund trägt es auf hellen wie dunklen Browserleisten.
|
||||
*
|
||||
* Gerendert wird aus `logo-deeg.png`, damit das Symbol bei einem neuen Logo neu entsteht statt
|
||||
* als Fremdkörper in der Mediathek zu liegen. Läuft nur gegen die DEEG-Datenbank.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$quelle = $argv[1] ?? $root.'/var/immobilien-deeg-assets/logo-deeg.png';
|
||||
if (!is_file($quelle)) {
|
||||
fwrite(STDERR, "Logo nicht gefunden: $quelle\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Der linke Giebel des Doppeldachs im Logo. Die Maße stammen aus dem Bild selbst: das Dach liegt
|
||||
// über dem Schriftzug, x 3-203, y 0-40; die Hälfte davon ist ein Giebel.
|
||||
$AUSSCHNITT = ['x1' => 3, 'y1' => 0, 'x2' => 103, 'y2' => 40];
|
||||
$BLAU = [0, 129, 199]; // #0081c7, das Logo-Blau
|
||||
$KANTE = 512; // Vorlage für alle Ableitungen des Kerns (16 bis 180)
|
||||
$RAND = 0.06; // wenig Luft: klein gelesen zählt jede Fläche
|
||||
|
||||
$logo = imagecreatefrompng($quelle);
|
||||
$w = $AUSSCHNITT['x2'] - $AUSSCHNITT['x1'] + 1;
|
||||
$h = $AUSSCHNITT['y2'] - $AUSSCHNITT['y1'] + 1;
|
||||
$zeichen = imagecreatetruecolor($w, $h);
|
||||
imagealphablending($zeichen, false);
|
||||
imagesavealpha($zeichen, true);
|
||||
imagefilledrectangle($zeichen, 0, 0, $w, $h, imagecolorallocatealpha($zeichen, 0, 0, 0, 127));
|
||||
for ($y = 0; $y < $h; ++$y) {
|
||||
for ($x = 0; $x < $w; ++$x) {
|
||||
// Das Logo ist ein Palettenbild; imagecolorsforindex beantwortet Palette und Truecolor.
|
||||
$c = imagecolorsforindex($logo, imagecolorat($logo, $AUSSCHNITT['x1'] + $x, $AUSSCHNITT['y1'] + $y));
|
||||
$hell = $c['red'] > 230 && $c['green'] > 230 && $c['blue'] > 230;
|
||||
if ($c['alpha'] > 100 || $hell) {
|
||||
continue;
|
||||
}
|
||||
imagesetpixel($zeichen, $x, $y, imagecolorallocate($zeichen, 255, 255, 255));
|
||||
}
|
||||
}
|
||||
imagedestroy($logo);
|
||||
|
||||
$symbol = imagecreatetruecolor($KANTE, $KANTE);
|
||||
imagealphablending($symbol, false);
|
||||
imagesavealpha($symbol, true);
|
||||
imagefilledrectangle($symbol, 0, 0, $KANTE, $KANTE, imagecolorallocate($symbol, $BLAU[0], $BLAU[1], $BLAU[2]));
|
||||
imagealphablending($symbol, true);
|
||||
$platz = (int) round($KANTE * (1 - 2 * $RAND));
|
||||
$faktor = min($platz / $w, $platz / $h);
|
||||
$zw = (int) round($w * $faktor);
|
||||
$zh = (int) round($h * $faktor);
|
||||
imagecopyresampled($symbol, $zeichen, (int) (($KANTE - $zw) / 2), (int) (($KANTE - $zh) / 2), 0, 0, $zw, $zh, $w, $h);
|
||||
imagedestroy($zeichen);
|
||||
|
||||
$datei = sys_get_temp_dir().'/favicon-deeg.png';
|
||||
imagepng($symbol, $datei, 9);
|
||||
imagedestroy($symbol);
|
||||
echo "Symbol gerendert: {$KANTE}x{$KANTE}\n";
|
||||
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
|
||||
$eintrag = $media->upload(new UploadedFile($datei, 'favicon-deeg.png', null, null, true));
|
||||
$media->update($eintrag['id'], [
|
||||
'title' => 'Favicon DEEG',
|
||||
'alt' => 'Dachgiebel aus dem DEEG-Logo, weiß auf Blau',
|
||||
'archived' => false,
|
||||
'version' => $eintrag['version'],
|
||||
]);
|
||||
@unlink($datei);
|
||||
echo "In der Mediathek: {$eintrag['id']}\n";
|
||||
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'faviconMediaId' => $eintrag['id'],
|
||||
]);
|
||||
echo "Favicon gesetzt; der Kern hat 32 px (Browserleiste) und 180 px (Apple-Touch) daraus gerendert.\n";
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use Doctrine\DBAL\Connection;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Räumt Texte auf, die vor 1.20.1 bei jedem Speichern eine Kodierungsstufe dazubekommen haben.
|
||||
*
|
||||
* `RichText::sanitise()` hat Text ohne ein einziges Tag nur escaped, statt ihn vorher zu
|
||||
* dekodieren. Aus „Freudenstadt & Umgebung“ wurde deshalb `&amp;`, dann `&amp;amp;`
|
||||
* – bis die Entity sichtbar auf der Seite stand. Der Fehler ist behoben; dieser Befehl bringt in
|
||||
* Ordnung, was schon in der Datenbank liegt.
|
||||
*
|
||||
* Angefasst wird, was in Gebrauch ist: die veröffentlichte und die Entwurfsfassung jeder Seite
|
||||
* und die Bereiche. Ältere Fassungen bleiben Historie – wer eine zurückholt, speichert sie
|
||||
* danach einmal, und der reparierte Kern schreibt sie richtig.
|
||||
*/
|
||||
#[AsCommand(name: 'cms:repair:entities', description: 'Repariert doppelt kodierte Texte (&amp;) aus Fassungen vor 1.20.1.')]
|
||||
final class RepairEntitiesCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $db, private DocumentValidator $validator)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('schreiben', null, InputOption::VALUE_NONE, 'Die Korrekturen wirklich speichern. Ohne diese Option wird nur gezeigt, was sich ändern würde.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$schreiben = (bool) $input->getOption('schreiben');
|
||||
$gesamt = 0;
|
||||
$zeilen = [];
|
||||
|
||||
// Die aktiven Fassungen jeder Seite: veröffentlicht und Entwurf.
|
||||
$aktive = [];
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, path, published_revision_id, draft_revision_id FROM page') as $seite) {
|
||||
foreach (['published_revision_id' => 'veröffentlicht', 'draft_revision_id' => 'Entwurf'] as $spalte => $was) {
|
||||
if (!empty($seite[$spalte])) {
|
||||
$aktive[(string) $seite[$spalte]] = '/'.$seite['path'].' ('.$was.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($aktive as $revision => $bezeichnung) {
|
||||
$roh = $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]);
|
||||
if (!is_string($roh)) {
|
||||
continue;
|
||||
}
|
||||
$dokument = json_decode($roh, true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = [$bezeichnung, $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Geprüft wie beim Speichern: eine Korrektur darf kein ungültiges Dokument hinterlassen.
|
||||
$this->validator->validate($dokument, writing: true);
|
||||
$this->db->executeStatement('UPDATE revision SET document = ? WHERE id = ?', [$this->json($dokument), $revision]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, kind, name, document FROM partial') as $bereich) {
|
||||
$dokument = json_decode((string) $bereich['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$zahl = 0;
|
||||
$beispiele = [];
|
||||
$dokument['blocks'] = $this->bausteine($dokument['blocks'] ?? [], $zahl, $beispiele);
|
||||
if (0 === $zahl) {
|
||||
continue;
|
||||
}
|
||||
$gesamt += $zahl;
|
||||
$zeilen[] = ['Bereich '.$bereich['kind'].' „'.$bereich['name'].'“', $zahl, implode(' · ', $beispiele)];
|
||||
if ($schreiben) {
|
||||
// Ein Bereich traegt keine Ueberschrift - genau wie im PartialRepository.
|
||||
$this->validator->validate($dokument, requireHeading: false, writing: true);
|
||||
$this->db->executeStatement('UPDATE partial SET document = ? WHERE id = ?', [$this->json($dokument), $bereich['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $gesamt) {
|
||||
$io->success('Nichts zu reparieren: kein Text trägt eine doppelt kodierte Entity.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->table(['Wo', 'Stellen', 'Beispiel'], $zeilen);
|
||||
if ($schreiben) {
|
||||
$io->success(sprintf('%d Stelle(n) repariert.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$io->warning(sprintf('%d Stelle(n) gefunden. Mit --schreiben anwenden.', $gesamt));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $liste */
|
||||
private function bausteine(array $liste, int &$zahl, array &$beispiele): array
|
||||
{
|
||||
foreach ($liste as $index => $block) {
|
||||
foreach (['text', 'caption', 'label', 'title', 'hint', 'provider'] as $feld) {
|
||||
$wert = $block['props'][$feld] ?? null;
|
||||
if (!is_string($wert) || '' === $wert) {
|
||||
continue;
|
||||
}
|
||||
$neu = $this->entschachteln($wert);
|
||||
if ($neu !== $wert) {
|
||||
$liste[$index]['props'][$feld] = $neu;
|
||||
++$zahl;
|
||||
if (count($beispiele) < 3) {
|
||||
$beispiele[] = mb_substr($wert, 0, 40).' → '.mb_substr($neu, 0, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$liste[$index]['children'] = $this->bausteine($block['children'], $zahl, $beispiele);
|
||||
}
|
||||
}
|
||||
|
||||
return $liste;
|
||||
}
|
||||
|
||||
/** Entschachtelt so oft wie nötig: aus &amp;amp; wird &. Ein einzelnes & ist richtig und bleibt. */
|
||||
private function entschachteln(string $text): string
|
||||
{
|
||||
$vorher = null;
|
||||
while ($vorher !== $text) {
|
||||
$vorher = $text;
|
||||
$text = preg_replace('/&(amp|lt|gt|quot|apos|nbsp|#\d{1,6}|#x[0-9a-fA-F]{1,6});/', '&$1;', $text) ?? $text;
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $dokument */
|
||||
private function json(array $dokument): string
|
||||
{
|
||||
return json_encode($dokument, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
+67
-19
@@ -122,34 +122,75 @@ final class DemoContent
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
/**
|
||||
* The start page of a fresh installation - not demo content.
|
||||
*
|
||||
* Short on purpose. Somebody who has just installed should find a page they can edit, not a
|
||||
* brochure about the CMS they have to delete first. Exactly one H1, as every page requires.
|
||||
* A website that is already reachable but not yet written needs a page that says exactly that:
|
||||
* the name of the site, the notice that something is being built here, and nothing else. No
|
||||
* instructions for the owner - those stand on the last screen of the installer and in the
|
||||
* admin area, not on a page every visitor sees. Exactly one H1, as every page requires.
|
||||
*/
|
||||
public static function starterPage(string $siteName): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 16,
|
||||
'schemaVersion' => 24,
|
||||
'title' => $siteName,
|
||||
'description' => 'NOCH AUSZUFÜLLEN: ein Satz darüber, was es hier gibt. Er erscheint in Suchergebnissen.',
|
||||
'description' => 'Hier entsteht eine neue Webpräsenz. Diese Seite wird gerade eingerichtet.',
|
||||
'blocks' => [
|
||||
self::section('start-hero', [
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
self::text('start-intro', 'Diese Seite ist der Anfang. Melde dich an, öffne sie im Editor und ersetze diesen Text durch deinen eigenen.'),
|
||||
], 'mint'),
|
||||
self::section('start-body', [
|
||||
self::heading('start-next-heading', 'Die nächsten Schritte', 2),
|
||||
self::text('start-next', "Impressum und Datenschutzerklärung sind bereits angelegt und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.\n\nUnter „Design“ legst du Farben, Schriften und die Breite fest, unter „Menüs“ die Navigation, unter „Einstellungen“ den Namen und die Angaben zum Unternehmen."),
|
||||
], 'paper', 'narrow'),
|
||||
[
|
||||
'id' => 'start-hero',
|
||||
'type' => 'section',
|
||||
'layout' => ['tone' => 'mint', 'width' => 'narrow'],
|
||||
'style' => [
|
||||
// Eine Seite ohne Inhalt: der Abschnitt füllt den Bildschirm und stellt das
|
||||
// Wenige, das dasteht, in die Mitte, statt es oben in eine Ecke zu setzen.
|
||||
'desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center',
|
||||
'minHeight' => '70vh', 'textAlign' => 'center',
|
||||
'padding' => ['top' => '96px', 'bottom' => '96px'],
|
||||
'gradientFrom' => '@paper', 'gradientTo' => '@mint', 'gradientAngle' => 165,
|
||||
],
|
||||
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||
],
|
||||
'children' => [
|
||||
[
|
||||
'id' => 'start-logo',
|
||||
'type' => 'image',
|
||||
'props' => ['src' => '/assets/kantsites-logo.svg', 'alt' => 'KantSites', 'decorative' => false, 'width' => 128, 'height' => 128],
|
||||
'style' => ['desktop' => ['maxWidth' => '86px', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '30px']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-label',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'In Arbeit'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '2px',
|
||||
'textTransform' => 'uppercase', 'color' => '@accent', 'margin' => ['bottom' => '16px'],
|
||||
]],
|
||||
],
|
||||
self::heading('start-heading', $siteName, 1),
|
||||
[
|
||||
'id' => 'start-intro',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Hier entsteht eine neue Webpräsenz.'],
|
||||
'style' => ['desktop' => [
|
||||
'fontSize' => '21px', 'maxWidth' => '38ch',
|
||||
'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '0px'],
|
||||
]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-rule',
|
||||
'type' => 'divider',
|
||||
'style' => ['desktop' => ['width' => '72px', 'margin' => ['top' => '40px', 'right' => 'auto', 'bottom' => '22px', 'left' => 'auto']]],
|
||||
],
|
||||
[
|
||||
'id' => 'start-note',
|
||||
'type' => 'text',
|
||||
'props' => ['text' => 'Erstellt mit KantSites'],
|
||||
'style' => ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.4px', 'margin' => ['bottom' => '0px']]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -164,6 +205,13 @@ final class DemoContent
|
||||
return $areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer a fresh installation starts with. They are ordinary block documents:
|
||||
* everything in them can be changed, moved or removed in the editor. No main heading, because
|
||||
* every page brings its own.
|
||||
*
|
||||
* @return array<string, array{name: string, document: array}>
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$row = static fn (string $id, array $children): array => [
|
||||
|
||||
@@ -50,7 +50,19 @@ final class RichText
|
||||
}
|
||||
if (!str_contains($html, '<')) {
|
||||
// Plain text: escape it and keep line breaks, so typing without formatting still works.
|
||||
return nl2br(htmlspecialchars($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false);
|
||||
//
|
||||
// Erst dekodieren: Was aus dem Editor kommt, ist HTML - auch ohne ein einziges Tag.
|
||||
// „Freudenstadt & Umgebung“ meint ein kaufmaennisches Und. Ohne diesen Schritt
|
||||
// wuerde daraus bei jedem Speichern eine Entity mehr (&amp;, &amp;amp; ...),
|
||||
// bis sie sichtbar auf der Seite steht. Der Zweig mit Tags macht es ueber den DOM
|
||||
// laengst richtig; dieser hier zog nach.
|
||||
$text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Der Umbruch wird ersetzt, nicht ergaenzt: nl2br() liess den Zeilenumbruch
|
||||
// hinter dem <br> stehen, und beim naechsten Speichern machte breakLines()
|
||||
// daraus ein zweites <br> - aus einem Absatz wurden zwei. So enden beide Zweige
|
||||
// bei derselben Darstellung.
|
||||
return str_replace(["\r\n", "\r", "\n"], '<br>', htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
}
|
||||
|
||||
$document = new \DOMDocument();
|
||||
|
||||
@@ -68,6 +68,10 @@ final class Theme
|
||||
'linkColor' => '@link',
|
||||
'containerWidth' => '1240px',
|
||||
'headingScale' => 1.25,
|
||||
// Die Ecken jedes Bildes auf der Website. Stand bis 1.20.2 fest im Stylesheet -
|
||||
// sichtbar gerundet, aber in keinem Feld zu finden. 12px bleibt die Vorgabe,
|
||||
// damit bestehende Websites gleich aussehen; 0px macht Bilder eckig.
|
||||
'imageRadius' => '12px',
|
||||
// Unterstrichene Links im Fließtext. An, weil ein Link, den nur die Farbe verrät,
|
||||
// für Menschen mit Farbsehschwäche kein Link ist. Wer eine ruhigere Seite will,
|
||||
// schaltet es hier ab - dann bleibt der Unterstrich beim Überfahren.
|
||||
@@ -229,7 +233,7 @@ final class Theme
|
||||
}
|
||||
$base[$field] = $value;
|
||||
}
|
||||
foreach (['fontSize', 'containerWidth'] as $field) {
|
||||
foreach (['fontSize', 'containerWidth', 'imageRadius'] as $field) {
|
||||
$value = trim((string) ($input[$field] ?? $base[$field]));
|
||||
if (1 !== preg_match(StyleSchema::LENGTH, $value)) {
|
||||
throw new InvalidPage(sprintf('„%s“ ist keine Größenangabe wie 18px oder 70rem.', $value));
|
||||
@@ -338,6 +342,7 @@ final class Theme
|
||||
}
|
||||
$base = $theme['base'];
|
||||
$variables[] = sprintf('--k-container:%s', $base['containerWidth']);
|
||||
$variables[] = sprintf('--k-image-radius:%s', $base['imageRadius']);
|
||||
|
||||
$colour = static fn (string $v): string => str_starts_with($v, '@') ? sprintf('var(--k-%s)', substr($v, 1)) : $v;
|
||||
|
||||
|
||||
@@ -162,6 +162,11 @@
|
||||
<input id="base-scale" name="base[headingScale]" type="number" step="0.01" min="1" max="2" value="{{ theme.base.headingScale }}">
|
||||
<p class="field-help">1,25 ergibt eine ruhige Staffelung, 1,5 eine sehr kontrastreiche.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-image-radius">Bildecken</label>
|
||||
<input id="base-image-radius" name="base[imageRadius]" value="{{ theme.base.imageRadius }}" placeholder="12px">
|
||||
<p class="field-help">Gilt für jedes Bild auf der Website – auch in Galerien, Logoleisten und Beitragslisten. <code>0px</code> macht sie eckig. Ein einzelnes Bild lässt sich im Editor unter <strong>Design → Bild → Bildecken</strong> abweichend einstellen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="base-container">Inhaltsbreite</label>
|
||||
<input id="base-container" name="base[containerWidth]" value="{{ theme.base.containerWidth }}" placeholder="1240px">
|
||||
|
||||
@@ -3111,6 +3111,74 @@ final class CmsTest extends TestCase
|
||||
$code->assertScriptsUnchanged($added, $before);
|
||||
}
|
||||
|
||||
public function testImageCornersComeFromTheDesignAreaAndCanBeSquare(): void
|
||||
{
|
||||
// Die Ecken jedes Bildes standen fest im Stylesheet: sichtbar gerundet, in keinem Feld
|
||||
// zu finden, nur durch einen eigenen Wert am Block loszuwerden. Jetzt gehoeren sie dem
|
||||
// Designbereich - mit derselben Vorgabe wie vorher, damit sich nichts veraendert.
|
||||
$theme = $this->themes->load();
|
||||
self::assertSame('12px', $theme['base']['imageRadius'], 'Die Vorgabe bleibt, was vorher im Stylesheet stand.');
|
||||
|
||||
$document = DemoContent::starter('Mit Bild');
|
||||
$document['blocks'][0]['children'][] = ['id' => 'bild', 'type' => 'image', 'props' => ['src' => '/assets/image-placeholder.svg', 'alt' => 'Platzhalter', 'decorative' => false, 'width' => 1000, 'height' => 600]];
|
||||
$id = $this->publishedPage('ecken', 'Ecken');
|
||||
$stand = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']);
|
||||
|
||||
self::assertStringContainsString('--k-image-radius:12px', $this->request('/ecken')->getContent());
|
||||
|
||||
// Eckig, wie es eine Vorlage ohne Rundungen braucht.
|
||||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => '0px']);
|
||||
self::assertStringContainsString('--k-image-radius:0px', $this->request('/ecken')->getContent());
|
||||
|
||||
// Und was keine Groessenangabe ist, wird abgewiesen - wie bei der Inhaltsbreite.
|
||||
try {
|
||||
$this->themes->save($theme['colors'], $theme['fonts'], [...$theme['base'], 'imageRadius' => 'rund']);
|
||||
self::fail('„rund“ ist keine Groessenangabe.');
|
||||
} catch (InvalidPage) {
|
||||
}
|
||||
}
|
||||
|
||||
public function testSavingTextTwiceKeepsAmpersandsAndLineBreaksAsTheyAre(): void
|
||||
{
|
||||
// Umut: „Beim Startseitenbild wird & umgewandelt, aber nach dem Speichern steht &
|
||||
// da.“ Der Editor schickt HTML - auch ein Text ohne ein einziges Tag. Wurde der nur
|
||||
// escaped statt vorher dekodiert, wuchs die Entity bei jedem Speichern um eine Stufe,
|
||||
// bis sie sichtbar auf der Seite stand. Dasselbe galt fuer den Zeilenumbruch: nl2br()
|
||||
// liess das \n hinter dem <br> stehen, und der zweite Durchlauf machte zwei daraus.
|
||||
$text = new RichText();
|
||||
foreach ([
|
||||
'Freudenstadt & Umgebung' => 'Freudenstadt & Umgebung',
|
||||
'Haus & Hof' => 'Haus & Hof',
|
||||
'5 < 7 & mehr' => '5 < 7 & mehr',
|
||||
"Zeile eins\nZeile zwei" => 'Zeile eins<br>Zeile zwei',
|
||||
'<p>A & B</p>' => '<p>A & B</p>',
|
||||
'<b>woertlich</b>' => '<b>woertlich</b>',
|
||||
] as $eingabe => $erwartet) {
|
||||
$einmal = $text->sanitise($eingabe);
|
||||
self::assertSame($erwartet, $einmal, $eingabe);
|
||||
// Und jedes weitere Speichern laesst es, wie es ist.
|
||||
self::assertSame($einmal, $text->sanitise($einmal), 'zweites Speichern: '.$eingabe);
|
||||
self::assertSame($einmal, $text->sanitise($text->sanitise($einmal)), 'drittes Speichern: '.$eingabe);
|
||||
}
|
||||
|
||||
// Und durch das ganze Dokument, so wie es beim Speichern einer Seite passiert.
|
||||
$document = DemoContent::starter('Kaufmaennisches Und');
|
||||
$document['schemaVersion'] = 17;
|
||||
$document['blocks'][0]['children'][0]['props']['text'] = 'Freudenstadt & Umgebung';
|
||||
$document['blocks'][0]['children'][1]['props']['text'] = 'Immobilien & Verwaltung';
|
||||
$id = $this->publishedPage('und', 'Und');
|
||||
for ($runde = 0; $runde < 3; ++$runde) {
|
||||
$stand = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $this->pages->saveDraft($id, $document, $stand['version']), $this->pages->findForEditor($id)['version']);
|
||||
$document = $this->pages->findForEditor($id)['document'];
|
||||
}
|
||||
$html = $this->request('/und')->getContent();
|
||||
self::assertStringContainsString('Freudenstadt & Umgebung', $html);
|
||||
self::assertStringContainsString('Immobilien & Verwaltung', $html);
|
||||
self::assertStringNotContainsString('&amp;', $html, 'Nach drei Speichervorgaengen steht immer noch ein einziges Und da.');
|
||||
}
|
||||
|
||||
public function testOwnHtmlReachesThePageUnchangedAndRefusesScriptAndFrame(): void
|
||||
{
|
||||
// Der Baustein „Eigenes HTML“ (Schema v24): Markup des Betreibers, unverändert auf der
|
||||
|
||||
@@ -98,7 +98,7 @@ final class InstallFlowTest extends TestCase
|
||||
$home = $this->request('/');
|
||||
self::assertSame(200, $home->getStatusCode());
|
||||
self::assertStringContainsString('Beispiel GmbH', $home->getContent());
|
||||
self::assertStringContainsString('Diese Seite ist der Anfang.', $home->getContent());
|
||||
self::assertStringContainsString('Hier entsteht eine neue Webpräsenz.', $home->getContent());
|
||||
// The public page is still a public page: no script, and the header carries the name.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $home->headers->get('Content-Security-Policy'));
|
||||
self::assertSame(200, $this->request('/impressum')->getStatusCode());
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Command\RepairEntitiesCommand;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* `cms:repair:entities` räumt auf, was das doppelte Kodieren vor 1.20.1 hinterlassen hat.
|
||||
*
|
||||
* Geprüft wird beides: dass der Trockenlauf nichts anfasst, und dass das Schreiben genau die
|
||||
* verschachtelten Entities entschachtelt – ein einzelnes `&` ist richtig und muss bleiben.
|
||||
*/
|
||||
final class RepairEntitiesCommandTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private Connection $db;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-repair-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'KantSites', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
}
|
||||
|
||||
/** Legt eine Seite mit einem Text an und gibt die Kennung der Fassung zurück. */
|
||||
private function seite(string $text): string
|
||||
{
|
||||
$dokument = [
|
||||
'schemaVersion' => 17,
|
||||
'title' => 'Start',
|
||||
'description' => 'Eine Seite zum Reparieren.',
|
||||
'blocks' => [['id' => 's1', 'type' => 'section', 'children' => [
|
||||
['id' => 'h1', 'type' => 'heading', 'props' => ['text' => 'Start', 'level' => 1]],
|
||||
['id' => 't1', 'type' => 'text', 'props' => ['text' => $text]],
|
||||
]]],
|
||||
];
|
||||
// Erst die Seite, dann ihre Fassung: die Fassung zeigt per Fremdschluessel auf die Seite.
|
||||
$revision = bin2hex(random_bytes(16));
|
||||
$this->db->insert('page', ['id' => 'page-1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->insert('revision', ['id' => $revision, 'page_id' => 'page-1', 'document' => json_encode($dokument, JSON_THROW_ON_ERROR), 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->update('page', ['published_revision_id' => $revision, 'draft_revision_id' => $revision], ['id' => 'page-1']);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
private function text(string $revision): string
|
||||
{
|
||||
$dokument = json_decode((string) $this->db->fetchOne('SELECT document FROM revision WHERE id = ?', [$revision]), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $dokument['blocks'][0]['children'][1]['props']['text'];
|
||||
}
|
||||
|
||||
private function tester(): CommandTester
|
||||
{
|
||||
return new CommandTester(new RepairEntitiesCommand($this->db, new DocumentValidator()));
|
||||
}
|
||||
|
||||
public function testTheDryRunReportsWithoutChangingAnything(): void
|
||||
{
|
||||
$revision = $this->seite('Freudenstadt &amp; Umgebung');
|
||||
$tester = $this->tester();
|
||||
$tester->execute([]);
|
||||
|
||||
self::assertStringContainsString('1 Stelle', $tester->getDisplay());
|
||||
self::assertSame('Freudenstadt &amp; Umgebung', $this->text($revision), 'Ohne --schreiben bleibt alles stehen.');
|
||||
}
|
||||
|
||||
public function testWritingUnnestsEveryLevelAndLeavesASingleEntityAlone(): void
|
||||
{
|
||||
// Drei Stufen, wie sie auf einer Seite standen, die dreimal gespeichert wurde.
|
||||
$revision = $this->seite('Projekte &amp;amp; Lösungen, „Zitat&quot;, und ein richtiges & dazu');
|
||||
$tester = $this->tester();
|
||||
$tester->execute(['--schreiben' => true]);
|
||||
|
||||
self::assertStringContainsString('repariert', $tester->getDisplay());
|
||||
self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision));
|
||||
|
||||
// Ein zweiter Lauf findet nichts mehr - und macht aus dem richtigen & kein nacktes &.
|
||||
$zweiter = $this->tester();
|
||||
$zweiter->execute(['--schreiben' => true]);
|
||||
self::assertStringContainsString('Nichts zu reparieren', $zweiter->getDisplay());
|
||||
self::assertSame('Projekte & Lösungen, „Zitat", und ein richtiges & dazu', $this->text($revision));
|
||||
}
|
||||
|
||||
public function testAnUntouchedInstallationSaysSoAndKeepsQuiet(): void
|
||||
{
|
||||
$revision = $this->seite('Alles gut & sauber');
|
||||
$tester = $this->tester();
|
||||
$tester->execute(['--schreiben' => true]);
|
||||
|
||||
self::assertStringContainsString('Nichts zu reparieren', $tester->getDisplay());
|
||||
self::assertSame('Alles gut & sauber', $this->text($revision));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user