diff --git a/packages/docs/app/[[...slug]]/page.tsx b/packages/docs/app/[[...slug]]/page.tsx index 7dfcc0f3f..3c96d9f1a 100644 --- a/packages/docs/app/[[...slug]]/page.tsx +++ b/packages/docs/app/[[...slug]]/page.tsx @@ -6,6 +6,7 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page'; import { notFound } from 'next/navigation'; +import CodeBlock from '@/components/CodeBlock'; import { source } from '@/lib/source'; export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { @@ -33,6 +34,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> components={{ ...defaultMdxComponents, TypeTable, + pre: CodeBlock, }} /> diff --git a/packages/docs/components/CodeBlock/index.tsx b/packages/docs/components/CodeBlock/index.tsx new file mode 100644 index 000000000..c240f2b3f --- /dev/null +++ b/packages/docs/components/CodeBlock/index.tsx @@ -0,0 +1,201 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// +// CodeBlock — a wrapper around the Fumadocs code block that adds an +// "Open in agent" action next to the built-in copy button. Readers can send +// the snippet straight to an AI agent (Claude or ChatGPT) with the code +// pre-filled as the prompt. +// +// This replaces the default `pre` MDX component (registered in +// app/[[...slug]]/page.tsx), so it applies to every fenced code block in the +// docs automatically — no per-page markup required. +// + +'use client'; + +import { + CodeBlock as FumaCodeBlock, + Pre, + type CodeBlockProps, +} from 'fumadocs-ui/components/codeblock'; +import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +interface Agent { + id: string; + label: string; + url: (prompt: string) => string; +} + +const AGENTS: Agent[] = [ + { + id: 'claude', + label: 'Claude', + url: (p) => `https://claude.ai/new?q=${encodeURIComponent(p)}`, + }, + { + id: 'chatgpt', + label: 'ChatGPT', + url: (p) => `https://chatgpt.com/?q=${encodeURIComponent(p)}`, + }, + // Note: Gemini's web app (gemini.google.com/app) does not support prefilling a + // prompt via query string — the param is silently ignored and it opens empty. + // Only agents with working prompt prefill are listed here. +]; + +function cx(...parts: (string | false | undefined)[]): string { + return parts.filter(Boolean).join(' '); +} + +// Pull the plain-text code out of the rendered code block. Mirrors the approach +// the Fumadocs copy button uses: clone the
, drop copy-ignored nodes, and
+// read textContent so we don't capture line numbers or button labels.
+function readCode(container: HTMLElement | null): string {
+	const pre = container?.getElementsByTagName('pre').item(0);
+	if (!pre) return '';
+	const clone = pre.cloneNode(true) as HTMLElement;
+	clone.querySelectorAll('.nd-copy-ignore').forEach((node) => node.replaceWith('\n'));
+	return clone.textContent ?? '';
+}
+
+const AGENT_PROMPT_PREFIX =
+	'I found this code snippet in the Sui TypeScript SDK docs. Explain what it does and help me adapt it to my project:\n\n';
+
+// Robot glyph, sized to match the Fumadocs copy button icon.
+function BotIcon() {
+	return (
+		
+	);
+}
+
+function OpenInAgentButton({ figureRef }: { figureRef: React.RefObject }) {
+	const [open, setOpen] = useState(false);
+	const [coords, setCoords] = useState<{ top: number; right: number } | null>(null);
+	const btnRef = useRef(null);
+	const menuRef = useRef(null);
+
+	// Position the menu just below the button using viewport coordinates. The
+	// menu renders in a portal on document.body so the code block's
+	// `overflow-hidden` can't clip it.
+	const positionMenu = () => {
+		const rect = btnRef.current?.getBoundingClientRect();
+		if (!rect) return;
+		setCoords({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
+	};
+
+	const toggle = () => {
+		if (!open) positionMenu();
+		setOpen((v) => !v);
+	};
+
+	useEffect(() => {
+		if (!open) return;
+		const onClick = (e: MouseEvent) => {
+			const target = e.target as Node;
+			if (!btnRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+				setOpen(false);
+			}
+		};
+		const onKey = (e: KeyboardEvent) => {
+			if (e.key === 'Escape') setOpen(false);
+		};
+		const onReflow = () => setOpen(false);
+		document.addEventListener('mousedown', onClick);
+		document.addEventListener('keydown', onKey);
+		window.addEventListener('resize', onReflow);
+		window.addEventListener('scroll', onReflow, true);
+		return () => {
+			document.removeEventListener('mousedown', onClick);
+			document.removeEventListener('keydown', onKey);
+			window.removeEventListener('resize', onReflow);
+			window.removeEventListener('scroll', onReflow, true);
+		};
+	}, [open]);
+
+	const hrefFor = (agent: Agent) => agent.url(AGENT_PROMPT_PREFIX + readCode(figureRef.current));
+
+	return (
+		<>
+			
+			{open &&
+				coords &&
+				typeof document !== 'undefined' &&
+				createPortal(
+					
+
Open in
+ {AGENTS.map((agent) => ( + setOpen(false)} + > + {agent.label} + + ))} +
, + document.body, + )} + + ); +} + +function CustomCodeBlock(props: CodeBlockProps) { + const figureRef = useRef(null); + return ( + ( +
+ + {children} +
+ )} + /> + ); +} + +// Drop-in replacement for the default `pre` MDX component. +export function CodeBlock(props: ComponentProps<'pre'>) { + return ( + +
{props.children}
+
+ ); +} + +export default CodeBlock;