diff --git a/src/app/duolingo-streak.json b/src/app/duolingo-streak.json index 32d6838..c489348 100644 --- a/src/app/duolingo-streak.json +++ b/src/app/duolingo-streak.json @@ -1,5 +1,5 @@ { - "streak": 1538, - "since": "2026-06-14", + "streak": 1566, + "since": "2026-07-12", "username": "marv51" } diff --git a/src/app/routes.ts b/src/app/routes.ts index 56f4a1d..785d422 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -5,6 +5,7 @@ export default [ route("/Project-Quick-Open/datenschutz.html", "routes/project-quick-privacy.tsx"), route("/SWR3App/datenschutz.html", "routes/swr3-app-privacy.tsx"), route("404", "routes/not-found.tsx", { "index": true}), + route("sitemap.xml", "routes/sitemap.tsx"), layout("routes/layout.tsx", [ index("routes/home.tsx"), ...prefix("en", [ diff --git a/src/app/routes/home-de.tsx b/src/app/routes/home-de.tsx index daa5497..266a77e 100644 --- a/src/app/routes/home-de.tsx +++ b/src/app/routes/home-de.tsx @@ -1,12 +1,15 @@ import type { Route } from "./+types/home-de"; import { Welcome } from "../welcome/welcome"; +import { bilingualLinks, PAGES, SITE_URL } from "../seo-links"; + +export const links: Route.LinksFunction = () => bilingualLinks(PAGES.home, "de"); export function meta({}: Route.MetaArgs) { const title = "Marvin Rühe — Teamleitung Softwareentwicklung"; const description = "Marvin Rühe — Teamleitung Softwareentwicklung bei gb&t. Ich entwickle Windows-Apps (WinUI 3, WPF, MSIX), lokale KI und Web-Tools und leite die Entwicklung der preisgekrönten Software telani."; - const url = "https://ruehe.me/de"; - const image = "https://ruehe.me/og-de.jpg"; + const url = `${SITE_URL}${PAGES.home.de}`; + const image = `${SITE_URL}/og-de.jpg`; return [ { title }, { name: "description", content: description }, diff --git a/src/app/routes/home.tsx b/src/app/routes/home.tsx index 3e6691c..32caa56 100644 --- a/src/app/routes/home.tsx +++ b/src/app/routes/home.tsx @@ -1,12 +1,15 @@ import type { Route } from "./+types/home"; import { Welcome } from "../welcome/welcome"; +import { bilingualLinks, PAGES, SITE_URL } from "../seo-links"; + +export const links: Route.LinksFunction = () => bilingualLinks(PAGES.home, "en"); export function meta({}: Route.MetaArgs) { const title = "Marvin Rühe — Team Lead, Software Development"; const description = "Marvin Rühe — Team Lead, Software Development at gb&t. I build Windows apps (WinUI 3, WPF, MSIX), on-device AI, and web tools, and lead development of the award-winning telani software."; - const url = "https://ruehe.me/"; - const image = "https://ruehe.me/og.jpg"; + const url = `${SITE_URL}${PAGES.home.en}`; + const image = `${SITE_URL}/og.jpg`; return [ { title }, { name: "description", content: description }, diff --git a/src/app/routes/imprint-de.tsx b/src/app/routes/imprint-de.tsx index 7c36a00..ba57442 100644 --- a/src/app/routes/imprint-de.tsx +++ b/src/app/routes/imprint-de.tsx @@ -1,10 +1,12 @@ import type { Route } from "./+types/imprint-de"; import { Imprint } from "../imprint/imprint"; +import { bilingualLinks, PAGES } from "../seo-links"; + +export const links: Route.LinksFunction = () => bilingualLinks(PAGES.imprint, "de"); export function meta({}: Route.MetaArgs) { return [ { title: "Marvin Rühes Webseite - Impressum und Datenschutz" }, - { lang: "de" } ]; } diff --git a/src/app/routes/imprint.tsx b/src/app/routes/imprint.tsx index 30237d4..0c3efc5 100644 --- a/src/app/routes/imprint.tsx +++ b/src/app/routes/imprint.tsx @@ -1,10 +1,12 @@ import type { Route } from "./+types/imprint"; import { Imprint } from "../imprint/imprint"; +import { bilingualLinks, PAGES } from "../seo-links"; + +export const links: Route.LinksFunction = () => bilingualLinks(PAGES.imprint, "en"); export function meta({}: Route.MetaArgs) { return [ { title: "Marvin Rühe's Webseite - Imprint & Privacy" }, - { lang: "en" } ]; } diff --git a/src/app/routes/layout-de.tsx b/src/app/routes/layout-de.tsx index b9cb677..fd15d7e 100644 --- a/src/app/routes/layout-de.tsx +++ b/src/app/routes/layout-de.tsx @@ -6,7 +6,6 @@ import { Footer } from "../footer/footer"; export function meta({}: Route.MetaArgs) { return [ { title: "Marvin Rühe's Webseite" }, - { lang: "en" } ]; } diff --git a/src/app/routes/layout.tsx b/src/app/routes/layout.tsx index 7045409..152a5ce 100644 --- a/src/app/routes/layout.tsx +++ b/src/app/routes/layout.tsx @@ -6,7 +6,6 @@ import { Footer } from "../footer/footer"; export function meta({}: Route.MetaArgs) { return [ { title: "Marvin Rühe's Webseite" }, - { lang: "en" } ]; } diff --git a/src/app/routes/sitemap.tsx b/src/app/routes/sitemap.tsx new file mode 100644 index 0000000..0508587 --- /dev/null +++ b/src/app/routes/sitemap.tsx @@ -0,0 +1,9 @@ +import { renderSitemap } from "../seo-links"; + +// Resource route (loader only, no component): prerendered to +// build/client/sitemap.xml, and also served live under `npm run dev`. +export function loader() { + return new Response(renderSitemap(), { + headers: { "Content-Type": "application/xml" }, + }); +} diff --git a/src/app/seo-links.ts b/src/app/seo-links.ts new file mode 100644 index 0000000..063ed6a --- /dev/null +++ b/src/app/seo-links.ts @@ -0,0 +1,52 @@ +export const SITE_URL = "https://ruehe.me"; + +export type Lang = "en" | "de"; +export type PagePair = { en: string; de: string }; + +/** + * EN ↔ DE equivalents for every indexable page. Single source of truth: per-page + * canonical/hreflang links (`bilingualLinks`) and the generated sitemap + * (`renderSitemap`) both read from this, so a new page added here flows to both. + */ +export const PAGES = { + home: { en: "/", de: "/de" }, + imprint: { en: "/en/imprint", de: "/de/impressum" }, +} as const satisfies Record; + +/** Canonical + hreflang alternate s for one page in one language. */ +export function bilingualLinks({ en, de }: PagePair, current: Lang) { + return [ + { rel: "canonical", href: `${SITE_URL}${current === "en" ? en : de}` }, + { rel: "alternate", hrefLang: "en", href: `${SITE_URL}${en}` }, + { rel: "alternate", hrefLang: "de", href: `${SITE_URL}${de}` }, + { rel: "alternate", hrefLang: "x-default", href: `${SITE_URL}${en}` }, + ]; +} + +/** The sitemap.xml document, derived from PAGES. */ +export function renderSitemap(): string { + const urls = Object.values(PAGES) + .flatMap(({ en, de }) => + [en, de].map( + (loc) => ` + ${SITE_URL}${loc} + + + + `, + ), + ) + .join("\n"); + + return ` + +${urls} + +`; +} + +/** Every URL listed in the sitemap (both languages), for build-time drift checks. */ +export function sitemapPaths(): string[] { + return Object.values(PAGES).flatMap(({ en, de }) => [en, de]); +} diff --git a/src/public/robots.txt b/src/public/robots.txt new file mode 100644 index 0000000..b6034e7 --- /dev/null +++ b/src/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://ruehe.me/sitemap.xml diff --git a/src/react-router.config.ts b/src/react-router.config.ts index e9758ea..f32a97a 100644 --- a/src/react-router.config.ts +++ b/src/react-router.config.ts @@ -1,8 +1,33 @@ import type { Config } from "@react-router/dev/config"; +import { sitemapPaths } from "./app/seo-links"; + +// Prerendered paths intentionally kept OUT of the sitemap: the error page, the +// sitemap resource route itself, and the two standalone app privacy policies +// (not part of the bilingual site). +const SITEMAP_EXCLUDED = new Set([ + "/404", + "/sitemap.xml", + "/Project-Quick-Open/datenschutz.html", + "/SWR3App/datenschutz.html", +]); export default { - // Config options... - // Server-side render by default, to enable SPA mode set this to `false` ssr: false, - prerender: true, + // Function form of `prerender` returns the same static paths as `true`, but + // also lets us cross-check route discovery against the sitemap. A prerendered + // page missing from both PAGES and the exclude list fails the build, so the + // sitemap can't silently fall out of sync with the routes. + prerender: ({ getStaticPaths }) => { + const discovered = getStaticPaths(); + const known = new Set([...sitemapPaths(), ...SITEMAP_EXCLUDED]); + const unaccounted = discovered.filter((path) => !known.has(path)); + if (unaccounted.length > 0) { + throw new Error( + `[sitemap] Prerendered path(s) missing from the sitemap and exclude ` + + `list: ${unaccounted.join(", ")}. Add them to PAGES in ` + + `app/seo-links.ts, or to SITEMAP_EXCLUDED in react-router.config.ts.`, + ); + } + return discovered; + }, } satisfies Config;