diff --git a/docs-site/app/docs/[[...slug]]/page.tsx b/docs-site/app/docs/[[...slug]]/page.tsx index e0677c9e9..ab3af901a 100644 --- a/docs-site/app/docs/[[...slug]]/page.tsx +++ b/docs-site/app/docs/[[...slug]]/page.tsx @@ -9,7 +9,6 @@ import { notFound, redirect } from "next/navigation"; import defaultMdxComponents from "fumadocs-ui/mdx"; import { CodeBlock } from "@/components/code-block"; import { DocsPageActions } from "@/components/docs-page-actions"; -import { readDocsPageMarkdown } from "@/lib/docs-markdown"; const docsIndexPath = "/docs/getting-started/introduction"; const docsIndexSlug = ["getting-started", "introduction"] as const; @@ -34,7 +33,6 @@ export default async function Page(props: { if (!page) notFound(); const MDX = page.data.body; - const mdxSource = await readDocsPageMarkdown(page.slugs); const hero = isHeroPage(params.slug); @@ -51,13 +49,24 @@ export default async function Page(props: { <>
{page.data.title} - +
{page.data.description} )} + {hero && ( +
+ +
+ )} diff --git a/docs-site/components/docs-page-actions.tsx b/docs-site/components/docs-page-actions.tsx index ce85aca05..2263153d6 100644 --- a/docs-site/components/docs-page-actions.tsx +++ b/docs-site/components/docs-page-actions.tsx @@ -1,38 +1,318 @@ "use client"; -import { useState } from "react"; +import { type ReactNode, useEffect, useId, useRef, useState } from "react"; type Props = { - mdxSource: string; + title: string; + markdownHref: string; }; -function stripFrontmatter(source: string) { - return source.trim().replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); -} - -export function DocsPageActions({ mdxSource }: Props) { +export function DocsPageActions({ title, markdownHref }: Props) { const [copied, setCopied] = useState(false); + const [open, setOpen] = useState(false); + const panelId = useId(); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const closeOnOutsidePointer = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + + document.addEventListener("pointerdown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); const onCopy = async () => { try { - await navigator.clipboard.writeText(stripFrontmatter(mdxSource)); + const response = await fetch(markdownHref, { + headers: { Accept: "text/markdown" }, + }); + if (!response.ok) { + throw new Error(`Markdown request failed: ${response.status}`); + } + + await navigator.clipboard.writeText(await response.text()); setCopied(true); + setOpen(false); setTimeout(() => setCopied(false), 1500); } catch { - // Clipboard denied - fail silently + // Clipboard denied or Markdown unavailable - fail silently. } }; + const publicMarkdownUrl = `https://docs.kaelio.com/ktx${markdownHref}`; + const assistantPrompt = + `Use this documentation page to answer questions about ${title}: ${publicMarkdownUrl}`; + const chatGptHref = + `https://chatgpt.com/?q=${encodeURIComponent(assistantPrompt)}`; + const claudeHref = + `https://claude.ai/new?q=${encodeURIComponent(assistantPrompt)}`; + return ( -
+
+ {open && ( +
+ } + title="Copy page" + description="Copy page as Markdown for LLMs" + onClick={onCopy} + /> + } + title="View as Markdown" + description="View this page as plain text" + href={markdownHref} + onClick={() => setOpen(false)} + /> + } + title="Open in ChatGPT" + description="Ask questions about this page" + href={chatGptHref} + onClick={() => setOpen(false)} + /> + } + title="Open in Claude" + description="Ask questions about this page" + href={claudeHref} + onClick={() => setOpen(false)} + /> +
+ )}
); } + +function ActionButton({ + icon, + title, + description, + onClick, +}: { + icon: ReactNode; + title: string; + description: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ActionLink({ + icon, + title, + description, + href, + onClick, +}: { + icon: ReactNode; + title: string; + description: string; + href: string; + onClick: () => void; +}) { + return ( + + {icon} + + + ); +} + +function ActionIcon({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function ActionText({ + title, + description, + external, +}: { + title: string; + description: string; + external?: boolean; +}) { + return ( + + + {title} + {external && } + + + {description} + + + ); +} + +function CopyIcon() { + return ( + + ); +} + +function CheckIcon() { + return ( + + ); +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} + +function MarkdownIcon() { + return ( + + ); +} + +function OpenAiIcon() { + return ( + + ); +} + +function ClaudeIcon() { + return ( + + ); +} + +function ExternalIcon() { + return ( + + ); +} diff --git a/docs-site/tests/docs-search-behavior.test.mjs b/docs-site/tests/docs-search-behavior.test.mjs index ece514776..907794c76 100644 --- a/docs-site/tests/docs-search-behavior.test.mjs +++ b/docs-site/tests/docs-search-behavior.test.mjs @@ -34,6 +34,28 @@ test("markdown negotiation uses the Next proxy convention", async () => { assert.doesNotMatch(proxy, /export function middleware/); }); +test("docs pages expose a canonical Markdown copy disclosure", async () => { + const page = await readDocsFile("app/docs/[[...slug]]/page.tsx"); + const action = await readDocsFile("components/docs-page-actions.tsx"); + + assert.match(page, / { const css = await readDocsFile("app/global.css");