Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes "https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte "https://wabenhain.com" im Produkt-JSON-LD). Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging, Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host — zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain weist die Indexierung aktiv von der echten Seite weg. Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit siteConfig.domain als Rueckfallebene, damit eine vergessene Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in E-Mail-Code eingebuergerten Namen und liefert denselben Wert. robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte): ohne das rendert Next.js die Datei beim Build statisch vor und backt den Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt alle dem zur Laufzeit gesetzten Wert. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
108 lines
4.0 KiB
TypeScript
108 lines
4.0 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Container } from "@/components/ui";
|
|
import { BackLink } from "@/components/back-link";
|
|
import { wissenSlugs } from "@/lib/site-config";
|
|
import { getSiteUrl } from "@/lib/url";
|
|
import { getCompanyInfo } from "@/lib/company-info";
|
|
import { getLocale } from "@/i18n/locale";
|
|
import { getDictionary } from "@/i18n/get-dictionary";
|
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
|
|
|
function isWissenSlug(slug: string): slug is WissenSlug {
|
|
return (wissenSlugs as readonly string[]).includes(slug);
|
|
}
|
|
|
|
export async function generateMetadata(
|
|
props: PageProps<"/wissen/[slug]">
|
|
): Promise<Metadata> {
|
|
const { slug } = await props.params;
|
|
if (!isWissenSlug(slug)) return {};
|
|
const t = getDictionary(await getLocale());
|
|
const article = t.wissenArticles[slug];
|
|
return {
|
|
title: article.title,
|
|
description: article.excerpt,
|
|
alternates: { canonical: `/wissen/${slug}` },
|
|
};
|
|
}
|
|
|
|
export function generateStaticParams() {
|
|
return wissenSlugs.map((slug) => ({ slug }));
|
|
}
|
|
|
|
export default async function WissenArticlePage(
|
|
props: PageProps<"/wissen/[slug]">
|
|
) {
|
|
const { slug } = await props.params;
|
|
if (!isWissenSlug(slug)) notFound();
|
|
const t = getDictionary(await getLocale());
|
|
const article = t.wissenArticles[slug];
|
|
|
|
const otherArticles = wissenSlugs.filter((s) => s !== slug).slice(0, 2);
|
|
|
|
const company = await getCompanyInfo();
|
|
const base = getSiteUrl();
|
|
// Kein datePublished/image: fuer diese Artikel existiert kein echtes
|
|
// Veroeffentlichungsdatum und keine echte Fotografie — beides zu erfinden
|
|
// waere irrefuehrende strukturierte Daten statt hilfreicher.
|
|
const jsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
headline: article.title,
|
|
description: article.excerpt,
|
|
author: { "@type": "Organization", name: company.name },
|
|
publisher: { "@type": "Organization", name: company.name },
|
|
mainEntityOfPage: `${base}/wissen/${slug}`,
|
|
};
|
|
const breadcrumbJsonLd = {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{ "@type": "ListItem", position: 1, name: t.wissenListPage.eyebrow, item: `${base}/wissen` },
|
|
{ "@type": "ListItem", position: 2, name: article.title, item: `${base}/wissen/${slug}` },
|
|
],
|
|
};
|
|
|
|
return (
|
|
<div className="py-20">
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
|
/>
|
|
<Container className="max-w-2xl">
|
|
<BackLink href="/wissen" label={t.wissenArticlePage.backLabel} />
|
|
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.wissenArticlePage.eyebrow}</p>
|
|
<h1 className="text-3xl text-forest">{article.title}</h1>
|
|
<div className="mt-8 space-y-5 text-ink/75 leading-relaxed">
|
|
{article.body.map((paragraph, i) => (
|
|
<p key={i}>{paragraph}</p>
|
|
))}
|
|
</div>
|
|
|
|
{otherArticles.length > 0 ? (
|
|
<div className="mt-16 border-t border-ink/10 pt-10">
|
|
<p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p>
|
|
<div className="space-y-6">
|
|
{otherArticles.map((otherSlug) => {
|
|
const other = t.wissenArticles[otherSlug];
|
|
return (
|
|
<Link key={otherSlug} href={`/wissen/${otherSlug}`} className="group block">
|
|
<h2 className="text-lg text-forest group-hover:underline">{other.title}</h2>
|
|
<p className="mt-1 text-sm text-ink/65 leading-relaxed">{other.excerpt}</p>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|