diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx index d57eaaa4..87a0d999 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -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'; @@ -36,9 +36,45 @@ 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
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) => ( + + {part} + {i < parts.length - 1 &&
} +
+ )); +} + const jsxConverters: JSXConverters = { ...defaultJSXConverters, - // Let the default converters handle text nodes to preserve valid formatting + // Handle Lexical linebreak nodes (explicit shift+enter) + linebreak: () =>
, + // Custom text converter: preserve \n inside text nodes as
+ 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)}`); + } + // Apply Lexical formatting flags + if (node.format) { + if (node.format & 1) content = {content}; + if (node.format & 2) content = {content}; + if (node.format & 8) content = {content}; + if (node.format & 4) content = {content}; + if (node.format & 16) content = {content}; + if (node.format & 32) content = {content}; + if (node.format & 64) content = {content}; + } + return <>{content}; + }, // Use div instead of p for paragraphs to allow nested block elements (like the lists above) paragraph: ({ node, nodesToJSX }: any) => { return ( @@ -57,16 +93,16 @@ const jsxConverters: JSXConverters = { 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, '') + .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') diff --git a/package.json b/package.json index be8a2079..74fe8c67 100644 --- a/package.json +++ b/package.json @@ -139,7 +139,7 @@ "prepare": "husky", "preinstall": "npx only-allow pnpm" }, - "version": "2.2.9", + "version": "2.2.10", "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", diff --git a/tests/og-image.test.ts b/tests/og-image.test.ts index 7dc3e38f..bce8febf 100644 --- a/tests/og-image.test.ts +++ b/tests/og-image.test.ts @@ -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(/[^<]*\/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); });