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 (
+