237 lines
7.5 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|