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( , { ...OG_IMAGE_SIZE, diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index 3ecd4afe..a2c51229 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -1,12 +1,18 @@ import { notFound, redirect } from 'next/navigation'; import JsonLd from '@/components/JsonLd'; import { SITE_URL } from '@/lib/schema'; -import { getPostBySlug, getAdjacentPosts, getReadingTime } from '@/lib/blog'; +import { + getPostBySlug, + getAdjacentPosts, + getReadingTime, + extractLexicalHeadings, +} from '@/lib/blog'; import { Metadata } from 'next'; import Link from 'next/link'; import Image from 'next/image'; import PostNavigation from '@/components/blog/PostNavigation'; import PowerCTA from '@/components/blog/PowerCTA'; +import TableOfContents from '@/components/blog/TableOfContents'; import { Heading } from '@/components/ui'; import { setRequestLocale } from 'next-intl/server'; import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; @@ -67,6 +73,10 @@ export default async function BlogPost({ params }: BlogPostProps) { const { prev, next, isPrevRandom, isNextRandom } = await getAdjacentPosts(post.slug, locale); + // Convert Lexical content into a plain string to estimate reading time roughly + // Extract headings for TOC + const headings = extractLexicalHeadings(post.content?.root || post.content); + // Convert Lexical content into a plain string to estimate reading time roughly const rawTextContent = JSON.stringify(post.content); @@ -88,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) { alt={post.frontmatter.title} fill priority + quality={100} className="object-cover" sizes="100vw" style={{ @@ -113,7 +124,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
- {/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */} + {/* Right Column: Sticky Sidebar - TOC */} diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index 263efddd..2144da2c 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -198,7 +198,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
diff --git a/app/[locale]/products/[...slug]/page.tsx b/app/[locale]/products/[...slug]/page.tsx index d42d6a3d..c4be5c33 100644 --- a/app/[locale]/products/[...slug]/page.tsx +++ b/app/[locale]/products/[...slug]/page.tsx @@ -322,6 +322,8 @@ export default async function ProductPage({ params }: ProductPageProps) { } } + console.log(`[DEBUG PAGE] Slug: ${productSlug}, children count: ${descriptionChildren.length}`); + const descriptionContent = { root: { ...product.content.root, @@ -353,29 +355,31 @@ export default async function ProductPage({ params }: ProductPageProps) { categories={product.frontmatter.categories} sku={product.frontmatter.sku} /> -
+
{/* Background Decorative Elements */}
-