Compare commits

...

4 Commits

Author SHA1 Message Date
57b6963efe chore: release v2.2.12
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m44s
Build & Deploy / 🏗️ Build (push) Successful in 4m54s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m41s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-02 10:23:08 +01:00
1a136540d0 feat: implement email and phone obfuscation with Payload inline blocks
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Has started running
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-02 10:22:52 +01:00
92bc88dfbd style: design refinements — reduce title/heading sizes, remove Scribble decorations, add image quality
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 3m19s
Build & Deploy / 🏗️ Build (push) Successful in 5m26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m4s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 43s
- Hero title: text-7xl → text-5xl, removed text-shadow
- Removed all Scribble decorative strokes from Hero, VideoSection, products page
- PayloadRichText headings reduced by one size step
- Team page: harmonized Michael/Klaus heading sizes (both text-4xl)
- Product overview: removed min-height from hero, reduced CTA heading
- Added quality={100} to team photos, Experience and MeetTheTeam backgrounds
- Cleaned up unused Scribble imports
2026-03-02 01:13:28 +01:00
fb3ec6e10a fix(blog): preserve newlines in Lexical text nodes as <br> for proper list rendering
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 6m15s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
test(og): use real blog slug from sitemap instead of hardcoded hello-world

chore(release): bump version to 2.2.10
2026-03-01 23:21:35 +01:00
16 changed files with 308 additions and 86 deletions

View File

@@ -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) {
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')}
</h4>
<a
href="mailto:info@klz-cables.com"
<ObfuscatedEmail
email="info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
>
info@klz-cables.com
</a>
/>
</div>
</div>
</address>

View File

@@ -1,5 +1,4 @@
import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next';
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return (
<div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */}
<section className="relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark">
<section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
<Container className="relative z-10">
<div className="max-w-4xl animate-slide-up">
<Badge
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge>
<Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble
variant="circle"
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
/>
</span>
),
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
})}
</Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight">
<h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')}
</h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed">

View File

@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')}
</Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl">
<Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<span className="text-white">{t('michael.name')}</span>
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')}
</p>
</div>
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('klaus.name')}
fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')}
</Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl">
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
{t('klaus.name')}
</Heading>
<div className="relative mb-6 md:mb-12">
<div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
<p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')}
</p>
</div>

View File

@@ -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 (
<span className={className} aria-hidden="true">
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
</span>
);
}
// Once mounted on the client, render the real mailto link
return (
<a href={`mailto:${email}`} className={className}>
{children || email}
</a>
);
}

View File

@@ -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 (
<span className={className} aria-hidden="true">
{children || obscured}
</span>
);
}
return (
<a href={telLink} className={className}>
{children || phone}
</a>
);
}

View File

@@ -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 <br /> 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) => (
<Fragment key={`${key}-${i}`}>
{part}
{i < parts.length - 1 && <br />}
</Fragment>
));
}
const jsxConverters: JSXConverters = {
...defaultJSXConverters,
// Let the default converters handle text nodes to preserve valid formatting
// Handle Lexical linebreak nodes (explicit shift+enter)
linebreak: () => <br />,
// Custom text converter: preserve \n inside text nodes as <br /> 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 <ObfuscatedEmail key={`e-${i}`} email={part} />;
}
return part;
});
}
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
if (typeof content === 'string' && content.match(/\+\d+/)) {
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
const parts = content.split(phoneRegex);
content = parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
}
return part;
});
}
// Handle array content (from previous mappings)
if (Array.isArray(content)) {
content = content.map((item, idx) => {
if (typeof item === 'string') {
// Re-apply phone regex to strings in array
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
if (item.match(phoneRegex)) {
const parts = item.split(phoneRegex);
return parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
}
return part;
});
}
}
return item;
});
}
// Apply Lexical formatting flags
if (node.format) {
if (node.format & 1) content = <strong>{content}</strong>;
if (node.format & 2) content = <em>{content}</em>;
if (node.format & 8) content = <u>{content}</u>;
if (node.format & 4) content = <s>{content}</s>;
if (node.format & 16)
content = (
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
{content}
</code>
);
if (node.format & 32) content = <sub>{content}</sub>;
if (node.format & 64) content = <sup>{content}</sup>;
}
return <>{content}</>;
},
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ node, nodesToJSX }: any) => {
return (
@@ -73,7 +161,7 @@ const jsxConverters: JSXConverters = {
return (
<h2
id={id}
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
>
{children}
</h2>
@@ -82,7 +170,7 @@ const jsxConverters: JSXConverters = {
return (
<h3
id={id}
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h3>
@@ -91,7 +179,7 @@ const jsxConverters: JSXConverters = {
return (
<h4
id={id}
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
>
{children}
</h4>
@@ -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 (
<ObfuscatedEmail
email={email}
className="text-primary no-underline hover:underline font-medium transition-colors"
/>
);
}
return (
<a
href={href}
@@ -1012,6 +1111,14 @@ const jsxConverters: JSXConverters = {
<CTA data={node?.fields} />
</Reveal>
),
'block-email': ({ node }: any) => {
const { email, label } = node.fields;
return <ObfuscatedEmail email={email}>{label || email}</ObfuscatedEmail>;
},
'block-phone': ({ node }: any) => {
const { phone, label } = node.fields;
return <ObfuscatedPhone phone={phone}>{label || phone}</ObfuscatedPhone>;
},
},
// Custom converter for the Payload "upload" Lexical node (Media collection)
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes

View File

@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
fill
className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />

View File

@@ -1,6 +1,5 @@
'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui';
import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic';
@@ -20,45 +19,19 @@ export default function Hero({ data }: { data?: any }) {
<div>
<Heading
level={1}
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
>
{data?.title ? (
<>
{data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => {
if (part.startsWith('<green>') && part.endsWith('</green>')) {
const content = part.replace(/<\/?green>/g, '');
return (
<span key={i} className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">
{content}
</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
);
}
return <span key={i}>{part}</span>;
})}
</>
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<green>/g, '<span class="text-accent italic">')
.replace(/<\/green>/g, '</span>'),
}}
/>
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">
{chunks}
</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}
>
<Scribble variant="circle" />
</div>
</span>
),
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
})
)}
</Heading>

View File

@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
fill
className="object-cover scale-105 animate-slow-zoom"
sizes="100vw"
quality={100}
/>
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />

View File

@@ -1,7 +1,6 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) {
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} />
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<future>/g, '<span class="italic text-accent">')
.replace(/<\/future>/g, '</span>'),
}}
/>
) : (
t.rich('title', {
future: (chunks) => (
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
})
)}
</h2>

View File

@@ -139,7 +139,7 @@
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"version": "2.2.9",
"version": "2.2.12",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
@@ -161,4 +161,4 @@
"peerDependencies": {
"lucide-react": "^0.563.0"
}
}
}

View File

@@ -21,6 +21,8 @@ import { Posts } from './src/payload/collections/Posts';
import { FormSubmissions } from './src/payload/collections/FormSubmissions';
import { Products } from './src/payload/collections/Products';
import { Pages } from './src/payload/collections/Pages';
import { Email } from './src/payload/blocks/Email';
import { Phone } from './src/payload/blocks/Phone';
import { seedDatabase } from './src/payload/seed';
const filename = fileURLToPath(import.meta.url);
@@ -62,6 +64,7 @@ export default buildConfig({
...defaultFeatures,
BlocksFeature({
blocks: payloadBlocks,
inlineBlocks: [Email, Phone],
}),
],
}),

View File

@@ -0,0 +1,25 @@
import { Block } from 'payload';
export const Email: Block = {
slug: 'email',
interfaceName: 'EmailBlock',
labels: {
singular: 'Email (Inline)',
plural: 'Emails (Inline)',
},
fields: [
{
name: 'email',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: false,
admin: {
placeholder: 'Optional: Custom link text',
},
},
],
};

View File

@@ -0,0 +1,28 @@
import { Block } from 'payload';
export const Phone: Block = {
slug: 'phone',
interfaceName: 'PhoneBlock',
labels: {
singular: 'Phone (Inline)',
plural: 'Phones (Inline)',
},
fields: [
{
name: 'phone',
type: 'text',
required: true,
admin: {
placeholder: '+49 123 456 789',
},
},
{
name: 'label',
type: 'text',
required: false,
admin: {
placeholder: 'Optional: Custom link text',
},
},
],
};

View File

@@ -1,4 +1,6 @@
import { AnimatedImage } from './AnimatedImage';
import { Email } from './Email';
import { Phone } from './Phone';
import { Callout } from './Callout';
import { CategoryGrid } from './CategoryGrid';
import { ChatBubble } from './ChatBubble';
@@ -21,6 +23,8 @@ import { homeBlocksArray } from './HomeBlocks';
export const payloadBlocks = [
...homeBlocksArray,
AnimatedImage,
Email,
Phone,
Callout,
CategoryGrid,
ChatBubble,

View File

@@ -76,19 +76,31 @@ describe('OG Image Generation', () => {
await verifyImageResponse(response);
}, 30000);
it('should generate dynamic blog post OG image', async ({ skip }) => {
it('should generate dynamic blog post OG image with featured photo', async ({ skip }) => {
if (!isServerUp) skip();
// Assuming 'hello-world' or a newly created post slug.
// If it 404s, it still tests the routing, though 200 is expected for an actual post.
const url = `${BASE_URL}/de/blog/hello-world/opengraph-image`;
const response = await fetch(url);
// Even if the post "hello-world" doesn't exist and returns 404 in some environments,
// we should at least check it doesn't 500. We'll accept 200 or 404 as valid "working" states
// vs a 500 compilation/satori error.
expect([200, 404]).toContain(response.status);
if (response.status === 200) {
await verifyImageResponse(response);
// Discover a real blog slug from the sitemap
const sitemapRes = await fetch(`${BASE_URL}/sitemap.xml`);
const sitemapXml = await sitemapRes.text();
const blogMatch = sitemapXml.match(/<loc>[^<]*\/de\/blog\/([^<]+)<\/loc>/);
const slug = blogMatch ? blogMatch[1] : null;
if (!slug) {
console.log('⚠️ No blog post found in sitemap, skipping dynamic OG test');
skip();
return;
}
const url = `${BASE_URL}/de/blog/${slug}/opengraph-image`;
const response = await fetch(url);
await verifyImageResponse(response);
// Verify the image is substantially large (>50KB) to confirm it actually
// contains the featured photo and isn't just a tiny fallback/text-only image
const buffer = await response.clone().arrayBuffer();
expect(
buffer.byteLength,
`OG image for "${slug}" is suspiciously small (${buffer.byteLength} bytes) — likely missing featured photo`,
).toBeGreaterThan(50000);
}, 30000);
});