From 20c8ad7eafee1a20f7b20b0a930ddfca53af55a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Mon, 1 Dec 2025 19:28:43 +0100 Subject: [PATCH] feat: Add comprehensive SEO implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add robots.txt with admin/API blocking - Add dynamic sitemap.xml with static pages and genre pages - Implement full meta tags (Open Graph, Twitter Cards, Canonical, Alternates) - Add SEO helper functions for domain detection and URL generation - Add generateMetadata to all pages (homepage, about, genre, special) - Support automatic domain detection for hoerdle.de and hördle.de - Add SEO configuration to lib/config.ts --- app/[locale]/[genre]/page.tsx | 28 ++++++ app/[locale]/about/page.tsx | 12 +++ app/[locale]/layout.tsx | 9 +- app/[locale]/page.tsx | 20 ++++- app/[locale]/special/[name]/page.tsx | 26 ++++++ app/robots.ts | 20 +++++ app/sitemap.ts | 128 +++++++++++++++++++++++++++ lib/config.ts | 4 + lib/metadata.ts | 64 ++++++++++++++ lib/seo.ts | 43 +++++++++ 10 files changed, 349 insertions(+), 5 deletions(-) create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 lib/metadata.ts create mode 100644 lib/seo.ts diff --git a/app/[locale]/[genre]/page.tsx b/app/[locale]/[genre]/page.tsx index 619ce82..f105ea8 100644 --- a/app/[locale]/[genre]/page.tsx +++ b/app/[locale]/[genre]/page.tsx @@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client'; import { notFound } from 'next/navigation'; import { getLocalizedValue } from '@/lib/i18n'; import { getTranslations } from 'next-intl/server'; +import { generateBaseMetadata } from '@/lib/metadata'; +import type { Metadata } from 'next'; export const dynamic = 'force-dynamic'; @@ -15,6 +17,32 @@ interface PageProps { params: Promise<{ locale: string; genre: string }>; } +export async function generateMetadata({ params }: PageProps): Promise { + const { locale, genre } = await params; + const decodedGenre = decodeURIComponent(genre); + + // Fetch genre to get localized name + const allGenres = await prisma.genre.findMany(); + const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre); + + if (!currentGenre || !currentGenre.active) { + return await generateBaseMetadata(locale, genre); + } + + const genreName = getLocalizedValue(currentGenre.name, locale); + const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale); + + const title = locale === 'de' + ? `${genreName} - Hördle` + : `${genreName} - Hördle`; + + const description = genreSubtitle || (locale === 'de' + ? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!` + : `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`); + + return await generateBaseMetadata(locale, genre, title, description); +} + export default async function GenrePage({ params }: PageProps) { const { locale, genre } = await params; const decodedGenre = decodeURIComponent(genre); diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx index 0e4e660..4192c5a 100644 --- a/app/[locale]/about/page.tsx +++ b/app/[locale]/about/page.tsx @@ -1,10 +1,22 @@ import { getTranslations } from "next-intl/server"; import { Link } from "@/lib/navigation"; +import { generateBaseMetadata } from "@/lib/metadata"; +import type { Metadata } from "next"; interface AboutPageProps { params: Promise<{ locale: string }>; } +export async function generateMetadata({ params }: AboutPageProps): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "About" }); + + const title = t("title"); + const description = t("intro"); + + return await generateBaseMetadata(locale, "about", title, description); +} + export default async function AboutPage({ params }: AboutPageProps) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "About" }); diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 88d659c..c503773 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -8,6 +8,7 @@ import { notFound } from 'next/navigation'; import { headers } from 'next/headers'; import { config } from "@/lib/config"; +import { generateBaseMetadata } from "@/lib/metadata"; import InstallPrompt from "@/components/InstallPrompt"; import AppFooter from "@/components/AppFooter"; @@ -21,10 +22,10 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - title: config.appName, - description: config.appDescription, -}; +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + return await generateBaseMetadata(locale); +} export const viewport: Viewport = { themeColor: config.colors.themeColor, diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b0d671d..e50aefa 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -7,15 +7,33 @@ import { Link } from '@/lib/navigation'; import { PrismaClient } from '@prisma/client'; import { getTranslations } from 'next-intl/server'; import { getLocalizedValue } from '@/lib/i18n'; +import { generateBaseMetadata } from '@/lib/metadata'; +import type { Metadata } from 'next'; export const dynamic = 'force-dynamic'; const prisma = new PrismaClient(); +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params; + const t = await getTranslations('Home'); + + // Get localized title and description + const title = locale === 'de' + ? 'Hördle - Tägliches Musik-Erraten' + : 'Hördle - Daily Music Guessing Game'; + + const description = locale === 'de' + ? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.' + : 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.'; + + return await generateBaseMetadata(locale, '', title, description); +} + export default async function Home({ params }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { const { locale } = await params; const t = await getTranslations('Home'); diff --git a/app/[locale]/special/[name]/page.tsx b/app/[locale]/special/[name]/page.tsx index d6884f3..f722748 100644 --- a/app/[locale]/special/[name]/page.tsx +++ b/app/[locale]/special/[name]/page.tsx @@ -5,6 +5,8 @@ import { Link } from '@/lib/navigation'; import { PrismaClient } from '@prisma/client'; import { getLocalizedValue } from '@/lib/i18n'; import { getTranslations } from 'next-intl/server'; +import { generateBaseMetadata } from '@/lib/metadata'; +import type { Metadata } from 'next'; export const dynamic = 'force-dynamic'; @@ -14,6 +16,30 @@ interface PageProps { params: Promise<{ locale: string; name: string }>; } +export async function generateMetadata({ params }: PageProps): Promise { + const { locale, name } = await params; + const decodedName = decodeURIComponent(name); + + // Fetch special to get localized name + const allSpecials = await prisma.special.findMany(); + const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName); + + if (!currentSpecial) { + return await generateBaseMetadata(locale, `special/${name}`); + } + + const specialName = getLocalizedValue(currentSpecial.name, locale); + const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale); + + const title = `★ ${specialName} - Hördle`; + + const description = specialSubtitle || (locale === 'de' + ? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!` + : `Play the Hördle special "${specialName}" and guess songs from short audio clips!`); + + return await generateBaseMetadata(locale, `special/${name}`, title, description); +} + export default async function SpecialPage({ params }: PageProps) { const { locale, name } = await params; const decodedName = decodeURIComponent(name); diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..27cc942 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,20 @@ +import { MetadataRoute } from 'next'; +import { config } from '@/lib/config'; + +export default function robots(): MetadataRoute.Robots { + const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const siteUrl = `${protocol}://${baseUrl}`; + + return { + rules: [ + { + userAgent: '*', + allow: '/', + disallow: ['/admin/', '/api/'], + }, + ], + sitemap: `${siteUrl}/sitemap.xml`, + }; +} + diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..5ae3825 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,128 @@ +import { MetadataRoute } from 'next'; +import { PrismaClient } from '@prisma/client'; +import { getLocalizedValue } from '@/lib/i18n'; +import { config } from '@/lib/config'; + +const prisma = new PrismaClient(); + +export default async function sitemap(): Promise { + const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const siteUrl = `${protocol}://${baseUrl}`; + + const now = new Date().toISOString(); + + // Static pages + const staticPages: MetadataRoute.Sitemap = [ + { + url: `${siteUrl}/en`, + lastModified: now, + changeFrequency: 'daily', + priority: 1.0, + alternates: { + languages: { + 'de': `${siteUrl}/de`, + 'en': `${siteUrl}/en`, + 'x-default': `${siteUrl}/en`, + }, + }, + }, + { + url: `${siteUrl}/de`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.8, + alternates: { + languages: { + 'de': `${siteUrl}/de`, + 'en': `${siteUrl}/en`, + 'x-default': `${siteUrl}/en`, + }, + }, + }, + { + url: `${siteUrl}/en/about`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.7, + alternates: { + languages: { + 'de': `${siteUrl}/de/about`, + 'en': `${siteUrl}/en/about`, + 'x-default': `${siteUrl}/en/about`, + }, + }, + }, + { + url: `${siteUrl}/de/about`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.7, + alternates: { + languages: { + 'de': `${siteUrl}/de/about`, + 'en': `${siteUrl}/en/about`, + 'x-default': `${siteUrl}/en/about`, + }, + }, + }, + ]; + + // Dynamic genre pages + try { + const genres = await prisma.genre.findMany({ + where: { active: true }, + }); + + const genrePages: MetadataRoute.Sitemap = []; + + for (const genre of genres) { + const genreNameEn = getLocalizedValue(genre.name, 'en'); + const genreNameDe = getLocalizedValue(genre.name, 'de'); + + // Only add if genre name is valid + if (genreNameEn && genreNameDe) { + const encodedEn = encodeURIComponent(genreNameEn); + const encodedDe = encodeURIComponent(genreNameDe); + + genrePages.push( + { + url: `${siteUrl}/en/${encodedEn}`, + lastModified: now, + changeFrequency: 'daily', + priority: 0.9, + alternates: { + languages: { + 'de': `${siteUrl}/de/${encodedDe}`, + 'en': `${siteUrl}/en/${encodedEn}`, + 'x-default': `${siteUrl}/en/${encodedEn}`, + }, + }, + }, + { + url: `${siteUrl}/de/${encodedDe}`, + lastModified: now, + changeFrequency: 'daily', + priority: 0.9, + alternates: { + languages: { + 'de': `${siteUrl}/de/${encodedDe}`, + 'en': `${siteUrl}/en/${encodedEn}`, + 'x-default': `${siteUrl}/en/${encodedEn}`, + }, + }, + } + ); + } + } + + return [...staticPages, ...genrePages]; + } catch (error) { + console.error('Error generating sitemap:', error); + // Return static pages only if database query fails + return staticPages; + } finally { + await prisma.$disconnect(); + } +} + diff --git a/lib/config.ts b/lib/config.ts index ca3d593..bf9f204 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -12,5 +12,9 @@ export const config = { text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by', linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social', linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron', + }, + seo: { + ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/favicon.ico', + twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined, } }; diff --git a/lib/metadata.ts b/lib/metadata.ts new file mode 100644 index 0000000..9bb968d --- /dev/null +++ b/lib/metadata.ts @@ -0,0 +1,64 @@ +import type { Metadata } from 'next'; +import { config } from './config'; +import { getBaseUrl } from './seo'; + +/** + * Generate base metadata with Open Graph, Twitter Cards, and canonical URLs + */ +export async function generateBaseMetadata( + locale: string, + path: string = '', + title?: string, + description?: string, + image?: string +): Promise { + const baseUrl = await getBaseUrl(); + const pathSegment = path ? `/${path}` : ''; + const fullUrl = `${baseUrl}/${locale}${pathSegment}`; + + // Determine alternate URLs for both locales (same path for both) + const alternateLocale = locale === 'de' ? 'en' : 'de'; + const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`; + + // Default values + const metaTitle = title || config.appName; + const metaDescription = description || config.appDescription; + const ogImage = image || `${baseUrl}${config.seo.ogImage}`; + + return { + title: metaTitle, + description: metaDescription, + alternates: { + canonical: fullUrl, + languages: { + [locale]: fullUrl, + [alternateLocale]: alternateUrl, + 'x-default': `${baseUrl}/en${pathSegment}`, + }, + }, + openGraph: { + title: metaTitle, + description: metaDescription, + url: fullUrl, + siteName: config.appName, + images: [ + { + url: ogImage, + width: 1200, + height: 630, + alt: metaTitle, + }, + ], + locale: locale, + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: metaTitle, + description: metaDescription, + images: [ogImage], + ...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }), + }, + }; +} + diff --git a/lib/seo.ts b/lib/seo.ts new file mode 100644 index 0000000..7569b9e --- /dev/null +++ b/lib/seo.ts @@ -0,0 +1,43 @@ +import { headers } from 'next/headers'; +import { config } from './config'; + +/** + * Get the current base URL from request headers + * Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de) + */ +export async function getBaseUrl(): Promise { + const headersList = await headers(); + const host = headersList.get('host') || headersList.get('x-forwarded-host') || ''; + + let domain = config.domain; // Default fallback + + if (host) { + // Extract domain from host (remove port if present) + const detectedDomain = host.split(':')[0].toLowerCase(); + + // Map domains + if (detectedDomain === 'hoerdle.de') { + domain = 'hoerdle.de'; + } else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') { + domain = 'hördle.de'; + } else { + // Use detected domain if it's different from default + domain = detectedDomain; + } + } + + // Always use HTTPS in production + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + return `${protocol}://${domain}`; +} + +/** + * Get base URL synchronously (for use in non-async contexts) + * Uses environment variable or config as fallback + */ +export function getBaseUrlSync(): string { + const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain; + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + return `${protocol}://${domain}`; +} +