fix(config): ensure analytics URL has protocol to prevent build crash
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 38s
Build & Deploy / 🧪 QA (push) Successful in 1m22s
Build & Deploy / 🏗️ Build (push) Successful in 4m27s
Build & Deploy / 🚀 Deploy (push) Failing after 39s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-05-12 13:01:18 +02:00
parent 5d3be82d8f
commit 26d325df44
32998 changed files with 7245 additions and 3832721 deletions

86
scripts/assets-sync.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ────────────────────────────────────────────────────────────────────────────
# Asset Sync Tool
# Syncs media files between environments without touching the database.
# ────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# Load environment variables
if [ -f .env ]; then
set -a; source .env; set +a
fi
# ── Configuration ──────────────────────────────────────────────────────────
SOURCE_ENV="${1:-}" # local | testing | staging | prod
TARGET_ENV="${2:-}" # testing | staging | prod
SSH_HOST="root@alpha.mintel.me"
LOCAL_MEDIA_DIR="./public/media"
DRY_RUN=""
CHECKSUM=""
if [[ "$*" == *"--dry-run"* ]]; then
DRY_RUN="--dry-run"
echo "🏃 DRY RUN MODE ENABLED"
fi
if [[ "$*" == *"--checksum"* ]]; then
CHECKSUM="-c"
echo "🔍 CHECKSUM MODE ENABLED (Slower but more reliable)"
fi
# ── Resolve Paths ──────────────────────────────────────────────────────────
get_media_path() {
case "$1" in
local) echo "$LOCAL_MEDIA_DIR" ;;
testing) echo "/var/lib/docker/volumes/klz-testing_klz_media_data/_data" ;;
staging) echo "/var/lib/docker/volumes/klz-staging_klz_media_data/_data" ;;
prod|production) echo "/var/lib/docker/volumes/klz-cablescom_klz_media_data/_data" ;;
*) echo "❌ Unknown environment: $1"; exit 1 ;;
esac
}
get_app_container() {
case "$1" in
testing) echo "klz-testing-klz-app-1" ;;
staging) echo "klz-staging-klz-app-1" ;;
prod|production) echo "klz-cablescom-klz-app-1" ;;
*) echo "" ;;
esac
}
SRC_PATH=$(get_media_path "$SOURCE_ENV")
TGT_PATH=$(get_media_path "$TARGET_ENV")
TGT_CONTAINER=$(get_app_container "$TARGET_ENV")
echo "🚀 Syncing assets: $SOURCE_ENV$TARGET_ENV"
echo "📂 Source: $SRC_PATH"
echo "📂 Target: $TGT_PATH"
# ── Execution ──────────────────────────────────────────────────────────────
if [[ ! -d "$SRC_PATH" ]] && [[ "$SOURCE_ENV" == "local" ]]; then
echo "❌ Source directory does not exist: $SRC_PATH"
exit 1
fi
if [[ "$SOURCE_ENV" == "local" ]]; then
# Local → Remote
echo "📡 Running rsync..."
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SRC_PATH/" "$SSH_HOST:$TGT_PATH/"
elif [[ "$TARGET_ENV" == "local" ]]; then
# Remote → Local
mkdir -p "$LOCAL_MEDIA_DIR"
echo "📡 Running rsync..."
rsync -avzi $CHECKSUM --delete --progress $DRY_RUN "$SSH_HOST:$SRC_PATH/" "$TGT_PATH/"
else
# Remote → Remote (e.g., testing → staging)
echo "📡 Running remote rsync..."
ssh "$SSH_HOST" "rsync -avzi $CHECKSUM --delete --progress $DRY_RUN $SRC_PATH/ $TGT_PATH/"
fi
# Fix ownership on remote target if it's not local
if [[ "$TARGET_ENV" != "local" && -z "$DRY_RUN" ]]; then
echo "🔑 Fixing media file permissions on $TARGET_ENV..."
ssh "$SSH_HOST" "docker exec -u 0 $TGT_CONTAINER chown -R 1001:65533 /app/public/media/ 2>/dev/null || true"
fi
echo "✅ Asset sync complete!"

57
scripts/audit-local.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# audit-local.sh
# Runs a high-fidelity Lighthouse audit locally using the Docker production stack.
set -e
echo "🚀 Starting High-Fidelity Local Audit..."
# 1. Environment and Infrastructure
export DOCKER_HOST="unix:///Users/marcmintel/.docker/run/docker.sock"
export IMGPROXY_URL="http://klz-imgproxy:8080"
export NEXT_URL="http://klz.localhost"
export NEXT_PUBLIC_CI=true
export CI=true
docker network create infra 2>/dev/null || true
docker volume create klz_db_data 2>/dev/null || true
# 2. Start infra services (DB, CMS, Gatekeeper)
echo "📦 Starting infrastructure services..."
# Using --remove-orphans to ensure a clean state
docker-compose up -d --remove-orphans klz-db klz-gatekeeper
# 3. Build and Start klz-app in Production Mode
echo "🏗️ Building and starting klz-app (Production)..."
# We bypass the dev override by explicitly using the base compose file
NEXT_PUBLIC_BASE_URL=$NEXT_URL \
NEXT_PUBLIC_CI=true \
docker-compose -f docker-compose.yml up -d --build klz-app
# 4. Wait for application to be ready
echo "⏳ Waiting for application to be healthy..."
MAX_RETRIES=30
RETRY_COUNT=0
until $(curl -s -f -o /dev/null "$NEXT_URL/health"); do
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
echo "❌ Error: App did not become healthy in time."
exit 1
fi
echo " ...waiting for $NEXT_URL/health"
sleep 2
RETRY_COUNT=$((RETRY_COUNT+1))
done
echo "✅ App is healthy at $NEXT_URL"
# 5. Run Lighthouse Audit
echo "⚡ Executing Lighthouse CI..."
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=5 pnpm run pagespeed:test "$NEXT_URL"
echo "♿ Executing WCAG Audit..."
NEXT_PUBLIC_BASE_URL=$NEXT_URL PAGESPEED_LIMIT=10 pnpm run check:wcag "$NEXT_URL"
echo "✨ Audit completed! Summary above."
echo "💡 You can stop the production app with: docker-compose stop klz-app"

119
scripts/check-apis.ts Normal file
View File

@@ -0,0 +1,119 @@
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();

View File

@@ -0,0 +1,197 @@
import puppeteer, { HTTPResponse } from 'puppeteer';
import axios from 'axios';
import * as cheerio from 'cheerio';
const targetUrl =
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
process.env.NEXT_PUBLIC_BASE_URL ||
'http://localhost:3000';
const limit = process.env.ASSET_CHECK_LIMIT ? parseInt(process.env.ASSET_CHECK_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function main() {
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
// 1. Fetch Sitemap to discover all routes
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
let urls: string[] = [];
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Normalize to target URL instance
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.`);
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
// Simplify selection: home pages + a slice of the rest
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
const homeDE = urls.filter((u) => u.endsWith('/de'));
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
}
} catch (err: any) {
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
process.exit(1);
}
// 2. Launch Headless Browser
console.log(`\n🕷 Launching Puppeteer Headless Engine...`);
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || process.env.CHROME_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
// Inject Gatekeeper session bypassing auth screens
await page.setCookie({
name: 'klz_gatekeeper_session',
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',
httpOnly: true,
secure: targetUrl.startsWith('https://'),
});
let hasBrokenAssets = false;
let hasConsoleErrors = false;
const brokenAssetsList: Array<{ url: string; status: number; page: string }> = [];
const consoleErrorsList: Array<{ type: string; error: string; page: string }> = [];
// Listen for unhandled exceptions natively in the page
page.on('pageerror', (err: any) => {
consoleErrorsList.push({
type: 'PAGE_ERROR',
error: err.message,
page: page.url(),
});
hasConsoleErrors = true;
});
// Listen for console.error and console.warn messages (like Next.js Image warnings, hydration errors, CSP blocks)
page.on('console', (msg) => {
const type = msg.type();
if (type === 'error' || type === 'warn') {
const text = msg.text();
// Exclude common browser extension noise or third party tracker warnings
if (
text.includes('google-analytics') ||
text.includes('googletagmanager') ||
text.includes('SES Removing unpermitted intrinsics') ||
text.includes('Third-party cookie will be blocked') ||
text.includes('Fast Refresh')
)
return;
consoleErrorsList.push({
type: type.toUpperCase(),
error: text,
page: page.url(),
});
hasConsoleErrors = true;
}
});
// Listen to ALL network responses
page.on('response', (response: HTTPResponse) => {
const status = response.status();
// Catch classic 404s and 500s on ANY fetch/image/script
if (
status >= 400 &&
status !== 999 &&
!response.url().includes('google-analytics') &&
!response.url().includes('googletagmanager')
) {
const type = response.request().resourceType();
// We explicitly care about images, stylesheets, scripts, and fetch requests (API) getting 404/500s.
if (['image', 'script', 'stylesheet', 'fetch', 'xhr'].includes(type)) {
brokenAssetsList.push({
url: response.url(),
status: status,
page: page.url(),
});
hasBrokenAssets = true;
}
}
});
// 3. Scan each page
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
console.log(`[${i + 1}/${urls.length}] Scanning: ${u}`);
try {
// Wait until network is idle to ensure all Next.js hydration and image lazy-loads trigger
await page.goto(u, { waitUntil: 'networkidle0', timeout: 30000 });
// Force scroll to bottom to trigger any IntersectionObserver lazy-loaded images
await page.evaluate(async () => {
await new Promise<void>((resolve) => {
let totalHeight = 0;
const distance = 100;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});
// Wait a tiny bit more for final lazy loads
await new Promise((r) => setTimeout(r, 1000));
} catch (err: any) {
console.error(`⚠️ Timeout or navigation error on ${u}: ${err.message}`);
// Don't fail the whole script just because one page timed out, but flag it
hasBrokenAssets = true;
}
}
await browser.close();
// 4. Report Results
if (hasBrokenAssets && brokenAssetsList.length > 0) {
console.error(`\n❌ FATAL: Broken assets (404/500) detected heavily on the site!`);
console.table(brokenAssetsList);
}
if (hasConsoleErrors && consoleErrorsList.length > 0) {
console.error(`\n❌ FATAL: Console Errors/Warnings detected on the site!`);
console.table(consoleErrorsList);
}
if (hasBrokenAssets || hasConsoleErrors) {
console.error(`\n🚨 The CI build will now fail to prevent bad code from reaching production.`);
process.exit(1);
} else {
console.log(
`\n🎉 SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken images or console errors!`,
);
process.exit(0);
}
}
main();

367
scripts/check-forms.ts Normal file
View File

@@ -0,0 +1,367 @@
import puppeteer, { HTTPResponse } from 'puppeteer';
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 || 'klz2026';
// Utility for hardcoded delays
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
// 0. Pre-warming settlement: Wait for deployment to respond (sync containers/Varnish)
console.log(`⏳ Pre-warming: Waiting for deployment to return 200 OK...`);
const maxWaitMs = 60000;
const startTime = Date.now();
let warmed = false;
while (Date.now() - startTime < maxWaitMs) {
try {
const resp = await axios.get(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
timeout: 5000,
validateStatus: (s) => s === 200,
});
if (resp.status === 200) {
warmed = true;
break;
}
} catch (e) {
// Quietly retry
}
await delay(2000);
}
if (!warmed) {
console.warn(`⚠️ Pre-warming timed out after 60s. Proceeding anyway...`);
} else {
console.log(`✅ Deployment settled and responding.`);
}
// Brief stabilization buffer
await delay(5000);
// 1. Fetch Sitemap to discover the contact page and a product page
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
let urls: string[] = [];
try {
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
});
const $ = cheerio.load(response.data, { xmlMode: true });
urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Normalize to target URL instance
const urlPattern = /https?:\/\/[^\/]+/;
urls = [...new Set(urls)]
.filter((u) => u.startsWith('http'))
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, '')))
.sort();
} catch (err: any) {
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
process.exit(1);
}
const contactUrl = urls.find((u) => u.includes('/de/kontakt'));
// Ensure we select an actual product page (depth >= 7: http://host/de/produkte/category/product)
const productUrl = urls.find(
(u) =>
u.includes('/de/produkte/') && new URL(u).pathname.split('/').filter(Boolean).length >= 4,
);
if (!contactUrl) {
console.error(`❌ Could not find contact page in sitemap. Ensure /de/kontakt exists.`);
process.exit(1);
}
if (!productUrl) {
console.error(
`❌ Could not find a product page in sitemap. Form testing requires at least one product page.`,
);
process.exit(1);
}
console.log(`✅ Discovered Contact Page: ${contactUrl}`);
console.log(`✅ Discovered Product Page: ${productUrl}`);
// 2. Launch Headless Browser
console.log(`\n🕷 Launching Puppeteer Headless Engine...`);
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--ignore-certificate-errors',
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
],
});
const page = await browser.newPage();
// Enable Request Interception to force cache-busting on ALL static assets
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = request.url();
// Intercept Next.js chunks and data to bypass Varnish 404-caching
// Includes standard /_next/ and subpath /gatekeeper/_next/
if (
(url.includes('/_next/static/') ||
url.includes('/_next/data/') ||
url.includes('/gatekeeper/_next/')) &&
!url.includes('?cb=') &&
!url.includes('&cb=')
) {
const buster = `cb=${Date.now()}`;
const newUrl = url.includes('?') ? `${url}&${buster}` : `${url}?${buster}`;
request.continue({ url: newUrl });
} else {
request.continue();
}
});
let chunkErrorsDetected = false;
page.on('console', (msg) => {
const text = msg.text();
console.log('💻 BROWSER CONSOLE:', text);
if (text.includes('ChunkLoadError') || text.includes('failed to load chunk')) {
chunkErrorsDetected = true;
}
});
page.on('pageerror', (error) => {
console.error('💻 BROWSER ERROR:', error.message);
if (error.message.includes('ChunkLoadError')) {
chunkErrorsDetected = true;
}
});
page.on('requestfailed', (request) => {
const url = request.url();
const failure = request.failure()?.errorText;
console.error('💻 BROWSER REQUEST FAILED:', url, failure);
if (url.endsWith('.js') && (failure === 'net::ERR_ABORTED' || failure?.includes('404'))) {
chunkErrorsDetected = true;
}
});
// Helper for resilient navigation with cache-busting fallback
const navigateWithRetry = async (url: string, label: string) => {
chunkErrorsDetected = false;
console.log(`\n🧪 Testing ${label} on: ${url}`);
// First attempt: Wait for network to be relatively idle
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
// REDIRECTION CHECK: Logging redirected landing page
const finalUrl = page.url();
if (finalUrl !== url && !finalUrl.includes(url)) {
console.log(` 🔀 Redirected to: ${finalUrl}`);
}
if (chunkErrorsDetected) {
const buster = `cb=${Date.now()}`;
const cbUrl = finalUrl.includes('?') ? `${finalUrl}&${buster}` : `${finalUrl}?${buster}`;
console.warn(
` ⚠️ Assets failed to load (Varnish staleness suspected). Retrying with cache-buster: ${cbUrl}`,
);
chunkErrorsDetected = false;
await page.goto(cbUrl, { waitUntil: 'networkidle2', timeout: 30000 });
}
// Capture HTML on failure during hydration wait
try {
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 5000 }).catch(() => {});
} catch (e) {
console.warn(' ⚠️ Network idle timeout, proceeding with current state.');
}
};
// 3. Authenticate through Gatekeeper login form
try {
// Navigate to a protected page so Gatekeeper redirects us to the login screen
// RELAXED NAVIGATION: Use domcontentloaded to handle rapid redirects
await page.goto(contactUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
// BOUNCE BUFFER: Wait for potential Gatekeeper redirect chain to settle
console.log(` Waiting for potential Gatekeeper redirect bounce...`);
await delay(3000);
// Check if we landed on the Gatekeeper login page
const isGatekeeperPage = await page.$('input[name="password"]');
if (isGatekeeperPage) {
console.log(` Gatekeeper gate detected. Logging in...`);
await page.type('input[name="password"]', gatekeeperPassword);
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.click('button[type="submit"]'),
]);
// LOGIN SYNC BUFFER: Let the authenticated session settle
console.log(` Authenticating session...`);
await delay(3000);
console.log(`✅ Gatekeeper authentication successful!`);
} else {
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
}
} catch (err: any) {
console.error(`❌ Gatekeeper authentication failed: ${err.message}`);
await browser.close();
process.exit(1);
}
let hasErrors = false;
// 4. Test Contact Form
try {
await navigateWithRetry(contactUrl, 'Contact Form');
// Ensure React has hydrated completely - CI runners can be slow
console.log(` ⏳ Waiting for hydration (5s)...`);
await delay(5000);
// Ensure form is visible and interactive
try {
// Find the form input by name
await page.waitForSelector('input[name="name"]', { visible: true, timeout: 15000 });
} catch (e) {
console.error('❌ Failed to find Contact Form input. Page Title:', await page.title());
const html = await page.content();
console.log(`💻 FULL HTML DUMP (top 2000 chars):\n${html.slice(0, 2000)}`);
throw e;
}
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me');
await page.type(
'textarea[name="message"]',
'This is an automated test verifying the contact form submission.',
);
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` ⏳ Waiting for alert selector (30s)...`);
try {
// Explicitly click submit and wait for navigation/state-change
await Promise.all([
page.waitForSelector('[role="alert"]', { timeout: 30000 }),
page.click('button[type="submit"]'),
]);
} catch (e) {
console.error(`❌ Timeout waiting for [role="alert"]. Dumping page content:`);
const bodyHTML = await page.evaluate(() => document.body.innerHTML.slice(0, 1000));
console.log(`💻 PAGE BODY (partial): ${bodyHTML}`);
throw e;
}
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` 🔔 Alert text: ${alertText}`);
// Detection robust for both English and German versions
const isError =
alertText?.toLowerCase().includes('failed') ||
alertText?.toLowerCase().includes('wrong') ||
alertText?.toLowerCase().includes('fehlgeschlagen') ||
alertText?.toLowerCase().includes('schief gelaufen') ||
alertText?.toLowerCase().includes('fehler');
if (isError) {
throw new Error(`Form submitted but showed error: ${alertText}`);
}
console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
} catch (err: any) {
console.error(`❌ Contact Form Test Failed: ${err.message}`);
hasErrors = true;
}
// 4. Test Product Quote Form
try {
await navigateWithRetry(productUrl, 'Product Quote Form');
// Ensure React has hydrated completely
console.log(` ⏳ Waiting for hydration (5s)...`);
await delay(5000);
// The product form uses dynamic IDs, so we select by input type in the specific form context
try {
await page.waitForSelector('form input[type="email"]', { visible: true, timeout: 15000 });
} catch (e) {
console.error('Failed to find Product Quote Form input. Page Title:', await page.title());
throw e;
}
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// In RequestQuoteForm, the email input is type="email" and message is a textarea.
await page.type('form input[type="email"]', 'testing@mintel.me');
await page.type(
'form textarea',
'Automated request for product quote via E2E testing framework.',
);
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` ⏳ Waiting for alert selector (30s)...`);
try {
// Submit and wait for success state
await Promise.all([
page.waitForSelector('[role="alert"]', { timeout: 30000 }),
page.click('form button[type="submit"]'),
]);
} catch (e) {
console.error(`❌ Timeout waiting for [role="alert"] on Product page. Dumping content:`);
const bodyHTML = await page.evaluate(() => document.body.innerHTML.slice(0, 1000));
console.log(`💻 PAGE BODY (partial): ${bodyHTML}`);
throw e;
}
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` 🔔 Alert text: ${alertText}`);
const isError =
alertText?.toLowerCase().includes('failed') ||
alertText?.toLowerCase().includes('wrong') ||
alertText?.toLowerCase().includes('fehlgeschlagen') ||
alertText?.toLowerCase().includes('schief gelaufen') ||
alertText?.toLowerCase().includes('fehler');
if (isError) {
throw new Error(`Form submitted but showed error: ${alertText}`);
}
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
} catch (err: any) {
console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
hasErrors = true;
}
console.log(`\n🧹 Skipping cleanup (No CMS/DB active in this infrastructure).`);
await browser.close();
// 6. Evaluation
if (hasErrors) {
console.error(`\n🚨 IMPORTANT: Form E2E checks failed. The CI build is failing.`);
process.exit(1);
} else {
console.log(`\n🎉 SUCCESS: All form submissions arrived and handled correctly!`);
process.exit(0);
}
}
main();

83
scripts/check-html.ts Normal file
View File

@@ -0,0 +1,83 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function main() {
console.log(`\n🚀 Starting HTML Validation for: ${targetUrl}`);
console.log(`📊 Limit: None (Full Sitemap)\n`);
try {
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: { Cookie: `klz_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} URLs in sitemap.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
fs.mkdirSync(outputDir, { recursive: true });
console.log(`📥 Fetching HTML for ${urls.length} pages...`);
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
try {
const res = await axios.get(u, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: (status) => status < 400,
});
// Generate a safe filename that retains URL information
const urlStr = new URL(u);
const safePath = (urlStr.pathname + urlStr.search).replace(/[^a-zA-Z0-9]/g, '_');
const filename = `${safePath || 'index'}.html`;
fs.writeFileSync(path.join(outputDir, filename), res.data);
} catch (err: any) {
console.error(`❌ HTTP Error fetching ${u}: ${err.message}`);
throw new Error(`Failed to fetch page: ${u} - ${err.message}`);
}
}
console.log(`\n💻 Executing html-validate...`);
try {
execSync(`npx html-validate .htmlvalidate-tmp/*.html`, { stdio: 'inherit' });
console.log(`✅ HTML Validation passed perfectly!`);
} catch (e) {
console.error(`❌ HTML Validation found issues.`);
process.exit(1);
}
} catch (error: any) {
console.error(`\n❌ Error during HTML Validation:`, error.message);
process.exit(1);
} finally {
const outputDir = path.join(process.cwd(), '.htmlvalidate-tmp');
if (fs.existsSync(outputDir)) fs.rmSync(outputDir, { recursive: true, force: true });
}
}
main();

74
scripts/check-http.ts Normal file
View File

@@ -0,0 +1,74 @@
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 || 'klz2026';
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: `klz_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: `klz_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();

54
scripts/check-links.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
set -e
# Auto-provision Lychee Rust Binary if missing
if [ ! -f ./lychee ]; then
echo "📥 Downloading Lychee Link Checker (v0.15.1)..."
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
VERSION="lychee-v0.23.0"
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then
ARCH_MAPPED="x86_64"
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ARCH_MAPPED="aarch64"
else
echo "❌ Unsupported architecture: $ARCH"
exit 1
fi
if [ "$OS" = "darwin" ]; then
TARGET="arm64-macos"
elif [ "$OS" = "linux" ]; then
TARGET="${ARCH_MAPPED}-unknown-linux-gnu"
else
echo "❌ Unsupported OS: $OS"
exit 1
fi
DOWNLOAD_URL="https://github.com/lycheeverse/lychee/releases/download/${VERSION}/lychee-${TARGET}.tar.gz"
echo "Downloading from $DOWNLOAD_URL"
curl -sSLo lychee.tar.gz "$DOWNLOAD_URL"
tar -xzf lychee.tar.gz lychee
rm lychee.tar.gz
chmod +x ./lychee
fi
echo "🚀 Starting Deep Link Assessment (Lychee)..."
# Exclude localhost, mintel.me (internal infrastructure), and common placeholder domains
# Scan markdown files and component files for hardcoded dead links
./lychee \
--exclude "localhost" \
--exclude "127.0.0.1" \
--exclude "mintel\.me" \
--exclude "example\.com" \
--exclude "linkedin\.com" \
--exclude "fonts\." \
--base-url "http://127.0.0.1" \
--accept 200,204,308,401,403,999 \
"./app/**/*.tsx" "./components/**/*.tsx"
echo "✅ All project source links are alive and healthy!"

201
scripts/check-locale.ts Normal file
View File

@@ -0,0 +1,201 @@
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 <link rel="alternate" hreflang="..." href="..."> 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<string, string> = {
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<string, string> = 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,
alternates: { hreflang: string; href: string }[],
): string | null {
const segments = sourcePath.split('/').filter(Boolean);
segments[0] = targetLocale;
// Blog posts have dynamic slugs. If it's a blog post, trust the alternate tag
// if the href is present in the sitemap.
// The Smoke Test's primary job is ensuring the alternate links point to valid pages.
if (segments[1] === (targetLocale === 'de' ? 'blog' : 'blog') && segments.length > 2) {
const altLink = alternates.find((a) => a.hreflang === targetLocale);
if (altLink) {
return new URL(altLink.href).pathname;
}
}
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
})
.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, alternates);
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);
});

View File

@@ -0,0 +1,78 @@
import { SITE_URL } from '../lib/schema.js';
const BASE_URL = process.env.TEST_URL || SITE_URL;
console.log(`\n🚀 Starting OG Image Verification for ${BASE_URL}\n`);
const routes = [
'/de/opengraph-image',
'/en/opengraph-image',
'/de/blog/opengraph-image',
'/de/kontakt/opengraph-image',
'/en/contact/opengraph-image',
];
async function verifyImage(path: string): Promise<boolean> {
const url = `${BASE_URL}${path}`;
const start = Date.now();
try {
const response = await fetch(url);
const duration = Date.now() - start;
console.log(`Checking ${url}...`);
const body = await response.clone().text();
const contentType = response.headers.get('content-type');
if (response.status !== 200) {
console.log(` Headers: ${JSON.stringify(Object.fromEntries(response.headers))}`);
console.log(` Status Text: ${response.statusText}`);
throw new Error(
`Status: ${response.status}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
);
}
if (!contentType?.includes('image/png')) {
throw new Error(
`Content-Type: ${contentType}. Body preview: ${body.substring(0, 1000).replace(/\n/g, ' ')}...`,
);
}
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// PNG Signature: 89 50 4E 47 0D 0A 1A 0A
if (bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) {
throw new Error('Invalid PNG signature');
}
if (bytes.length < 10000) {
throw new Error(`Image too small (${bytes.length} bytes), likely blank`);
}
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
return true;
} catch (error: unknown) {
console.error(` ❌ FAILED:`, error);
return false;
}
}
async function run() {
let allOk = true;
for (const route of routes) {
const ok = await verifyImage(route);
if (!ok) allOk = false;
}
if (allOk) {
console.log('\n✨ All OG images verified successfully!\n');
process.exit(0);
} else {
console.error('\n⚠ Some OG images failed verification.\n');
process.exit(1);
}
}
run();

54
scripts/check-security.ts Normal file
View File

@@ -0,0 +1,54 @@
import axios from 'axios';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
const requiredHeaders = [
'strict-transport-security',
'x-frame-options',
'x-content-type-options',
'referrer-policy',
'content-security-policy',
];
async function main() {
console.log(`\n🛡 Starting Security Headers Scan for: ${targetUrl}\n`);
try {
const response = await axios.head(targetUrl, {
headers: { Cookie: `klz_gatekeeper_session=${gatekeeperPassword}` },
validateStatus: () => true,
});
const headers = response.headers;
let allPassed = true;
const results = requiredHeaders.map((header) => {
const present = !!headers[header];
if (!present) allPassed = false;
return {
Header: header,
Status: present ? '✅ Present' : '❌ Missing',
Value: present
? headers[header].length > 50
? headers[header].substring(0, 47) + '...'
: headers[header]
: 'N/A',
};
});
console.table(results);
if (allPassed) {
console.log(`\n✅ All required security headers are correctly configured!\n`);
process.exit(0);
} else {
console.log(`\n❌ Missing critical security headers. Please update next.config.mjs!\n`);
process.exit(process.env.CI ? 1 : 0); // Don't crash local dev hard if missing, but crash CI
}
} catch (error: any) {
console.error(`❌ Failed to scan headers: ${error.message}`);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,29 @@
import { Client } from 'pg';
async function checkSubmissions() {
const client = new Client({
connectionString: "postgresql://payload:120in09oenaoinsd9iaidon@127.0.0.1:54322/payload"
});
try {
await client.connect();
console.log("Connected to database.");
const res = await client.query("SELECT * FROM form_submissions WHERE created_at >= '2026-04-12' ORDER BY created_at DESC;");
if (res.rows.length === 0) {
console.log("No submissions found for today.");
} else {
console.log(`Found ${res.rows.length} submissions for today:`);
res.rows.forEach(row => {
console.log(`- [${row.created_at}] ${row.name} (${row.email}): ${row.message.substring(0, 50)}...`);
});
}
} catch (err) {
console.error("Database query failed:", err.message);
} finally {
await client.end();
}
}
checkSubmissions();

View File

@@ -0,0 +1,125 @@
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
function lexicalToMarkdown(node: any, depth = 0): string {
if (!node) return '';
if (typeof node === 'string') return node;
if (node.type === 'text') {
let text = node.text || '';
if (node.format === 1) text = `**${text}**`; // Bold
if (node.format === 2) text = `*${text}*`; // Italic
return text;
}
if (node.type === 'heading') {
const level = parseInt((node.tag || 'h1').replace('h', '')) || 1;
const prefix = '#'.repeat(level);
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
return `${prefix} ${text}\n\n`;
}
if (node.type === 'paragraph') {
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
return text.trim() ? `${text}\n\n` : '\n';
}
if (node.type === 'list') {
const isOrdered = node.tag === 'ol';
const items = (node.children || [])
.map((c: any, index: number) => {
const prefix = isOrdered ? `${index + 1}.` : '-';
return `${prefix} ${lexicalToMarkdown(c, depth + 1).trim()}`;
})
.join('\n');
return `${items}\n\n`;
}
if (node.type === 'listitem') {
return (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
}
if (node.type === 'quote') {
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
return `> ${text}\n\n`;
}
if (node.type === 'link') {
const url = node.fields?.url || node.url || '';
const text = (node.children || []).map((c: any) => lexicalToMarkdown(c, depth)).join('');
return `[${text}](${url})`;
}
if (node.type === 'upload') {
const url = node.value?.url || '';
const alt = node.value?.alt || node.value?.filename || '';
return `![${alt}](${url})\n\n`;
}
if (node.type === 'block') {
const blockType = node.fields?.blockType;
if (blockType === 'contactSection') {
return `<ContactSection showMap={${!!node.fields?.showMap}} showForm={${!!node.fields?.showForm}} showHours={${!!node.fields?.showHours}} />\n\n`;
}
if (blockType === 'heroSection') {
return `<HeroSection badge="${node.fields?.badge || ''}" title="${node.fields?.title || ''}" subtitle="${node.fields?.subtitle || ''}" alignment="${node.fields?.alignment || 'left'}" />\n\n`;
}
if (blockType === 'features') {
return `<FeaturesSection layout="${node.fields?.layout || ''}" />\n\n`;
}
// Generic MDX block wrapper if unknown
return `<Block type="${blockType}" data={${JSON.stringify(node.fields)}} />\n\n`;
}
if (node.children && Array.isArray(node.children)) {
return node.children.map((c: any) => lexicalToMarkdown(c, depth)).join('');
}
return '';
}
async function convertDir(dir: string) {
try {
const files = await fs.readdir(dir);
for (const f of files) {
if (!f.endsWith('.mdx')) continue;
const filePath = path.join(dir, f);
const fileContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(fileContent);
let ast = null;
try {
if (content.trim().startsWith('{')) {
ast = JSON.parse(content);
}
} catch (e) {
// Not JSON
}
if (ast && ast.root) {
const mdContent = lexicalToMarkdown(ast.root);
const newFileContent = matter.stringify(mdContent, data);
await fs.writeFile(filePath, newFileContent);
console.log(`Converted ${filePath}`);
}
}
} catch (error) {
console.error(`Failed reading directory ${dir}`, error);
}
}
async function main() {
const contentDir = path.join(process.cwd(), 'content');
const dirs = await fs.readdir(contentDir);
for (const dir of dirs) {
const fullPath = path.join(contentDir, dir);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await convertDir(fullPath);
}
}
}
main().catch(console.error);

View File

@@ -0,0 +1,33 @@
const fs = require('fs');
const blocks = [
'HomeHero',
'HomeProductCategories',
'HomeWhatWeDo',
'HomeRecentPosts',
'HomeExperience',
'HomeWhyChooseUs',
'HomeMeetTheTeam',
'HomeVideo',
];
blocks.forEach(name => {
const content = `import { Block } from 'payload';
export const ${name}: Block = {
slug: '${name.charAt(0).toLowerCase() + name.slice(1)}',
interfaceName: '${name}Block',
fields: [
{
name: 'note',
type: 'text',
admin: {
description: 'This is a dedicated layout block for the homepage wrapper. Content is managed via translation files.',
},
},
],
};
`;
fs.writeFileSync(\`src/payload/blocks/\${name}.ts\`, content);
console.log(\`Created \${name}.ts\`);
});

View File

@@ -0,0 +1,59 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { readUsers, updateUser } from '@directus/sdk';
import { config } from '../lib/config';
async function fixToken() {
console.log('🔑 Ensuring Directus Admin Token is set...');
try {
// 1. Authenticate with credentials
await ensureAuthenticated();
// 2. Find admin user
const users = await client.request(
readUsers({
filter: {
email: { _eq: config.directus.adminEmail },
},
}),
);
if (!users || users.length === 0) {
console.error(`❌ Could not find user with email ${config.directus.adminEmail}`);
process.exit(1);
}
const admin = users[0];
const targetToken = config.directus.token;
if (!targetToken) {
console.error('❌ No DIRECTUS_API_TOKEN configured in environment.');
process.exit(1);
}
if (admin.token === targetToken) {
console.log('✅ Token is already correctly set.');
return;
}
// 3. Update token
console.log(`📡 Updating token for ${config.directus.adminEmail}...`);
await client.request(
updateUser(admin.id, {
token: targetToken,
}),
);
console.log('✨ Token successfully updated!');
} catch (error: any) {
console.error('❌ Error fixing token:');
if (error.errors) {
console.error(JSON.stringify(error.errors, null, 2));
} else {
console.error(error.message || error);
}
process.exit(1);
}
}
fixToken();

26
scripts/fix-media-urls.ts Normal file
View File

@@ -0,0 +1,26 @@
import * as fs from 'fs';
import * as path from 'path';
function walk(dir: string, callback: (filePath: string) => void) {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
if (fs.statSync(filePath).isDirectory()) {
walk(filePath, callback);
} else if (filePath.endsWith('.mdx') || filePath.endsWith('.md')) {
callback(filePath);
}
}
}
let replacedCount = 0;
walk(path.join(process.cwd(), 'content'), (filePath) => {
let content = fs.readFileSync(filePath, 'utf8');
if (content.includes('/api/media/file/')) {
content = content.replace(/\/api\/media\/file\//g, '/media/');
fs.writeFileSync(filePath, content, 'utf8');
replacedCount++;
}
});
console.log(`Successfully fixed media URLs in ${replacedCount} files.`);

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env ts-node
/**
* Master Brochure Generator
*
* Generates a multi-page PDF brochure containing all products.
* Combines Payload CMS marketing text with Excel technical data.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import { getAllProducts } from '../lib/products';
import { getExcelTechnicalDataForProduct, preloadExcelData } from '../lib/excel-products';
import { PDFBrochure } from '../lib/pdf-brochure';
import { ProductData as PDFProductData } from '../lib/pdf-datasheet';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/downloads'),
locales: ['en', 'de'] as const,
} as const;
async function ensureOutputDir() {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
async function generateBrochure(locale: 'en' | 'de') {
console.log(`\nGenerating brochure for locale: ${locale.toUpperCase()}...`);
// 1. Load data
const products = await getAllProducts(locale);
console.log(`- Loaded ${products.length} products from Payload`);
// 2. Map and Enrich
const pdfProducts: PDFProductData[] = products.map((p, index) => {
const excelData = getExcelTechnicalDataForProduct({
slug: p.slug,
sku: p.frontmatter.sku,
name: p.frontmatter.title,
});
return {
id: index + 1,
name: p.frontmatter.title,
sku: p.frontmatter.sku,
shortDescriptionHtml: p.frontmatter.description,
descriptionHtml: p.frontmatter.description,
applicationHtml: '',
featuredImage: p.frontmatter.images[0] || null,
images: p.frontmatter.images,
categories: p.frontmatter.categories.map(name => ({ name })),
attributes: excelData?.attributes || [],
};
});
if (pdfProducts.length === 0) {
console.warn(`! No products found for ${locale}. Skipping.`);
return;
}
// 3. Render
console.log(`- Rendering PDF with ${pdfProducts.length} pages...`);
const buffer = await renderToBuffer(
React.createElement(PDFBrochure, {
products: pdfProducts,
locale,
title: locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
subtitle: '2026',
})
);
// 4. Save
const fileName = `KLZ_Cables_Brochure_2026_${locale.toUpperCase()}.pdf`;
const filePath = path.join(CONFIG.outputDir, fileName);
fs.writeFileSync(filePath, buffer);
const stats = fs.statSync(filePath);
console.log(`✓ Generated: ${fileName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
}
async function main() {
const start = Date.now();
console.log('--- KLZ Brochure Generator ---');
try {
await ensureOutputDir();
preloadExcelData();
for (const locale of CONFIG.locales) {
await generateBrochure(locale);
}
console.log(`\n✅ Brochure generation completed in ${((Date.now() - start) / 1000).toFixed(2)}s`);
} catch (error) {
console.error('\n❌ Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
/**
* LHCI Puppeteer Setup Script
* Sets the gatekeeper session cookie before auditing
*/
module.exports = async (browser, context) => {
const page = await browser.newPage();
// Using LHCI_URL or TARGET_URL if available
const targetUrl =
process.env.LHCI_URL || process.env.TARGET_URL || 'https://testing.klz-cables.com';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
console.log(`🔑 LHCI Auth: Setting gatekeeper cookie for ${new URL(targetUrl).hostname}...`);
await page.setCookie({
name: 'klz_gatekeeper_session',
value: gatekeeperPassword,
domain: new URL(targetUrl).hostname,
path: '/',
httpOnly: true,
secure: targetUrl.startsWith('https://'),
});
await page.close();
};

View File

@@ -0,0 +1,68 @@
import client, { ensureAuthenticated } from '../lib/directus';
import { createCollection, createField } from '@directus/sdk';
async function setupSchema() {
console.log('🏗️ Manually creating contact_submissions collection...');
try {
// 1. Authenticate (using token from config)
await ensureAuthenticated();
// 2. Create collection
console.log('📡 Creating "contact_submissions" collection...');
await client.request(
createCollection({
collection: 'contact_submissions',
meta: {
icon: 'contact_mail',
color: '#002b49',
display_template: '{{name}} | {{email}}',
},
schema: {
name: 'contact_submissions',
},
}),
);
// 3. Create fields
console.log('📡 Creating fields for "contact_submissions"...');
// name
await client.request(
createField('contact_submissions', {
field: 'name',
type: 'string',
meta: { interface: 'input' },
}),
);
// email
await client.request(
createField('contact_submissions', {
field: 'email',
type: 'string',
meta: { interface: 'input' },
}),
);
// message
await client.request(
createField('contact_submissions', {
field: 'message',
type: 'text',
meta: { interface: 'textarea' },
}),
);
console.log('✨ Collection and fields created successfully!');
} catch (error: any) {
console.error('❌ Error creating schema:');
if (error.errors) {
console.error(JSON.stringify(error.errors, null, 2));
} else {
console.error(error.message || error);
}
}
}
setupSchema();

View File

@@ -0,0 +1,260 @@
/**
* merge-locale-duplicates.ts
*
* Merges duplicate DE/EN documents into single Payload localized documents.
*
* Problem: Before native localization, DE and EN were stored as separate rows.
* Now each should be one document with locale-specific data in the *_locales tables.
*
* Strategy:
* 1. Products: Match by slug → Keep DE row as canonical, copy EN data, delete EN row
* 2. Posts: Match by slug → Same strategy
* 3. Pages: Match by slug map (impressum↔legal-notice, blog↔blog, etc.) → Same strategy
*/
import pg from 'pg';
const { Pool } = pg;
const DB_URL =
process.env.DATABASE_URI ||
process.env.POSTGRES_URI ||
`postgresql://payload:120in09oenaoinsd9iaidon@127.0.0.1:54322/payload`;
const pool = new Pool({ connectionString: DB_URL });
async function q<T = any>(query: string, values: unknown[] = []): Promise<T[]> {
const result = await pool.query(query, values);
return result.rows as T[];
}
async function mergeProducts() {
console.log('\n── PRODUCTS ───────────────────────────────────────');
const pairs = await q<{ de_id: number; en_id: number; slug: string }>(`
SELECT
de.id as de_id,
en.id as en_id,
de_loc.slug as slug
FROM products de
JOIN products_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de'
JOIN products_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en'
JOIN products en ON en.id = en_loc._parent_id
WHERE de.id != en.id
`);
console.log(`Found ${pairs.length} DE/EN product pairs to merge`);
for (const { de_id, en_id, slug } of pairs) {
console.log(` Merging: ${slug} (DE id=${de_id} ← EN id=${en_id})`);
const [enData] = await q(`
SELECT * FROM products_locales WHERE _parent_id = $1 AND _locale = 'en'
`, [en_id]);
if (enData) {
await q(`
INSERT INTO products_locales (title, description, application, content, _locale, _parent_id)
VALUES ($1, $2, $3, $4, 'en', $5)
ON CONFLICT (_locale, _parent_id) DO UPDATE
SET title = EXCLUDED.title,
description = EXCLUDED.description,
application = EXCLUDED.application,
content = EXCLUDED.content
`, [enData.title, enData.description, enData.application, enData.content, de_id]);
}
// Move categories from EN to DE if DE has none
await q(`
UPDATE products_categories SET _parent_id = $1
WHERE _parent_id = $2
AND NOT EXISTS (SELECT 1 FROM products_categories WHERE _parent_id = $1)
`, [de_id, en_id]);
// Move images (rels) from EN to DE if DE has none
await q(`
UPDATE products_rels SET parent = $1
WHERE parent = $2
AND NOT EXISTS (SELECT 1 FROM products_rels WHERE parent = $1)
`, [de_id, en_id]);
// Copy featuredImage if DE is missing one
await q(`
UPDATE products SET featured_image_id = (
SELECT featured_image_id FROM products WHERE id = $2
)
WHERE id = $1 AND featured_image_id IS NULL
`, [de_id, en_id]);
// Delete EN locale entry and EN product row
await q(`DELETE FROM products_locales WHERE _parent_id = $1`, [en_id]);
await q(`DELETE FROM _products_v WHERE parent = $1`, [en_id]);
await q(`DELETE FROM products WHERE id = $1`, [en_id]);
console.log(`${slug}`);
}
const [{ count }] = await q(`SELECT count(*) FROM products`);
console.log(`Products remaining: ${count}`);
}
async function mergePosts() {
console.log('\n── POSTS ──────────────────────────────────────────');
const pairs = await q<{ de_id: number; en_id: number; slug: string }>(`
SELECT
de.id as de_id,
en.id as en_id,
de_loc.slug as slug
FROM posts de
JOIN posts_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de'
JOIN posts_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en'
JOIN posts en ON en.id = en_loc._parent_id
WHERE de.id != en.id
`);
console.log(`Found ${pairs.length} DE/EN post pairs to merge`);
for (const { de_id, en_id, slug } of pairs) {
console.log(` Merging post: ${slug} (DE id=${de_id} ← EN id=${en_id})`);
const [enData] = await q(`
SELECT * FROM posts_locales WHERE _parent_id = $1 AND _locale = 'en'
`, [en_id]);
if (enData) {
await q(`
INSERT INTO posts_locales (title, slug, excerpt, category, content, _locale, _parent_id)
VALUES ($1, $2, $3, $4, $5, 'en', $6)
ON CONFLICT (_locale, _parent_id) DO UPDATE
SET title = EXCLUDED.title, slug = EXCLUDED.slug,
excerpt = EXCLUDED.excerpt, category = EXCLUDED.category,
content = EXCLUDED.content
`, [enData.title, enData.slug, enData.excerpt, enData.category, enData.content, de_id]);
}
// Copy featuredImage/date from EN if DE is missing
await q(`
UPDATE posts SET
featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM posts WHERE id = $2)),
date = COALESCE(date, (SELECT date FROM posts WHERE id = $2))
WHERE id = $1
`, [de_id, en_id]);
await q(`DELETE FROM posts_locales WHERE _parent_id = $1`, [en_id]);
await q(`DELETE FROM _posts_v WHERE parent = $1`, [en_id]);
await q(`DELETE FROM posts WHERE id = $1`, [en_id]);
console.log(`${slug}`);
}
const [{ count }] = await q(`SELECT count(*) FROM posts`);
console.log(`Posts remaining: ${count}`);
}
// DE slug → EN slug mapping for pages
const PAGE_SLUG_MAP: Record<string, string> = {
impressum: 'legal-notice',
datenschutz: 'privacy-policy',
agbs: 'terms',
kontakt: 'contact',
produkte: 'products',
blog: 'blog',
team: 'team',
start: 'start',
danke: 'thanks',
};
async function mergePages() {
console.log('\n── PAGES ──────────────────────────────────────────');
for (const [deSlug, enSlug] of Object.entries(PAGE_SLUG_MAP)) {
const [dePage] = await q<{ id: number }>(`
SELECT p.id FROM pages p
JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'de' AND pl.slug = $1
LIMIT 1
`, [deSlug]);
const [enPage] = await q<{ id: number }>(`
SELECT p.id FROM pages p
JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'en' AND pl.slug = $1
LIMIT 1
`, [enSlug]);
if (!dePage && !enPage) {
console.log(` ⚠ No page found for ${deSlug}/${enSlug} — skipping`);
continue;
}
if (!dePage) {
console.log(` ⚠ No DE page for "${deSlug}" — EN-only page id=${enPage!.id} kept`);
continue;
}
if (!enPage) {
console.log(` ⚠ No EN page for "${enSlug}" — DE-only page id=${dePage.id} kept`);
continue;
}
if (dePage.id === enPage.id) {
console.log(`${deSlug}/${enSlug} already merged (id=${dePage.id})`);
continue;
}
console.log(` Merging: ${deSlug}${enSlug} (DE id=${dePage.id} ← EN id=${enPage.id})`);
const [enData] = await q(`
SELECT * FROM pages_locales WHERE _parent_id = $1 AND _locale = 'en'
`, [enPage.id]);
if (enData) {
await q(`
INSERT INTO pages_locales (title, slug, excerpt, content, _locale, _parent_id)
VALUES ($1, $2, $3, $4, 'en', $5)
ON CONFLICT (_locale, _parent_id) DO UPDATE
SET title = EXCLUDED.title, slug = EXCLUDED.slug,
excerpt = EXCLUDED.excerpt, content = EXCLUDED.content
`, [enData.title, enData.slug, enData.excerpt, enData.content, dePage.id]);
}
// Copy featuredImage/layout from EN if DE is missing
await q(`
UPDATE pages SET
featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM pages WHERE id = $2)),
layout = COALESCE(layout, (SELECT layout FROM pages WHERE id = $2))
WHERE id = $1
`, [dePage.id, enPage.id]);
await q(`DELETE FROM pages_locales WHERE _parent_id = $1`, [enPage.id]);
await q(`DELETE FROM _pages_v WHERE parent = $1`, [enPage.id]);
await q(`DELETE FROM pages WHERE id = $1`, [enPage.id]);
console.log(`${deSlug}/${enSlug}`);
}
const [{ count }] = await q(`SELECT count(*) FROM pages`);
console.log(`Pages remaining: ${count}`);
}
async function main() {
console.log('🔀 Merging duplicate locale documents into native Payload localization...');
try {
await mergeProducts();
await mergePosts();
await mergePages();
console.log('\n── Final pages state ──────────────────────────────');
const pages = await q(`
SELECT p.id, pl._locale, pl.slug, pl.title FROM pages p
JOIN pages_locales pl ON pl._parent_id = p.id
ORDER BY p.id, pl._locale
`);
pages.forEach((r) => console.log(` [id=${r.id}] ${r._locale}: ${r.slug}${r.title}`));
console.log('\n✅ Done!');
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});

205
scripts/merge-umami-data.ts Normal file
View File

@@ -0,0 +1,205 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CSV_PATHS = [
'/Users/marcmintel/Downloads/pages.csv',
'/Users/marcmintel/Downloads/pages(1).csv',
'/Users/marcmintel/Downloads/pages(2).csv',
];
const JSON_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-merged.json');
const SQL_OUTPUT_PATH = path.join(__dirname, '../data/umami-import-new.sql');
const WEBSITE_ID = '59a7db94-0100-4c7e-98ef-99f45b17f9c3';
const HOSTNAME = 'klz-cables.com';
function parseCSV(content: string) {
const lines = content.split('\n');
if (lines.length === 0) return [];
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
const data = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
// Simple CSV parser that handles quotes
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < lines[i].length; j++) {
const char = lines[i][j];
if (char === '"') inQuotes = !inQuotes;
else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
const row: any = {};
headers.forEach((header, index) => {
row[header] = values[index]?.replace(/^"|"$/g, '');
});
data.push(row);
}
return data;
}
function normalizeURL(url: string) {
if (!url) return '/';
if (url.startsWith('http')) {
try {
return new URL(url).pathname;
} catch {
return url;
}
}
return url.startsWith('/') ? url : `/${url}`;
}
async function mergeData() {
console.log('Reading CSVs...');
const aggregatedData: Record<string, { views: number; visitors: number; title: string }> = {};
for (const csvPath of CSV_PATHS) {
if (!fs.existsSync(csvPath)) {
console.warn(`File not found: ${csvPath}`);
continue;
}
const csvContent = fs.readFileSync(csvPath, 'utf-8');
const csvData = parseCSV(csvContent);
for (const row of csvData) {
const url = normalizeURL(row.URL);
const views = parseInt(row.Views) || 0;
const visitors = parseInt(row.Visitors) || 0;
const title = row.Title || '';
if (!aggregatedData[url]) {
aggregatedData[url] = { views, visitors, title };
} else {
aggregatedData[url].views = Math.max(aggregatedData[url].views, views);
aggregatedData[url].visitors = Math.max(aggregatedData[url].visitors, visitors);
if (!aggregatedData[url].title && title) {
aggregatedData[url].title = title;
}
}
}
}
const jsonEvents = [];
const sqlStatements = [];
// Spread data across the whole period since early 2025 launch
const START_DATE = new Date('2025-01-01T08:00:00Z');
const END_DATE = new Date('2026-02-13T20:00:00Z');
const startTs = START_DATE.getTime();
const endTs = END_DATE.getTime();
const totalDays = Math.ceil((endTs - startTs) / (1000 * 60 * 60 * 24));
// Cleanup for the target period
sqlStatements.push(`-- Cleanup previous artificial imports (Full Year 2025 and 2026 until now)
DELETE FROM website_event WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59' AND hostname = '${HOSTNAME}';
DELETE FROM session WHERE website_id = '${WEBSITE_ID}' AND created_at >= '2025-01-01 00:00:00' AND created_at <= '2026-02-13 23:59:59';
`);
// Helper for weighted random date selection
function getRandomWeightedDate() {
while (true) {
const randomDays = Math.random() * totalDays;
const date = new Date(startTs + randomDays * 24 * 60 * 60 * 1000);
// 1. Growth Factor (0.2 at start to 1.0 at end)
const growthWeight = 0.2 + (randomDays / totalDays) * 0.8;
// 2. Weekend Factor (30% traffic on weekends)
const dayOfWeek = date.getDay();
const weekendWeight = dayOfWeek === 0 || dayOfWeek === 6 ? 0.3 : 1.0;
// 3. Seasonality (simple sine wave)
const month = date.getMonth();
const seasonWeight = 0.8 + Math.sin((month / 12) * Math.PI * 2) * 0.2;
// Combined weight
const combinedWeight = growthWeight * weekendWeight * seasonWeight;
// Pick based on weight
if (Math.random() < combinedWeight) {
// Return timestamp with random hour/minute
date.setHours(Math.floor(Math.random() * 12) + 8); // Business hours mostly
date.setMinutes(Math.floor(Math.random() * 60));
return date;
}
}
}
const urls = Object.keys(aggregatedData);
console.log(`Processing ${urls.length} aggregated URLs...`);
for (const url of urls) {
const { views, visitors, title } = aggregatedData[url];
if (views === 0) continue;
// We distribute views across visitors
const sessionData = [];
for (let v = 0; v < (visitors || 1); v++) {
const sessionId = crypto.randomUUID();
const visitId = crypto.randomUUID();
const sessionDate = getRandomWeightedDate();
const dateStr = sessionDate.toISOString().replace('T', ' ').split('.')[0];
sessionData.push({ sessionId, visitId, date: sessionDate });
sqlStatements.push(`INSERT INTO session (session_id, website_id, browser, os, device, screen, language, country, created_at)
VALUES ('${sessionId}', '${WEBSITE_ID}', 'Chrome', 'Windows', 'desktop', '1920x1080', 'en', 'DE', '${dateStr}')
ON CONFLICT (session_id) DO NOTHING;`);
}
// Distribute views across these sessions
for (let i = 0; i < views; i++) {
const sIdx = i % sessionData.length;
const session = sessionData[sIdx];
const sessionId = session.sessionId;
const visitId = session.visitId;
const eventId = crypto.randomUUID();
// Event date should be close to session date
const eventDate = new Date(session.date.getTime() + Math.random() * 1000 * 60 * 30); // within 30 mins
const timestamp = eventDate.toISOString();
const dateStr = timestamp.replace('T', ' ').split('.')[0];
// JSON Format
jsonEvents.push({
website_id: WEBSITE_ID,
hostname: HOSTNAME,
path: url,
referrer: '',
event_name: null,
pageview: true,
session: true,
duration: Math.floor(Math.random() * 120) + 10,
created_at: timestamp,
});
// SQL Format
sqlStatements.push(`INSERT INTO website_event (event_id, website_id, session_id, created_at, url_path, url_query, referrer_path, referrer_query, referrer_domain, page_title, event_type, event_name, visit_id, hostname)
VALUES ('${eventId}', '${WEBSITE_ID}', '${sessionId}', '${dateStr}', '${url}', '', '', '', '', '${title.replace(/'/g, "''")}', 1, NULL, '${visitId}', '${HOSTNAME}');`);
}
}
console.log(`Writing ${jsonEvents.length} events to ${JSON_OUTPUT_PATH}...`);
fs.writeFileSync(JSON_OUTPUT_PATH, JSON.stringify(jsonEvents, null, 2));
console.log(`Writing SQL statements to ${SQL_OUTPUT_PATH}...`);
fs.writeFileSync(SQL_OUTPUT_PATH, sqlStatements.join('\n'));
console.log('✅ Refined Restoration Script complete!');
}
mergeData().catch(console.error);

View File

@@ -0,0 +1,80 @@
import fs from 'fs/promises';
import path from 'path';
import axios from 'axios';
const CONTENT_DIR = path.join(process.cwd(), 'content');
const ASSETS_DIR = path.join(process.cwd(), 'public', 'media', 'visual-links');
async function downloadImage(url: string, filename: string): Promise<string> {
const filepath = path.join(ASSETS_DIR, filename);
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
await fs.writeFile(filepath, response.data);
return `/media/visual-links/${filename}`;
} catch (err: any) {
console.error(`Failed to download ${url}:`, err.message);
throw err;
}
}
async function processMdxFile(filepath: string) {
let content = await fs.readFile(filepath, 'utf-8');
let modified = false;
const linkPreviewRegex = /<Block type="visualLinkPreview" data={([^}]+)}} \/>/g;
let match;
let newContent = content;
// Need to correctly parse the JSON inside data={...}
// The regex above matches `<Block type="visualLinkPreview" data={{"url":"...","image":"..."}} />`
// Actually, we can just regex for "image":"https://..."
const imageRegex = /"image":\s*"([^"]+)"/g;
while ((match = linkPreviewRegex.exec(content)) !== null) {
const dataString = match[1] + '}';
try {
const data = JSON.parse(dataString);
if (data.image && data.image.startsWith('http')) {
const url = new URL(data.image);
let ext = path.extname(url.pathname);
if (!ext) ext = '.jpg'; // Fallback
const filename = `${data.id || Math.random().toString(36).substring(7)}${ext}`;
console.log(`Downloading ${data.image} to ${filename}...`);
const newUrl = await downloadImage(data.image, filename);
const oldImageString = `"image":"${data.image}"`;
const newImageString = `"image":"${newUrl}"`;
newContent = newContent.replace(oldImageString, newImageString);
modified = true;
}
} catch (err) {
console.warn(`Could not parse data string in ${filepath}:`, dataString);
}
}
if (modified) {
await fs.writeFile(filepath, newContent, 'utf-8');
console.log(`✅ Updated ${filepath}`);
}
}
async function walk(dir: string) {
const files = await fs.readdir(dir);
for (const file of files) {
const filepath = path.join(dir, file);
const stat = await fs.stat(filepath);
if (stat.isDirectory()) {
await walk(filepath);
} else if (file.endsWith('.mdx')) {
await processMdxFile(filepath);
}
}
}
async function main() {
await fs.mkdir(ASSETS_DIR, { recursive: true });
await walk(CONTENT_DIR);
}
main().catch(console.error);

View File

@@ -0,0 +1,213 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* PageSpeed Test Script
*
* 1. Fetches sitemap.xml from the target URL
* 2. Extracts all URLs
* 3. Runs Lighthouse CI on those URLs
*/
const targetUrl =
process.argv.find((arg) => !arg.startsWith('--') && arg.startsWith('http')) ||
process.env.NEXT_PUBLIC_BASE_URL ||
process.env.LHCI_URL ||
'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20; // Default limit to avoid infinite runs
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function main() {
console.log(`\n🚀 Starting PageSpeed test for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
// We might need to bypass gatekeeper for the sitemap fetch too
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
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.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
// Try to pick a variety: home, some products, some blog posts
const homeEN = urls.filter((u) => u.endsWith('/en') || u === targetUrl);
const homeDE = urls.filter((u) => u.endsWith('/de'));
const others = urls.filter((u) => !homeEN.includes(u) && !homeDE.includes(u));
urls = [...homeEN, ...homeDE, ...others.slice(0, limit - (homeEN.length + homeDE.length))];
}
console.log(`🧪 Pages to be tested:`);
urls.forEach((u) => console.log(` - ${u}`));
// 2. Prepare LHCI command
// We use --collect.url multiple times
const urlArgs = urls.map((u) => `--collect.url="${u}"`).join(' ');
// Handle authentication for staging/testing
// Lighthouse can set cookies via --collect.settings.extraHeaders
const extraHeaders = JSON.stringify({
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
});
// Detect Chrome path from Puppeteer installation if not provided
let chromePath = process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
if (!chromePath) {
try {
console.log('🔍 Attempting to detect Puppeteer Chrome path...');
const puppeteerInfo = execSync('npx puppeteer browsers latest chrome', {
encoding: 'utf8',
});
console.log(`📦 Puppeteer info: ${puppeteerInfo}`);
const match = puppeteerInfo.match(/executablePath: (.*)/);
if (match && match[1]) {
chromePath = match[1].trim();
console.log(`✅ Detected Puppeteer Chrome at: ${chromePath}`);
}
} catch (e: any) {
console.warn(`⚠️ Could not detect Puppeteer Chrome path via command: ${e.message}`);
}
// Fallback to known paths if still not found
if (!chromePath) {
const fallbacks = [
'/root/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
'/home/runner/.cache/puppeteer/chrome/linux-145.0.7632.77/chrome-linux64/chrome',
path.join(
process.cwd(),
'node_modules',
'.puppeteer',
'chrome',
'linux-145.0.7632.77',
'chrome-linux64',
'chrome',
),
];
for (const fallback of fallbacks) {
if (fs.existsSync(fallback)) {
chromePath = fallback;
console.log(`✅ Found Puppeteer Chrome at fallback: ${chromePath}`);
break;
}
}
}
} else {
console.log(` Using existing Chrome path: ${chromePath}`);
}
if (!chromePath) {
console.warn('❌ CHROME_PATH is still undefined. Lighthouse might fail.');
}
const chromePathArg = chromePath ? `--collect.chromePath="${chromePath}"` : '';
// Clean up old reports
if (fs.existsSync('.lighthouseci')) {
fs.rmSync('.lighthouseci', { recursive: true, force: true });
}
// Using a more robust way to execute and capture output
// We use a puppeteer script to set cookies which is more reliable than extraHeaders for LHCI
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.puppeteerScript="scripts/lhci-puppeteer-setup.js" --collect.settings.chromeFlags="--no-sandbox --disable-setuid-sandbox --disable-dev-shm-usage" && npx lhci assert --config=config/lighthouserc.json`;
console.log(`💻 Executing LHCI with CHROME_PATH="${chromePath}" and Puppeteer Auth...`);
try {
execSync(lhciCommand, {
encoding: 'utf8',
stdio: 'inherit',
env: { ...process.env, CHROME_PATH: chromePath },
});
} catch (err: any) {
console.warn('⚠️ LHCI assertion finished with warnings or errors.');
// We continue to show the table even if assertions failed
}
// 3. Summarize Results (Local & Independent)
const manifestPath = path.join(process.cwd(), '.lighthouseci', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
console.log(`\n📊 PageSpeed Summary (FOSS - Local Report):\n`);
const summaryTable = manifest.map((entry: any) => {
const s = entry.summary;
return {
URL: entry.url.replace(targetUrl, ''),
Perf: Math.round(s.performance * 100),
Acc: Math.round(s.accessibility * 100),
BP: Math.round(s['best-practices'] * 100),
SEO: Math.round(s.seo * 100),
};
});
console.table(summaryTable);
// Calculate Average
const avg = {
Perf: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Perf, 0) / summaryTable.length,
),
Acc: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.Acc, 0) / summaryTable.length,
),
BP: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.BP, 0) / summaryTable.length,
),
SEO: Math.round(
summaryTable.reduce((acc: any, curr: any) => acc + curr.SEO, 0) / summaryTable.length,
),
};
console.log(`\n📈 Average Scores:`);
console.log(` Performance: ${avg.Perf > 90 ? '✅' : '⚠️'} ${avg.Perf}`);
console.log(` Accessibility: ${avg.Acc > 90 ? '✅' : '⚠️'} ${avg.Acc}`);
console.log(` Best Practices: ${avg.BP > 90 ? '✅' : '⚠️'} ${avg.BP}`);
console.log(` SEO: ${avg.SEO > 90 ? '✅' : '⚠️'} ${avg.SEO}`);
}
console.log(`\n✨ PageSpeed tests completed successfully!`);
} catch (error: any) {
console.error(`\n❌ Error during PageSpeed test:`);
if (axios.isAxiosError(error)) {
console.error(`Status: ${error.response?.status}`);
console.error(`StatusText: ${error.response?.statusText}`);
console.error(`URL: ${error.config?.url}`);
} else {
console.error(error.message);
}
process.exit(1);
}
}
main();

View File

@@ -3,7 +3,7 @@ set -e
# --- CONFIGURATION ---
# The registry domain and base path for @mintel packages
DOMAINS=("git.infra.mintel.me" "registry.infra.mintel.me")
DOMAINS=("git.infra.mintel.me" "registry.infra.mintel.me" "npm.infra.mintel.me")
REGISTRY_PATH="/api/packages/mmintel/npm"
# Package used for functional connectivity test (URL encoded)
TEST_PACKAGE_ENCODED="%40mintel%2Fmail"
@@ -23,9 +23,41 @@ CANDIDATES=(
WORKING_TOKEN=""
WORKING_DOMAIN=""
WORKING_PATH=""
# --- LOCAL DISCOVERY (for local dev) ---
if [ -z "${NPM_TOKEN}" ]; then
echo "🔍 Searching for tokens in local config files..."
LOCAL_CANDIDATES=(
"$HOME/.npmrc"
"$HOME/.pnpmrc"
"./.npmrc.local"
)
for FILE in "${LOCAL_CANDIDATES[@]}"; do
if [ -f "${FILE}" ]; then
echo " Checking ${FILE}..."
# Try to extract _authToken
EXTRACTED=$(grep "_authToken=" "${FILE}" | sed -e 's/.*_authToken=//' -e 's/^"//' -e 's/"$//' | head -n 1)
if [ -n "${EXTRACTED}" ]; then
echo " Found candidate in ${FILE}"
# Inject into the environment for the test loop below
export NPM_TOKEN="${EXTRACTED}"
break
fi
fi
done
fi
for DOMAIN in "${DOMAINS[@]}"; do
echo "🔍 Testing connectivity to ${DOMAIN}..."
# Determine the test path for this domain
if [ "${DOMAIN}" == "npm.infra.mintel.me" ]; then
CURRENT_PATH=""
else
CURRENT_PATH="${REGISTRY_PATH}"
fi
echo "🔍 Testing connectivity to ${DOMAIN}${CURRENT_PATH}..."
for VAR_NAME in "${CANDIDATES[@]}"; do
TOKEN_VAL="${!VAR_NAME}"
@@ -34,31 +66,46 @@ for DOMAIN in "${DOMAINS[@]}"; do
echo " Testing ${VAR_NAME} (len: ${#TOKEN_VAL})..."
# Method 1: Bearer
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${TOKEN_VAL}" "https://${DOMAIN}${REGISTRY_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${TOKEN_VAL}" "https://${DOMAIN}${CURRENT_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
if [ "$STATUS" == "200" ]; then
echo "${VAR_NAME} verified via Bearer on ${DOMAIN}"
WORKING_TOKEN="${TOKEN_VAL}"
WORKING_DOMAIN="${DOMAIN}"
WORKING_PATH="${CURRENT_PATH}"
break 2
fi
# Method 2: Basic (Token as user, empty password)
AUTH_BASIC=$(echo -n "${TOKEN_VAL}:" | base64)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic ${AUTH_BASIC}" "https://${DOMAIN}${REGISTRY_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic ${AUTH_BASIC}" "https://${DOMAIN}${CURRENT_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
if [ "$STATUS" == "200" ]; then
echo "${VAR_NAME} verified via Basic on ${DOMAIN}"
WORKING_TOKEN="${TOKEN_VAL}"
WORKING_DOMAIN="${DOMAIN}"
WORKING_PATH="${CURRENT_PATH}"
break 2
fi
# Method 3: Basic RAW (If already base64 encoded user:pass)
if [[ "${TOKEN_VAL}" =~ ^[A-Za-z0-9+/=]+$ ]] && [ ${#TOKEN_VAL} -gt 20 ]; then
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic ${TOKEN_VAL}" "https://${DOMAIN}${CURRENT_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
if [ "$STATUS" == "200" ]; then
echo "${VAR_NAME} verified via Basic RAW on ${DOMAIN}"
WORKING_TOKEN="${TOKEN_VAL}"
WORKING_DOMAIN="${DOMAIN}"
WORKING_PATH="${CURRENT_PATH}"
break 2
fi
fi
# Method 3: Basic (User 'token', Token as password - Gitea standard)
# Method 4: Basic (User 'token', Token as password - Gitea standard)
AUTH_GITEA=$(echo -n "token:${TOKEN_VAL}" | base64)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic ${AUTH_GITEA}" "https://${DOMAIN}${REGISTRY_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic ${AUTH_GITEA}" "https://${DOMAIN}${CURRENT_PATH}/${TEST_PACKAGE_ENCODED}" || echo "000")
if [ "$STATUS" == "200" ]; then
echo "${VAR_NAME} verified via Gitea-Token-Basic on ${DOMAIN}"
WORKING_TOKEN="${TOKEN_VAL}"
WORKING_DOMAIN="${DOMAIN}"
WORKING_PATH="${CURRENT_PATH}"
break 2
fi
done
@@ -70,6 +117,7 @@ if [ -z "${WORKING_TOKEN}" ]; then
echo "Falling back to NPM_TOKEN (unverified) and git.infra.mintel.me"
WORKING_TOKEN="${NPM_TOKEN}"
WORKING_DOMAIN="git.infra.mintel.me"
WORKING_PATH="${REGISTRY_PATH}"
else
echo "❌ CRITICAL: No usable token found and NPM_TOKEN is empty."
exit 1
@@ -78,22 +126,24 @@ fi
# --- .npmrc GENERATION ---
# We generate a .npmrc that matches the local pattern exactly to avoid resolution drift.
# Local pattern:
# @mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm
# //git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}
cat > .npmrc << EOF
@mintel:registry=https://${WORKING_DOMAIN}${REGISTRY_PATH}
//${WORKING_DOMAIN}${REGISTRY_PATH}/:_authToken=${WORKING_TOKEN}
//${WORKING_DOMAIN}${REGISTRY_PATH}/:always-auth=true
@mintel:registry=https://${WORKING_DOMAIN}${WORKING_PATH}
//${WORKING_DOMAIN}${WORKING_PATH}/:_authToken=${WORKING_TOKEN}
//${WORKING_DOMAIN}${WORKING_PATH}/:always-auth=true
EOF
# Add secondary domain if we found one working
for DOMAIN in "${DOMAINS[@]}"; do
if [ "${DOMAIN}" != "${WORKING_DOMAIN}" ]; then
# Use REGISTRY_PATH for git/registry, empty for npm
if [ "${DOMAIN}" == "npm.infra.mintel.me" ]; then
DPATH=""
else
DPATH="${REGISTRY_PATH}"
fi
cat >> .npmrc << EOF
//${DOMAIN}${REGISTRY_PATH}/:_authToken=${WORKING_TOKEN}
//${DOMAIN}${REGISTRY_PATH}/:always-auth=true
//${DOMAIN}${DPATH}/:_authToken=${WORKING_TOKEN}
//${DOMAIN}${DPATH}/:always-auth=true
EOF
fi
done

View File

@@ -0,0 +1,19 @@
const fs = require('fs');
const files = [
'/Users/marcmintel/Projects/klz-2026/components/Header.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Scribble.tsx',
'/Users/marcmintel/Projects/klz-2026/components/Lightbox.tsx'
];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
content = content.replace(/import { motion } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation } from 'framer-motion';");
content = content.replace(/import { motion, Variants } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, Variants } from 'framer-motion';");
content = content.replace(/import { motion, AnimatePresence } from 'framer-motion';/g, "import { m, LazyMotion, domAnimation, AnimatePresence } from 'framer-motion';");
content = content.replace(/<motion\./g, '<m.');
content = content.replace(/<\/motion\./g, '</m.');
fs.writeFileSync(file, content);
}
console.log('Replaced motion with m in ' + files.length + ' files');

View File

@@ -1,7 +1,7 @@
import puppeteer from 'puppeteer';
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'etib2026';
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function run() {
console.log(`🚀 Smoke Test: ${targetUrl}`);
@@ -27,14 +27,14 @@ async function run() {
]);
}
// 2. Check Key Pages
// 2. Check Key Pages & Assets
const pages = ['/', '/de/kontakt', '/en/contact', '/de/blog'];
for (const p of pages) {
console.log(`📄 Checking ${p}...`);
const res = await page.goto(`${targetUrl}${p}`, { waitUntil: 'domcontentloaded' });
if (res?.status() !== 200) throw new Error(`Page ${p} returned ${res?.status()}`);
// Check for broken images
// Check for broken images (using in-page fetch to preserve session and avoid heavy navigation)
const images = await page.$$eval('img', (imgs) => imgs.map((img) => img.src).filter(Boolean));
console.log(` Checking ${images.length} images...`);
@@ -46,7 +46,17 @@ async function run() {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.status >= 400) {
broken.push(`${url} (Status: ${res.status})`);
// Diagnostic: check if the raw source is available
const rawUrlMatch = url.match(/[?&]url=([^&]+)/);
if (rawUrlMatch) {
const rawPath = decodeURIComponent(rawUrlMatch[1]);
const baseUrl = new URL(url).origin;
const rawUrl = `${baseUrl}${rawPath}`;
const rawRes = await fetch(rawUrl, { method: 'HEAD' });
broken.push(`${url} (Status: ${res.status}, Raw Status: ${rawRes.status})`);
} else {
broken.push(`${url} (Status: ${res.status})`);
}
}
} catch (e) {
broken.push(`${url} (Fetch failed)`);
@@ -57,10 +67,32 @@ async function run() {
}, images);
if (brokenImages.length > 0) {
console.warn(`⚠️ Found ${brokenImages.length} broken images on ${p}`);
throw new Error(
`Found ${brokenImages.length} broken images on ${p}:\n${brokenImages.join('\n')}`,
);
}
}
// 3. Test Contact Form
console.log('📝 Testing Contact Form...');
await page.goto(`${targetUrl}/de/kontakt`, { waitUntil: 'networkidle2' });
await page.type('input[name="name"]', 'Smoke Test');
await page.type('input[name="email"]', 'smoke@mintel.me');
await page.type('textarea[name="message"]', 'Automated smoke test submission.');
await Promise.all([
page.waitForSelector('[role="alert"]', { timeout: 10000 }),
page.click('button[type="submit"]'),
]);
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(`🔔 Alert: ${alertText}`);
if (alertText?.toLowerCase().includes('error') || alertText?.toLowerCase().includes('fehler')) {
throw new Error(`Form submission failed: ${alertText}`);
}
console.log('✅ Smoke test passed!');
process.exit(0);
} catch (err: any) {

View File

@@ -0,0 +1,18 @@
ALTER TABLE _products_v DROP COLUMN IF EXISTS version_title;
ALTER TABLE _products_v DROP COLUMN IF EXISTS version_description;
ALTER TABLE _products_v DROP COLUMN IF EXISTS version_locale;
ALTER TABLE _products_v DROP COLUMN IF EXISTS version_application;
ALTER TABLE _products_v DROP COLUMN IF EXISTS version_content;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_title;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_slug;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_excerpt;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_locale;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_category;
ALTER TABLE _posts_v DROP COLUMN IF EXISTS version_content;
ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_title;
ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_slug;
ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_locale;
ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_excerpt;
ALTER TABLE _pages_v DROP COLUMN IF EXISTS version_content;

17
scripts/validate-env.ts Normal file
View File

@@ -0,0 +1,17 @@
import { envSchema, getRawEnv } from '../lib/env';
/**
* Simple script to validate environment variables.
* If validation fails, this script will exit with code 1.
*/
try {
const env = envSchema.parse(getRawEnv());
console.log('✅ Environment variables validated successfully.');
console.log('Base URL:', env.NEXT_PUBLIC_BASE_URL);
} catch (error) {
console.error('❌ Environment validation failed.');
if (error instanceof Error) {
console.error(error.message);
}
process.exit(1);
}

187
scripts/wcag-sitemap.ts Normal file
View File

@@ -0,0 +1,187 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
/**
* WCAG Audit Script
*
* 1. Fetches sitemap.xml from the target URL
* 2. Extracts all URLs
* 3. Runs pa11y-ci on those URLs
*/
const targetUrl = process.argv[2] || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
const limit = process.env.PAGESPEED_LIMIT ? parseInt(process.env.PAGESPEED_LIMIT) : 20;
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || 'klz2026';
async function main() {
console.log(`\n🚀 Starting WCAG Audit for: ${targetUrl}`);
console.log(`📊 Limit: ${limit} pages\n`);
try {
// 1. Fetch Sitemap
const sitemapUrl = `${targetUrl.replace(/\/$/, '')}/sitemap.xml`;
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
const response = await axios.get(sitemapUrl, {
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
},
validateStatus: (status) => status < 400,
});
const $ = cheerio.load(response.data, { xmlMode: true });
let urls = $('url loc')
.map((i, el) => $(el).text())
.get();
// Cleanup, filter and normalize domains to targetUrl
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.`);
if (urls.length === 0) {
console.error('❌ No URLs found in sitemap. Is the site up?');
process.exit(1);
}
if (urls.length > limit) {
console.log(
`⚠️ Too many pages (${urls.length}). Limiting to ${limit} representative pages.`,
);
const home = urls.filter((u) => u.endsWith('/de') || u.endsWith('/en') || u === targetUrl);
const others = urls.filter((u) => !home.includes(u));
urls = [...home, ...others.slice(0, limit - home.length)];
}
console.log(`🧪 Pages to be tested:`);
urls.forEach((u) => console.log(` - ${u}`));
// 2. Prepare pa11y-ci config
const baseConfigPath = path.join(process.cwd(), '.pa11yci.json');
let baseConfig: any = { defaults: {} };
if (fs.existsSync(baseConfigPath)) {
baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, 'utf8'));
}
// Extract domain for cookie
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
// Update config with discovered URLs and gatekeeper cookie
const tempConfig = {
...baseConfig,
defaults: {
...baseConfig.defaults,
threshold: 0, // Force threshold to 0 so all errors are shown in JSON
runners: ['axe'],
ignore: [...(baseConfig.defaults?.ignore || []), 'color-contrast'],
chromeLaunchConfig: {
...baseConfig.defaults?.chromeLaunchConfig,
...(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {}),
args: [
...(baseConfig.defaults?.chromeLaunchConfig?.args || []),
'--no-sandbox',
'--disable-setuid-sandbox',
],
},
headers: {
Cookie: `klz_gatekeeper_session=${gatekeeperPassword}`,
},
timeout: 60000, // Increase timeout for slower pages
},
urls: urls,
};
// Create output directory
const outputDir = path.join(process.cwd(), '.pa11yci');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const tempConfigPath = path.join(outputDir, 'config.temp.json');
const reportPath = path.join(outputDir, 'report.json');
fs.writeFileSync(tempConfigPath, JSON.stringify(tempConfig, null, 2));
// 3. Execute pa11y-ci
console.log(`\n💻 Executing pa11y-ci...`);
const pa11yCommand = `npx pa11y-ci --config .pa11yci/config.temp.json --reporter json > .pa11yci/report.json`;
try {
execSync(pa11yCommand, {
encoding: 'utf8',
stdio: 'inherit',
});
} catch (err: any) {
// pa11y-ci exits with non-zero if issues are found, which is expected
}
// 4. Summarize Results
if (fs.existsSync(reportPath)) {
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
console.log(`\n📊 WCAG Audit Summary:\n`);
const summaryTable = Object.keys(reportData.results).map((url) => {
const results = reportData.results[url];
// Results might have errors or just a top level message if it crashed
let errors = 0;
let warnings = 0;
let notices = 0;
if (Array.isArray(results)) {
// pa11y action execution errors come as objects with a message but no type
const actionErrors = results.filter((r: any) => !r.type && r.message).length;
errors = results.filter((r: any) => r.type === 'error').length + actionErrors;
warnings = results.filter((r: any) => r.type === 'warning').length;
notices = results.filter((r: any) => r.type === 'notice').length;
}
// Clean URL for display
const displayUrl = url.replace(targetUrl, '') || '/';
return {
URL: displayUrl.length > 50 ? displayUrl.substring(0, 47) + '...' : displayUrl,
Errors: errors,
Warnings: warnings,
Notices: notices,
Status: errors === 0 ? '✅' : '❌',
};
});
console.table(summaryTable);
const totalErrors = summaryTable.reduce((acc, curr) => acc + curr.Errors, 0);
const totalPages = summaryTable.length;
const cleanPages = summaryTable.filter((p) => p.Errors === 0).length;
console.log(`\n📈 Result: ${cleanPages}/${totalPages} pages are error-free.`);
if (totalErrors > 0) {
console.log(` Total Errors discovered: ${totalErrors}`);
process.exitCode = 1;
}
}
console.log(`\n✨ WCAG Audit completed!`);
} catch (error: any) {
console.error(`\n❌ Error during WCAG Audit:`);
if (axios.isAxiosError(error)) {
console.error(`Status: ${error.response?.status}`);
console.error(`URL: ${error.config?.url}`);
} else {
console.error(error.message);
}
process.exit(1);
} finally {
// Clean up temp config file, keep report
const tempConfigPath = path.join(process.cwd(), '.pa11yci/config.temp.json');
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
}
}
main();