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;