Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/duolingo-streak.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"streak": 1538,
"since": "2026-06-14",
"streak": 1566,
"since": "2026-07-12",
"username": "marv51"
}
1 change: 1 addition & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down
7 changes: 5 additions & 2 deletions src/app/routes/home-de.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
7 changes: 5 additions & 2 deletions src/app/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
4 changes: 3 additions & 1 deletion src/app/routes/imprint-de.tsx
Original file line number Diff line number Diff line change
@@ -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" }
];
}

Expand Down
4 changes: 3 additions & 1 deletion src/app/routes/imprint.tsx
Original file line number Diff line number Diff line change
@@ -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" }
];
}

Expand Down
1 change: 0 additions & 1 deletion src/app/routes/layout-de.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Footer } from "../footer/footer";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Marvin Rühe's Webseite" },
{ lang: "en" }
];
}

Expand Down
1 change: 0 additions & 1 deletion src/app/routes/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { Footer } from "../footer/footer";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Marvin Rühe's Webseite" },
{ lang: "en" }
];
}

Expand Down
9 changes: 9 additions & 0 deletions src/app/routes/sitemap.tsx
Original file line number Diff line number Diff line change
@@ -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" },
});
}
52 changes: 52 additions & 0 deletions src/app/seo-links.ts
Original file line number Diff line number Diff line change
@@ -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<string, PagePair>;

/** Canonical + hreflang alternate <link>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) => ` <url>
<loc>${SITE_URL}${loc}</loc>
<xhtml:link rel="alternate" hreflang="en" href="${SITE_URL}${en}" />
<xhtml:link rel="alternate" hreflang="de" href="${SITE_URL}${de}" />
<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_URL}${en}" />
</url>`,
),
)
.join("\n");

return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls}
</urlset>
`;
}

/** 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]);
}
4 changes: 4 additions & 0 deletions src/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://ruehe.me/sitemap.xml
31 changes: 28 additions & 3 deletions src/react-router.config.ts
Original file line number Diff line number Diff line change
@@ -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;