feat: add DocsActions toolbar to docs pages with copy-markdown and AI chat links

- New `DocsActions` component renders a `ButtonGroup` with a "Copy Page" button (fetches the `.md` alternate and writes it to the clipboard) and a chevron dropdown linking to View as Markdown, ChatGPT, Claude, and Perplexity — each AI link pre-fills a prompt with the page URL
- Wire `DocsActions` into the docs route's page header alongside the title/description, computing `markdownPath` and `pageUrl` from `page.url` and the exported `SITE_URL`
This commit is contained in:
2026-05-27 18:22:11 -04:00
parent f59cc4c195
commit 190a078593
2 changed files with 212 additions and 7 deletions
@@ -0,0 +1,193 @@
"use client";
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react";
import * as React from "react";
import type { ReactElement, SVGProps } from "react";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type DocsActionsProps = {
/** Path to the markdown alternate of this page (e.g. `/docs/registry.md`). */
markdownPath: string;
/** Absolute URL of this page — embedded in AI-chat prompts. */
pageUrl: string;
className?: string;
};
type DocsActionsMenuItem = {
label: string;
href: (urls: { markdownPath: string; pageUrl: string }) => string;
icon: (props: SVGProps<SVGSVGElement>) => ReactElement;
};
const menuItems: readonly DocsActionsMenuItem[] = [
{
label: "View as Markdown",
href: ({ markdownPath }) => markdownPath,
icon: MarkdownIcon,
},
{
label: "Open in ChatGPT",
href: ({ pageUrl }) => getPromptUrl("https://chatgpt.com", pageUrl),
icon: ChatGPTIcon,
},
{
label: "Open in Claude",
href: ({ pageUrl }) => getPromptUrl("https://claude.ai/new", pageUrl),
icon: ClaudeIcon,
},
{
label: "Open in Perplexity",
href: ({ pageUrl }) => getPromptUrl("https://www.perplexity.ai/search", pageUrl),
icon: PerplexityIcon,
},
];
export function DocsActions({ markdownPath, pageUrl, className }: DocsActionsProps) {
return (
<ButtonGroup
className={cn(
"[&_svg:not([class*='size-'])]:size-4! [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
>
<CopyMarkdownButton markdownPath={markdownPath} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger render={<Button size="icon-sm" variant="outline" />}>
<IconChevronDown data-icon aria-hidden="true" />
<span className="sr-only">Open page actions</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-max min-w-44">
<DropdownMenuGroup>
{menuItems.map((item) => {
const Icon = item.icon;
const href = item.href({ markdownPath, pageUrl });
return (
<DropdownMenuItem
key={item.label}
className="cursor-pointer [&_svg]:text-muted-foreground"
render={<a href={href} target="_blank" rel="noopener noreferrer" />}
>
<Icon aria-hidden="true" data-icon="inline-start" />
<span className="text-[13px] leading-none">{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
);
}
function CopyMarkdownButton({ markdownPath }: { markdownPath: string }) {
const [copied, setCopied] = React.useState(false);
const timeoutRef = React.useRef<number | undefined>(undefined);
React.useEffect(() => {
return () => window.clearTimeout(timeoutRef.current);
}, []);
const handleClick = React.useCallback(async () => {
if (copied) return;
try {
const response = await fetch(markdownPath, { headers: { Accept: "text/markdown" } });
if (!response.ok) throw new Error("Page markdown is unavailable.");
const text = await response.text();
await navigator.clipboard.writeText(text);
window.clearTimeout(timeoutRef.current);
setCopied(true);
timeoutRef.current = window.setTimeout(() => setCopied(false), 2000);
} catch {
// surface as a no-op; chevron menu still offers `View as Markdown`
}
}, [copied, markdownPath]);
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"shrink-0 gap-1.5 px-2.5! text-[13px] leading-none",
copied && "cursor-default",
)}
aria-label={copied ? "Copied" : "Copy Page"}
onClick={() => {
void handleClick();
}}
>
{copied ? (
<IconCheck aria-hidden="true" data-icon="inline-start" />
) : (
<IconCopy aria-hidden="true" data-icon="inline-start" />
)}
<span>{copied ? "Copied" : "Copy Page"}</span>
</Button>
);
}
function getPromptUrl(baseUrl: string, url: string): string {
const promptUrl = new URL(baseUrl);
const prompt = `I have questions about this documentation page for Stanza, a modular scaffolding tool for full-stack TypeScript monorepos: ${url}
Study it and let me know when you're ready for my questions.`;
promptUrl.searchParams.set("q", prompt);
return promptUrl.toString();
}
function MarkdownIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg strokeLinejoin="round" viewBox="0 0 22 16" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.5 2.25H2.5C1.80964 2.25 1.25 2.80964 1.25 3.5V12.5C1.25 13.1904 1.80964 13.75 2.5 13.75H19.5C20.1904 13.75 20.75 13.1904 20.75 12.5V3.5C20.75 2.80964 20.1904 2.25 19.5 2.25ZM2.5 1C1.11929 1 0 2.11929 0 3.5V12.5C0 13.8807 1.11929 15 2.5 15H19.5C20.8807 15 22 13.8807 22 12.5V3.5C22 2.11929 20.8807 1 19.5 1H2.5ZM3 4.5H4H4.25H4.6899L4.98715 4.82428L7 7.02011L9.01285 4.82428L9.3101 4.5H9.75H10H11V5.5V11.5H9V7.79807L7.73715 9.17572L7 9.97989L6.26285 9.17572L5 7.79807V11.5H3V5.5V4.5ZM15 8V4.5H17V8H19.5L17 10.5L16 11.5L15 10.5L12.5 8H15Z"
fill="currentColor"
/>
</svg>
);
}
function ChatGPTIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path
d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5Z"
fill="currentColor"
/>
</svg>
);
}
function ClaudeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path
d="m4.714 15.956 4.718-2.648.079-.23-.08-.128h-.23l-.79-.048-2.695-.073-2.337-.097-2.265-.122-.57-.121-.535-.704.055-.353.48-.321.685.06 1.518.104 2.277.157 1.651.098 2.447.255h.389l.054-.158-.133-.097-.103-.098-2.356-1.596-2.55-1.688-1.336-.972-.722-.491L2 6.223l-.158-1.008.655-.722.88.06.225.061.893.686 1.906 1.476 2.49 1.833.364.304.146-.104.018-.072-.164-.274-1.354-2.446-1.445-2.49-.644-1.032-.17-.619a2.972 2.972 0 0 1-.103-.729L6.287.133 6.7 0l.995.134.42.364.619 1.415L9.735 4.14l1.555 3.03.455.898.243.832.09.255h.159V9.01l.127-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.583.28.48.685-.067.444-.286 1.851-.558 2.903-.365 1.942h.213l.243-.242.983-1.306 1.652-2.064.728-.82.85-.904.547-.431h1.032l.759 1.129-.34 1.166-1.063 1.347-.88 1.142-1.263 1.7-.79 1.36.074.11.188-.02 2.853-.606 1.542-.28 1.84-.315.832.388.09.395-.327.807-1.967.486-2.307.462-3.436.813-.043.03.049.061 1.548.146.662.036h1.62l3.018.225.79.522.473.638-.08.485-1.213.62-1.64-.389-3.825-.91-1.31-.329h-.183v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.321.455-.34-.049-2.204-1.657-.85-.747-1.925-1.62h-.127v.17l.443.649 2.343 3.521.122 1.08-.17.353-.607.213-.668-.122-1.372-1.924-1.415-2.168-1.141-1.943-.14.08-.674 7.254-.316.37-.728.28-.607-.461-.322-.747.322-1.476.388-1.924.316-1.53.285-1.9.17-.632-.012-.042-.14.018-1.432 1.967-2.18 2.945-1.724 1.845-.413.164-.716-.37.066-.662.401-.589 2.386-3.036 1.439-1.882.929-1.086-.006-.158h-.055L4.138 18.56l-1.13.146-.485-.456.06-.746.231-.243 1.907-1.312Z"
fill="currentColor"
/>
</svg>
);
}
function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M8 .188a.5.5 0 0 1 .503.5V4.03l3.022-2.92l.059-.048a.51.51 0 0 1 .49-.054a.5.5 0 0 1 .306.46v3.247h1.117l.1.01a.5.5 0 0 1 .403.49v5.558a.5.5 0 0 1-.503.5H12.38v3.258a.5.5 0 0 1-.312.462a.51.51 0 0 1-.55-.11l-3.016-3.018v3.448c0 .275-.225.5-.503.5a.5.5 0 0 1-.503-.5v-3.448l-3.018 3.019a.51.51 0 0 1-.548.11a.5.5 0 0 1-.312-.463v-3.258H2.503a.5.5 0 0 1-.503-.5V5.215l.01-.1c.047-.229.25-.4.493-.4H3.62V1.469l.006-.074a.5.5 0 0 1 .302-.387a.51.51 0 0 1 .547.102l3.023 2.92V.687c0-.276.225-.5.503-.5M4.626 9.333v3.984l2.87-2.872v-4.01zm3.877 1.113l2.871 2.871V9.333l-2.87-2.897zm3.733-1.668a.5.5 0 0 1 .145.35v1.145h.612V5.715H9.201zm-9.23 1.495h.613V9.13c0-.131.052-.257.145-.35l3.033-3.064h-3.79zm1.62-5.558H6.76L4.626 2.652zm4.613 0h2.134V2.652z"
/>
</svg>
);
}
+19 -7
View File
@@ -5,10 +5,11 @@ import { DocsBody } from "fumadocs-ui/layouts/docs/page";
import { createRelativeLink } from "fumadocs-ui/mdx";
import { RootProvider } from "fumadocs-ui/provider/tanstack";
import { DocsActions } from "@/components/docs/docs-actions";
import { DocsSidebar } from "@/components/docs/docs-sidebar";
import { DocsToc } from "@/components/docs/docs-toc";
import { getMDXComponents } from "@/components/mdx";
import { buildHead, getTechArticleJsonLd } from "@/lib/seo";
import { buildHead, getTechArticleJsonLd, SITE_URL } from "@/lib/seo";
import { source } from "@/lib/source";
import { getDocMeta } from "@/server/docs-meta.functions";
@@ -23,6 +24,8 @@ const getDocLayout = createServerFn({ method: "GET" })
const page = source.getPage(slugs);
if (!page) throw notFound();
const MDX = page.data.body;
const markdownPath = `${page.url}.md`;
const pageUrl = `${SITE_URL.replace(/\/$/, "")}${page.url}`;
return await renderServerComponent(
<RootProvider theme={{ enabled: false }} search={{ enabled: false }}>
<div className="mx-auto max-w-7xl px-4 sm:px-6">
@@ -31,12 +34,21 @@ const getDocLayout = createServerFn({ method: "GET" })
<div className="min-w-0 flex-1">
<div className="xl:flex xl:gap-8">
<article className="min-w-0 flex-1 pt-2 pb-8 md:pt-8">
<h1 className="text-3xl font-medium tracking-tight">{page.data.title}</h1>
{page.data.description && (
<p className="mt-2 text-base leading-normal text-muted-foreground">
{page.data.description}
</p>
)}
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-3xl font-medium tracking-tight">{page.data.title}</h1>
{page.data.description && (
<p className="mt-2 text-base leading-normal text-muted-foreground">
{page.data.description}
</p>
)}
</div>
<DocsActions
markdownPath={markdownPath}
pageUrl={pageUrl}
className="mt-1 shrink-0"
/>
</div>
<DocsBody className="mt-8">
<MDX
components={getMDXComponents({