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