fix(blog): restore TOC, list styling, and dynamic OG images
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m51s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m12s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 13s
Build & Deploy / 🧪 QA (push) Successful in 2m51s
Build & Deploy / 🏗️ Build (push) Successful in 5m32s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m12s
This commit reapplies fixes directly to main after reverting an accidental feature branch merge. chore(release): bump version to 2.2.6
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
</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>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-4 md:mb-6 leading-relaxed text-text-secondary last:mb-0">
|
||||
<div className="mb-6 leading-relaxed text-text-secondary">
|
||||
{nodesToJSX({ nodes: node.children })}
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<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 });
|
||||
if (node?.listType === 'number') {
|
||||
return (
|
||||
<ol className="list-decimal pl-4 space-y-2 text-text-secondary marker:text-primary marker:font-bold prose-p:mb-0">
|
||||
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
@@ -86,7 +133,7 @@ const jsxConverters: JSXConverters = {
|
||||
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
|
||||
}
|
||||
return (
|
||||
<ul className="list-disc pl-4 space-y-2 text-text-secondary marker:text-primary prose-p:mb-0">
|
||||
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
@@ -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 });
|
||||
|
||||
39
lib/blog.ts
39
lib/blog.ts
@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
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
|
||||
@@ -162,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
|
||||
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 '';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user