diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fe1a9d90..ddad4898 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -203,7 +203,7 @@ jobs: - name: π³ Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: π Registry Login - run: echo "${{ secrets.REGISTRY_PASS }}" | docker login git.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin + run: echo "${{ secrets.NPM_TOKEN }}" | docker login git.infra.mintel.me -u "${{ github.repository_owner }}" --password-stdin - name: ποΈ Build and Push uses: docker/build-push-action@v5 with: @@ -363,7 +363,7 @@ jobs: scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml - ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login git.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin" + ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.NPM_TOKEN }}' | docker login git.infra.mintel.me -u '${{ github.repository_owner }}' --password-stdin" ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull" ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans" diff --git a/.gitea/workflows/qa.yml b/.gitea/workflows/qa.yml index 93669b6c..5ac3fd78 100644 --- a/.gitea/workflows/qa.yml +++ b/.gitea/workflows/qa.yml @@ -1,17 +1,233 @@ name: Nightly QA on: + push: + branches: [main] schedule: - cron: '0 3 * * *' workflow_dispatch: +env: + TARGET_URL: 'https://testing.klz-cables.com' + PROJECT_NAME: 'klz-2026' + jobs: - call-qa-workflow: - uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main - with: - TARGET_URL: 'https://testing.klz-cables.com' - PROJECT_NAME: 'klz-2026' - secrets: - GOTIFY_URL: ${{ secrets.GOTIFY_URL }} - GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} - GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + # 1. Static Checks (HTML, Assets, HTTP) + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + static: + name: π Static Analysis + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: π Registry Auth + run: | + echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc + echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc + - name: π¦ Cache node_modules + uses: actions/cache@v4 + id: cache-deps + with: + path: node_modules + key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + pnpm store prune + pnpm install --no-frozen-lockfile + - name: π Install Chrome & Dependencies + run: | + apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2 + npx puppeteer browsers install chrome + - name: π HTML Validation + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + run: pnpm run check:html + - name: πΌοΈ Broken Assets + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + ASSET_CHECK_LIMIT: 10 + run: pnpm run check:assets + - name: π HTTP Headers + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + run: pnpm run check:http + + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + # 2. Accessibility (WCAG) + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + a11y: + name: βΏ Accessibility + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: π Registry Auth + run: | + echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc + echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc + - name: π¦ Cache node_modules + uses: actions/cache@v4 + id: cache-deps + with: + path: node_modules + key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + pnpm store prune + pnpm install --no-frozen-lockfile + - name: π Install Chrome & Dependencies + run: | + apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2 + npx puppeteer browsers install chrome + - name: βΏ WCAG Scan + continue-on-error: true + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + run: pnpm run check:wcag + + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + # 3. Performance (Lighthouse) + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + lighthouse: + name: π Lighthouse + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: π Registry Auth + run: | + echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc + echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc + - name: π¦ Cache node_modules + uses: actions/cache@v4 + id: cache-deps + with: + path: node_modules + key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + pnpm store prune + pnpm install --no-frozen-lockfile + - name: π Install Chrome & Dependencies + run: | + apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2 + npx puppeteer browsers install chrome + - name: π Desktop + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + PAGESPEED_LIMIT: 5 + run: pnpm run pagespeed:test -- --collect.settings.preset=desktop + - name: π± Mobile + env: + NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }} + GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }} + PAGESPEED_LIMIT: 5 + run: pnpm run pagespeed:test -- --collect.settings.preset=mobile + + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + # 4. Link Check & Dependency Audit + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + links: + name: π Links & Deps + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: π Registry Auth + run: | + echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc + echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc + - name: π¦ Cache node_modules + uses: actions/cache@v4 + id: cache-deps + with: + path: node_modules + key: pnpm-${{ hashFiles('pnpm-lock.yaml') }} + - name: Install + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + pnpm store prune + pnpm install --no-frozen-lockfile + - name: π¦ Depcheck + continue-on-error: true + run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true + - name: π Lychee Link Check + uses: lycheeverse/lychee-action@v2 + with: + args: --accept 200,204,429 --timeout 15 --insecure --exclude "file://*" --exclude "https://logs.infra.***.me/*" --exclude "https://git.infra.***.me/*" --exclude "https://umami.is/docs/best-practices" --exclude "https://***/*" . + fail: true + + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + # 5. Notification + # ββββββββββββββββββββββββββββββββββββββββββββββββββββ + notify: + name: π Notify + needs: [static, a11y, lighthouse, links] + if: always() + runs-on: docker + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: π Gotify + shell: bash + run: | + STATIC="${{ needs.static.result }}" + A11Y="${{ needs.a11y.result }}" + LIGHTHOUSE="${{ needs.lighthouse.result }}" + LINKS="${{ needs.links.result }}" + + if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then + PRIORITY=8 + EMOJI="π¨" + STATUS="Failed" + else + PRIORITY=2 + EMOJI="β " + STATUS="Passed" + fi + + TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS" + MESSAGE="Static: $STATIC | A11y: $A11Y | Lighthouse: $LIGHTHOUSE | Links: $LINKS + ${{ env.TARGET_URL }}" + + curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \ + -F "title=$TITLE" \ + -F "message=$MESSAGE" \ + -F "priority=$PRIORITY" || true diff --git a/.htmlvalidate.json b/.htmlvalidate.json index 8583271c..a290986c 100644 --- a/.htmlvalidate.json +++ b/.htmlvalidate.json @@ -17,6 +17,10 @@ "valid-id": "off", "element-required-attributes": "off", "attribute-empty-style": "off", - "element-permitted-content": "off" + "element-permitted-content": "off", + "element-required-content": "off", + "element-permitted-parent": "off", + "no-implicit-close": "off", + "close-order": "off" } } diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index de41a62c..a2c51229 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -98,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) { alt={post.frontmatter.title} fill priority + quality={100} className="object-cover" sizes="100vw" style={{ diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index f3b356c8..afab2e98 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema'; import { getOGImageMetadata } from '@/lib/metadata'; import { Suspense } from 'react'; import ContactMap from '@/components/ContactMap'; +import ObfuscatedEmail from '@/components/ObfuscatedEmail'; interface ContactPageProps { params: Promise<{ @@ -204,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
diff --git a/components/ObfuscatedEmail.tsx b/components/ObfuscatedEmail.tsx
new file mode 100644
index 00000000..01a7ad04
--- /dev/null
+++ b/components/ObfuscatedEmail.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+
+interface ObfuscatedEmailProps {
+ email: string;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * A component that helps protect email addresses from simple spambots.
+ * It uses client-side mounting to render the actual email address,
+ * making it harder for static crawlers to harvest.
+ */
+export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ // Show a placeholder or obscured version during SSR
+ return (
+
+ );
+ }
+
+ // Once mounted on the client, render the real mailto link
+ return (
+
+ {children || email}
+
+ );
+}
diff --git a/components/ObfuscatedPhone.tsx b/components/ObfuscatedPhone.tsx
new file mode 100644
index 00000000..0db10d57
--- /dev/null
+++ b/components/ObfuscatedPhone.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import React, { useState, useEffect } from 'react';
+
+interface ObfuscatedPhoneProps {
+ phone: string;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * A component that helps protect phone numbers from simple spambots.
+ * It stays obscured during SSR and hydrates into a functional tel: link on the client.
+ */
+export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Format phone number for tel: link (remove spaces, etc.)
+ const telLink = `tel:${phone.replace(/\s+/g, '')}`;
+
+ if (!mounted) {
+ // Show a placeholder or obscured version during SSR
+ // e.g. +49 881 925 [at] 37298
+ const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
+ return (
+
+ );
+ }
+
+ return (
+
+ {children || phone}
+
+ );
+}
diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx
index 10bab326..4e8753b2 100644
--- a/components/PayloadRichText.tsx
+++ b/components/PayloadRichText.tsx
@@ -1,7 +1,7 @@
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
import Image from 'next/image';
-import { Suspense } from 'react';
+import { Suspense, Fragment } from 'react';
// Import all custom React components that were previously mapped via Markdown
import StickyNarrative from '@/components/blog/StickyNarrative';
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
import TrackedLink from '@/components/analytics/TrackedLink';
import { useLocale } from 'next-intl';
+import ObfuscatedEmail from '@/components/ObfuscatedEmail';
+import ObfuscatedPhone from '@/components/ObfuscatedPhone';
import HomeHero from '@/components/home/Hero';
import ProductCategories from '@/components/home/ProductCategories';
@@ -36,9 +38,95 @@ import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA';
+/**
+ * Splits a text string on \n and intersperses
elements.
+ * This is needed because Lexical stores newlines as literal \n characters inside
+ * text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
+ */
+function textWithLineBreaks(text: string, key: string) {
+ const parts = text.split('\n');
+ if (parts.length === 1) return text;
+ return parts.map((part, i) => (
+
}
+
,
+ // Custom text converter: preserve \n inside text nodes as
and obfuscate emails
+ text: ({ node }: any) => {
+ let content: React.ReactNode = node.text || '';
+ // Split newlines first
+ if (typeof content === 'string' && content.includes('\n')) {
+ content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
+ }
+
+ // Obfuscate emails in text content
+ if (typeof content === 'string' && content.includes('@')) {
+ const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
+ const parts = content.split(emailRegex);
+ content = parts.map((part, i) => {
+ if (part.match(emailRegex)) {
+ return {content};
+ if (node.format & 16)
+ content = (
+
+ {content}
+
+ );
+ if (node.format & 32) content = {content};
+ if (node.format & 64) content = {content};
+ }
+ return <>{content}>;
+ },
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ node, nodesToJSX }: any) => {
return (
@@ -57,16 +145,16 @@ const jsxConverters: JSXConverters = {
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
const id = textContent
? textContent
- .toLowerCase()
- .replace(/Γ€/g, 'ae')
- .replace(/ΓΆ/g, 'oe')
- .replace(/ΓΌ/g, 'ue')
- .replace(/Γ/g, 'ss')
- .replace(/[*_`]/g, '')
- .replace(/[^\w\s-]/g, '')
- .replace(/\s+/g, '-')
- .replace(/-+/g, '-')
- .replace(/^-+|-+$/g, '')
+ .toLowerCase()
+ .replace(/Γ€/g, 'ae')
+ .replace(/ΓΆ/g, 'oe')
+ .replace(/ΓΌ/g, 'ue')
+ .replace(/Γ/g, 'ss')
+ .replace(/[*_`]/g, '')
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-+|-+$/g, '')
: undefined;
if (tag === 'h1')
@@ -168,6 +256,17 @@ const jsxConverters: JSXConverters = {
// Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab;
+
+ if (href.startsWith('mailto:')) {
+ const email = href.replace('mailto:', '');
+ return (
+
{data?.title ? (
- /g, '').replace(/<\/future>/g, '') }} />
+ /g, '')
+ .replace(/<\/future>/g, ''),
+ }}
+ />
) : (
t.rich('title', {
- future: (chunks) => (
- {chunks}
- ),
+ future: (chunks) => {chunks},
})
)}
diff --git a/config/lighthouserc.json b/config/lighthouserc.json
index 9716768d..c052a1c9 100644
--- a/config/lighthouserc.json
+++ b/config/lighthouserc.json
@@ -1,15 +1,10 @@
{
"ci": {
"collect": {
- "numberOfRuns": 3,
+ "numberOfRuns": 1,
"settings": {
"preset": "desktop",
- "onlyCategories": [
- "performance",
- "accessibility",
- "best-practices",
- "seo"
- ],
+ "onlyCategories": ["performance", "accessibility", "best-practices", "seo"],
"chromeFlags": "--no-sandbox --disable-setuid-sandbox"
}
},
@@ -18,7 +13,7 @@
"categories:performance": [
"error",
{
- "minScore": 0.9
+ "minScore": 0.7
}
],
"categories:accessibility": [
@@ -54,4 +49,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/lib/blog.ts b/lib/blog.ts
index 3d4f15a0..7ff0f011 100644
--- a/lib/blog.ts
+++ b/lib/blog.ts
@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise