web: E9 und E10 entschieden — Zuordnung aktiv, eine Adresse je statischer Seite, Hub-Portalseiten leiten weiter

E9: Kategorie-Zuordnung wie vorgeschlagen übernommen, Zuordnung ist
standardmäßig an (CANONICAL_ASSIGNMENT_ENABLED). Auf der Dev-Kopie
verifiziert: Kopie trägt rel=canonical auf die Erstquelle und fällt aus der
Sitemap ihres Portals.

E10: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten und die
API-Doku lagen auf allen drei Hosts mit identischem Text und je eigenem
Self-Canonical. Jetzt führt presseecho die Informationsseiten, der Hub die
API-Doku; Sitemap und llms.txt nennen nur die führende Adresse.
Rechtstexte (Impressum, Datenschutz, AGB, Richtlinien, Meldeweg,
Kündigung) bleiben bewusst je Host — jede Domain ist ein eigenes Angebot
mit eigener Anbieterkennzeichnung.

Bei der Erreichbarkeitsprüfung des Hubs gefunden und behoben:
/de/preise, /de/veroeffentlichen, /de/newsrooms und /de/suche wurden unter
pressekonto.com mit dem Branding von businessportal24 ausgeliefert (der
Portal-Kontext fällt dort darauf zurück) — jetzt 301 auf presseecho, die
Transparenz-Navigation verlinkt direkt dorthin. /de/kontakt und
/de/ueber-uns sprangen über /de auf die Startseite, jetzt in einem Schritt.

Der Self-Canonical kommt jetzt aus dem aufgerufenen Host; Portal-Inhalte,
die über den Hub laufen, behalten ihr Portal (webPortalContextApplied).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 07:52:09 +00:00
parent ee267c622e
commit 4c17a17254
18 changed files with 434 additions and 17 deletions

View file

@ -265,6 +265,8 @@ TRANSPARENCY_FREE_PORTALS_CHECKED_AT=
# Doppelveröffentlichung (Roadmap Anhang 2, 1c.8): zweite Fassung verweist per
# rel=canonical auf die Erstquelle, Zuordnung je Kategorie in config/canonical.php
# (Entscheidung E9). Aus = jede Fassung bleibt ihr eigenes Canonical.
CANONICAL_ASSIGNMENT_ENABLED=false
# (E9, entschieden 18.09.2026 — Standard an). Aus = jede Fassung bleibt ihr
# eigenes Canonical. ACHTUNG Reihenfolge beim ersten Deploy: migrate →
# press-releases:hash-content → press-releases:assign-canonicals.
CANONICAL_ASSIGNMENT_ENABLED=true
CANONICAL_FALLBACK=first

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Support\StaticPageCanonical;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -32,6 +33,9 @@ class LlmsTxtController extends Controller
private function hub(string $base): string
{
// Seiten, die auf allen drei Hosts liegen, werden mit der führenden
// Adresse genannt (Anhang 2, E10) — eine Adresse je Text.
$shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base;
$presseecho = rtrim((string) config('domains.domains.presseecho.url'), '/');
$businessportal = rtrim((string) config('domains.domains.businessportal24.url'), '/');
@ -70,6 +74,8 @@ class LlmsTxtController extends Controller
private function portal(string $base): string
{
// Wie im Hub: gemeinsame Seiten mit der führenden Adresse nennen.
$shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base;
$context = WebPortalContext::fromRequest();
$brand = (array) ($context->domainConfig['brand'] ?? []);
$name = ($brand['name'] ?? '').($brand['accent'] ?? '');

View file

@ -9,6 +9,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\CanonicalAssigner;
use App\Services\Web\CategoryArchivePageData;
use App\Support\StaticPageCanonical;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -53,6 +54,18 @@ class SitemapController extends Controller
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* Ob dieser Host die Seite führt (Anhang 2, E10): gemeinsame
* Informationsseiten liegen auf allen drei Hosts, in die Sitemap gehört
* nur die kanonische Adresse. Rechtstexte bleiben je Host gelistet.
*/
private function leadsPage(string $base, string $page): bool
{
$canonicalBase = StaticPageCanonical::baseFor($page);
return $canonicalBase === null || parse_url($canonicalBase, PHP_URL_HOST) === parse_url($base, PHP_URL_HOST);
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
@ -61,6 +74,10 @@ class SitemapController extends Controller
$urls = [['loc' => $base.'/', 'lastmod' => null]];
foreach (self::HUB_PAGES as $page) {
if (! $this->leadsPage($base, $page)) {
continue;
}
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
@ -77,6 +94,10 @@ class SitemapController extends Controller
$urls = [['loc' => $base.'/de', 'lastmod' => null]];
foreach (self::PORTAL_PAGES as $page) {
if (! $this->leadsPage($base, $page)) {
continue;
}
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Support\StaticPageCanonical;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Portalseiten auf dem Hub (Anhang 2, E10): Preise, Veröffentlichen,
* Newsrooms und Suche gibt es nur auf den Portalen. Unter pressekonto.com
* wurden sie bisher trotzdem ausgeliefert — mit dem Branding von
* businessportal24, weil der Portal-Kontext dort darauf zurückfällt. Statt
* einer Seite mit fremdem Namen unter fremder Domain: 301 auf das führende
* Portal.
*/
class RedirectPortalOnlyPagesFromHub
{
public function handle(Request $request, Closure $next): Response
{
if (! str_contains($request->getHost(), 'pressekonto')) {
return $next($request);
}
$base = StaticPageCanonical::portalOnlyRedirect($request->route()?->getName());
if ($base === null) {
return $next($request);
}
return redirect($base.$request->getPathInfo(), 301);
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Support;
/**
* Kanonischer Host statischer Seiten (Roadmap Anhang 2, E10).
*
* faq, hilfe, Transparenzseiten und die API-Doku werden auf Hub und beiden
* Portalen mit identischem Text ausgeliefert. Damit nicht drei Adressen um
* denselben Text konkurrieren, führt je Seite ein Host; die beiden anderen
* verweisen per rel=canonical dorthin. Rechtstexte sind bewusst
* ausgenommen — sie gehören zu ihrer Domain (siehe config/canonical.php).
*/
class StaticPageCanonical
{
/**
* Kanonische Adresse der aktuellen Seite oder null, wenn die Seite
* self-canonical bleibt.
*/
public static function urlFor(?string $routeName, string $path): ?string
{
$base = self::baseFor($routeName);
return $base === null ? null : $base.$path;
}
/**
* Basis-URL des führenden Hosts einer Route, oder null.
*/
public static function baseFor(?string $routeName): ?string
{
if ($routeName === null) {
return null;
}
$domainKey = config('canonical.static_page_host.'.$routeName);
if (! is_string($domainKey)) {
return null;
}
$url = config('domains.domains.'.$domainKey.'.url');
return is_string($url) && $url !== '' ? rtrim($url, '/') : null;
}
/**
* Basis der Selbst-Referenz: die konfigurierte URL des Hosts, unter dem
* die Seite tatsächlich aufgerufen wurde.
*
* Der geteilte `$domainUrl` aus dem ThemeServiceProvider taugt dafür
* nicht immer — er wird beim Booten aus dem damaligen Host abgeleitet
* und von `WebPortalContext::fromRequest()` auf ein Portal umgestellt,
* auf dem Hub also auf businessportal24. Maßgeblich ist der Host der
* Anfrage — außer bei Portal-Inhalten, die über den Hub aufgerufen
* wurden ($portalContextApplied).
*/
public static function selfBase(?string $domainUrl = null, bool $portalContextApplied = false): string
{
$host = request()->getHost();
// Portal-Inhalte (Kategorie, Archiv, Meldung, Newsroom) sind auch
// über den Hub erreichbar und rendern dort das Portal. Ihr Canonical
// gehört auf das Portal, nicht auf pressekonto.
if ($portalContextApplied && is_string($domainUrl) && $domainUrl !== '') {
return rtrim($domainUrl, '/');
}
if (is_string($domainUrl) && $domainUrl !== '' && parse_url($domainUrl, PHP_URL_HOST) === $host) {
return rtrim($domainUrl, '/');
}
foreach ((array) config('domains.domains', []) as $domain) {
$url = $domain['url'] ?? null;
if (is_string($url) && parse_url($url, PHP_URL_HOST) === $host) {
return rtrim($url, '/');
}
}
return rtrim(request()->getSchemeAndHttpHost(), '/');
}
/**
* Adresse für einen Link auf eine benannte Seite: normalerweise die des
* aufgerufenen Hosts — bei Portalseiten auf dem Hub direkt die des
* führenden Portals, damit der Besucher nicht über eine Weiterleitung
* läuft.
*/
public static function publicUrlFor(string $routeName): string
{
$url = route($routeName);
$portalBase = self::portalOnlyRedirect($routeName);
if ($portalBase !== null && str_contains(request()->getHost(), 'pressekonto')) {
return $portalBase.(string) parse_url($url, PHP_URL_PATH);
}
return $url;
}
/**
* Seiten, die es nur auf den Portalen gibt — auf dem Hub führen sie auf
* das führende Portal statt auf eine Fassung mit fremdem Branding.
*/
public static function portalOnlyRedirect(?string $routeName): ?string
{
if ($routeName === null || ! in_array($routeName, (array) config('canonical.portal_only_pages', []), true)) {
return null;
}
$url = config('domains.domains.'.config('canonical.portal_only_host', 'presseecho').'.url');
return is_string($url) && $url !== '' ? rtrim($url, '/') : null;
}
}

View file

@ -3,6 +3,7 @@
namespace App\Support;
use App\Enums\Portal;
use Illuminate\Support\Facades\View;
/**
* Öffentlicher Portal-Kontext (Presseecho/Businessportal24) einer Web-Landingpage,
@ -39,6 +40,12 @@ class WebPortalContext
$portal = self::portalFromHost();
$domainKey = $portal === Portal::Presseecho ? 'presseecho' : 'businessportal24';
// Merker fürs Layout: Diese Seite rendert Portal-Inhalte, die
// Domain-Config zeigt also auf ein Portal — auch wenn der Aufruf über
// den Hub kam. Der Self-Canonical muss dann dem Portal folgen, nicht
// dem aufgerufenen Host (Anhang 2, E10).
View::share('webPortalContextApplied', true);
return new self(
domainKey: $domainKey,
portal: $portal,

View file

@ -3,6 +3,7 @@
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\LogApiUsage;
use App\Http\Middleware\RecordMailLinkClick;
use App\Http\Middleware\RedirectPortalOnlyPagesFromHub;
use App\Http\Middleware\RedirectWwwToApex;
use App\Http\Middleware\RejectLegacyApiKeys;
use App\Http\Middleware\SecurityHeaders;
@ -50,6 +51,13 @@ return Application::configure(basePath: dirname(__DIR__))
// Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen.
$middleware->append(SetEdition::class);
// Portalseiten auf dem Hub (Anhang 2, E10): /de/preise & Co. gibt es
// nur auf den Portalen — 301 statt einer Fassung mit fremdem Branding
// unter pressekonto.com. In der web-Gruppe, nicht global: die
// Middleware liest den Routennamen, und der steht erst nach dem
// Routing fest.
$middleware->web(append: [RedirectPortalOnlyPagesFromHub::class]);
// Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt
// werden: rollen- und verifizierungsbewusst statt fix auf /dashboard,
// sonst landet ein Customer dort im 403 und sitzt fest.

View file

@ -19,7 +19,7 @@
return [
'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', false),
'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', true),
// DE-Slug der Hauptkategorie => Portal, das die Erstquelle führt.
// Vorschlag zu E9; Kategorien ohne Eintrag fallen auf `fallback`.
@ -44,4 +44,46 @@ return [
// bleibt Erstquelle; alternativ ein Portalname als feste Vorgabe.
'fallback' => env('CANONICAL_FALLBACK', 'first'),
/*
|----------------------------------------------------------------------
| Statische Seiten auf drei Hosts (E10, entschieden 18.09.2026)
|----------------------------------------------------------------------
|
| faq, hilfe, Transparenzseiten & Co. werden auf Hub und beiden Portalen
| mit identischem Text ausgeliefert — dieselbe Doppelung wie bei den
| Meldungen, nur dreifach. Die Portale führen (ältere Domains),
| presseecho ist der Standard; die API-Dokumentation gehört zum Hub.
|
| Routenname => Domain-Schlüssel aus config/domains.php. Nicht gelistete
| Seiten bleiben self-canonical je Host.
|
| BEWUSST NICHT gelistet: impressum, datenschutz, agb, richtlinien,
| melden, kuendigung. Jede Domain ist ein eigenes Angebot mit eigener
| Anbieterkennzeichnung, eigenem Meldeweg und eigenen Bedingungen —
| diese Seiten müssen je Host für sich stehen und auffindbar bleiben.
|
*/
'static_page_host' => [
'faq' => 'presseecho',
'hilfe' => 'presseecho',
'gute-pressemitteilung' => 'presseecho',
'was-kostet-eine-pressemitteilung' => 'presseecho',
'pressemitteilung-veroeffentlichen-ohne-abo' => 'presseecho',
'pressemitteilung-loeschen-oder-aendern-lassen' => 'presseecho',
'pressemitteilung-kostenlos-veroeffentlichen' => 'presseecho',
'api' => 'pressekonto',
],
/*
| Seiten, die es nur auf den Portalen gibt. Auf dem Hub wurden sie bisher
| mit Portal-Branding unter pressekonto.com ausgeliefert (der Portal-
| Kontext fällt dort auf businessportal24 zurück) — jetzt 301 auf das
| führende Portal.
*/
'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche'],
'portal_only_host' => 'presseecho',
];

View file

@ -241,8 +241,8 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |
| ~~E7~~ | **Entschieden 17.09.2026 (Anhang 1):** keine kostenfreie Einreichung, dauerhaft | 2.4, 2.6 |
| E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`), Entscheidung nach Verteilung aus 1c.4 (Anhang 1) | 1c.5 |
| E9 | Kanonische Zuordnung je Kategorie — Vorschlag in `config/canonical.php`, Mechanismus gebaut und aus (`CANONICAL_ASSIGNMENT_ENABLED`) (Anhang 2) | 1c.8 |
| E10 | Transparenz-/Statikseiten: Hub oder Portale als Canonical — Befund: dreifach vorhanden (Anhang 2, Protokoll Teil 7) | 2.4 |
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 8) | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 8) | 2.4 |
---

View file

@ -9,7 +9,7 @@
| Änderung | Betrifft | Art |
|---|---|---|
| Crawler-Abruf-Protokoll als eigenes Arbeitspaket | 1c.0 | neu |
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.6 | ersetzt |
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.N9A59YXJai6 | ersetzt |
| Erstquelle gegen Kopie als Argument für die Direktveröffentlichung | 1d | neu |
| Transparenzseiten ausformuliert, kein Wettbewerbervergleich | 2.4 | ersetzt |
| Zuschriften an Portalübersichten ausformuliert, zwei konkrete Ziele | 2.6 | ersetzt |

View file

@ -99,7 +99,7 @@ Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der
Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nicht gebraucht — das Tageskontingent geht an die beiden Portale. Zwei Dinge sind dort trotzdem zu prüfen:
- ~~Stehen Konto- und Login-Bereiche auf `noindex`?~~ **17.09.2026: ja, jetzt per Meta-Tag in allen Portal-Layouts.** Die Indexierungsübersicht der neuen Property zeigt, was Google von dort erfasst hat.
- **Offene Entscheidung E10:** Wohin gehören die Transparenzseiten aus 2.4 — auf den Hub oder auf die Portale? Die Portale haben die deutlich ältere Domain, der Hub die thematisch passendere Adresse.
- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar (Canonical zeigt aufs Portal, keine Sitemap-Einträge) — Empfehlung 301 auf businessportal24, ~34.000 Adressen, wartet auf Freigabe.
---
@ -107,5 +107,5 @@ Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nich
| Nr. | Frage | Betrifft |
|---|---|---|
| E9 | Kanonische Zuordnung je Kategorie: welches Portal führt welches Themenfeld — *Vorschlag liegt in `config/canonical.php`, Mechanismus wartet auf `CANONICAL_ASSIGNMENT_ENABLED=true`* | 1c.8 |
| E10 | Wohin gehören die Transparenzseiten aus 2.4 — Hub oder Portale. *Befund 17.09.2026: alle statischen Seiten liegen auf drei Hosts mit je eigenem Canonical; Empfehlung Portale (Protokoll Teil 7)* | 2.4 |
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung wie vorgeschlagen (businessportal24: finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; presseecho: alle übrigen inkl. sonstiges). Zuordnung ist aktiv, `CANONICAL_ASSIGNMENT_ENABLED` steht standardmäßig auf true | 1c.8 |
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 8) | 2.4 |

View file

@ -1998,3 +1998,93 @@ entkoppelt. Pint, Larastan ohne Befund.
Hintergrund), dann `press-releases:duplicates-report`. Erst nach E9:
`CANONICAL_ASSIGNMENT_ENABLED=true`, `press-releases:assign-canonicals
--dry-run`, dann ohne Flag, `view:cache`, Sitemaps prüfen.
## Teil 8 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026)
### E9 — kanonische Zuordnung aktiv
Zuordnung wie vorgeschlagen übernommen (`config/canonical.php`):
businessportal24 führt finanzen, handel, it, recht, wissenschaft-forschung,
motor-verkehr; presseecho führt haus, kultur, medizin, mode, politik,
reisen, sport, sonstiges. `CANONICAL_ASSIGNMENT_ENABLED` steht jetzt
standardmäßig auf `true` — ohne `.env`-Eintrag ist die Zuordnung an.
Auf der Dev-Kopie verifiziert: 9.092 Paare zugeordnet, die Kopie trägt
`rel=canonical` auf die Erstquelle, steht nicht mehr in der Sitemap ihres
Portals, die Erstquelle schon.
### E10 — eine Adresse je Text, Rechtstexte ausgenommen
Befund: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten, api
und alle Rechtstexte liegen auf **allen drei Hosts** mit identischem Text
(nur Markenname getauscht, per Prüfsumme bestätigt) und trugen je einen
eigenen Self-Canonical.
Umgesetzt (`config/canonical.static_page_host`, `App\Support\StaticPageCanonical`):
| Seiten | Führender Host |
|---|---|
| faq, hilfe, gute-pressemitteilung, 4 Transparenzseiten | presseecho |
| api (Doku des Publisher-Bereichs) | pressekonto |
| impressum, datenschutz, agb, richtlinien, melden, kuendigung | **je Host selbst** |
**Rechtstexte bewusst ausgenommen** — das ist eine Abweichung von „alles auf
die Portale". Jede Domain ist ein eigenes Angebot mit eigener
Anbieterkennzeichnung und eigenem Meldeweg; wer auf businessportal24.com
das Impressum aufruft, muss das Impressum dieser Domain bekommen, und es
muss für sich auffindbar bleiben. Ein Canonical auf eine fremde Domain
würde dem widersprechen. Inhaltlich sind die Texte identisch — das ist bei
Pflichtangaben unkritisch.
Mitgezogen: Sitemap listet je Seite nur noch den führenden Host (Hub
behält api + Rechtstexte), `llms.txt` nennt die führende Adresse, `og:url`
folgt dem Canonical.
### Erreichbarkeit pressekonto.com — geprüft, drei Befunde
Alle 24 Hub-Adressen und jeder Link der Hub-Startseite geprüft: **alles
200**, unbekannte Pfade 404, `sitemap.xml`, `sitemap-news.xml`,
`llms.txt`, `robots.txt`, `/docs/api/v1` in Ordnung.
1. **Portalseiten unter pressekonto.com** (behoben): `/de/preise`,
`/de/veroeffentlichen`, `/de/newsrooms`, `/de/suche` wurden auf dem Hub
ausgeliefert — mit dem Branding und Titel von **businessportal24**,
weil `WebPortalContext::fromRequest()` auf dem Hub auf dieses Portal
zurückfällt. Jetzt 301 auf presseecho
(`RedirectPortalOnlyPagesFromHub`, web-Gruppe). Die
Transparenz-Navigation verlinkt „Tarife & Preise" auf dem Hub direkt
auf presseecho, ohne Umweg über die Weiterleitung.
2. **Weiterleitungskette** (behoben): `/de/kontakt` und `/de/ueber-uns`
zeigten auf `/de`, das auf dem Hub selbst auf `/` weiterleitet — zwei
Sprünge. Jetzt direkt auf die Startseite des jeweiligen Hosts.
3. **Portal-Inhalte unter pressekonto.com** (offen, Empfehlung unten):
`/de/category/{slug}`, `/de/category/{slug}/archiv/{jahr}` und
`/de/press-release/{slug}` sind auf dem Hub erreichbar und rendern das
Portal (businessportal24), weil die Ausgabe-Routen für alle Hosts
gelten. Ihr Canonical zeigt auf das Portal — sie stehen in keiner
Sitemap und werden nicht zusätzlich indexiert. **Empfehlung:** auch
diese Routen auf dem Hub per 301 auf businessportal24 legen (dasselbe
Portal, das der Hub ohnehin rendert; presseecho-only-Meldungen laufen
heute wie danach ins 404). Ein Eintrag in
`canonical.portal_only_pages` würde nicht reichen, weil das Ziel dort
fest auf presseecho steht — braucht eine eigene Zeile. Nicht ohne
Freigabe gemacht, weil es rund 34.000 Adressen betrifft.
Technischer Nebeneffekt: Der Self-Canonical kommt jetzt aus dem
tatsächlich aufgerufenen Host (`StaticPageCanonical::selfBase()`) statt aus
der geteilten Domain-Config. Portal-Inhalte behalten ihr Portal —
`WebPortalContext::fromRequest()` setzt dafür den Merker
`webPortalContextApplied`.
### Tests / Deploy
`HubPortalPageRedirectTest` (19), angepasste Sitemap-Erwartungen in
`StaticPagesTest` und `TransparencyPagesTest`. Gesamtlauf 1.804 grün
(eine Ausnahme: `ManualInvoicePdfTest` schlägt unabhängig von diesen
Änderungen fehl — mit und ohne Stash reproduziert, Anrede „Frau Erika
Muster" fehlt; gehört zum Rechnungs-Paket).
**Deploy:** `view:cache`/`route:cache` neu, `php artisan cache:clear` für
die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor
müssen `press-releases:hash-content` und `press-releases:assign-canonicals`
gelaufen sein, sonst passiert schlicht nichts.

View file

@ -24,7 +24,7 @@
@if ($routeName === $current)
<span class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-brand bg-brand text-white" aria-current="page">{{ $label }}</span>
@else
<a href="{{ route($routeName) }}" class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">{{ $label }} →</a>
<a href="{{ \App\Support\StaticPageCanonical::publicUrlFor($routeName) }}" class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">{{ $label }} →</a>
@endif
</li>
@endforeach

View file

@ -14,8 +14,13 @@
$pageDescription = html_entity_decode(trim($__env->yieldContent('meta_description')), ENT_QUOTES, 'UTF-8') ?: $siteName;
// Self-Canonical als Default aus der Portal-URL + Request-Pfad (ohne Query):
// pro Portal/Domain getrennt und unabhängig von URL::forceRootUrl – verhindert Cross-Portal-Duplicate.
$portalBase = rtrim($domainUrl ?? config('app.url'), '/');
$canonicalUrl = trim($__env->yieldContent('canonical')) ?: ($portalBase.request()->getPathInfo());
$portalBase = \App\Support\StaticPageCanonical::selfBase($domainUrl ?? null, ($webPortalContextApplied ?? false) === true);
// Statische Seiten liegen auf Hub UND beiden Portalen mit identischem
// Text (Anhang 2, E10): dort führt ein Host, die anderen verweisen
// dorthin. Rechtstexte sind ausgenommen — siehe config/canonical.php.
$canonicalUrl = trim($__env->yieldContent('canonical'))
?: (\App\Support\StaticPageCanonical::urlFor(request()->route()?->getName(), request()->getPathInfo())
?: $portalBase.request()->getPathInfo());
$ogType = trim($__env->yieldContent('og_type')) ?: 'website';
$ogImage = trim($__env->yieldContent('og_image'));
@endphp

View file

@ -244,8 +244,15 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
// (Platzhalter-Adresse/-Team); Alt-Links landen per 301 auf der Startseite.
// Der Kontakt-POST bleibt für das Hub-Kontakt-Modal erhalten.
Route::view('faq', 'web.faq')->name('faq');
Route::get('kontakt', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301));
Route::get('ueber-uns', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301));
// Ziel ist die Startseite des jeweiligen Hosts. Auf dem Hub liegt die
// unter `/` — ein Umweg über `/de` wäre dort eine zweite Weiterleitung.
$homeRedirect = fn (string $edition) => redirect(rtrim(
request()->getSchemeAndHttpHost().(str_contains(request()->getHost(), 'pressekonto') ? '/' : '/'.$edition),
'/'
) ?: request()->getSchemeAndHttpHost().'/', 301);
Route::get('kontakt', $homeRedirect);
Route::get('ueber-uns', $homeRedirect);
Route::post('kontakt', ContactController::class)
->middleware('throttle:10,1')
->name('kontakt.store');

View file

@ -0,0 +1,74 @@
<?php
use App\Enums\Portal;
use App\Models\Category;
use Tests\TestCase;
test('portal-only pages redirect from the hub to the leading portal', function (string $path) {
/** @var TestCase $this */
$this->get('https://pressekonto.test'.$path)
->assertStatus(301)
->assertRedirect('https://presseecho.test'.$path);
})->with(['/de/preise', '/de/veroeffentlichen', '/de/newsrooms', '/de/suche', '/en/preise']);
test('the portals keep serving those pages themselves', function (string $host) {
/** @var TestCase $this */
$this->get($host.'/de/preise')->assertSuccessful();
})->with(['https://presseecho.test', 'https://businessportal24.test']);
test('shared info pages name one leading host on all three domains', function (string $host) {
/** @var TestCase $this */
// Anhang 2, E10: Informationsseiten liegen dreifach vor — presseecho
// führt, die API-Doku gehört zum Hub.
$this->get($host.'/de/faq')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/faq">', false);
$this->get($host.'/de/was-kostet-eine-pressemitteilung')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/de/was-kostet-eine-pressemitteilung">', false)
->assertSee('<meta property="og:url" content="https://presseecho.test/de/was-kostet-eine-pressemitteilung">', false);
$this->get($host.'/de/api')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://pressekonto.test/de/api">', false);
})->with(['https://pressekonto.test', 'https://presseecho.test', 'https://businessportal24.test']);
test('legal pages stay with their own domain', function (string $host, string $path) {
/** @var TestCase $this */
// Jede Domain ist ein eigenes Angebot: Impressum, AGB, Datenschutz,
// Richtlinien und Meldeweg gehören zu dem Host, auf dem sie stehen.
$this->get($host.$path)->assertSuccessful()
->assertSee('<link rel="canonical" href="'.$host.$path.'">', false);
})->with([
['https://pressekonto.test', '/de/impressum'],
['https://businessportal24.test', '/de/agb'],
['https://businessportal24.test', '/de/datenschutz'],
['https://presseecho.test', '/de/melden'],
['https://businessportal24.test', '/de/richtlinien'],
]);
test('the english edition keeps the leading host too', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/en/hilfe')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://presseecho.test/en/hilfe">', false);
});
test('the retired contact and about pages redirect to the home page in one hop', function (string $host, string $target) {
/** @var TestCase $this */
$this->get($host.'/de/kontakt')->assertStatus(301)->assertRedirect($target);
$this->get($host.'/de/ueber-uns')->assertStatus(301)->assertRedirect($target);
})->with([
// Auf dem Hub liegt die Startseite unter `/` — der frühere Weg über
// `/de` war eine zweite Weiterleitung.
['https://pressekonto.test', 'https://pressekonto.test'],
['https://presseecho.test', 'https://presseecho.test/de'],
]);
test('portal content served through the hub keeps the portal as canonical', function () {
/** @var TestCase $this */
// Die Ausgabe-Routen gelten für alle Hosts, deshalb sind Kategorien und
// Meldungen auch unter pressekonto.com erreichbar. Sie rendern dort das
// Portal — der Canonical muss dorthin zeigen, nie auf den Hub.
$category = Category::factory()->create(['portal' => Portal::Both]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
$this->get('https://pressekonto.test/de/category/handel')->assertSuccessful()
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/category/handel">', false)
->assertDontSee('<link rel="canonical" href="https://pressekonto.test/de/category/handel">', false);
});

View file

@ -263,7 +263,9 @@ test('the good-release help page explains the check without selling anything', f
$body = Str::between($response->getContent(), 'class="pm-body', 'aria-labelledby="partner-heading"');
expect($body)->not->toContain('/preise')->not->toContain('Credits');
// Aus dem Hilfe-Center und der Sitemap erreichbar.
// Aus dem Hilfe-Center erreichbar; in der Sitemap steht sie beim
// führenden Host (Anhang 2, E10) — presseecho, nicht businessportal24.
$this->get('https://businessportal24.test/de/hilfe')->assertSee('/de/gute-pressemitteilung', false);
$this->get('https://businessportal24.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false);
$this->get('https://presseecho.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false);
$this->get('https://businessportal24.test/sitemap.xml')->assertDontSee('/de/gute-pressemitteilung', false);
});

View file

@ -123,6 +123,9 @@ test('the pages are linked from the pricing page, the help centre and the sitema
$this->get('https://presseecho.test/llms.txt')->assertSuccessful()
->assertSee('/de/pressemitteilung-kostenlos-veroeffentlichen', false);
// Die Seiten liegen auf allen drei Hosts; in der Sitemap steht nur die
// führende Adresse (Anhang 2, E10) — beim Hub bleibt die API-Doku.
$this->get('https://pressekonto.test/sitemap.xml')->assertSuccessful()
->assertSee('<loc>https://pressekonto.test/de/pressemitteilung-loeschen-oder-aendern-lassen</loc>', false);
->assertDontSee('/de/pressemitteilung-loeschen-oder-aendern-lassen', false)
->assertSee('<loc>https://pressekonto.test/de/api</loc>', false);
});