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);
});