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 c58938c8..de41a62c 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); @@ -231,10 +241,10 @@ export default async function BlogPost({ params }: BlogPostProps) { - {/* Right Column: Sticky Sidebar - Temporarily Hidden without ToC */} + {/* Right Column: Sticky Sidebar - TOC */} diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx index 598441f8..d57eaaa4 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -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 ( -

{children}

+

+ {children} +

); if (tag === 'h2') return ( -

{children}

+

+ {children} +

); if (tag === 'h3') return ( -

{children}

+

+ {children} +

); if (tag === 'h4') return ( -
{children}
+
+ {children} +
); if (tag === 'h5') return ( -
{children}
+
+ {children} +
); - return
{children}
; + return ( +
+ {children} +
+ ); }, 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 ( -
  • +
  • - {children} +
    {children}
  • ); } - return
  • {children}
  • ; + return
  • {children}
  • ; }, quote: ({ node, nodesToJSX }: any) => { const children = nodesToJSX({ nodes: node.children }); diff --git a/lib/blog.ts b/lib/blog.ts index 196efea3..3d4f15a0 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -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 ''; +} diff --git a/tests/og-image.test.ts b/tests/og-image.test.ts index c23d95a7..7dc3e38f 100644 --- a/tests/og-image.test.ts +++ b/tests/og-image.test.ts @@ -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); +});