mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 07:18:56 -05:00
190 lines
4.5 KiB
TypeScript
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);
|
|
}
|