Files
sharper.ly/apps/web/app/routes/_shared/RichText.tsx
T
2026-09-11 02:46:56 +02:00

237 lines
7.5 KiB
TypeScript

/* ============================================================================
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
`<html dir>` 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 (
<a
href={href}
className="text-text-link underline underline-offset-2 hover:text-text-link-hover"
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{renderInline(node.children)}
</a>
);
}
const text = node.text ?? "";
if (text === "") return null;
let element: ReactNode = text;
if (node.code) {
element = (
<code className="rounded-control bg-surface-sunken px-2xs py-0 font-mono text-[0.9em]">
{element}
</code>
);
}
if (node.bold) element = <strong className="font-bold">{element}</strong>;
if (node.italic) element = <em>{element}</em>;
if (node.underline) element = <u>{element}</u>;
if (node.strikethrough) element = <s>{element}</s>;
return <>{element}</>;
}
function renderInline(children: readonly CmsInlineNode[] | undefined): ReactNode {
if (!children || children.length === 0) return null;
return children.map((child, index) => (
<InlineText key={index} node={child} />
));
}
/* ---------------------------------------------------------------------------
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<Record<2 | 3 | 4, string>> = {
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 <h2 className={HEADING_CLASS[2]}>{content}</h2>;
if (level === 3) return <h3 className={HEADING_CLASS[3]}>{content}</h3>;
return <h4 className={HEADING_CLASS[4]}>{content}</h4>;
}
case "list": {
const ordered = node.format === "ordered";
const List = ordered ? "ol" : "ul";
return (
<List
className={`flex list-outside flex-col gap-xs ps-lg body-md text-text-secondary ${
ordered ? "list-decimal" : "list-disc"
}`}
>
{(node.children ?? []).map((item, index) => (
<li key={index}>
{item.type === "list" ? (
<Block node={item} />
) : (
renderInline((item as { children?: readonly CmsInlineNode[] }).children)
)}
</li>
))}
</List>
);
}
case "quote":
return (
<blockquote className="border-s-2 border-border-accent ps-md body-lg text-text-primary">
{renderInline(node.children)}
</blockquote>
);
case "code":
return (
<pre className="overflow-x-auto rounded-card bg-surface-sunken p-md body-sm text-text-secondary">
<code>{renderInline(node.children)}</code>
</pre>
);
case "image": {
const image = node.image;
if (!image?.url) return null;
return (
<figure className="flex flex-col gap-xs">
<img
src={image.url}
alt={image.alternativeText ?? ""}
width={image.width ?? undefined}
height={image.height ?? undefined}
loading="lazy"
decoding="async"
className="w-full rounded-card border border-border-subtle"
/>
{image.alternativeText ? (
<figcaption className="body-sm text-text-tertiary">
{image.alternativeText}
</figcaption>
) : null}
</figure>
);
}
case "paragraph":
default:
return (
<p className="body-md text-text-secondary">
{renderInline((node as { children?: readonly CmsInlineNode[] }).children)}
</p>
);
}
}
/* ---------------------------------------------------------------------------
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 (
<div className={stack}>
{paragraphs.map((paragraph, index) => (
// `whitespace-pre-line` keeps single newlines inside a paragraph.
<p key={index} className="body-md whitespace-pre-line text-text-secondary">
{paragraph}
</p>
))}
</div>
);
}
if (value.length === 0) return null;
return (
<div className={stack}>
{value.map((node, index) => (
<Block key={index} node={node} />
))}
</div>
);
}