import type { ReactNode } from "react"; import Image from "next/image"; import type { TOCSection } from "./SectionTOC"; // Minimal Lexical JSON → JSX renderer for Payload's richText fields. // Deliberately small and dependency-free (matches the project's existing // style — see Posts.ts's own hand-rolled extractPlainText on the Payload // side) rather than pulling in @payloadcms/richtext-lexical's full React // renderer just to walk a legal page's headings/paragraphs/lists. Covers // the node types real content actually uses; add more only when a page // genuinely needs them. type LexicalNode = { type: string; children?: LexicalNode[]; text?: string; format?: number; tag?: string; listType?: "bullet" | "number"; fields?: { url?: string }; }; // Lexical's text format is a bitmask — see TextFormatType in the Lexical // source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8). const BOLD = 1; const ITALIC = 2; const UNDERLINE = 8; 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. 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; } function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode { if (!nodes) return null; return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel)); } function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode { switch (node.type) { case "linebreak": return
; case "text": { let el: ReactNode = node.text; const format = node.format ?? 0; if (format & BOLD) el = {el}; if (format & ITALIC) el = {el}; if (format & UNDERLINE) el = {el}; return {el}; } case "link": return ( {renderChildren(node.children, key, quoteLabel)} ); case "heading": { const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; const text = plainText(node); return ( {renderChildren(node.children, key, quoteLabel)} ); } case "list": { const ListTag = node.listType === "number" ? "ol" : "ul"; return ( {renderChildren(node.children, key, quoteLabel)} ); } case "listitem": return (
  • {renderChildren(node.children, key, quoteLabel)}
  • ); case "paragraph": return (

    {renderChildren(node.children, key, quoteLabel)}

    ); // Lexical's default blockquote feature — used sitewide as a "Merke // dir:" pull-quote callout, per page-blog-detail's actual built Figma // frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/ // background card (an earlier version of this guessed one; the real // design has no background or padding at all, just a plain 3-column // row: label+underline, a full-height divider rule, then the quote // lines). Icon is the actual exported sparkle asset from that node // (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The // "Merke dir:" label itself is generic/hardcoded here rather than // content-authored, since a blog post's own body text drives which // lines get quoted, not the label framing them — legal pages never // use blockquotes, so this styling is effectively blog-only in // practice despite living in the shared renderer. case "quote": return (
    {/* Label/icon/underline are optional (Posts.quoteLabel) — if empty, only the divider + quote text render. The blockquote itself is never optional, just this framing around it. */} {quoteLabel && ( <>
    {quoteLabel}
    {/* Hand-drawn underline image, not a plain bar — exported straight from the Figma node (label-underline). */} )}
    {/* Lexical's real QuoteNode holds flat text/linebreak children directly, NOT nested paragraphs — pressing Enter inside a blockquote in the editor exits it into a new paragraph rather than adding a line within it (confirmed by reading @lexical/rich-text's QuoteNode.insertNewAfter). An earlier version of this case assumed nested-paragraph children, which only happened to work for this session's own hand-authored seed JSON — any blockquote actually typed in the CMS (Shift+Enter for a soft line break) rendered blank, since child.children was undefined on a plain text node. */}

    {renderChildren(node.children, key, quoteLabel)}

    ); default: return renderChildren(node.children, key, quoteLabel); } } export function RichText({ content, quoteLabel = "Merke dir:", }: { content: unknown; /** Label for any blockquote's callout (see the "quote" case above) — * 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 blockquote here. */ quoteLabel?: string; }) { const root = (content as { root?: LexicalNode })?.root; if (!root?.children) return null; return (
    {renderChildren(root.children, "root", quoteLabel)}
    ); }