Skip to content

Commit bed757a

Browse files
authored
feat: per-page md for llms (#43)
## Summary implements a per-page button where you can copy the md content to your clipboard, or view the raw md in another tab. modeled off of [Stripe's docs](https://docs.stripe.com/disputes/prevention/card-testing) <img width="1042" alt="Screenshot 2025-03-18 at 4 13 16 PM" src="https://github.com/user-attachments/assets/0b3992b4-3525-4184-b1b6-7a33e7d363f8" /> <img width="1011" alt="Screenshot 2025-03-18 at 4 21 01 PM" src="https://github.com/user-attachments/assets/7c423398-4bdd-4ce4-8598-563a5435661e" /> ## Details - all pages allow you to get the `/raw/<docs_file>.md`, served by a `/raw/[[...slug]` API that reads/serves the file - adds a new `components/md-copy/actions.tsx` component and `app/raw/[...slug]/route.ts` route to handle this - note: to make this possible, we had to "eject" a built-in page component from our docs framework, so the diff looks a bit larger than the actual changes. i.e., the diff includes components that were part of an external package, but they're now part of the actual docs codebase
1 parent 30ee6b3 commit bed757a

File tree

14 files changed

+1425
-14102
lines changed

14 files changed

+1425
-14102
lines changed

app/[[...slug]]/page.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import { Step, Steps } from "fumadocs-ui/components/steps";
55
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
66
import { TypeTable } from "fumadocs-ui/components/type-table";
77
import fumadocsMdxComponents from "fumadocs-ui/mdx";
8-
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
98
import { notFound } from "next/navigation";
109
import { HTMLAttributes } from "react";
1110

1211
import { Callout, CalloutProps } from "@/components/theme/callout";
1312
import { Card, CardProps, Cards } from "@/components/theme/card";
13+
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "@/components/theme/page";
1414
import { createMetadata } from "@/lib/metadata";
1515
import { source } from "@/lib/source";
1616

@@ -65,6 +65,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
6565
}}
6666
toc={isRootPage ? undefined : page.data.toc}
6767
editOnGithub={isRootPage ? undefined : githubInfo}
68+
currentPath={isRootPage ? undefined : page.file.path}
6869
>
6970
{!isRootPage && <DocsTitle>{page.data.title}</DocsTitle>}
7071
{!isRootPage && <DocsDescription className="mb-1">{page.data.description}</DocsDescription>}

app/raw/[...slug]/route.ts

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NextResponse } from "next/server";
2+
import path from "path";
3+
4+
import { getRawDocContent } from "@/lib/files";
5+
6+
export async function GET(_: Request, { params }: { params: Promise<{ slug: string[] }> }) {
7+
try {
8+
// Slugs are passed in a way that makes the API URL a bit prettier, but we need to convert them
9+
// to the correct format for the file system to read the file:
10+
// 1. They have the `.mdx` extension converted to `.md`
11+
// 2. They have the `docs` slug prefix removed (removed from the passed github file url)
12+
const slug = (await params).slug.map((s) => s.replace(".md", ".mdx"));
13+
const filePath = path.join(process.cwd(), "docs", slug.join("/"));
14+
const { content, title, description } = await getRawDocContent(filePath);
15+
const merged = `# ${title}\n\n${description}\n\n${content}`;
16+
const filename = slug.pop() || "index.md";
17+
18+
return new NextResponse(merged, {
19+
headers: {
20+
"Content-Type": "text/markdown",
21+
"Content-Disposition": `filename="${filename}"`,
22+
},
23+
});
24+
} catch {
25+
return new NextResponse("Not found", { status: 404 });
26+
}
27+
}

components/md-copy/actions.tsx

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"use client";
2+
3+
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
4+
import { Check, ChevronDown, ClipboardCopy, ExternalLink, FileText } from "lucide-react";
5+
import { useState } from "react";
6+
7+
import { buttonVariants } from "@/components/theme/ui/button";
8+
import { cn } from "@/lib/theme/cn";
9+
10+
interface MarkdownActionsProps {
11+
currentPath: string;
12+
}
13+
14+
export function MarkdownActions({ currentPath }: MarkdownActionsProps) {
15+
const [copied, setCopied] = useState(false);
16+
const [error, setError] = useState<string | null>(null);
17+
18+
const getRawPath = () => {
19+
// Convert paths like docs/intro/concepts.mdx to /raw/intro/concepts.md
20+
// This uses the `raw/[...slug]` route to serve the markdown file
21+
const path = currentPath.replace(/^docs\//, "").replace(/\.mdx$/, ".md");
22+
return `/raw/${path}`;
23+
};
24+
25+
const handleCopy = async () => {
26+
try {
27+
const response = await fetch(getRawPath());
28+
if (!response.ok) {
29+
throw new Error(`Failed to fetch markdown content: ${response.statusText}`);
30+
}
31+
const content = await response.text();
32+
await navigator.clipboard.writeText(content);
33+
setCopied(true);
34+
setError(null);
35+
setTimeout(() => setCopied(false), 2000);
36+
} catch (err) {
37+
console.error("Failed to copy:", err);
38+
setError("Failed to copy content");
39+
setTimeout(() => setError(null), 2000);
40+
}
41+
};
42+
43+
const handleView = () => {
44+
window.open(getRawPath(), "_blank");
45+
};
46+
47+
return (
48+
<div className="flex">
49+
<button
50+
onClick={handleCopy}
51+
className={cn(
52+
buttonVariants({ color: "secondary" }),
53+
"gap-2 rounded-r-none border-r-0 text-sm font-normal"
54+
)}
55+
>
56+
{copied ? (
57+
<>
58+
<Check className="size-4" />
59+
Copied!
60+
</>
61+
) : error ? (
62+
<>
63+
<ClipboardCopy className="size-4" />
64+
{error}
65+
</>
66+
) : (
67+
<>
68+
<ClipboardCopy className="size-4" />
69+
Copy page
70+
</>
71+
)}
72+
</button>
73+
74+
<DropdownMenu.Root>
75+
<DropdownMenu.Trigger
76+
className={cn(
77+
buttonVariants({ color: "secondary" }),
78+
"border-l-border dark:border-l-fd-background rounded-l-none border-l px-2 focus:outline-none"
79+
)}
80+
>
81+
<ChevronDown className="size-4" />
82+
</DropdownMenu.Trigger>
83+
84+
<DropdownMenu.Portal>
85+
<DropdownMenu.Content
86+
className="bg-popover text-popover-foreground z-50 min-w-[12rem] overflow-hidden rounded-md border p-1 shadow-md"
87+
align="end"
88+
>
89+
<DropdownMenu.Item
90+
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none"
91+
onClick={handleCopy}
92+
>
93+
<ClipboardCopy className="mr-2 size-4" />
94+
<div className="flex flex-col">
95+
<span>Copy page</span>
96+
<span className="text-muted-foreground text-xs">
97+
Copy this page as markdown for LLMs
98+
</span>
99+
</div>
100+
</DropdownMenu.Item>
101+
102+
<DropdownMenu.Item
103+
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none"
104+
onClick={handleView}
105+
>
106+
<FileText className="mr-2 size-4" />
107+
<div className="flex flex-col">
108+
<span className="flex items-center">
109+
View as markdown <ExternalLink className="ml-2 size-3" />
110+
</span>
111+
<span className="text-muted-foreground text-xs">
112+
View this page as plain markdown text
113+
</span>
114+
</div>
115+
</DropdownMenu.Item>
116+
</DropdownMenu.Content>
117+
</DropdownMenu.Portal>
118+
</DropdownMenu.Root>
119+
</div>
120+
);
121+
}

components/theme/layout/toc-clerk.tsx

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"use client";
2+
3+
import type { TOCItemType } from "fumadocs-core/server";
4+
import * as Primitive from "fumadocs-core/toc";
5+
import { useEffect, useRef, useState } from "react";
6+
7+
import { cn } from "../../../lib/theme/cn";
8+
import { TocItemsEmpty } from "./toc";
9+
import { TocThumb } from "./toc-thumb";
10+
11+
export default function ClerkTOCItems({ items }: { items: TOCItemType[] }) {
12+
const containerRef = useRef<HTMLDivElement>(null);
13+
14+
const [svg, setSvg] = useState<{
15+
path: string;
16+
width: number;
17+
height: number;
18+
}>();
19+
20+
useEffect(() => {
21+
if (!containerRef.current) return;
22+
const container = containerRef.current;
23+
24+
function onResize(): void {
25+
if (container.clientHeight === 0) return;
26+
let w = 0,
27+
h = 0;
28+
const d: string[] = [];
29+
for (let i = 0; i < items.length; i++) {
30+
const element: HTMLElement | null = container.querySelector(
31+
`a[href="#${items[i]?.url?.slice(1)}"]`
32+
);
33+
if (!element) continue;
34+
35+
const styles = getComputedStyle(element);
36+
const offset = getLineOffset(items[i]?.depth ?? 0) + 1,
37+
top = element.offsetTop + parseFloat(styles.paddingTop),
38+
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
39+
40+
w = Math.max(offset, w);
41+
h = Math.max(h, bottom);
42+
43+
d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`);
44+
d.push(`L${offset} ${bottom}`);
45+
}
46+
47+
setSvg({
48+
path: d.join(" "),
49+
width: w + 1,
50+
height: h,
51+
});
52+
}
53+
54+
const observer = new ResizeObserver(onResize);
55+
onResize();
56+
57+
observer.observe(container);
58+
return () => {
59+
observer.disconnect();
60+
};
61+
}, [items]);
62+
63+
if (items.length === 0) return <TocItemsEmpty />;
64+
65+
return (
66+
<>
67+
{svg ? (
68+
<div
69+
className="absolute start-0 top-0 rtl:-scale-x-100"
70+
style={{
71+
width: svg.width,
72+
height: svg.height,
73+
maskImage: `url("data:image/svg+xml,${
74+
// Inline SVG
75+
encodeURIComponent(
76+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`
77+
)
78+
}")`,
79+
}}
80+
>
81+
<TocThumb
82+
containerRef={containerRef}
83+
className="bg-fd-primary mt-(--fd-top) h-(--fd-height) transition-all"
84+
/>
85+
</div>
86+
) : null}
87+
<div className="flex flex-col" ref={containerRef}>
88+
{items.map((item, i) => (
89+
<TOCItem
90+
key={item.url}
91+
item={item}
92+
upper={items[i - 1]?.depth}
93+
lower={items[i + 1]?.depth}
94+
/>
95+
))}
96+
</div>
97+
</>
98+
);
99+
}
100+
101+
function getItemOffset(depth: number): number {
102+
if (depth <= 2) return 14;
103+
if (depth === 3) return 26;
104+
return 36;
105+
}
106+
107+
function getLineOffset(depth: number): number {
108+
return depth >= 3 ? 10 : 0;
109+
}
110+
111+
function TOCItem({
112+
item,
113+
upper = item.depth,
114+
lower = item.depth,
115+
}: {
116+
item: TOCItemType;
117+
upper?: number;
118+
lower?: number;
119+
}) {
120+
const offset = getLineOffset(item.depth),
121+
upperOffset = getLineOffset(upper),
122+
lowerOffset = getLineOffset(lower);
123+
124+
return (
125+
<Primitive.TOCItem
126+
href={item.url}
127+
style={{
128+
paddingInlineStart: getItemOffset(item.depth),
129+
}}
130+
className="prose text-fd-muted-foreground data-[active=true]:text-fd-primary relative py-1.5 text-sm [overflow-wrap:anywhere] transition-colors first:pt-0 last:pb-0"
131+
>
132+
{offset !== upperOffset ? (
133+
<svg
134+
xmlns="http://www.w3.org/2000/svg"
135+
viewBox="0 0 16 16"
136+
className="absolute start-0 -top-1.5 size-4 rtl:-scale-x-100"
137+
>
138+
<line
139+
x1={upperOffset}
140+
y1="0"
141+
x2={offset}
142+
y2="12"
143+
className="stroke-fd-foreground/10"
144+
strokeWidth="1"
145+
/>
146+
</svg>
147+
) : null}
148+
<div
149+
className={cn(
150+
"bg-fd-foreground/10 absolute inset-y-0 w-px",
151+
offset !== upperOffset && "top-1.5",
152+
offset !== lowerOffset && "bottom-1.5"
153+
)}
154+
style={{
155+
insetInlineStart: offset,
156+
}}
157+
/>
158+
{item.title}
159+
</Primitive.TOCItem>
160+
);
161+
}

0 commit comments

Comments
 (0)