Files
sharper.ly/apps/web/app/routes/_page-kit/rich-text.tsx
T
2026-09-11 02:46:56 +02:00

273 lines
8.5 KiB
TypeScript

/* ============================================================================
RICH TEXT
----------------------------------------------------------------------------
Strapi's `richtext` fields are Markdown. This renders the subset the editors
actually use - headings, paragraphs, bullet and numbered lists, blockquotes,
plus inline bold / italic / code / links - by BUILDING REACT ELEMENTS.
There is deliberately no `dangerouslySetInnerHTML` and no HTML parser here:
CMS prose is data, and any raw markup inside it renders as visible text
rather than executing. That also means no markdown dependency to install.
Link targets are the one exception to "prose is inert": an `href` executes
if its scheme says so, so every `[label](url)` target goes through
`safeHref` and a target that is not http/https/mailto/tel or an in-site
path renders as plain text instead of a link.
Anything the CMS returns that this does not understand still reaches the
reader - it falls through to a paragraph - so no copy is ever silently lost.
========================================================================= */
import type { ReactNode } from "react";
import { Link } from "react-router";
import { safeHref } from "~/lib/safe-href";
import { cx } from "./sections";
/* ---------------------------------------------------------------------------
INLINE
------------------------------------------------------------------------ */
/** `**bold**`, `*italic*`, `` `code` ``, `[label](/href)`. */
const INLINE_PATTERN =
/(\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|`[^`\n]+`|\[[^\]\n]+\]\([^)\s]+\))/g;
const LINK_PATTERN = /^\[([^\]\n]+)\]\(([^)\s]+)\)$/;
/**
* Presentation only - it decides `<a>` vs `<Link>`, NOT whether the target is
* allowed. `safeHref` has already made that call by the time this is read.
*/
const LEAVES_THE_ROUTER = /^(?:[a-z][a-z\d+\-.]*:|\/\/|#)/i;
function renderInline(text: string, keyPrefix: string): ReactNode[] {
return text
.split(INLINE_PATTERN)
.filter((part) => part !== "" && part !== undefined)
.map((part, index) => {
const key = `${keyPrefix}-${index}`;
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("__") && part.endsWith("__") && part.length > 4) {
return <strong key={key}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("*") && part.endsWith("*") && part.length > 2) {
return <em key={key}>{part.slice(1, -1)}</em>;
}
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
return (
<code
key={key}
className="rounded-control bg-[var(--surface-sunken)] px-2xs py-px body-sm"
>
{part.slice(1, -1)}
</code>
);
}
const link = LINK_PATTERN.exec(part);
if (link) {
const [, label, target] = link as unknown as [string, string, string];
const className =
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
const href = safeHref(target);
// A target this site refuses to navigate to is not silently dropped:
// the reader still gets the words the editor wrote.
if (href === undefined) return label;
return LEAVES_THE_ROUTER.test(href) ? (
<a
key={key}
href={href}
className={className}
{...(href.startsWith("#")
? {}
: { target: "_blank", rel: "noopener noreferrer" })}
>
{label}
</a>
) : (
<Link key={key} to={href} prefetch="intent" className={className}>
{label}
</Link>
);
}
return part;
});
}
/* ---------------------------------------------------------------------------
BLOCKS
------------------------------------------------------------------------ */
type Block =
| { kind: "heading"; level: 2 | 3 | 4; text: string }
| { kind: "paragraph"; text: string }
| { kind: "quote"; text: string }
| { kind: "list"; ordered: boolean; items: string[] };
const HEADING = /^(#{1,6})\s+(.*)$/;
const BULLET = /^[-*+]\s+(.*)$/;
const NUMBERED = /^\d+[.)]\s+(.*)$/;
const QUOTE = /^>\s?(.*)$/;
/** Group the source's lines into renderable blocks. */
export function parseMarkdown(source: string): Block[] {
const blocks: Block[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
let listItems: string[] = [];
let listOrdered = false;
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
paragraph = [];
}
};
const flushQuote = () => {
if (quote.length > 0) {
blocks.push({ kind: "quote", text: quote.join(" ") });
quote = [];
}
};
const flushList = () => {
if (listItems.length > 0) {
blocks.push({ kind: "list", ordered: listOrdered, items: listItems });
listItems = [];
}
};
const flushAll = () => {
flushParagraph();
flushQuote();
flushList();
};
for (const rawLine of source.replace(/\r\n?/g, "\n").split("\n")) {
const line = rawLine.trim();
if (line === "") {
flushAll();
continue;
}
const heading = HEADING.exec(line);
if (heading) {
flushAll();
// The page owns h1, so CMS headings start at h2 and never go past h4.
const level = Math.min(Math.max(heading[1]!.length + 1, 2), 4) as 2 | 3 | 4;
blocks.push({ kind: "heading", level, text: heading[2]! });
continue;
}
const quoted = QUOTE.exec(line);
if (quoted) {
flushParagraph();
flushList();
quote.push(quoted[1]!);
continue;
}
const bullet = BULLET.exec(line);
const numbered = NUMBERED.exec(line);
if (bullet ?? numbered) {
flushParagraph();
flushQuote();
const ordered = numbered !== null;
if (listItems.length > 0 && ordered !== listOrdered) flushList();
listOrdered = ordered;
listItems.push((numbered?.[1] ?? bullet?.[1])!);
continue;
}
flushQuote();
flushList();
paragraph.push(line);
}
flushAll();
return blocks;
}
export interface RichTextProps {
/** Markdown from a Strapi `richtext` field. */
markdown?: string | null;
className?: string;
}
/** Renders a CMS markdown field with the site's type roles and tokens. */
export function RichText({ markdown, className }: RichTextProps) {
const source = markdown?.trim();
if (!source) return null;
const blocks = parseMarkdown(source);
if (blocks.length === 0) return null;
return (
<div className={cx("flex flex-col gap-md text-start", className)}>
{blocks.map((block, index) => {
const key = `block-${index}`;
switch (block.kind) {
case "heading": {
const Heading = `h${block.level}` as "h2" | "h3" | "h4";
return (
<Heading
key={key}
className={cx(
"text-[var(--text-primary)]",
block.level === 2 ? "heading-h2" : block.level === 3 ? "heading-h3" : "body-lg-bold",
index > 0 && "mt-lg",
)}
>
{renderInline(block.text, key)}
</Heading>
);
}
case "quote":
return (
<blockquote
key={key}
className="border-s-2 border-[var(--border-accent)] ps-lg body-lg text-[var(--text-secondary)]"
>
{renderInline(block.text, key)}
</blockquote>
);
case "list": {
const List = block.ordered ? "ol" : "ul";
return (
<List
key={key}
className={cx(
"flex list-outside flex-col gap-xs ps-lg body-md text-[var(--text-secondary)]",
block.ordered ? "list-decimal" : "list-disc",
)}
>
{block.items.map((item, itemIndex) => (
<li key={`${key}-${itemIndex}`} className="ps-2xs">
{renderInline(item, `${key}-${itemIndex}`)}
</li>
))}
</List>
);
}
default:
return (
<p key={key} className="body-md text-[var(--text-secondary)]">
{renderInline(block.text, key)}
</p>
);
}
})}
</div>
);
}