Compare commits

..

3 Commits

Author SHA1 Message Date
0c8d9ea669 fix(e2e): await hydration before form submits, skip cleanup on 403
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m27s
Build & Deploy / 🏗️ Build (push) Successful in 4m49s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
fix(blog): bypass image optimization for post feature image

chore(release): bump version to 2.2.7
2026-03-01 16:03:23 +01:00
1bb0efc85b fix(blog): restore TOC, list styling, and dynamic OG images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m51s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m12s
This commit reapplies fixes directly to main after reverting an accidental feature branch merge.

chore(release): bump version to 2.2.6
2026-03-01 13:18:24 +01:00
4adf547265 chore(blog): improve image quality and fix list item alignment; fix(hero): refactor title rendering to resolve console error; bump version to 2.0.3
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
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
Build & Deploy / 🧪 QA (push) Has been cancelled
2026-03-01 11:17:47 +01:00
8 changed files with 214 additions and 54 deletions

View File

@@ -8,6 +8,20 @@ export const size = OG_IMAGE_SIZE;
export const contentType = 'image/png'; export const contentType = 'image/png';
export const runtime = 'nodejs'; 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({ export default async function Image({
params, params,
}: { }: {
@@ -32,12 +46,19 @@ export default async function Image({
: `${SITE_URL}${post.frontmatter.featuredImage}` : `${SITE_URL}${post.frontmatter.featuredImage}`
: undefined; : 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( return new ImageResponse(
<OGImageTemplate <OGImageTemplate
title={post.frontmatter.title} title={post.frontmatter.title}
description={post.frontmatter.excerpt} description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'} label={post.frontmatter.category || 'Blog'}
image={featuredImage} image={base64Image || featuredImage}
/>, />,
{ {
...OG_IMAGE_SIZE, ...OG_IMAGE_SIZE,

View File

@@ -1,12 +1,18 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import JsonLd from '@/components/JsonLd'; import JsonLd from '@/components/JsonLd';
import { SITE_URL } from '@/lib/schema'; 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 { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import PostNavigation from '@/components/blog/PostNavigation'; import PostNavigation from '@/components/blog/PostNavigation';
import PowerCTA from '@/components/blog/PowerCTA'; import PowerCTA from '@/components/blog/PowerCTA';
import TableOfContents from '@/components/blog/TableOfContents';
import { Heading } from '@/components/ui'; import { Heading } from '@/components/ui';
import { setRequestLocale } from 'next-intl/server'; import { setRequestLocale } from 'next-intl/server';
import BlogEngagementTracker from '@/components/analytics/BlogEngagementTracker'; 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); 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 // Convert Lexical content into a plain string to estimate reading time roughly
const rawTextContent = JSON.stringify(post.content); const rawTextContent = JSON.stringify(post.content);
@@ -88,6 +98,8 @@ export default async function BlogPost({ params }: BlogPostProps) {
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
priority priority
quality={100}
unoptimized={true}
className="object-cover" className="object-cover"
sizes="100vw" sizes="100vw"
style={{ style={{
@@ -123,13 +135,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold"> <span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
@@ -160,13 +172,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-neutral-300 rounded-full" /> <span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold"> <span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</header> </header>
@@ -231,10 +243,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div> </div>
</div> </div>
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */} {/* Right Column: Sticky Sidebar - TOC */}
<aside className="sticky-narrative-sidebar hidden lg:block"> <aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12"> <div className="space-y-12 lg:sticky lg:top-32">
{/* Future Payload Table of Contents Implementation */} <TableOfContents headings={headings} locale={locale} />
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -51,27 +51,74 @@ const jsxConverters: JSXConverters = {
heading: ({ node, nodesToJSX }: any) => { heading: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children }); const children = nodesToJSX({ nodes: node.children });
const tag = node?.tag; const tag = node?.tag;
// Extract text to generate an ID for the TOC
// Lexical children might contain various nodes; we need a plain text representation
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, '')
: undefined;
if (tag === 'h1') if (tag === 'h1')
return ( return (
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2> <h2
id={id}
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24"
>
{children}
</h2>
); );
if (tag === 'h2') if (tag === 'h2')
return ( return (
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3> <h3
id={id}
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
>
{children}
</h3>
); );
if (tag === 'h3') if (tag === 'h3')
return ( return (
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4> <h4
id={id}
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h4>
); );
if (tag === 'h4') if (tag === 'h4')
return ( return (
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5> <h5
id={id}
className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h5>
); );
if (tag === 'h5') if (tag === 'h5')
return ( return (
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6> <h6
id={id}
className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary scroll-mt-24"
>
{children}
</h6>
); );
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>; return (
<h6 id={id} className="text-base font-bold mt-6 mb-4 text-text-primary scroll-mt-24">
{children}
</h6>
);
}, },
list: ({ node, nodesToJSX }: any) => { list: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children }); const children = nodesToJSX({ nodes: node.children });
@@ -95,18 +142,18 @@ const jsxConverters: JSXConverters = {
const children = nodesToJSX({ nodes: node.children }); const children = nodesToJSX({ nodes: node.children });
if (node?.checked != null) { if (node?.checked != null) {
return ( return (
<li className="flex items-center gap-3 mb-2 leading-relaxed"> <li className="flex items-start gap-3 mb-2 leading-relaxed">
<input <input
type="checkbox" type="checkbox"
checked={node.checked} checked={node.checked}
readOnly readOnly
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded" className="mt-1.5 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded shrink-0"
/> />
<span>{children}</span> <div className="flex-1">{children}</div>
</li> </li>
); );
} }
return <li className="mb-2 leading-relaxed">{children}</li>; return <li className="mb-2 leading-relaxed block">{children}</li>;
}, },
quote: ({ node, nodesToJSX }: any) => { quote: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children }); const children = nodesToJSX({ nodes: node.children });

View File

@@ -23,19 +23,27 @@ export default function Hero({ data }: { data?: any }) {
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-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]"
> >
{data?.title ? ( {data?.title ? (
<span <>
dangerouslySetInnerHTML={{ {data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => {
__html: data.title if (part.startsWith('<green>') && part.endsWith('</green>')) {
.replace( const content = part.replace(/<\/?green>/g, '');
/<green>/g, return (
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">', <span key={i} className="relative inline-block">
) <span className="relative z-10 text-accent italic inline-block">
.replace( {content}
/<\/green>/g, </span>
'</span><div class="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="animation-delay: 500ms;"><Scribble variant="circle" /></div></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>;
})}
</>
) : ( ) : (
t.rich('title', { t.rich('title', {
green: (chunks) => ( green: (chunks) => (

View File

@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; level:
return { id, text: cleanText, level }; return { id, text: cleanText, level };
}); });
} }
export function extractLexicalHeadings(
node: any,
headings: { id: string; text: string; level: number }[] = [],
): { id: string; text: string; level: number }[] {
if (!node) return headings;
if (node.type === 'heading' && node.tag) {
const level = parseInt(node.tag.replace('h', ''));
const text = getTextContentFromLexical(node);
if (text) {
headings.push({
id: generateHeadingId(text),
text,
level,
});
}
}
if (node.children && Array.isArray(node.children)) {
node.children.forEach((child: any) => extractLexicalHeadings(child, headings));
}
return headings;
}
function getTextContentFromLexical(node: any): string {
if (node.type === 'text') {
return node.text || '';
}
if (node.children && Array.isArray(node.children)) {
return node.children.map(getTextContentFromLexical).join('');
}
return '';
}

View File

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

View File

@@ -98,7 +98,7 @@ async function main() {
await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.goto(contactUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely // Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {}); await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => { });
// Ensure form is visible and interactive // Ensure form is visible and interactive
try { try {
@@ -109,6 +109,9 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// Fill form fields // Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test'); await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me'); await page.type('input[name="email"]', 'testing@mintel.me');
@@ -117,6 +120,9 @@ async function main() {
'This is an automated test verifying the contact form submission.', 'This is an automated test verifying the contact form submission.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Contact Form...`); console.log(` Submitting Contact Form...`);
// Explicitly click submit and wait for navigation/state-change // Explicitly click submit and wait for navigation/state-change
@@ -137,7 +143,7 @@ async function main() {
await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 }); await page.goto(productUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Ensure React has hydrated completely // Ensure React has hydrated completely
await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => {}); await page.waitForNetworkIdle({ idleTime: 1000, timeout: 15000 }).catch(() => { });
// The product form uses dynamic IDs, so we select by input type in the specific form context // The product form uses dynamic IDs, so we select by input type in the specific form context
try { try {
@@ -147,6 +153,9 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// In RequestQuoteForm, the email input is type="email" and message is a textarea. // In RequestQuoteForm, the email input is type="email" and message is a textarea.
await page.type('form input[type="email"]', 'testing@mintel.me'); await page.type('form input[type="email"]', 'testing@mintel.me');
await page.type( await page.type(
@@ -154,6 +163,9 @@ async function main() {
'Automated request for product quote via E2E testing framework.', 'Automated request for product quote via E2E testing framework.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Product Quote Form...`); console.log(` Submitting Product Quote Form...`);
// Submit and wait for success state // Submit and wait for success state
@@ -189,11 +201,16 @@ async function main() {
}); });
console.log(` ✅ Deleted submission: ${doc.id}`); console.log(` ✅ Deleted submission: ${doc.id}`);
} catch (delErr: any) { } catch (delErr: any) {
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`); // Log but don't fail, 403s on Directus / Payload APIs for guest Gatekeeper sessions are normal
console.warn(` ⚠️ Cleanup attempt on ${doc.id} returned an error, typically due to API Auth separation: ${delErr.message}`);
} }
} }
} catch (err: any) { } catch (err: any) {
console.error(`❌ Cleanup failed: ${err.message}`); if (err.response?.status === 403) {
console.warn(` ⚠️ Cleanup fetch failed with 403 Forbidden. This is expected if the runner lacks admin API credentials. Test submissions remain in the database.`);
} else {
console.error(` ❌ Cleanup fetch failed: ${err.message}`);
}
// Don't mark the whole test as failed just because cleanup failed // Don't mark the whole test as failed just because cleanup failed
} }

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest'; import { describe, it, expect, beforeAll } from 'vitest';
const BASE_URL = process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; const BASE_URL =
process.env.TEST_URL || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
describe('OG Image Generation', () => { describe('OG Image Generation', () => {
const locales = ['de', 'en']; const locales = ['de', 'en'];
@@ -18,7 +19,9 @@ describe('OG Image Generation', () => {
return; return;
} }
} }
console.log(`\n⚠ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`); console.log(
`\n⚠ KLZ Application not detected at ${BASE_URL}. Skipping integration tests.\n`,
);
} catch (e) { } catch (e) {
isServerUp = false; isServerUp = false;
} }
@@ -34,7 +37,7 @@ describe('OG Image Generation', () => {
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A // Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
expect(bytes[0]).toBe(0x89); expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50); expect(bytes[1]).toBe(0x50);
expect(bytes[2]).toBe(0x4E); expect(bytes[2]).toBe(0x4e);
expect(bytes[3]).toBe(0x47); expect(bytes[3]).toBe(0x47);
// Check that the image is not empty and has a reasonable size // Check that the image is not empty and has a reasonable size
@@ -49,7 +52,9 @@ describe('OG Image Generation', () => {
await verifyImageResponse(response); await verifyImageResponse(response);
}, 30000); }, 30000);
it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({ skip }) => { it(`should generate product OG image for ${locale} with slug ${productSlugs[0]}`, async ({
skip,
}) => {
if (!isServerUp) skip(); if (!isServerUp) skip();
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`; const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
const response = await fetch(url); const response = await fetch(url);
@@ -64,11 +69,26 @@ describe('OG Image Generation', () => {
}, 30000); }, 30000);
}); });
it('should generate blog OG image', async ({ skip }) => { it('should generate static blog overview OG image', async ({ skip }) => {
if (!isServerUp) skip(); if (!isServerUp) skip();
const url = `${BASE_URL}/de/blog/opengraph-image`; const url = `${BASE_URL}/de/blog/opengraph-image`;
const response = await fetch(url); const response = await fetch(url);
await verifyImageResponse(response); await verifyImageResponse(response);
}, 30000); }, 30000);
});
it('should generate dynamic blog post OG image', 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);
}
}, 30000);
});