Compare commits

...

2 Commits

Author SHA1 Message Date
17ebde407e feat: product catalog
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 2m0s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 22:35:49 +01:00
56cd1fb1ba fix(blog): restore table of contents, fix list item styling, resolve dynamic og image generation 2026-03-01 13:12:07 +01:00
9 changed files with 577 additions and 484 deletions

View File

@@ -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(
<OGImageTemplate
title={post.frontmatter.title}
description={post.frontmatter.excerpt}
label={post.frontmatter.category || 'Blog'}
image={featuredImage}
image={base64Image || featuredImage}
/>,
{
...OG_IMAGE_SIZE,

View File

@@ -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);
@@ -231,10 +241,10 @@ export default async function BlogPost({ params }: BlogPostProps) {
</div>
</div>
{/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */}
{/* Right Column: Sticky Sidebar - TOC */}
<aside className="sticky-narrative-sidebar hidden lg:block">
<div className="space-y-12">
{/* Future Payload Table of Contents Implementation */}
<div className="space-y-12 lg:sticky lg:top-32">
<TableOfContents headings={headings} locale={locale} />
</div>
</aside>
</div>

View File

@@ -51,27 +51,74 @@ const jsxConverters: JSXConverters = {
heading: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children });
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')
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')
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')
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')
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')
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) => {
const children = nodesToJSX({ nodes: node.children });
@@ -95,18 +142,18 @@ const jsxConverters: JSXConverters = {
const children = nodesToJSX({ nodes: node.children });
if (node?.checked != null) {
return (
<li className="flex items-center gap-3 mb-2 leading-relaxed">
<li className="flex items-start gap-3 mb-2 leading-relaxed">
<input
type="checkbox"
checked={node.checked}
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>
);
}
return <li className="mb-2 leading-relaxed">{children}</li>;
return <li className="mb-2 leading-relaxed block">{children}</li>;
},
quote: ({ node, nodesToJSX }: any) => {
const children = nodesToJSX({ nodes: node.children });

View File

@@ -286,3 +286,38 @@ export function getHeadings(content: string): { id: string; text: string; 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 '';
}

File diff suppressed because it is too large Load Diff

BIN
lychee Executable file

Binary file not shown.

View File

@@ -104,48 +104,50 @@ function generateExcelForProduct(product: ProductData): Buffer {
const workbook = XLSX.utils.book_new();
const l = product.locale === 'de';
// ── Sheet 1: Product Info ──
const infoData: Array<[string, string]> = [
[l ? 'Produktname' : 'Product Name', product.title],
[l ? 'Artikelnummer' : 'SKU', product.sku],
[l ? 'Kategorie' : 'Category', product.categories.join(', ') || '-'],
[l ? 'Beschreibung' : 'Description', product.description || '-'],
];
// Single sheet: all cross-sections from all voltage tables combined
const hasMultipleVoltages = product.voltageTables.length > 1;
const allRows: string[][] = [];
const infoSheet = XLSX.utils.aoa_to_sheet(infoData);
infoSheet['!cols'] = [{ wch: 25 }, { wch: 65 }];
XLSX.utils.book_append_sheet(workbook, infoSheet, l ? 'Produktinfo' : 'Product Info');
// ── Sheet 2: Technical Data ──
if (product.technicalItems.length > 0) {
const techData: Array<[string, string]> = product.technicalItems.map(item => {
const label = item.unit ? `${item.label} [${item.unit}]` : item.label;
return [label, item.value];
});
const techSheet = XLSX.utils.aoa_to_sheet([
[l ? 'Eigenschaft' : 'Property', l ? 'Wert' : 'Value'],
...techData
// Build unified header row
// Use columns from the first voltage table (they're the same across tables)
const refTable = product.voltageTables[0];
if (!refTable) {
// No voltage tables — create a minimal info sheet
const ws = XLSX.utils.aoa_to_sheet([
[product.title],
[l ? 'Keine Querschnittsdaten verfügbar' : 'No cross-section data available'],
]);
techSheet['!cols'] = [{ wch: 40 }, { wch: 60 }];
XLSX.utils.book_append_sheet(workbook, techSheet, l ? 'Technische Daten' : 'Technical Data');
ws['!cols'] = [{ wch: 40 }];
XLSX.utils.book_append_sheet(workbook, ws, product.title.substring(0, 31));
return Buffer.from(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
}
// ── Sheet 3+: Voltage Tables ──
const headers: string[] = [
l ? 'Querschnitt' : 'Cross-section',
...(hasMultipleVoltages ? [l ? 'Spannung' : 'Voltage'] : []),
...refTable.columns.map(c => c.label),
];
allRows.push(headers);
// Merge rows from all voltage tables
for (const table of product.voltageTables) {
const headers = ['Configuration/Cross-section', ...table.columns.map(c => c.label)];
const dataRows = table.crossSections.map((cs, rowIndex) => {
return [cs, ...table.columns.map(c => c.get(rowIndex) || '-')];
});
const ws = XLSX.utils.aoa_to_sheet([headers, ...dataRows]);
ws['!cols'] = headers.map(() => ({ wch: 22 }));
const safeName = table.voltageLabel.replace(/[:\\/?*[\]]/g, '-').trim();
const sheetName = safeName.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
for (let rowIdx = 0; rowIdx < table.crossSections.length; rowIdx++) {
const row: string[] = [
table.crossSections[rowIdx],
...(hasMultipleVoltages ? [table.voltageLabel] : []),
...table.columns.map(c => c.get(rowIdx) || '-'),
];
allRows.push(row);
}
}
const ws = XLSX.utils.aoa_to_sheet(allRows);
// Auto-width: first col wider for cross-section labels
ws['!cols'] = headers.map((_, i) => ({ wch: i === 0 ? 30 : 18 }));
const sheetName = product.title.substring(0, 31);
XLSX.utils.book_append_sheet(workbook, ws, sheetName);
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
return Buffer.from(buffer);
}

17
scripts/test-axios.ts Normal file
View File

@@ -0,0 +1,17 @@
import axios from 'axios';
async function test() {
const u = 'https://testing.klz-cables.com/de/blog/johannes-gleich-startet-als-senior-key-account-manager-durch';
try {
const res = await axios.get(u, {
headers: { Cookie: 'klz_gatekeeper_session=lassmichrein' },
validateStatus: (status) => status < 400
});
console.log('Status:', res.status);
console.log('Headers:', res.headers);
} catch (err: any) {
console.log('Error status:', err.response?.status);
console.log('Error data:', err.response?.data);
}
}
test();

View File

@@ -1,6 +1,7 @@
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', () => {
const locales = ['de', 'en'];
@@ -18,7 +19,9 @@ describe('OG Image Generation', () => {
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) {
isServerUp = false;
}
@@ -34,7 +37,7 @@ describe('OG Image Generation', () => {
// Check for PNG signature: 89 50 4E 47 0D 0A 1A 0A
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50);
expect(bytes[2]).toBe(0x4E);
expect(bytes[2]).toBe(0x4e);
expect(bytes[3]).toBe(0x47);
// Check that the image is not empty and has a reasonable size
@@ -49,7 +52,9 @@ describe('OG Image Generation', () => {
await verifyImageResponse(response);
}, 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();
const url = `${BASE_URL}/${locale}/api/og/product?slug=${productSlugs[0]}`;
const response = await fetch(url);
@@ -64,11 +69,26 @@ describe('OG Image Generation', () => {
}, 30000);
});
it('should generate blog OG image', async ({ skip }) => {
it('should generate static blog overview OG image', async ({ skip }) => {
if (!isServerUp) skip();
const url = `${BASE_URL}/de/blog/opengraph-image`;
const response = await fetch(url);
await verifyImageResponse(response);
}, 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);
});