diff --git a/package.json b/package.json
index 3dcba51..e5f0d3b 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,10 @@
"prepare": "husky",
"generate:types": "payload generate:types",
"generate:importmap": "payload generate:importmap",
- "pagespeed:test": "mintel pagespeed test"
+ "pagespeed:test": "mintel pagespeed test",
+ "check:http": "tsx ./scripts/check-http.ts",
+ "check:apis": "tsx ./scripts/check-apis.ts",
+ "check:locale": "tsx ./scripts/check-locale.ts"
},
"keywords": [],
"author": "",
@@ -62,6 +65,8 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.23",
+ "axios": "^1.13.5",
+ "cheerio": "^1.2.0",
"eslint": "^8.57.1",
"eslint-config-next": "15.1.6",
"happy-dom": "^20.6.1",
@@ -72,6 +77,7 @@
"postcss": "^8.5.6",
"prettier": "^3.5.0",
"tailwindcss": "^4.1.18",
+ "tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
diff --git a/scripts/check-apis.ts b/scripts/check-apis.ts
new file mode 100644
index 0000000..1583e4f
--- /dev/null
+++ b/scripts/check-apis.ts
@@ -0,0 +1,133 @@
+import axios from "axios";
+import dns from "dns";
+import { promisify } from "util";
+import url from "url";
+
+const resolve4 = promisify(dns.resolve4);
+
+// This script verifies that external logging and analytics APIs are reachable
+// from the deployment environment (which could be behind corporate firewalls or VPNs).
+
+const umamiEndpoint =
+ process.env.UMAMI_API_ENDPOINT || "https://analytics.infra.mintel.me";
+const sentryDsn = process.env.SENTRY_DSN || "";
+
+async function checkUmami() {
+ console.log(`\nš Checking Umami Analytics API Availability...`);
+ console.log(` Endpoint: ${umamiEndpoint}`);
+
+ try {
+ // Umami usually exposes a /api/heartbeat or /api/health if we know the route.
+ // Trying root or /api/auth/verify (which will give 401 but proves routing works).
+ // A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
+ const response = await axios.get(
+ `${umamiEndpoint.replace(/\/$/, "")}/api/health`,
+ {
+ timeout: 5000,
+ validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
+ },
+ );
+
+ // As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
+ if (response.status >= 500) {
+ throw new Error(
+ `Umami API responded with server error HTTP ${response.status}`,
+ );
+ }
+
+ console.log(` ā
Umami Analytics is reachable (HTTP ${response.status})`);
+ return true;
+ } catch (err: any) {
+ // If /api/health fails completely, maybe try a DNS check as a fallback
+ try {
+ console.warn(
+ ` ā ļø HTTP check failed, falling back to DNS resolution...`,
+ );
+ const umamiHost = new url.URL(umamiEndpoint).hostname;
+ await resolve4(umamiHost);
+ console.log(
+ ` ā
Umami Analytics DNS resolved successfully (${umamiHost})`,
+ );
+ return true;
+ } catch (dnsErr: any) {
+ console.error(
+ ` ā CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
+ );
+ return false;
+ }
+ }
+}
+
+async function checkSentry() {
+ console.log(`\nš Checking Glitchtip/Sentry Error Tracking Availability...`);
+
+ if (!sentryDsn) {
+ console.log(` ā¹ļø No SENTRY_DSN provided in environment. Skipping.`);
+ return true;
+ }
+
+ try {
+ const parsedDsn = new url.URL(sentryDsn);
+ const host = parsedDsn.hostname;
+ console.log(` Host: ${host}`);
+
+ // We do a DNS lookup to ensure the runner can actually resolve the tracking server
+ const addresses = await resolve4(host);
+
+ if (addresses && addresses.length > 0) {
+ console.log(` ā
Glitchtip/Sentry domain resolved: ${addresses[0]}`);
+
+ // Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
+ try {
+ const proto = parsedDsn.protocol || "https:";
+ await axios.get(`${proto}//${host}/api/0/`, {
+ timeout: 5000,
+ validateStatus: () => true,
+ });
+ console.log(` ā
Glitchtip/Sentry API root responds to HTTP.`);
+ } catch (ignore) {
+ console.log(
+ ` ā ļø Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
+ );
+ }
+
+ return true;
+ }
+ throw new Error("No IP addresses found for DSN host");
+ } catch (err: any) {
+ console.error(
+ ` ā CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
+ );
+ return false;
+ }
+}
+
+async function main() {
+ console.log("š Starting External API Connectivity Smoke Test...");
+
+ let hasErrors = false;
+
+ const umamiOk = await checkUmami();
+ if (!umamiOk) hasErrors = true;
+
+ const sentryOk = await checkSentry();
+ if (!sentryOk) hasErrors = true;
+
+ if (hasErrors) {
+ console.error(
+ `\nšØ POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
+ );
+ console.error(
+ ` This might mean the deployment environment lacks outbound internet access, `,
+ );
+ console.error(
+ ` DNS is misconfigured, or the upstream services are down.`,
+ );
+ process.exit(1);
+ }
+
+ console.log(`\nš SUCCESS: All required external APIs are reachable!`);
+ process.exit(0);
+}
+
+main();
diff --git a/scripts/check-http.ts b/scripts/check-http.ts
new file mode 100644
index 0000000..17334cc
--- /dev/null
+++ b/scripts/check-http.ts
@@ -0,0 +1,83 @@
+import axios from "axios";
+import * as cheerio from "cheerio";
+
+const targetUrl =
+ process.argv[2] ||
+ process.env.NEXT_PUBLIC_BASE_URL ||
+ "http://localhost:3000";
+const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
+
+async function main() {
+ console.log(`\nš Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
+
+ try {
+ const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
+ console.log(`š„ Fetching sitemap from ${sitemapUrl}...`);
+
+ const response = await axios.get(sitemapUrl, {
+ headers: { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` },
+ validateStatus: (status) => status < 400,
+ });
+
+ const $ = cheerio.load(response.data, { xmlMode: true });
+ let urls = $("url loc")
+ .map((i, el) => $(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} target URLs in sitemap.`);
+
+ if (urls.length === 0) {
+ console.error("ā No URLs found in sitemap. Is the site up?");
+ process.exit(1);
+ }
+
+ console.log(`\nš Verifying HTTP Status Codes (Limit: None)...`);
+ let hasErrors = false;
+
+ // Run fetches sequentially to avoid overwhelming the server during CI
+ for (let i = 0; i < urls.length; i++) {
+ const u = urls[i];
+ try {
+ const res = await axios.get(u, {
+ headers: {
+ Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
+ },
+ validateStatus: null, // Don't throw on error status
+ });
+
+ if (res.status >= 400) {
+ console.error(`ā ERROR ${res.status}: ${res.statusText} -> ${u}`);
+ hasErrors = true;
+ } else {
+ console.log(`ā
OK ${res.status} -> ${u}`);
+ }
+ } catch (err: any) {
+ console.error(`ā NETWORK ERROR: ${err.message} -> ${u}`);
+ hasErrors = true;
+ }
+ }
+
+ if (hasErrors) {
+ console.error(
+ `\nā HTTP Sitemap Validation Failed. One or more pages returned an error.`,
+ );
+ process.exit(1);
+ } else {
+ console.log(
+ `\n⨠Success: All ${urls.length} pages are healthy! (HTTP 200)`,
+ );
+ process.exit(0);
+ }
+ } catch (error: any) {
+ console.error(`\nā Critical Error during Sitemap Fetch:`, error.message);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/scripts/check-locale.ts b/scripts/check-locale.ts
new file mode 100644
index 0000000..a48ba68
--- /dev/null
+++ b/scripts/check-locale.ts
@@ -0,0 +1,197 @@
+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 || "lassmichrein";
+
+// Expected slug translations: German key ā English value
+const SLUG_MAP: Record = {
+ // Add translations if mb-grid translates URLs: e.g. produkte: 'products'
+};
+
+// Reverse map: English ā German
+const REVERSE_SLUG_MAP: Record = Object.fromEntries(
+ Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
+);
+
+const headers = { Cookie: `mintel_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);
+});