mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-27 00:09:07 +02:00
port mintlify to fumadocs
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import 'server-only';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
|
||||
/**
|
||||
* Bring-your-own-key model configuration for the docs assistant. Everything comes from
|
||||
* environment variables on the server; keys are never sent to the browser.
|
||||
*
|
||||
* AI_PROVIDER anthropic | openai | google | openai-compatible
|
||||
* (optional: inferred from whichever key is set)
|
||||
* AI_MODEL model id for that provider (optional: sensible default per provider)
|
||||
*
|
||||
* ANTHROPIC_API_KEY for `anthropic`
|
||||
* OPENAI_API_KEY for `openai`
|
||||
* GOOGLE_GENERATIVE_AI_API_KEY for `google`
|
||||
* AI_BASE_URL + AI_API_KEY for `openai-compatible` (OpenRouter, Ollama, vLLM,
|
||||
* LiteLLM, a Pangolin AI Gateway resource, ...)
|
||||
*/
|
||||
export type Provider = 'anthropic' | 'openai' | 'google' | 'openai-compatible';
|
||||
|
||||
const defaultModels: Record<Provider, string | undefined> = {
|
||||
anthropic: 'claude-opus-5',
|
||||
openai: 'gpt-5.6-luna',
|
||||
google: 'gemini-2.5-pro',
|
||||
'openai-compatible': undefined,
|
||||
};
|
||||
|
||||
function detectProvider(): Provider | undefined {
|
||||
const explicit = process.env.AI_PROVIDER?.trim().toLowerCase();
|
||||
if (explicit) {
|
||||
if (explicit in defaultModels) return explicit as Provider;
|
||||
throw new Error(`Unknown AI_PROVIDER "${explicit}"`);
|
||||
}
|
||||
if (process.env.AI_BASE_URL) return 'openai-compatible';
|
||||
if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
|
||||
if (process.env.OPENAI_API_KEY) return 'openai';
|
||||
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) return 'google';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
provider: Provider;
|
||||
modelId: string;
|
||||
model: LanguageModel;
|
||||
}
|
||||
|
||||
export class AIConfigError extends Error {}
|
||||
|
||||
export function getModel(): ModelConfig {
|
||||
const provider = detectProvider();
|
||||
if (!provider) {
|
||||
throw new AIConfigError(
|
||||
'The docs assistant is not configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, or AI_BASE_URL + AI_API_KEY on the server.',
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = process.env.AI_MODEL?.trim() || defaultModels[provider];
|
||||
if (!modelId) throw new AIConfigError(`AI_MODEL is required for provider "${provider}".`);
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return { provider, modelId, model: createAnthropic()(modelId) };
|
||||
case 'openai':
|
||||
return { provider, modelId, model: createOpenAI()(modelId) };
|
||||
case 'google':
|
||||
return { provider, modelId, model: createGoogleGenerativeAI()(modelId) };
|
||||
case 'openai-compatible': {
|
||||
const baseURL = process.env.AI_BASE_URL;
|
||||
if (!baseURL) throw new AIConfigError('AI_BASE_URL is required for openai-compatible.');
|
||||
const compatible = createOpenAICompatible({
|
||||
name: 'custom',
|
||||
baseURL,
|
||||
apiKey: process.env.AI_API_KEY,
|
||||
});
|
||||
return { provider, modelId, model: compatible.chatModel(modelId) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isAIConfigured() {
|
||||
try {
|
||||
getModel();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'server-only';
|
||||
import { source } from '@/lib/source';
|
||||
import { siteUrl } from '@/lib/shared';
|
||||
|
||||
let pageIndex: string | undefined;
|
||||
|
||||
/** compact list of every page, so the model knows what exists before searching */
|
||||
function getPageIndex() {
|
||||
pageIndex ??= source
|
||||
.getPages()
|
||||
.map((page) => `- ${page.url} | ${page.data.title}${page.data.description ? ` — ${page.data.description}` : ''}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
return pageIndex;
|
||||
}
|
||||
|
||||
export function getSystemPrompt() {
|
||||
return `You are the documentation assistant for Pangolin, an open-source networking and security platform (identity-aware access to apps, infrastructure, and AI workloads). You answer questions from people reading the docs at ${siteUrl}.
|
||||
|
||||
How to work:
|
||||
- Ground every answer in the documentation. Before answering anything specific (config keys, commands, versions, UI steps, feature availability), use search_docs and read_page to look it up. Read the full page when the answer depends on details.
|
||||
- The page index below lists every page. If a page's title clearly matches the question, you can read it directly.
|
||||
- Cite the pages you used as Markdown links with site-relative URLs, e.g. [Install a Site](/manage/sites/install-site). Link to sections with #anchors when you have them.
|
||||
- If the docs don't cover something, say so plainly and point to the closest relevant page or to the community (Discord: https://pangolin.net/discord, GitHub discussions: https://github.com/fosrl/pangolin/discussions). Do not invent configuration options, flags, or behavior.
|
||||
- Page sources contain MDX components such as <Note>, <Steps>, <Card> or <ResponseField>. Never output those tags; rewrite their content as plain Markdown (blockquotes, lists, headings, tables).
|
||||
- Be concise and practical. Prefer short steps and code blocks that the user can copy. Use the same terminology as the docs (sites, resources, clients, Newt, Olm, Gerbil, Badger, remote nodes).
|
||||
- Pangolin has a hosted Cloud edition and a self-hosted edition (Community and Enterprise). When the answer differs between them, say which one you are describing.
|
||||
- Each user message may include "[Client Context: ...]" with the page the user is currently viewing. Use it to resolve questions like "this page" or "how do I do this".
|
||||
|
||||
Page index (URL | title — description):
|
||||
${getPageIndex()}`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'server-only';
|
||||
|
||||
/**
|
||||
* Minimal in-memory sliding-window limiter so a public docs site can't be used to burn
|
||||
* through your API credits. It is per server instance; put a shared limiter (or your
|
||||
* reverse proxy's) in front if you run several replicas.
|
||||
*/
|
||||
const windowMs = 60_000;
|
||||
const limit = Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10);
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
export function checkRateLimit(key: string): { ok: boolean; retryAfter: number } {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
|
||||
|
||||
const now = Date.now();
|
||||
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (recent.length >= limit) {
|
||||
hits.set(key, recent);
|
||||
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
hits.set(key, recent);
|
||||
|
||||
// keep the map from growing forever
|
||||
if (hits.size > 10_000) {
|
||||
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
export function clientKey(req: Request) {
|
||||
return (
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
req.headers.get('x-real-ip') ||
|
||||
'anonymous'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'server-only';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getPageText, source } from '@/lib/source';
|
||||
import { searchServer } from '@/lib/search';
|
||||
|
||||
const MAX_PAGE_CHARS = 60_000;
|
||||
|
||||
function findPage(pathOrUrl: string) {
|
||||
let path = pathOrUrl.trim();
|
||||
try {
|
||||
path = new URL(path, 'http://x').pathname;
|
||||
} catch {
|
||||
// not a URL, use as is
|
||||
}
|
||||
path = path.replace(/\.mdx?$/, '').replace(/^\/+|\/+$/g, '');
|
||||
const slugs = path === '' || path === 'index' ? [] : path.split('/');
|
||||
return source.getPage(slugs);
|
||||
}
|
||||
|
||||
export const searchDocs = tool({
|
||||
description:
|
||||
'Full-text search over the Pangolin documentation. Returns matching pages and sections with their URLs. Use several short keyword queries rather than one long question.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('keywords, e.g. "newt docker compose" or "OIDC auto provisioning"'),
|
||||
limit: z.number().int().min(1).max(25).default(10),
|
||||
}),
|
||||
async execute({ query, limit }) {
|
||||
const results = await searchServer.search(query, { limit: limit * 3 });
|
||||
const pages = new Map<string, { url: string; title: string; matches: string[] }>();
|
||||
|
||||
for (const result of results) {
|
||||
const url = result.url.split('#')[0];
|
||||
const page = pages.get(url) ?? {
|
||||
url,
|
||||
title: findPage(url)?.data.title ?? url,
|
||||
matches: [],
|
||||
};
|
||||
if (result.type !== 'page' && page.matches.length < 3) {
|
||||
const text = result.content.replace(/<\/?mark>/g, '');
|
||||
page.matches.push(result.type === 'heading' ? `${text} (${result.url})` : text);
|
||||
}
|
||||
pages.set(url, page);
|
||||
if (pages.size >= limit) break;
|
||||
}
|
||||
return [...pages.values()];
|
||||
},
|
||||
});
|
||||
|
||||
export const readPage = tool({
|
||||
description:
|
||||
'Read the full Markdown content of one documentation page. Pass the page URL path from search results or the page index, e.g. "/manage/sites/install-site".',
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
async execute({ path }) {
|
||||
const page = findPage(path);
|
||||
if (!page) return { error: `No page found at "${path}". Use search_docs to find the right URL.` };
|
||||
|
||||
const text = await getPageText(page);
|
||||
if (text.length <= MAX_PAGE_CHARS) return { url: page.url, content: text };
|
||||
return {
|
||||
url: page.url,
|
||||
truncated: true,
|
||||
note: `Page is ${text.length} characters; only the first ${MAX_PAGE_CHARS} are included. Search for specific sections if the answer is not here.`,
|
||||
content: text.slice(0, MAX_PAGE_CHARS),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const tools = {
|
||||
search_docs: searchDocs,
|
||||
read_page: readPage,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { appName, enableDarkMode, links } from './shared';
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src="/logo/light.png"
|
||||
alt={appName}
|
||||
width={665}
|
||||
height={164}
|
||||
className="pg-logo dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo/dark.png"
|
||||
alt={appName}
|
||||
width={933}
|
||||
height={164}
|
||||
className="pg-logo hidden dark:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: <Logo />,
|
||||
url: '/',
|
||||
},
|
||||
themeSwitch: { enabled: enableDarkMode },
|
||||
links: [
|
||||
// pill buttons styled like the pangolin.net navbar (secondary + primary)
|
||||
{
|
||||
type: 'custom',
|
||||
on: 'nav',
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={links.login} className="pg-btn pg-btn-secondary">
|
||||
Log in
|
||||
</a>
|
||||
<a href={links.signup} className="pg-btn">
|
||||
Start for free
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Log in',
|
||||
url: links.login,
|
||||
external: true,
|
||||
on: 'menu',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Start for free',
|
||||
url: links.signup,
|
||||
external: true,
|
||||
on: 'menu',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"group": "About",
|
||||
"pages": [
|
||||
"index",
|
||||
"about/how-pangolin-works",
|
||||
"about/pangolin-vs-reverse-proxy-vs-vpn",
|
||||
"about/pangolin-vs-bifrost-vs-litellm",
|
||||
"about/pangolin-cloud-vs-self-hosted"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Manage Pangolin",
|
||||
"pages": [
|
||||
{
|
||||
"group": "Sites",
|
||||
"icon": "plug",
|
||||
"pages": [
|
||||
"manage/sites/understanding-sites",
|
||||
"manage/sites/install-site",
|
||||
"manage/sites/configure-site",
|
||||
"manage/sites/update-site",
|
||||
"manage/sites/auto-update",
|
||||
"manage/sites/credentials",
|
||||
"manage/sites/site-provisioning",
|
||||
{
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"manage/sites/kubernetes/helm",
|
||||
"manage/sites/kubernetes/kustomize",
|
||||
"manage/sites/kubernetes/configuration",
|
||||
"manage/sites/kubernetes/troubleshooting"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Resources",
|
||||
"icon": "link",
|
||||
"pages": [
|
||||
"manage/resources/understanding-resources",
|
||||
"manage/resource-launcher",
|
||||
{
|
||||
"group": "Public Resources",
|
||||
"pages": [
|
||||
"manage/resources/public/http-https",
|
||||
"manage/resources/public/ai-gateway",
|
||||
"manage/resources/public/ssh",
|
||||
"manage/resources/public/rdp",
|
||||
"manage/resources/public/vnc",
|
||||
"manage/resources/public/raw-resources",
|
||||
"manage/resources/public/authentication",
|
||||
"manage/resources/public/resource-policies",
|
||||
"manage/resources/public/targets",
|
||||
"manage/resources/public/wildcard-resources",
|
||||
"manage/resources/public/healthchecks-failover",
|
||||
"manage/resources/public/maintenance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Private Resources",
|
||||
"pages": [
|
||||
"manage/resources/private/host",
|
||||
"manage/resources/private/cidr",
|
||||
"manage/resources/private/private-http",
|
||||
"manage/resources/private/ai-gateway",
|
||||
"manage/resources/private/ssh",
|
||||
"manage/resources/private/authentication",
|
||||
"manage/resources/private/destinations",
|
||||
"manage/resources/private/port-restrictions",
|
||||
"manage/resources/private/alias",
|
||||
"manage/resources/private/multi-site-routing"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Clients",
|
||||
"icon": "desktop",
|
||||
"pages": [
|
||||
"manage/clients/understanding-clients",
|
||||
"manage/clients/nat-traversal",
|
||||
"manage/clients/install-client",
|
||||
"manage/clients/configure-client",
|
||||
"manage/clients/client-logs",
|
||||
"manage/clients/update-client",
|
||||
"manage/clients/credentials",
|
||||
"manage/clients/fingerprinting",
|
||||
"manage/clients/archiving-blocking",
|
||||
"manage/clients/firewalls",
|
||||
{
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"manage/clients/kubernetes/deployment"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "AI Gateway",
|
||||
"icon": "sparkles",
|
||||
"pages": [
|
||||
"manage/ai/overview",
|
||||
"manage/ai/multiple-gateway-resources",
|
||||
"manage/ai/virtual-api-keys",
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"manage/ai/providers/overview",
|
||||
"manage/ai/providers/configuration",
|
||||
"manage/ai/providers/model-routing",
|
||||
"manage/ai/providers/openai",
|
||||
"manage/ai/providers/anthropic",
|
||||
"manage/ai/providers/google-gemini",
|
||||
"manage/ai/providers/vertex-ai",
|
||||
"manage/ai/providers/bedrock",
|
||||
"manage/ai/providers/microsoft-foundry",
|
||||
"manage/ai/providers/open-router",
|
||||
"manage/ai/providers/vercel-ai-gateway",
|
||||
{
|
||||
"group": "Custom",
|
||||
"pages": [
|
||||
"manage/ai/providers/custom",
|
||||
"manage/ai/providers/custom/ollama",
|
||||
"manage/ai/providers/custom/vllm",
|
||||
"manage/ai/providers/custom/bifrost",
|
||||
"manage/ai/providers/custom/cloud-apis-through-a-site"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Configure AI Clients and Agents",
|
||||
"pages": [
|
||||
"manage/ai/configure-ai-clients/claude",
|
||||
"manage/ai/configure-ai-clients/codex",
|
||||
"manage/ai/configure-ai-clients/opencode",
|
||||
"manage/ai/configure-ai-clients/gemini",
|
||||
"manage/ai/configure-ai-clients/open-webui",
|
||||
"manage/ai/configure-ai-clients/claude-desktop",
|
||||
"manage/ai/configure-ai-clients/openclaw"
|
||||
]
|
||||
},
|
||||
"manage/ai/budgets",
|
||||
"manage/ai/model-catalog",
|
||||
"manage/ai/session-logs",
|
||||
"manage/ai/usage-analytics"
|
||||
]
|
||||
},
|
||||
"manage/domains",
|
||||
{
|
||||
"group": "Organizations",
|
||||
"icon": "building",
|
||||
"pages": [
|
||||
"manage/organizations/manage-organizations",
|
||||
"manage/organizations/org-id",
|
||||
"manage/labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Access Control",
|
||||
"icon": "user-group",
|
||||
"pages": [
|
||||
"manage/access-control/create-user",
|
||||
"manage/access-control/mfa",
|
||||
"manage/access-control/security-keys",
|
||||
"manage/access-control/session-length",
|
||||
"manage/access-control/password-rotation",
|
||||
"manage/access-control/change-password",
|
||||
"manage/ssh",
|
||||
"manage/access-control/rules",
|
||||
"manage/geoblocking",
|
||||
"manage/asnblocking",
|
||||
"manage/access-control/links",
|
||||
"manage/access-control/approvals",
|
||||
"manage/access-control/forwarded-headers",
|
||||
"manage/access-control/login-page"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Identity Providers",
|
||||
"icon": "id-card",
|
||||
"pages": [
|
||||
"manage/identity-providers/add-an-idp",
|
||||
"manage/identity-providers/auto-provisioning",
|
||||
"manage/identity-providers/openid-connect",
|
||||
"manage/identity-providers/google",
|
||||
"manage/identity-providers/azure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Logs & Analytics",
|
||||
"icon": "chart-bar",
|
||||
"pages": [
|
||||
"manage/analytics/request",
|
||||
"manage/analytics/access",
|
||||
"manage/analytics/connection",
|
||||
"manage/analytics/action",
|
||||
"manage/ai/session-logs",
|
||||
"manage/ai/usage-analytics",
|
||||
{
|
||||
"group": "Event Streaming",
|
||||
"pages": [
|
||||
"manage/analytics/streaming",
|
||||
"manage/analytics/streaming/http",
|
||||
"manage/analytics/streaming/s3"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Alerting",
|
||||
"icon": "bell",
|
||||
"pages": [
|
||||
"manage/alerting/alert-rules",
|
||||
"manage/alerting/health-checks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Blueprints",
|
||||
"icon": "file-code",
|
||||
"pages": [
|
||||
"manage/blueprints",
|
||||
"manage/community-blueprints-repo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Remote Nodes",
|
||||
"icon": "circle-nodes",
|
||||
"pages": [
|
||||
"manage/remote-node/understanding-nodes",
|
||||
"manage/remote-node/quick-install-remote",
|
||||
"manage/remote-node/config-file",
|
||||
"manage/remote-node/backhaul"
|
||||
]
|
||||
},
|
||||
"manage/endpoints-and-pops",
|
||||
"manage/integration-api",
|
||||
"manage/branding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Self-host Pangolin",
|
||||
"pages": [
|
||||
"self-host/quick-install",
|
||||
"self-host/choosing-a-vps",
|
||||
{
|
||||
"group": "Manual Installation",
|
||||
"pages": [
|
||||
"self-host/manual/docker-compose",
|
||||
"self-host/manual/unraid",
|
||||
{
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"self-host/manual/kubernetes/overview",
|
||||
"self-host/manual/kubernetes/choose-method",
|
||||
"self-host/manual/kubernetes/prerequisites",
|
||||
"self-host/manual/kubernetes/helm",
|
||||
"self-host/manual/kubernetes/kustomize",
|
||||
"self-host/manual/kubernetes/helmfile",
|
||||
{
|
||||
"group": "GitOps",
|
||||
"pages": [
|
||||
"self-host/manual/kubernetes/gitops/overview",
|
||||
"self-host/manual/kubernetes/gitops/argocd",
|
||||
"self-host/manual/kubernetes/gitops/flux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Pangolin",
|
||||
"pages": [
|
||||
"self-host/manual/kubernetes/pangolin/helm",
|
||||
"self-host/manual/kubernetes/pangolin/kustomize",
|
||||
"self-host/manual/kubernetes/pangolin/configuration",
|
||||
"self-host/manual/kubernetes/pangolin/troubleshooting"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"self-host/manual/podman-quadlets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Clustering",
|
||||
"pages": [
|
||||
"self-host/clustering/understanding-clustering",
|
||||
"self-host/clustering/requirements",
|
||||
"self-host/clustering/deploy-a-cluster"
|
||||
]
|
||||
},
|
||||
"self-host/dns-and-networking",
|
||||
{
|
||||
"group": "Advanced Configuration",
|
||||
"pages": [
|
||||
"self-host/advanced/config-file",
|
||||
"self-host/advanced/increasing-subnet-capacity",
|
||||
"self-host/advanced/private-config-file",
|
||||
"self-host/advanced/wild-card-domains",
|
||||
"self-host/advanced/cloudflare-proxy",
|
||||
"self-host/advanced/without-tunneling",
|
||||
"self-host/advanced/container-cli-tool",
|
||||
"self-host/advanced/database-options",
|
||||
"self-host/advanced/integration-api",
|
||||
"self-host/advanced/observability",
|
||||
"self-host/advanced/enable-geolocation",
|
||||
"self-host/advanced/enable-asn-lookup",
|
||||
"self-host/advanced/traefik-log-rotation",
|
||||
"self-host/telemetry"
|
||||
]
|
||||
},
|
||||
"self-host/how-to-update",
|
||||
{
|
||||
"group": "Community Guides",
|
||||
"pages": [
|
||||
"self-host/community-guides/overview",
|
||||
"self-host/community-guides/rules",
|
||||
"self-host/community-guides/remove-geoblock-plugin",
|
||||
"self-host/community-guides/crowdsec",
|
||||
"self-host/community-guides/metrics",
|
||||
"self-host/community-guides/homeassistant",
|
||||
"self-host/community-guides/middlewaremanager",
|
||||
"self-host/community-guides/traefiklogsdashboard",
|
||||
"self-host/community-guides/geolite2automation"
|
||||
]
|
||||
},
|
||||
"self-host/enterprise-edition"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Development",
|
||||
"pages": [
|
||||
"development/system-architecture",
|
||||
"development/contributing",
|
||||
"development/feature-requests-and-bug-reports",
|
||||
"manage/common-api-routes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tabs": [
|
||||
{
|
||||
"label": "Downloads",
|
||||
"href": "https://pangolin.net/downloads"
|
||||
},
|
||||
{
|
||||
"label": "Website",
|
||||
"href": "https://pangolin.net/"
|
||||
},
|
||||
{
|
||||
"label": "GitHub",
|
||||
"href": "https://github.com/fosrl/pangolin"
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"href": "https://pangolin.net/news/tags/release"
|
||||
},
|
||||
{
|
||||
"label": "Trust Center",
|
||||
"href": "https://trust.pangolin.net/"
|
||||
}
|
||||
],
|
||||
"anchors": [
|
||||
{
|
||||
"label": "Discord",
|
||||
"href": "https://pangolin.net/discord",
|
||||
"icon": "discord"
|
||||
},
|
||||
{
|
||||
"label": "Slack",
|
||||
"href": "https://pangolin.net/slack",
|
||||
"icon": "slack"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
import { Icon } from '@/components/icons';
|
||||
import { source } from './source';
|
||||
import navigation from './navigation.json';
|
||||
|
||||
/**
|
||||
* The sidebar is defined in `lib/navigation.json` (same shape as Mintlify's `docs.json`
|
||||
* groups), because the groups do not map 1:1 to folders on disk. Pages that exist in
|
||||
* `content/docs` but are not listed here are still published, just hidden from the sidebar.
|
||||
*/
|
||||
type NavItem = string | NavGroup;
|
||||
interface NavGroup {
|
||||
group: string;
|
||||
icon?: string;
|
||||
pages: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavLink {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export const navTabs: NavLink[] = navigation.tabs;
|
||||
export const navAnchors: NavLink[] = navigation.anchors;
|
||||
|
||||
function pageNode(path: string): PageTree.Item | null {
|
||||
const slugs = path === 'index' ? [] : path.split('/');
|
||||
const page = source.getPage(slugs);
|
||||
if (!page) {
|
||||
console.warn(`[navigation] page not found: ${path}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
$id: `page:${path}`,
|
||||
type: 'page',
|
||||
name: page.data.title,
|
||||
url: page.url,
|
||||
icon: page.data.icon ? <Icon name={page.data.icon} /> : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildItems(items: NavItem[], idPrefix: string): PageTree.Node[] {
|
||||
const out: PageTree.Node[] = [];
|
||||
for (const item of items) {
|
||||
if (typeof item === 'string') {
|
||||
const node = pageNode(item);
|
||||
if (node) out.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = `${idPrefix}/${item.group}`;
|
||||
out.push({
|
||||
$id: id,
|
||||
type: 'folder',
|
||||
name: item.group,
|
||||
icon: item.icon ? <Icon name={item.icon} /> : undefined,
|
||||
children: buildItems(item.pages, id),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let cached: PageTree.Root | undefined;
|
||||
|
||||
export function getNavigationTree(): PageTree.Root {
|
||||
if (cached) return cached;
|
||||
|
||||
const children: PageTree.Node[] = [];
|
||||
for (const group of navigation.groups as NavGroup[]) {
|
||||
// top-level groups render as sidebar section headings
|
||||
children.push({ $id: `sep:${group.group}`, type: 'separator', name: group.group });
|
||||
children.push(...buildItems(group.pages, group.group));
|
||||
}
|
||||
|
||||
cached = { $id: 'root', name: 'Docs', children };
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Page paths in sidebar order (first occurrence wins), used for llms-full.txt */
|
||||
export function getOrderedPagePaths(): string[] {
|
||||
const seen = new Set<string>();
|
||||
function walk(items: NavItem[]) {
|
||||
for (const item of items) {
|
||||
if (typeof item === 'string') seen.add(item);
|
||||
else walk(item.pages);
|
||||
}
|
||||
}
|
||||
walk(navigation.groups as NavGroup[]);
|
||||
return [...seen];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
[
|
||||
{
|
||||
"source": "/self-host/clustering",
|
||||
"destination": "/self-host/clustering/understanding-clustering",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/self-host/advanced/enable-geoblocking",
|
||||
"destination": "/self-host/advanced/enable-geolocation",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/self-host/advanced/enable-asnblocking",
|
||||
"destination": "/self-host/advanced/enable-asn-lookup",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/telemetry",
|
||||
"destination": "/self-host/telemetry",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/community/:slug*",
|
||||
"destination": "/self-host/:slug*",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/resources/tcp-udp-resources",
|
||||
"destination": "/manage/resources/public/raw-resources",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/healthchecks-failover",
|
||||
"destination": "/manage/resources/public/healthchecks-failover",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/resources/private/icmp-access",
|
||||
"destination": "/manage/resources/private/port-restrictions",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/sites/install-kubernetes",
|
||||
"destination": "/manage/sites/kubernetes/helm",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/claude",
|
||||
"destination": "/manage/ai/configure-ai-clients/claude",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/codex",
|
||||
"destination": "/manage/ai/configure-ai-clients/codex",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/opencode",
|
||||
"destination": "/manage/ai/configure-ai-clients/opencode",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/gemini",
|
||||
"destination": "/manage/ai/configure-ai-clients/gemini",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/open-webui",
|
||||
"destination": "/manage/ai/configure-ai-clients/open-webui",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/claude-desktop",
|
||||
"destination": "/manage/ai/configure-ai-clients/claude-desktop",
|
||||
"permanent": false
|
||||
},
|
||||
{
|
||||
"source": "/manage/ai/openclaw",
|
||||
"destination": "/manage/ai/configure-ai-clients/openclaw",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
import { source } from './source';
|
||||
|
||||
/**
|
||||
* One full-text index (Orama) shared by the search dialog (`/api/search`) and the
|
||||
* AI assistant's `search_docs` tool.
|
||||
*/
|
||||
export const searchServer = createFromSource(source, {
|
||||
language: 'english',
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createGetUrl } from 'fumadocs-core/source';
|
||||
|
||||
export const appName = 'Pangolin Docs';
|
||||
export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://docs.pangolin.net';
|
||||
export const siteDescription =
|
||||
'Modern networking and security platform providing secure access and connectivity to apps, infrastructure, and AI workloads. Connect and protect your users.';
|
||||
|
||||
/** docs are served from the site root, same URLs as the old Mintlify site */
|
||||
export const docsRoute = '/';
|
||||
export const docsImageRoute = '/og';
|
||||
export const docsContentRoute = '/llms.mdx';
|
||||
|
||||
export const gitConfig = {
|
||||
user: 'fosrl',
|
||||
repo: 'docs-v2',
|
||||
branch: 'main',
|
||||
};
|
||||
|
||||
const getContentUrl = createGetUrl(docsContentRoute);
|
||||
|
||||
export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) {
|
||||
const segments = [...page.slugs, 'content.md'];
|
||||
|
||||
return { segments, url: getContentUrl(segments, page.locale) };
|
||||
}
|
||||
|
||||
const getImageUrl = createGetUrl(docsImageRoute);
|
||||
|
||||
export function getPageImageUrl(page: { slugs: string[]; locale?: string }) {
|
||||
const segments = [...page.slugs, 'image.png'];
|
||||
|
||||
return { segments, url: getImageUrl(segments, page.locale) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Mintlify site was light-only (`appearance.strict`). Flip this to enable the
|
||||
* theme switch; the dark palette in `app/global.css` is ready for it.
|
||||
*/
|
||||
export const enableDarkMode = false;
|
||||
|
||||
export const links = {
|
||||
login: 'https://app.pangolin.net/auth/login',
|
||||
signup: 'https://app.pangolin.net/auth/signup',
|
||||
github: 'https://github.com/fosrl/pangolin',
|
||||
featureRequest: 'https://github.com/fosrl/pangolin/discussions',
|
||||
};
|
||||
|
||||
/** top banner (text lives in `app/(docs)/layout.tsx`) */
|
||||
export const banner = {
|
||||
/** change the id whenever the text changes so dismissed banners show again */
|
||||
id: 'ai-gateway-launch',
|
||||
link: { label: 'Get started', href: '/manage/ai/overview' },
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { llms, loader } from 'fumadocs-core/source';
|
||||
import { docsContentRoute, docsRoute } from './shared';
|
||||
import { defineDocs } from 'fumadocs-mdx/macro';
|
||||
import { applyMdxPreset } from 'fumadocs-mdx/config';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
import { transformerMetaHighlight } from '@shikijs/transformers';
|
||||
import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins';
|
||||
|
||||
const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
docs: {
|
||||
schema: pageSchema,
|
||||
mdxOptions: applyMdxPreset({
|
||||
rehypeCodeOptions: {
|
||||
// languages used in the content that Shiki doesn't ship
|
||||
langAlias: { env: 'dotenv', dns: 'txt', promql: 'txt' },
|
||||
langs: ['dotenv'],
|
||||
fallbackLanguage: 'txt',
|
||||
themes: {
|
||||
light: 'gruvbox-light-hard',
|
||||
dark: 'gruvbox-dark-hard',
|
||||
},
|
||||
transformers: [
|
||||
...(rehypeCodeDefaultOptions.transformers ?? []),
|
||||
// Mintlify-style `{1,3-5}` line highlights
|
||||
transformerMetaHighlight(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
postprocess: {
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: docsRoute,
|
||||
source: docs.toFumadocsSource(),
|
||||
});
|
||||
|
||||
export type Page = ReturnType<typeof source.getPages>[number];
|
||||
|
||||
export async function getPageText(page: Page) {
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${page.data.description ? `> ${page.data.description}\n\n` : ''}${await page.data.getText('processed')}`;
|
||||
}
|
||||
|
||||
export const docsLlms = llms(source, {
|
||||
renderPage: getPageText,
|
||||
});
|
||||
|
||||
export { docsContentRoute };
|
||||
Reference in New Issue
Block a user