From 0c4583c015807077cbded89e089f59f7d3d67b48 Mon Sep 17 00:00:00 2001 From: Robi Date: Thu, 17 Jul 2025 11:16:04 +0300 Subject: [PATCH] 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 --- .gitattributes | 1 + docs/app/docs/[[...slug]]/page.client.tsx | 193 ++++++++++++++++++++++ docs/app/docs/[[...slug]]/page.tsx | 8 + docs/app/docs/lib/get-llm-text.ts | 40 +++++ docs/app/llms.txt/[...slug]/route.ts | 21 +++ docs/app/llms.txt/route.ts | 42 +---- docs/next.config.mjs | 8 + 7 files changed, 277 insertions(+), 36 deletions(-) create mode 100644 .gitattributes create mode 100644 docs/app/docs/[[...slug]]/page.client.tsx create mode 100644 docs/app/docs/lib/get-llm-text.ts create mode 100644 docs/app/llms.txt/[...slug]/route.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..818b8942 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ + * text=auto eol=lf \ No newline at end of file diff --git a/docs/app/docs/[[...slug]]/page.client.tsx b/docs/app/docs/[[...slug]]/page.client.tsx new file mode 100644 index 00000000..ff4fcc1c --- /dev/null +++ b/docs/app/docs/[[...slug]]/page.client.tsx @@ -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, +): [checked: boolean, onClick: MouseEventHandler] { + const [checked, setChecked] = useState(false); + const timeoutRef = useRef(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(); + +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 ( + + ); +} + +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 ( + + + Open in + + + + {[ + { + title: "Open in GitHub", + href: props.githubUrl, + icon: ( + + GitHub + + + ), + }, + { + title: "Open in ChatGPT", + href: gpt, + icon: ( + + OpenAI + + + ), + }, + { + title: "Open in Claude", + href: claude, + icon: ( + + Anthropic + + + ), + }, + { + title: "Open in T3 Chat", + href: t3, + icon: , + }, + ].map((item) => ( + + {item.icon} + {item.title} + + + ))} + + + ); +} diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/docs/[[...slug]]/page.tsx index dbe5398c..d3b2bea1 100644 --- a/docs/app/docs/[[...slug]]/page.tsx +++ b/docs/app/docs/[[...slug]]/page.tsx @@ -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({ }} > {page.data.title} +
+ + +
}, +) { + 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(); +} diff --git a/docs/app/llms.txt/route.ts b/docs/app/llms.txt/route.ts index e03da705..e6e03c22 100644 --- a/docs/app/llms.txt/route.ts +++ b/docs/app/llms.txt/route.ts @@ -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 { - 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); -} diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 233d24ff..877a8a55 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -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 [ {