diff --git a/.pa11yci.json b/.pa11yci.json index 1de1549e..2c34f03f 100644 --- a/.pa11yci.json +++ b/.pa11yci.json @@ -2,7 +2,7 @@ "defaults": { "standard": "WCAG2AA", "runners": ["axe", "htmlcs"], - "ignore": ["color-contrast"], + "ignore": [], "timeout": 50000, "wait": 1000, "chromeLaunchConfig": { diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index b2f0b050..396eacce 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -24,29 +24,37 @@ const inter = Inter({ variable: '--font-inter', }); -export const metadata: Metadata = { - metadataBase: new URL(SITE_URL), - alternates: { - canonical: '/', - languages: { - de: '/de', - en: '/en', +export async function generateMetadata(props: { + params: Promise<{ locale: string }>; +}): Promise { + const params = await props.params; + const { locale } = params; + + return { + metadataBase: new URL(SITE_URL), + manifest: '/manifest.webmanifest', + alternates: { + canonical: locale === 'en' ? '/' : `/${locale}`, + languages: { + de: '/de', + en: '/en', + }, }, - }, - icons: { - icon: [ - { url: '/favicon.ico', sizes: 'any' }, - { url: '/logo-blue.svg', type: 'image/svg+xml' }, - ], - apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], - }, -}; + icons: { + icon: [ + { url: '/favicon.ico', sizes: 'any' }, + { url: '/logo-blue.svg', type: 'image/svg+xml' }, + ], + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }], + }, + }; +} export const viewport: Viewport = { width: 'device-width', initialScale: 1, - maximumScale: 1, - userScalable: false, + maximumScale: 5, + userScalable: true, viewportFit: 'cover', themeColor: '#001a4d', }; diff --git a/components/Footer.tsx b/components/Footer.tsx index 940b4717..294c67b5 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -230,7 +230,7 @@ export default function Footer() {

{post.title}

- + {t('readArticle')} → @@ -240,7 +240,7 @@ export default function Footer() { -
+

{t('copyright', { year: currentYear })}

-
+
    {recentPosts.map((post) => ( - - - {post.frontmatter.featuredImage && ( -
    - {post.frontmatter.title} -
    - {post.frontmatter.category && ( - - {post.frontmatter.category} - - )} -
    - )} -
    -
    - - -
    -

    - {post.frontmatter.title} -

    -
    - {t('readMore')} -
    + {post.frontmatter.title} - +
    + {post.frontmatter.category && ( + + {post.frontmatter.category} + + )} +
    + )} +
    +
    + + +
    +

    + {post.frontmatter.title} +

    +
    + {t('readMore')} + +
    -
    - - + + + ))} -
    +
); diff --git a/components/home/WhyChooseUs.tsx b/components/home/WhyChooseUs.tsx index ecc8cb20..47475b0c 100644 --- a/components/home/WhyChooseUs.tsx +++ b/components/home/WhyChooseUs.tsx @@ -18,9 +18,9 @@ export default function WhyChooseUs() { {t('subtitle')}

-
+
    {[0, 1, 2, 3].map((i) => ( -
    +
  • {t(`features.${i}`)} -
    +
  • ))} -
    +
    diff --git a/lib/imgproxy-loader.ts b/lib/imgproxy-loader.ts index ab510b1d..61f6e6ab 100644 --- a/lib/imgproxy-loader.ts +++ b/lib/imgproxy-loader.ts @@ -17,6 +17,12 @@ export default function imgproxyLoader({ width: number; _quality?: number; }) { + // Skip imgproxy for SVGs as they are vectors and don't benefit from resizing, + // and often cause 404s if the source is not correctly resolvable by imgproxy. + if (src.toLowerCase().endsWith('.svg')) { + return src; + } + // We use the width provided by Next.js for responsive images // Height is set to 0 to maintain aspect ratio return getImgproxyUrl(src, { diff --git a/messages/de.json b/messages/de.json index 3df71c4f..b85f4428 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,12 +58,14 @@ } }, "Navigation": { + "menu": "Menü", "home": "KLZ Cables Startseite", "team": "Team", "products": "Produkte", "blog": "Blog", "contact": "Kontakt", - "toggleMenu": "Menü umschalten" + "toggleMenu": "Menü umschalten", + "skipToContent": "Zum Inhalt springen" }, "Footer": { "legal": "Rechtliches", diff --git a/middleware.ts b/middleware.ts index 6bc5bfd1..86737402 100644 --- a/middleware.ts +++ b/middleware.ts @@ -21,7 +21,8 @@ export default function middleware(request: NextRequest) { pathname.startsWith('/health') || pathname.includes('/api/og') || pathname.includes('opengraph-image') || - pathname.endsWith('sitemap.xml') + pathname.endsWith('sitemap.xml') || + pathname.endsWith('manifest.webmanifest') ) { return NextResponse.next(); } @@ -94,6 +95,6 @@ export default function middleware(request: NextRequest) { export const config = { matcher: [ - '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)', + '/((?!api|_next/static|_next/image|favicon.ico|manifest.webmanifest|.*\\.(?:svg|png|jpg|jpeg|gif|webp|pdf|txt|vcf|xml)$).*)', ], }; diff --git a/package.json b/package.json index 80c04b79..cd3f7b21 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "check:og": "tsx scripts/check-og-images.ts", "check:mdx": "node scripts/validate-mdx.mjs", "check:a11y": "start-server-and-test start http://localhost:3000 'pa11y-ci'", + "check:wcag": "tsx ./scripts/wcag-sitemap.ts", "cms:branding:local": "DIRECTUS_URL=${DIRECTUS_URL:-http://cms.klz.localhost} npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:testing": "DIRECTUS_URL=https://cms.testing.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts", "cms:branding:staging": "DIRECTUS_URL=https://cms.staging.klz-cables.com npx tsx --env-file=.env scripts/setup-directus-branding.ts", diff --git a/scripts/wcag-sitemap.ts b/scripts/wcag-sitemap.ts new file mode 100644 index 00000000..3e6cedb0 --- /dev/null +++ b/scripts/wcag-sitemap.ts @@ -0,0 +1,163 @@ +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 || 'https://testing.klz-cables.com'; +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, + actions: [ + `set cookie klz_gatekeeper_session=${gatekeeperPassword} domain=${domain} path=/`, + ...(baseConfig.defaults?.actions || []), + ], + timeout: 60000, // Increase timeout for slower pages + }, + urls: urls, + }; + + const tempConfigPath = path.join(process.cwd(), '.pa11yci.temp.json'); + const reportPath = path.join(process.cwd(), '.pa11yci-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.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]; + const errors = results.filter((r: any) => r.type === 'error').length; + const warnings = results.filter((r: any) => r.type === 'warning').length; + const 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}`); + } + } + + 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 files + ['.pa11yci.temp.json', '.pa11yci-report.json'].forEach((f) => { + const p = path.join(process.cwd(), f); + if (fs.existsSync(p)) fs.unlinkSync(p); + }); + } +} + +main();