port mintlify to fumadocs

This commit is contained in:
miloschwartz
2026-09-25 15:31:51 -04:00
parent dc54fb1017
commit 63199a588c
373 changed files with 13533 additions and 4577 deletions
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useState, type FormEvent } from 'react';
import { ArrowUp } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useAISearchContext, useAskAI } from './search';
/**
* Mintlify-style "Ask a question..." bar that sticks to the bottom of the page content.
* Submitting opens the chat panel with the question; it hides while the panel is open.
*/
export function AskBar() {
const { open } = useAISearchContext();
const ask = useAskAI();
const [value, setValue] = useState('');
function onSubmit(e: FormEvent) {
e.preventDefault();
const text = value.trim();
if (!text) return;
ask(text);
setValue('');
}
return (
<div className={cn('pg-askbar-wrap', open && 'pg-askbar-hidden')} aria-hidden={open}>
<form onSubmit={onSubmit} className="pg-chat-input pg-askbar">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Ask a question..."
aria-label="Ask AI about the docs"
tabIndex={open ? -1 : undefined}
/>
<kbd className="pg-askbar-kbd">⌘I</kbd>
<button
type="submit"
className="pg-chat-send"
aria-label="Send"
disabled={value.trim().length === 0}
tabIndex={open ? -1 : undefined}
>
<ArrowUp className="size-4" />
</button>
</form>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { Sparkles } from 'lucide-react';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { useAskAI } from './search';
export function AskAIAboutPage({ title }: { title: string }) {
const ask = useAskAI();
return (
<button
type="button"
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
)}
onClick={() => ask(`Summarize the "${title}" page I'm on and what I should do next.`)}
>
<Sparkles />
Ask AI
</button>
);
}
+580
View File
@@ -0,0 +1,580 @@
'use client';
import {
type ComponentProps,
createContext,
type ReactNode,
type SyntheticEvent,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react';
import { cn } from '../../lib/cn';
import { buttonVariants } from '../ui/button';
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { Markdown } from '../markdown';
export type ChatUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<ChatUIMessage>;
} | null>(null);
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
const { setOpen } = useAISearchContext();
return (
<div
className={cn(
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
className,
)}
{...props}
>
<div className="px-3 py-2 flex-1">
<p className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Sparkles className="size-3.5 text-fd-primary" />
Ask Pangolin AI
</p>
<p className="text-xs text-fd-muted-foreground">
Answers are generated from the docs and can be wrong. Check the linked pages.
</p>
</div>
<button
aria-label="Close"
tabIndex={-1}
className={cn(
buttonVariants({
size: 'icon-sm',
variant: 'ghost',
className: 'text-fd-muted-foreground rounded-full',
}),
)}
onClick={() => setOpen(false)}
>
<X />
</button>
</div>
);
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
return (
<>
{!isLoading && messages.at(-1)?.role === 'assistant' && (
<button
type="button"
className={cn(
buttonVariants({
variant: 'secondary',
size: 'sm',
className: 'rounded-full gap-1.5',
}),
)}
onClick={() => regenerate()}
>
<RefreshCw className="size-4" />
Retry
</button>
)}
<button
type="button"
className={cn(
buttonVariants({
variant: 'secondary',
size: 'sm',
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
>
Clear Chat
</button>
</>
);
}
const suggestions = [
'How do I expose a web app running on my home server?',
'What is the difference between a site and a client?',
'How do I self-host Pangolin with Docker Compose?',
'How do I connect Claude Code to the AI Gateway?',
];
function sendText(send: UseChatHelpers<ChatUIMessage>['sendMessage'], text: string) {
void send({
role: 'user',
parts: [
{ type: 'data-client', data: { location: location.href } },
{ type: 'text', text },
],
});
}
/** open the panel and immediately ask `text` */
export function useAskAI() {
const { setOpen, chat } = useAISearchContext();
return (text: string) => {
setOpen(true);
if (chat.status === 'streaming' || chat.status === 'submitted') return;
sendText(chat.sendMessage, text);
};
}
const StorageKeyInput = '__ai_search_input';
export function AISearchInput(props: ComponentProps<'form'>) {
const { status, sendMessage, stop } = useChatContext();
const [input, setInput] = useState(() => {
try {
return localStorage.getItem(StorageKeyInput) ?? '';
} catch {
return '';
}
});
const isLoading = status === 'streaming' || status === 'submitted';
const onStart = (e?: SyntheticEvent) => {
e?.preventDefault();
const message = input.trim();
if (message.length === 0) return;
sendText(sendMessage, message);
setInput('');
try {
localStorage.removeItem(StorageKeyInput);
} catch {
// storage unavailable
}
};
useEffect(() => {
if (isLoading) document.getElementById('nd-ai-input')?.focus();
}, [isLoading]);
return (
<form {...props} className={cn('flex items-end gap-2 p-2', props.className)} onSubmit={onStart}>
<Input
value={input}
placeholder={isLoading ? 'Pangolin AI is answering…' : 'Ask a question...'}
autoFocus
className="px-2 py-1.5 text-sm"
disabled={status === 'streaming' || status === 'submitted'}
onChange={(e) => {
setInput(e.target.value);
try {
localStorage.setItem(StorageKeyInput, e.target.value);
} catch {
// storage unavailable
}
}}
onKeyDown={(event) => {
// keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (!event.shiftKey && event.key === 'Enter') {
onStart(event);
}
}}
/>
{isLoading ? (
<button key="bn" type="button" className="pg-chat-send" aria-label="Stop answering" onClick={stop}>
<Square className="size-3 fill-current" />
</button>
) : (
<button
key="bn"
type="submit"
className="pg-chat-send"
aria-label="Send"
disabled={input.trim().length === 0}
>
<ArrowUp className="size-4" />
</button>
)}
</form>
);
}
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
function callback() {
const container = containerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: 'instant',
});
}
const observer = new ResizeObserver(callback);
callback();
const element = containerRef.current?.firstElementChild;
if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}, []);
return (
<div
ref={containerRef}
{...props}
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
>
{props.children}
</div>
);
}
function Input(props: ComponentProps<'textarea'>) {
const ref = useRef<HTMLDivElement>(null);
const shared = cn('pg-chat-field col-start-1 row-start-1', props.className);
return (
<div className="grid flex-1">
<textarea
id="nd-ai-input"
{...props}
className={cn(
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
shared,
)}
/>
<div ref={ref} className={cn(shared, 'break-all invisible')}>
{`${props.value?.toString() ?? ''}\n`}
</div>
</div>
);
}
const roleName: Record<string, string> = {
user: 'You',
assistant: 'Pangolin AI',
};
interface ToolPart {
type: string;
toolCallId: string;
state: string;
input?: { query?: string; path?: string };
output?: unknown;
errorText?: string;
}
function ToolActivity({ part }: { part: ToolPart }) {
const name = part.type.slice('tool-'.length);
const failed = part.state === 'output-error' || part.state === 'output-denied';
const done = part.state === 'output-available';
let label: ReactNode;
if (name === 'search_docs') {
const count = Array.isArray(part.output) ? part.output.length : 0;
label = done ? (
<>
Searched <q>{part.input?.query}</q>, {count} {count === 1 ? 'page' : 'pages'}
</>
) : (
<>Searching <q>{part.input?.query ?? '…'}</q></>
);
} else if (name === 'read_page') {
label = <>{done ? 'Read' : 'Reading'} <code>{part.input?.path ?? '…'}</code></>;
} else {
label = name;
}
const Icon = name === 'read_page' ? FileText : SearchIcon;
return (
<div className="flex flex-row gap-2 items-center rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs px-2 py-1.5 min-w-0">
{done || failed ? <Icon className="size-3.5 shrink-0" /> : <Loader2 className="size-3.5 shrink-0 animate-spin" />}
{failed ? (
<p className="text-fd-error truncate">{part.errorText ?? 'Tool call failed'}</p>
) : (
<p className="truncate">{label}</p>
)}
</div>
);
}
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
let markdown = '';
const toolCalls: ToolPart[] = [];
for (const part of message.parts ?? []) {
if (part.type === 'text') {
markdown += part.text;
continue;
}
if (part.type.startsWith('tool-')) {
const p = part as unknown as ToolPart;
if (p.toolCallId) toolCalls.push(p);
}
}
return (
<div onClick={(e) => e.stopPropagation()} {...props}>
<p
className={cn(
'mb-1 text-sm font-medium text-fd-muted-foreground',
message.role === 'assistant' && 'text-fd-primary',
)}
>
{roleName[message.role] ?? 'unknown'}
</p>
{toolCalls.length > 0 && (
<div className="flex flex-col gap-1 mb-2">
{toolCalls.map((call) => (
<ToolActivity key={call.toolCallId} part={call} />
))}
</div>
)}
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat<ChatUIMessage>({
id: 'search',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
);
}
export function AISearchTrigger({
position = 'default',
className,
...props
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
const { open, setOpen } = useAISearchContext();
return (
<button
data-state={open ? 'open' : 'closed'}
className={cn(
position === 'float' && [
'fixed bottom-4 gap-2 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
open && 'translate-y-10 opacity-0',
],
className,
)}
onClick={() => setOpen(!open)}
{...props}
>
{props.children}
</button>
);
}
export function AISearchPanel() {
const { open, setOpen } = useAISearchContext();
const [actualOpen, setActualOpen] = useState(open);
useHotKey();
if (open && !actualOpen) setActualOpen(open);
// The docs grid reserves a column for the panel while this attribute is set (see
// `--pg-panel` in app/global.css). It follows `open` directly, so the content moves
// back as soon as the panel starts closing, whatever happens to the close animation.
useEffect(() => {
const layout = document.getElementById('nd-notebook-layout');
layout?.toggleAttribute('data-ai-open', open);
return () => layout?.removeAttribute('data-ai-open');
}, [open]);
// Unmount after the close animation. `animationend` never fires when animations are
// skipped (background tab, reduced motion), so also fall back to a timer.
useEffect(() => {
if (open || !actualOpen) return;
const timer = window.setTimeout(() => setActualOpen(false), 300);
return () => window.clearTimeout(timer);
}, [open, actualOpen]);
return (
<>
<style>
{`
@keyframes ask-ai-open {
from {
translate: 100% 0;
}
to {
translate: 0 0;
}
}
@keyframes ask-ai-close {
from {
width: var(--ai-chat-width);
}
to {
width: 0px;
}
}`}
</style>
{actualOpen && (
<div
className={cn(
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
)}
onClick={() => setOpen(false)}
onAnimationEnd={() => {
if (!open) flushSync(() => setActualOpen(false));
}}
/>
)}
{actualOpen && (
<div
className={cn(
'pg-ai-panel overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
onAnimationEnd={() => {
if (!open) flushSync(() => setActualOpen(false));
}}
>
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="pg-chat-input">
<AISearchInput />
<div className="flex items-center gap-1.5 px-2 pb-2 empty:hidden">
<AISearchInputActions />
</div>
</div>
</div>
</div>
)}
</>
);
}
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
const chat = useChatContext();
const messages = chat.messages.filter((msg) => msg.role !== 'system');
return (
<List
className={cn('py-4 overscroll-contain', className)}
style={{
maskImage:
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
...style,
}}
{...props}
>
{messages.length === 0 ? (
<div
className="size-full flex flex-col justify-end gap-2 px-1"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-fd-muted-foreground mb-1">
Ask anything about Pangolin. The assistant searches and reads these docs to answer.
</p>
{suggestions.map((q) => (
<button
key={q}
type="button"
className="text-start text-sm rounded-xl border bg-fd-card px-3 py-2 text-fd-foreground transition-colors hover:bg-fd-accent"
onClick={() => sendText(chat.sendMessage, q)}
>
{q}
</button>
))}
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
))}
{chat.error && (
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
<p className="text-xs text-fd-muted-foreground mb-1">Request failed</p>
<p className="text-sm">{errorText(chat.error)}</p>
</div>
)}
</div>
)}
</List>
);
}
/** `/api/chat` returns `{ error }` JSON for config / rate-limit errors */
function errorText(error: Error) {
try {
const parsed = JSON.parse(error.message) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// not JSON
}
return error.message;
}
export function useHotKey() {
const { open, setOpen } = useAISearchContext();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
setOpen(false);
e.preventDefault();
}
if ((e.key === '/' || e.key === 'i') && (e.metaKey || e.ctrlKey) && !open) {
setOpen(true);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener('keydown', onKeyPress);
return () => window.removeEventListener('keydown', onKeyPress);
}, []);
}
export function useAISearchContext() {
return use(Context)!;
}
function useChatContext() {
return use(Context)!.chat;
}
+38
View File
@@ -0,0 +1,38 @@
import Script from 'next/script';
/**
* Same analytics as the Mintlify site (PostHog via the pangolin.net relay, Rybbit, Reo).
* Only loaded in production builds; set NEXT_PUBLIC_DISABLE_ANALYTICS=1 to turn off.
*/
const POSTHOG_KEY = 'phc_RIHQ7o2Y2hf8qms2nP62vpoJHEvsrw6TieflQGQO7yI';
const POSTHOG_HOST = 'https://pangolin.net/relay-O7yI';
const RYBBIT_SITE_ID = 'da4fb64dc2d5';
const REO_CLIENT_ID = '4209f8e1b88e13b';
export function Analytics() {
if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PUBLIC_DISABLE_ANALYTICS) {
return null;
}
return (
<>
<Script id="posthog" strategy="afterInteractive">
{`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init(${JSON.stringify(POSTHOG_KEY)},{api_host:${JSON.stringify(POSTHOG_HOST)},ui_host:'https://us.posthog.com',person_profiles:'identified_only'});`}
</Script>
<Script
src="https://rybbit.fossorial.io/api/script.js"
data-site-id={RYBBIT_SITE_ID}
strategy="afterInteractive"
/>
<Script
src={`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`}
strategy="afterInteractive"
id="reo"
/>
<Script id="reo-init" strategy="lazyOnload">
{`(function w(){if(window.Reo){Reo.init({clientID:${JSON.stringify(REO_CLIENT_ID)}})}else setTimeout(w,500)})();`}
</Script>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { Sparkles } from 'lucide-react';
import {
FullSearchTrigger,
SearchTrigger,
type FullSearchTriggerProps,
type SearchTriggerProps,
} from 'fumadocs-ui/layouts/shared/slots/search-trigger';
import { cn } from '@/lib/cn';
import { useAISearchContext } from './ai/search';
/**
* Header search slot: the normal search bar with an "Ask AI" button to its right, like
* Mintlify. The layout passes the search bar's sizing classes; they go on the wrapper.
*/
function HeaderSearchFull({ className, ...props }: FullSearchTriggerProps) {
const { open, setOpen } = useAISearchContext();
return (
<div className={cn(className, 'flex items-center gap-2 ps-0 max-w-md')}>
<FullSearchTrigger {...props} className="ps-2.5 rounded-xl flex-1 min-w-0 h-9" />
<button
type="button"
className="pg-ask-ai-btn"
data-state={open ? 'open' : 'closed'}
aria-pressed={open}
onClick={() => setOpen(!open)}
>
<Sparkles />
Ask AI
</button>
</div>
);
}
function HeaderSearchSm(props: SearchTriggerProps) {
return <SearchTrigger {...props} />;
}
export const headerSearchSlot = { full: HeaderSearchFull, sm: HeaderSearchSm };
+206
View File
@@ -0,0 +1,206 @@
import type { SVGProps } from 'react';
import {
BadgeCheck,
Bell,
Bolt,
BookOpen,
Bot,
Box,
Boxes,
Brain,
Brush,
Building2,
ChartColumn,
ChartLine,
Check,
CircleHelp,
Cloud,
CreditCard,
Database,
Download,
FileCode,
GitBranch,
Globe,
Hash,
IdCard,
Key,
Layers,
LayoutGrid,
Link,
ListChecks,
Lock,
Mail,
Monitor,
Network,
PaintBucket,
Plug,
Route,
Server,
Shield,
SlidersHorizontal,
Sparkles,
TableProperties,
Terminal,
User,
Users,
Waypoints,
Workflow,
X,
type LucideIcon,
} from 'lucide-react';
type IconComponent = LucideIcon | ((props: SVGProps<SVGSVGElement>) => React.ReactElement);
// Brand icons are not part of lucide, so they are inlined here.
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
</svg>
);
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.6 1.3a18.3 18.3 0 0 0-5.5 0L8.6 3a19.7 19.7 0 0 0-4.9 1.5A20.3 20.3 0 0 0 .1 18.1a19.9 19.9 0 0 0 6 3l1.3-2a12.9 12.9 0 0 1-2-1l.5-.4a14.2 14.2 0 0 0 12.2 0l.5.4-2 1 1.3 2a19.8 19.8 0 0 0 6-3 20.2 20.2 0 0 0-3.6-13.7ZM8 15.4c-1.2 0-2.2-1.1-2.2-2.4S6.8 10.5 8 10.5s2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Zm8 0c-1.2 0-2.2-1.1-2.2-2.4s1-2.5 2.2-2.5 2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Z" />
</svg>
);
}
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M5 15.2a2.5 2.5 0 1 1-2.5-2.5H5v2.5Zm1.3 0a2.5 2.5 0 0 1 5 0v6.3a2.5 2.5 0 1 1-5 0v-6.3ZM8.8 5a2.5 2.5 0 1 1 2.5-2.5V5H8.8Zm0 1.3a2.5 2.5 0 0 1 0 5H2.5a2.5 2.5 0 1 1 0-5h6.3ZM19 8.8a2.5 2.5 0 1 1 2.5 2.5H19V8.8Zm-1.3 0a2.5 2.5 0 0 1-5 0V2.5a2.5 2.5 0 1 1 5 0v6.3ZM15.2 19a2.5 2.5 0 1 1-2.5 2.5V19h2.5Zm0-1.3a2.5 2.5 0 0 1 0-5h6.3a2.5 2.5 0 1 1 0 5h-6.3Z" />
</svg>
);
}
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M20.4 20.5h-3.6v-5.6c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9v5.7H9.3V9h3.4v1.6c.5-.9 1.6-1.8 3.4-1.8 3.6 0 4.3 2.4 4.3 5.5v6.2ZM5.3 7.4a2.1 2.1 0 1 1 0-4.1 2.1 2.1 0 0 1 0 4.1Zm1.8 13.1H3.6V9h3.5v11.5ZM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0Z" />
</svg>
);
}
export function YouTubeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31.4 31.4 0 0 0 0 12a31.4 31.4 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.4 31.4 0 0 0 24 12a31.4 31.4 0 0 0-.5-5.8ZM9.6 15.6V8.4l6.2 3.6-6.2 3.6Z" />
</svg>
);
}
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.2-2.8.8-3.5.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-1.1 2.7-2.2c.9-1.2 1.2-2.4 1.2-2.5-.1 0-2.3-.9-2.3-3.9ZM14.6 5.9c.6-.8 1.1-1.8.9-2.9-1 .1-2.1.6-2.8 1.4-.6.7-1.2 1.7-1 2.7 1.1.1 2.2-.5 2.9-1.2Z" />
</svg>
);
}
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M3 5.2 10.6 4.1v7.1H3V5.2Zm8.4-1.2L21 2.6v8.6h-9.6V4Zm-8.4 8.2h7.6v7.2L3 18.3v-6.1Zm8.4 0H21v8.8l-9.6-1.3v-7.5Z" />
</svg>
);
}
export function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 304.998 304.998" fill="currentColor" aria-hidden {...props}>
<path d="M274.659,244.888c-8.944-3.663-12.77-8.524-12.4-15.777c0.381-8.466-4.422-14.667-6.703-17.117 c1.378-5.264,5.405-23.474,0.004-39.291c-5.804-16.93-23.524-42.787-41.808-68.204c-7.485-10.438-7.839-21.784-8.248-34.922 c-0.392-12.531-0.834-26.735-7.822-42.525C190.084,9.859,174.838,0,155.851,0c-11.295,0-22.889,3.53-31.811,9.684 c-18.27,12.609-15.855,40.1-14.257,58.291c0.219,2.491,0.425,4.844,0.545,6.853c1.064,17.816,0.096,27.206-1.17,30.06 c-0.819,1.865-4.851,7.173-9.118,12.793c-4.413,5.812-9.416,12.4-13.517,18.539c-4.893,7.387-8.843,18.678-12.663,29.597 c-2.795,7.99-5.435,15.537-8.005,20.047c-4.871,8.676-3.659,16.766-2.647,20.505c-1.844,1.281-4.508,3.803-6.757,8.557 c-2.718,5.8-8.233,8.917-19.701,11.122c-5.27,1.078-8.904,3.294-10.804,6.586c-2.765,4.791-1.259,10.811,0.115,14.925 c2.03,6.048,0.765,9.876-1.535,16.826c-0.53,1.604-1.131,3.42-1.74,5.423c-0.959,3.161-0.613,6.035,1.026,8.542 c4.331,6.621,16.969,8.956,29.979,10.492c7.768,0.922,16.27,4.029,24.493,7.035c8.057,2.944,16.388,5.989,23.961,6.913 c1.151,0.145,2.291,0.218,3.39,0.218c11.434,0,16.6-7.587,18.238-10.704c4.107-0.838,18.272-3.522,32.871-3.882 c14.576-0.416,28.679,2.462,32.674,3.357c1.256,2.404,4.567,7.895,9.845,10.724c2.901,1.586,6.938,2.495,11.073,2.495 c0.001,0,0,0,0.001,0c4.416,0,12.817-1.044,19.466-8.039c6.632-7.028,23.202-16,35.302-22.551c2.7-1.462,5.226-2.83,7.441-4.065 c6.797-3.768,10.506-9.152,10.175-14.771C282.445,250.905,279.356,246.811,274.659,244.888z M124.189,243.535 c-0.846-5.96-8.513-11.871-17.392-18.715c-7.26-5.597-15.489-11.94-17.756-17.312c-4.685-11.082-0.992-30.568,5.447-40.602 c3.182-5.024,5.781-12.643,8.295-20.011c2.714-7.956,5.521-16.182,8.66-19.783c4.971-5.622,9.565-16.561,10.379-25.182 c4.655,4.444,11.876,10.083,18.547,10.083c1.027,0,2.024-0.134,2.977-0.403c4.564-1.318,11.277-5.197,17.769-8.947 c5.597-3.234,12.499-7.222,15.096-7.585c4.453,6.394,30.328,63.655,32.972,82.044c2.092,14.55-0.118,26.578-1.229,31.289 c-0.894-0.122-1.96-0.221-3.08-0.221c-7.207,0-9.115,3.934-9.612,6.283c-1.278,6.103-1.413,25.618-1.427,30.003 c-2.606,3.311-15.785,18.903-34.706,21.706c-7.707,1.12-14.904,1.688-21.39,1.688c-5.544,0-9.082-0.428-10.551-0.651l-9.508-10.879 C121.429,254.489,125.177,250.583,124.189,243.535z M136.254,64.149c-0.297,0.128-0.589,0.265-0.876,0.411 c-0.029-0.644-0.096-1.297-0.199-1.952c-1.038-5.975-5-10.312-9.419-10.312c-0.327,0-0.656,0.025-1.017,0.08 c-2.629,0.438-4.691,2.413-5.821,5.213c0.991-6.144,4.472-10.693,8.602-10.693c4.85,0,8.947,6.536,8.947,14.272 C136.471,62.143,136.4,63.113,136.254,64.149z M173.94,68.756c0.444-1.414,0.684-2.944,0.684-4.532 c0-7.014-4.45-12.509-10.131-12.509c-5.552,0-10.069,5.611-10.069,12.509c0,0.47,0.023,0.941,0.067,1.411 c-0.294-0.113-0.581-0.223-0.861-0.329c-0.639-1.935-0.962-3.954-0.962-6.015c0-8.387,5.36-15.211,11.95-15.211 c6.589,0,11.95,6.824,11.95,15.211C176.568,62.78,175.605,66.11,173.94,68.756z M169.081,85.08 c-0.095,0.424-0.297,0.612-2.531,1.774c-1.128,0.587-2.532,1.318-4.289,2.388l-1.174,0.711c-4.718,2.86-15.765,9.559-18.764,9.952 c-2.037,0.274-3.297-0.516-6.13-2.441c-0.639-0.435-1.319-0.897-2.044-1.362c-5.107-3.351-8.392-7.042-8.763-8.485 c1.665-1.287,5.792-4.508,7.905-6.415c4.289-3.988,8.605-6.668,10.741-6.668c0.113,0,0.215,0.008,0.321,0.028 c2.51,0.443,8.701,2.914,13.223,4.718c2.09,0.834,3.895,1.554,5.165,2.01C166.742,82.664,168.828,84.422,169.081,85.08z M205.028,271.45c2.257-10.181,4.857-24.031,4.436-32.196c-0.097-1.855-0.261-3.874-0.42-5.826 c-0.297-3.65-0.738-9.075-0.283-10.684c0.09-0.042,0.19-0.078,0.301-0.109c0.019,4.668,1.033,13.979,8.479,17.226 c2.219,0.968,4.755,1.458,7.537,1.458c7.459,0,15.735-3.659,19.125-7.049c1.996-1.996,3.675-4.438,4.851-6.372 c0.257,0.753,0.415,1.737,0.332,3.005c-0.443,6.885,2.903,16.019,9.271,19.385l0.927,0.487c2.268,1.19,8.292,4.353,8.389,5.853 c-0.001,0.001-0.051,0.177-0.387,0.489c-1.509,1.379-6.82,4.091-11.956,6.714c-9.111,4.652-19.438,9.925-24.076,14.803 c-6.53,6.872-13.916,11.488-18.376,11.488c-0.537,0-1.026-0.068-1.461-0.206C206.873,288.406,202.886,281.417,205.028,271.45z M39.917,245.477c-0.494-2.312-0.884-4.137-0.465-5.905c0.304-1.31,6.771-2.714,9.533-3.313c3.883-0.843,7.899-1.714,10.525-3.308 c3.551-2.151,5.474-6.118,7.17-9.618c1.228-2.531,2.496-5.148,4.005-6.007c0.085-0.05,0.215-0.108,0.463-0.108 c2.827,0,8.759,5.943,12.177,11.262c0.867,1.341,2.473,4.028,4.331,7.139c5.557,9.298,13.166,22.033,17.14,26.301 c3.581,3.837,9.378,11.214,7.952,17.541c-1.044,4.909-6.602,8.901-7.913,9.784c-0.476,0.108-1.065,0.163-1.758,0.163 c-7.606,0-22.662-6.328-30.751-9.728l-1.197-0.503c-4.517-1.894-11.891-3.087-19.022-4.241c-5.674-0.919-13.444-2.176-14.732-3.312 c-1.044-1.171,0.167-4.978,1.235-8.337c0.769-2.414,1.563-4.91,1.998-7.523C41.225,251.596,40.499,248.203,39.917,245.477z" />
</svg>
);
}
export function AndroidIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 512 512" fill="currentColor" aria-hidden {...props}>
<path d="M120.606,169h270.788v220.663c0,13.109-10.628,23.737-23.721,23.737h-27.123v67.203 c0,17.066-13.612,30.897-30.415,30.897c-16.846,0-30.438-13.831-30.438-30.897v-67.203h-47.371v67.203 c0,17.066-13.639,30.897-30.441,30.897c-16.799,0-30.437-13.831-30.437-30.897v-67.203h-27.099 c-13.096,0-23.744-10.628-23.744-23.737V169z M67.541,167.199c-16.974,0-30.723,13.963-30.723,31.2v121.937 c0,17.217,13.749,31.204,30.723,31.204c16.977,0,30.723-13.987,30.723-31.204V198.399 C98.264,181.162,84.518,167.199,67.541,167.199z M391.395,146.764H120.606c3.342-38.578,28.367-71.776,64.392-90.998 l-25.746-37.804c-3.472-5.098-2.162-12.054,2.946-15.525c5.102-3.471,12.044-2.151,15.533,2.943l28.061,41.232 c15.558-5.38,32.446-8.469,50.208-8.469c17.783,0,34.672,3.089,50.229,8.476L334.29,5.395c3.446-5.108,10.41-6.428,15.512-2.957 c5.108,3.471,6.418,10.427,2.946,15.525l-25.725,37.804C363.047,74.977,388.055,108.175,391.395,146.764z M213.865,94.345 c0-8.273-6.699-14.983-14.969-14.983c-8.291,0-14.99,6.71-14.99,14.983c0,8.269,6.721,14.976,14.99,14.976 S213.865,102.614,213.865,94.345z M329.992,94.345c0-8.273-6.722-14.983-14.99-14.983c-8.291,0-14.97,6.71-14.97,14.983 c0,8.269,6.679,14.976,14.97,14.976C323.271,109.321,329.992,102.614,329.992,94.345z M444.48,167.156 c-16.956,0-30.744,13.984-30.744,31.222v121.98c0,17.238,13.788,31.226,30.744,31.226c16.978,0,30.701-13.987,30.701-31.226 v-121.98C475.182,181.14,461.458,167.156,444.48,167.156z" />
</svg>
);
}
function PostgresIcon(props: SVGProps<SVGSVGElement>) {
return <Database {...props} />;
}
/** Font Awesome names used by the Mintlify content -> equivalent icon */
const icons: Record<string, IconComponent> = {
plug: Plug,
link: Link,
desktop: Monitor,
display: Monitor,
sparkles: Sparkles,
building: Building2,
'user-group': Users,
users: Users,
user: User,
'id-card': IdCard,
'chart-bar': ChartColumn,
'chart-simple': ChartColumn,
'chart-line': ChartLine,
bell: Bell,
'file-code': FileCode,
'circle-nodes': Waypoints,
'code-branch': GitBranch,
server: Server,
'circle-question': CircleHelp,
question: CircleHelp,
sliders: SlidersHorizontal,
box: Box,
cube: Box,
cubes: Boxes,
'boxes-stacked': Boxes,
'layer-group': Layers,
route: Route,
'list-check': ListChecks,
globe: Globe,
shield: Shield,
'network-wired': Network,
sitemap: Network,
'diagram-project': Workflow,
cloud: Cloud,
book: BookOpen,
download: Download,
database: Database,
postgres: PostgresIcon,
terminal: Terminal,
'table-list': TableProperties,
robot: Bot,
lock: Lock,
key: Key,
hashtag: Hash,
'grid-2': LayoutGrid,
envelope: Mail,
'credit-card': CreditCard,
check: Check,
certificate: BadgeCheck,
bucket: PaintBucket,
brush: Brush,
brain: Brain,
bolt: Bolt,
x: X,
github: GitHubIcon,
discord: DiscordIcon,
slack: SlackIcon,
linkedin: LinkedInIcon,
youtube: YouTubeIcon,
};
export function Icon({ name, ...props }: { name?: string } & SVGProps<SVGSVGElement>) {
if (!name) return null;
const Component = icons[name];
if (!Component) {
if (process.env.NODE_ENV === 'development') console.warn(`[icons] no mapping for "${name}"`);
return null;
}
return <Component {...(props as object)} />;
}
export function hasIcon(name?: string) {
return !!name && name in icons;
}
+116
View File
@@ -0,0 +1,116 @@
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import {
Children,
type ComponentProps,
type ReactElement,
type ReactNode,
Suspense,
use,
useDeferredValue,
} from 'react';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { visit } from 'unist-util-visit';
import type { ElementContent, Root, RootContent } from 'hast';
export interface Processor {
process: (content: string) => Promise<ReactNode>;
}
export function rehypeWrapWords() {
return (tree: Root) => {
visit(tree, ['text', 'element'], (node, index, parent) => {
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
if (node.type !== 'text' || !parent || index === undefined) return;
const words = node.value.split(/(?=\s)/);
// Create new span nodes for each word and whitespace
const newNodes: ElementContent[] = words.flatMap((word) => {
if (word.length === 0) return [];
return {
type: 'element',
tagName: 'span',
properties: {
class: 'animate-fd-fade-in',
},
children: [{ type: 'text', value: word }],
};
});
Object.assign(node, {
type: 'element',
tagName: 'span',
properties: {},
children: newNodes,
} satisfies RootContent);
return 'skip';
});
};
}
function createProcessor(): Processor {
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
return {
async process(content) {
const nodes = processor.parse({ value: content });
const hast = await processor.run(nodes);
return toJsxRuntime(hast, {
development: false,
jsx,
jsxs,
Fragment,
components: {
...defaultMdxComponents,
pre: Pre,
img: undefined, // use JSX
},
});
},
};
}
function Pre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
let lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
if (lang === 'mdx') lang = 'md';
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
}
const processor = createProcessor();
export function Markdown({ text }: { text: string }) {
const deferredText = useDeferredValue(text);
return (
<Suspense fallback={<p className="invisible">{text}</p>}>
<Renderer text={deferredText} />
</Suspense>
);
}
const cache = new Map<string, Promise<ReactNode>>();
function Renderer({ text }: { text: string }) {
const result = cache.get(text) ?? processor.process(text);
cache.set(text, result);
return use(result);
}
+17
View File
@@ -0,0 +1,17 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { mintlifyComponents } from './mintlify';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...mintlifyComponents,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
+439
View File
@@ -0,0 +1,439 @@
/**
* Drop-in replacements for the Mintlify MDX components used across the docs, so the
* content could be ported without rewriting pages. Styling lives in `app/global.css`
* under the `pg-*` class names.
*/
import {
Children,
isValidElement,
type ComponentProps,
type ReactElement,
type ReactNode,
} from 'react';
import Link from 'fumadocs-core/link';
import { Tab as FumaTab, Tabs as FumaTabs } from 'fumadocs-ui/components/tabs';
import {
ArrowRight,
ChevronRight,
CircleAlert,
CircleCheck,
Info as InfoIcon,
Lightbulb,
OctagonAlert,
StickyNote,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { Icon } from './icons';
/* -------------------------------------------------------------------------- */
/* Callouts */
/* -------------------------------------------------------------------------- */
type CalloutType = 'note' | 'info' | 'tip' | 'warning' | 'check' | 'danger';
const calloutIcons: Record<CalloutType, typeof InfoIcon> = {
note: StickyNote,
info: InfoIcon,
tip: Lightbulb,
warning: CircleAlert,
check: CircleCheck,
danger: OctagonAlert,
};
function Callout({
type,
title,
icon,
children,
}: {
type: CalloutType;
title?: ReactNode;
icon?: string;
children?: ReactNode;
}) {
const DefaultIcon = calloutIcons[type];
return (
<div className="pg-callout not-prose" data-type={type} role="note">
<span className="pg-callout-icon">
{icon ? <Icon name={icon} /> : <DefaultIcon />}
</span>
<div className="pg-callout-body prose">
{title && <p className="font-medium">{title}</p>}
{children}
</div>
</div>
);
}
type CalloutProps = { title?: ReactNode; icon?: string; children?: ReactNode };
export const Note = (p: CalloutProps) => <Callout type="note" {...p} />;
export const Info = (p: CalloutProps) => <Callout type="info" {...p} />;
export const Tip = (p: CalloutProps) => <Callout type="tip" {...p} />;
export const Warning = (p: CalloutProps) => <Callout type="warning" {...p} />;
export const Check = (p: CalloutProps) => <Callout type="check" {...p} />;
export const Danger = (p: CalloutProps) => <Callout type="danger" {...p} />;
/* -------------------------------------------------------------------------- */
/* Cards */
/* -------------------------------------------------------------------------- */
export function Card({
title,
icon,
href,
arrow,
cta,
horizontal,
img,
children,
}: {
title?: ReactNode;
icon?: string;
href?: string;
arrow?: boolean | string;
cta?: string;
horizontal?: boolean;
img?: string;
children?: ReactNode;
}) {
const external = href ? /^https?:\/\//.test(href) : false;
const showArrow = arrow === true || arrow === 'true' || (arrow === undefined && external);
const content = (
<>
{img && <img src={img} alt="" className="pg-card-img" />}
<div className={cn('pg-card-inner', horizontal && 'pg-card-horizontal')}>
{icon && (
<span className="pg-card-icon">
<Icon name={icon} />
</span>
)}
<div className="min-w-0 flex-1">
{title && (
<p className="pg-card-title">
{title}
{showArrow && <ArrowRight className="pg-card-arrow" />}
</p>
)}
{children && <div className="pg-card-content prose">{children}</div>}
{cta && (
<p className="pg-card-cta">
{cta}
<ChevronRight className="size-3.5" />
</p>
)}
</div>
</div>
</>
);
if (!href) return <div className="pg-card not-prose">{content}</div>;
// A full-card overlay link instead of wrapping the card in <a>: card bodies can contain
// their own links, and nested <a> elements are invalid HTML (hydration errors).
return (
<div className="pg-card pg-card-link not-prose" data-card="">
<Link
href={href}
external={external}
className="pg-card-overlay"
aria-label={typeof title === 'string' ? title : cta}
/>
{content}
</div>
);
}
export function CardGroup({ cols = 2, children }: { cols?: number; children?: ReactNode }) {
return (
<div className="pg-card-group not-prose" style={{ '--pg-cols': cols } as React.CSSProperties}>
{children}
</div>
);
}
export const Columns = CardGroup;
/* -------------------------------------------------------------------------- */
/* Steps */
/* -------------------------------------------------------------------------- */
export function Steps({ children }: { children?: ReactNode }) {
return <div className="pg-steps">{children}</div>;
}
export function Step({
title,
icon,
children,
}: {
title?: ReactNode;
icon?: string;
stepNumber?: number;
titleSize?: string;
children?: ReactNode;
}) {
return (
<div className="pg-step">
<span className="pg-step-marker" aria-hidden>
{icon ? <Icon name={icon} className="size-3.5" /> : null}
</span>
{title && <p className="pg-step-title">{title}</p>}
<div className="pg-step-content">{children}</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Tabs */
/* -------------------------------------------------------------------------- */
type TabElement = ReactElement<{ title?: string; children?: ReactNode }>;
export function Tabs({ children }: { children?: ReactNode }) {
const tabs = Children.toArray(children).filter(
(child): child is TabElement => isValidElement(child),
);
const items = tabs.map((tab, i) => tab.props.title ?? `Tab ${i + 1}`);
return (
<FumaTabs items={items}>
{tabs.map((tab, i) => (
<FumaTab key={items[i]} value={items[i]}>
{tab.props.children}
</FumaTab>
))}
</FumaTabs>
);
}
/** Only rendered through <Tabs>, which reads its props directly. */
export function Tab({ children }: { title?: string; children?: ReactNode }) {
return <>{children}</>;
}
/* -------------------------------------------------------------------------- */
/* Accordions & Expandables */
/* -------------------------------------------------------------------------- */
function slugify(value: unknown) {
return typeof value === 'string'
? value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
: undefined;
}
export function Accordion({
title,
description,
icon,
defaultOpen,
children,
}: {
title: ReactNode;
description?: ReactNode;
icon?: string;
defaultOpen?: boolean;
children?: ReactNode;
}) {
return (
<details className="pg-accordion" open={defaultOpen} id={slugify(title)}>
<summary>
<ChevronRight className="pg-accordion-chevron" />
{icon && <Icon name={icon} className="size-4 shrink-0 text-fd-muted-foreground" />}
<span className="flex-1">
<span className="font-medium">{title}</span>
{description && (
<span className="block text-sm text-fd-muted-foreground">{description}</span>
)}
</span>
</summary>
<div className="pg-accordion-content prose">{children}</div>
</details>
);
}
export function AccordionGroup({ children }: { children?: ReactNode }) {
return <div className="pg-accordion-group">{children}</div>;
}
export function Expandable({
title = 'properties',
defaultOpen,
children,
}: {
title?: string;
defaultOpen?: boolean;
children?: ReactNode;
}) {
return (
<details className="pg-expandable" open={defaultOpen}>
<summary>
<ChevronRight className="pg-accordion-chevron" />
<span className="pg-expandable-closed">Show {title}</span>
<span className="pg-expandable-open">Hide {title}</span>
</summary>
<div className="pg-expandable-content">{children}</div>
</details>
);
}
/* -------------------------------------------------------------------------- */
/* API fields */
/* -------------------------------------------------------------------------- */
export function ResponseField({
name,
type,
required,
default: defaultValue,
deprecated,
post,
pre,
children,
}: {
name: string;
type?: string;
required?: boolean;
default?: unknown;
deprecated?: boolean;
post?: string[];
pre?: string[];
children?: ReactNode;
}) {
const id = slugify(name);
return (
<div className="pg-field" id={id ? `field-${id}` : undefined}>
<div className="pg-field-header">
{pre?.map((p) => (
<span key={p} className="pg-field-pill">
{p}
</span>
))}
<code className="pg-field-name">{name}</code>
{type && <span className="pg-field-type">{type}</span>}
{defaultValue !== undefined && (
<span className="pg-field-pill">
default: <code>{String(defaultValue)}</code>
</span>
)}
{post?.map((p) => (
<span key={p} className="pg-field-pill">
{p}
</span>
))}
{required && <span className="pg-field-required">required</span>}
{deprecated && <span className="pg-field-deprecated">deprecated</span>}
</div>
{children && <div className="pg-field-body prose">{children}</div>}
</div>
);
}
export const ParamField = ResponseField;
/* -------------------------------------------------------------------------- */
/* Media */
/* -------------------------------------------------------------------------- */
export function Frame({
caption,
hint,
children,
}: {
caption?: ReactNode;
hint?: ReactNode;
children?: ReactNode;
}) {
return (
<figure className="pg-frame not-prose">
<div className="pg-frame-inner">
{hint && <p className="pg-frame-hint">{hint}</p>}
{children}
{caption && <figcaption>{caption}</figcaption>}
</div>
</figure>
);
}
export function Iframe(props: ComponentProps<'iframe'>) {
return (
<Frame>
<iframe {...props} />
</Frame>
);
}
export function Video(props: ComponentProps<'video'>) {
return (
<Frame>
<video {...props} />
</Frame>
);
}
/** plain <img>: content images are referenced by absolute `/images/...` paths */
export function Img({
centered: _centered,
noZoom: _noZoom,
...props
}: ComponentProps<'img'> & { centered?: boolean; noZoom?: boolean }) {
return <img loading="lazy" {...props} alt={props.alt ?? ''} />;
}
/* -------------------------------------------------------------------------- */
/* Misc */
/* -------------------------------------------------------------------------- */
export function Update({
label,
description,
children,
}: {
label: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="pg-update" id={slugify(label)}>
<div className="pg-update-meta">
<span className="pg-field-pill">{label}</span>
{description && <p className="text-sm text-fd-muted-foreground">{description}</p>}
</div>
<div className="prose">{children}</div>
</div>
);
}
export function MintLink(props: ComponentProps<'a'>) {
const external = props.href ? /^https?:\/\//.test(props.href) : false;
return <Link {...props} href={props.href ?? '#'} external={external} />;
}
export const mintlifyComponents = {
Note,
Info,
Tip,
Warning,
Check,
Danger,
Card,
CardGroup,
Columns,
Steps,
Step,
Tabs,
Tab,
Accordion,
AccordionGroup,
Expandable,
ResponseField,
ParamField,
Frame,
iframe: Iframe,
video: Video,
Update,
Link: MintLink,
img: Img,
};
+36
View File
@@ -0,0 +1,36 @@
'use client';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
import navigation from '@/lib/navigation.json';
import { Icon } from './icons';
/**
* Sidebar header with the global anchors (Discord, Slack) from `lib/navigation.json`,
* styled like Mintlify's anchors. Rendered through the sidebar `banner` slot as a
* component so it owns the header padding; the bottom spacing matches the gap between
* sidebar groups.
*/
export function SidebarAnchors({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div {...props} className={cn('flex flex-col px-4 pt-4', className)}>
{children}
<ul className="flex flex-col gap-1 max-lg:mt-4">
{navigation.anchors.map((anchor) => (
<li key={anchor.href}>
<a
href={anchor.href}
target="_blank"
rel="noreferrer noopener"
className="pg-anchor"
>
<span className="pg-anchor-icon">
<Icon name={anchor.icon ?? undefined} />
</span>
{anchor.label}
</a>
</li>
))}
</ul>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import {
AndroidIcon,
AppleIcon,
GitHubIcon,
LinuxIcon,
LinkedInIcon,
WindowsIcon,
YouTubeIcon,
} from './icons';
import { Logo } from '@/lib/layout.shared';
const downloads = [
{ label: 'macOS', href: 'https://pangolin.net/downloads/mac', icon: AppleIcon },
{ label: 'iOS', href: 'https://pangolin.net/downloads/ios', icon: AppleIcon },
{ label: 'Windows', href: 'https://pangolin.net/downloads/windows', icon: WindowsIcon },
{ label: 'Android', href: 'https://pangolin.net/downloads/android', icon: AndroidIcon },
{ label: 'Linux', href: 'https://pangolin.net/downloads/linux', icon: LinuxIcon },
];
const socials = [
{ label: 'GitHub', href: 'https://github.com/fosrl/pangolin', icon: GitHubIcon },
{ label: 'LinkedIn', href: 'https://linkedin.com/company/pangolin-net', icon: LinkedInIcon },
{ label: 'YouTube', href: 'https://youtube.com/@pangolin-net', icon: YouTubeIcon },
];
export function SiteFooter() {
const year = new Date().getFullYear();
return (
<footer className="pg-footer">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
<div className="flex items-center gap-4">
<Logo />
<span className="h-6 w-px bg-fd-border" aria-hidden />
<div className="flex gap-3">
{socials.map(({ label, href, icon: Icon }) => (
<a
key={label}
href={href}
aria-label={label}
target="_blank"
rel="noreferrer noopener"
className="text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<Icon className="size-4.5" />
</a>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{downloads.map(({ label, href, icon: Icon }) => (
<a key={label} href={href} className="pg-download">
<Icon className="size-4" />
{label}
</a>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-sm text-fd-muted-foreground">
<a
href="https://status.pangolin.net"
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 text-fd-muted-foreground no-underline transition-colors hover:text-fd-foreground"
>
<span className="size-2 shrink-0 rounded-full bg-[#2d8a4e]" aria-hidden />
All systems operational
</a>
<p>© {year} Fossorial Inc.</p>
</div>
</footer>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from 'class-variance-authority';
const primary = 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80';
const variants = {
default: primary,
primary,
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
secondary:
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
} as const;
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
{
variants: {
variant: variants,
// `color` and `primary` predate the Shadcn UI compatible names
color: variants,
size: {
sm: 'gap-1 px-2 py-1.5 text-xs',
icon: 'p-1.5 [&_svg]:size-5',
'icon-sm': 'p-1.5 [&_svg]:size-4.5',
'icon-xs': 'p-1 [&_svg]:size-4',
},
},
},
);
export type ButtonProps = VariantProps<typeof buttonVariants>;