1import {
2 createContext,
3 type ExoticComponent,
4 memo,
5 type PropsWithChildren,
6 useContext,
7 useRef,
8} from "react";
9import { componentsAreEqual, defaultProcessor, Memoizer, type BaseProcessor } from "./Memoizer";
10import { type Components } from "rehype-react";
11import { jsx } from "react/jsx-runtime";
12
13/** Options for {@linkcode Markdown} */
14export interface MarkdownOptions {
15 /** Markdown source content. */
16 content: string;
17 /** Add any other markdown processors. */
18 processor?: BaseProcessor | null | undefined;
19 /** Customize component rendering. */
20 components?: Partial<Components> | null | undefined;
21 /** Automatically predict closing tags, useful for LLM streaming. */
22 predict?: boolean;
23}
24
25/**
26 * Render Markdown. This component uses very aggressive memoization, perfect for
27 * live editors and streaming LLM chat use cases. Configured with a unified
28 * processor pipeline through the `processor` prop or a
29 * {@linkcode MarkdownOptionsProvider}.
30 */
31export const Markdown: ExoticComponent<MarkdownOptions> = memo(function Markdown({
32 content,
33 processor,
34 components,
35 predict,
36}: MarkdownOptions) {
37 const context = useContext(OptionsContext);
38 const ref = useRef<Memoizer | null>(null);
39 const memoizer = (ref.current ??= new Memoizer());
40 memoizer.reconfigure(
41 processor ?? context.processor ?? defaultProcessor,
42 predict ?? context.predict ?? false,
43 { ...context.components, ...components },
44 );
45 return memoizer.update(content);
46}, markdownPropsAreEqual);
47
48type Context = Omit<MarkdownOptions, "content">;
49const OptionsContext = createContext<Context>({});
50
51/** Configure default options for {@linkcode Markdown} */
52export const MarkdownOptionsProvider: ExoticComponent<PropsWithChildren<Context>> = memo(
53 function MarkdownOptionsProvider({
54 processor,
55 components,
56 children,
57 predict,
58 }: PropsWithChildren<Context>) {
59 const valueRef = useRef<Context | null>(null);
60 const nextValue: Context = { processor, components, predict };
61 if (!valueRef.current || !contextIsEqual(valueRef.current, nextValue)) {
62 valueRef.current = nextValue;
63 }
64 return jsx(OptionsContext.Provider, { value: valueRef.current, children });
65 },
66 providerPropsAreEqual,
67);
68
69function markdownPropsAreEqual(prev: MarkdownOptions, next: MarkdownOptions) {
70 if (prev.content !== next.content) return false;
71 return contextIsEqual(prev, next);
72}
73
74function providerPropsAreEqual(prev: PropsWithChildren<Context>, next: PropsWithChildren<Context>) {
75 if (prev.children !== next.children) return false;
76 return contextIsEqual(prev, next);
77}
78
79function contextIsEqual(prev: Context, next: Context) {
80 if (prev.processor !== next.processor) return false;
81 if (prev.predict !== next.predict) return false;
82 if (prev.components === next.components) return true;
83 if (prev.components && next.components) {
84 return componentsAreEqual(prev.components, next.components);
85 }
86 return !prev.components && !next.components;
87}