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]/opengraph-image.tsx b/app/[locale]/blog/[slug]/opengraph-image.tsx
index 9b4a1e43..b6d3c670 100644
--- a/app/[locale]/blog/[slug]/opengraph-image.tsx
+++ b/app/[locale]/blog/[slug]/opengraph-image.tsx
@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png';
export const runtime = 'nodejs';
+async function fetchImageAsBase64(url: string) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return undefined;
+ const arrayBuffer = await res.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+ const contentType = res.headers.get('content-type') || 'image/jpeg';
+ return `data:${contentType};base64,${buffer.toString('base64')}`;
+ } catch (error) {
+ console.error('Failed to fetch OG image:', url, error);
+ return undefined;
+ }
+}
+
export default async function Image({
params,
}: {
@@ -32,12 +46,19 @@ export default async function Image({
: `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined;
+ // Fetch image explicitly and convert to base64 because Satori sometimes struggles
+ // fetching remote URLs directly inside ImageResponse correctly in various environments.
+ let base64Image: string | undefined = undefined;
+ if (featuredImage) {
+ base64Image = await fetchImageAsBase64(featuredImage);
+ }
+
return new ImageResponse(