/* ============================================================================ RICH TEXT ---------------------------------------------------------------------------- Renders a Strapi 5 blocks field, or a plain-text field, as React elements. No `dangerouslySetInnerHTML` anywhere: CMS prose is authored by project and partner staff, and turning it into markup would make an editor account an XSS vector. Every node below is mapped to a known element, and text is rendered as text. That is not enough on its own for links: an `href` is executable if its scheme says so, so every link node's URL goes through `safeHref`, and a URL that is not http/https/mailto/tel or an in-site path renders as plain text rather than as an anchor. Colour, size and rhythm come from tier-2 tokens and the published type roles, and direction is left entirely to the document: RTL falls out of `` plus logical properties, with no mirrored stylesheet. ========================================================================= */ import type { ReactNode } from "react"; import { safeHref } from "~/lib/safe-href"; import type { CmsBlockNode, CmsInlineNode, CmsRichText } from "./cms"; /* --------------------------------------------------------------------------- INLINE ------------------------------------------------------------------------ */ function isLinkNode(node: CmsInlineNode): boolean { return node.type === "link" && typeof node.url === "string"; } /** * External links open in a new tab; in-site links stay a plain anchor. This is * presentation only - `safeHref` decides whether there is a link at all. */ const EXTERNAL_HREF = /^(?:https?:)?\/\//i; function InlineText({ node }: { node: CmsInlineNode }) { if (isLinkNode(node)) { const href = safeHref(node.url); // A URL this site refuses to navigate to still keeps its words: the label // renders as text instead of becoming an anchor with a dangerous target. if (href === undefined) return <>{renderInline(node.children)}; const external = EXTERNAL_HREF.test(href); return ( {renderInline(node.children)} ); } const text = node.text ?? ""; if (text === "") return null; let element: ReactNode = text; if (node.code) { element = ( {element} ); } if (node.bold) element = {element}; if (node.italic) element = {element}; if (node.underline) element = {element}; if (node.strikethrough) element = {element}; return <>{element}; } function renderInline(children: readonly CmsInlineNode[] | undefined): ReactNode { if (!children || children.length === 0) return null; return children.map((child, index) => ( )); } /* --------------------------------------------------------------------------- BLOCKS ------------------------------------------------------------------------ */ /** * Heading levels are clamped so a document that starts at H1 cannot introduce * a second H1 into a page that already has one. */ const HEADING_CLASS: Readonly> = { 2: "heading-h2 text-text-primary", 3: "heading-h3 text-text-primary", 4: "body-lg-bold text-text-primary", }; function Block({ node }: { node: CmsBlockNode }) { switch (node.type) { case "heading": { const level = Math.min(4, Math.max(2, node.level ?? 2)) as 2 | 3 | 4; const content = renderInline(node.children); if (level === 2) return

{content}

; if (level === 3) return

{content}

; return

{content}

; } case "list": { const ordered = node.format === "ordered"; const List = ordered ? "ol" : "ul"; return ( {(node.children ?? []).map((item, index) => (
  • {item.type === "list" ? ( ) : ( renderInline((item as { children?: readonly CmsInlineNode[] }).children) )}
  • ))}
    ); } case "quote": return (
    {renderInline(node.children)}
    ); case "code": return (
              {renderInline(node.children)}
            
    ); case "image": { const image = node.image; if (!image?.url) return null; return (
    {image.alternativeText {image.alternativeText ? (
    {image.alternativeText}
    ) : null}
    ); } case "paragraph": default: return (

    {renderInline((node as { children?: readonly CmsInlineNode[] }).children)}

    ); } } /* --------------------------------------------------------------------------- ENTRY POINT ------------------------------------------------------------------------ */ /** A blank paragraph separates plain-text bodies into paragraphs. */ function paragraphsOf(value: string): string[] { return value .split(/\n{2,}/) .map((paragraph) => paragraph.trim()) .filter(Boolean); } export interface RichTextProps { /** A Strapi blocks value, a plain string, or nothing at all. */ value: CmsRichText | undefined; className?: string; } /** * Renders whichever of the two shapes the field turned out to be, and nothing * at all when it is empty - so a body that has not been written yet leaves no * stray heading or empty box behind. */ export function RichText({ value, className }: RichTextProps) { if (!value) return null; const stack = `flex flex-col gap-md${className ? ` ${className}` : ""}`; if (typeof value === "string") { const paragraphs = paragraphsOf(value); if (paragraphs.length === 0) return null; return (
    {paragraphs.map((paragraph, index) => ( // `whitespace-pre-line` keeps single newlines inside a paragraph.

    {paragraph}

    ))}
    ); } if (value.length === 0) return null; return (
    {value.map((node, index) => ( ))}
    ); }