From 36455ef4796a91bfa836ad461b52fbd12eeb2e00 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sat, 21 Feb 2026 21:42:13 +0100 Subject: [PATCH] fix(blog): table of contents scroll links Fixes an issue where TOC links wouldn't scroll due to ID generation mismatches on MDX headers containing formatted text or German umlauts. --- components/blog/MDXComponents.tsx | 57 ++++++++++++++++++++----------- lib/blog.ts | 46 ++++++++++++++++++++----- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/components/blog/MDXComponents.tsx b/components/blog/MDXComponents.tsx index 11fcdd8f..f4a68ebc 100644 --- a/components/blog/MDXComponents.tsx +++ b/components/blog/MDXComponents.tsx @@ -10,6 +10,7 @@ import PowerCTA from '@/components/blog/PowerCTA'; import StickyNarrative from '@/components/blog/StickyNarrative'; import TechnicalGrid from '@/components/blog/TechnicalGrid'; import ComparisonGrid from '@/components/blog/ComparisonGrid'; +import { generateHeadingId, getTextContent } from '@/lib/blog'; export const mdxComponents = { VisualLinkPreview, @@ -36,17 +37,28 @@ export const mdxComponents = { className="inline-flex items-center gap-3 px-6 py-3 bg-primary text-white font-bold rounded-xl hover:bg-accent hover:text-primary-dark transition-all duration-300 no-underline my-8 group shadow-lg hover:shadow-xl hover:-translate-y-1" > - + {children} - (PDF) + + (PDF) + ); } if (href?.startsWith('/')) { return ( - + {children} ); @@ -61,18 +73,19 @@ export const mdxComponents = { > {children} - + ); }, - img: (props: any) => ( - - ), + img: (props: any) => , h2: ({ children, ...props }: any) => { - const id = typeof children === 'string' - ? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') - : props.id; + const id = props.id || generateHeadingId(getTextContent(children)); return ( {children} @@ -80,9 +93,7 @@ export const mdxComponents = { ); }, h3: ({ children, ...props }: any) => { - const id = typeof children === 'string' - ? children.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') - : props.id; + const id = props.id || generateHeadingId(getTextContent(children)); return (

{children} @@ -108,17 +119,22 @@ export const mdxComponents = {
  • - + {children}
  • ), blockquote: ({ children, ...props }: any) => ( -
    -
    - {children} -
    +
    +
    {children}
    ), strong: ({ children, ...props }: any) => ( @@ -144,7 +160,10 @@ export const mdxComponents = { ), thead: ({ children, ...props }: any) => ( - + {children} ), diff --git a/lib/blog.ts b/lib/blog.ts index 8a15bbac..0b301f2c 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -109,7 +109,12 @@ export async function getAllPostsMetadata(locale: string): Promise { +): Promise<{ + prev: PostMdx | null; + next: PostMdx | null; + isPrevRandom?: boolean; + isNextRandom?: boolean; +}> { const posts = await getAllPosts(locale); const currentIndex = posts.findIndex((post) => post.slug === slug); @@ -127,7 +132,7 @@ export async function getAdjacentPosts( let isPrevRandom = false; const getRandomPost = (excludeSlugs: string[]) => { - const available = posts.filter(p => !excludeSlugs.includes(p.slug)); + const available = posts.filter((p) => !excludeSlugs.includes(p.slug)); if (available.length === 0) return null; return available[Math.floor(Math.random() * available.length)]; }; @@ -154,17 +159,42 @@ export function getReadingTime(content: string): number { return Math.ceil(minutes); } +export function generateHeadingId(text: string): string { + let id = text.toLowerCase(); + id = id.replace(/ä/g, 'ae'); + id = id.replace(/ö/g, 'oe'); + id = id.replace(/ü/g, 'ue'); + id = id.replace(/ß/g, 'ss'); + + id = id.replace(/[*_`]/g, ''); + id = id.replace(/[^\w\s-]/g, ''); + id = id + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + + return id || 'heading'; +} + +export function getTextContent(node: any): string { + if (typeof node === 'string') return node; + if (typeof node === 'number') return node.toString(); + if (Array.isArray(node)) return node.map(getTextContent).join(''); + if (node && typeof node === 'object' && node.props && node.props.children) { + return getTextContent(node.props.children); + } + return ''; +} + export function getHeadings(content: string): { id: string; text: string; level: number }[] { const headingLines = content.split('\n').filter((line) => line.match(/^#{2,3}\s/)); return headingLines.map((line) => { const level = line.match(/^#+/)?.[0].length || 0; - const text = line.replace(/^#+\s/, '').trim(); - const id = text - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-'); + const rawText = line.replace(/^#+\s/, '').trim(); + const cleanText = rawText.replace(/[*_`]/g, ''); + const id = generateHeadingId(cleanText); - return { id, text, level }; + return { id, text: cleanText, level }; }); }