feat(docs): integrate multi-page documentation into OmniRoute dashboard — Closes #1958 (#1969)

Integrated into release/v3.7.9
This commit is contained in:
Paijo
2026-05-05 19:32:43 +07:00
committed by GitHub
parent 8077e9b62b
commit 7dab1fb731
38 changed files with 4979 additions and 605 deletions

1178
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,7 @@
"homepage": "https://omniroute.online",
"scripts": {
"dev": "node scripts/run-next.mjs dev",
"prebuild:docs": "node scripts/generate-docs-index.mjs",
"build": "node scripts/build-next-isolated.mjs",
"build:cli": "node --import tsx/esm scripts/prepublish.ts",
"start": "node scripts/run-next.mjs start",
@@ -121,12 +122,15 @@
"bottleneck": "^2.19.5",
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"fuse.js": "^7.3.0",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^9.0.0",
"jose": "^6.1.3",
"js-yaml": "^4.1.0",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.14.0",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-intl": "^4.11.0",
@@ -172,6 +176,8 @@
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.2.4",
"glob": "^13.0.6",
"gray-matter": "^4.0.3",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.2.7",

View File

@@ -184,15 +184,21 @@ export async function main() {
await resetStandaloneOutput(projectRoot);
console.log("[build-next-isolated] Generating docs index...");
try {
const { execSync } = await import("node:child_process");
execSync("node scripts/generate-docs-index.mjs", { cwd: projectRoot, stdio: "inherit" });
} catch (docGenErr) {
console.warn(
"[build-next-isolated] Docs index generation failed (non-fatal):",
docGenErr?.message
);
}
const result = await runNextBuild();
if (result.code === 0 && (await exists(path.join(projectRoot, ".next", "standalone")))) {
console.log("[build-next-isolated] Copying static assets for standalone server...");
try {
await fs.cp(
path.join(projectRoot, "public"),
path.join(projectRoot, ".next", "standalone", "public"),
{ recursive: true }
);
await fs.cp(
path.join(projectRoot, ".next", "static"),
path.join(projectRoot, ".next", "standalone", ".next", "static"),
@@ -202,6 +208,17 @@ export async function main() {
console.warn("[build-next-isolated] Non-fatal error copying static assets:", copyErr);
}
try {
await fs.cp(
path.join(projectRoot, "docs"),
path.join(projectRoot, ".next", "standalone", "docs"),
{ recursive: true }
);
console.log("[build-next-isolated] Copied docs/ to standalone output");
} catch (docsCopyErr) {
console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message);
}
try {
await pruneStandaloneArtifacts(projectRoot);
} catch (pruneErr) {

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Build-time script: scans docs/*.md and generates
* src/app/docs/lib/docs-auto-generated.ts with static navigation + search data.
*
* This file is imported by both client and server components — NO fs/path imports.
* Run via: node scripts/generate-docs-index.mjs
* Automatically runs as prebuild step.
*/
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const DOCS_DIR = path.join(ROOT, "docs");
const OUT_FILE = path.join(ROOT, "src", "app", "docs", "lib", "docs-auto-generated.ts");
const SECTION_CATEGORIES = {
"Getting Started": [
"SETUP_GUIDE",
"USER_GUIDE",
"CLI_TOOLS",
"ARCHITECTURE",
"QUICK_START",
"GETTING_STARTED",
],
Features: [
"FEATURES",
"AUTO_COMBO",
"COMPRESSION_GUIDE",
"RTK_COMPRESSION",
"COMPRESSION_ENGINES",
"COMPRESSION_RULES_FORMAT",
"COMPRESSION_LANGUAGE_PACKS",
"FREE_TIERS",
],
"API & Protocols": ["API_REFERENCE", "MCP_SERVER", "A2A_SERVER"],
Deployment: [
"DOCKER_GUIDE",
"VM_DEPLOYMENT_GUIDE",
"FLY_IO_DEPLOYMENT_GUIDE",
"TERMUX_GUIDE",
"PWA_GUIDE",
],
Operations: ["PROXY_GUIDE", "RESILIENCE_GUIDE", "ENVIRONMENT", "TROUBLESHOOTING"],
Development: [
"CODEBASE_DOCUMENTATION",
"COVERAGE_PLAN",
"I18N",
"RELEASE_CHECKLIST",
"UNINSTALL",
"CONTRIBUTING",
"CHANGELOG",
"CODE_OF_CONDUCT",
],
};
const SECTION_ORDER = {
"Getting Started": 1,
Features: 2,
"API & Protocols": 3,
Deployment: 4,
Operations: 5,
Development: 6,
};
function categorizeFile(fileName) {
const stem = fileName.replace(/\.md$/i, "").toUpperCase().replace(/-/g, "_");
for (const [section, patterns] of Object.entries(SECTION_CATEGORIES)) {
if (patterns.some((p) => stem === p)) {
return section;
}
}
return "Other";
}
function extractTitleFromContent(content) {
const match = content.match(/^#\s+(.+)$/m);
if (match) {
return match[1]
.replace(/^📖\s*/, "")
.replace(/^🌐\s*/, "")
.replace(/\s*—\s*OmniRoute\s*$/i, "")
.replace(/\s*—\s*OmniRoute Docs\s*$/i, "")
.trim();
}
return "";
}
function extractHeadings(content) {
const headings = [];
const regex = /^(#{2,4})\s+(.+)$/gm;
let match;
while ((match = regex.exec(content)) !== null) {
headings.push(match[2].replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, "").trim());
}
return headings.slice(0, 10);
}
function extractContentPreview(content) {
const stripped = content
.replace(/^---[\s\S]*?---/m, "")
.replace(/^#{1,6}\s+.+$/gm, "")
.replace(/```[\s\S]*?```/g, "")
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_`~>#|]/g, "")
.replace(/\s+/g, " ")
.trim();
return stripped.slice(0, 300);
}
// ---------- Main ----------
const files = fs.readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md") || f.endsWith(".mdx"));
const docs = [];
for (const fileName of files) {
const filePath = path.join(DOCS_DIR, fileName);
const fileContent = fs.readFileSync(filePath, "utf8");
const { data: frontmatter, content } = matter(fileContent);
const slug =
frontmatter.slug ||
fileName
.replace(/\.mdx?$/i, "")
.toLowerCase()
.replace(/_/g, "-");
const title = frontmatter.title || extractTitleFromContent(content) || slug.replace(/-/g, " ");
const section = frontmatter.section || categorizeFile(fileName);
const order = frontmatter.order ?? 999;
const headings = extractHeadings(content);
const contentPreview = frontmatter.description || extractContentPreview(content);
docs.push({ slug, title, fileName, section, order, content: contentPreview, headings });
}
docs.sort((a, b) => {
const sectionA = SECTION_ORDER[a.section] ?? 99;
const sectionB = SECTION_ORDER[b.section] ?? 99;
if (sectionA !== sectionB) return sectionA - sectionB;
return a.order - b.order;
});
// Build navigation sections
const sectionMap = new Map();
for (const doc of docs) {
const items = sectionMap.get(doc.section) || [];
items.push(doc);
sectionMap.set(doc.section, items);
}
const orderedSections = [...new Set([...Object.keys(SECTION_ORDER), ...sectionMap.keys()])];
const navSections = orderedSections
.filter((s) => sectionMap.has(s))
.sort((a, b) => (SECTION_ORDER[a] ?? 99) - (SECTION_ORDER[b] ?? 99))
.map((title) => ({
title,
items: sectionMap.get(title).map((doc) => ({
slug: doc.slug,
title: doc.title,
fileName: doc.fileName,
})),
}));
// Build search index
const searchIndex = docs.map((doc) => ({
slug: doc.slug,
title: doc.title,
fileName: doc.fileName,
section: doc.section,
content: doc.content,
headings: doc.headings,
}));
// Add api-explorer synthetic entry if api-reference exists
if (searchIndex.some((item) => item.slug === "api-reference")) {
if (!searchIndex.some((item) => item.slug === "api-explorer")) {
searchIndex.push({
slug: "api-explorer",
title: "API Explorer",
fileName: "API_REFERENCE.md",
section: "API & Protocols",
content: "interactive try it live api explorer endpoint test request response curl example",
headings: ["Try It", "Endpoints"],
});
}
}
// ---------- Write output ----------
const output = `// AUTO-GENERATED by scripts/generate-docs-index.mjs — DO NOT EDIT MANUALLY
// Regenerate with: node scripts/generate-docs-index.mjs
export interface AutoGenDocItem {
slug: string;
title: string;
fileName: string;
}
export interface AutoGenNavSection {
title: string;
items: AutoGenDocItem[];
}
export interface AutoGenSearchItem {
slug: string;
title: string;
fileName: string;
section: string;
content: string;
headings: string[];
}
export const autoNavSections: AutoGenNavSection[] = ${JSON.stringify(navSections, null, 2)};
export const autoSearchIndex: AutoGenSearchItem[] = ${JSON.stringify(searchIndex, null, 2)};
export const autoAllSlugs: string[] = ${JSON.stringify(
docs.map((d) => d.slug),
null,
2
)};
`;
fs.writeFileSync(OUT_FILE, output, "utf8");
console.log(`✅ Generated ${OUT_FILE} with ${docs.length} docs, ${navSections.length} sections`);

View File

@@ -0,0 +1,326 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { docsNavigation } from "../lib/docsNavigation";
import { autoAllSlugs, autoNavSections } from "../lib/docs-auto-generated";
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { Metadata } from "next";
import { DocCodeBlocks } from "../components/DocCodeBlocks";
import { FeedbackWidget } from "../components/FeedbackWidget";
import { DocsPageAnalytics } from "../components/DocsPageAnalytics";
import { DocsLazyWrapper } from "../components/DocsLazyWrapper";
import { MermaidChartsClient } from "../components/MermaidChartsClient";
export function generateStaticParams() {
return autoAllSlugs.map((slug) => ({ slug }));
}
export function getDocItemBySlug(slug: string) {
for (const section of docsNavigation) {
const item = section.items.find((item) => item.slug === slug);
if (item) {
return { sectionTitle: section.title, item };
}
}
for (const section of autoNavSections) {
const item = section.items.find((i) => i.slug === slug);
if (item) {
return {
sectionTitle: section.title,
item: { slug: item.slug, title: item.title, fileName: item.fileName },
};
}
}
return null;
}
export function getAllDocSlugsFlat(): string[] {
return autoAllSlugs;
}
export function getPrevNextSlugs(currentSlug: string) {
const allSlugs = getAllDocSlugsFlat();
const idx = allSlugs.indexOf(currentSlug);
return {
prev: idx > 0 ? allSlugs[idx - 1] : null,
next: idx < allSlugs.length - 1 ? allSlugs[idx + 1] : null,
};
}
export function extractHeadings(content: string): { id: string; text: string; level: number }[] {
const headings: { id: string; text: string; level: number }[] = [];
const regex = /^(#{2,4})\s+(.+)$/gm;
let match;
while ((match = regex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, "");
const id = text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
headings.push({ id, text, level });
}
return headings;
}
export function extractMermaidCharts(content: string): string[] {
const charts: string[] = [];
const regex = /```mermaid\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(content)) !== null) {
charts.push(match[1].trim());
}
return charts;
}
export function renderMarkdown(content: string): string {
return content
.replace(/^####\s+(.*)$/gm, '<h4 id="$1" class="text-lg font-bold mb-2 mt-6">$1</h4>')
.replace(/^###\s+(.*)$/gm, '<h3 id="$1" class="text-xl font-bold mb-3 mt-8">$1</h3>')
.replace(/^##\s+(.*)$/gm, '<h2 id="$1" class="text-2xl font-bold mb-4 mt-10">$1</h2>')
.replace(/^#\s+(.*)$/gm, '<h1 class="text-3xl font-bold mb-4">$1</h1>')
.replace(
/```mermaid\n([\s\S]*?)```/g,
'<div class="mermaid-diagram-fallback my-6" data-mermaid="$1">$1</div>'
)
.replace(
/```(\w*)\n([\s\S]*?)```/g,
'<div class="group relative"><pre class="bg-bg-subtle p-4 rounded-lg overflow-x-auto"><code class="language-$1">$2</code></pre></div>'
)
.replace(/`([^`]+)`/g, '<code class="bg-bg-subtle px-2 py-1 rounded text-sm">$1</code>')
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/__(.*?)__/g, "<strong>$1</strong>")
.replace(/_(.*?)_/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-primary hover:underline">$1</a>')
.replace(
/!\[([^\]]+)\]\(([^)]+)\)/g,
'<img src="$2" alt="$1" class="max-w-full rounded-lg my-4">'
)
.replace(/^(\*|\-)\s+(.*)$/gm, '<li class="mb-1 ml-4">$2</li>')
.replace(/^(\d+)\.\s+(.*)$/gm, '<li class="mb-1 ml-4">$2</li>')
.replace(/^\|\s*(.+?)\s*\|$/gm, (match) => {
if (match.match(/^\|\s*[-:]+[-|\s:]*$/)) return "";
const cells = match
.split("|")
.filter((c) => c.trim())
.map((c) => `<td class="border border-border p-2 text-sm">${c.trim()}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.replace(
/(<tr>.*<\/tr>\n?)+/g,
(match) =>
`<table class="w-full border-collapse mb-4 text-sm"><tbody>${match}</tbody></table>`
)
.replace(
/^>\s+(.*)$/gm,
'<blockquote class="border-l-4 border-primary/30 pl-4 italic text-text-muted mb-4">$1</blockquote>'
)
.replace(/^---$/gm, '<hr class="border-border my-8">');
}
function cleanHeadingIds(html: string): string {
return html.replace(
/id="([^"]*)"/g,
(_, id) =>
`id="${id
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")}"`
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const docItem = getDocItemBySlug(slug);
if (!docItem) {
return {
title: "Document Not Found",
};
}
return {
title: `${docItem.item.title} — OmniRoute Docs`,
description: `OmniRoute documentation: ${docItem.item.title}`,
};
}
export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const docItem = getDocItemBySlug(slug);
if (!docItem) {
notFound();
}
const { sectionTitle, item } = docItem;
let pageTitle = item.title;
let htmlContent = "";
let headings: { id: string; text: string; level: number }[] = [];
let loadError: string | null = null;
let version: string | null = null;
let lastUpdated: string | null = null;
let mermaidCharts: string[] = [];
try {
const docsRoot = path.join(process.cwd(), "docs");
const fileContent = fs.readFileSync(path.join(docsRoot, item.fileName), "utf8");
const { content, data: frontmatter } = matter(fileContent);
pageTitle = (frontmatter.title as string) || item.title;
version = (frontmatter.version as string) || null;
lastUpdated = (frontmatter.lastUpdated as string) || null;
mermaidCharts = extractMermaidCharts(content);
headings = extractHeadings(content);
const rawHtml = renderMarkdown(content);
htmlContent = cleanHeadingIds(rawHtml);
} catch (error) {
console.error(`Failed to read doc file for slug: ${slug}`, error);
loadError = error instanceof Error ? error.message : "Unknown error";
}
if (loadError) {
return (
<div className="text-red-500 p-4 border border-red-200 bg-red-50 rounded-lg">
<h2 className="text-xl font-bold mb-2">Error Loading Documentation</h2>
<p>Failed to load {item.fileName}. Please try again later.</p>
<p className="text-sm mt-2 text-gray-600">Error: {loadError}</p>
</div>
);
}
const { prev, next } = getPrevNextSlugs(slug);
const prevItem = prev ? getDocItemBySlug(prev) : null;
const nextItem = next ? getDocItemBySlug(next) : null;
const breadcrumbJsonLd = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Docs",
item: `https://omniroute.online/docs`,
},
{
"@type": "ListItem",
position: 2,
name: sectionTitle,
item: `https://omniroute.online/docs/${slug}`,
},
{
"@type": "ListItem",
position: 3,
name: pageTitle,
},
],
};
return (
<>
<DocsPageAnalytics slug={slug} title={pageTitle} section={sectionTitle} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<div className="flex gap-8">
<div className="flex-1 min-w-0">
<nav className="mb-6" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-text-muted">
<li>
<Link href="/docs" className="hover:text-text-main">
Docs
</Link>
</li>
<li className='before:content-["&gt;"] before:mx-2'>{sectionTitle}</li>
<li className='before:content-["&gt;"] before:mx-2'>{pageTitle}</li>
</ol>
</nav>
<div className="flex items-center gap-3 mb-6">
<h1 className="text-3xl font-bold text-text-main">{pageTitle}</h1>
{version && (
<span className="px-2 py-0.5 text-xs font-mono bg-primary/10 text-primary border border-primary/20 rounded">
v{version}
</span>
)}
</div>
{lastUpdated && (
<p className="text-xs text-text-muted mb-4">Last updated: {lastUpdated}</p>
)}
<DocsLazyWrapper>
<div className="prose-content" dangerouslySetInnerHTML={{ __html: htmlContent }} />
</DocsLazyWrapper>
{mermaidCharts.length > 0 && (
<DocsLazyWrapper>
<MermaidChartsClient charts={mermaidCharts} />
</DocsLazyWrapper>
)}
<DocCodeBlocks />
<FeedbackWidget slug={slug} />
<div className="flex items-center justify-between border-t border-border pt-6 mt-12">
{prevItem ? (
<Link
href={`/docs/${prev}`}
className="flex items-center gap-2 text-sm text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">arrow_back</span>
{prevItem.item.title}
</Link>
) : (
<div />
)}
{nextItem ? (
<Link
href={`/docs/${next}`}
className="flex items-center gap-2 text-sm text-text-muted hover:text-primary transition-colors"
>
{nextItem.item.title}
<span className="material-symbols-outlined text-sm">arrow_forward</span>
</Link>
) : (
<div />
)}
</div>
</div>
{headings.length > 0 && (
<aside className="hidden xl:block w-56 shrink-0">
<div className="sticky top-8">
<h4 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
On this page
</h4>
<nav className="space-y-1">
{headings.map((heading) => (
<a
key={heading.id}
href={`#${heading.id}`}
className={`block text-sm text-text-muted hover:text-primary transition-colors truncate
${heading.level === 3 ? "pl-3" : ""}
${heading.level === 4 ? "pl-6" : ""}`}
>
{heading.text}
</a>
))}
</nav>
</div>
</aside>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,20 @@
import { Metadata } from "next";
import { ApiExplorerClient } from "../components/ApiExplorerClient";
export const metadata: Metadata = {
title: "API Explorer — OmniRoute Docs",
description: "Interactive API explorer — try OmniRoute endpoints live with real-time responses",
};
export default function ApiExplorerPage() {
return (
<div>
<h1 className="text-3xl font-bold text-text-main mb-2">API Explorer</h1>
<p className="text-text-muted mb-8">
Try OmniRoute endpoints live. Select an endpoint, configure your request, and see the
response in real time.
</p>
<ApiExplorerClient />
</div>
);
}

View File

@@ -0,0 +1,298 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
interface ApiEndpoint {
method: string;
path: string;
description: string;
tag: string;
}
const API_ENDPOINTS: ApiEndpoint[] = [
{
method: "POST",
path: "/v1/chat/completions",
description: "OpenAI-compatible chat completions with streaming support",
tag: "Chat",
},
{
method: "POST",
path: "/v1/responses",
description: "OpenAI Responses API format",
tag: "Responses",
},
{
method: "GET",
path: "/v1/models",
description: "List available models across all providers",
tag: "Models",
},
{
method: "POST",
path: "/v1/embeddings",
description: "Generate text embeddings",
tag: "Embeddings",
},
{
method: "POST",
path: "/v1/images/generations",
description: "Generate images from text prompts",
tag: "Images",
},
{
method: "POST",
path: "/v1/audio/transcriptions",
description: "Transcribe audio files",
tag: "Audio",
},
{
method: "POST",
path: "/v1/audio/speech",
description: "Text-to-speech generation",
tag: "Audio",
},
{
method: "POST",
path: "/v1/moderations",
description: "Content moderation check",
tag: "Moderations",
},
{
method: "POST",
path: "/v1/rerank",
description: "Re-rank documents by relevance",
tag: "Rerank",
},
{
method: "POST",
path: "/v1/search",
description: "Web search across 5 providers",
tag: "Search",
},
{
method: "POST",
path: "/v1/videos/generations",
description: "Generate videos from prompts",
tag: "Video",
},
{
method: "POST",
path: "/v1/music/generations",
description: "Generate music from prompts",
tag: "Music",
},
];
const METHOD_COLORS: Record<string, string> = {
GET: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20",
POST: "bg-blue-500/10 text-blue-600 border-blue-500/20",
PUT: "bg-amber-500/10 text-amber-600 border-amber-500/20",
DELETE: "bg-red-500/10 text-red-600 border-red-500/20",
PATCH: "bg-purple-500/10 text-purple-600 border-purple-500/20",
};
const EXAMPLE_BODIES: Record<string, string> = {
"/v1/chat/completions": JSON.stringify(
{ model: "openai/gpt-4o-mini", messages: [{ role: "user", content: "Hello!" }], stream: true },
null,
2
),
"/v1/models": "",
"/v1/embeddings": JSON.stringify(
{ model: "openai/text-embedding-3-small", input: "Hello world" },
null,
2
),
"/v1/images/generations": JSON.stringify(
{ model: "openai/dall-e-3", prompt: "A sunset over mountains", n: 1 },
null,
2
),
"/v1/responses": JSON.stringify(
{ model: "openai/gpt-4o-mini", input: "What is OmniRoute?" },
null,
2
),
};
export function ApiExplorerClient() {
const [selected, setSelected] = useState<ApiEndpoint | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
const [apiKey, setApiKey] = useState("");
const [requestBody, setRequestBody] = useState("");
const [response, setResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [filterTag, setFilterTag] = useState<string | null>(null);
const allTags = [...new Set(API_ENDPOINTS.map((e) => e.tag))];
const filteredEndpoints = filterTag
? API_ENDPOINTS.filter((e) => e.tag === filterTag)
: API_ENDPOINTS;
const handleSelect = useCallback((endpoint: ApiEndpoint) => {
setSelected(endpoint);
setResponse(null);
const example = EXAMPLE_BODIES[endpoint.path] || "";
setRequestBody(example);
}, []);
const handleTryIt = async () => {
if (!selected) return;
setLoading(true);
setResponse(null);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const opts: RequestInit = { method: selected.method, headers };
if (selected.method !== "GET" && requestBody.trim()) {
opts.body = requestBody;
}
const res = await fetch(`${baseUrl}${selected.path}`, opts);
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
setResponse("SSE stream started — check the terminal/devtools for real-time output.");
} else {
const data = await res.json();
setResponse(JSON.stringify(data, null, 2));
}
} catch (err) {
setResponse(`Error: ${err instanceof Error ? err.message : "Request failed"}`);
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-6">
<div className="lg:w-72 shrink-0">
<div className="sticky top-4">
<div className="flex flex-wrap gap-1.5 mb-4">
<button
onClick={() => setFilterTag(null)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setFilterTag(filterTag === tag ? null : tag)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${filterTag === tag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
{tag}
</button>
))}
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{filteredEndpoints.map((endpoint) => (
<button
key={`${endpoint.method}-${endpoint.path}`}
onClick={() => handleSelect(endpoint)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors
${
selected?.path === endpoint.path && selected?.method === endpoint.method
? "bg-primary/10 border border-primary/20"
: "hover:bg-bg-subtle border border-transparent"
}`}
>
<div className="flex items-center gap-2">
<span
className={`px-1.5 py-0.5 text-[10px] font-mono font-bold rounded border ${METHOD_COLORS[endpoint.method] || "border-border"}`}
>
{endpoint.method}
</span>
<span className="truncate text-text-muted font-mono text-xs">
{endpoint.path}
</span>
</div>
</button>
))}
</div>
</div>
</div>
<div className="flex-1 min-w-0">
{selected ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span
className={`px-2 py-1 text-xs font-mono font-bold rounded border ${METHOD_COLORS[selected.method]}`}
>
{selected.method}
</span>
<span className="font-mono text-sm text-text-main">{selected.path}</span>
</div>
<p className="text-sm text-text-muted">{selected.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted block mb-1">Base URL</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 text-sm bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{selected.method !== "GET" && (
<div>
<label className="text-xs text-text-muted block mb-1">Request Body</label>
<textarea
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
rows={8}
className="w-full px-3 py-2 text-sm font-mono bg-bg-subtle border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
)}
<button
onClick={handleTryIt}
disabled={loading}
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? "Sending..." : "Send Request"}
</button>
{response !== null && (
<div>
<label className="text-xs text-text-muted block mb-1">Response</label>
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
{response}
</pre>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-text-muted">
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
<p className="text-lg font-medium">Select an endpoint to explore</p>
<p className="text-sm mt-1">
Choose an API from the sidebar to see details and try it live
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import React, { useState, useRef } from "react";
export function CodeBlockCopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setCopied(true);
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
}
};
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 bg-bg border border-border rounded-md
hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
aria-label={copied ? "Copied" : "Copy code"}
>
<span className="material-symbols-outlined text-sm text-text-muted">
{copied ? "check" : "content_copy"}
</span>
</button>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
export function DocCodeBlocks() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current?.parentElement;
if (!container) return;
const pres = container.querySelectorAll("pre");
pres.forEach((pre) => {
if (pre.parentElement?.querySelector(".copy-btn")) return;
pre.parentElement?.classList.add("relative");
const btn = document.createElement("button");
btn.className = "copy-btn absolute top-2 right-2 p-1.5 bg-bg border border-border rounded-md hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100";
btn.setAttribute("aria-label", "Copy code");
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-text-muted">content_copy</span>';
btn.addEventListener("click", async () => {
const code = pre.querySelector("code")?.textContent || pre.textContent || "";
try {
await navigator.clipboard.writeText(code);
} catch {
const textarea = document.createElement("textarea");
textarea.value = code;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-primary">check</span>';
btn.setAttribute("aria-label", "Copied");
setTimeout(() => {
btn.innerHTML = '<span class="material-symbols-outlined text-sm text-text-muted">content_copy</span>';
btn.setAttribute("aria-label", "Copy code");
}, 2000);
});
pre.parentElement?.appendChild(btn);
});
}, []);
return <div ref={containerRef} className="hidden" />;
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useMemo, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
type Locale = "en" | "pt-BR" | "es" | "fr" | "de" | "ja" | "zh-CN" | "ko" | "ru" | "ar";
const SECTION_LABELS: Record<Locale, Record<string, string>> = {
en: {
"Getting Started": "Getting Started",
Features: "Features",
"API & Protocols": "API & Protocols",
Deployment: "Deployment",
Operations: "Operations",
Development: "Development",
},
"pt-BR": {
"Getting Started": "Primeiros Passos",
Features: "Recursos",
"API & Protocols": "API & Protocolos",
Deployment: "Implantação",
Operations: "Operações",
Development: "Desenvolvimento",
},
es: {
"Getting Started": "Primeros Pasos",
Features: "Características",
"API & Protocols": "API y Protocolos",
Deployment: "Despliegue",
Operations: "Operaciones",
Development: "Desarrollo",
},
fr: {
"Getting Started": "Démarrage",
Features: "Fonctionnalités",
"API & Protocols": "API et Protocoles",
Deployment: "Déploiement",
Operations: "Opérations",
Development: "Développement",
},
de: {
"Getting Started": "Erste Schritte",
Features: "Funktionen",
"API & Protocols": "API & Protokolle",
Deployment: "Bereitstellung",
Operations: "Betrieb",
Development: "Entwicklung",
},
ja: {
"Getting Started": "はじめに",
Features: "機能",
"API & Protocols": "APIとプロトコル",
Deployment: "デプロイ",
Operations: "運用",
Development: "開発",
},
"zh-CN": {
"Getting Started": "快速开始",
Features: "功能",
"API & Protocols": "API与协议",
Deployment: "部署",
Operations: "运维",
Development: "开发",
},
ko: {
"Getting Started": "시작하기",
Features: "기능",
"API & Protocols": "API 및 프로토콜",
Deployment: "배포",
Operations: "운영",
Development: "개발",
},
ru: {
"Getting Started": "Начало работы",
Features: "Возможности",
"API & Protocols": "API и Протоколы",
Deployment: "Развертывание",
Operations: "Эксплуатация",
Development: "Разработка",
},
ar: {
"Getting Started": "البدء",
Features: "الميزات",
"API & Protocols": "API والبروتوكولات",
Deployment: "النشر",
Operations: "العمليات",
Development: "التطوير",
},
};
function detectLocale(): Locale {
if (typeof navigator === "undefined") return "en";
const lang = navigator.language;
if (lang.startsWith("pt-BR")) return "pt-BR";
if (lang.startsWith("es")) return "es";
if (lang.startsWith("fr")) return "fr";
if (lang.startsWith("de")) return "de";
if (lang.startsWith("ja")) return "ja";
if (lang.startsWith("zh")) return "zh-CN";
if (lang.startsWith("ko")) return "ko";
if (lang.startsWith("ru")) return "ru";
if (lang.startsWith("ar")) return "ar";
return "en";
}
export function useDocsLocale() {
const searchParams = useSearchParams();
return useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
return detectLocale();
}, [searchParams]);
}
export function useLocalizedSectionTitle(englishTitle: string): string {
const locale = useDocsLocale();
return SECTION_LABELS[locale]?.[englishTitle] ?? englishTitle;
}
export function getAvailableLocales(): Locale[] {
return Object.keys(SECTION_LABELS) as Locale[];
}
export const LOCALE_NAMES: Record<Locale, string> = {
en: "English",
"pt-BR": "Português (Brasil)",
es: "Español",
fr: "Français",
de: "Deutsch",
ja: "日本語",
"zh-CN": "简体中文",
ko: "한국어",
ru: "Русский",
ar: "العربية",
};
const COMMON_LOCALES: Locale[] = ["en", "pt-BR", "es", "fr", "de", "ja", "zh-CN", "ko", "ru", "ar"];
export function DocsLocaleSwitcher() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const currentLocale = useMemo<Locale>(() => {
const langParam = searchParams.get("lang");
if (langParam && langParam in SECTION_LABELS) return langParam as Locale;
if (typeof navigator !== "undefined") {
const navLocale = detectLocale();
if (navLocale in SECTION_LABELS) return navLocale;
}
return "en";
}, [searchParams]);
const handleLocaleChange = (locale: Locale) => {
setIsOpen(false);
const params = new URLSearchParams(searchParams.toString());
if (locale === "en") {
params.delete("lang");
} else {
params.set("lang", locale);
}
const queryString = params.toString();
router.push(`${pathname}${queryString ? `?${queryString}` : ""}`);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs border border-border rounded hover:border-primary/50 transition-colors text-text-muted hover:text-text-main"
aria-label="Change documentation language"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className="material-symbols-outlined text-sm">language</span>
{LOCALE_NAMES[currentLocale]}
</button>
{isOpen && (
<div
className="absolute right-0 top-full mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 py-1 min-w-[160px]"
role="listbox"
>
{COMMON_LOCALES.map((locale) => (
<button
key={locale}
onClick={() => handleLocaleChange(locale)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-primary/5 transition-colors ${
locale === currentLocale ? "text-primary font-semibold" : "text-text-muted"
}`}
role="option"
aria-selected={locale === currentLocale}
>
{LOCALE_NAMES[locale]}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
export function DocsLazyWrapper({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<DocsSkeleton />}>{children}</Suspense>;
}
function DocsSkeleton() {
return (
<div className="animate-pulse space-y-6" aria-label="Loading documentation" role="status">
<div className="h-8 bg-bg-subtle rounded w-3/4" />
<div className="h-4 bg-bg-subtle rounded w-1/4 mt-2" />
<div className="space-y-3 mt-6">
<div className="h-4 bg-bg-subtle rounded w-full" />
<div className="h-4 bg-bg-subtle rounded w-5/6" />
<div className="h-4 bg-bg-subtle rounded w-4/6" />
</div>
<div className="h-32 bg-bg-subtle rounded w-full mt-4" />
<div className="space-y-3 mt-4">
<div className="h-4 bg-bg-subtle rounded w-full" />
<div className="h-4 bg-bg-subtle rounded w-3/4" />
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
interface PageAnalyticsProps {
slug: string;
title: string;
section: string;
}
const STORAGE_KEY = "omniroute_docs_analytics";
const MAX_EVENTS = 200;
interface AnalyticsEvent {
slug: string;
title: string;
section: string;
timestamp: number;
referrer: string;
}
function getEvents(): AnalyticsEvent[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function pruneEvents(events: AnalyticsEvent[]): AnalyticsEvent[] {
return events.slice(-MAX_EVENTS);
}
export function DocsPageAnalytics({ slug, title, section }: PageAnalyticsProps) {
const trackedRef = useRef(false);
const trackPageView = useCallback(() => {
if (trackedRef.current) return;
trackedRef.current = true;
try {
const events = getEvents();
events.push({
slug,
title,
section,
timestamp: Date.now(),
referrer: typeof document !== "undefined" ? document.referrer : "",
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruneEvents(events)));
} catch {
// localStorage unavailable — silent fail
}
}, [slug, title, section]);
useEffect(() => {
trackPageView();
}, [trackPageView]);
return null; // invisible tracking component
}
export function getPopularPages(
limit = 5
): { slug: string; title: string; section: string; views: number }[] {
if (typeof window === "undefined") return [];
try {
const events = getEvents();
const counts = new Map<string, { title: string; section: string; views: number }>();
for (const event of events) {
const existing = counts.get(event.slug);
if (existing) {
existing.views++;
} else {
counts.set(event.slug, {
slug: event.slug,
title: event.title,
section: event.section,
views: 1,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.views - a.views)
.slice(0, limit);
} catch {
return [];
}
}

View File

@@ -0,0 +1,175 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import Fuse from "fuse.js";
import { SEARCH_INDEX, SearchItem } from "../lib/searchIndex";
import { docsNavigation } from "../lib/docsNavigation";
const fuseTitles = new Fuse(SEARCH_INDEX, {
keys: [
{ name: "title", weight: 3 },
{ name: "slug", weight: 1 },
],
threshold: 0.3,
includeScore: true,
});
const fuseContent = new Fuse(SEARCH_INDEX, {
keys: [
{ name: "title", weight: 3 },
{ name: "content", weight: 2 },
{ name: "headings", weight: 2 },
],
threshold: 0.4,
includeScore: true,
});
export function DocsSearchClient({ onResultClick }: { onResultClick?: () => void }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Fuse.FuseResult<SearchItem>[]>([]);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSearch = useCallback((value: string) => {
setQuery(value);
if (value.trim().length === 0) {
setResults([]);
setIsOpen(false);
return;
}
const titleResults = fuseTitles.search(value.trim()).slice(0, 4);
const contentResults = fuseContent.search(value.trim()).slice(0, 4);
const seen = new Set<string>();
const merged: Fuse.FuseResult<SearchItem>[] = [];
for (const r of [...titleResults, ...contentResults]) {
if (!seen.has(r.item.slug)) {
seen.add(r.item.slug);
merged.push(r);
}
}
setResults(merged.slice(0, 8));
setIsOpen(true);
}, []);
const handleSelect = useCallback(() => {
setQuery("");
setResults([]);
setIsOpen(false);
onResultClick?.();
}, [onResultClick]);
const handleClear = useCallback(() => {
setQuery("");
setResults([]);
setIsOpen(false);
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
}
if (e.key === "Escape") {
setIsOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative">
<div className="relative">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted text-sm">
search
</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => {
if (results.length > 0) setIsOpen(true);
}}
placeholder="Search docs..."
className="w-full pl-8 pr-8 py-2 text-sm bg-bg-subtle border border-border rounded-lg
focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary
placeholder:text-text-muted transition-colors"
aria-label="Search documentation"
/>
{query && (
<button
onClick={handleClear}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main transition-colors"
aria-label="Clear search"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
)}
{!query && (
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-text-muted bg-bg border border-border rounded px-1.5 py-0.5 font-mono">
K
</kbd>
)}
</div>
{isOpen && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto">
{results.map((result) => {
const item = result.item;
const section = docsNavigation.find((s) => s.items.some((i) => i.slug === item.slug));
return (
<Link
key={item.slug}
href={`/docs/${item.slug}`}
onClick={handleSelect}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-bg-subtle transition-colors border-b border-border last:border-b-0"
>
<span className="material-symbols-outlined text-text-muted text-sm">article</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-main truncate">{item.title}</div>
{section && <div className="text-xs text-text-muted">{section.title}</div>}
{item.content && (
<div className="text-xs text-text-muted truncate mt-0.5">
{item.content.slice(0, 80)}...
</div>
)}
</div>
<span className="material-symbols-outlined text-text-muted text-xs">
arrow_forward
</span>
</Link>
);
})}
</div>
</div>
)}
{isOpen && query.trim().length > 0 && results.length === 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bg border border-border rounded-lg shadow-lg z-50 p-4">
<p className="text-sm text-text-muted text-center">
No results found for &ldquo;{query}&rdquo;
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { docsNavigation } from "../lib/docsNavigation";
import { DocsSearchClient } from "./DocsSearchClient";
export function DocsSidebarClient({ mobileOnly = false }: { mobileOnly?: boolean }) {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
// Extract slug from pathname (e.g., /docs/setup-guide -> setup-guide)
const currentSlug = pathname.split("/").pop();
const isActive = (slug: string) => currentSlug === slug;
if (mobileOnly) {
return (
<div className="lg:hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 bg-bg-subtle border border-border rounded-lg hover:bg-bg transition-colors"
aria-label="Toggle Sidebar"
>
<span className="material-symbols-outlined">menu</span>
</button>
{isOpen && (
<div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm">
<div className="fixed left-0 top-0 h-full w-64 bg-bg border-r border-border overflow-y-auto z-50">
<MobileSidebarContent currentSlug={currentSlug} onClose={() => setIsOpen(false)} />
</div>
</div>
)}
</div>
);
}
return (
<nav
className="flex flex-col h-full w-64 bg-bg border-r border-border"
aria-label="Documentation pages"
>
<div className="p-4 border-b border-border">
<h2 className="font-bold text-text-primary mb-3">OmniRoute Docs</h2>
<DocsSearchClient />
</div>
<div
className="flex-1 overflow-y-auto p-4 space-y-6"
role="list"
aria-label="Documentation sections"
>
{docsNavigation.map((section, sectionIdx) => (
<div key={sectionIdx} className="space-y-2" role="listitem">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
className={cn(
"block rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-bg-subtle hover:text-text-main",
isActive(item.slug)
? "text-primary font-medium bg-primary/10"
: "text-text-main"
)}
aria-current={isActive(item.slug) ? "page" : undefined}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</nav>
);
}
function MobileSidebarContent({
currentSlug,
onClose,
}: {
currentSlug: string;
onClose: () => void;
}) {
const isActive = (slug: string) => currentSlug === slug;
return (
<div className="flex flex-col h-full">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-bold text-text-primary">OmniRoute Docs</h2>
<button
onClick={onClose}
className="p-1 hover:bg-bg-subtle rounded transition-colors"
aria-label="Close Sidebar"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="px-4 pt-3 border-b border-border">
<DocsSearchClient onResultClick={onClose} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{docsNavigation.map((section, sectionIdx) => (
<div key={sectionIdx} className="space-y-2">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
onClick={onClose}
className={cn(
"block rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-bg-subtle hover:text-text-main",
isActive(item.slug)
? "text-primary font-medium bg-primary/10"
: "text-text-main"
)}
aria-current={isActive(item.slug) ? "page" : undefined}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import React, { useState } from "react";
export function FeedbackWidget({ slug }: { slug: string }) {
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const [submitted, setSubmitted] = useState(false);
const handleFeedback = (type: "yes" | "no") => {
setFeedback(type);
setSubmitted(true);
try {
const stored = JSON.parse(localStorage.getItem("docs-feedback") || "{}");
stored[slug] = type;
localStorage.setItem("docs-feedback", JSON.stringify(stored));
} catch {}
};
if (submitted) {
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg text-center">
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
check_circle
</span>
<p className="text-sm text-text-main">Thanks for your feedback!</p>
</div>
);
}
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
<p className="text-sm text-text-main mb-3">Was this page helpful?</p>
<div className="flex gap-3">
<button
onClick={() => handleFeedback("yes")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_up</span>
Yes
</button>
<button
onClick={() => handleFeedback("no")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_down</span>
No
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import dynamic from "next/dynamic";
const MermaidDiagram = dynamic(() => import("./MermaidDiagram").then((mod) => mod.MermaidDiagram), {
ssr: false,
loading: () => <div className="my-6 h-32 bg-bg-subtle rounded-lg animate-pulse" />,
});
export function MermaidChartsClient({ charts }: { charts: string[] }) {
if (charts.length === 0) return null;
return (
<div className="mt-6 space-y-4">
{charts.map((chart, i) => (
<MermaidDiagram key={i} chart={chart} />
))}
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useRef, useId } from "react";
interface MermaidDiagramProps {
chart: string;
}
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const uniqueId = useId().replace(/:/g, "_");
const renderedRef = useRef(false);
useEffect(() => {
if (renderedRef.current || !containerRef.current) return;
const renderDiagram = async () => {
try {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
fontFamily: "inherit",
});
const { svg } = await mermaid.render(`mermaid-${uniqueId}`, chart);
if (containerRef.current) {
containerRef.current.innerHTML = svg;
renderedRef.current = true;
}
} catch (error) {
console.error("Mermaid render error:", error);
if (containerRef.current) {
containerRef.current.innerHTML = `<pre class="text-red-500 text-sm p-2">${escapeHtml(chart)}</pre>`;
}
}
};
renderDiagram();
}, [chart, uniqueId]);
return (
<div
ref={containerRef}
className="mermaid-diagram my-6 flex justify-center overflow-x-auto rounded-lg border border-border bg-bg-subtle p-4"
role="img"
aria-label="Diagram"
/>
);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from "react";
interface WhatsNewEntry {
version: string;
date: string;
title: string;
description: string;
slug?: string;
type: "feature" | "improvement" | "fix" | "breaking";
}
const WHATS_NEW_ENTRIES: WhatsNewEntry[] = [
{
version: "3.7",
date: "2025-06",
title: "Documentation Site Overhaul",
description:
"Full interactive docs site with search, API explorer, Mermaid diagrams, and accessibility compliance.",
slug: "/docs",
type: "feature",
},
{
version: "3.6",
date: "2025-05",
title: "MCP Server 37 Tools + A2A Protocol",
description:
"Expanded MCP to 37 tools with scoped auth. Added A2A v0.3 agent-to-agent protocol.",
slug: "mcp-server",
type: "feature",
},
{
version: "3.5",
date: "2025-04",
title: "RTK + Caveman Stacked Compression",
description:
"Stacked compression pipeline: RTK → Caveman. Save 78-95% eligible tokens on tool outputs.",
slug: "rtk-compression",
type: "feature",
},
{
version: "3.4",
date: "2025-03",
title: "1proxy Free Proxy Marketplace",
description:
"Built-in free proxy marketplace with quality scoring, auto-rotation, and circuit breaker.",
slug: "proxy-guide",
type: "feature",
},
{
version: "3.3",
date: "2025-02",
title: "DeepSeek V3.2 + GLM-5.1 Added",
description:
"New cheap providers: DeepSeek V3.2 at $0.27/M and GLM-5.1 at $0.5/1M with 128K output.",
slug: "free-tiers",
type: "feature",
},
{
version: "3.2",
date: "2025-01",
title: "Responses API Full Support",
description:
"Complete /v1/responses endpoint for Codex and OpenAI Responses API compatibility.",
slug: "api-reference",
type: "feature",
},
];
const TYPE_BADGES: Record<string, { label: string; color: string }> = {
feature: { label: "Feature", color: "bg-green-500/10 text-green-600 border-green-500/20" },
improvement: { label: "Improvement", color: "bg-blue-500/10 text-blue-600 border-blue-500/20" },
fix: { label: "Fix", color: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20" },
breaking: { label: "Breaking", color: "bg-red-500/10 text-red-600 border-red-500/20" },
};
export function WhatsNewSection() {
const [expanded, setExpanded] = useState(false);
const displayEntries = expanded ? WHATS_NEW_ENTRIES : WHATS_NEW_ENTRIES.slice(0, 3);
return (
<section className="mt-8" aria-labelledby="whats-new-heading">
<h2
id="whats-new-heading"
className="text-xl font-bold text-text-main mb-4 flex items-center gap-2"
>
<span className="material-symbols-outlined text-primary">new_releases</span>
What&apos;s New
</h2>
<div className="space-y-3">
{displayEntries.map((entry) => (
<div
key={`${entry.version}-${entry.date}`}
className="flex items-start gap-3 p-4 border border-border rounded-lg hover:border-primary/50 transition-colors"
>
<div className="shrink-0 mt-0.5">
<span
className={`inline-block px-2 py-0.5 text-xs font-semibold border rounded ${TYPE_BADGES[entry.type]?.color ?? TYPE_BADGES.feature.color}`}
>
{TYPE_BADGES[entry.type]?.label ?? "Feature"}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-text-main">{entry.title}</span>
<span className="text-xs font-mono text-primary">v{entry.version}</span>
</div>
<p className="text-sm text-text-muted">{entry.description}</p>
</div>
<span className="shrink-0 text-xs text-text-muted">{entry.date}</span>
</div>
))}
</div>
{WHATS_NEW_ENTRIES.length > 3 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-3 text-sm text-primary hover:underline"
aria-expanded={expanded}
>
{expanded ? "Show less" : `Show ${WHATS_NEW_ENTRIES.length - 3} more updates`}
</button>
)}
</section>
);
}
export function MigrationGuideBanner({
fromVersion,
toVersion,
}: {
fromVersion: string;
toVersion: string;
}) {
return (
<div className="mb-6 border border-yellow-500/30 bg-yellow-500/5 rounded-lg p-4" role="alert">
<div className="flex items-center gap-2 mb-1">
<span className="material-symbols-outlined text-yellow-600">upgrade</span>
<span className="font-semibold text-yellow-600">
Migration Guide: v{fromVersion} v{toVersion}
</span>
</div>
<p className="text-sm text-text-muted">
This page describes features from v{toVersion}. If you&apos;re upgrading from v{fromVersion}
, check the breaking changes above.
</p>
</div>
);
}

68
src/app/docs/layout.tsx Normal file
View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import { ReactNode, Suspense } from "react";
import { DocsSidebarClient } from "./components/DocsSidebarClient";
import { DocsLocaleSwitcher } from "./components/DocsI18n";
export const metadata = {
title: {
template: "%s — OmniRoute Docs",
default: "OmniRoute Documentation",
},
description:
"Comprehensive documentation for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
robots: {
index: true,
follow: true,
},
};
export default function DocsLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
<a
href="#docs-main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-white focus:rounded"
>
Skip to main content
</a>
<aside
className="hidden lg:flex lg:w-64 lg:shrink-0 lg:flex-col lg:border-r lg:border-border"
role="navigation"
aria-label="Documentation sidebar"
>
<DocsSidebarClient />
</aside>
<div className="lg:hidden fixed top-4 left-4 z-50">
<DocsSidebarClient mobileOnly={true} />
</div>
<main id="docs-main-content" className="flex-1 overflow-y-auto bg-bg" tabIndex={-1}>
<div className="max-w-6xl mx-auto p-4 sm:px-6 lg:px-8 lg:py-8">
<nav
className="flex items-center justify-between gap-4 mb-6"
aria-label="Docs navigation"
>
<div className="flex items-center gap-4">
<Link href="/docs" className="text-text-muted hover:text-text-main transition-colors">
Back to Docs Overview
</Link>
<Link
href="/dashboard"
className="text-text-muted hover:text-text-main transition-colors"
>
Dashboard
</Link>
</div>
<Suspense fallback={<div className="w-24 h-8" />}>
<DocsLocaleSwitcher />
</Suspense>
</nav>
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,798 @@
// AUTO-GENERATED by scripts/generate-docs-index.mjs — DO NOT EDIT MANUALLY
// Regenerate with: node scripts/generate-docs-index.mjs
export interface AutoGenDocItem {
slug: string;
title: string;
fileName: string;
}
export interface AutoGenNavSection {
title: string;
items: AutoGenDocItem[];
}
export interface AutoGenSearchItem {
slug: string;
title: string;
fileName: string;
section: string;
content: string;
headings: string[];
}
export const autoNavSections: AutoGenNavSection[] = [
{
title: "Getting Started",
items: [
{
slug: "architecture",
title: "OmniRoute Architecture",
fileName: "ARCHITECTURE.md",
},
{
slug: "cli-tools",
title: "CLI Tools Setup Guide",
fileName: "CLI-TOOLS.md",
},
{
slug: "setup-guide",
title: "Setup Guide",
fileName: "SETUP_GUIDE.md",
},
{
slug: "user-guide",
title: "User Guide",
fileName: "USER_GUIDE.md",
},
],
},
{
title: "Features",
items: [
{
slug: "auto-combo",
title: "OmniRoute Auto-Combo Engine",
fileName: "AUTO-COMBO.md",
},
{
slug: "compression-engines",
title: "Compression Engines",
fileName: "COMPRESSION_ENGINES.md",
},
{
slug: "compression-guide",
title: "🗜️ Prompt Compression Guide",
fileName: "COMPRESSION_GUIDE.md",
},
{
slug: "compression-language-packs",
title: "Compression Language Packs",
fileName: "COMPRESSION_LANGUAGE_PACKS.md",
},
{
slug: "compression-rules-format",
title: "Compression Rules Format",
fileName: "COMPRESSION_RULES_FORMAT.md",
},
{
slug: "features",
title: "OmniRoute — Dashboard Features Gallery",
fileName: "FEATURES.md",
},
{
slug: "free-tiers",
title: "🆓 Free LLM API Providers — Consolidated Directory",
fileName: "FREE_TIERS.md",
},
{
slug: "rtk-compression",
title: "RTK Compression",
fileName: "RTK_COMPRESSION.md",
},
],
},
{
title: "API & Protocols",
items: [
{
slug: "a2a-server",
title: "OmniRoute A2A Server Documentation",
fileName: "A2A-SERVER.md",
},
{
slug: "api-reference",
title: "API Reference",
fileName: "API_REFERENCE.md",
},
{
slug: "mcp-server",
title: "OmniRoute MCP Server Documentation",
fileName: "MCP-SERVER.md",
},
],
},
{
title: "Deployment",
items: [
{
slug: "docker-guide",
title: "🐳 Docker Guide",
fileName: "DOCKER_GUIDE.md",
},
{
slug: "fly-io-deployment-guide",
title: "OmniRoute Fly.io 部署指南",
fileName: "FLY_IO_DEPLOYMENT_GUIDE.md",
},
{
slug: "pwa-guide",
title: "Progressive Web App (PWA) Guide",
fileName: "PWA_GUIDE.md",
},
{
slug: "termux-guide",
title: "Termux Headless Setup",
fileName: "TERMUX_GUIDE.md",
},
{
slug: "vm-deployment-guide",
title: "OmniRoute — Deployment Guide on VM with Cloudflare",
fileName: "VM_DEPLOYMENT_GUIDE.md",
},
],
},
{
title: "Operations",
items: [
{
slug: "environment",
title: "Environment Variables Reference",
fileName: "ENVIRONMENT.md",
},
{
slug: "proxy-guide",
title: "OmniRoute Proxy Guide",
fileName: "PROXY_GUIDE.md",
},
{
slug: "resilience-guide",
title: "🛡️ Resilience Guide",
fileName: "RESILIENCE_GUIDE.md",
},
{
slug: "troubleshooting",
title: "Troubleshooting",
fileName: "TROUBLESHOOTING.md",
},
],
},
{
title: "Development",
items: [
{
slug: "codebase-documentation",
title: "omniroute — Codebase Documentation",
fileName: "CODEBASE_DOCUMENTATION.md",
},
{
slug: "coverage-plan",
title: "Test Coverage Plan",
fileName: "COVERAGE_PLAN.md",
},
{
slug: "i18n",
title: "i18n — Internationalization Guide",
fileName: "I18N.md",
},
{
slug: "release-checklist",
title: "Release Checklist",
fileName: "RELEASE_CHECKLIST.md",
},
{
slug: "uninstall",
title: "OmniRoute — Uninstall Guide",
fileName: "UNINSTALL.md",
},
],
},
];
export const autoSearchIndex: AutoGenSearchItem[] = [
{
slug: "architecture",
title: "OmniRoute Architecture",
fileName: "ARCHITECTURE.md",
section: "Getting Started",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Executive Summary",
"Scope and Boundaries",
"In Scope",
"Out of Scope",
"Dashboard Surface (Current)",
"High-Level System Context",
"Core Runtime Components",
"1) API and Routing Layer (Next.js App Routes)",
"2) SSE + Translation Core",
"3) Persistence Layer",
],
},
{
slug: "cli-tools",
title: "CLI Tools Setup Guide",
fileName: "CLI-TOOLS.md",
section: "Getting Started",
content:
"This guide explains how to install and configure all supported AI coding CLI tools to use OmniRoute as the unified backend, giving you centralized key management, cost tracking, model switching, and request logging across every tool. The dashboard cards in /dashboard/cli-tools are generated from src",
headings: [
"How It Works",
"Supported Tools (Dashboard Source of Truth)",
"CLI fingerprint sync (Agents + Settings)",
"Step 1 — Get an OmniRoute API Key",
"Step 2 — Install CLI Tools",
"Step 3 — Set Global Environment Variables",
"Step 4 — Configure Each Tool",
"Claude Code",
"OpenAI Codex",
"OpenCode",
],
},
{
slug: "setup-guide",
title: "Setup Guide",
fileName: "SETUP_GUIDE.md",
section: "Getting Started",
content:
"Complete setup reference for OmniRoute. For the quick version, see the Quick Start in README. - Install Methods - CLI Tool Configuration - Protocol Setup (MCP + A2A) - Timeout Configuration - Split-Port Mode - Void Linux (xbps-src) - Uninstalling -------------------- --------------------------------",
headings: [
"Table of Contents",
"Install Methods",
"npm (recommended)",
"pnpm",
"Arch Linux (AUR)",
"From Source",
"Docker",
"CLI Options",
"CLI Tool Configuration",
"1) Connect Providers and Create API Key",
],
},
{
slug: "user-guide",
title: "User Guide",
fileName: "USER_GUIDE.md",
section: "Getting Started",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Table of Contents",
"💰 Pricing at a Glance",
"🎯 Use Cases",
'Case 1: "I have Claude Pro subscription"',
'Case 2: "I want zero cost"',
'Case 3: "I need 24/7 coding, no interruptions"',
'Case 4: "I want FREE AI in OpenClaw"',
"📖 Provider Setup",
"🔐 Subscription Providers",
"Claude Code (Pro/Max)",
],
},
{
slug: "auto-combo",
title: "OmniRoute Auto-Combo Engine",
fileName: "AUTO-COMBO.md",
section: "Features",
content:
"Self-managing model chains with adaptive scoring The Auto-Combo Engine dynamically selects the best provider/model for each request using a 6-factor scoring function: Factor Weight Description :--------- :----- :---------------------------------------------- Quota 0.20 Remaining capacity [0..1] Heal",
headings: [
"How It Works",
"Mode Packs",
"Self-Healing",
"Bandit Exploration",
"API",
"Task Fitness",
"Files",
],
},
{
slug: "compression-engines",
title: "Compression Engines",
fileName: "COMPRESSION_ENGINES.md",
section: "Features",
content:
"OmniRoute compression is built around engine contracts. A mode can run one engine directly (caveman or rtk) or a deterministic stacked pipeline that executes multiple engines in order. Mode Engine path Intended input ------------ ---------------------------------- -----------------------------------",
headings: [
"Modes",
"Engine Registry",
"Caveman",
"RTK",
"Stacked Pipelines",
"Compression Combos",
"API Surface",
"MCP Tools",
"Validation",
],
},
{
slug: "compression-guide",
title: "🗜️ Prompt Compression Guide",
fileName: "COMPRESSION_GUIDE.md",
section: "Features",
content:
"Save 15-95% on eligible context automatically. For a quick overview, see the README Compression section. OmniRoute implements a modular prompt compression pipeline that runs proactively before requests hit upstream providers. This means your token savings happen transparently — no changes needed to ",
headings: [
"Overview",
"Compression Modes",
"Off",
"Lite Mode (~15% savings, <1ms latency)",
"Standard Mode (~30% savings)",
"Aggressive Mode (~50% savings)",
"Ultra Mode (~75% savings)",
"RTK Mode (60-90% upstream range)",
"Stacked Mode (78-95% eligible range)",
"Upstream Savings Math",
],
},
{
slug: "compression-language-packs",
title: "Compression Language Packs",
fileName: "COMPRESSION_LANGUAGE_PACKS.md",
section: "Features",
content:
"Caveman compression can load language-specific rule packs in addition to the built-in English rules. This keeps the core engine stable while allowing Portuguese, Spanish, German, French, Japanese, and future language packs to evolve independently. Language packs live under: Current shipped packs inc",
headings: [
"Location",
"Language Detection",
"Config Shape",
"Adding a Language Pack",
"API",
"Operational Notes",
],
},
{
slug: "compression-rules-format",
title: "Compression Rules Format",
fileName: "COMPRESSION_RULES_FORMAT.md",
section: "Features",
content:
"Compression rules are JSON files loaded at runtime. They are intentionally data-only so new language packs and RTK command filters can be reviewed without changing engine code. Caveman rule packs live under: Each pack contains replacements that apply to normal prose after protected regions are isola",
headings: [
"Caveman Rule Packs",
"Caveman Fields",
"RTK Filter Packs",
"RTK Fields",
"Safety Rules",
"Validation",
],
},
{
slug: "features",
title: "OmniRoute — Dashboard Features Gallery",
fileName: "FEATURES.md",
section: "Features",
content:
"🌐 Main README translations: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indones",
headings: [
"🔌 Providers",
"🎨 Combos",
"📊 Analytics",
"🏥 System Health",
"🔧 Translator Playground",
"🎮 Model Playground _(v2.0.9+)_",
"🎨 Themes _(v2.0.5+)_",
"⚙️ Settings",
"🔧 CLI Tools",
"🤖 CLI Agents _(v2.0.11+)_",
],
},
{
slug: "free-tiers",
title: "🆓 Free LLM API Providers — Consolidated Directory",
fileName: "FREE_TIERS.md",
section: "Features",
content:
"The ultimate aggregated reference for all permanently free LLM API providers. Consolidated from 6 community repositories. Use with OmniRoute to route through 25+ free providers simultaneously. Last consolidated: May 2026 · Sources: awesome-free-llm-apis, awesome-free-llm-apis2, free-llm-api-resource",
headings: [
"Table of Contents",
"Quick Comparison",
"Provider APIs (First-Party)",
"Google Gemini 🇺🇸",
"Mistral AI 🇫🇷",
"Cohere 🇨🇦",
"Z.AI (Zhipu AI) 🇨🇳",
"IBM watsonx 🇺🇸",
"Inference Providers (Third-Party)",
"Groq 🇺🇸",
],
},
{
slug: "rtk-compression",
title: "RTK Compression",
fileName: "RTK_COMPRESSION.md",
section: "Features",
content:
"RTK compression is OmniRoute's command-aware compression engine for terminal and tool output. It is designed for coding-agent sessions where most context growth comes from test logs, build output, package manager noise, shell transcripts, Docker output, git output, and stack traces. RTK can run dire",
headings: [
"What It Compresses",
"Filter Resolution",
"Filter DSL",
"Configuration",
"API",
"Raw Output Recovery",
"Verify Gate",
"Extending RTK",
],
},
{
slug: "a2a-server",
title: "OmniRoute A2A Server Documentation",
fileName: "A2A-SERVER.md",
section: "API & Protocols",
content:
"Agent-to-Agent Protocol v0.3 — OmniRoute as an intelligent routing agent Returns the Agent Card describing OmniRoute's capabilities, skills, and authentication requirements. Sends a message to a skill and waits for the complete response. Response: Same as message/send but returns Server-Sent Events ",
headings: [
"Agent Discovery",
"Authentication",
"Enablement",
"JSON-RPC 2.0 Methods",
"message/send — Synchronous Execution",
"message/stream — SSE Streaming",
"tasks/get — Query Task Status",
"tasks/cancel — Cancel a Task",
"Available Skills",
"Task Lifecycle",
],
},
{
slug: "api-reference",
title: "API Reference",
fileName: "API_REFERENCE.md",
section: "API & Protocols",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Table of Contents",
"Chat Completions",
"Custom Headers",
"Embeddings",
"Image Generation",
"List Models",
"Compatibility Endpoints",
"Dedicated Provider Routes",
"Semantic Cache",
"Dashboard & Management",
],
},
{
slug: "mcp-server",
title: "OmniRoute MCP Server Documentation",
fileName: "MCP-SERVER.md",
section: "API & Protocols",
content:
"Model Context Protocol server with 37 tools across routing, cache, compression, memory, skills, and proxy operations OmniRoute MCP is built-in. Start it with: Or via the open-sse transport: See MCP Client Configuration for Claude Desktop, Cursor, Cline, and compatible MCP client setup. -------------",
headings: [
"Installation",
"IDE Configuration",
"Essential Tools (8)",
"Advanced Tools (8)",
"Cache Tools (2)",
"Compression Tools (5)",
"Other Tool Groups",
"Authentication",
"Audit Logging",
"Files",
],
},
{
slug: "docker-guide",
title: "🐳 Docker Guide",
fileName: "DOCKER_GUIDE.md",
section: "Deployment",
content:
"Complete Docker deployment reference. For a quick start, see the README Docker section. - Quick Run - With Environment File - Docker Compose - Docker Compose with Caddy (HTTPS) - Cloudflare Quick Tunnel - Image Tags - Important Notes --------------------- -------- ------ --------------------- diegos",
headings: [
"Table of Contents",
"Quick Run",
"With Environment File",
"Docker Compose",
"Docker Compose with Caddy (HTTPS Auto-TLS)",
"Cloudflare Quick Tunnel",
"Tunnel Notes",
"Image Tags",
"Important Notes",
"See Also",
],
},
{
slug: "fly-io-deployment-guide",
title: "OmniRoute Fly.io 部署指南",
fileName: "FLY_IO_DEPLOYMENT_GUIDE.md",
section: "Deployment",
content:
"本文档记录 OmniRoute 在 Fly.io 上的实际部署方法,适用于两类场景: - 首次把当前项目部署到 Fly.io - 后续代码更新后继续发布 - 新项目参考同样流程部署 本文基于当前项目已经验证通过的配置整理,应用名为 omniroute。 当前仓库中的 fly.toml 已确认包含以下关键项: 说明: - app = 'omniroute' 决定实际部署到哪个 Fly 应用 - destination = '/data' 决定持久卷挂载目录 - 本项目必须让 DATADIR=/data否则数据库和密钥会写到容器临时目录 --- Windows PowerShell 如果安装脚",
headings: [
"1. 部署目标",
"2. 当前项目关键配置",
"3. 必备工具",
"3.1 安装 Fly CLI",
"3.2 登录 Fly 账号",
"3.3 检查登录状态",
"4. 首次部署当前项目",
"4.1 获取代码并进入目录",
"4.2 确认应用名",
"4.3 创建应用",
],
},
{
slug: "pwa-guide",
title: "Progressive Web App (PWA) Guide",
fileName: "PWA_GUIDE.md",
section: "Deployment",
content:
'OmniRoute ships as a fully installable Progressive Web App. When you access the dashboard from any mobile browser — Android (Chrome) or iOS (Safari) — you can "Add to Home Screen" and get a native app-like experience with no app store required. A Progressive Web App turns the OmniRoute web dashboard',
headings: [
"What Is a PWA?",
"Installation",
"Android (Chrome)",
"iOS (Safari)",
"Desktop (Chrome / Edge)",
"Features",
"Fullscreen Experience",
"Offline Support",
"Offline Page",
"App Icons",
],
},
{
slug: "termux-guide",
title: "Termux Headless Setup",
fileName: "TERMUX_GUIDE.md",
section: "Deployment",
content:
"OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network. Install Termux from F-Droid or GitHub releases, then update pa",
headings: [
"Prerequisites",
"Install",
"Run",
"Background Execution",
"Access From Other Devices",
"Data Directory",
"Limitations",
"Troubleshooting",
"better-sqlite3 Build Errors",
"Port Already In Use",
],
},
{
slug: "vm-deployment-guide",
title: "OmniRoute — Deployment Guide on VM with Cloudflare",
fileName: "VM_DEPLOYMENT_GUIDE.md",
section: "Deployment",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Prerequisites",
"1. Configure the VM",
"1.1 Create the instance",
"1.2 Connect via SSH",
"1.3 Update the system",
"1.4 Install Docker",
"1.5 Install nginx",
"1.6 Configure Firewall (UFW)",
"2. Install OmniRoute",
"2.1 Create configuration directory",
],
},
{
slug: "environment",
title: "Environment Variables Reference",
fileName: "ENVIRONMENT.md",
section: "Operations",
content:
"Complete reference for every environment variable recognized by OmniRoute. For a quick-start template, see .env.example. These must be set before the first run. Without them, the application will either refuse to start or operate with insecure defaults. Variable Required Default Source File Descript",
headings: [
"Table of Contents",
"1. Required Secrets",
"Generation Commands",
"2. Storage & Database",
"Scenarios",
"3. Network & Ports",
"Port Modes",
"4. Security & Authentication",
"Hardening Checklist",
"5. Input Sanitization & PII Protection",
],
},
{
slug: "proxy-guide",
title: "OmniRoute Proxy Guide",
fileName: "PROXY_GUIDE.md",
section: "Operations",
content:
"Bypass geographic blocks, protect your identity, and route AI traffic through any proxy — with zero configuration complexity. OmniRoute includes a full-featured proxy management system that lets you route upstream AI provider traffic through HTTP, HTTPS, or SOCKS5 proxies. Whether you're in a blocke",
headings: [
"Table of Contents",
"Why Use Proxies?",
"Architecture Overview",
"Key Components",
"3-Level Proxy System",
"How Resolution Works",
"What Gets Proxied",
"Proxy Registry (CRUD)",
"Creating a Proxy",
"Updating a Proxy",
],
},
{
slug: "resilience-guide",
title: "🛡️ Resilience Guide",
fileName: "RESILIENCE_GUIDE.md",
section: "Operations",
content:
"How OmniRoute keeps your AI coding workflow running when providers fail. OmniRoute implements a multi-layered resilience system that ensures zero downtime: ------------ ------- ------------------------------------ Queue Size 10 Max queued requests per connection Pacing Interval 0ms Minimum gap betwe",
headings: [
"Overview",
"Request Queue & Pacing",
"Connection Cooldown",
"Circuit Breaker",
"Wait For Cooldown",
"Anti-Thundering Herd",
"Combo Fallback Chains",
"13 Routing Strategies",
"TLS Fingerprint Spoofing",
"Health Dashboard",
],
},
{
slug: "troubleshooting",
title: "Troubleshooting",
fileName: "TROUBLESHOOTING.md",
section: "Operations",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Quick Fixes",
"Node.js Compatibility",
'Login page crashes or shows "Module self-registration" error',
'macOS: dlopen / "slice is not valid mach-o file"',
"Proxy Issues",
'Provider validation shows "fetch failed"',
'Token health check fails with "fetch failed"',
'SOCKS5 proxy returns "invalid onRequestStart method"',
"Provider Issues",
'"Language model did not provide messages"',
],
},
{
slug: "codebase-documentation",
title: "omniroute — Codebase Documentation",
fileName: "CODEBASE_DOCUMENTATION.md",
section: "Development",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"1. What Is omniroute?",
"2. Architecture Overview",
"Core Principle: Hub-and-Spoke Translation",
"3. Project Structure",
"4. Module-by-Module Breakdown",
"4.1 Config (open-sse/config/)",
"Credential Loading Flow",
"4.2 Executors (open-sse/executors/)",
"4.3 Handlers (open-sse/handlers/)",
"Request Lifecycle (chatCore.ts)",
],
},
{
slug: "coverage-plan",
title: "Test Coverage Plan",
fileName: "COVERAGE_PLAN.md",
section: "Development",
content:
"Last updated: 2026-03-28 There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful. Metric Scope Statements / Lines Branches Functions Notes -------------------- ----------------------------------------------------- -----------------: -----",
headings: [
"Baseline",
"Rules",
"Current command set",
"Milestones",
"Priority hotspots",
"Execution checklist",
"Phase 1: 56.95% -> 60%",
"Phase 2: 60% -> 65%",
"Phase 3: 65% -> 70%",
"Phase 4: 70% -> 75%",
],
},
{
slug: "i18n",
title: "i18n — Internationalization Guide",
fileName: "I18N.md",
section: "Development",
content:
"OmniRoute supports 30 languages with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew. 🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇩🇪 Deutsch 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇯🇵 日本語 🇰🇷 한국어 🇸🇦 العربية 🇮🇳 ",
headings: [
"Quick Reference",
"Architecture",
"Source of Truth",
"Runtime Flow",
"Supported Locales",
"Adding a New Language",
"1. Register the Locale",
"2. Add to Generator",
"3. Generate Initial Translation",
"4. Review & Fix Auto-Translations",
],
},
{
slug: "release-checklist",
title: "Release Checklist",
fileName: "RELEASE_CHECKLIST.md",
section: "Development",
content:
"Use this checklist before tagging or publishing a new OmniRoute release. 1. Bump package.json version (x.y.z) in the release branch. 2. Move release notes from [Unreleased] in CHANGELOG.md to a dated section: - [x.y.z] — YYYY-MM-DD 3. Keep [Unreleased] as the first changelog section for upcoming wor",
headings: ["Version and Changelog", "API Docs", "Runtime Docs", "Automated Check"],
},
{
slug: "uninstall",
title: "OmniRoute — Uninstall Guide",
fileName: "UNINSTALL.md",
section: "Development",
content:
"🌐 Languages: 🇺🇸 English 🇧🇷 Português (Brasil) 🇪🇸 Español 🇫🇷 Français 🇮🇹 Italiano 🇷🇺 Русский 🇨🇳 中文 (简体) 🇩🇪 Deutsch 🇮🇳 हिन्दी 🇹🇭 ไทย 🇺🇦 Українська 🇸🇦 العربية 🇯🇵 日本語 🇻🇳 Tiếng Việt 🇧🇬 Български 🇩🇰 Dansk 🇫🇮 Suomi 🇮🇱 עברית 🇭🇺 Magyar 🇮🇩 Bahasa Indonesia 🇰🇷 한국어 🇲\ud83c",
headings: [
"Quick Uninstall (v3.6.2+)",
"Keep Your Data",
"Full Removal",
"Manual Uninstall",
"NPM Global Install",
"pnpm Global Install",
"Docker",
"Docker Compose",
"Electron Desktop App",
"Source Install (git clone)",
],
},
{
slug: "api-explorer",
title: "API Explorer",
fileName: "API_REFERENCE.md",
section: "API & Protocols",
content: "interactive try it live api explorer endpoint test request response curl example",
headings: ["Try It", "Endpoints"],
},
];
export const autoAllSlugs: string[] = [
"architecture",
"cli-tools",
"setup-guide",
"user-guide",
"auto-combo",
"compression-engines",
"compression-guide",
"compression-language-packs",
"compression-rules-format",
"features",
"free-tiers",
"rtk-compression",
"a2a-server",
"api-reference",
"mcp-server",
"docker-guide",
"fly-io-deployment-guide",
"pwa-guide",
"termux-guide",
"vm-deployment-guide",
"environment",
"proxy-guide",
"resilience-guide",
"troubleshooting",
"codebase-documentation",
"coverage-plan",
"i18n",
"release-checklist",
"uninstall",
];

View File

@@ -0,0 +1,36 @@
import { autoNavSections } from "./docs-auto-generated";
export interface DocNavItem {
slug: string;
title: string;
fileName: string;
}
export interface DocNavSection {
title: string;
items: DocNavItem[];
}
const MANUAL_OVERRIDES: Record<string, Partial<DocNavItem>> = {
"setup-guide": { title: "Setup Guide" },
"user-guide": { title: "User Guide" },
"cli-tools": { title: "CLI Tools" },
"compression-rules-format": { title: "Rules Format" },
"compression-language-packs": { title: "Language Packs" },
"vm-deployment-guide": { title: "VM Deployment" },
"fly-io-deployment-guide": { title: "Fly.io Deployment" },
"codebase-documentation": { title: "Codebase Docs" },
"release-checklist": { title: "Release Checklist" },
};
export const docsNavigation: DocNavSection[] = autoNavSections.map((section) => ({
title: section.title,
items: section.items.map((item) => {
const override = MANUAL_OVERRIDES[item.slug];
return {
slug: item.slug,
title: (override?.title ?? item.title) as string,
fileName: item.fileName,
};
}),
}));

View File

@@ -0,0 +1,12 @@
import { autoSearchIndex } from "./docs-auto-generated";
export interface SearchItem {
slug: string;
title: string;
fileName: string;
section: string;
content: string;
headings: string[];
}
export const SEARCH_INDEX: SearchItem[] = autoSearchIndex as SearchItem[];

View File

@@ -1,610 +1,107 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { APP_CONFIG } from "@/shared/constants/config";
import { FREE_PROVIDERS, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
import {
DOCS_DEPLOYMENT_GUIDES,
DOCS_ENDPOINT_ROWS,
DOCS_FEATURE_ITEMS,
DOCS_MANAGEMENT_ENDPOINT_ROWS,
DOCS_MCP_TOOL_GROUPS,
DOCS_TOC_ITEMS,
DOCS_TROUBLESHOOTING_KEYS,
DOCS_USE_CASE_ITEMS,
} from "./content";
import { Metadata } from "next";
import { docsNavigation } from "./lib/docsNavigation";
import { WhatsNewSection } from "./components/WhatsNewSection";
function ProviderTable({
title,
providers,
colorDot,
maxHeight,
}: {
title: string;
providers: Record<string, any>;
colorDot: string;
maxHeight?: string;
}) {
const t = useTranslations("docs");
const entries = Object.values(providers) as any[];
const isLargeList = entries.length > 20;
export const metadata: Metadata = {
title: "OmniRoute Documentation",
description:
"Everything you need to route, compress, and scale your AI — setup guides, API reference, compression, deployment, and more.",
openGraph: {
title: "OmniRoute Documentation",
description:
"Comprehensive docs for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
type: "website",
url: "https://omniroute.online/docs",
},
twitter: {
card: "summary_large_image",
title: "OmniRoute Documentation",
description: "Comprehensive docs for OmniRoute AI gateway",
},
};
const featuredLinks = [
{
slug: "setup-guide",
title: "Setup Guide",
icon: "rocket_launch",
desc: "Get OmniRoute running in 3 minutes",
},
{
slug: "api-reference",
title: "API Reference",
icon: "code",
desc: "All endpoints with examples",
},
{
slug: "compression-guide",
title: "Compression Guide",
icon: "compress",
desc: "Save 15-95% eligible tokens automatically",
},
];
export default function DocsHomePage() {
return (
<div className="rounded-lg border border-border bg-bg p-4">
<div className="flex items-center gap-2 mb-3">
<span className={`size-2.5 rounded-full ${colorDot}`} />
<h3 className="font-semibold">{title}</h3>
<span className="text-xs text-text-muted ml-auto">
{t("providersCount", { count: entries.length })}
</span>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-text-main mb-4">OmniRoute Documentation</h1>
<p className="text-lg text-text-muted mb-6">
Everything you need to route, compress, and scale your AI
</p>
<p className="text-sm text-text-muted">
Press{" "}
<kbd className="px-1.5 py-0.5 bg-bg-subtle border border-border rounded font-mono text-xs">
K
</kbd>{" "}
to search the docs
</p>
</div>
<div
className={`${isLargeList ? "grid grid-cols-2 sm:grid-cols-3 gap-1.5" : "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1"} text-sm`}
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
>
{entries.map((p) => (
<div
key={p.id}
className={`flex items-center ${isLargeList ? "gap-1.5 py-1 px-2 rounded-md bg-bg-subtle border border-border/30" : "justify-between py-1.5 border-b border-border/40 last:border-0"}`}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-12">
{featuredLinks.map((link) => (
<Link
key={link.slug}
href={`/docs/${link.slug}`}
className="flex flex-col items-center text-center p-6 bg-bg-subtle border border-border rounded-xl
hover:border-primary hover:bg-primary/5 transition-all group"
>
<code className="text-xs text-text-muted shrink-0">{p.alias}/</code>
<span className={`${isLargeList ? "text-xs truncate" : "font-medium"}`}>{p.name}</span>
<span className="material-symbols-outlined text-3xl text-primary mb-3">
{link.icon}
</span>
<span className="font-semibold text-text-main group-hover:text-primary transition-colors">
{link.title}
</span>
<span className="text-sm text-text-muted mt-1">{link.desc}</span>
</Link>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{docsNavigation.map((section, sectionIdx) => (
<div
key={sectionIdx}
className="border border-border rounded-xl p-6 hover:border-primary/50 transition-colors"
>
<h2 className="text-lg font-bold text-text-main mb-4">{section.title}</h2>
<ul className="space-y-2">
{section.items.map((item) => (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
className="text-sm text-text-muted hover:text-primary hover:underline transition-colors"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
}
export default function DocsPage() {
const t = useTranslations("docs");
const totalProviders =
Object.keys(FREE_PROVIDERS).length +
Object.keys(OAUTH_PROVIDERS).length +
Object.keys(APIKEY_PROVIDERS).length;
const endpointRows = DOCS_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const managementEndpointRows = DOCS_MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const featureItems = DOCS_FEATURE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const useCases = DOCS_USE_CASE_ITEMS.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const deploymentGuides = DOCS_DEPLOYMENT_GUIDES.map((item) => ({
...item,
title: t(item.titleKey),
text: t(item.textKey),
}));
const troubleshootingItems = DOCS_TROUBLESHOOTING_KEYS.map((key) => t(key));
const tocItems = DOCS_TOC_ITEMS.map((item) => ({ ...item, label: t(item.labelKey) }));
const mcpToolGroups = DOCS_MCP_TOOL_GROUPS.map((group) => ({
...group,
title: t(group.titleKey),
text: t(group.textKey),
}));
const totalMcpTools = DOCS_MCP_TOOL_GROUPS.reduce((sum, group) => sum + group.tools.length, 0);
const providerPrefixRows = [
...Object.values(FREE_PROVIDERS).map((p) => ({ ...p, type: "free" as const })),
...Object.values(OAUTH_PROVIDERS).map((p) => ({ ...p, type: "oauth" as const })),
...Object.values(APIKEY_PROVIDERS).map((p) => ({ ...p, type: "apiKey" as const })),
];
const getProviderTypeLabel = (type: "free" | "oauth" | "apiKey") => {
if (type === "free") return t("providerTypeFree");
if (type === "oauth") return t("providerTypeOAuth");
return t("providerTypeApiKey");
};
return (
<div className="min-h-screen bg-bg text-text-main">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-10 md:py-14 flex flex-col gap-8">
<header className="rounded-2xl border border-border bg-bg-subtle p-6 md:p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-wider text-text-muted">
{t("documentationVersion", { version: APP_CONFIG.version })}
</p>
<h1 className="text-3xl md:text-4xl font-bold mt-1">
{APP_CONFIG.name} {t("docsLabel")}
</h1>
<p className="text-sm md:text-base text-text-muted mt-2 max-w-3xl">
{t("docsHeroDescription")}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/dashboard"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("openDashboard")}
</Link>
<Link
href="/dashboard/endpoint"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("endpointPage")}
</Link>
<a
href="https://github.com/diegosouzapw/OmniRoute"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors flex items-center gap-1"
>
{t("github")}{" "}
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
<a
href="https://github.com/diegosouzapw/OmniRoute/issues"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("reportIssue")}
</a>
</div>
</div>
</header>
<nav className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-sm font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("onThisPage")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
{tocItems.map((item) => (
<a
key={item.href}
href={item.href}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border hover:bg-bg transition-colors"
>
<span className="material-symbols-outlined text-[14px] text-text-muted">tag</span>
{item.label}
</a>
))}
</div>
</nav>
<section id="quick-start" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("quickStart")}</h2>
<ol className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep1Title")}</span>
<p className="text-text-muted mt-1">
{t("quickStartStep1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">npx omniroute</code>{" "}
{t("quickStartStep1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">npm start</code>.
</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep2Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep2Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep3Title")}</span>
<p className="text-text-muted mt-1">{t("quickStartStep3Text")}</p>
</li>
<li className="rounded-lg border border-border p-3 bg-bg">
<span className="font-semibold">{t("quickStartStep4Title")}</span>
<p className="text-text-muted mt-1">
{t("quickStartStep4Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>.{" "}
{t("quickStartStep4Suffix")}{" "}
<code className="px-1 rounded bg-bg-subtle">gh/gpt-5.1-codex</code>.
</p>
</li>
</ol>
</section>
<section id="deployment" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("deploymentGuides")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{deploymentGuides.map((item) => (
<a
key={item.titleKey}
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-border p-4 bg-bg flex gap-3 transition-colors hover:border-primary/40 hover:bg-bg-subtle"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
{item.icon}
</span>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.text}</p>
</div>
</a>
))}
</div>
</section>
<section id="features" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("features")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{featureItems.map((item) => (
<article
key={item.titleKey}
className="rounded-lg border border-border p-4 bg-bg flex gap-3"
>
<span className="material-symbols-outlined text-[20px] text-primary shrink-0 mt-0.5">
{item.icon}
</span>
<div>
<h3 className="font-semibold text-sm">{item.title}</h3>
<p className="text-sm text-text-muted mt-1">{item.text}</p>
</div>
</article>
))}
</div>
</section>
<section
id="supported-providers"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold">{t("supportedProviders")}</h2>
<p className="text-sm text-text-muted mt-1">
{t("providersAcrossConnectionTypes", { count: totalProviders })}
</p>
</div>
<Link
href="/dashboard/providers"
className="px-3 py-2 rounded-lg border border-border text-sm hover:bg-bg transition-colors"
>
{t("manageProviders")}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<ProviderTable
title={t("providerTypeFree")}
providers={FREE_PROVIDERS}
colorDot="bg-green-500"
/>
<ProviderTable
title={t("providerTypeOAuth")}
providers={OAUTH_PROVIDERS}
colorDot="bg-blue-500"
/>
</div>
<div className="mt-3">
<ProviderTable
title={t("providerTypeApiKey")}
providers={APIKEY_PROVIDERS}
colorDot="bg-amber-500"
maxHeight="400px"
/>
</div>
</section>
<section id="use-cases" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("commonUseCases")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{useCases.map((item) => (
<article key={item.titleKey} className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-text-muted mt-2">{item.text}</p>
</article>
))}
</div>
</section>
<section
id="client-compatibility"
className="rounded-2xl border border-border bg-bg-subtle p-6"
>
<h2 className="text-xl font-semibold">{t("clientCompatibility")}</h2>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCherryStudioTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("baseUrlLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">https://&lt;host&gt;/v1</code>
</li>
<li>
{t("chatEndpointLabel")}:{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>
</li>
<li>
{t("modelRecommendationLabel")} (
<code className="px-1 rounded bg-bg-subtle">gh/...</code>,{" "}
<code className="px-1 rounded bg-bg-subtle">cc/...</code>)
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCodexTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientCodexBullet1")} <code className="px-1 rounded bg-bg-subtle">gh/</code>.
</li>
<li>
{t("clientCodexBullet2")}{" "}
<code className="px-1 rounded bg-bg-subtle">/responses</code>.
</li>
<li>
{t("clientCodexBullet3")}{" "}
<code className="px-1 rounded bg-bg-subtle">/chat/completions</code>.
</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientCursorTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientCursorBullet1")} <code className="px-1 rounded bg-bg-subtle">cu/</code>{" "}
{t("clientCursorBullet1Suffix")}
</li>
<li>{t("clientCursorBullet2")}</li>
<li>{t("supportsChat")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientClaudeTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>
{t("clientClaudeBullet1Prefix")}{" "}
<code className="px-1 rounded bg-bg-subtle">cc/</code>{" "}
{t("clientClaudeBullet1Middle")}{" "}
<code className="px-1 rounded bg-bg-subtle">antigravity/</code>{" "}
{t("clientClaudeBullet1Suffix")}
</li>
<li>{t("oauthAutoRefresh")}</li>
<li>{t("fullStreaming")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientWindsurfTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientWindsurfBullet1")}</li>
<li>{t("clientWindsurfBullet2")}</li>
<li>{t("clientWindsurfBullet3")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientClineTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientClineBullet1")}</li>
<li>{t("clientClineBullet2")}</li>
<li>{t("clientClineBullet3")}</li>
</ul>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("clientKimiTitle")}</h3>
<ul className="mt-2 text-text-muted space-y-1">
<li>{t("clientKimiBullet1")}</li>
<li>{t("clientKimiBullet2")}</li>
<li>{t("clientKimiBullet3")}</li>
</ul>
</article>
</div>
</section>
<section id="protocols" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("protocolsTitle")}</h2>
<p className="text-sm text-text-muted mt-2">{t("protocolsDescription")}</p>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-4 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolMcpTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolMcpDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolMcpStep1")}</li>
<li>{t("protocolMcpStep2")}</li>
<li>{t("protocolMcpStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`omniroute --mcp`}</code>
</pre>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolA2aTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolA2aDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolA2aStep1")}</li>
<li>{t("protocolA2aStep2")}</li>
<li>{t("protocolA2aStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`GET /.well-known/agent.json
POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
</pre>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolAcpTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolAcpDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolAcpStep1")}</li>
<li>{t("protocolAcpStep2")}</li>
<li>{t("protocolAcpStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`Dashboard -> Agents
Dashboard -> CLI Tools`}</code>
</pre>
</article>
</div>
<div className="mt-4 rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolTroubleshootingTitle")}</h3>
<ul className="mt-2 list-disc list-inside text-sm text-text-muted space-y-1">
<li>{t("protocolTroubleshooting1")}</li>
<li>{t("protocolTroubleshooting2")}</li>
<li>{t("protocolTroubleshooting3")}</li>
</ul>
</div>
</section>
<section id="mcp-tools" className="rounded-2xl border border-border bg-bg-subtle p-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xl font-semibold">{t("mcpToolsTitle")}</h2>
<p className="mt-2 text-sm text-text-muted">
{t("mcpToolsDescription", { count: totalMcpTools })}
</p>
</div>
<div className="rounded-full border border-border bg-bg px-3 py-1 text-xs text-text-muted">
{t("mcpToolsCount", { count: totalMcpTools })}
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-3">
{mcpToolGroups.map((group) => (
<article key={group.titleKey} className="rounded-lg border border-border bg-bg p-4">
<h3 className="font-semibold">{group.title}</h3>
<p className="mt-1 text-sm text-text-muted">{group.text}</p>
<div className="mt-3 flex flex-wrap gap-2">
{group.tools.map((tool) => (
<code
key={tool}
className="rounded-md border border-border/70 bg-bg-subtle px-2 py-1 text-xs text-text-muted"
>
{tool}
</code>
))}
</div>
</article>
))}
</div>
</section>
<section id="api-reference" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("apiReference")}</h2>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{endpointRows.map((row) => (
<tr key={row.path} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="model-prefixes" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("modelPrefixes")}</h2>
<p className="text-sm text-text-muted mt-2 mb-4">
{t("modelPrefixesDescriptionStart")}{" "}
<code className="px-1 rounded bg-bg">gh/gpt-5.1-codex</code>{" "}
{t("modelPrefixesDescriptionEnd")}
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("prefix")}</th>
<th className="text-left py-2 pr-4">{t("provider")}</th>
<th className="text-left py-2">{t("type")}</th>
</tr>
</thead>
<tbody>
{providerPrefixRows.map((p) => (
<tr key={p.id} className="border-b border-border/60">
<td className="py-2 pr-4 font-mono">
<code className="px-1.5 py-0.5 rounded bg-bg">{p.alias}/</code>
</td>
<td className="py-2 pr-4">{p.name}</td>
<td className="py-2">
<span
className={`inline-flex items-center gap-1 text-xs ${
p.type === "free"
? "text-green-500"
: p.type === "oauth"
? "text-blue-500"
: "text-amber-500"
}`}
>
<span
className={`size-1.5 rounded-full ${
p.type === "free"
? "bg-green-500"
: p.type === "oauth"
? "bg-blue-500"
: "bg-amber-500"
}`}
/>
{getProviderTypeLabel(p.type)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="management-api" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("managementApiReference")}</h2>
<p className="text-sm text-text-muted mt-2">{t("managementApiDescription")}</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{managementEndpointRows.map((row) => (
<tr key={`${row.method}:${row.path}`} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="troubleshooting" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("troubleshooting")}</h2>
<ul className="mt-4 list-disc list-inside text-sm text-text-muted space-y-2">
{troubleshootingItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</section>
</div>
<WhatsNewSection />
</div>
);
}

View File

@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react";
import APIReference from "./APIReference";
const meta: Meta<typeof APIReference> = {
title: "Docs/APIReference",
component: APIReference,
};
export default meta;
type Story = StoryObj<typeof APIReference>;
export const Default: Story = {
args: {
name: "handleChat()",
type: "async function",
description: "Processes a chat completion request and streams the response to the client.",
params: [
{
name: "request",
type: "ChatRequest",
description: "The structured request containing messages and model config.",
},
{
name: "options",
type: "RequestOptions",
description: "Optional overrides for the request pipeline.",
},
],
returns: {
type: "Promise<SSEStream>",
description:
"A promise that resolves to a Server-Sent Events stream containing model chunks.",
},
},
};

View File

@@ -0,0 +1,83 @@
"use client";
import React from "react";
import { cn } from "@/shared/utils/cn";
interface APIReferenceProps {
name: string;
type: string;
description: string;
params?: { name: string; type: string; description: string }[];
returns?: { type: string; description: string };
className?: string;
}
export default function APIReference({
name,
type,
description,
params,
returns,
className,
}: APIReferenceProps) {
return (
<div
role="region"
aria-labelledby="api-ref-title"
className={cn("my-6 p-6 rounded-xl border border-border bg-bg-subtle", className)}
>
<div className="flex items-center gap-2 mb-4">
<code id="api-ref-title" className="text-lg font-mono font-bold text-primary">
{name}
</code>
<span className="text-xs font-medium px-2 py-1 rounded bg-border text-text-muted">
{type}
</span>
</div>
<p className="text-text-main mb-6 leading-relaxed">{description}</p>
{params && params.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-text-primary mb-3">Parameters</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left border-collapse">
<thead className="text-xs text-text-muted uppercase bg-border/50">
<tr>
<th className="px-4 py-2 border-b border-border" scope="col">
Name
</th>
<th className="px-4 py-2 border-b border-border" scope="col">
Type
</th>
<th className="px-4 py-2 border-b border-border" scope="col">
Description
</th>
</tr>
</thead>
<tbody>
{params.map((param, i) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="px-4 py-3 font-mono text-text-main">{param.name}</td>
<td className="px-4 py-3 font-mono text-xs text-text-muted">{param.type}</td>
<td className="px-4 py-3 text-text-muted">{param.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{returns && (
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Returns</h4>
<div className="p-3 rounded bg-bg border border-border font-mono text-sm">
<span className="text-text-muted">Type: </span>
<span className="text-text-main">{returns.type}</span>
<div className="text-xs text-text-muted mt-1">{returns.description}</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react";
import Callout from "./Callout";
const meta: Meta<typeof Callout> = {
title: "Docs/Callout",
component: Callout,
argTypes: {
type: {
control: "select",
options: ["info", "warning", "danger"],
},
},
};
export default meta;
type Story = StoryObj<typeof Callout>;
export const Default: Story = {
args: {
type: "info",
title: "Information",
children: "This is a helpful hint to guide users through the documentation.",
},
};
export const Warning: Story = {
args: {
type: "warning",
title: "Warning",
children: "Be careful when modifying these settings as they can affect system stability.",
},
};
export const Danger: Story = {
args: {
type: "danger",
title: "Critical",
children: "Deleting this resource is irreversible and will remove all associated data.",
},
};

View File

@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { cn } from "@/shared/utils/cn";
import { DOCS_TOKENS } from "./tokens";
type CalloutType = "info" | "warning" | "danger";
interface CalloutProps {
type: CalloutType;
title: string;
children: React.ReactNode;
className?: string;
}
export default function Callout({ type, title, children, className }: CalloutProps) {
const theme = DOCS_TOKENS.colors[type];
return (
<div
role="note"
aria-labelledby="callout-title"
className={cn(
"relative overflow-hidden rounded-lg border-l-4 p-4 my-4 transition-colors",
"bg-bg-subtle border-border",
className
)}
style={{ borderLeftColor: theme.border }}
>
<div className="flex items-start gap-3">
<div id="callout-title" className="font-semibold text-sm text-text-main">
{title}
</div>
<div className="text-sm leading-relaxed text-text-muted">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react";
import CodeBlock from "./CodeBlock";
const meta: Meta<typeof CodeBlock> = {
title: "Docs/CodeBlock",
component: CodeBlock,
};
export default meta;
type Story = StoryObj<typeof CodeBlock>;
export const Default: Story = {
args: {
code: 'const hello = "world";\nconsole.log(hello);',
language: "typescript",
filename: "example.ts",
},
};
export const WithoutFilename: Story = {
args: {
code: "npm install @omniroute/core",
language: "bash",
},
};

View File

@@ -0,0 +1,52 @@
"use client";
import React from "react";
import { cn } from "@/shared/utils/cn";
interface CodeBlockProps {
code: string;
language?: string;
filename?: string;
children?: React.ReactNode;
className?: string;
}
export default function CodeBlock({
code,
language = "typescript",
filename,
children,
className,
}: CodeBlockProps) {
const copyToClipboard = async () => {
await navigator.clipboard.writeText(code);
};
return (
<div
className={cn(
"group relative my-6 rounded-lg overflow-hidden border border-border",
className
)}
>
{filename && (
<div className="flex items-center justify-between px-4 py-2 bg-bg-subtle border-b border-border text-xs font-medium text-text-muted">
<span>{filename}</span>
<button
onClick={copyToClipboard}
className="opacity-0 group-hover:opacity-100 transition-opacity hover:text-text-main"
aria-label="Copy code to clipboard"
>
Copy
</button>
</div>
)}
<pre
className="p-4 overflow-x-auto text-sm font-mono bg-bg-subtle text-text-main"
tabIndex={0}
>
<code className="block">{children || code}</code>
</pre>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import DocsBreadcrumbs from "./DocsBreadcrumbs";
const meta: Meta<typeof DocsBreadcrumbs> = {
title: "Docs/DocsBreadcrumbs",
component: DocsBreadcrumbs,
};
export default meta;
type Story = StoryObj<typeof DocsBreadcrumbs>;
export const Default: Story = {
args: {
labels: {
intro: "Introduction",
routing: "Routing Engine",
providers: "Provider Ecosystem",
},
},
};

View File

@@ -0,0 +1,46 @@
"use client";
import React from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { cn } from "@/shared/utils/cn";
interface DocsBreadcrumbsProps {
labels: Record<string, string>;
className?: string;
}
export default function DocsBreadcrumbs({ labels, className }: DocsBreadcrumbsProps) {
const pathname = usePathname();
if (!pathname || pathname === "/") return null;
const segments = pathname.split("/").filter(Boolean);
const crumbs = segments.map((seg, idx) => ({
label: labels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1),
href: "/" + segments.slice(0, idx + 1).join("/"),
isLast: idx === segments.length - 1,
}));
return (
<nav
aria-label="Breadcrumb"
className={cn("flex items-center gap-2 text-sm text-text-muted mb-6", className)}
>
<Link href="/" className="hover:text-text-main transition-colors">
Docs
</Link>
{crumbs.map((crumb, i) => (
<React.Fragment key={crumb.href}>
<span className="text-text-muted">/</span>
{crumb.isLast ? (
<span className="font-medium text-text-primary">{crumb.label}</span>
) : (
<Link href={crumb.href} className="hover:text-text-main transition-colors">
{crumb.label}
</Link>
)}
</React.Fragment>
))}
</nav>
);
}

View File

@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import DocsSidebar from "./DocsSidebar";
const meta: Meta<typeof DocsSidebar> = {
title: "Docs/DocsSidebar",
component: DocsSidebar,
};
export default meta;
type Story = StoryObj<typeof DocsSidebar>;
export const Default: Story = {
args: {
sections: [
{
title: "Getting Started",
children: [
{ title: "Introduction", href: "/docs/intro" },
{ title: "Quick Start", href: "/docs/quickstart" },
],
},
{
title: "Core Concepts",
children: [
{ title: "Routing", href: "/docs/routing" },
{ title: "Providers", href: "/docs/providers" },
],
},
],
},
};

View File

@@ -0,0 +1,77 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { DOCS_TOKENS } from "./tokens";
interface NavItem {
title: string;
href: string;
children?: NavItem[];
}
interface DocsSidebarProps {
sections: NavItem[];
currentPath?: string;
className?: string;
}
export default function DocsSidebar({ sections, currentPath, className }: DocsSidebarProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const isActive = (href: string) => pathname === href || currentPath === href;
return (
<div
className={cn(
"flex flex-col h-full bg-sidebar border-r border-border transition-all duration-300",
collapsed ? "w-16" : "w-64",
className
)}
>
<div className="p-4 flex items-center justify-between border-b border-border">
{!collapsed && <span className="font-bold text-text-primary">OmniRoute Docs</span>}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1 hover:bg-bg-subtle rounded transition-colors"
aria-label="Toggle Sidebar"
>
<span className="block w-4 h-0.5 bg-text-main mb-1"></span>
<span className="block w-4 h-0.5 bg-text-main mb-1"></span>
<span className="block w-4 h-0.5 bg-text-main"></span>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{sections.map((section, idx) => (
<div key={idx} className="space-y-2">
{!collapsed && (
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{section.title}
</h3>
)}
<div className="space-y-1">
{section.children?.map((item, childIdx) => (
<Link
key={childIdx}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
isActive(item.href)
? "bg-primary/10 text-primary font-medium"
: "text-text-main hover:bg-bg-subtle"
)}
>
<span className="truncate">{item.title}</span>
</Link>
))}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import React from "react";
import { cn } from "@/shared/utils/cn";
import { DOCS_TOKENS } from "./tokens";
interface ThemeProviderProps {
children: React.ReactNode;
initialTheme?: "light" | "dark" | "system";
}
export default function DocsThemeProvider({
children,
initialTheme = "system",
}: ThemeProviderProps) {
return (
<div className="min-h-screen bg-bg text-text-main font-sans selection:bg-primary/20">
{children}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react";
import Tabs from "./Tabs";
const meta: Meta<typeof Tabs> = {
title: "Docs/Tabs",
component: Tabs,
};
export default meta;
type Story = StoryObj<typeof Tabs>;
export const Default: Story = {
args: {
tabs: [
{
label: "Installation",
content: '<div className="p-2">Run npm install to get started.</div>',
},
{ label: "Configuration", content: '<div className="p-2">Edit your config.json file.</div>' },
{
label: "Usage",
content: '<div className="p-2">Import the provider and start routing.</div>',
},
],
},
};

View File

@@ -0,0 +1,50 @@
"use client";
import React, { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface Tab {
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultIndex?: number;
className?: string;
}
export default function Tabs({ tabs, defaultIndex = 0, className }: TabsProps) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<div className={cn("my-6 flex flex-col gap-2", className)} role="tablist">
<div className="flex gap-1 border-b border-border">
{tabs.map((tab, index) => (
<button
key={index}
role="tab"
aria-selected={activeIndex === index}
aria-controls={`tab-panel-${index}`}
onClick={() => setActiveIndex(index)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors",
activeIndex === index
? "text-text-primary border-b-2 border-primary"
: "text-text-muted hover:text-text-main"
)}
>
{tab.label}
</button>
))}
</div>
<div
id={`tab-panel-${activeIndex}`}
role="tabpanel"
className="p-4 rounded-lg bg-bg-subtle border border-border"
>
{tabs[activeIndex].content}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
export const DOCS_TOKENS = {
colors: {
info: {
bg: "var(--color-bg-subtle)",
border: "var(--color-accent)",
text: "var(--color-text-main)",
darkBg: "rgba(99, 102, 241, 0.1)",
},
warning: {
bg: "var(--color-bg-subtle)",
border: "var(--color-warning)",
text: "var(--color-text-main)",
darkBg: "rgba(245, 158, 11, 0.1)",
},
danger: {
bg: "var(--color-bg-subtle)",
border: "var(--color-error)",
text: "var(--color-text-main)",
darkBg: "rgba(239, 68, 68, 0.1)",
},
code: {
bg: "var(--color-sidebar)",
text: "var(--color-text-main)",
border: "var(--color-border)",
},
},
typography: {
headingLg: "text-3xl font-bold tracking-tight text-text-primary",
headingMd: "text-2xl font-semibold tracking-tight text-text-primary",
headingSm: "text-xl font-semibold tracking-tight text-text-primary",
body: "text-base leading-relaxed text-text-main",
muted: "text-sm text-text-muted",
},
spacing: {
cardGap: "gap-6",
sectionGap: "gap-12",
},
};

View File

@@ -0,0 +1,309 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
extractHeadings,
renderMarkdown,
getDocItemBySlug,
getAllDocSlugsFlat,
getPrevNextSlugs,
} from "../../src/app/docs/[slug]/page";
import { docsNavigation } from "../../src/app/docs/lib/docsNavigation";
import { SEARCH_INDEX } from "../../src/app/docs/lib/searchIndex";
// ──────────────────────────────────────────────
// docsNavigation structure
// ──────────────────────────────────────────────
test("docsNavigation has 6 sections", () => {
assert.equal(docsNavigation.length, 6);
});
test("every section has title and items", () => {
for (const section of docsNavigation) {
assert.ok(section.title, "section must have a title");
assert.ok(Array.isArray(section.items), "section.items must be an array");
assert.ok(section.items.length > 0, "section must have at least one item");
}
});
test("every doc item has slug, title, fileName", () => {
for (const section of docsNavigation) {
for (const item of section.items) {
assert.ok(item.slug, "item must have a slug");
assert.ok(item.title, "item must have a title");
assert.ok(item.fileName, "item must have a fileName");
}
}
});
// ──────────────────────────────────────────────
// getDocItemBySlug
// ──────────────────────────────────────────────
test("getDocItemBySlug returns section title and item for known slug", () => {
const result = getDocItemBySlug("setup-guide");
assert.ok(result, "setup-guide should be found");
assert.equal(result.item.slug, "setup-guide");
assert.equal(result.sectionTitle, "Getting Started");
});
test("getDocItemBySlug returns null for unknown slug", () => {
const result = getDocItemBySlug("nonexistent-page");
assert.equal(result, null);
});
test("getDocItemBySlug finds items in all sections", () => {
const sectionTitles = docsNavigation.map((s) => s.title);
for (const section of docsNavigation) {
const firstItem = section.items[0];
const result = getDocItemBySlug(firstItem.slug);
assert.ok(result, `should find ${firstItem.slug}`);
assert.ok(sectionTitles.includes(result.sectionTitle));
}
});
// ──────────────────────────────────────────────
// getAllDocSlugsFlat
// ──────────────────────────────────────────────
test("getAllDocSlugsFlat returns all slugs from all sections", () => {
const slugs = getAllDocSlugsFlat();
const totalItems = docsNavigation.reduce((sum, s) => sum + s.items.length, 0);
assert.equal(slugs.length, totalItems);
});
test("getAllDocSlugsFlat includes setup-guide as first slug", () => {
const slugs = getAllDocSlugsFlat();
assert.ok(slugs.includes("setup-guide"));
});
// ──────────────────────────────────────────────
// getPrevNextSlugs
// ──────────────────────────────────────────────
test("getPrevNextSlugs returns null prev for first slug", () => {
const slugs = getAllDocSlugsFlat();
const firstSlug = slugs[0];
const { prev } = getPrevNextSlugs(firstSlug);
assert.equal(prev, null);
});
test("getPrevNextSlugs returns null next for last slug", () => {
const slugs = getAllDocSlugsFlat();
const lastSlug = slugs[slugs.length - 1];
const { next } = getPrevNextSlugs(lastSlug);
assert.equal(next, null);
});
test("getPrevNextSlugs returns correct prev and next for middle slug", () => {
const slugs = getAllDocSlugsFlat();
const middleIdx = Math.floor(slugs.length / 2);
const middleSlug = slugs[middleIdx];
const { prev, next } = getPrevNextSlugs(middleSlug);
assert.equal(prev, slugs[middleIdx - 1]);
assert.equal(next, slugs[middleIdx + 1]);
});
// ──────────────────────────────────────────────
// extractHeadings
// ──────────────────────────────────────────────
test("extractHeadings extracts h2, h3, h4 headings", () => {
const md = `## First Section\nSome text\n### Subsection\nMore text\n#### Details\nEnd`;
const headings = extractHeadings(md);
assert.equal(headings.length, 3);
assert.equal(headings[0].text, "First Section");
assert.equal(headings[0].level, 2);
assert.equal(headings[1].text, "Subsection");
assert.equal(headings[1].level, 3);
assert.equal(headings[2].text, "Details");
assert.equal(headings[2].level, 4);
});
test("extractHeadings generates valid id from heading text", () => {
const md = `## Getting Started Guide\n### API Reference\n#### Step 1: Install`;
const headings = extractHeadings(md);
assert.equal(headings[0].id, "getting-started-guide");
assert.equal(headings[1].id, "api-reference");
assert.equal(headings[2].id, "step-1-install");
});
test("extractHeadings returns empty array for content without headings", () => {
const md = "Just some text without any headings";
const headings = extractHeadings(md);
assert.equal(headings.length, 0);
});
test("extractHeadings strips bold and code from heading text", () => {
const md = "## **Bold** Heading\n### \`Code\` Heading";
const headings = extractHeadings(md);
assert.equal(headings[0].text, "Bold Heading");
assert.equal(headings[1].text, "Code Heading");
});
// ──────────────────────────────────────────────
// renderMarkdown
// ──────────────────────────────────────────────
test("renderMarkdown converts headings to HTML", () => {
const html = renderMarkdown("# Title\n## Section\n### Subsection\n#### Details");
assert.ok(html.includes('<h1 class="text-3xl'), "h1 tag");
assert.ok(html.includes('<h2 id="Section"'), "h2 tag with id");
assert.ok(html.includes('<h3 id="Subsection"'), "h3 tag with id");
assert.ok(html.includes('<h4 id="Details"'), "h4 tag with id");
});
test("renderMarkdown converts code blocks", () => {
const html = renderMarkdown("```js\nconst x = 1;\n```");
assert.ok(html.includes('<pre class="bg-bg-subtle'), "pre tag");
assert.ok(html.includes("language-js"), "language class");
});
test("renderMarkdown converts inline code", () => {
const html = renderMarkdown("Use `npm install` to install");
assert.ok(html.includes('<code class="bg-bg-subtle'), "inline code tag");
});
test("renderMarkdown converts bold text", () => {
const html = renderMarkdown("This is **bold** text");
assert.ok(html.includes("<strong>bold</strong>"));
});
test("renderMarkdown converts italic text", () => {
const html = renderMarkdown("This is *italic* text");
assert.ok(html.includes("<em>italic</em>"));
});
test("renderMarkdown converts links", () => {
const html = renderMarkdown("[OmniRoute](https://omniroute.online)");
assert.ok(html.includes('<a href="https://omniroute.online"'));
assert.ok(html.includes(">OmniRoute</a>"));
});
test("renderMarkdown converts unordered lists", () => {
const html = renderMarkdown("- Item 1\n- Item 2");
assert.ok(html.includes('<li class="mb-1 ml-4">'));
});
test("renderMarkdown converts ordered lists", () => {
const html = renderMarkdown("1. First\n2. Second");
assert.ok(html.includes('<li class="mb-1 ml-4">'));
});
test("renderMarkdown converts blockquotes", () => {
const html = renderMarkdown("> This is a quote");
assert.ok(html.includes("<blockquote"));
assert.ok(html.includes("border-l-4"));
});
test("renderMarkdown converts horizontal rules", () => {
const html = renderMarkdown("---");
assert.ok(html.includes('<hr class="border-border'));
});
// ──────────────────────────────────────────────
// SEARCH_INDEX
// ──────────────────────────────────────────────
test("SEARCH_INDEX has entries for all doc slugs", () => {
const navSlugs = getAllDocSlugsFlat();
// searchIndex and nav slugs should have significant overlap
const indexSlugs = SEARCH_INDEX.map((item) => item.slug);
for (const slug of navSlugs) {
assert.ok(indexSlugs.includes(slug), `SEARCH_INDEX missing slug: ${slug}`);
}
});
test("SEARCH_INDEX entries have required fields", () => {
for (const item of SEARCH_INDEX) {
assert.ok(item.slug, "item must have slug");
assert.ok(item.title, "item must have title");
assert.ok(item.fileName, "item must have fileName");
assert.ok(item.section, "item must have section");
assert.ok(typeof item.content === "string", "item must have content string");
assert.ok(Array.isArray(item.headings), "item must have headings array");
}
});
test("SEARCH_INDEX entries have non-empty content", () => {
for (const item of SEARCH_INDEX) {
assert.ok(item.content.length > 0, `${item.slug} should have content`);
}
});
// ──────────────────────────────────────────────
// Mermaid extraction
// ──────────────────────────────────────────────
test("extractMermaidCharts extracts mermaid blocks from content", async () => {
const { extractMermaidCharts } = await import("../../src/app/docs/[slug]/page");
const content =
"## Diagram\n\n```mermaid\ngraph TD\n A-->B\n```\n\nSome text\n\n```mermaid\nsequenceDiagram\n Alice->>Bob: Hi\n```";
const charts = extractMermaidCharts(content);
assert.equal(charts.length, 2);
assert.ok(charts[0].includes("graph TD"));
assert.ok(charts[1].includes("Alice->>Bob"));
});
test("extractMermaidCharts returns empty array when no mermaid blocks", async () => {
const { extractMermaidCharts } = await import("../../src/app/docs/[slug]/page");
const content = "## Heading\n\nSome text with ```js\ncode\n```";
const charts = extractMermaidCharts(content);
assert.equal(charts.length, 0);
});
// ──────────────────────────────────────────────
// Mermaid rendering in markdown
// ──────────────────────────────────────────────
test("renderMarkdown converts mermaid code blocks to fallback divs", () => {
const markdown = "```mermaid\ngraph TD\n A-->B\n```";
const html = renderMarkdown(markdown);
assert.ok(
html.includes("mermaid-diagram-fallback"),
"Should contain mermaid-diagram-fallback class"
);
assert.ok(html.includes("data-mermaid"), "Should contain data-mermaid attribute");
});
// ──────────────────────────────────────────────
// Analytics component
// ──────────────────────────────────────────────
test("DocsPageAnalytics is importable", async () => {
const mod = await import("../../src/app/docs/components/DocsPageAnalytics");
assert.ok(mod.DocsPageAnalytics, "DocsPageAnalytics should be exported");
assert.ok(typeof mod.getPopularPages === "function", "getPopularPages should be exported");
});
// ──────────────────────────────────────────────
// What's New and Migration Guide
// ──────────────────────────────────────────────
test("WhatsNewSection is importable", async () => {
const mod = await import("../../src/app/docs/components/WhatsNewSection");
assert.ok(mod.WhatsNewSection, "WhatsNewSection should be exported");
assert.ok(mod.MigrationGuideBanner, "MigrationGuideBanner should be exported");
});
// ──────────────────────────────────────────────
// i18n locale system
// ──────────────────────────────────────────────
test("DocsI18n is importable", async () => {
const mod = await import("../../src/app/docs/components/DocsI18n");
assert.ok(mod.useLocalizedSectionTitle, "useLocalizedSectionTitle should be exported");
assert.ok(mod.getAvailableLocales, "getAvailableLocales should be exported");
assert.ok(mod.LOCALE_NAMES, "LOCALE_NAMES should be exported");
assert.equal(mod.LOCALE_NAMES.en, "English");
assert.equal(mod.LOCALE_NAMES["zh-CN"], "简体中文");
});
test("getAvailableLocales returns 10 locales", async () => {
const { getAvailableLocales } = await import("../../src/app/docs/components/DocsI18n");
const locales = getAvailableLocales();
assert.equal(locales.length, 10);
assert.ok(locales.includes("en"));
assert.ok(locales.includes("pt-BR"));
assert.ok(locales.includes("zh-CN"));
});