diff --git a/apps/framework-docs/src/pages/api/docs/raw.ts b/apps/framework-docs/src/pages/api/docs/raw.ts new file mode 100644 index 0000000000..25ef603e5e --- /dev/null +++ b/apps/framework-docs/src/pages/api/docs/raw.ts @@ -0,0 +1,180 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import path from "path"; +import fs from "fs/promises"; +import { extractDecodedParam } from "@/lib/llmHelpers"; + +const DOCS_ROOT = path.join(process.cwd(), "src/pages"); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(`Method ${req.method} Not Allowed`); + } + + const scope = extractDecodedParam(req.query.scope); + const file = extractDecodedParam(req.query.file); + + try { + const resolvedPath = await resolveDocPath({ file, scope }); + + if (!resolvedPath) { + return res.status(404).send("Document not found"); + } + + const content = await fs.readFile(resolvedPath, "utf8"); + res.setHeader("Content-Type", "text/markdown; charset=utf-8"); + res.status(200).send(content); + } catch (error) { + console.error("Failed to read documentation file", error); + res.status(500).send("Internal Server Error"); + } +} + +interface ResolveOptions { + file?: string; + scope?: string; +} + +async function resolveDocPath(options: ResolveOptions) { + const byFile = await resolveByFile(options.file); + if (byFile) { + return byFile; + } + + return resolveByScope(options.scope); +} + +async function resolveByFile(file?: string) { + if (!file) { + return null; + } + + const withoutPrefix = stripKnownPrefixes(file); + const normalized = sanitizePath(withoutPrefix); + if (!normalized) { + return null; + } + + const absolutePath = path.resolve(DOCS_ROOT, normalized); + + if (!absolutePath.startsWith(DOCS_ROOT)) { + return null; + } + + return readIfFile(absolutePath); +} + +async function resolveByScope(scope?: string) { + const normalized = sanitizePath(scope); + + const candidates = buildScopeCandidates(normalized); + + for (const candidate of candidates) { + const absolutePath = path.resolve(DOCS_ROOT, candidate); + + if (!absolutePath.startsWith(DOCS_ROOT)) { + continue; + } + + const file = await readIfFile(absolutePath); + if (file) { + return file; + } + } + + return null; +} + +function buildScopeCandidates(scope?: string) { + if (!scope) { + return ["index.mdx", "index.md"]; + } + + const scopedIndex = path.join(scope, "index"); + + return [ + `${scope}.mdx`, + `${scope}.md`, + `${scope}.markdown`, + `${scope}.mdoc`, + `${scopedIndex}.mdx`, + `${scopedIndex}.md`, + `${scopedIndex}.markdown`, + `${scopedIndex}.mdoc`, + ]; +} + +async function readIfFile(targetPath: string) { + try { + const stats = await fs.stat(targetPath); + + if (stats.isFile()) { + return targetPath; + } + } catch { + // Ignore missing files + } + + return null; +} + +function sanitizePath(raw?: string) { + if (!raw) { + return undefined; + } + + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + + const withoutLeadingSlash = trimmed.replace(/^[/\\]+/, ""); + + const segments = withoutLeadingSlash + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter(Boolean); + + if ( + segments.length === 0 || + segments.some( + (segment) => + segment === ".." || segment === "." || segment.includes(".."), + ) + ) { + return undefined; + } + + return segments.join(path.sep); +} + +function stripKnownPrefixes(value: string) { + let trimmed = value.trim(); + + const prefixes = [ + "apps/framework-docs/src/pages/", + "src/pages/", + "pages/", + "./", + ".\\", + ]; + + let prefixApplied = true; + + while (prefixApplied) { + prefixApplied = false; + + for (const prefix of prefixes) { + if (trimmed.startsWith(prefix)) { + trimmed = trimmed.slice(prefix.length); + prefixApplied = true; + break; + } + } + } + + return trimmed; +} diff --git a/apps/framework-docs/src/styles/globals.css b/apps/framework-docs/src/styles/globals.css index 76fa40da2d..bfea25a9ba 100644 --- a/apps/framework-docs/src/styles/globals.css +++ b/apps/framework-docs/src/styles/globals.css @@ -526,4 +526,4 @@ li:not(:first-child) { .sticky[class*="border"] { border-color: hsl(var(--border)) !important; } -} \ No newline at end of file +} diff --git a/apps/framework-docs/theme.config.jsx b/apps/framework-docs/theme.config.jsx index 8b19491db6..668a871337 100644 --- a/apps/framework-docs/theme.config.jsx +++ b/apps/framework-docs/theme.config.jsx @@ -12,10 +12,21 @@ import { BreadcrumbList, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useRouter } from "next/router"; import { useConfig, useThemeConfig } from "nextra-theme-docs"; import { PathConfig } from "./src/components/ctas"; import { GitHubStarsButton } from "@/components"; +import { Bot, ChevronDown, Copy, FileText, Sparkles } from "lucide-react"; // Base text styles that match your typography components const baseTextStyles = { @@ -26,32 +37,251 @@ const baseTextStyles = { heading: "text-primary font-semibold", }; -function buildLlmHref(asPath, suffix) { - if (!suffix) { - return "/"; - } +const DEFAULT_SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "https://docs.fiveonefour.com"; +function normalizePath(asPath) { const safePath = asPath || "/"; - const pathWithoutHash = safePath.split("#")[0]; - const pathWithoutQuery = pathWithoutHash.split("?")[0]; + const pathWithoutQuery = pathWithoutHash.split("?")[0] || "/"; if (!pathWithoutQuery || pathWithoutQuery === "/") { - return `/${suffix}`; + return "/"; } - const trimmedPath = - pathWithoutQuery.endsWith("/") ? + return pathWithoutQuery.endsWith("/") ? pathWithoutQuery.slice(0, -1) : pathWithoutQuery; +} + +function resolveAbsoluteUrl(path, origin) { + if (!path) { + return origin; + } + + if (/^https?:\/\//.test(path)) { + return path; + } + + const base = origin.endsWith("/") ? origin.slice(0, -1) : origin; + const normalized = path.startsWith("/") ? path : `/${path}`; + + return `${base}${normalized}`; +} + +function buildLlmHref(asPath, suffix) { + if (!suffix) { + return "/"; + } + + const normalizedPath = normalizePath(asPath); + + if (!normalizedPath || normalizedPath === "/") { + return `/${suffix}`; + } + + return `${normalizedPath}/${suffix}`; +} - return `${trimmedPath}/${suffix}`; +function buildLlmPrompt(languageLabel, canonicalPageUrl, docUrl) { + return ( + `I'm looking at the Moose documentation: ${canonicalPageUrl}. ` + + `Use the ${languageLabel} LLM doc for additional context: ${docUrl}. ` + + "Help me understand how to use it. Be ready to explain concepts, give examples, or help debug based on it." + ); +} + +async function copyTextToClipboard(text) { + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(text); + return true; + } + + if (typeof document === "undefined") { + throw new Error("Clipboard API unavailable"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + + try { + textarea.focus(); + textarea.select(); + const successful = document.execCommand("copy"); + if (!successful) { + throw new Error("document.execCommand('copy') returned false"); + } + return true; + } finally { + document.body.removeChild(textarea); + } +} + +function LlmHelperMenu({ buttonClassName, align = "start" } = {}) { + const { pageOpts } = useConfig(); + const { asPath } = useRouter(); + + const resolvedFilePath = pageOpts?.filePath; + const tsHref = buildLlmHref(asPath, "llm-ts.txt"); + const pyHref = buildLlmHref(asPath, "llm-py.txt"); + const normalizedPath = normalizePath(asPath); + + const canonicalPageUrl = + !normalizedPath || normalizedPath === "/" ? + DEFAULT_SITE_URL + : resolveAbsoluteUrl(normalizedPath, DEFAULT_SITE_URL); + + const scopeParam = + normalizedPath && normalizedPath !== "/" ? + normalizedPath.replace(/^\/+/, "") + : undefined; + + const rawDocParams = new URLSearchParams(); + + if (scopeParam) { + rawDocParams.set("scope", scopeParam); + } + + if (resolvedFilePath) { + rawDocParams.set("file", resolvedFilePath); + } + + const rawDocUrl = `/api/docs/raw${ + rawDocParams.size > 0 ? `?${rawDocParams.toString()}` : "" + }`; + + const handleOpenDoc = (target) => () => { + if (typeof window === "undefined") { + return; + } + + const absoluteUrl = resolveAbsoluteUrl(target, window.location.origin); + window.open(absoluteUrl, "_blank", "noopener,noreferrer"); + }; + + const handleOpenChatGpt = (languageLabel, docTarget) => () => { + if (typeof window === "undefined") { + return; + } + + const docUrl = resolveAbsoluteUrl(docTarget, DEFAULT_SITE_URL); + const prompt = buildLlmPrompt(languageLabel, canonicalPageUrl, docUrl); + + const chatGptUrl = + "https://chatgpt.com/?prompt=" + encodeURIComponent(prompt); + + window.open(chatGptUrl, "_blank", "noopener,noreferrer"); + }; + + const handleOpenClaude = (languageLabel, docTarget) => async () => { + if (typeof window === "undefined") { + return; + } + + const docUrl = resolveAbsoluteUrl(docTarget, DEFAULT_SITE_URL); + const prompt = buildLlmPrompt(languageLabel, canonicalPageUrl, docUrl); + + try { + await copyTextToClipboard(prompt); + } catch (error) { + console.warn("Failed to copy Claude prompt to clipboard", error); + } + + const claudeUrl = + "https://claude.ai/new?prompt=" + encodeURIComponent(prompt); + window.open(claudeUrl, "_blank", "noopener,noreferrer"); + }; + + const handleCopyMarkdown = async () => { + if (typeof window === "undefined") { + return; + } + + try { + const response = await fetch(rawDocUrl); + if (!response.ok) { + throw new Error(`Failed to fetch markdown: ${response.status}`); + } + + const markdown = await response.text(); + await copyTextToClipboard(markdown); + } catch (error) { + console.error("Failed to copy page markdown", error); + } + }; + + return ( + + + + + + Doc utilities + + + Copy page Markdown + MD + + + View as .txt + + + View TypeScript doc + TS + + + + View Python doc + PY + + + Send to Claude + + + Claude · TypeScript + TS + + + + Claude · Python + PY + + + Send to ChatGPT + + + ChatGPT · TypeScript + TS + + + + ChatGPT · Python + PY + + + + ); } function EditLinks({ filePath, href, className, children }) { const { pageOpts } = useConfig(); const { docsRepositoryBase } = useThemeConfig(); - const { asPath } = useRouter(); const resolvedFilePath = filePath || pageOpts?.filePath; @@ -66,9 +296,6 @@ function EditLinks({ filePath, href, className, children }) { `${cleanedRepoBase}/${resolvedFilePath}` : undefined); - const tsHref = buildLlmHref(asPath, "llm-ts.txt"); - const pyHref = buildLlmHref(asPath, "llm-py.txt"); - return (
{editHref ? @@ -81,26 +308,7 @@ function EditLinks({ filePath, href, className, children }) { {children} : {children}} - - LLM docs:{" "} - - TS - {" "} - /{" "} - - PY - - +
); } @@ -245,12 +453,7 @@ export default { navbar: { extraContent: () => , }, - // main: ({ children }) => ( - //
- // {children} - // - //
- // ), + main: ({ children }) => <>{children}, navigation: { prev: true, next: true,