diff --git a/.claude/launch.json b/.claude/launch.json index 937123e..bfa1f3c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,7 +4,19 @@ { "name": "kant-cms", "runtimeExecutable": "php", - "runtimeArgs": ["-d", "upload_max_filesize=8M", "-d", "post_max_size=10M", "-d", "memory_limit=256M", "-S", "127.0.0.1:8080", "-t", "public"], + "runtimeArgs": [ + "-d", + "upload_max_filesize=8M", + "-d", + "post_max_size=10M", + "-d", + "memory_limit=256M", + "-S", + "127.0.0.1:8080", + "-t", + "public", + "bin/dev-router.php" + ], "port": 8080 } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e7ccf..b77cf4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - run: npm test - run: npm run test:editor -- --audit - name: Start production-mode preview - run: APP_ENV=prod APP_DEBUG=0 php -S 127.0.0.1:8080 -t public > var/server.log 2>&1 & + run: APP_ENV=prod APP_DEBUG=0 php -S 127.0.0.1:8080 -t public bin/dev-router.php > var/server.log 2>&1 & - name: Wait for preview run: node -e "for(let i=0;i<30;i++){try{if((await fetch('http://127.0.0.1:8080')).ok)process.exit(0)}catch{}await new Promise(r=>setTimeout(r,500))}process.exit(1)" - run: npm run test:browser diff --git a/README.md b/README.md index b11835d..d5422fd 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ composer install php bin/setup.php php vendor/bin/doctrine-migrations migrate --no-interaction php bin/console cms:seed-demo -php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public +php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public bin/dev-router.php ``` -Danach `http://127.0.0.1:8080/` öffnen. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. +Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage. `bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token. @@ -48,7 +48,7 @@ Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durc composer build $env:APP_ENV = 'prod' $env:APP_DEBUG = '0' -php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public +php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8080 -t public bin/dev-router.php ``` Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=0` verwenden. Vorher eine laufende Instanz auf diesem Port beenden. Zum Entwickeln `APP_ENV=dev` und `APP_DEBUG=1` setzen. Debug-Seiten senden absichtlich `X-Robots-Tag: noindex`; Audits laufen im Produktionsmodus. Der eingebaute PHP-Server bleibt ein lokaler Entwicklungsserver. diff --git a/bin/dev-router.php b/bin/dev-router.php new file mode 100644 index 0000000..90293bd --- /dev/null +++ b/bin/dev-router.php @@ -0,0 +1,16 @@ + document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow'); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze(); - assert.deepEqual(axe.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); - results.push({ width, path, violations: axe.violations.length, checks: axe.passes.length }); + + // A contrast failure has two very different causes, and they need different answers. + // Comes from the theme or a stylesheet: a defect of the system, and the run must fail - + // this is what caught the link colour that overrode every dark section. + // Comes from a colour an editor set on one block: an editorial finding. It is reported on + // every run and written into the report, but it must not turn a code change red, because + // nobody can fix it in the code. + const styledIds = new Set(await page.evaluate(() => + [...document.querySelectorAll('style')] + .flatMap(node => [...node.textContent.matchAll(/#([a-zA-Z0-9_-]+)\s*[{:]/g)].map(match => match[1])))); + const isEditorial = node => node.target.every(target => + typeof target === 'string' && target.startsWith('#') && styledIds.has(target.slice(1))); + + const defects = []; + for (const violation of axe.violations) { + const editorial = 'color-contrast' === violation.id ? violation.nodes.filter(isEditorial) : []; + const rest = violation.nodes.filter(node => !editorial.includes(node)); + if (editorial.length > 0) { + findings.push({ + path, + width, + id: violation.id, + cause: 'Inhalt: im Editor gesetzte Farbe', + nodes: editorial.map(node => ({ target: node.target, summary: node.failureSummary?.split('\n')[1]?.trim() })), + }); + } + if (rest.length > 0) defects.push({ id: violation.id, nodes: rest.map(node => node.target) }); + } + assert.deepEqual(defects, [], JSON.stringify(defects, null, 1)); + results.push({ width, path, defects: defects.length, editorialFindings: findings.filter(f => f.path === path && f.width === width).length, checks: axe.passes.length }); await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true }); } await page.goto(origin); @@ -41,8 +83,17 @@ try { assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/); await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click(); assert.equal(new URL(noJs.url()).pathname, '/grundlagen'); - await writeFile('reports/browser.json', JSON.stringify({ origin, results, keyboard: 'passed', noJavaScript: 'passed' }, null, 2)); + await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2)); console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.'); + if (findings.length > 0) { + // Loud on every run: these are real accessibility problems, they are just fixed in the editor + // rather than in the code, so they do not make a code change fail. + console.log('\n' + findings.length + ' Barrierefreiheits-Befund(e) aus im Editor gesetzten Farben:'); + for (const finding of findings) { + console.log(' ' + finding.path + ' (' + finding.width + ' px) ' + finding.id + ': ' + finding.nodes.map(n => n.target.join(',') + ' - ' + n.summary).join(' | ')); + } + console.log(' Zu beheben im Editor, nicht im Code. Details auch in reports/browser.json.'); + } } finally { await browser.close(); } diff --git a/src/Content/MenuRepository.php b/src/Content/MenuRepository.php index 543a875..dcdbb37 100644 --- a/src/Content/MenuRepository.php +++ b/src/Content/MenuRepository.php @@ -18,7 +18,7 @@ final class MenuRepository public const MAX_ITEMS = 60; public const MAX_DEPTH = 2; - public function __construct(private Connection $db) + public function __construct(private Connection $db, private SiteSettings $settings) { } @@ -82,14 +82,14 @@ final class MenuRepository $pages[$page['id']] = $page; } - return $this->resolveLevel($items, $pages, $currentPath); + return $this->resolveLevel($items, $pages, $currentPath, $this->settings->homePath()); } - private function resolveLevel(array $items, array $pages, string $currentPath): array + private function resolveLevel(array $items, array $pages, string $currentPath, string $homePath): array { $links = []; foreach ($items as $item) { - $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath); + $children = $this->resolveLevel($item['children'] ?? [], $pages, $currentPath, $homePath); $label = $item['label'] ?? ''; if ('page' === $item['type']) { $page = $pages[$item['pageId']] ?? null; @@ -98,7 +98,7 @@ final class MenuRepository array_push($links, ...$children); continue; } - $path = 'home' === $page['path'] ? '/' : '/'.$page['path']; + $path = $this->settings->publicPath($page['path'], $homePath); $anchor = $item['anchor'] ?? ''; $href = $path.('' === $anchor ? '' : '#'.$anchor); // An anchor link is never the current page on its own; it jumps inside one. diff --git a/src/Content/PageRepository.php b/src/Content/PageRepository.php index ebc0d64..3df3041 100644 --- a/src/Content/PageRepository.php +++ b/src/Content/PageRepository.php @@ -12,7 +12,7 @@ final class PageRepository public const RESERVED_SLUGS = ['admin', 'media', 'assets', 'favicon', 'kategorie', 'schlagwort', 'feed', 'beitraege']; public const MAX_DEPTH = 4; - public function __construct(private Connection $db, private DocumentValidator $validator) + public function __construct(private Connection $db, private DocumentValidator $validator, private SiteSettings $settings) { } @@ -94,11 +94,10 @@ final class PageRepository if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } - if ('home' === $page['path']) { - // The path 'home' is assumed as the site root in several places (public routing, canonical, - // sitemap, llms.txt, menu resolution). Making it configurable is its own step; until - // then the start page keeps its address rather than half of the system following it. - throw new InvalidPage('Die Startseite behält ihre Adresse. Eine andere Seite als Startseite zu wählen ist noch nicht möglich.'); + if ($page['path'] === $this->settings->homePath()) { + // The root answers at "/". Renaming it would move the whole site to a new address, + // which is a settings decision, not an editing one. + throw new InvalidPage('Diese Seite ist die Startseite. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } $newPath = $slug; @@ -169,8 +168,8 @@ final class PageRepository if ((int) $page['version'] !== $expectedVersion) { throw new StaleWrite('Die Seite wurde inzwischen geändert. Lade die Übersicht neu.'); } - if ('home' === $page['path']) { - throw new InvalidPage('Die Startseite kann nicht in den Papierkorb.'); + if ($page['path'] === $this->settings->homePath()) { + throw new InvalidPage('Die Startseite kann nicht in den Papierkorb. Wähle unter Einstellungen zuerst eine andere Seite als Startseite.'); } return $this->db->executeStatement( diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php index c0bce17..ac38485 100644 --- a/src/Content/PostRepository.php +++ b/src/Content/PostRepository.php @@ -17,7 +17,7 @@ final class PostRepository public const ARCHIVE_PREFIX = ['category' => 'kategorie', 'tag' => 'schlagwort']; public const PER_PAGE = 10; - public function __construct(private Connection $db, private MediaLibrary $media) + public function __construct(private Connection $db, private MediaLibrary $media, private SiteSettings $settings) { } @@ -141,7 +141,7 @@ final class PostRepository return [ 'title' => $document['title'] ?? $row['path'], - 'href' => '/'.$row['path'], + 'href' => $this->settings->publicPath($row['path']), 'excerpt' => $row['excerpt'] ?? $document['description'] ?? '', 'author' => $row['author'], 'publishedAt' => $row['published_at'], diff --git a/src/Content/SiteSettings.php b/src/Content/SiteSettings.php index d7d524a..949aae0 100644 --- a/src/Content/SiteSettings.php +++ b/src/Content/SiteSettings.php @@ -25,6 +25,8 @@ final class SiteSettings 'localBusiness' => false, 'llmsTxt' => false, 'robotsExtra' => '', + // Which stored page answers at the site root. Everything else derives from this. + 'homePath' => 'home', ]; public function __construct(private Connection $db) @@ -82,12 +84,37 @@ final class SiteSettings } $values['robotsExtra'] = implode("\n", array_slice($extra, 0, 30)); + $home = trim(trim((string) ($input['homePath'] ?? self::DEFAULTS['homePath'])), '/'); + if (1 !== preg_match('#^[a-z0-9]+(?:[-/][a-z0-9]+)*$#D', $home)) { + throw new InvalidPage('Die Startseite braucht eine gültige Adresse.'); + } + // A root that points nowhere would answer every visit with 404, so it must exist. + if (false === $this->db->fetchOne('SELECT id FROM page WHERE website_id = ? AND path = ? AND deleted_at IS NULL', ['default', $home])) { + throw new InvalidPage(sprintf('Es gibt keine Seite mit der Adresse /%s.', $home)); + } + $values['homePath'] = $home; + $this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [ json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), 'default', ]); } + /** The stored path of the page that answers at the site root. */ + public function homePath(): string + { + return $this->load()['homePath']; + } + + /** + * Turns a stored page path into its public address. The root page answers at "/" and must not + * also be reachable under its own path, or the same content would live at two addresses. + */ + public function publicPath(string $storedPath, ?string $homePath = null): string + { + return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath; + } + /** * Structured data for the organisation, built from the maintained values only. Returns null * while nothing is maintained: an empty Organization block would be noise, not data. diff --git a/src/Controller/FormController.php b/src/Controller/FormController.php index 6691246..3c73d91 100644 --- a/src/Controller/FormController.php +++ b/src/Controller/FormController.php @@ -5,6 +5,7 @@ namespace App\Controller; use App\Content\FormRepository; use App\Content\MenuRepository; use App\Content\PageRepository; +use App\Content\SiteSettings; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +26,7 @@ final class FormController extends AbstractController private FormRepository $forms, private PageRepository $pages, private MenuRepository $menus, + private SiteSettings $settings, private MailerInterface $mailer, private string $secret, private string $siteOrigin, @@ -35,7 +37,7 @@ final class FormController extends AbstractController public function submit(Request $request, string $slug): Response { $form = $this->forms->findBySlug($slug); - $path = trim($request->request->getString('page'), '/') ?: 'home'; + $path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath(); $page = $this->pages->findPublished($path); if (null === $form || null === $page) { throw $this->createNotFoundException('Formular oder Seite nicht gefunden.'); @@ -61,7 +63,7 @@ final class FormController extends AbstractController } // Post/Redirect/Get so a reload does not send the message twice. - return $this->redirect(('home' === $path ? '/' : '/'.$path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); + return $this->redirect($this->settings->publicPath($path).'?gesendet='.rawurlencode($slug).'#'.rawurlencode($request->request->getString('block')), 303); } /** The signed timestamp makes the age of the form checkable without storing anything. */ @@ -133,7 +135,7 @@ final class FormController extends AbstractController $response = $this->render('page.html.twig', [ 'page' => $page, - 'canonical' => rtrim($this->siteOrigin, '/').('home' === $page['path'] ? '/' : '/'.$page['path']), + 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($page['path']), 'navigation' => $navigation, 'postLists' => [], 'postMeta' => null, diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index fbce483..520ce45 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -27,10 +27,12 @@ final class PageController extends AbstractController #[Route('/', name: 'home', methods: ['GET'])] #[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])] - public function page(Request $request, string $path = 'home'): Response + public function page(Request $request, string $path = ''): Response { - // One canonical address for the home page. - if ('home' === $path && '/home' === $request->getPathInfo()) { + $home = $this->settings->homePath(); + $path = '' === $path ? $home : $path; + // One canonical address for the root page: its own path redirects to "/". + if ($path === $home && '/' !== $request->getPathInfo()) { return $this->redirectToRoute('home', status: 301); } $page = $this->pages->findPublished($path); @@ -52,7 +54,7 @@ final class PageController extends AbstractController } else { $response = $this->render('page.html.twig', [ 'page' => $page, - 'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path), + 'canonical' => rtrim($this->siteOrigin, '/').$this->settings->publicPath($path, $home), 'navigation' => $this->navigation($path), 'postLists' => $this->postLists($page['document']), 'postMeta' => 'post' === $page['type'] ? $this->posts->termsFor($page['id']) : null, @@ -140,7 +142,7 @@ final class PageController extends AbstractController */ private function breadcrumbs(array $page): array { - if ('home' === $page['path']) { + if ($page['path'] === $this->settings->homePath()) { return []; } $trail = []; @@ -166,7 +168,7 @@ final class PageController extends AbstractController { $origin = rtrim($this->siteOrigin, '/'); $blocks = []; - if ('home' === $page['path']) { + if ($page['path'] === $this->settings->homePath()) { $organisation = $this->settings->organisation($this->settings->load(), $origin, (string) $page['website_name']); if (null !== $organisation) { $blocks[] = $organisation; diff --git a/src/Controller/SeoController.php b/src/Controller/SeoController.php index 55233a2..fdf2b2b 100644 --- a/src/Controller/SeoController.php +++ b/src/Controller/SeoController.php @@ -28,6 +28,7 @@ final class SeoController extends AbstractController public function sitemap(): Response { $origin = rtrim($this->siteOrigin, '/'); + $home = $this->settings->homePath(); $entries = []; // Only published pages that are allowed to be indexed. A draft or a noindex page in the // sitemap would ask a search engine to fetch something it must not list. @@ -36,7 +37,7 @@ final class SeoController extends AbstractController ['default'], ) as $row) { $entries[] = [ - 'loc' => $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), + 'loc' => $origin.$this->settings->publicPath($row['path'], $home), 'lastmod' => substr((string) ($row['published_at'] ?? $row['created_at']), 0, 10), ]; } @@ -80,6 +81,7 @@ final class SeoController extends AbstractController throw $this->createNotFoundException('llms.txt ist für diese Website nicht aktiviert.'); } $origin = rtrim($this->siteOrigin, '/'); + $home = $settings['homePath']; $name = (string) $this->db->fetchOne('SELECT name FROM website WHERE id = ?', ['default']); $lines = ['# '.('' !== $settings['legalName'] ? $settings['legalName'] : $name), '']; if ('' !== $settings['description']) { @@ -98,7 +100,7 @@ final class SeoController extends AbstractController foreach ($rows as $row) { $document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR); $summary = trim((string) ($row['excerpt'] ?? $document['description'] ?? '')); - $lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.('home' === $row['path'] ? '/' : '/'.$row['path']), '' === $summary ? '' : ': '.$summary); + $lines[] = sprintf('- [%s](%s)%s', $document['title'] ?? $row['path'], $origin.$this->settings->publicPath($row['path'], $home), '' === $summary ? '' : ': '.$summary); } $lines[] = ''; } diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index ef992a6..83175da 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Content\PageRepository; use App\Content\RedirectRepository; use App\Content\SiteSettings; use App\Domain\InvalidPage; @@ -12,16 +13,26 @@ use Symfony\Component\Routing\Attribute\Route; final class SettingsController extends AbstractController { - public function __construct(private SiteSettings $settings, private RedirectRepository $redirects) + public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages) { } #[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])] public function index(): Response { + $choosable = []; + foreach ($this->pages->listForEditor() as $page) { + // Only a published page can serve as the root; a draft would answer every visit with 404. + if ('page' === $page['type'] && null !== $page['published_revision_id']) { + $document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); + $choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']]; + } + } + return $this->render('admin/settings.html.twig', [ 'settings' => $this->settings->load(), 'redirects' => $this->redirects->all(), + 'choosableHome' => $choosable, ]); } diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index d335ddf..4dc1baf 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -55,6 +55,11 @@
Zweibuchstabiger Code, zum Beispiel DE.
+ + +Diese Seite antwortet unter /; ihre eigene Adresse leitet dauerhaft dorthin. Nur veröffentlichte Seiten sind wählbar, und die Startseite lässt sich nicht umbenennen oder in den Papierkorb legen.