feat: optimize performance and SEO, integrate Lighthouse CI
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 1m53s
Build & Deploy / 🏗️ Build (push) Successful in 7m44s
Build & Deploy / 🚀 Deploy (push) Successful in 33s
Build & Deploy / 🧪 Smoke Test (push) Successful in 59s
Build & Deploy / ⚡ Lighthouse (push) Successful in 3m14s
Build & Deploy / 🔔 Notify (push) Successful in 1s

- Integrated imgproxy for centralized image optimization
- Implemented Lighthouse CI in Gitea pipeline with native Chromium
- Reached 100/100 SEO score by fixing canonicals, hreflang, and link text
- Optimized LCP by forcing Hero component visibility until hydration
- Decoupled analytics into an async shell to reduce TTI
This commit is contained in:
2026-02-18 10:01:00 +01:00
parent 374fcc9689
commit df2dd23206
24 changed files with 413 additions and 56 deletions

View File

@@ -406,11 +406,79 @@ jobs:
run: pnpm run check:og run: pnpm run check:og
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications # JOB 6: Lighthouse (Performance & Accessibility)
# ──────────────────────────────────────────────────────────────────────────────
lighthouse:
name: ⚡ Lighthouse
needs: [prepare, deploy]
if: success() && needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: 🔐 Registry Auth
run: |
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔍 Install Chromium (Native & ARM64)
run: |
apt-get update
apt-get install -y gnupg wget ca-certificates
# Detect OS
OS_ID=$(. /etc/os-release && echo $ID)
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
if [ "$OS_ID" = "debian" ]; then
echo "🎯 Debian detected - installing native chromium"
apt-get install -y chromium
else
echo "🎯 Ubuntu detected - adding xtradeb PPA"
mkdir -p /etc/apt/keyrings
KEY_ID="82BB6851C64F6880"
# Fetch PPA key
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
# Add PPA repository
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
# PRIORITY PINNING: Force PPA over Snap-dummy
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
apt-get update
apt-get install -y --allow-downgrades chromium
fi
# Standardize binary paths
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
- name: ⚡ Run Lighthouse CI
env:
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
CHROME_PATH: /usr/bin/chromium
PAGESPEED_LIMIT: 8
run: pnpm run pagespeed:test
# ──────────────────────────────────────────────────────────────────────────────
# JOB 7: Notifications
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
notifications: notifications:
name: 🔔 Notify name: 🔔 Notify
needs: [prepare, deploy, smoke_test] needs: [prepare, deploy, smoke_test, lighthouse]
if: always() if: always()
runs-on: docker runs-on: docker
container: container:

5
.gitignore vendored
View File

@@ -2,6 +2,11 @@ node_modules
.next .next
.DS_Store .DS_Store
# Lighthouse CI
.lighthouseci/
lighthouserc.cjs
.lighthouserc.json
# Directus # Directus
directus/uploads directus/uploads
!directus/extensions/ !directus/extensions/

View File

@@ -5,6 +5,7 @@ WORKDIR /app
# Arguments for build-time configuration # Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET ARG NEXT_PUBLIC_TARGET
ARG NEXT_PUBLIC_IMGPROXY_URL
ARG DIRECTUS_URL ARG DIRECTUS_URL
ARG UMAMI_WEBSITE_ID ARG UMAMI_WEBSITE_ID
ARG UMAMI_API_ENDPOINT ARG UMAMI_API_ENDPOINT
@@ -13,6 +14,7 @@ ARG NPM_TOKEN
# Environment variables for Next.js build # Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV NEXT_PUBLIC_IMGPROXY_URL=$NEXT_PUBLIC_IMGPROXY_URL
ENV DIRECTUS_URL=$DIRECTUS_URL ENV DIRECTUS_URL=$DIRECTUS_URL
ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT

View File

@@ -32,11 +32,6 @@ export async function generateMetadata({ params }: BlogPostProps): Promise<Metad
description: description, description: description,
alternates: { alternates: {
canonical: `${SITE_URL}/${locale}/blog/${slug}`, canonical: `${SITE_URL}/${locale}/blog/${slug}`,
languages: {
de: `${SITE_URL}/de/blog/${slug}`,
en: `${SITE_URL}/en/blog/${slug}`,
'x-default': `${SITE_URL}/en/blog/${slug}`,
},
}, },
openGraph: { openGraph: {
title: `${post.frontmatter.title} | KLZ Cables`, title: `${post.frontmatter.title} | KLZ Cables`,

View File

@@ -1,16 +1,16 @@
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import Header from '@/components/Header'; import Header from '@/components/Header';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import AnalyticsProvider from '@/components/analytics/AnalyticsProvider';
import ScrollDepthTracker from '@/components/analytics/ScrollDepthTracker';
import SkipLink from '@/components/SkipLink'; import SkipLink from '@/components/SkipLink';
import CMSConnectivityNotice from '@/components/CMSConnectivityNotice'; import CMSConnectivityNotice from '@/components/CMSConnectivityNotice';
import { RecordModeProvider } from '@/components/record-mode/RecordModeContext'; import { RecordModeProvider } from '@/components/record-mode/RecordModeContext';
import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals'; import { RecordModeVisuals } from '@/components/record-mode/RecordModeVisuals';
import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator'; import { ToolCoordinator } from '@/components/record-mode/ToolCoordinator';
import AnalyticsShell from '@/components/analytics/AnalyticsShell';
import { Metadata, Viewport } from 'next'; import { Metadata, Viewport } from 'next';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import dynamic from 'next/dynamic';
import { Suspense } from 'react'; import { Suspense } from 'react';
import '../../styles/globals.css'; import '../../styles/globals.css';
import { SITE_URL } from '@/lib/schema'; import { SITE_URL } from '@/lib/schema';
@@ -26,6 +26,13 @@ const inter = Inter({
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
alternates: {
canonical: '/',
languages: {
de: '/de',
en: '/en',
},
},
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.ico', sizes: 'any' }, { url: '/favicon.ico', sizes: 'any' },
@@ -116,10 +123,7 @@ export default async function Layout(props: {
<CMSConnectivityNotice /> <CMSConnectivityNotice />
<Suspense fallback={null}> <AnalyticsShell />
<AnalyticsProvider />
<ScrollDepthTracker />
</Suspense>
<ToolCoordinator feedbackEnabled={feedbackEnabled} /> <ToolCoordinator feedbackEnabled={feedbackEnabled} />
</RecordModeProvider> </RecordModeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>

View File

@@ -79,7 +79,9 @@ export async function generateMetadata({
} }
const title = t('title') || 'KLZ Cables'; const title = t('title') || 'KLZ Cables';
const description = t('description') || ''; const description =
t('description') ||
'Ihr Experte für hochwertige Stromkabel, Mittelspannungslösungen und Solarkabel. Zuverlässige Infrastruktur für eine grüne Energiezukunft.';
return { return {
title, title,

View File

@@ -0,0 +1,20 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
const DynamicAnalyticsProvider = dynamic(() => import('./AnalyticsProvider'), {
ssr: false,
});
const DynamicScrollDepthTracker = dynamic(() => import('./ScrollDepthTracker'), {
ssr: false,
});
export default function AnalyticsShell() {
return (
<Suspense fallback={null}>
<DynamicAnalyticsProvider />
<DynamicScrollDepthTracker />
</Suspense>
);
}

View File

@@ -49,6 +49,7 @@ export default function GallerySection() {
fill fill
className="object-cover transition-transform duration-1000 group-hover:scale-110" className="object-cover transition-transform duration-1000 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading="lazy"
/> />
<div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" /> <div className="absolute inset-0 bg-primary-dark/20 group-hover:bg-transparent transition-all duration-500" />
<div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" /> <div className="absolute inset-0 border-0 group-hover:border-[16px] border-white/10 transition-all duration-500 pointer-events-none" />

View File

@@ -130,19 +130,19 @@ const containerVariants = {
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.12, staggerChildren: 0.1,
delayChildren: 0.4, delayChildren: 0.1,
}, },
}, },
} as const; } as const;
const headingVariants = { const headingVariants = {
hidden: { opacity: 0, y: 60, scale: 0.85 }, hidden: { opacity: 1, y: 30, scale: 0.95 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,
scale: 1, scale: 1,
transition: { duration: 1.2, ease: [0.25, 0.46, 0.45, 0.94] }, transition: { duration: 0.8, ease: [0.25, 0.46, 0.45, 0.94] },
}, },
} as const; } as const;

View File

@@ -44,6 +44,7 @@ export default async function RecentPosts({ locale }: RecentPostsProps) {
fill fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
sizes="(max-width: 768px) 100vw, 33vw" sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/> />
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && ( {post.frontmatter.category && (

52
config/lighthouserc.json Normal file
View File

@@ -0,0 +1,52 @@
{
"ci": {
"collect": {
"numberOfRuns": 1,
"settings": {
"preset": "desktop",
"onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
}
},
"assert": {
"assertions": {
"categories:performance": [
"error",
{
"minScore": 0.8
}
],
"categories:accessibility": [
"error",
{
"minScore": 0.9
}
],
"categories:best-practices": [
"error",
{
"minScore": 0.9
}
],
"categories:seo": [
"error",
{
"minScore": 0.9
}
],
"first-contentful-paint": [
"warn",
{
"maxNumericValue": 2000
}
],
"interactive": [
"warn",
{
"maxNumericValue": 3500
}
]
}
}
}
}

View File

@@ -4,6 +4,7 @@ date: '2025-03-31T12:00:34'
featuredImage: /uploads/2025/02/image_fx_-6.webp featuredImage: /uploads/2025/02/image_fx_-6.webp
locale: de locale: de
category: Kabel Technologie category: Kabel Technologie
excerpt: Die Energiewende braucht leistungsfähige Netze. Erfahren Sie, warum Investitionen in die Kabelinfrastruktur der Schlüssel zu 100 % erneuerbarer Energie sind.
--- ---
# 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur! # 100 % erneuerbare Energie? Nur mit der richtigen Kabelinfrastruktur!
Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält. Die Vision ist klar: Ein Europa, das seinen Strom zu 100 % aus erneuerbaren Energien gewinnt. Doch während Solar- und Windparks boomen, hinkt der Ausbau der Stromnetze hinterher. Die Ursache? Eine Infrastruktur, die für fossile Kraftwerke gebaut wurde und mit den neuen Anforderungen nicht Schritt hält.

View File

@@ -5,6 +5,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL} NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}
NEXT_PUBLIC_IMGPROXY_URL: ${NEXT_PUBLIC_IMGPROXY_URL}
DIRECTUS_URL: ${DIRECTUS_URL} DIRECTUS_URL: ${DIRECTUS_URL}
image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest} image: registry.infra.mintel.me/mintel/klz-cables.com:${IMAGE_TAG:-latest}
restart: unless-stopped restart: unless-stopped
@@ -152,6 +153,33 @@ services:
networks: networks:
- default - default
klz-imgproxy:
image: darthsim/imgproxy:latest
restart: unless-stopped
networks:
- default
- infra
extra_hosts:
- "klz.localhost:host-gateway"
- "cms.klz.localhost:host-gateway"
- "host.docker.internal:host-gateway"
environment:
IMGPROXY_URL_MAPPING: "http://klz.localhost/:http://klz-app:3000/,http://cms.klz.localhost/:http://klz-cms:8055/"
IMGPROXY_USE_ETAG: "true"
IMGPROXY_MAX_SRC_RESOLUTION: 20
IMGPROXY_ALLOWED_NETWORKS: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
IMGPROXY_IGNORE_SSL_ERRORS: "true"
IMGPROXY_DEBUG: "true"
labels:
- "traefik.enable=true"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.rule=Host(`img.${TRAEFIK_HOST:-klz.localhost}`)"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.entrypoints=web"
- "traefik.http.routers.${PROJECT_NAME:-klz}-imgproxy.service=${PROJECT_NAME:-klz}-imgproxy-svc"
- "traefik.http.services.${PROJECT_NAME:-klz}-imgproxy-svc.loadbalancer.server.port=8080"
- "traefik.docker.network=infra"
- "caddy=http://img.${TRAEFIK_HOST:-klz.localhost}"
- "caddy.reverse_proxy={{upstreams 8080}}"
networks: networks:
default: default:
name: ${PROJECT_NAME:-klz-cables}-internal name: ${PROJECT_NAME:-klz-cables}-internal

27
lib/imgproxy-loader.ts Normal file
View File

@@ -0,0 +1,27 @@
import { getImgproxyUrl } from './imgproxy';
/**
* Next.js Image Loader for imgproxy
*
* @param {Object} props - properties from Next.js Image component
* @param {string} props.src - The source image URL
* @param {number} props.width - The desired image width
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
*/
export default function imgproxyLoader({
src,
width,
_quality,
}: {
src: string;
width: number;
_quality?: number;
}) {
// We use the width provided by Next.js for responsive images
// Height is set to 0 to maintain aspect ratio
return getImgproxyUrl(src, {
width,
resizing_type: 'fit',
gravity: 'fv', // Use face-aware focusing (face detection)
});
}

82
lib/imgproxy.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Generates an imgproxy URL for a given source image and options.
*
* Documentation: https://docs.imgproxy.net/usage/processing
*/
interface ImgproxyOptions {
width?: number;
height?: number;
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
gravity?: string;
enlarge?: boolean;
extension?: string;
}
/**
* Encodes a string to Base64 (URL-safe)
*/
function encodeBase64(str: string): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(str)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
} else {
// Fallback for browser environment if Buffer is not available
return window.btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
}
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
const baseUrl = process.env.NEXT_PUBLIC_IMGPROXY_URL || 'https://img.infra.mintel.me';
// If no imgproxy URL is configured, return the source as is
if (!baseUrl) return src;
// Handle local paths or relative URLs
let absoluteSrc = src;
if (src.startsWith('/')) {
const baseUrlForSrc =
process.env.NEXT_PUBLIC_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
if (baseUrlForSrc) {
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
}
}
// Development mapping: Map local domains to internal Docker hostnames
// so imgproxy can fetch images without SSL issues or external routing
if (process.env.NODE_ENV === 'development') {
if (absoluteSrc.includes('klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
} else if (absoluteSrc.includes('cms.klz.localhost')) {
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
}
// Also handle direct container names if needed
}
const {
width = 0,
height = 0,
resizing_type = 'fit',
gravity = 'sm', // Default to smart gravity
enlarge = false,
extension = '',
} = options;
// Processing options
// Format: /rs:<type>:<width>:<height>:<enlarge>/g:<gravity>
const processingOptions = [
`rs:${resizing_type}:${width}:${height}:${enlarge ? 1 : 0}`,
`g:${gravity}`,
].join('/');
// Using /unsafe/ for now as we don't handle signatures yet
// Format: <base_url>/unsafe/<options>/<base64_url>
const suffix = extension ? `@${extension}` : '';
const encodedSrc = encodeBase64(absoluteSrc + suffix);
return `${baseUrl}/unsafe/${processingOptions}/${encodedSrc}`;
}

View File

@@ -1,19 +0,0 @@
module.exports = {
ci: {
collect: {
numberOfRuns: 1,
settings: {
preset: 'desktop',
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
},
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.9 }],
'categories:accessibility': ['warn', { minScore: 0.9 }],
'categories:best-practices': ['warn', { minScore: 0.9 }],
'categories:seo': ['warn', { minScore: 0.9 }],
},
},
},
};

View File

@@ -58,7 +58,7 @@
} }
}, },
"Navigation": { "Navigation": {
"home": "Start", "home": "KLZ Cables Startseite",
"team": "Team", "team": "Team",
"products": "Produkte", "products": "Produkte",
"blog": "Blog", "blog": "Blog",
@@ -394,4 +394,4 @@
"cta": "Zurück zur Sicherheit" "cta": "Zurück zur Sicherheit"
} }
} }
} }

View File

@@ -59,7 +59,7 @@
}, },
"Navigation": { "Navigation": {
"menu": "Menu", "menu": "Menu",
"home": "Home", "home": "KLZ Cables Home",
"team": "Team", "team": "Team",
"products": "Products", "products": "Products",
"blog": "Blog", "blog": "Blog",

View File

@@ -1,5 +1,5 @@
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
// Create the internationalization middleware // Create the internationalization middleware
const intlMiddleware = createMiddleware({ const intlMiddleware = createMiddleware({
@@ -20,9 +20,10 @@ export default function middleware(request: NextRequest) {
pathname.startsWith('/errors') || pathname.startsWith('/errors') ||
pathname.startsWith('/health') || pathname.startsWith('/health') ||
pathname.includes('/api/og') || pathname.includes('/api/og') ||
pathname.includes('opengraph-image') pathname.includes('opengraph-image') ||
pathname.endsWith('sitemap.xml')
) { ) {
return; return NextResponse.next();
} }
// Build header object for logging // Build header object for logging
@@ -93,6 +94,6 @@ export default function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf)$).*)', '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)',
], ],
}; };

View File

@@ -317,17 +317,8 @@ const nextConfig = {
]; ];
}, },
images: { images: {
remotePatterns: [ loader: 'custom',
{ loaderFile: './lib/imgproxy-loader.ts',
protocol: 'https',
hostname: 'klz-cables.com',
port: '',
pathname: '/wp-content/uploads/**',
},
],
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
}, },
async rewrites() { async rewrites() {
const umamiUrl = const umamiUrl =

View File

@@ -63,6 +63,7 @@
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"@vitest/ui": "^4.0.16", "@vitest/ui": "^4.0.16",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"cheerio": "^1.2.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"happy-dom": "^20.6.1", "happy-dom": "^20.6.1",
"husky": "^9.1.7", "husky": "^9.1.7",
@@ -112,6 +113,7 @@
"cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing", "cms:push:testing:DANGER": "./scripts/sync-directus.sh push testing",
"cms:push:prod:DANGER": "./scripts/sync-directus.sh push production", "cms:push:prod:DANGER": "./scripts/sync-directus.sh push production",
"pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts", "pagespeed:test": "tsx ./scripts/pagespeed-sitemap.ts",
"pagespeed:audit": "./scripts/audit-local.sh",
"pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"", "pagespeed:urls": "tsx -e \"import sitemap from './app/sitemap'; sitemap().then(urls => console.log(urls.map(u => u.url).join('\\n')))\"",
"remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4", "remotion:render": "remotion render WebsiteVideo remotion/index.ts out.mp4",
"remotion:preview": "remotion preview remotion/index.ts", "remotion:preview": "remotion preview remotion/index.ts",

43
pnpm-lock.yaml generated
View File

@@ -186,6 +186,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.23 specifier: ^10.4.23
version: 10.4.24(postcss@8.5.6) version: 10.4.24(postcss@8.5.6)
cheerio:
specifier: ^1.2.0
version: 1.2.0
eslint: eslint:
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
@@ -3396,6 +3399,10 @@ packages:
resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'}
chokidar@3.6.0: chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'} engines: {node: '>= 8.10.0'}
@@ -3893,6 +3900,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1: env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4554,6 +4565,9 @@ packages:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
htmlparser2@8.0.2: htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -6955,6 +6969,10 @@ packages:
resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
undici@7.22.0:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
unicode-properties@1.4.1: unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
@@ -10712,6 +10730,20 @@ snapshots:
undici: 6.23.0 undici: 6.23.0
whatwg-mimetype: 4.0.0 whatwg-mimetype: 4.0.0
cheerio@1.2.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 10.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 7.22.0
whatwg-mimetype: 4.0.0
chokidar@3.6.0: chokidar@3.6.0:
dependencies: dependencies:
anymatch: 3.1.3 anymatch: 3.1.3
@@ -11211,6 +11243,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
entities@7.0.1: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
env-paths@3.0.0: {} env-paths@3.0.0: {}
@@ -12200,6 +12234,13 @@ snapshots:
css-line-break: 2.1.0 css-line-break: 2.1.0
text-segmentation: 1.0.3 text-segmentation: 1.0.3
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
htmlparser2@8.0.2: htmlparser2@8.0.2:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@@ -15077,6 +15118,8 @@ snapshots:
undici@6.23.0: {} undici@6.23.0: {}
undici@7.22.0: {}
unicode-properties@1.4.1: unicode-properties@1.4.1:
dependencies: dependencies:
base64-js: 1.5.1 base64-js: 1.5.1

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

@@ -0,0 +1,51 @@
#!/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 NEXT_PUBLIC_IMGPROXY_URL="http://img.klz.localhost"
export NEXT_URL="http://klz.localhost"
docker network create infra 2>/dev/null || true
docker volume create klz-cablescom_directus-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-cms klz-gatekeeper
# 3. Build and Start klz-app and klz-imgproxy 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 \
docker-compose -f docker-compose.yml up -d --build klz-app klz-imgproxy
# 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 "✨ Audit completed! Summary above."
echo "💡 You can stop the production app with: docker-compose stop klz-app"

View File

@@ -86,7 +86,7 @@ async function main() {
// Using a more robust way to execute and capture output // Using a more robust way to execute and capture output
// We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports // We remove 'npx lhci upload' to keep everything local and avoid Google-hosted reports
const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --collect.settings.chromeFlags='--no-sandbox --disable-setuid-sandbox' --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert`; const lhciCommand = `npx lhci collect ${urlArgs} ${chromePathArg} --config=config/lighthouserc.json --collect.settings.extraHeaders='${extraHeaders}' && npx lhci assert --config=config/lighthouserc.json`;
console.log(`💻 Executing LHCI...`); console.log(`💻 Executing LHCI...`);