diff --git a/apps/web/src/components/docs/docs-actions.tsx b/apps/web/src/components/docs/docs-actions.tsx new file mode 100644 index 0000000..6728163 --- /dev/null +++ b/apps/web/src/components/docs/docs-actions.tsx @@ -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) => 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 ( + + + + }> + + + + {menuItems.map((item) => { + const Icon = item.icon; + const href = item.href({ markdownPath, pageUrl }); + return ( + } + > + + ); + })} + + + + + ); +} + +function CopyMarkdownButton({ markdownPath }: { markdownPath: string }) { + const [copied, setCopied] = React.useState(false); + const timeoutRef = React.useRef(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 ( + + ); +} + +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) { + return ( + + + + ); +} + +function ChatGPTIcon(props: SVGProps) { + return ( + + + + ); +} + +function ClaudeIcon(props: SVGProps) { + return ( + + + + ); +} + +function PerplexityIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/routes/docs.$.tsx b/apps/web/src/routes/docs.$.tsx index 54db9d7..14753d2 100644 --- a/apps/web/src/routes/docs.$.tsx +++ b/apps/web/src/routes/docs.$.tsx @@ -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(
@@ -31,12 +34,21 @@ const getDocLayout = createServerFn({ method: "GET" })
-

{page.data.title}

- {page.data.description && ( -

- {page.data.description} -

- )} +
+
+

{page.data.title}

+ {page.data.description && ( +

+ {page.data.description} +

+ )} +
+ +