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 a5aed315..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); @@ -88,7 +98,6 @@ export default async function BlogPost({ params }: BlogPostProps) { alt={post.frontmatter.title} fill priority - quality={90} className="object-cover" sizes="100vw" style={{ @@ -232,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 c3de9da9..d57eaaa4 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -42,7 +42,7 @@ const jsxConverters: JSXConverters = { // Use div instead of p for paragraphs to allow nested block elements (like the lists above) paragraph: ({ node, nodesToJSX }: any) => { return ( -
+
{nodesToJSX({ nodes: node.children })}
); @@ -51,33 +51,80 @@ 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 }); if (node?.listType === 'number') { return ( -
    +
      {children}
    ); @@ -86,7 +133,7 @@ const jsxConverters: JSXConverters = { return
      {children}
    ; } return ( -
      +
        {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 db765d6f..3d4f15a0 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise { category: doc.category || '', featuredImage: typeof doc.featuredImage === 'object' && doc.featuredImage !== null - ? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url + ? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url : null, focalX: typeof doc.featuredImage === 'object' && doc.featuredImage !== null @@ -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/package.json b/package.json index 973753cd..b95cfd79 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "prepare": "husky", "preinstall": "npx only-allow pnpm" }, - "version": "2.2.5", + "version": "2.2.6", "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", @@ -161,4 +161,4 @@ "peerDependencies": { "lucide-react": "^0.563.0" } -} +} \ No newline at end of file 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); +});