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)}
); // 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 ({renderChildren(node.children, key, quoteLabel)}