Skip to content

Commit abe024c

Browse files
Marv51claude
andcommitted
Generate sitemap.xml at build time instead of hand-maintaining it
Derive the sitemap from the route config rather than a hand-written file: a `buildEnd` hook writes build/client/sitemap.xml from a single PAGES map in seo-links.ts, and the `prerender` hook cross-checks getStaticPaths() so a prerendered page that's missing from both the sitemap and the exclude list warns at build time. Centralize SITE_URL and the EN<->DE page pairing in seo-links.ts so canonical, hreflang, and the sitemap can't drift apart; the route link functions now take a PAGES entry. Remove the now-redundant public/sitemap.xml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 579ebcf commit abe024c

7 files changed

Lines changed: 89 additions & 48 deletions

File tree

src/app/routes/home-de.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Route } from "./+types/home-de";
22
import { Welcome } from "../welcome/welcome";
3-
import { bilingualLinks } from "../seo-links";
3+
import { bilingualLinks, PAGES } from "../seo-links";
44

5-
export const links: Route.LinksFunction = () => bilingualLinks("/", "/de", "de");
5+
export const links: Route.LinksFunction = () => bilingualLinks(PAGES.home, "de");
66

77
export function meta({}: Route.MetaArgs) {
88
const title = "Marvin Rühe — Teamleitung Softwareentwicklung";

src/app/routes/home.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Route } from "./+types/home";
22
import { Welcome } from "../welcome/welcome";
3-
import { bilingualLinks } from "../seo-links";
3+
import { bilingualLinks, PAGES } from "../seo-links";
44

5-
export const links: Route.LinksFunction = () => bilingualLinks("/", "/de", "en");
5+
export const links: Route.LinksFunction = () => bilingualLinks(PAGES.home, "en");
66

77
export function meta({}: Route.MetaArgs) {
88
const title = "Marvin Rühe — Team Lead, Software Development";

src/app/routes/imprint-de.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { Route } from "./+types/imprint-de";
22
import { Imprint } from "../imprint/imprint";
3-
import { bilingualLinks } from "../seo-links";
3+
import { bilingualLinks, PAGES } from "../seo-links";
44

5-
export const links: Route.LinksFunction = () =>
6-
bilingualLinks("/en/imprint", "/de/impressum", "de");
5+
export const links: Route.LinksFunction = () => bilingualLinks(PAGES.imprint, "de");
76

87
export function meta({}: Route.MetaArgs) {
98
return [

src/app/routes/imprint.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import type { Route } from "./+types/imprint";
22
import { Imprint } from "../imprint/imprint";
3-
import { bilingualLinks } from "../seo-links";
3+
import { bilingualLinks, PAGES } from "../seo-links";
44

5-
export const links: Route.LinksFunction = () =>
6-
bilingualLinks("/en/imprint", "/de/impressum", "en");
5+
export const links: Route.LinksFunction = () => bilingualLinks(PAGES.imprint, "en");
76

87
export function meta({}: Route.MetaArgs) {
98
return [

src/app/seo-links.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,52 @@
1-
const BASE_URL = "https://ruehe.me";
1+
export const SITE_URL = "https://ruehe.me";
22

3-
export function bilingualLinks(enPath: string, dePath: string, current: "en" | "de") {
4-
const canonicalPath = current === "en" ? enPath : dePath;
3+
export type Lang = "en" | "de";
4+
export type PagePair = { en: string; de: string };
5+
6+
/**
7+
* EN ↔ DE equivalents for every indexable page. Single source of truth: per-page
8+
* canonical/hreflang links (`bilingualLinks`) and the generated sitemap
9+
* (`renderSitemap`) both read from this, so a new page added here flows to both.
10+
*/
11+
export const PAGES = {
12+
home: { en: "/", de: "/de" },
13+
imprint: { en: "/en/imprint", de: "/de/impressum" },
14+
} as const satisfies Record<string, PagePair>;
15+
16+
/** Canonical + hreflang alternate <link>s for one page in one language. */
17+
export function bilingualLinks({ en, de }: PagePair, current: Lang) {
518
return [
6-
{ rel: "canonical", href: `${BASE_URL}${canonicalPath}` },
7-
{ rel: "alternate", hrefLang: "en", href: `${BASE_URL}${enPath}` },
8-
{ rel: "alternate", hrefLang: "de", href: `${BASE_URL}${dePath}` },
9-
{ rel: "alternate", hrefLang: "x-default", href: `${BASE_URL}${enPath}` },
19+
{ rel: "canonical", href: `${SITE_URL}${current === "en" ? en : de}` },
20+
{ rel: "alternate", hrefLang: "en", href: `${SITE_URL}${en}` },
21+
{ rel: "alternate", hrefLang: "de", href: `${SITE_URL}${de}` },
22+
{ rel: "alternate", hrefLang: "x-default", href: `${SITE_URL}${en}` },
1023
];
1124
}
25+
26+
/** The sitemap.xml document, derived from PAGES. */
27+
export function renderSitemap(): string {
28+
const urls = Object.values(PAGES)
29+
.flatMap(({ en, de }) =>
30+
[en, de].map(
31+
(loc) => ` <url>
32+
<loc>${SITE_URL}${loc}</loc>
33+
<xhtml:link rel="alternate" hreflang="en" href="${SITE_URL}${en}" />
34+
<xhtml:link rel="alternate" hreflang="de" href="${SITE_URL}${de}" />
35+
<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_URL}${en}" />
36+
</url>`,
37+
),
38+
)
39+
.join("\n");
40+
41+
return `<?xml version="1.0" encoding="UTF-8"?>
42+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
43+
xmlns:xhtml="http://www.w3.org/1999/xhtml">
44+
${urls}
45+
</urlset>
46+
`;
47+
}
48+
49+
/** Every URL listed in the sitemap (both languages), for build-time drift checks. */
50+
export function sitemapPaths(): string[] {
51+
return Object.values(PAGES).flatMap(({ en, de }) => [en, de]);
52+
}

src/public/sitemap.xml

Lines changed: 0 additions & 28 deletions
This file was deleted.

src/react-router.config.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
11
import type { Config } from "@react-router/dev/config";
2+
import { writeFile } from "node:fs/promises";
3+
import { join } from "node:path";
4+
import { renderSitemap, sitemapPaths } from "./app/seo-links";
5+
6+
// Prerendered paths intentionally kept OUT of the sitemap: the error page and
7+
// the two standalone app privacy policies (not part of the bilingual site).
8+
const SITEMAP_EXCLUDED = new Set([
9+
"/404",
10+
"/Project-Quick-Open/datenschutz.html",
11+
"/SWR3App/datenschutz.html",
12+
]);
213

314
export default {
4-
// Config options...
5-
// Server-side render by default, to enable SPA mode set this to `false`
615
ssr: false,
7-
prerender: true,
16+
// Function form of `prerender` returns the same static paths as `true`, but
17+
// also lets us cross-check route discovery against the sitemap so a new page
18+
// can't silently go missing from it.
19+
prerender: ({ getStaticPaths }) => {
20+
const discovered = getStaticPaths();
21+
const known = new Set([...sitemapPaths(), ...SITEMAP_EXCLUDED]);
22+
const unaccounted = discovered.filter((path) => !known.has(path));
23+
if (unaccounted.length > 0) {
24+
console.warn(
25+
`[sitemap] Prerendered path(s) missing from the sitemap and exclude ` +
26+
`list: ${unaccounted.join(", ")}. Add them to PAGES in ` +
27+
`app/seo-links.ts, or to SITEMAP_EXCLUDED in react-router.config.ts.`,
28+
);
29+
}
30+
return discovered;
31+
},
32+
// Emit sitemap.xml into the prerendered output, derived from PAGES.
33+
buildEnd: async ({ reactRouterConfig }) => {
34+
const out = join(reactRouterConfig.buildDirectory, "client", "sitemap.xml");
35+
await writeFile(out, renderSitemap(), "utf-8");
36+
console.log(`[sitemap] wrote ${out}`);
37+
},
838
} satisfies Config;

0 commit comments

Comments
 (0)