273 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|