initial commit
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/* ============================================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user