diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index fb2b73e0..5d59100c 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -439,6 +439,11 @@ jobs:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: pnpm run check:http
+ - name: š Locale & Language Switcher Validation
+ env:
+ NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
+ GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
+ run: pnpm run check:locale
# āā Quality Gates (informational, don't block pipeline) āāāāāāāāāāāāāāā
- name: š HTML DOM Validation
diff --git a/package.json b/package.json
index 7965ba04..6e9eaaab 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"check:wcag": "tsx ./scripts/wcag-sitemap.ts",
"check:html": "tsx ./scripts/check-html.ts",
"check:http": "tsx ./scripts/check-http.ts",
+ "check:locale": "tsx ./scripts/check-locale.ts",
"check:spell": "cspell \"content/**/*.{md,mdx}\" \"app/**/*.tsx\" \"components/**/*.tsx\"",
"check:security": "tsx ./scripts/check-security.ts",
"check:links": "bash ./scripts/check-links.sh",
diff --git a/scripts/check-locale.ts b/scripts/check-locale.ts
new file mode 100644
index 00000000..26eeab7a
--- /dev/null
+++ b/scripts/check-locale.ts
@@ -0,0 +1,191 @@
+import axios from 'axios';
+import * as cheerio from 'cheerio';
+
+/**
+ * Locale & Language Switcher Smoke Test
+ *
+ * For every URL in the sitemap:
+ * 1. Fetches the page HTML
+ * 2. Extracts tags
+ * 3. Verifies each alternate URL uses correctly translated slugs
+ * 4. Verifies each alternate URL returns HTTP 200
+ */
+
+const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
+const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
+
+// Expected slug translations: German key ā English value
+const SLUG_MAP: Record = {
+ produkte: 'products',
+ kontakt: 'contact',
+ niederspannungskabel: 'low-voltage-cables',
+ mittelspannungskabel: 'medium-voltage-cables',
+ hochspannungskabel: 'high-voltage-cables',
+ solarkabel: 'solar-cables',
+ impressum: 'legal-notice',
+ datenschutz: 'privacy-policy',
+ agbs: 'terms',
+};
+
+// Reverse map: English ā German
+const REVERSE_SLUG_MAP: Record = Object.fromEntries(
+ Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
+);
+
+const headers = { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` };
+
+function getExpectedTranslation(
+ sourcePath: string,
+ sourceLocale: string,
+ targetLocale: string,
+): string {
+ const segments = sourcePath.split('/').filter(Boolean);
+ // First segment is locale
+ segments[0] = targetLocale;
+
+ const map = sourceLocale === 'de' ? SLUG_MAP : REVERSE_SLUG_MAP;
+
+ return (
+ '/' +
+ segments
+ .map((seg, i) => {
+ if (i === 0) return seg; // locale
+ return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
+ })
+ .join('/')
+ );
+}
+
+async function main() {
+ console.log(`\nš Starting Locale Smoke Test for: ${targetUrl}\n`);
+
+ // 1. Fetch sitemap
+ const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
+ console.log(`š„ Fetching sitemap from ${sitemapUrl}...`);
+ const sitemapRes = await axios.get(sitemapUrl, { headers, validateStatus: (s) => s < 400 });
+ const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
+
+ let urls = $sitemap('url loc')
+ .map((_i, el) => $sitemap(el).text())
+ .get();
+
+ const urlPattern = /https?:\/\/[^/]+/;
+ urls = [...new Set(urls)]
+ .filter((u) => u.startsWith('http'))
+ .map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
+ .sort();
+
+ console.log(`ā
Found ${urls.length} URLs in sitemap.\n`);
+
+ let totalChecked = 0;
+ let totalPassed = 0;
+ let totalFailed = 0;
+ const failures: string[] = [];
+
+ for (const url of urls) {
+ const path = new URL(url).pathname;
+ const locale = path.split('/')[1];
+ if (!locale || !['de', 'en'].includes(locale)) continue;
+
+ try {
+ const res = await axios.get(url, { headers, validateStatus: null });
+ if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
+
+ const $ = cheerio.load(res.data);
+
+ // Extract hreflang alternate links
+ const alternates: { hreflang: string; href: string }[] = [];
+ $('link[rel="alternate"][hreflang]').each((_i, el) => {
+ const hreflang = $(el).attr('hreflang') || '';
+ let href = $(el).attr('href') || '';
+ if (href && hreflang && hreflang !== 'x-default') {
+ href = href.replace(urlPattern, targetUrl.replace(/\/$/, ''));
+ alternates.push({ hreflang, href });
+ }
+ });
+
+ if (alternates.length === 0) {
+ // Some pages may not have alternates, that's OK
+ continue;
+ }
+
+ totalChecked++;
+
+ // Validate each alternate
+ let pageOk = true;
+
+ for (const alt of alternates) {
+ if (alt.hreflang === locale) continue; // Same locale, skip
+
+ // 1. Check slug translation is correct
+ const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
+ const actualPath = new URL(alt.href).pathname;
+
+ if (actualPath !== expectedPath) {
+ console.error(
+ `ā SLUG MISMATCH: ${path} ā hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
+ );
+ failures.push(
+ `Slug mismatch: ${path} ā ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
+ );
+ pageOk = false;
+ continue;
+ }
+
+ // 2. Check alternate URL returns 200
+ try {
+ const altRes = await axios.get(alt.href, {
+ headers,
+ validateStatus: null,
+ maxRedirects: 5,
+ });
+ if (altRes.status >= 400) {
+ console.error(`ā BROKEN ALTERNATE: ${path} ā ${alt.href} returned ${altRes.status}`);
+ failures.push(`Broken alternate: ${path} ā ${alt.href} (${altRes.status})`);
+ pageOk = false;
+ }
+ } catch (err: any) {
+ console.error(`ā NETWORK ERROR: ${path} ā ${alt.href}: ${err.message}`);
+ failures.push(`Network error: ${path} ā ${alt.href}: ${err.message}`);
+ pageOk = false;
+ }
+ }
+
+ if (pageOk) {
+ console.log(
+ `ā
${path} ā alternates OK (${alternates
+ .map((a) => a.hreflang)
+ .filter((h) => h !== locale)
+ .join(', ')})`,
+ );
+ totalPassed++;
+ } else {
+ totalFailed++;
+ }
+ } catch (err: any) {
+ console.error(`ā NETWORK ERROR fetching ${url}: ${err.message}`);
+ totalFailed++;
+ }
+ }
+
+ console.log(`\n${'ā'.repeat(60)}`);
+ console.log(`š Locale Smoke Test Results:`);
+ console.log(` Pages checked: ${totalChecked}`);
+ console.log(` Passed: ${totalPassed}`);
+ console.log(` Failed: ${totalFailed}`);
+
+ if (failures.length > 0) {
+ console.log(`\nā Failures:`);
+ failures.forEach((f) => console.log(` ⢠${f}`));
+ console.log(`\nā Locale Smoke Test FAILED.`);
+ process.exit(1);
+ } else {
+ console.log(`\n⨠All locale alternates are correctly translated and reachable!`);
+ process.exit(0);
+ }
+}
+
+main().catch((err) => {
+ console.error(`\nā Critical error:`, err.message);
+ process.exit(1);
+});