Files
better-auth/docs/components/markdown.tsx
2026-04-01 13:38:37 +00:00

190 lines
4.5 KiB
TypeScript

import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { js_beautify } from "js-beautify";
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { Children, Suspense, use, useDeferredValue, useMemo } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import { visit } from "unist-util-visit";
export interface Processor {
process: (content: string) => Promise<ReactNode>;
}
type RehypeTextNode = {
type: "text";
value: string;
};
type RehypeElementNode = {
type: "element";
tagName: string;
properties?: Record<string, unknown>;
children?: RehypeNode[];
};
type RehypeNode = RehypeTextNode | RehypeElementNode;
export function rehypeWrapWords() {
return (tree: RehypeNode) => {
visit(tree, ["text", "element"], (node, index, parent) => {
if (node.type === "element" && node.tagName === "pre") return "skip";
if (node.type !== "text" || !parent || index === undefined) return;
const words = node.value.split(/(?=\s)/);
const newNodes: RehypeElementNode[] = words.flatMap((word) => {
if (word.length === 0) return [];
return {
type: "element",
tagName: "span",
properties: {
class: "animate-fd-fade-in",
},
children: [{ type: "text", value: word }],
};
});
Object.assign(node, {
type: "element",
tagName: "span",
properties: {},
children: newNodes,
} satisfies RehypeElementNode);
return "skip";
});
};
}
function createProcessor(): Processor {
const processor = remark()
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeWrapWords);
return {
async process(content) {
const nodes = processor.parse({ value: content });
const hast = await processor.run(nodes);
return toJsxRuntime(hast, {
development: false,
jsx,
jsxs,
Fragment,
components: {
...defaultMdxComponents,
pre: Pre,
img: undefined,
},
});
},
};
}
function reindent(code: string): string {
const lines = code.split("\n");
let level = 0;
const result: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) {
result.push("");
continue;
}
const withoutStrings = trimmed
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/`(?:[^`\\]|\\.)*`/g, "``")
.replace(/\/\/.*$/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "");
const opens = (withoutStrings.match(/[{\[\(]/g) || []).length;
const closes = (withoutStrings.match(/[}\]\)]/g) || []).length;
let currentLineLevel = level;
if (withoutStrings.match(/^[}\]\)]/)) {
currentLineLevel = Math.max(0, level - 1);
}
result.push("\t".repeat(currentLineLevel) + trimmed);
level = Math.max(0, level + opens - closes);
}
return result.join("\n");
}
function CodeBlock({
content,
className,
}: {
content: string;
className?: string;
}) {
const lang =
className
?.split(" ")
.find((v) => v.startsWith("language-"))
?.slice("language-".length) ?? "text";
const displayLang = lang === "mdx" ? "md" : lang;
const formattedCode = useMemo(() => {
if (
["ts", "tsx", "typescript", "js", "javascript", "json"].includes(
displayLang,
)
) {
return js_beautify(content, {
indent_size: 2,
indent_with_tabs: true,
brace_style: "preserve-inline",
}).trim();
}
return reindent(content.trimEnd());
}, [content, displayLang]);
return (
<div style={{ tabSize: 2 }}>
<DynamicCodeBlock lang={displayLang} code={formattedCode} />
</div>
);
}
function Pre(props: ComponentProps<"pre">) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<"code">;
const content = codeProps.children;
if (typeof content !== "string") return null;
return <CodeBlock content={content} className={codeProps.className} />;
}
const processor = createProcessor();
export function Markdown({ text }: { text: string }) {
const deferredText = useDeferredValue(text);
return (
<Suspense fallback={<p className="invisible">{text}</p>}>
<Renderer text={deferredText} />
</Suspense>
);
}
const cache = new Map<string, Promise<ReactNode>>();
function Renderer({ text }: { text: string }) {
const result = cache.get(text) ?? processor.process(text);
cache.set(text, result);
return use(result);
}