import Image from "next/image"; import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react"; import type { TOCSection } from "./SectionTOC"; import { QuoteLabel } from "./QuoteLabel"; // Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to // Payload's own official React renderer + custom JSXConverters — needed // once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/ // Video/Zitat, see payload/src/collections/Posts.ts), which the old // hand-rolled switch had no case for at all. extractHeadings()/headingId() // below are kept as an independent, minimal walk over the raw JSON (same // as before) — they only ever need to find h2 headings for SectionTOC and // never touch Blocks, no reason to route that through the new renderer too. type LexicalNode = { type: string; children?: LexicalNode[]; text?: string; tag?: string; }; function plainText(node: LexicalNode): string { if (node.type === "text") return node.text ?? ""; return (node.children ?? []).map(plainText).join(""); } // Numbered legal-page headings ("1. Verantwortlicher") get a stable // "section-1" id from the leading number — immune to copy edits changing // the heading text later, unlike a text-derived slug. Anything else // (headings with no leading number) falls back to a plain slugify. // Exported — the Impressum page renders some of its own headings outside // this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/ // "Verantwortlich für den Inhalt" sections come straight from // company-settings, not the richText field, see app/impressum/page.tsx) // and needs the exact same id-assignment logic so its SectionTOC entries // actually match the ids those headings render with. export function headingId(text: string): string { const numbered = text.match(/^(\d+)\./); if (numbered) return `section-${numbered[1]}`; return text .toLowerCase() .replace(/[^a-z0-9äöüß]+/g, "-") .replace(/^-+|-+$/g, ""); } // Walks the same tree the renderer does, collecting h2 headings for a // SectionTOC sidebar — kept in this file (not duplicated) so the ids it // produces can never drift from the ones the renderer actually assigns. export function extractHeadings(content: unknown): TOCSection[] { const root = (content as { root?: LexicalNode })?.root; if (!root?.children) return []; const headings: TOCSection[] = []; const walk = (nodes: LexicalNode[]) => { for (const node of nodes) { if (node.type === "heading" && (node.tag ?? "h2") === "h2") { const text = plainText(node); headings.push({ id: headingId(text), title: text }); } if (node.children) walk(node.children); } }; walk(root.children); return headings; } // Payload upload relations resolve to the full media doc when fetched at // sufficient depth (every richText-consuming fetch in app/lib/payload.ts // already uses depth >= 2), or fall back to a bare id if not — only // render when actually populated. type MediaRef = { url?: string | null } | number | null | undefined; function mediaUrl(ref: MediaRef): string | null { if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url; return null; } // Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short- // link edge cases) — good enough for a "paste a link" editor field; a // URL that doesn't match either pattern just doesn't render rather than // guessing wrong. function toEmbedUrl(url: string): string | null { const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/); if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`; const vimeo = url.match(/vimeo\.com\/(\d+)/); if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`; return null; } type ImageBlockFields = { image: MediaRef; caption?: string | null }; type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] }; type VideoEmbedBlockFields = { url: string; caption?: string | null }; type QuoteBlockFields = { text: string; label?: string | null }; function BlockCaption({ caption }: { caption?: string | null }) { if (!caption) return null; return
{caption}
; } // Same visual treatment as the QuoteBlock converter below (and the native // blockquote case it replaces going forward) — see that converter's own // comment for why both still exist. function Quote({ label, children }: { label?: string; children: React.ReactNode }) { return ({children}
{nodesToJSX({ nodes: node.children })}
), heading: ({ node, nodesToJSX }) => { const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; const text = plainText(node as unknown as LexicalNode); return ({nodesToJSX({ nodes: node.children })}), blocks: { image: ({ node }: { node: { fields: unknown } }) => { const fields = node.fields as ImageBlockFields; const url = mediaUrl(fields.image); if (!url) return null; return (
{lines.map((line, i) => ( {line} {i < lines.length - 1 &&); }, }, }); } export function RichText({ content, quoteLabel = "Merke dir:", }: { content: unknown; /** Label for any native blockquote's callout — defaults to "Merke dir:" * for callers that don't pass one (legal pages never use blockquotes, * so this only actually matters for blog posts). Pass "" to hide the * label/icon/underline for every native blockquote here. New content * should use the Zitat block instead, which carries its own label. */ quoteLabel?: string; }) { const root = (content as { root?: { children?: unknown[] } })?.root; if (!root?.children) return null; return (
} ))}