Compare commits
6
Commits
v1.20.0
...
741eaa80f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,31 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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