mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-06 04:19:20 +00:00
docs: add LLM copy button and view options components (#3423)
* feat: add LLM copy button and view options components - update routing for LLM text generation, adding .mdx to a route now generates its .md repsresentation - add rewrite from /docs/:path*mdx to /llms.txt/:path so ai can traverse the llms.txt as routes * chore: lint * chore: cubic
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
193
docs/app/docs/[[...slug]]/page.client.tsx
Normal file
193
docs/app/docs/[[...slug]]/page.client.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "fumadocs-ui/components/ui/popover";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { type MouseEventHandler, useEffect, useRef } from "react";
|
||||
import { useEffectEvent } from "fumadocs-core/utils/use-effect-event";
|
||||
|
||||
export function useCopyButton(
|
||||
onCopy: () => void | Promise<void>,
|
||||
): [checked: boolean, onClick: MouseEventHandler] {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
|
||||
const onClick: MouseEventHandler = useEffectEvent(() => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
const res = Promise.resolve(onCopy());
|
||||
|
||||
void res.then(() => {
|
||||
setChecked(true);
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setChecked(false);
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
// Avoid updates after being unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [checked, onClick];
|
||||
}
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function LLMCopyButton() {
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [checked, onClick] = useCopyButton(async () => {
|
||||
setLoading(true);
|
||||
const url = window.location.pathname + ".mdx";
|
||||
try {
|
||||
const cached = cache.get(url);
|
||||
|
||||
if (cached) {
|
||||
await navigator.clipboard.writeText(cached);
|
||||
} else {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": fetch(url).then(async (res) => {
|
||||
const content = await res.text();
|
||||
cache.set(url, content);
|
||||
|
||||
return content;
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground",
|
||||
}),
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{checked ? <Check /> : <Copy />}
|
||||
Copy Markdown
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const optionVariants = cva(
|
||||
"text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4",
|
||||
);
|
||||
|
||||
export function ViewOptions(props: { markdownUrl: string; githubUrl: string }) {
|
||||
const markdownUrl = new URL(props.markdownUrl, "https://better-auth.com");
|
||||
const q = `Read ${markdownUrl}, I want to ask questions about it.`;
|
||||
|
||||
const claude = `https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`;
|
||||
const gpt = `https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
q,
|
||||
})}`;
|
||||
const t3 = `https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "secondary",
|
||||
size: "sm",
|
||||
className: "gap-2",
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Open in
|
||||
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col overflow-auto">
|
||||
{[
|
||||
{
|
||||
title: "Open in GitHub",
|
||||
href: props.githubUrl,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Open in ChatGPT",
|
||||
href: gpt,
|
||||
icon: (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Open in Claude",
|
||||
href: claude,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Anthropic</title>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Open in T3 Chat",
|
||||
href: t3,
|
||||
icon: <MessageCircle />,
|
||||
},
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className={cn(optionVariants())}
|
||||
>
|
||||
{item.icon}
|
||||
{item.title}
|
||||
<ExternalLink className="text-fd-muted-foreground size-3.5 ms-auto" />
|
||||
</a>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { contents } from "@/components/sidebar-content";
|
||||
import { Endpoint } from "@/components/endpoint";
|
||||
import { DividerText } from "@/components/divider-text";
|
||||
import { LLMCopyButton, ViewOptions } from "./page.client";
|
||||
|
||||
const { AutoTypeTable } = createTypeTable();
|
||||
|
||||
@@ -60,6 +61,13 @@ export default async function Page({
|
||||
}}
|
||||
>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
|
||||
<LLMCopyButton />
|
||||
<ViewOptions
|
||||
markdownUrl={`${page.url}.mdx`}
|
||||
githubUrl={`https://github.com/better-auth/better-auth/blob/dev/apps/docs/content/docs/${page.file.path}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={{
|
||||
|
||||
40
docs/app/docs/lib/get-llm-text.ts
Normal file
40
docs/app/docs/lib/get-llm-text.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { remark } from "remark";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { fileGenerator, remarkDocGen, remarkInstall } from "fumadocs-docgen";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import remarkMdx from "remark-mdx";
|
||||
import { remarkAutoTypeTable } from "fumadocs-typescript";
|
||||
import { remarkInclude } from "fumadocs-mdx/config";
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
const processor = remark()
|
||||
.use(remarkMdx)
|
||||
.use(remarkInclude)
|
||||
.use(remarkGfm)
|
||||
.use(remarkAutoTypeTable)
|
||||
.use(remarkDocGen, { generators: [fileGenerator()] })
|
||||
.use(remarkInstall)
|
||||
.use(remarkStringify);
|
||||
|
||||
export async function getLLMText(docPage: any) {
|
||||
const category = [docPage.slugs[0]];
|
||||
|
||||
// Read the raw file content
|
||||
const rawContent = await readFile(docPage.data._file.absolutePath, "utf-8");
|
||||
|
||||
const processed = await processor.process({
|
||||
path: docPage.data._file.absolutePath,
|
||||
value: rawContent,
|
||||
});
|
||||
|
||||
return `# ${category}: ${docPage.data.title}
|
||||
URL: ${docPage.url}
|
||||
Source: https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/${
|
||||
docPage.file.path
|
||||
}
|
||||
|
||||
${docPage.data.description}
|
||||
|
||||
${processed.toString()}
|
||||
`;
|
||||
}
|
||||
21
docs/app/llms.txt/[...slug]/route.ts
Normal file
21
docs/app/llms.txt/[...slug]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { source } from "@/lib/source";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getLLMText } from "@/app/docs/lib/get-llm-text";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> },
|
||||
) {
|
||||
const slug = (await params).slug;
|
||||
const page = source.getPage(slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return new NextResponse(await getLLMText(page));
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
@@ -1,44 +1,14 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import fg from "fast-glob";
|
||||
import matter from "gray-matter";
|
||||
import { remark } from "remark";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkInstall } from "fumadocs-docgen";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import remarkMdx from "remark-mdx";
|
||||
import { source } from "@/lib/source";
|
||||
import { getLLMText } from "../docs/lib/get-llm-text";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
// all scanned content
|
||||
const files = await fg(["./content/docs/**/*.mdx"]);
|
||||
|
||||
const scan = files.map(async (file) => {
|
||||
const fileContent = await fs.readFile(file);
|
||||
const { content, data } = matter(fileContent.toString());
|
||||
|
||||
const processed = await processContent(content);
|
||||
return `file: ${file}
|
||||
meta: ${JSON.stringify(data, null, 2)}
|
||||
|
||||
${processed}`;
|
||||
});
|
||||
|
||||
const scan = source
|
||||
.getPages()
|
||||
.filter((file) => file.slugs[0] !== "openapi")
|
||||
.map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join("\n\n"));
|
||||
}
|
||||
|
||||
async function processContent(content: string): Promise<string> {
|
||||
const file = await remark()
|
||||
.use(remarkMdx)
|
||||
// gfm styles
|
||||
.use(remarkGfm)
|
||||
// your remark plugins
|
||||
.use(remarkInstall, { persist: { id: "package-manager" } })
|
||||
// to string
|
||||
.use(remarkStringify)
|
||||
.process(content);
|
||||
|
||||
return String(file);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/docs/:path*.mdx",
|
||||
destination: "/llms.txt/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
redirects: async () => {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user