Merge upstream fumadocs dev (16.6.17) with @hanzo/docs namespace rename

Merge 131 upstream commits from fuma-nama/fumadocs dev branch, bringing
framework packages to v16.6.17. All fumadocs-* and @fumadocs/* package
references renamed to @hanzo/docs-* namespace.

Upstream additions:
- packages/content/ (@hanzo/docs-content) - Fuma Content adapter
- packages/epub/ (@hanzo/docs-epub) - EPUB export support
- MarkdownCopyButton, ViewOptionsPopover in docs page layout
- Flexsearch, Orama search integration
- Shiki v4 upgrade
- Various UI framework improvements

Hanzo customizations preserved:
- apps/docs/content/ (all Hanzo service/project docs)
- apps/docs/app/ (Hanzo homepage, AI chat, subdomain routing)
- apps/docs/components/ (AI search, error boundaries, auth)
- apps/docs/lib/ (source config, OpenAPI, metadata)
- Webpack alias layer for upstream project doc imports
- MdxErrorBoundary wrapper for graceful MDX failures
This commit is contained in:
Hanzo Dev
2026-03-16 14:34:53 -07:00
784 changed files with 26639 additions and 22847 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-typescript': patch
---
More options for simplifying types
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
---
Implement renderer API for replacing layout components, deprecate old options
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Implement i18n
+5 -5
View File
@@ -3,16 +3,16 @@
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
["@hanzo/docs-core", "@hanzo/docs-base-ui"],
["@hanzo/docs-create-app", "@hanzo/docs-create-app-versions"]
["fumadocs-core", "fumadocs-ui", "@fumadocs/base-ui"],
["create-fumadocs-app", "create-fumadocs-versions"]
],
"access": "public",
"baseBranch": "main",
"access": "restricted",
"baseBranch": "dev",
"updateInternalDependencies": "patch",
"bumpVersionsWithWorkspaceProtocolOnly": true,
"privatePackages": {
"version": true,
"tag": false
},
"ignore": ["docs", "dev-docs", "zap-docs", "example-*"]
"ignore": ["docs", "example-*"]
}
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-mdx': patch
---
Fix dynamic index generation so generated `dynamic.ts` imports `node:path` and passes lazy entries to `create.doc()` / `create.docs()` as arrays.
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
---
Improved `defineI18nUI()` usage: allow language translations to be defined at root config.
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
---
Improve `<GithubInfo />` component
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-typescript': minor
'fumadocs-openapi': minor
---
Bump deps
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
'fumadocs-core': minor
---
stablize Shiki factory API
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support `placeholder()` API in llms.txt generation
@@ -0,0 +1,24 @@
---
name: radix-base-ui-sync
description: Keep packages/radix-ui (fumadocs-ui) and packages/base-ui in sync. Use when editing either package, adding features, or fixing bugs so both UI variants stay consistent.
---
# Radix UI vs Base UI Package Sync
`packages/radix-ui` (fumadocs-ui) and `packages/base-ui` (@fumadocs/base-ui) are two variants of Fumadocs UI. They share the same layout structure, components, and public API; only the underlying primitives differ. When changing one package, consider the other.
## Package identity
| | radix-ui | base-ui |
| ---------------- | ----------------------- | ---------------------- |
| **Package name** | `fumadocs-ui` | `@fumadocs/base-ui` |
| **Primitives** | `@radix-ui/*` | `@base-ui/react` |
| **Config key** | `uiLibrary: 'radix-ui'` | `uiLibrary: 'base-ui'` |
## What to keep in sync
- **Layouts**: `layouts/docs`, `layouts/flux`, `layouts/home`, `layouts/notebook`, `layouts/shared` — structure and props should match; only primitive usage differs.
- **Components**: Same public API and behavior for components under `components/` (e.g. toc, steps, sidebar/page-tree, dialog/search behavior). UI primitives in `components/ui/` differ by design.
- **Provider**: `provider/base.tsx` — same props; only `DirectionProvider` and search/dialog primitives differ.
- **Contexts, utils, i18n, mdx, og**: Should stay identical or nearly identical.
- **package.json**: Same `exports` shape and version; `dependencies` differ (Radix vs Base UI).
+2 -2
View File
@@ -58,7 +58,7 @@ This repository uses Turborepo, so development servers should be started using t
Before running any app, build all shared packages:
```bash
pnpm run build --filter=./packages/*
pnpm run build --filter='./packages/*'
```
Then, start docs site in dev mode.
@@ -92,6 +92,6 @@ You may start with contributing to the docs,
it is located in `/apps/docs/content/docs`.
To run the docs site in dev mode,
build the dependencies with `pnpm run build --filter=./packages/*` and run `pnpm run dev --filter=docs` to start the dev server.
build the dependencies with `pnpm run build --filter='./packages/*'` and run `pnpm run dev --filter=docs` to start the dev server.
You don't need any extra environment variables to run this project.
+2 -1
View File
@@ -15,5 +15,6 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"oxc.fmt.configPath": ".oxfmtrc.jsonc",
"terminal.integrated.scrollback": 10000
"terminal.integrated.scrollback": 10000,
"typescript.tsdk": "node_modules/typescript/lib"
}
+13 -4
View File
@@ -2,6 +2,12 @@
Fork of [fumadocs](https://github.com/fuma-nama/fumadocs) with all packages renamed to `@hanzo/docs-*` namespace.
## Branch Convention
- **`main`** — Production branch. CF Pages deploys docs.hanzo.ai from here. All Hanzo work lands here.
- **`dev`** — Tracks upstream `fumadocs/dev`. Used for upstream sync merges only.
- **`upstream`** remote — points to `fuma-nama/fumadocs`
## Architecture
pnpm workspace monorepo with turbo. Two apps, 24 packages, 24 examples.
@@ -60,13 +66,16 @@ All upstream `fumadocs-*` and `@fumadocs/*` packages are renamed:
## Upstream Sync
Remote `upstream` points to `fuma-nama/fumadocs`. To merge upstream changes:
Remote `upstream` points to `fuma-nama/fumadocs`. Local `dev` tracks `upstream/dev`.
To merge upstream changes:
```bash
git fetch upstream
git checkout -b merge-upstream
git merge upstream/dev
git checkout dev && git pull upstream dev
git checkout -b merge-upstream-YYYY-MM-DD main
git merge dev
# Resolve conflicts, then rename fumadocs-* → @hanzo/docs-*
# Merge into main when ready
```
After merge, do bulk rename:
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -2
View File
@@ -7,7 +7,7 @@ import { blog } from '@/lib/source';
import { createMetadata } from '@/lib/metadata';
import { buttonVariants } from '@/components/ui/button';
import { ShareButton } from '@/app/(home)/blog/[slug]/page.client';
import { getMDXComponents } from '@/mdx-components';
import { getMDXComponents } from '@/components/mdx';
import path from 'node:path';
import { cn } from '@/lib/cn';
@@ -21,6 +21,7 @@ export const dynamic = 'force-static';
export default async function Page(props: BlogPageProps) {
const params = await props.params;
const page = blog.getPage([params.slug]);
const components = getMDXComponents();
if (!page) notFound();
@@ -71,7 +72,7 @@ export default async function Page(props: BlogPageProps) {
</div>
<InlineTOC items={toc} />
<Mdx components={getMDXComponents()} />
<Mdx components={components} />
</div>
</article>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+368
View File
@@ -0,0 +1,368 @@
'use client';
import {
type ComponentProps,
Fragment,
type HTMLAttributes,
type ReactElement,
type ReactNode,
type RefObject,
useEffect,
useRef,
useState,
} from 'react';
import { ArrowRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import Image from 'next/image';
import MainImg from './main.png';
import OpenAPIImg from './openapi.png';
import NotebookImg from './notebook.png';
import { cva } from 'class-variance-authority';
import HeroImage from './hero-preview.jpeg';
import { useTheme } from 'next-themes';
import dynamic from 'next/dynamic';
const GrainGradient = dynamic(
() => import('@paper-design/shaders-react').then((mod) => mod.GrainGradient),
{
ssr: false,
},
);
const Dithering = dynamic(
() => import('@paper-design/shaders-react').then((mod) => mod.Dithering),
{
ssr: false,
},
);
export function Hero() {
const { resolvedTheme } = useTheme();
const ref = useRef<HTMLImageElement | null>(null);
const visible = useIsVisible(ref);
const [showShaders, setShowShaders] = useState(false);
const [imageReady, setImageReady] = useState(false);
useEffect(() => {
// apply some delay, otherwise on slower devices, it errors with uniform images not being fully loaded.
setTimeout(() => {
setShowShaders(true);
}, 400);
}, []);
return (
<>
{showShaders && (
<GrainGradient
className="absolute inset-0 animate-fd-fade-in duration-800"
colors={
resolvedTheme === 'dark'
? ['#39BE1C', '#9c2f05', '#7A2A0000']
: ['#fcfc51', '#ffa057', '#7A2A0020']
}
colorBack="#00000000"
softness={1}
intensity={0.9}
noise={0.5}
speed={visible ? 1 : 0}
shape="corners"
minPixelRatio={1}
maxPixelCount={1920 * 1080}
/>
)}
{showShaders && (
<Dithering
width={720}
height={720}
colorBack="#00000000"
colorFront={resolvedTheme === 'dark' ? '#DF3F00' : '#fa8023'}
shape="sphere"
type="4x4"
scale={0.5}
size={3}
speed={0}
frame={5000 * 120}
className="absolute animate-fd-fade-in duration-400 max-lg:bottom-[-50%] max-lg:left-[-200px] lg:top-[-5%] lg:right-0"
minPixelRatio={1}
/>
)}
<Image
ref={ref}
src={HeroImage}
alt="hero-image"
className={cn(
'absolute top-[460px] left-[20%] max-w-[1200px] rounded-xl border-2 lg:top-[400px]',
imageReady ? 'animate-in fade-in duration-400' : 'invisible',
)}
onLoad={() => setImageReady(true)}
priority
/>
</>
);
}
export function CreateAppAnimation(props: ComponentProps<'div'>) {
const installCmd = 'pnpm create @hanzo/docs-create-app';
const tickTime = 100;
const timeCommandEnter = installCmd.length;
const timeCommandRun = timeCommandEnter + 3;
const timeCommandEnd = timeCommandRun + 3;
const timeWindowOpen = timeCommandEnd + 1;
const timeEnd = timeWindowOpen + 1;
const [tick, setTick] = useState(timeEnd);
useEffect(() => {
const timer = setInterval(() => {
setTick((prev) => (prev >= timeEnd ? prev : prev + 1));
}, tickTime);
return () => {
clearInterval(timer);
};
}, [timeEnd]);
const lines: ReactElement[] = [];
lines.push(
<span key="command_type">
{installCmd.substring(0, tick)}
{tick < timeCommandEnter && (
<div className="inline-block h-3 w-1 animate-pulse bg-fd-foreground" />
)}
</span>,
);
if (tick >= timeCommandEnter) {
lines.push(<span key="space"> </span>);
}
if (tick > timeCommandRun)
lines.push(
<Fragment key="command_response">
{tick > timeCommandRun + 1 && (
<>
<span className="font-medium">◇ Project name</span>
<span>│ my-app</span>
</>
)}
{tick > timeCommandRun + 2 && (
<>
<span>│</span>
<span className="font-medium">◆ Choose a framework</span>
</>
)}
{tick > timeCommandRun + 3 && (
<>
<span>│ ● Next.js</span>
<span>│ ○ Waku</span>
<span>│ ○ Tanstack Start</span>
<span>│ ○ React Router</span>
</>
)}
</Fragment>,
);
return (
<div
{...props}
onMouseEnter={() => {
if (tick >= timeEnd) {
setTick(0);
}
}}
>
{tick > timeWindowOpen && (
<LaunchAppWindow className="absolute bottom-5 right-4 z-10 animate-in fade-in slide-in-from-top-10" />
)}
<pre className="font-mono text-sm min-h-[240px]">
<code className="grid">{lines}</code>
</pre>
</div>
);
}
function LaunchAppWindow(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={cn('overflow-hidden rounded-md border bg-fd-popover shadow-lg', props.className)}
>
<p className="text-xs text-fd-muted-foreground text-center px-4 py-2 border-b">
localhost:3000
</p>
<p className="text-sm px-4 py-2">New App launched!</p>
</div>
);
}
const previewButtonVariants = cva('w-20 h-8 text-sm font-medium transition-colors rounded-full', {
variants: {
active: {
true: 'text-fd-primary-foreground',
false: 'text-fd-muted-foreground',
},
},
});
export function PreviewImages(props: ComponentProps<'div'>) {
const [active, setActive] = useState(0);
const previews = [
{
image: MainImg,
name: 'Docs',
},
{
image: NotebookImg,
name: 'Notebook',
},
{
image: OpenAPIImg,
name: 'OpenAPI',
},
];
return (
<div {...props} className={cn('relative grid', props.className)}>
<div className="absolute flex flex-row left-1/2 -translate-1/2 bottom-0 z-2 p-0.5 rounded-full bg-fd-card border shadow-xl">
<div
role="none"
className="absolute bg-fd-primary rounded-full w-20 h-8 transition-transform z-[-1]"
style={{
transform: `translateX(calc(var(--spacing) * 20 * ${active}))`,
}}
/>
{previews.map((item, i) => (
<button
key={i}
className={cn(previewButtonVariants({ active: active === i }))}
onClick={() => setActive(i)}
>
{item.name}
</button>
))}
</div>
{previews.map((item, i) => (
<Image
key={i}
src={item.image}
alt="preview"
className={cn(
'col-start-1 row-start-1 select-none',
active === i ? 'animate-in fade-in slide-in-from-bottom-12 duration-800' : 'invisible',
)}
/>
))}
</div>
);
}
const WritingTabs = [
{
name: 'Writer',
value: 'writer',
},
{
name: 'Developer',
value: 'developer',
},
{
name: 'Automation',
value: 'automation',
},
] as const;
export function Writing({
tabs: tabContents,
}: {
tabs: Record<(typeof WritingTabs)[number]['value'], ReactNode>;
}) {
const [tab, setTab] = useState<(typeof WritingTabs)[number]['value']>('writer');
return (
<div className="col-span-full my-20">
<h2 className="text-4xl text-brand mb-8 text-center font-medium tracking-tight">
Anybody can write.
</h2>
<p className="text-center mb-8 mx-auto w-full max-w-[800px]">
Native support for Markdown & MDX, offering intuitive, convenient and extensive syntax for
non-dev writers, developers, and AI agents.
</p>
<div className="flex justify-center items-center gap-4 text-fd-muted-foreground mb-6">
{WritingTabs.map((item) => (
<Fragment key={item.value}>
<ArrowRight className="size-4 first:hidden" />
<button
className={cn(
'text-lg font-medium transition-colors',
item.value === tab && 'text-brand',
)}
onClick={() => setTab(item.value)}
>
{item.name}
</button>
</Fragment>
))}
</div>
{Object.entries(tabContents).map(([key, value]) => (
<div
key={key}
aria-hidden={key !== tab}
className={cn('animate-fd-fade-in', key !== tab && 'hidden')}
>
{value}
</div>
))}
</div>
);
}
export function AgnosticBackground() {
const ref = useRef<HTMLDivElement>(null);
const visible = useIsVisible(ref);
return (
<div
ref={ref}
className="absolute inset-0 -z-1 mask-[linear-gradient(to_top,white_30%,transparent_calc(100%-120px))]"
>
<Dithering
colorBack="#00000000"
colorFront="#c6bb58"
shape="warp"
type="4x4"
speed={visible ? 0.4 : 0}
className="size-full"
minPixelRatio={1}
/>
</div>
);
}
let observer: IntersectionObserver;
const observerTargets = new WeakMap<Element, (entry: IntersectionObserverEntry) => void>();
function useIsVisible(ref: RefObject<HTMLElement | null>) {
const [visible, setVisible] = useState(false);
useEffect(() => {
observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) {
observerTargets.get(entry.target)?.(entry);
}
});
const element = ref.current;
if (!element) return;
observerTargets.set(element, (entry) => {
setVisible(entry.isIntersecting);
});
observer.observe(element);
return () => {
observer.unobserve(element);
observerTargets.delete(element);
};
}, [ref]);
return visible;
}
+449
View File
@@ -0,0 +1,449 @@
import { PlusIcon } from 'lucide-react';
import Image, { type StaticImageData } from 'next/image';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@/components/ui/button';
import { createMetadata } from '@/lib/metadata';
import Expostarter from '@/public/showcases/expostarter.png';
import Sunar from '@/public/showcases/sunar.png';
import OpenPanel from '@/public/showcases/openpanel.png';
import Supastarter from '@/public/showcases/supastarter.png';
import BetterAuth from '@/public/showcases/better-auth.png';
import ArkType from '@/public/showcases/arktype.png';
import AssistantUI from '@/public/showcases/assistant-ui.png';
import VisionUI from '@/public/showcases/vision-ui.png';
import Design from './design.png';
import Link from '@hanzo/docs-core/link';
import MixSpace from '@/public/showcases/mix-space.png';
import TurboStarter from '@/public/showcases/turbostarter.png';
import HexaasBlog from '@/public/showcases/hexaas-blog.png';
export const metadata = createMetadata({
title: 'Showcase',
description: 'Some cool websites using Fumadocs',
openGraph: {
url: 'https://fumadocs.dev/showcase',
},
});
interface ShowcaseObject {
image?: StaticImageData | string;
name: string;
url: string;
}
const showcases: ShowcaseObject[] = [
{
image: '/showcases/shadcn.png',
name: 'shadcn/ui',
url: 'https://ui.shadcn.com',
},
{
image: BetterAuth,
name: 'Better Auth',
url: 'https://better-auth.com',
},
{
image: '/showcases/c15t.png',
name: 'c15t',
url: 'https://c15t.com',
},
{
image: '/showcases/better-t-stack.jpeg',
name: 'Better T Stack',
url: 'https://better-t-stack.dev',
},
{
image: '/showcases/zod.png',
name: 'Zod',
url: 'https://zod.dev',
},
{
image: '/showcases/prisma.png',
name: 'Prisma',
url: 'https://prisma.io',
},
{ image: '/showcases/nuqs.jpg', name: 'nuqs', url: 'https://nuqs.47ng.com' },
{
image: '/showcases/dokploy.png',
name: 'Dokploy',
url: 'https://dokploy.com',
},
{
image: '/showcases/ultracite.jpg',
name: 'Ultracite',
url: 'https://ultracite.ai',
},
{
image: '/showcases/sim.png',
name: 'Sim',
url: 'https://sim.ai',
},
{
image: '/showcases/comfydeploy.png',
name: 'ComfyDeploy',
url: 'https://comfydeploy.com',
},
{
image: '/showcases/octarine.png',
name: 'Octarine',
url: 'https://docs.octarine.app',
},
{
image: '/showcases/llm-gateway.png',
name: 'LLM Gateway',
url: 'https://llmgateway.io',
},
{
image: '/showcases/nativewind.png',
name: 'Nativewind',
url: 'https://www.nativewind.dev',
},
{
image: ArkType,
name: 'Arktype',
url: 'https://arktype.io',
},
{
image: '/showcases/zen-browser.png',
name: 'Zen Browser',
url: 'https://docs.zen-browser.app',
},
{
image: '/showcases/hero-ui.png',
name: 'Hero UI',
url: 'https://v3.heroui.com',
},
{
image: '/showcases/documenso.png',
name: 'Documenso',
url: 'https://docs.documenso.com',
},
{
image: '/showcases/mrscraper.png',
name: 'MrScraper',
url: 'https://docs.mrscraper.com',
},
{
image: '/showcases/hiro.png',
name: 'Hiro',
url: 'https://docs.hiro.so/stacks',
},
{
image: '/showcases/next-faq.png',
name: 'Next.js Discord Common Questions',
url: 'https://nextjs-faq.com',
},
{
image: OpenPanel,
name: 'OpenPanel',
url: 'https://openpanel.dev',
},
{
image: '/showcases/animate-ui.png',
name: 'Animate UI',
url: 'https://animate-ui.com',
},
{
image: '/showcases/kibo-ui.jpg',
name: 'Kibo UI',
url: 'https://kibo-ui.com',
},
{
image: '/showcases/hexta-ui.png',
name: 'HextaUI',
url: 'https://hextaui.com',
},
{
image: MixSpace,
name: 'MixSpace',
url: 'https://mx-space.js.org',
},
{
image: '/showcases/libra.png',
name: 'Libra',
url: 'https://libra.dev',
},
{
image: Sunar,
name: 'Sunar',
url: 'https://sunar.js.org',
},
{
image: '/showcases/freestyle.png',
name: 'Freestyle',
url: 'https://freestyle.sh',
},
{
image: '/showcases/zap-studio.png',
name: 'Zap Studio',
url: 'https://zapstudio.dev',
},
{
image: VisionUI,
name: 'Vision UI',
url: 'https://vision.uing.dev',
},
{
image: '/showcases/yamlresume.png',
name: 'YAMLResume',
url: 'https://yamlresume.dev',
},
{
image: '/showcases/kaneo.png',
name: 'Kaneo',
url: 'https://kaneo.app',
},
{
image: '/showcases/axionjs.jpg',
name: 'axion/js',
url: 'https://axionjs.com',
},
{
image: '/showcases/codehike.png',
name: 'CodeHike',
url: 'https://codehike.org',
},
{
image: AssistantUI,
name: 'assistant-ui',
url: 'https://assistant-ui.com',
},
{
image: '/showcases/constatic.png',
name: 'Constatic',
url: 'https://constatic-docs.vercel.app',
},
{
image: Supastarter,
name: 'SupaStarter',
url: 'https://supastarter.dev',
},
{
image: Expostarter,
name: 'ExpoStarter',
url: 'https://expostarter.com/docs',
},
{
image: '/showcases/nxjs.png',
name: 'nx.js',
url: 'https://nxjs.n8.io',
},
{
image: TurboStarter,
name: 'TurboStarter',
url: 'https://turbostarter.dev',
},
{
name: 'ModelFetch',
url: 'https://modelfetch.com',
image: '/showcases/modelfetch.png',
},
{
image: '/showcases/fashnai.png',
name: 'FasnAI',
url: 'https://fashn.ai',
},
{
name: 'Dirstarter',
url: 'https://dirstarter.com',
},
{
name: 'Rehooks',
url: 'https://rehooks.pyr33x.ir',
},
{
name: 'Swellchain',
url: 'https://build.swellnetwork.io',
},
{
name: 'ESLint React',
url: 'https://eslint-react.xyz',
},
{
name: 'Llamaindex',
url: 'https://ts.llamaindex.ai',
},
{
name: 'Clojure Stack Lite',
url: 'https://stack.bogoyavlensky.com',
},
{
name: 'BProgress',
url: 'https://bprogress.vercel.app',
},
{
name: 'nextjs i18n docs',
url: 'https://nextjs.im',
},
{
name: 'Yeecord',
url: 'https://yeecord.com',
},
];
const blogs: ShowcaseObject[] = [
{
name: "RUNFUNRUN's Blog",
url: 'https://runfunrun.dev',
},
{
name: 'xlog.systems',
url: 'https://www.xlog.systems',
},
{
name: 'stutuer',
url: 'https://www.stutuer.tech',
},
{
image: HexaasBlog,
name: "hexaa's blog",
url: 'https://hexaa.sh/blog',
},
];
const vercel = [
{
name: 'Turbo',
url: 'https://turbo.build',
},
{
name: 'Flags SDK',
url: 'https://flags-sdk.dev',
},
{
name: 'Chat SDK',
url: 'https://chat-sdk.dev',
},
];
export default function Showcase() {
return (
<main className="px-4 py-12 z-2 w-full max-w-[1400px] mx-auto **:border-neutral-400 dark:**:border-neutral-700">
<div className="relative overflow-hidden border border-dashed p-6">
<h1 className="mb-4 text-xl font-medium">The docs framework designed with care.</h1>
<p className="text-fd-muted-foreground">
A list of beautiful open-source projects with their docs powered by Fumadocs.
</p>
<div className="mt-6">
<a
href="https://github.com/fuma-nama/fumadocs/discussions/30"
target="_blank"
rel="noreferrer noopener"
className={cn(
buttonVariants({
variant: 'outline',
}),
)}
>
<PlusIcon className="me-2 size-4" />
Suggest Yours
</a>
</div>
<span className="absolute text-xs left-6 bottom-6 text-fd-muted-foreground font-mono">
Showcases
</span>
<Image
src={Design}
alt="preview"
priority
className="ml-auto w-[600px] min-w-[600px] -mt-12 -mb-18 pointer-events-none select-none"
/>
</div>
<div className="flex gap-4 border border-dashed p-6 mt-6">
<svg
aria-label="Vercel logomark"
height="64"
role="img"
viewBox="0 0 74 64"
className="size-6 mt-1"
>
<path d="M37.5896 0.25L74.5396 64.25H0.639648L37.5896 0.25Z" fill="currentColor" />
</svg>
<div>
<h2 className="text-sm font-medium mb-2">
Fumadocs powers the docs of Vercel open source SDKs.
</h2>
<div className="flex items-center gap-2 -mx-1.5">
{vercel.map((item) => (
<Link
key={item.url}
href={item.url}
className={cn(
buttonVariants({
variant: 'link',
size: 'xs',
className: 'text-brand',
}),
)}
>
{item.name}
</Link>
))}
</div>
</div>
</div>
<div className="relative mt-6 grid gap-2.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{showcases.map((showcase) => (
<ShowcaseItem key={showcase.url} {...showcase} />
))}
<div className="absolute text-center bottom-0 inset-x-0 pt-4 bg-gradient-to-t from-fd-background">
<Link
href="https://github.com/fuma-nama/fumadocs/discussions/30"
className={cn(
buttonVariants({
size: 'sm',
variant: 'link',
}),
)}
>
See all of our showcases.
</Link>
</div>
</div>
<h2 className="text-xl font-medium mt-12 px-4">Fumadocs can power your blog, too.</h2>
<div className="mt-6 grid gap-2.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{blogs.map((showcase) => (
<ShowcaseItem key={showcase.url} {...showcase} />
))}
</div>
</main>
);
}
function ShowcaseItem({ name, url, image }: ShowcaseObject) {
if (image) {
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="group relative aspect-[1.91/1] border border-dashed"
>
<Image
alt="Preview"
src={image}
fill
sizes="100vw, (min-width: 750px) 500px"
className="object-cover transition-all group-hover:brightness-150"
/>
<p className="absolute bottom-0 inset-x-0 z-2 bg-fd-background px-4 py-2 text-sm font-medium">
{name}
</p>
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="flex aspect-[1.91/1] flex-col border border-dashed p-4 transition-all hover:bg-fd-accent"
>
<p className="font-mono text-xs mb-2 text-fd-muted-foreground">{new URL(url).hostname}</p>
<p className="text-xl font-medium">{name}</p>
</a>
);
}
+293
View File
@@ -0,0 +1,293 @@
import { Fragment } from 'react';
import Image from 'next/image';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/cn';
import { getSponsors } from '@/lib/get-sponsors';
import { owner } from '@/lib/github';
import { organizationAsUserSponsors } from '@/app/(home)/sponsors/data';
import { HeartIcon } from 'lucide-react';
const tiers = [
{
name: 'Platinum Sponsor',
min: 1000,
color: 'text-purple-600 dark:text-purple-400',
},
{
name: 'Golden Sponsor',
min: 225,
color: 'text-yellow-600 dark:text-yellow-400',
},
{
name: 'Silver Sponsor',
min: 128,
color: 'text-fd-muted-foreground',
},
];
export const revalidate = 3600;
export default async function Page() {
const result = await getSponsors(owner);
const sponsors = result.map((v) => {
const entity = organizationAsUserSponsors.find((entity) => entity.asUser === v.login);
if (entity) {
return {
login: entity.github,
name: entity.label,
websiteUrl: entity.url,
logo: entity.logo,
__typename: 'Organization',
tier: v.tier,
avatarUrl: undefined,
};
}
return {
logo: undefined,
...v,
};
});
return (
<main className="w-full max-w-page mx-auto flex flex-col items-center px-4 py-16 text-center z-2">
<Image
src="/circuit_2.svg"
alt="circuit"
width="1231"
height="536"
className="absolute top-16 z-[-1] w-full max-w-[1200px] opacity-0 dark:opacity-20"
/>
<h1 className="text-4xl font-semibold">Support Fumadocs</h1>
<p className="mt-4 text-sm">
Support the development work of Fumadocs. Fumadocs is fully open source, your sponsorship
means a lot.
</p>
<a
href="https://github.com/sponsors/fuma-nama"
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
className: 'group rounded-full mt-6',
}),
)}
>
Sponsor
<span className="w-0 transition-[width] overflow-hidden group-hover:w-6">
<HeartIcon className="text-pink-200 ms-auto fill-current size-4 dark:text-red-400" />
</span>
</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 200" className="max-w-[600px]">
<defs>
<linearGradient id="circuit-1-fill" x1="0" y1="0" x2="0.939104" y2="0.786487">
<stop offset="0%" stopColor="rgb(50,50,60)" />
<stop offset="100%" stopColor="rgb(30,30,38)" />
</linearGradient>
<linearGradient id="circuit-1-energy" x2="0" y2="1">
<stop offset="0%" stopColor="transparent" />
<stop offset="60%" stopColor="rgb(200,100,255)" />
</linearGradient>
<linearGradient id="circuit-1-x-line" x1="0" y1="0" y2="0" x2="1">
<stop offset="40%" stopColor="rgb(50,50,60)" />
<stop offset="100%" stopColor="transparent" />
</linearGradient>
<linearGradient id="circuit-1-x-energy" x1="0" y1="0" y2="0" x2="1">
<stop offset="0%" stopColor="transparent" />
<stop offset="60%" stopColor="rgba(140,180,255)" />
</linearGradient>
</defs>
<line
x1="300"
y1="0"
x2="300"
y2="99"
fill="none"
stroke="rgb(50,50,60)"
strokeWidth="2"
strokeLinecap="square"
/>
<rect
x="299"
y="0"
width="2"
height="20"
fill="url(#circuit-1-energy)"
style={{
animation: 'circuit_1 infinite linear 3s',
}}
/>
<rect
width="120"
height="85"
rx="6"
ry="6"
x="240"
y="99"
fill="url(#circuit-1-fill)"
stroke="#3f4c59"
strokeWidth="0.5"
/>
{new Array(3).fill(null).map((_, i) => (
<Fragment key={i}>
<rect
x="368"
y={120 + i * 20 + 5 / 4}
width="232"
height="2"
fill="url(#circuit-1-x-line)"
/>
<rect
x="368"
y={120 + i * 20 + 5 / 4}
width="20"
height="2"
fill="url(#circuit-1-x-energy)"
style={{
opacity: 0,
animation: 'circuit_1_x_energy infinite linear 6s',
animationDelay: `${(Math.pow(2, i) * 360).toString()}ms`,
}}
/>
<rect x="360" y={120 + i * 20} width="8" height="5" fill="rgb(100,100,120)" />
</Fragment>
))}
<circle
r="4"
cx="348"
cy="114"
fill="rgb(255,140,255)"
style={{
filter: 'drop-shadow(2px 0px 8px rgb(255, 100, 255))',
}}
/>
<text dx="256" dy="147.793041" fontSize="18" fontWeight="400" fill="rgba(70,70,86,0.9)">
Fumadocs
</text>
</svg>
<h2 className="mt-12 font-mono text-xs mb-7">Organization Sponsors</h2>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{sponsors
.filter((sponsor) => sponsor.__typename === 'Organization')
.map((sponsor) => {
const tier = tiers.find((tier) => sponsor.tier.monthlyPriceInDollars >= tier.min);
return (
<a
key={sponsor.name}
href={getSponsorHref(sponsor.login, sponsor.websiteUrl)}
rel="noreferrer noopener"
target="_blank"
className="flex flex-col items-start"
>
<div className="inline-flex h-14 items-center gap-2.5 font-medium text-xl">
{sponsor.logo ?? (
<>
<Image
alt="avatar"
src={sponsor.avatarUrl}
unoptimized
width="38"
height="38"
className="rounded-lg"
/>
<p>{sponsor.name}</p>
</>
)}
</div>
{tier && <p className={cn('text-xs', tier.color)}>{tier.name}</p>}
</a>
);
})}
</div>
<h2 className="mt-12 font-mono text-xs mb-7">Open Source Program</h2>
<div className="flex flex-row gap-4 items-center">
<a href="https://vercel.com" rel="noreferrer noopener">
<svg aria-label="Vercel logotype" role="img" viewBox="0 0 283 64" className="w-32 h-auto">
<path
d="M141.68 16.25c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zm117.14-14.5c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zm-39.03 3.5c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9v-46h9zM37.59.25l36.95 64H.64l36.95-64zm92.38 5l-27.71 48-27.71-48h10.39l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10v14.8h-9v-34h9v9.2c0-5.08 5.91-9.2 13.2-9.2z"
fill="currentColor"
/>
</svg>
</a>
<a href="https://openai.com" rel="noreferrer noopener">
<svg
width="1604"
height="718"
viewBox="0 0 1604 718"
className="w-32 h-auto"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_1637_2932)">
<path
d="M358.8 239.3C293.02 239.3 239.2 293.12 239.2 358.9C239.2 424.68 293.02 478.5 358.8 478.5C424.58 478.5 478.4 425.012 478.4 358.9C478.4 292.787 424.912 239.3 358.8 239.3ZM358.8 435.643C317.937 435.643 285.046 402.089 285.046 358.9C285.046 315.711 317.937 282.156 358.8 282.156C399.663 282.156 432.553 315.711 432.553 358.9C432.553 402.089 399.663 435.643 358.8 435.643Z"
fill="currentColor"
/>
<path
d="M599.326 305.744C577.732 305.744 556.802 314.382 545.838 329V309.066H502.649V541.622H545.838V457.57C556.802 471.191 577.067 478.5 599.326 478.5C645.837 478.5 682.382 441.955 682.382 392.122C682.382 342.289 645.837 305.744 599.326 305.744ZM592.017 440.959C567.433 440.959 545.506 421.69 545.506 392.122C545.506 362.554 567.433 343.285 592.017 343.285C616.602 343.285 638.528 362.554 638.528 392.122C638.528 421.69 616.602 440.959 592.017 440.959Z"
fill="currentColor"
/>
<path
d="M784.046 305.744C736.871 305.744 699.662 342.621 699.662 392.122C699.662 441.623 732.22 478.5 785.375 478.5C828.896 478.5 856.803 452.254 865.441 422.686H823.249C817.933 434.979 802.983 443.616 785.043 443.616C762.784 443.616 745.841 428.002 741.854 405.743H867.434V388.8C867.434 343.617 835.873 305.744 784.046 305.744ZM742.186 375.179C746.838 354.249 764.113 340.627 785.043 340.627C807.302 340.627 824.245 355.245 826.239 375.179H742.186Z"
fill="currentColor"
/>
<path
d="M982.38 305.744C963.111 305.744 942.845 314.382 933.543 328.667V309.066H890.354V475.177H933.543V385.81C933.543 359.896 947.496 342.953 970.087 342.953C991.017 342.953 1002.31 358.9 1002.31 381.159V475.177H1045.5V374.182C1045.5 332.986 1020.25 305.744 982.38 305.744Z"
fill="currentColor"
/>
<path
d="M1156.12 242.628L1062.1 475.184H1108.28L1128.21 424.354H1235.19L1255.12 475.184H1301.96L1208.61 242.628H1156.12ZM1143.16 385.816L1181.7 288.475L1219.9 385.816H1143.16Z"
fill="currentColor"
/>
<path d="M1363.42 242.628H1319.57V475.184H1363.42V242.628Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_1637_2932">
<rect
width="1603.2"
height="717.6"
fill="white"
transform="translate(0 0.0999756)"
/>
</clipPath>
</defs>
</svg>
</a>
</div>
<h2 className="mt-12 font-mono text-xs">Individual Sponsors</h2>
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{sponsors
.filter((sponsor) => sponsor.__typename === 'User')
.map((sponsor) => (
<a
key={sponsor.name}
href={getSponsorHref(sponsor.login, sponsor.websiteUrl)}
rel="noreferrer noopener"
target="_blank"
className="inline-flex items-center gap-2 rounded-xl p-3 text-xs transition-colors hover:bg-fd-primary/10"
>
<Image
alt="avatar"
src={sponsor.avatarUrl!}
unoptimized
width="30"
height="30"
className="rounded-lg"
/>
{sponsor.name}
</a>
))}
</div>
</main>
);
}
function getSponsorHref(login: string, url?: string): string {
if (url) return url.startsWith('http') ? url : `https://${url}`;
return `https://github.com/${login}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+81
View File
@@ -0,0 +1,81 @@
import { DataSourceId, orama } from '@/lib/orama/client';
import { createMcpHandler } from 'mcp-handler';
import { z } from 'zod';
import { ProvideLinksToolSchema } from '@/lib/inkeep/inkeep-qa-schema';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';
const openai = createOpenAICompatible({
name: 'inkeep',
apiKey: process.env.INKEEP_API_KEY,
baseURL: 'https://api.inkeep.com/v1',
});
const handler = createMcpHandler(
(server) => {
server.registerTool(
'search',
{
title: 'Search Docs',
description: 'Search docs pages with a query',
inputSchema: z.object({
query: z.string('the search query'),
}),
},
async ({ query }) => {
const result = await orama.search({
term: query,
datasources: [DataSourceId],
limit: 50,
});
return {
content: result.hits.map((hit) => ({
type: 'text',
text: JSON.stringify(hit.document),
})),
};
},
);
server.registerTool(
'ask-ai',
{
title: 'Ask AI',
description: 'Ask another specialized AI a question for more info',
inputSchema: z.object({
message: z.string(),
}),
},
async ({ message }) => {
const result = await generateText({
model: openai('inkeep-qa-sonnet-4'),
tools: {
provideLinks: {
inputSchema: ProvideLinksToolSchema,
},
},
messages: [
{
role: 'user',
content: message,
},
],
});
return {
content: [
{
type: 'text',
text: result.text,
},
],
};
},
);
},
{},
{ basePath: '/api', disableSse: true },
);
export { handler as GET, handler as POST, handler as DELETE };
+12 -7
View File
@@ -14,12 +14,17 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/h
import Link from '@hanzo/docs-core/link';
import { findSiblings } from '@hanzo/docs-core/page-tree';
import { Card, Cards } from '@hanzo/docs-base-ui/components/card';
import { getMDXComponents } from '@/mdx-components';
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
import { getMDXComponents } from '@/components/mdx';
import { Banner } from '@hanzo/docs-base-ui/components/banner';
import { Installation } from '@/components/preview/installation';
import { Customisation } from '@/components/preview/customisation';
import { DocsBody, DocsPage, PageLastUpdate } from '@hanzo/docs-base-ui/layouts/docs/page';
import {
DocsBody,
DocsPage,
PageLastUpdate,
MarkdownCopyButton,
ViewOptionsPopover,
} from '@hanzo/docs-base-ui/layouts/docs/page';
import { NotFound } from '@/components/layouts/not-found';
import { MdxErrorBoundary } from '@/components/mdx-error-boundary';
import { getSuggestions } from './suggestions';
@@ -75,15 +80,15 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
return (
<DocsPage
toc={toc}
tableOfContent={{
TOC={{
style: 'clerk',
}}
>
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
<p className="text-lg text-fd-muted-foreground mb-2">{page.data.description}</p>
<div className="flex flex-row flex-wrap gap-2 items-center border-b pb-6">
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
<ViewOptions
<MarkdownCopyButton markdownUrl={`${page.url}.mdx`} />
<ViewOptionsPopover
markdownUrl={`${page.url}.mdx`}
githubUrl={`https://github.com/${owner}/${repo}/blob/dev/apps/docs/content/docs/${page.path}`}
/>
@@ -94,7 +99,7 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
<Mdx
components={getMDXComponents({
...Twoslash,
a: ({ href, ...props }) => {
a({ href, ...props }) {
const found = source.getPageByHref(href ?? '', {
dir: PathUtils.dirname(page.path),
});
+23
View File
@@ -0,0 +1,23 @@
import { source } from '@/lib/source';
import { exportEpub } from '@hanzo/docs-epub';
export const revalidate = false;
export async function GET(): Promise<Response> {
const buffer = await exportEpub({
source,
getMarkdown(page) {
if (page.data.type === 'docs') return page.data.getText('raw');
},
title: 'Hanzo Docs Documentation',
author: 'Fuma Nama',
description: 'Documentation for Hanzo Docs - the documentation framework',
cover: '/og.png',
});
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/epub+zip',
'Content-Disposition': 'attachment; filename="hanzo-docs.epub"',
},
});
}
+8
View File
@@ -0,0 +1,8 @@
import { source } from '@/lib/source';
import { llms } from '@hanzo/docs-core/source';
export const revalidate = false;
export function GET() {
return new Response(llms(source).index());
}
+3 -11
View File
@@ -1,17 +1,9 @@
import { openapi, hasSpecs } from '@/lib/openapi';
import { createAPIPage } from '@hanzo/docs/openapi/ui';
import { defaultShikiOptions } from '@/lib/shiki';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
export const APIPage = hasSpecs
? createAPIPage(openapi, {
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
showResponseSchema: true,
schemaUI: {
showExample: true,
},
shikiOptions: defaultShikiOptions,
})
: null;
@@ -16,10 +16,10 @@ import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import Link from '@hanzo/docs-core/link';
import { type UIMessage, useChat, type UseChatHelpers } from '@ai-sdk/react';
import type { ProvideLinksToolSchema } from '@/lib/chat/inkeep-qa-schema';
import type { ProvideLinksToolSchema } from '@/lib/inkeep/inkeep-qa-schema';
import type { z } from 'zod';
import { DefaultChatTransport } from 'ai';
import { Markdown } from './markdown';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
const Context = createContext<{
+1 -1
View File
@@ -14,7 +14,7 @@ import {
AISearchPanelList,
useAISearchContext,
useHotKey,
} from '@/components/ai/search';
} from '@/components/inkeep/search';
import { MessageCircleIcon } from 'lucide-react';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { cn } from '@/lib/cn';
+32
View File
@@ -0,0 +1,32 @@
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import { Files, File, Folder } from '@hanzo/docs-ui/components/files';
import { Tabs, Tab, TabsContent, TabsList, TabsTrigger } from '@hanzo/docs-ui/components/tabs';
import { Steps, Step } from '@hanzo/docs-ui/components/steps';
import type { MDXComponents } from 'mdx/types';
import { Accordion, Accordions } from '@hanzo/docs-ui/components/accordion';
import * as icons from 'lucide-react';
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
Tabs,
Tab,
TabsContent,
TabsList,
TabsTrigger,
Steps,
Step,
Files,
File,
Folder,
Accordion,
Accordions,
...components,
};
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
+447
View File
@@ -0,0 +1,447 @@
'use client';
import {
type ComponentProps,
createContext,
type ReactNode,
type SyntheticEvent,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { Loader2, MessageCircleIcon, RefreshCw, SearchIcon, Send, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { buttonVariants } from '@hanzo/docs-ui/components/ui/button';
import { type UIMessage, useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type Tool, type UIToolInvocation } from 'ai';
import { Markdown } from '../markdown';
import { Presence } from '@radix-ui/react-presence';
import type { SearchTool } from '@/lib/openrouter/server';
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<UIMessage>;
} | 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-2">AI Chat</p>
<p className="text-xs text-fd-muted-foreground">
AI can be inaccurate, please verify the answers.
</p>
</div>
<button
aria-label="Close"
tabIndex={-1}
className={cn(
buttonVariants({
size: 'icon-sm',
color: '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({
color: 'secondary',
size: 'sm',
className: 'rounded-full gap-1.5',
}),
)}
onClick={() => regenerate()}
>
<RefreshCw className="size-4" />
Retry
</button>
)}
<button
type="button"
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
>
Clear Chat
</button>
</>
);
}
const StorageKeyInput = '__ai_search_input';
export function AISearchInput(props: ComponentProps<'form'>) {
const { status, sendMessage, stop } = useChatContext();
const [input, setInput] = useState(() => localStorage.getItem(StorageKeyInput) ?? '');
const isLoading = status === 'streaming' || status === 'submitted';
const onStart = (e?: SyntheticEvent) => {
e?.preventDefault();
void sendMessage({ text: input });
setInput('');
localStorage.removeItem(StorageKeyInput);
};
useEffect(() => {
if (isLoading) document.getElementById('nd-ai-input')?.focus();
}, [isLoading]);
return (
<form {...props} className={cn('flex items-start pe-2', props.className)} onSubmit={onStart}>
<Input
value={input}
placeholder={isLoading ? 'AI is answering...' : 'Ask a question'}
autoFocus
className="p-3"
disabled={status === 'streaming' || status === 'submitted'}
onChange={(e) => {
setInput(e.target.value);
localStorage.setItem(StorageKeyInput, e.target.value);
}}
onKeyDown={(event) => {
if (!event.shiftKey && event.key === 'Enter') {
onStart(event);
}
}}
/>
{isLoading ? (
<button
key="bn"
type="button"
className={cn(
buttonVariants({
color: 'secondary',
className: 'transition-all rounded-full mt-2 gap-2',
}),
)}
onClick={stop}
>
<Loader2 className="size-4 animate-spin text-fd-muted-foreground" />
Abort Answer
</button>
) : (
<button
key="bn"
type="submit"
className={cn(
buttonVariants({
color: 'primary',
className: 'transition-all rounded-full mt-2',
}),
)}
disabled={input.length === 0}
>
<Send 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('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: 'fumadocs',
};
function Message({ message, ...props }: { message: UIMessage } & ComponentProps<'div'>) {
let markdown = '';
const searchCalls: UIToolInvocation<SearchTool>[] = [];
for (const part of message.parts ?? []) {
if (part.type === 'text') {
markdown += part.text;
continue;
}
if (part.type.startsWith('tool-')) {
const toolName = part.type.slice('tool-'.length);
const p = part as UIToolInvocation<Tool>;
if (toolName !== 'search' || !p.toolCallId) continue;
searchCalls.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>
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
{searchCalls.map((call) => {
return (
<div
key={call.toolCallId}
className="flex flex-row gap-2 items-center mt-3 rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs p-2"
>
<SearchIcon className="size-4" />
{call.state === 'output-error' || call.state === 'output-denied' ? (
<p className="text-fd-error">{call.errorText ?? 'Failed to search'}</p>
) : (
<p>{!call.output ? 'Searching…' : `${call.output.length} search results`}</p>
)}
</div>
);
})}
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat({
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-3 w-24 end-[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();
useHotKey();
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>
<Presence present={open}>
<div
data-state={open ? 'open' : 'closed'}
className="fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out lg:hidden"
onClick={() => setOpen(false)}
/>
</Presence>
<Presence present={open}>
<div
className={cn(
'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:top-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
>
<div className="flex flex-col size-full p-2 max-lg:max-h-[80dvh] lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
<AISearchInput />
<div className="flex items-center gap-1.5 p-1 empty:hidden">
<AISearchInputActions />
</div>
</div>
</div>
</div>
</Presence>
</>
);
}
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="text-sm text-fd-muted-foreground/80 size-full flex flex-col items-center justify-center text-center gap-2">
<MessageCircleIcon fill="currentColor" stroke="none" />
<p onClick={(e) => e.stopPropagation()}>Start a new chat below.</p>
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
))}
</div>
)}
</List>
);
}
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.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;
}
@@ -1,4 +1,4 @@
import { CodeBlock } from '@/components/code-block';
import { ServerCodeBlock } from '@hanzo/docs-ui/components/codeblock.rsc';
export function Customisation() {
return (
@@ -7,13 +7,7 @@ export function Customisation() {
<p className="text-fd-muted-foreground text-sm">
For advanced customisation that supported options cannot suffice.
</p>
<CodeBlock
code="npx @hanzo-docs/cli@latest customise"
lang="bash"
wrapper={{
className: 'my-0',
}}
/>
<ServerCodeBlock code="npx @hanzo/docs-cli@latest customise" lang="bash" />
</div>
);
}
@@ -1,5 +1,5 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@hanzo/docs-base-ui/components/ui/tabs';
import { CodeBlock } from '@/components/code-block';
import { ServerCodeBlock } from '@hanzo/docs-ui/components/codeblock.rsc';
export function Installation({ name }: { name: string }) {
const tabs = [{ name: 'Hanzo Docs CLI', value: 'hanzo-docs-cli' }];
@@ -23,7 +23,7 @@ export function Installation({ name }: { name: string }) {
</TabsList>
<TabsContent value="hanzo-docs-cli">
<CodeBlock code={`npx @hanzo-docs/cli@latest add ${name}`} lang="bash" />
<ServerCodeBlock code={`npx @hanzo/docs-cli@latest add ${name}`} lang="bash" />
</TabsContent>
</Tabs>
);
+35 -15
View File
@@ -90,37 +90,54 @@ export const registry: Registry = {
],
},
{
name: 'ai/search',
title: 'AI Search (Next.js Only)',
description: 'Ask AI dialog for your docs, you need to configure Inkeep first',
name: 'ai/openrouter',
title: 'AI Chat (Next.js + OpenRouter)',
description: 'Ask AI dialog for your docs, requires OPENROUTER_API_KEY',
files: [
{
type: 'components',
path: 'components/ai/search.tsx',
},
{
type: 'components',
path: 'components/ai/markdown.tsx',
path: 'components/openrouter/search.tsx',
target: '<dir>/ai/search.tsx',
},
{
type: 'route',
path: 'app/api/chat/route.ts',
path: 'lib/openrouter/server.ts',
target: 'app/api/chat/route.ts',
},
],
dependencies: {
flexsearch: '^0.8.212',
},
},
{
name: 'markdown',
unlisted: true,
files: [
{
type: 'lib',
path: 'lib/chat/inkeep-qa-schema.ts',
type: 'components',
path: 'components/markdown.tsx',
},
],
},
{
name: 'ai/page-actions',
title: 'AI Page Actions',
description: 'Common page actions for AI',
name: 'ai/inkeep',
title: 'AI Chat (Next.js + Inkeep AI)',
description: 'Ask AI dialog for your docs, requires Inkeep AI',
files: [
{
type: 'components',
path: 'components/ai/page-actions.tsx',
path: 'components/inkeep/search.tsx',
target: '<dir>/ai/search.tsx',
},
{
type: 'route',
path: 'lib/inkeep/server.ts',
target: 'app/api/chat/route.ts',
},
{
type: 'lib',
path: 'lib/inkeep/inkeep-qa-schema.ts',
target: '<dir>/ai/inkeep-qa-schema.ts',
},
],
},
@@ -131,14 +148,17 @@ export const registry: Registry = {
{
type: 'lib',
path: 'lib/og/mono.tsx',
target: '<dir>/og/mono.tsx',
},
{
type: 'lib',
path: 'lib/og/JetBrainsMono-Bold.ttf',
target: '<dir>/og/JetBrainsMono-Bold.ttf',
},
{
type: 'lib',
path: 'lib/og/JetBrainsMono-Regular.ttf',
target: '<dir>/og/JetBrainsMono-Regular.ttf',
},
],
},
+178
View File
@@ -0,0 +1,178 @@
---
title: Making a Blog with Fumadocs
description: Fumadocs + Blog
author: Fuma Nama
date: 2024-12-15
---
Fumadocs is a docs framework, but it's also a powerful tool to manage content in Next.js. You can use Fumadocs to build a blog site along with docs, on a single Next.js app.
## Overview
This guide helps you build a blog site with Fumadocs and Fumadocs MDX.
We will use Fumadocs MDX to manage the content, and implement our own UI with Tailwind CSS & Fumadocs UI.
### Configure Content
Define a `blogPosts` collection.
```ts title="source.config.ts"
import { defineCollections } from '@hanzo/docs-mdx/config';
import { pageSchema } from '@hanzo/docs-core/source/schema';
import { z } from 'zod';
export const blogPosts = defineCollections({
type: 'doc',
dir: 'content/blog',
// add required frontmatter properties
schema: pageSchema.extend({
author: z.string(),
date: z.string().date().or(z.date()),
}),
});
```
Parse the output collection in `source.ts`:
```ts title="lib/source.ts"
import { loader } from '@hanzo/docs-core/source';
import { toFumadocsSource } from '@hanzo/docs-mdx/runtime/server';
import { blogPosts } from 'collections/server';
export const blog = loader({
baseUrl: '/blog',
source: toFumadocsSource(blogPosts, []),
});
```
You can now access the content from `blog`.
### Implement UI
Create an index page for blog posts.
By default, there should be a `(home)` route group with `<HomeLayout />` inside.
Let's put the blog pages under it, this way we can get the nice navbar on our blog site.
```tsx title="app/(home)/blog/page.tsx"
import Link from 'next/link';
import { blog } from '@/lib/source';
export default function Home() {
const posts = blog.getPages();
return (
<main className="flex-1 w-full max-w-[1400px] mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Latest Blog Posts</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.url}
href={post.url}
className="block bg-fd-secondary rounded-lg shadow-md overflow-hidden p-6"
>
<h2 className="text-xl font-semibold mb-2">{post.data.title}</h2>
<p className="mb-4">{post.data.description}</p>
</Link>
))}
</div>
</main>
);
}
```
<Callout title='Good to Know'>
Colors like `text-fd-muted-foreground` are from the design system of Fumadocs UI, you may also use your own colors, or use Shadcn UI.
</Callout>
And create a page for blog posts.
Note that blog posts won't have nested slugs like `/slug1/slug2`. We don't need a catch-all route for blog posts.
```tsx title="app/(home)/blog/[slug]/page.tsx"
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { InlineTOC } from '@hanzo/docs-ui/components/inline-toc';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import { blog } from '@/lib/source';
export default async function Page(props: { params: Promise<{ slug: string }> }) {
const params = await props.params;
const page = blog.getPage([params.slug]);
if (!page) notFound();
const Mdx = page.data.body;
return (
<>
<div className="w-full max-w-[1400px] mx-auto px-4 py-12 rounded-xl border md:px-8">
<h1 className="mb-2 text-3xl font-bold">{page.data.title}</h1>
<p className="mb-4 text-fd-muted-foreground">{page.data.description}</p>
<Link href="/blog">Back</Link>
</div>
<article className="w-full max-w-[1400px] mx-auto flex flex-col px-4 py-8">
<div className="prose min-w-0">
<InlineTOC items={page.data.toc} />
<Mdx components={defaultMdxComponents} />
</div>
<div className="flex flex-col gap-4 text-sm">
<div>
<p className="mb-1 text-fd-muted-foreground">Written by</p>
<p className="font-medium">{page.data.author}</p>
</div>
<div>
<p className="mb-1 text-sm text-fd-muted-foreground">At</p>
<p className="font-medium">{new Date(page.data.date).toDateString()}</p>
</div>
</div>
</article>
</>
);
}
export function generateStaticParams(): { slug: string }[] {
return blog.getPages().map((page) => ({
slug: page.slugs[0],
}));
}
```
And configure metadata:
```tsx title="app/(home)/blog/[slug]/page.tsx"
import { notFound } from 'next/navigation';
import { blog } from '@/lib/source';
export async function generateMetadata(props: { params: Promise<{ slug: string }> }) {
const params = await props.params;
const page = blog.getPage([params.slug]);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}
```
### Write Posts
The UI is now complete, you can write some blog posts under the `content/blog` directory, like:
```mdx title="content/blog/hello.mdx"
---
title: Hello World
author: Fuma Nama
date: 2024-12-15
---
## Hello World
This is an example!
```
After spinning up the development server with `next dev`, you should see the blog posts under `/blog` route.
+308
View File
@@ -0,0 +1,308 @@
---
title: Fumadocs MDX 14
description: Performance Improvements & API redesign.
author: Fuma Nama
date: 2025-11-16
---
We are pleased to announce Fumadocs MDX 14. This update introduces several major enhancements designed to:
- Improve flexibility, taking the plugin approach further.
- Improve performance & memory consumption.
- Improve browser/dynamic compilation support.
## New Model
The output is now separated into:
- **`.source/server`:** the content data available for server (e.g. RSC, SSR), should not be referenced in client code.
- **`.source/browser`:** the data available for browser, with built-in serialization, chunking and loader layer.
- **`.source/dynamic`:** interface for dynamic content compilation, only generated for collections with `dynamic: true`.
## Breaking Changes
### Entry Files
Fumadocs MDX now generates multiple entry files in `.source` folder.
To migrate, add/update the path aliases in `tsconfig.json` for new entry files.
```json title="tsconfig.json"
{
"compilerOptions": {
"paths": {
// [!code ++]
"collections/*": [".source/*"]
}
}
}
```
Import your collections from the entry files instead of `@/.source`:
```ts tab="Server/RSC"
// [!code --]
import { docs } from '@/.source';
// [!code ++]
import { docs } from 'collections/server';
// access collections on server-side, e.g.
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
source: docs.toFumadocsSource(),
});
```
```tsx tab="Browser/non-RSC"
// non-RSC environment (e.g. browser)
// [!code --:20]
import { docs } from '@/.source';
const clientLoader = createClientLoader(docs.doc, {
id: 'docs',
component({ toc, frontmatter, default: MDX }) {
return (
<DocsPage toc={toc}>
<DocsTitle>{frontmatter.title}</DocsTitle>
<DocsDescription>{frontmatter.description}</DocsDescription>
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
}}
/>
</DocsBody>
</DocsPage>
);
},
});
// [!code ++:20]
import browserCollections from 'collections/browser';
const clientLoader = browserCollections.docs.createClientLoader({
component({ frontmatter, default: MDX }) {
return (
<DocsPage toc={toc}>
<DocsTitle>{frontmatter.title}</DocsTitle>
<DocsDescription>{frontmatter.description}</DocsDescription>
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
}}
/>
</DocsBody>
</DocsPage>
);
},
});
```
<Callout>
The new browser `createClientLoader()` should work seamlessly with old usage.
See [Browser Entry](/docs/mdx/entry/browser#examples) for more examples of `browserCollections`.
</Callout>
### Fumadocs Adapter
The adapter for `loader()` API is also changed.
For `docs` collection, `create.sourceAsync()` (Vite) is now consistent with Next.js:
```ts
import { docs } from 'collections/server';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
// [!code --]
source: await create.sourceAsync(docs.doc, docs.meta),
// [!code ++]
source: docs.toFumadocsSource(),
});
```
For `doc` collections, `createMDXSource()` (Next.js) is renamed to `toFumadocsSource()`.
If you use `create.sourceAsync()` (Vite) for `doc` collections, you should also switch to `toFumadocsSource()`.
```ts
import { blogPosts } from 'collections/server';
import { toFumadocsSource } from '@hanzo/docs-mdx/runtime/server';
import { loader } from '@hanzo/docs-core/source';
export const blog = loader({
baseUrl: '/blog',
// [!code --] for Next.js
source: createMDXSource(blogPosts, []),
// [!code --] for Vite
source: await create.sourceAsync(blogPosts, []),
// [!code ++]
source: toFumadocsSource(blogPosts, []),
});
```
### Async & Dynamic Mode
On Next.js, **Async Mode** now aligns with the behaviour on Vite to generate async imports. This enables server-side chunking to improve memory usage.
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
// This no longer forces dynamic compilation! [!code ++]
async: true,
},
});
```
Instead, we introduced a `dynamic` option to enable dynamic compilation. This is available for both Vite and Next.js.
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
// [!code ++]
dynamic: true,
},
});
```
By enabling Async or Dynamic modes, the relevant collections are considered lazy-loaded, offering a `load()` function to load compiled properties.
### Updated Signature for `postInstall()`
It now accepts the framework plugin options, you can customise more properties:
```ts tab="Before"
import { postInstall } from '@hanzo/docs-mdx/next';
// or
import { postInstall } from '@hanzo/docs-mdx/vite';
postInstall('source.config.ts');
```
```ts tab="After"
import { postInstall } from '@hanzo/docs-mdx/next';
// or
import { postInstall } from '@hanzo/docs-mdx/vite';
postInstall({ configPath: 'source.config.ts' });
```
### `getDefaultMDXOptions()` -> `applyMdxPreset()`
To support a broader range of presets and accommodate dynamic modes, `getDefaultMDXOptions()` has been renamed to `applyMdxPreset()`.
```ts title="source.config.ts" tab="Before"
import { defineCollections, getDefaultMDXOptions } from '@hanzo/docs-mdx/config';
import { myPlugin } from './remark-plugin';
export const blog = defineCollections({
type: 'doc',
mdxOptions: getDefaultMDXOptions({
remarkPlugins: [myPlugin],
// Alternatively, use a function to customize plugin order:
remarkPlugins: (v) => [myPlugin, ...v],
}),
});
```
```ts title="source.config.ts" tab="After"
import { defineCollections, applyMdxPreset } from '@hanzo/docs-mdx/config';
import { myPlugin } from './remark-plugin';
export const blog = defineCollections({
type: 'doc',
mdxOptions: applyMdxPreset({
remarkPlugins: [myPlugin],
// Alternatively, use a function to customize plugin order:
remarkPlugins: (v) => [myPlugin, ...v],
}),
});
```
### Introduction of `lastModified` Plugin
**The `lastModifiedTime` option has been replaced by a dedicated `lastModified` plugin.**
This shift promotes a plugin-based architecture for better extensibility.
```ts title="source.config.ts"
import { defineConfig } from '@hanzo/docs-mdx/config';
import lastModified from '@hanzo/docs-mdx/plugins/last-modified';
export default defineConfig({
// [!code ++]
plugins: [lastModified()],
});
```
### Removal of Multiple `dir` Support
Support for specifying multiple directories in a single collection has been discontinued. Instead, utilize the `files` property to filter and include specific files.
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/guides',
docs: {
files: ['./i-love-fumadocs/**/*.{md,mdx}'],
},
});
```
### Disabling Default Generation of `extractedReferences`
The automatic generation of `extractedReferences` is no longer enabled by default. To retain this functionality, explicitly activate it via the `postprocess` option.
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
postprocess: {
// [!code ++]
extractLinkReferences: true,
},
},
});
```
### Vite: `generateIndexFile` -> `index`
The `generateIndexFile` option of our Vite plugin has been renamed to `index`.
```ts title="vite.config.ts"
import { defineConfig } from 'vite';
import mdx from '@hanzo/docs-mdx/vite';
import * as MdxConfig from './source.config';
export default defineConfig({
plugins: [
mdx(MdxConfig, {
index: {
// options
},
}),
],
});
```
## Patch Changes
- Resolved an issue with meta file validation when using Bun.
- Introduced optimizations to reduce unnecessary entry files re-generations.
Thank you for your continued support of Fumadocs. If you encounter any issues during the update, welcome to reach out through our GitHub Discussions.
@@ -0,0 +1,105 @@
---
title: Access Control
description: Limit the access of content.
---
## Overview
Thanks to the flexible design, it is simple to implement access control with Fumadocs.
## Loader API
If you are using [`loader()`](/docs/headless/source-api) API, you can create the loaders on-demand, and filter files from content source to restrict accessible content.
For example, this setup with Fumadocs MDX allows only MDX files with frontmatter `permission: public` to be shown.
```ts title="lib/source.ts"
import { docs } from 'collections/server';
import { loader, update } from '@hanzo/docs-core/source';
const filteredSource = update(docs.toFumadocsSource())
.files((files) =>
files.filter((file) => {
// keep meta files (e.g. `meta.json`)
if (file.type === 'meta') return true;
// access file data (type-safe)
return file.data.permission === 'public';
}),
)
.build();
export const source = loader(filteredSource, {
baseUrl: '/docs',
});
```
Or with more complicated setup, you may turn `source` into a factory function.
```ts tab="lib/source.ts"
import { docs } from 'collections/server';
import { loader, update } from '@hanzo/docs-core/source';
// uncached, it's better to cache it in-memory for real use case.
export function createSource(permission: 'public' | 'admin') {
const filteredSource = update(docs.toFumadocsSource())
.files((files) =>
files.filter((file) => {
if (file.type === 'meta') return true;
return file.data.permission === permission;
}),
)
.build();
return loader(filteredSource, {
baseUrl: '/docs',
});
}
```
```ts tab="References"
// [!code --]
import { source } from '@/lib/source';
// [!code ++:4]
import { createSource } from '@/lib/source';
const user = await getUser();
const source = createSource(user.permission);
const page = source.getPage(params.slugs);
```
### Advantages
What is special with this approach is that **the access is limited at input level**, rather than at rendering phase or build-time.
This guarantees the protected content is filtered entirely, and without compromise on flexibility.
## Custom Implementation
Without using Loader API, you can also implement your access control at framework-level (e.g. via Next.js routing & proxy).
For example, to limit access to certain pages:
```tsx title="page.tsx"
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
export default async function Page({ params }) {
const { slugs } = await params;
const user = await getUser();
const page = source.getPage(slugs);
if (!page) notFound();
if (page.data.permission !== user.permission) notFound();
// render page...
}
```
Note that it is up to you to manage the page tree (sidebar items), search and other details.
This approach offers the best flexibility, but it doesn't rely on Fumadocs itself for access control, the setup may take longer and be more complicated.
It is better to consult the docs of your React.js framework when implementing.
@@ -0,0 +1,139 @@
---
title: Export EPUB
description: Export your documentation to EPUB format for e-readers
---
## Introduction
The EPUB integration lets you export your Fumadocs documentation as an EPUB file, making it easy for readers to download and read your docs on e-readers, tablets, or any EPUB-compatible app.
It converts your MDX content to HTML, resolves images, and produces a standards-compliant EPUB with a table of contents.
## Setup
### Installation
```npm
npm install @hanzo/docs-epub
```
### Enable Processed Markdown
EPUB export requires `includeProcessedMarkdown` in your docs collection config:
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
docs: {
postprocess: {
// [!code ++]
includeProcessedMarkdown: true,
},
},
});
```
### Create Export Route
Create a route handler to serve the EPUB. For Next.js:
```ts title="app/export/epub/route.ts"
import { source } from '@/lib/source';
import { exportEpub } from '@hanzo/docs-epub';
export const revalidate = false;
export async function GET(): Promise<Response> {
const buffer = await exportEpub({
source,
title: 'My Documentation',
author: 'My Team',
description: 'Documentation for my project',
cover: '/cover.png',
});
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/epub+zip',
'Content-Disposition': 'attachment; filename="docs.epub"',
},
});
}
```
<Callout type="warning" title="Protect the endpoint">
Consider protecting `/export/epub` in production. For example, require an `Authorization: Bearer <secret>` header and set `EXPORT_SECRET` in your environment. The Fumadocs CLI scaffolds a route with this protection when you run `fumadocs export epub --scaffold-only`.
</Callout>
### Using the CLI
You can also export EPUB via the Fumadocs CLI:
```bash
fumadocs export epub --framework next
```
This fetches from your running server (Next.js) or copies from the build output (other frameworks). Run a production build first for non-Next.js frameworks.
## Options
The config passed to `exportEpub` supports these options:
| Option | Type | Description |
| -------------- | -------------------- | ------------------------------------------------------------------------------- |
| `title` | `string` | **Required.** Book title |
| `author` | `string \| string[]` | Author name(s). Default: `'anonymous'` |
| `description` | `string` | Book description |
| `language` | `string` | Language code (e.g. `'en'`). Default: `'en'` |
| `publisher` | `string` | Publisher name. Default: `'anonymous'` |
| `isbn` | `string` | ISBN |
| `cover` | `string` | Cover image. Supports `file://`, `http(s)://`, `/public/...`, or relative paths |
| `outputPath` | `string` | If set, writes the EPUB to file in addition to returning the buffer |
| `includePages` | `(page) => boolean` | Filter: include only pages where this returns `true` |
| `excludePages` | `(page) => boolean` | Filter: exclude pages where this returns `true` |
| `css` | `string` | Custom CSS for the EPUB. Uses `defaultEpubStyles` if omitted |
| `publicDir` | `string` | Public directory for resolving `/public/...` image paths. Default: `./public` |
### Custom CSS
You can override the default EPUB styles:
```ts
import { exportEpub, defaultEpubStyles } from '@hanzo/docs-epub';
const buffer = await exportEpub({
source,
title: 'My Docs',
author: 'Me',
css: `${defaultEpubStyles}
/* Custom overrides */
body { font-size: 1.1em; }
`,
});
```
### Filtering Pages
Use `includePages` and `excludePages` to control which docs are exported:
```ts
const buffer = await exportEpub({
source,
title: 'My Docs',
author: 'Me',
// Only include docs under /docs/getting-started
includePages: (page) => page.path.startsWith('getting-started'),
// Exclude specific paths
excludePages: (page) => page.path === 'changelog',
});
```
### Image Resolution
Images in your MDX are resolved automatically:
- **Relative paths** (e.g. `./image.png`) — resolved relative to the page file
- **Public paths** (e.g. `/og.png`) — resolved from the `public` directory
- **Remote URLs** (e.g. `https://...`) — embedded as-is
@@ -0,0 +1,101 @@
---
title: Export PDF
description: Export docs pages as PDF documents
---
## Overview
In general, we strongly recommend you to download the entire website (HTML files, etc) for offline browsing.
In case if you want to export the page as a PDF, you can follow this guide.
## Setup
Use Puppeteer to export PDF files.
```ts title="scripts/export-pdf.ts"
import puppeteer from 'puppeteer';
import fs from 'node:fs/promises';
import path from 'node:path';
const browser = await puppeteer.launch();
const outDir = 'pdfs';
// update this [!code highlight]
const urls = ['/docs/ui', '/docs/ui/customisations'];
async function exportPdf(pathname: string) {
const page = await browser.newPage();
await page.goto('http://localhost:3000' + pathname, {
waitUntil: 'networkidle2',
});
await page.pdf({
path: path.join(outDir, pathname.slice(1).replaceAll('/', '-') + '.pdf'),
width: 950,
printBackground: true,
});
console.log(`PDF generated successfully for ${pathname}`);
await page.close();
}
await fs.mkdir(outDir, { recursive: true });
await Promise.all(urls.map(exportPdf));
await browser.close();
```
Add the following to your Tailwind CSS file to disable navigation elements when printing:
```css
@media print {
#nd-docs-layout {
--fd-sidebar-width: 0px !important;
}
#nd-sidebar {
display: none;
}
}
```
You can now run the script:
```bash
bun ./scripts/export-pdf.ts
```
### Invisible Contents
For invisible contents in accordions/tabs, you may need to temporarily override the MDX components. For example:
```tsx title="components/mdx.tsx"
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { Accordion, Accordions } from '@hanzo/docs-ui/components/accordion';
// you may use environment variable here [!code highlight]
const isPrinting = true;
function PrintingAccordion(props: React.ComponentProps<typeof Accordion>) {
return (
<div>
<h3>{props.title}</h3>
{props.children}
</div>
);
}
function PrintingAccordions(props: React.ComponentProps<typeof Accordions>) {
return <div>{props.children}</div>;
}
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
// updated accordions:
Accordion: isPrinting ? PrintingAccordion : Accordion,
Accordions: isPrinting ? PrintingAccordions : Accordions,
...components,
} satisfies MDXComponents;
}
```
@@ -0,0 +1,264 @@
---
title: Quick Start
description: Getting Started with Fumadocs
icon: Rocket
---
import { CpuIcon, PanelsTopLeftIcon, DatabaseIcon, TerminalIcon } from 'lucide-react';
## Introduction
Fumadocs <span className='text-fd-muted-foreground text-sm'>(Foo-ma docs)</span> is a **documentation framework**, designed to be fast, flexible,
and composes seamlessly into your React framework.
Fumadocs has different parts:
<Cards>
<Card icon={<CpuIcon className="text-purple-300" />} title='Fumadocs Core' href='/docs/headless'>
Handles most of the logic, including document search, content source adapters, and Markdown extensions.
</Card>
<Card icon={<PanelsTopLeftIcon className="text-blue-300" />} title='Fumadocs UI' href='/docs/ui'>
The default theme of Fumadocs offers a beautiful look for documentation sites and interactive components.
</Card>
<Card icon={<DatabaseIcon />} title='Content Source'>
The source of your content, can be a CMS or local data layers like [Fumadocs MDX](/docs/mdx) (the official content source).
</Card>
<Card icon={<TerminalIcon />} title='Fumadocs CLI' href='/docs/cli'>
A command line tool to install UI components and automate things, useful for customizing layouts.
</Card>
</Cards>
<Callout title="Want to learn more?">
Read our in-depth [What is Fumadocs](/docs/what-is-fumadocs) introduction.
</Callout>
### Terminology
**Markdown/MDX:** Markdown is a markup language for creating formatted text. Fumadocs natively supports Markdown and MDX (superset of Markdown).
**[Bun](https://bun.sh):** A JavaScript runtime, we use it for running scripts.
Some basic knowledge of React.js would be useful for further customisations.
## Automatic Installation
A minimum version of Node.js 22 required.
```npm
npm create fumadocs-app
```
It will ask you the built-in template to use:
- **React.js framework**: Next.js, Waku, React Router, Tanstack Start.
- **Content source**: Fumadocs MDX.
A new fumadocs app should be initialized, with following features configured by default:
- [LLM integration](/docs/integrations/llms).
- [Dynamic metadata image](/docs/integrations/og).
<Callout title='From Existing Codebase?'>
You can follow the [Manual Installation](/docs/manual-installation) guide to get started.
</Callout>
### Enjoy!
Create your first MDX file in the docs folder.
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
## Yo what's up
```
Run the app in development mode and see http://localhost:3000/docs.
```npm
npm run dev
```
## FAQ
Some common questions you may encounter.
<Accordions>
<Accordion id='upgrade-fumadocs' title="Getting error with missing APIs or bugs?">
Make sure to upgrade Fumadocs when you've encountered any problems or trying out new features:
```bash title="pnpm"
pnpm update -i -r --latest
```
</Accordion>
<Accordion id='change-base-url' title="How to change the base route of docs?">
Routing is handled by your React framework, you need to change the routing structure first.
For example, in Next.js, rename the route (`/docs/*` -> `/info/*`):
<Files>
<Folder name="app/docs" defaultOpen className="opacity-50" disabled>
<File name="layout.tsx" />
</Folder>
<Folder name="app/info" defaultOpen>
<File name="layout.tsx" />
</Folder>
</Files>
Or rename from `/docs/*` to `/*` using a route group:
<Files>
<Folder name="app/(docs)" defaultOpen>
<File name="layout.tsx" />
</Folder>
</Files>
Finally, update the base URL of pages in `source.ts`:
```ts title="lib/source.ts"
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/info', // to the new value [!code highlight]
});
```
</Accordion>
<Accordion id='multi-docs' title="How to implement multi-docs?">
We recommend to use [Sidebar Tabs](/docs/ui/layouts/docs#sidebar-tabs).
</Accordion>
</Accordions>
### For Vite
<Accordions>
<Accordion id='vite-context-error' title="Getting error with React contexts?">
There's some weird pre-bundling problems with Vite: [#3910](https://github.com/vitejs/vite/issues/3910).
Make sure to exclude Fumadocs from pre-bundling and add it to `noExternal`:
```ts title="vite.config.ts"
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
// add other Fumadocs deps as needed
noExternal: ['@hanzo/docs-core', '@hanzo/docs-ui', '@hanzo/docs-openapi', '@hanzo/docs-base-ui'],
},
});
```
</Accordion>
</Accordions>
### For Next.js
<Accordions>
<Accordion id='node-23' title="Getting error on production build?">
Node.js 23.1 might have problems with Next.js, see [#1021](https://github.com/fuma-nama/fumadocs/issues/1021). Make sure to change your Node.js version.
</Accordion>
<Accordion id='dynamic-route' title="It uses Dynamic Route, will it be poor in performance?">
Next.js turns dynamic route into static routes when `generateStaticParams` is configured.
Hence, it is as fast as static pages.
You can [enable Static Exports](/docs/deploying/static) on Next.js to get a static build output.
</Accordion>
<Accordion id='custom-layout-docs-page' title='How to create a page in /docs without docs layout?'>
Same as managing layouts in Next.js App Router, remove the original MDX file from content directory (`/content/docs`).
This ensures duplicated pages will not cause errors.
Now, You can add the page to another route group, which isn't a descendant of docs layout.
For example, to replace `/docs/test`:
<Files>
<File name="(home)/docs/test/page.tsx" />
<Folder name="docs" defaultOpen>
<File name="layout.tsx" />
<File name="[[...slug]]/page.tsx" />
</Folder>
</Files>
For `/docs`, you need to change the catch-all route to be non-optional:
<Files>
<File name="(home)/docs/page.tsx" />
<Folder name="docs" defaultOpen>
<File name="layout.tsx" />
<File name="[...slug]/page.tsx" />
</Folder>
</Files>
</Accordion>
</Accordions>
## Learn More
New to here? Don't worry, we are welcome for your questions.
If you find anything confusing, please give your feedback on [Github Discussion](https://github.com/fuma-nama/fumadocs/discussions)!
### Writing Content
For authoring docs, make sure to read:
<Cards>
<Card href="/docs/markdown" title="Markdown">
Fumadocs has some additional features for authoring content.
</Card>
<Card href="/docs/navigation" title="Navigation">
Learn how to customise navigation structure.
</Card>
<Card href="/docs/page-conventions" title="Page Slugs & Page Tree">
Learn how to organise content.
</Card>
<Card
href="/docs/ui/components"
title="Components"
description="See all available components to enhance your docs"
/>
</Cards>
### Special Needs
<Cards>
<Card
href="/docs/deploying/static"
title="Configure Static Export"
description="Learn how to enable static export on your docs"
/>
<Card
href="/docs/internationalization"
title="Internationalization"
description="Learn how to enable i18n"
/>
<Card href="/docs/ui/theme" title="Color Themes" description="Add themes to Fumadocs UI" />
<Card href="/docs/ui/layouts" title="Layouts" description="Customise your Fumadocs UI layouts" />
</Cards>
@@ -0,0 +1,66 @@
---
title: Obsidian
description: Render your Obsidian vaults in Fumadocs.
---
<Callout type="warn" title="Experimental">
Might have bugs or breaking changes, use it at your own risk.
</Callout>
## Setup
```npm
npm i @hanzo/docs-obsidian
```
You can copy your vault folder to the project (e.g. root directory):
<Files>
<Folder name="Obsidian Vault" defaultOpen>
<File name="Introduction.md" />
<File name="Folder/Hello World.md" />
</Folder>
<File name="package.json" />
</Files>
Create a script to generate docs & assets:
```ts title="scripts/generate.ts"
import { fromVault } from '@hanzo/docs-obsidian';
await fromVault({
dir: 'Obsidian Vault',
out: {
// you can specify the locations of `/public` & `/content/docs` folder
},
});
```
Run the script to generate docs:
```bash
bun scripts/generate.ts
```
Finally, include necessary MDX components:
```tsx title="components/mdx.tsx"
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import * as ObsidianComponents from '@hanzo/docs-obsidian/ui';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...ObsidianComponents,
...components,
} satisfies MDXComponents;
}
```
### Additions
Some syntax features need to be enabled separately:
- [Mermaid](/docs/markdown/mermaid).
- [Math](/docs/markdown/math).
@@ -0,0 +1,63 @@
---
title: MDX Remote
description: Compiled Markdown/MDX content on demand.
---
## Introduction
Fumadocs offers the **MDX Remote** package, it is a helper to integrate Markdown/MDX content sources with Fumadocs.
You can think it as a `next-mdx-remote` with built-in plugins for Fumadocs.
<Callout type='warn' title="Be Careful">
The Passed MDX content must be trusted as it allows code execution by default.
</Callout>
### Setup
```package-install
@hanzo/docs-mdx-remote
```
The main feature it offers is the MDX Compiler, it can compile MDX content to JSX nodes.
Since it doesn't use a bundler, there's some limitations:
- No imports and exports in MDX files.
It's compatible with Server Components. For example:
```tsx
import { createCompiler } from '@hanzo/docs-mdx-remote';
import { getPage } from './my-content-source';
import { DocsBody, DocsPage } from '@hanzo/docs-ui/layouts/docs/page';
import { getMDXComponents } from '@/components/mdx';
const compiler = createCompiler({
// options
});
export default async function Page({ params }: { params: { slug?: string[] } }) {
const page = getPage(params.slug);
const compiled = await compiler.compile({
source: page.content,
});
const MdxContent = compiled.body;
return (
<DocsPage toc={compiled.toc}>
<DocsBody>
<MdxContent components={getMDXComponents()} />
</DocsBody>
</DocsPage>
);
}
```
### Images
On serverless platforms like Vercel, the original `public` folder (including static assets like images) will be removed after production build.
The MDX compiler might no longer be able to access local images in `public`.
When referencing images, make sure to use a URL.
@@ -76,12 +76,12 @@ export default defineConfig({
Then, define the `FeedbackBlock` MDX component:
```tsx title="mdx-components.tsx"
```tsx title="components/mdx.tsx"
import { FeedbackBlock } from '@/components/feedback/client';
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents): MDXComponents {
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
FeedbackBlock: ({ children, ...rest }) => (
@@ -90,7 +90,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
</FeedbackBlock>
),
...components,
};
} satisfies MDXComponents;
}
```
@@ -39,6 +39,71 @@ export const docs = defineDocs({
});
```
### `llms.txt`
You can generate it from page tree using Loader API.
```ts tab="Next.js" title="app/llms.txt/route.ts"
import { source } from '@/lib/source';
import { llms } from '@hanzo/docs-core/source';
// cached forever
export const revalidate = false;
export function GET() {
return new Response(llms(source).index());
}
```
```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('llms.txt', 'routes/llms.ts'),
] satisfies RouteConfig;
```
```ts tab="React Router" title="app/routes/llms.ts"
import { source } from '@/lib/source';
import { llms } from '@hanzo/docs-core/source';
export function loader() {
return new Response(llms(source).index());
}
```
```ts tab="Tanstack Start" title="src/routes/llms[.]txt.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { llms } from '@hanzo/docs-core/source';
export const Route = createFileRoute('/llms.txt')({
server: {
handlers: {
GET() {
return new Response(llms(source).index());
},
},
},
});
```
```ts tab="Waku" title="pages/_api/llms.txt.ts"
import { source } from '@/lib/source';
import { llms } from '@hanzo/docs-core/source';
export function GET() {
return new Response(llms(source).index());
}
export async function getConfig() {
return {
render: 'static' as const,
} as const;
}
```
### `llms-full.txt`
A version of docs for AIs to read.
@@ -97,6 +162,24 @@ export const Route = createFileRoute('/llms-full.txt')({
});
```
```ts tab="Waku" title="pages/_api/llms-full.txt.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
export async function getConfig() {
return {
render: 'static' as const,
} as const;
}
```
### `*.mdx` [#mdx-extension]
Allow AI agents to get the content of a page as Markdown/MDX, by appending `.mdx` to the end of path.
@@ -203,6 +286,7 @@ export default {
```ts tab="Tanstack Start" title="src/routes/llms[.]mdx.docs.$.ts"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
export const Route = createFileRoute('/llms.mdx/docs/$')({
server: {
@@ -212,7 +296,7 @@ export const Route = createFileRoute('/llms.mdx/docs/$')({
const page = source.getPage(slugs);
if (!page) throw notFound();
return new Response(await page.data.getText('processed'), {
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
@@ -284,11 +368,14 @@ npx @hanzo/docs-cli add ai/page-actions
Use it in your docs page like:
```tsx title="app/docs/[[...slug]]/page.tsx"
// URL to fetch Markdown content, only need to append .mdx to URL if you have `*.mdx` configured.
const markdownUrl = `${page.url}.mdx`
<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
<LLMCopyButton markdownUrl={markdownUrl} />
<ViewOptions
markdownUrl={`${page.url}.mdx`}
githubUrl={`https://github.com/${owner}/${repo}/blob/dev/apps/docs/content/docs/${page.path}`}
markdownUrl={markdownUrl}
githubUrl={`https://github.com/${owner}/${repo}/blob/main/content/docs/${page.path}`}
/>
</div>
```
@@ -297,56 +384,70 @@ Use it in your docs page like:
![AI Search](/docs/ai-search.png)
You can install the AI search dialog using Fumadocs CLI:
You can install the AI chat dialog using Fumadocs CLI.
<Tabs items={['OpenRouter', 'Inkeep AI']}>
<Tab>
```npm
npx @hanzo/docs-cli add ai/search
npx @hanzo/docs-cli add ai/openrouter
```
By default, it's configured for [Inkeep AI](https://inkeep.com) using Vercel AI SDK.
It's automatically configured for [OpenRouter](https://openrouter.ai) using Vercel AI SDK, with a `/search` tool for AI.
To setup for Inkeep AI:
</Tab>
<Tab>
1. Add your Inkeep API key to environment variables:
```npm
npx @hanzo/docs-cli add ai/inkeep
```
```dotenv
INKEEP_API_KEY="..."
```
It's automatically configured for [Inkeep AI](https://inkeep.com) using Vercel AI SDK.
2. Add the component & trigger to root layout (or anywhere you prefer):
Add your Inkeep API key to environment variables:
```tsx
import { AISearch, AISearchPanel, AISearchTrigger } from '@/components/ai/search';
import { MessageCircleIcon } from 'lucide-react';
import { buttonVariants } from '@/components/button';
```dotenv
INKEEP_API_KEY="..."
```
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* [!code ++:17] */}
<AISearch>
<AISearchPanel />
<AISearchTrigger
position="float"
className={cn(
buttonVariants({
variant: 'secondary',
className: 'text-fd-muted-foreground rounded-2xl',
}),
)}
>
<MessageCircleIcon className="size-4.5" />
Ask AI
</AISearchTrigger>
</AISearch>
</Tab>
</Tabs>
{children}
</body>
</html>
);
}
```
Add the component & trigger to root layout (or anywhere you prefer):
```tsx
import { AISearch, AISearchPanel, AISearchTrigger } from '@/components/ai/search';
import { MessageCircleIcon } from 'lucide-react';
// or import your own button styles
import { buttonVariants } from '@hanzo/docs-ui/components/ui/button';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* [!code ++:17] */}
<AISearch>
<AISearchPanel />
<AISearchTrigger
position="float"
className={cn(
buttonVariants({
variant: 'secondary',
className: 'text-fd-muted-foreground rounded-2xl',
}),
)}
>
<MessageCircleIcon className="size-4.5" />
Ask AI
</AISearchTrigger>
</AISearch>
{children}
</body>
</html>
);
}
```
To use your own AI models, update the configurations in `useChat` and `/api/chat` route.
@@ -0,0 +1,13 @@
---
title: OG Image Generation
description: Generate dynamic preview image for docs pages
index: true
---
You can configure [Open Graph](https://ogp.me) image metadata, which defines the preview image of docs pages in website/social media, such as:
<div className='mx-auto w-full max-w-[400px]'>
![preview](./preview.png)
</div>
@@ -0,0 +1,67 @@
---
title: next/og
description: Usage with Next.js Metadata API.
---
> Make sure to read their [Metadata section](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) for the fundamentals of Metadata API.
## Metadata Image
You can generate metadata images dynamically using `next/og`.
Add the following under your loader, and define image metadata for pages:
```ts tab="lib/source.ts"
import { type InferPageType } from '@hanzo/docs-core/source';
// [!code ++:8]
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
```
```tsx tab="app/docs/[[...slug]]/page.tsx"
import { notFound } from 'next/navigation';
import { source, getPageImage } from '@/lib/source';
import type { Metadata } from 'next';
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
openGraph: {
// [!code ++]
images: getPageImage(page).url,
},
};
}
```
> We append `image.png` to the end of slugs so that we can access it via `/og/docs/my-page/image.png`.
Finally, create a route handler to generate images at build time:
<include cwd meta='title="app/og/docs/[...slug]/route.tsx"'>
../../examples/next/app/og/docs/[...slug]/route.tsx
</include>
You can specify options for Satori (used by `next/og`), see https://github.com/vercel/satori for reference.
### Other Presets
There's other available styles on Fumadocs CLI, such as `mono`:
```npm
npx @hanzo/docs-cli@latest add og/mono
```
Replace your old `generate` with the installed one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,188 @@
---
title: <APIPage />
description: The component for rendering OpenAPI docs content
---
## Overview
Fumadocs OpenAPI uses a `<APIPage />` component to render page contents.
When customising it, the options are split into server/client configs.
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
client,
// server config
});
```
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from '@hanzo/docs-openapi/ui/client';
export default defineClientConfig({
// client config
});
```
### Generate Code Usages
Generate code usage examples for each API endpoint.
```ts title="lib/openapi/code-usage.ts" twoslash
import {
createCodeUsageGeneratorRegistry,
type CodeUsageGenerator,
} from '@hanzo/docs-openapi/requests/generators';
import { registerDefault } from '@hanzo/docs-openapi/requests/generators/all';
export const codeUsages = createCodeUsageGeneratorRegistry();
// include defaults
registerDefault(codeUsages);
// add custom generators
codeUsages.add('custom-id', {
label: 'My Example',
lang: 'js',
generate(url, data, { mediaAdapters }) {
// request data
console.log(url, data);
return 'const response = "hello world";';
},
});
```
Pass the registry to both client & server configs:
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
import { codeUsages } from '@/lib/openapi/code-usage';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
client,
// [!code ++]
codeUsages,
});
```
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from '@hanzo/docs-openapi/ui/client';
import { codeUsages } from '@/lib/openapi/code-usage';
export default defineClientConfig({
// [!code ++]
codeUsages,
});
```
In addition, you can also specify code samples via OpenAPI schema.
```yaml
paths:
/plants:
get:
x-codeSamples:
- lang: js
label: JavaScript SDK
source: |
const planter = require('planter');
planter.list({ unwatered: true });
```
### Internationalization
Assuming you have configured [Internationalization](/docs/internationalization) at UI level:
```tsx title="layout.shared.tsx"
import { defineI18n } from '@hanzo/docs-core/i18n';
import { defineI18nUI } from '@hanzo/docs-ui/i18n';
import { defineI18nOpenAPI } from '@hanzo/docs-openapi/i18n';
const i18n = defineI18n({
languages: ['en', 'cn'],
defaultLanguage: 'en',
});
export let i18nUI = defineI18nUI(i18n, {
cn: { displayName: 'Chinese' },
});
i18nUI = defineI18nOpenAPI(i18nUI, {
cn: {
titleRequestBody: '請求正文',
},
});
```
## Media Adapters
You can create a media adapter to support other media types in API pages, a media adapter implements:
- Converting value into `fetch()` body compatible with corresponding media type.
- Generate code example based on different programming language/tool.
Put your media adapters in a separate file.
```ts title="lib/openapi/media.ts" twoslash
import type { MediaAdapter } from '@hanzo/docs-openapi';
export const mediaAdapters: Record<string, MediaAdapter> = {
// example: custom `application/json
'application/json': {
encode(data) {
return JSON.stringify(data.body);
},
// returns code that inits a `body` variable, used for request body
generateExample(data, ctx) {
if (ctx.lang === 'js') {
return `const body = "hello world"`;
}
if (ctx.lang === 'python') {
return `body = "hello world"`;
}
if (ctx.lang === 'go' && 'addImport' in ctx) {
ctx.addImport('strings');
return `body := strings.NewReader("hello world")`;
}
},
},
};
```
Pass the adapter to both client & server configs.
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
import { mediaAdapters } from '@/lib/openapi/media';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
client,
// [!code ++]
mediaAdapters,
});
```
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from '@hanzo/docs-openapi/ui/client';
import { mediaAdapters } from '@/lib/openapi/media';
export default defineClientConfig({
// [!code ++]
mediaAdapters,
});
```
@@ -0,0 +1,213 @@
---
title: OpenAPI
description: Generating docs for OpenAPI schema
---
<Callout type="warning" title="Before Reading">
- Only OpenAPI 3.0 and 3.1 are supported.
- It only works under RSC
environments.
</Callout>
## Manual Setup
Install the required packages.
```npm
npm i @hanzo/docs-openapi shiki
```
<Callout title="For Bun PM" type="warning">
If you encountered any issues, please check
[#2223](https://github.com/fuma-nama/fumadocs/issues/2223).
</Callout>
### Generate Styles
Add the following line:
```css title="Tailwind CSS"
@import 'tailwindcss';
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
/* [!code ++] */
@import '@hanzo/docs-openapi/css/preset.css';
```
### Configure Plugin
Create an OpenAPI instance on the server.
```ts tab="lib/openapi.ts"
import { createOpenAPI } from '@hanzo/docs-openapi/server';
export const openapi = createOpenAPI({
// the OpenAPI schema, you can also give it an external URL.
input: ['./unkey.json'],
});
```
```ts tab="lib/source.ts"
import { openapiPlugin } from '@hanzo/docs-openapi/server';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
// [!code ++] optional: adds a badge to each page item in page tree
plugins: [openapiPlugin()],
});
```
Create a `APIPage` component:
```tsx tab="components/api-page.tsx"
import { openapi } from '@/lib/openapi';
import { createAPIPage } from '@hanzo/docs-openapi/ui';
import client from './api-page.client';
export const APIPage = createAPIPage(openapi, {
client,
});
```
```tsx tab="components/api-page.client.tsx"
'use client';
import { defineClientConfig } from '@hanzo/docs-openapi/ui/client';
export default defineClientConfig({
// client-side config
});
```
### Generate Pages
<Tabs items={["MDX Files", "Virtual Files"]}>
<Tab>
You can generate MDX files directly from your OpenAPI schema.
Create a script:
```js title="scripts/generate-docs.ts"
import { generateFiles } from '@hanzo/docs-openapi';
import { openapi } from '@/lib/openapi';
void generateFiles({
input: openapi,
output: './content/docs',
// we recommend to enable it
// make sure your endpoint description doesn't break MDX syntax.
includeDescription: true,
});
```
Generate docs with the script:
```bash
bun ./scripts/generate-docs.ts
```
Add the `APIPage` component to your MDX components.
```tsx title="components/mdx.tsx"
import defaultComponents from '@hanzo/docs-ui/mdx';
import { APIPage } from '@/components/api-page';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// should be available in MDX files [!code ++]
APIPage,
...components,
} satisfies MDXComponents;
}
```
</Tab>
<Tab>
You can also use it without generating real files by integrating into [Loader API](/docs/headless/source-api/source).
```ts title="lib/source.ts"
import { loader, multiple } from '@hanzo/docs-core/source';
import { openapiPlugin, openapiSource } from '@hanzo/docs-openapi/server';
import { docs } from 'collections/server';
import { openapi } from '@/lib/openapi';
export const source = loader(
// [!code ++:6]
multiple({
docs: docs.toFumadocsSource(),
openapi: await openapiSource(openapi, {
baseDir: 'openapi',
}),
}),
{
baseUrl: '/docs',
plugins: [openapiPlugin()],
// ...
},
);
```
`openapiSource()` is a server-side API that generates pages directly to your `loader()`, hence allowing dynamic generation (e.g. different page tree as schema changes).
It shares a different type from your original source, explicit handling of OpenAPI pages might be necessary (e.g. in your page component).
```tsx title="docs/[[...slug]]/page.tsx"
import { APIPage } from '@/components/api-page';
function Page() {
const page = source.getPage('...');
// for OpenAPI pages
if (page.data.type === 'openapi') {
return (
<DocsPage full>
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
<DocsBody>
<APIPage {...page.data.getAPIPageProps()} />
</DocsBody>
</DocsPage>
);
}
// your original flow below...
}
```
Or where you return text for LLM:
```ts
import { source } from '@/lib/source';
import type { InferPageType } from '@hanzo/docs-core/source';
export async function getLLMText(page: InferPageType<typeof source>) {
// [!code ++:4]
if (page.data.type === 'openapi') {
// e.g. return the stringified OpenAPI schema
return JSON.stringify(page.data.getSchema().bundled, null, 2);
}
// your original flow below...
}
```
</Tab>
</Tabs>
## Features
The official OpenAPI integration supports:
- Basic API endpoint information
- Interactive API playground
- Example code to send request (in different programming languages)
- Response samples and TypeScript definitions
- Request parameters and body generated from schemas
### Demo
[View demo](/docs/openapi).
@@ -34,6 +34,67 @@ Make sure its behaviour aligns with the [`hidePrefix`](/docs/headless/internatio
</Callout>
### Translations
Define your translations in a file (e.g. `lib/layout.shared.tsx`), the English translations are used when `translations` is not specified.
You can also add `locale` parameter to `baseOptions()` for localized layout options:
```tsx title="lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import { defineI18nUI } from '@hanzo/docs-ui/i18n';
import type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
// [!code ++:9]
export const i18nUI = defineI18nUI(i18n, {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
});
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
```
Pass translations to `<RootProvider />`:
```tsx title="app/[lang]/layout.tsx"
import { RootProvider } from '@hanzo/docs-ui/provider/next';
import { i18nUI } from '@/lib/layout.shared';
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: React.ReactNode;
}) {
const lang = (await params).lang;
return (
<html lang={lang}>
<body>
<RootProvider
// [!code ++]
i18n={i18nUI.provider(lang)}
>
{children}
</RootProvider>
</body>
</html>
);
}
```
### Routing
Create a `/app/[lang]` folder, and move your pages/layouts into it, except route handlers.
@@ -54,72 +115,13 @@ Create a `/app/[lang]` folder, and move your pages/layouts into it, except route
correct!
</Callout>
Provide UI translations and other config to `<RootProvider />`, the English translations are used when `translations` is not specified.
```tsx title="app/[lang]/layout.tsx"
import { RootProvider } from 'fumadocs-ui/provider/next';
import { defineI18nUI } from 'fumadocs-ui/i18n';
import { i18n } from '@/lib/i18n';
// [!code ++:11]
const { provider } = defineI18nUI(i18n, {
translations: {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
},
});
export default async function RootLayout({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: React.ReactNode;
}) {
const lang = (await params).lang;
return (
<html lang={lang}>
<body>
<RootProvider
// [!code ++]
i18n={provider(lang)}
>
{children}
</RootProvider>
</body>
</html>
);
}
```
### Pass Locale
Add `locale` parameter to `baseOptions()` and add `i18n` into it:
```tsx title="lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
i18n, // [!code ++]
// different props based on `locale`
};
}
```
Pass the locale to Fumadocs in your pages and layouts.
```ts tab="lib/source.ts"
import { i18n } from '@/lib/i18n';
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
i18n, // [!code ++]
@@ -129,7 +131,7 @@ export const source = loader({
```tsx tab="Home Layout" title="app/[lang]/(home)/layout.tsx"
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { HomeLayout } from '@hanzo/docs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default async function Layout({
@@ -148,7 +150,7 @@ export default async function Layout({
```tsx tab="Docs Layout" title="app/[lang]/docs/layout.tsx"
import type { ReactNode } from 'react';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default async function Layout({
@@ -226,11 +228,11 @@ const { lang } = useParams();
return <Link href={`/${lang}/another-page`}>This is a link</Link>;
```
In addition, the [`fumadocs-core/dynamic-link`](/docs/headless/components/link#dynamic-hrefs) component supports dynamic hrefs, you can use it to prepend the locale prefix.
In addition, the [`@hanzo/docs-core/dynamic-link`](/docs/headless/components/link#dynamic-hrefs) component supports dynamic hrefs, you can use it to prepend the locale prefix.
It is useful for Markdown/MDX content.
```mdx title="content.mdx"
import { DynamicLink } from 'fumadocs-core/dynamic-link';
import { DynamicLink } from '@hanzo/docs-core/dynamic-link';
<DynamicLink href="/[lang]/another-page">This is a link</DynamicLink>
```
@@ -37,17 +37,6 @@ export default [
You can also use `:lang?` prefix, and update the i18n config:
```ts tab="app/lib/i18n.ts"
import { defineI18n } from '@hanzo/docs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['cn', 'en'],
// [!code ++]
hideLocale: 'default-locale',
});
```
```ts tab="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
@@ -59,33 +48,60 @@ export default [
] satisfies RouteConfig;
```
### Pages
```ts tab="app/lib/i18n.ts"
import { defineI18n } from '@hanzo/docs-core/i18n';
Provide UI translations and other config to `<RootProvider />`, the English translations are used as fallbacks.
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['cn', 'en'],
// [!code ++]
hideLocale: 'default-locale',
});
```
```tsx title="app/root.tsx"
import { Links, Meta, Scripts, ScrollRestoration, useParams } from 'react-router';
import { RootProvider } from '@hanzo/docs-ui/provider/base';
import { ReactRouterProvider } from '@hanzo/docs-core/framework/react-router';
import { defineI18nUI } from '@hanzo/docs-ui/i18n';
### Translations
Define your translations in a file (e.g. `lib/layout.shared.tsx`), the English translations are used when `translations` is not specified.
You can also add `locale` parameter to `baseOptions()` for localized layout options:
```tsx title="lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import './app.css';
import { defineI18nUI } from '@hanzo/docs-ui/i18n';
import type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
// [!code ++:11]
const { provider } = defineI18nUI(i18n, {
translations: {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
// [!code ++:9]
export const i18nUI = defineI18nUI(i18n, {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
});
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
```
### Pages
Pass translations to `<RootProvider />`:
```tsx title="app/root.tsx"
import { Links, Meta, Scripts, ScrollRestoration, useParams } from 'react-router';
import { RootProvider } from '@hanzo/docs-ui/provider/react-router';
import { i18nUI } from '@/lib/layout.shared';
import './app.css';
export function Layout({ children }: { children: React.ReactNode }) {
const { lang = i18n.defaultLanguage } = useParams();
const { lang = i18nUI.defaultLanguage } = useParams();
return (
<html lang="en" suppressHydrationWarning>
@@ -96,14 +112,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links />
</head>
<body className="flex flex-col min-h-screen">
<ReactRouterProvider>
<RootProvider
// [!code ++]
i18n={provider(lang)}
>
{children}
</RootProvider>
</ReactRouterProvider>
<RootProvider
// [!code ++]
i18n={i18nUI.provider(lang)}
>
{children}
</RootProvider>
<ScrollRestoration />
<Scripts />
</body>
@@ -114,20 +128,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
### Pass Locale
Add `locale` parameter to `baseOptions()` and include `i18n` in props:
```tsx title="app/lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
export function baseOptions(locale: string): BaseLayoutProps {
return {
i18n, // [!code ++]
// different props based on `locale`
};
}
```
Pass the locale to Fumadocs in your pages and layouts.
```ts tab="app/lib/source.ts"
@@ -8,7 +8,7 @@ description: Support i18n routing on your Tanstack Start + Fumadocs app.
Define the i18n configurations in a file, we will import it with `@/lib/i18n` in this guide.
```ts title="src/lib/i18n.ts"
import { defineI18n } from 'fumadocs-core/i18n';
import { defineI18n } from '@hanzo/docs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
@@ -18,6 +18,67 @@ export const i18n = defineI18n({
> See [available options](/docs/headless/internationalization/config) for i18n config.
### Translations
Define your translations in a file (e.g. `lib/layout.shared.tsx`), the English translations are used when `translations` is not specified.
You can also add `locale` parameter to `baseOptions()` for localized layout options:
```tsx title="lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import { defineI18nUI } from '@hanzo/docs-ui/i18n';
import type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
// [!code ++:9]
export const i18nUI = defineI18nUI(i18n, {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
});
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
```
Pass translations to `<RootProvider />`:
```tsx title="src/routes/__root.tsx"
import { HeadContent, Scripts, useParams } from '@tanstack/react-router';
import * as React from 'react';
import { RootProvider } from '@hanzo/docs-ui/provider/tanstack';
import { i18nUI } from '@/lib/layout.shared';
function RootDocument({ children }: { children: React.ReactNode }) {
const { lang } = useParams({ strict: false });
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="flex flex-col min-h-screen">
<RootProvider
// [!code ++]
i18n={i18nUI.provider(lang)}
>
{children}
</RootProvider>
<Scripts />
</body>
</html>
);
}
```
### Routing
Create a `$lang` directory under `routes`, and move all pages into it.
@@ -34,74 +95,13 @@ Create a `$lang` directory under `routes`, and move all pages into it.
</Folder>
</Files>
Provide UI translations and other config to `<RootProvider />`, the English translations are used as fallbacks.
```tsx title="src/routes/__root.tsx"
import { HeadContent, Scripts, useParams } from '@tanstack/react-router';
import * as React from 'react';
import { RootProvider } from 'fumadocs-ui/provider/base';
import { TanstackProvider } from 'fumadocs-core/framework/tanstack';
import { defineI18nUI } from 'fumadocs-ui/i18n';
import { i18n } from '@/lib/i18n';
// [!code ++:11]
const { provider } = defineI18nUI(i18n, {
translations: {
cn: {
displayName: 'Chinese',
search: 'Translated Content',
},
en: {
displayName: 'English',
},
},
});
function RootDocument({ children }: { children: React.ReactNode }) {
const { lang } = useParams({ strict: false });
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="flex flex-col min-h-screen">
<TanstackProvider>
<RootProvider
// [!code ++]
i18n={provider(lang)}
>
{children}
</RootProvider>
</TanstackProvider>
<Scripts />
</body>
</html>
);
}
```
### Pass Locale
Add `locale` parameter to `baseOptions()` and include `i18n` in props:
```tsx title="src/lib/layout.shared.tsx"
import { i18n } from '@/lib/i18n';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export function baseOptions(locale: string): BaseLayoutProps {
return {
i18n, // [!code ++]
// different props based on `locale`
};
}
```
Pass the locale to Fumadocs in your pages and layouts.
```ts tab="src/lib/source.ts"
import { i18n } from '@/lib/i18n';
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
i18n, // [!code ++]
@@ -111,7 +111,7 @@ export const source = loader({
```tsx tab="Home Layout" title="src/routes/$lang/index.tsx"
import { createFileRoute } from '@tanstack/react-router';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { HomeLayout } from '@hanzo/docs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export const Route = createFileRoute('/$lang/')({
@@ -128,7 +128,7 @@ function Home() {
```tsx tab="Docs Page" title="src/routes/$lang/docs/$.tsx"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { createServerFn } from '@tanstack/react-start';
import { source } from '@/lib/source';
import { baseOptions } from '@/lib/layout.shared';
@@ -209,11 +209,11 @@ const { lang } = useParams({ from: '/$lang/' });
</Link>;
```
In addition, the [`fumadocs-core/dynamic-link`](/docs/headless/components/link#dynamic-hrefs) component supports dynamic hrefs, you can use it to attend the locale prefix.
In addition, the [`@hanzo/docs-core/dynamic-link`](/docs/headless/components/link#dynamic-hrefs) component supports dynamic hrefs, you can use it to attend the locale prefix.
It is useful for Markdown/MDX content.
```mdx title="content.mdx"
import { DynamicLink } from 'fumadocs-core/dynamic-link';
import { DynamicLink } from '@hanzo/docs-core/dynamic-link';
<DynamicLink href="/[lang]/another-page">This is a link</DynamicLink>
```
@@ -0,0 +1,104 @@
---
title: Next.js
description: Setup Fumadocs on Next.js.
---
## Prerequisite
Before continuing, make sure you have configured:
- Next.js 16.
- Tailwind CSS 4.
We will use [Fumadocs MDX](/docs/mdx) as a content source, you can configure it first:
<include cwd>content/docs/mdx/(integrations)/next.mdx#setup-section</include>
<Callout title='Good to Know'>
Fumadocs also supports other content sources, including [Content Collections](/docs/headless/content-collections) and headless CMS.
</Callout>
## Getting Started
```npm
npm i @hanzo/docs-ui @hanzo/docs-core
```
### Root Layout
Wrap the entire application inside [Root Provider](/docs/ui/layouts/root-provider), and add required styles to `body`.
```tsx title="app/layout.tsx"
import { RootProvider } from '@hanzo/docs-ui/provider/next';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body
// required styles [!code ++]
className="flex flex-col min-h-screen"
>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}
```
### Styles
Add the following Tailwind CSS styles to `global.css`.
```css title="global.css"
@import 'tailwindcss';
/* [!code ++:2] */
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
```
> It doesn't come with a default font, you may choose one from `next/font`.
### Routes
Create a `lib/layout.shared.tsx` file to put the shared options for our layouts.
<include cwd meta='title="lib/layout.shared.tsx"'>
../../examples/next-min/lib/layout.shared.tsx
</include>
Create the following files & routes:
<include cwd meta='tab="components/mdx.tsx"'>
../../examples/next-min/components/mdx.tsx
</include>
<include cwd meta='tab="app/docs/layout.tsx"'>
../../examples/next-min/app/docs/layout.tsx
</include>
<include cwd meta='tab="app/docs/[[...slug]]/page.tsx"'>
../../examples/next-min/app/docs/[[...slug]]/page.tsx
</include>
<include cwd meta='tab="app/api/search/route.ts"'>
../../examples/next-min/app/api/search/route.ts
</include>
> The search is powered by Orama, learn more about [Document Search](/docs/headless/search).
### Finish
You can start the dev server and create MDX files.
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
## Introduction
I love Anime.
```
@@ -44,6 +44,9 @@ export default [
Create the following files:
<include cwd meta='tab="app/components/mdx.tsx"'>
../../examples/react-router-min/app/components/mdx.tsx
</include>
<include cwd meta='tab="app/lib/layout.shared.tsx"'>
../../examples/react-router-min/app/lib/layout.shared.tsx
</include>
@@ -29,8 +29,11 @@ Add the following to your Tailwind CSS file:
### Create Pages
Create the following routes:
Create the following files:
<include cwd meta='tab="components/mdx.tsx"'>
../../examples/tanstack-start-min/src/components/mdx.tsx
</include>
<include cwd meta='tab="lib/layout.shared.tsx"'>
../../examples/tanstack-start-min/src/lib/layout.shared.tsx
</include>
@@ -0,0 +1,86 @@
---
title: Waku
description: Setup Fumadocs on Waku.
---
## Getting Started
Before continuing, make sure to configure:
- Tailwind CSS 4.
- Fumadocs MDX: follow the [Vite setup guide](/docs/mdx/vite) and create essential files like `lib/source.ts`.
### Installation
```npm
npm i @hanzo/docs-core @hanzo/docs-ui
```
### Styles
Add the following to your Tailwind CSS file:
```css title="src/styles/globals.css"
@import 'tailwindcss';
/* [!code ++:2] */
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
```
### Create Pages
Create a file for sharing layout props:
<include cwd meta='title="lib/layout.shared.tsx"'>
../../examples/waku/src/lib/layout.shared.tsx
</include>
Create the following files:
<include cwd meta='tab="components/mdx.tsx"'>
../../examples/waku/src/components/mdx.tsx
</include>
<include cwd meta='tab="pages/docs/_layout.tsx"'>
../../examples/waku/src/pages/docs/_layout.tsx
</include>
<include cwd meta='tab="pages/docs/[...slugs].tsx"'>
../../examples/waku/src/pages/docs/[...slugs].tsx
</include>
<include cwd meta='tab="pages/_api/api/search.ts"'>
../../examples/waku/src/pages/_api/api/search.ts
</include>
Finally, wrap your entire app under Fumadocs providers:
<include cwd meta='tab="components/provider.tsx"'>
../../examples/waku/src/components/provider.tsx
</include>
```tsx tab="pages/_layout.tsx"
import '@/styles/globals.css';
import type { ReactNode } from 'react';
import { Provider } from '@/components/provider';
// [!code ++:3]
export default function RootLayout({ children }: { children: ReactNode }) {
return <Provider>{children}</Provider>;
}
export const getConfig = async () => {
return {
render: 'static',
};
};
```
### Done
You can start writing documents at `content/docs`:
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
I love Fumadocs
```
@@ -3,6 +3,8 @@ title: Markdown
description: How to write documents
---
import { Building, HomeIcon, Rocket } from 'lucide-react';
## Introduction
Fumadocs provides many useful extensions to MDX, a markup language. Here is a brief introduction to the default MDX syntax of Fumadocs.
@@ -0,0 +1,171 @@
---
title: Mermaid
description: Rendering diagrams in your docs
---
## Setup
Fumadocs doesn't have a built-in Mermaid wrapper provided, we recommend using `mermaid` directly.
You can decide the Mermaid renderer to configure:
### Official Renderer
Install the required dependencies, `next-themes` is used with Fumadocs to manage the light/dark mode.
```npm
npm install mermaid next-themes
```
Create the Mermaid component:
<include cwd meta='title="components/mdx/mermaid.tsx"'>
./components/mdx/mermaid.tsx
</include>
### Beautiful Mermaid
[`beautiful-mermaid`](https://github.com/lukilabs/beautiful-mermaid) is a 3rd party Mermaid renderer.
```npm
npm install beautiful-mermaid
```
```tsx title="components/mdx/mermaid.tsx"
import { CodeBlock, Pre } from '@hanzo/docs-ui/components/codeblock';
import { renderMermaidSVG } from 'beautiful-mermaid';
export async function Mermaid({ chart }: { chart: string }) {
try {
const svg = renderMermaidSVG(chart, {
bg: 'var(--color-fd-background)',
fg: 'var(--color-fd-foreground)',
interactive: true,
transparent: true,
});
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
} catch {
return (
<CodeBlock title="Mermaid">
<Pre>{chart}</Pre>
</CodeBlock>
);
}
}
```
## Usage
Add the component as a MDX component:
```tsx title="components/mdx.tsx"
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import { Mermaid } from '@/components/mdx/mermaid'; // [!code ++]
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
Mermaid, // [!code ++]
...components,
} satisfies MDXComponents;
}
```
Then, use it in MDX files.
```mdx
<Mermaid
chart="
graph TD;
subgraph AA [Consumers]
A[Mobile app];
B[Web app];
C[Node.js client];
end
subgraph BB [Services]
E[REST API];
F[GraphQL API];
G[SOAP API];
end
Z[GraphQL API];
A --> Z;
B --> Z;
C --> Z;
Z --> E;
Z --> F;
Z --> G;"
/>
```
<Tabs items={['Diagram', 'User Journey']}>
<Tab>
<Mermaid
chart="
graph TD;
subgraph AA [Consumers]
A[Mobile app];
B[Web app];
C[Node.js client];
end
subgraph BB [Services]
E[REST API];
F[GraphQL API];
G[SOAP API];
end
Z[GraphQL API];
A --> Z;
B --> Z;
C --> Z;
Z --> E;
Z --> F;
Z --> G;"
/>
</Tab>
<Tab>
<Mermaid
chart="
journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 5: Me
"
/>
</Tab>
</Tabs>
### As CodeBlock
You can convert `mermaid` codeblocks into the MDX usage with the `remarkMdxMermaid` remark plugin.
```tsx tab="Fumadocs MDX" title="source.config.ts"
import { remarkMdxMermaid } from '@hanzo/docs-core/mdx-plugins';
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkPlugins: [remarkMdxMermaid],
},
});
```
````md
```mermaid
graph TD;
A-->B;
A-->C;
```
````
@@ -0,0 +1,126 @@
---
title: Twoslash
description: Use Typescript Twoslash in your docs
---
## Usage
Thanks to the Twoslash integration of [Shiki](https://github.com/shikijs/shiki), the default code syntax highlighter, it is as simple as adding a transformer.
```package-install
@hanzo/docs-twoslash twoslash
```
For Next.js, you need to externalize the following deps:
```js title="next.config.mjs (Next.js)"
const config = {
reactStrictMode: true,
// [!code ++]
serverExternalPackages: ['typescript', 'twoslash'],
};
```
Add to your Shiki transformers.
```ts title="source.config.ts (Fumadocs MDX)"
import { defineConfig } from '@hanzo/docs-mdx/config';
import { transformerTwoslash } from '@hanzo/docs-twoslash';
import { rehypeCodeDefaultOptions } from '@hanzo/docs-core/mdx-plugins';
export default defineConfig({
mdxOptions: {
rehypeCodeOptions: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
transformers: [...(rehypeCodeDefaultOptions.transformers ?? []), transformerTwoslash()],
// important: Shiki doesn't support lazy loading languages for codeblocks in Twoslash popups
// make sure to define them first (e.g. the common ones)
langs: ['js', 'jsx', 'ts', 'tsx'],
},
},
});
```
Add styles, Tailwind CSS v4 is required.
```css title="Tailwind CSS"
@import '@hanzo/docs-twoslash/twoslash.css';
```
Add MDX components.
```tsx title="components/mdx.tsx"
import * as Twoslash from '@hanzo/docs-twoslash/ui'; // [!code ++]
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// [!code ++]
...Twoslash,
...components,
} satisfies MDXComponents;
}
```
Now you can add `twoslash` meta string to codeblocks.
````md
```ts twoslash
console.log('Hello World');
```
````
### Cache
Optionally, you can enable filesystem cache with `typesCache` option:
```ts twoslash title="source.config.ts"
import { transformerTwoslash } from '@hanzo/docs-twoslash';
import { createFileSystemTypesCache } from '@hanzo/docs-twoslash/cache-fs';
transformerTwoslash({
typesCache: createFileSystemTypesCache(),
});
```
### Example
Learn more about [Twoslash notations](https://twoslash.netlify.app/refs/notations).
```ts twoslash title="Test" lineNumbers
type Player = {
/**
* The player name
* @default 'user'
*/
name: string;
};
// ---cut---
// @noErrors
console.g;
// ^|
const player: Player = { name: 'Hello World' };
// ^?
```
```ts twoslash
const a = '123';
console.log(a);
// ^^^
```
```ts twoslash
// @errors: 2588
const a = '123';
a = 132;
```
@@ -15,7 +15,7 @@ To expose search indexes statically, create a function to generate records.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { StructuredData } from '@hanzo/docs-core/mdx-plugins';
export interface DocumentRecord {
title: string;
@@ -46,21 +46,16 @@ Export it via static route handler.
Finally, the exported records can be accessed at:
<include cwd>content/docs/headless/search/.static-route-output.mdx#static-route-output</include>
You can read it for further processing:
```ts
import * as fs from 'node:fs';
import type { DocumentRecord } from '@/lib/export-search-indexes';
// the path of pre-rendered `static.json`, choose one according to your React framework
const filePath = {
next: '.next/server/app/static.json.body',
'tanstack-start': '.output/public/static.json',
'react-router': 'build/client/static.json',
waku: 'dist/public/static.json',
}['next'];
const content = fs.readFileSync(filePath);
const records = JSON.parse(content.toString()) as DocumentRecord[];
```
You can use the `structuredData` to generate accurate search indexes.
Use `structuredData` to generate accurate search indexes.
@@ -0,0 +1,81 @@
---
title: FlexSearch
description: A fast & minimal search engine.
---
## Overview
For the setup guide, see [Integrate FlexSearch](/docs/headless/search/flexsearch).
It works through a API endpoint (running on server), or a statically cached JSON file for static websites.
## Setup
Create a search dialog.
<Tabs items={['fetch (default)', 'static']}>
<Tab>
<include meta='title="components/search.tsx"'>fetch.tsx</include>
</Tab>
<Tab id='static'>
For Static Export, you can configure [static mode](/docs/headless/search/flexsearch#static-export) on search server, and change the search client:
<include meta='title="components/search.tsx"'>flexsearch.tsx</include>
</Tab>
</Tabs>
<include>.shared.mdx</include>
### Tag Filter
Optionally, you can add UI for filtering results by tags. Configure [Tag Filter](/docs/headless/search/flexsearch#tag-filter) on search server and add the following:
```tsx
'use client';
import {
SearchDialog,
SearchDialogContent,
SearchDialogFooter,
SearchDialogOverlay,
type SharedProps,
TagsList,
TagsListItem,
} from '@hanzo/docs-ui/components/dialog/search';
import { useState } from 'react';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { flexsearchStaticClient } from '@hanzo/docs-core/search/client/flexsearch-static';
export default function CustomSearchDialog(props: SharedProps) {
// [!code ++]
const [tag, setTag] = useState<string | undefined>();
const { search, setSearch, query } = useDocsSearch({
client: flexsearchStaticClient({
// [!code ++]
tag,
}),
});
return (
<SearchDialog>
<SearchDialogOverlay />
<SearchDialogContent>
...
<SearchDialogFooter className="flex flex-row">
{/* [!code ++:3] */}
<TagsList tag={tag} onTagChange={setTag}>
<TagsListItem value="my-value">My Value</TagsListItem>
</TagsList>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
```
@@ -9,25 +9,15 @@ import {
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from '@hanzo/docs-base-ui/components/dialog/search';
} from '@hanzo/docs-ui/components/dialog/search';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { create } from '@orama/orama';
import { useI18n } from '@hanzo/docs-base-ui/contexts/i18n';
function initOrama() {
return create({
schema: { _: 'string' },
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});
}
import { flexsearchStaticClient } from '@hanzo/docs-core/search/client/flexsearch-static';
import { useI18n } from '@hanzo/docs-ui/contexts/i18n';
export default function DefaultSearchDialog(props: SharedProps) {
const { locale } = useI18n(); // (optional) for i18n
const { search, setSearch, query } = useDocsSearch({
type: 'static',
initOrama,
locale,
client: flexsearchStaticClient({ locale }),
});
return (
@@ -0,0 +1,21 @@
---
title: Search
description: Implement document search in your docs
---
## Search UI
See [Search UI](/docs/ui/search) to learn how to customise the UI of document search.
## Search Client
You can choose & configure the search client according to your search engine, it defaults to Orama search.
<DocsCategory />
### Community Integrations
A list of integrations maintained by community.
- [Trieve Search](/docs/headless/search/trieve)
- [Typesense Search](/docs/headless/search/typesense)
@@ -0,0 +1,118 @@
---
title: Typesense
description: Using Typesense with Fumadocs UI.
---
> This is a community-maintained integration.
## Setup
1. Integrate [Typesense Search](/docs/headless/search/typesense).
2. Create a search dialog component.
```tsx title="components/search.tsx"
'use client';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from '@hanzo/docs-ui/components/dialog/search';
import { useI18n } from '@hanzo/docs-ui/contexts/i18n';
import { Client } from 'typesense';
import { useTypesenseSearch } from 'typesense-fumadocs-adapter/client';
const client = new Client({
nodes: [{ url: 'YOUR_TYPESENSE_SERVER_URL' }],
apiKey: 'YOUR_TYPESENSE_SEARCH_ONLY_API_KEY',
});
export default function TypesenseSearchDialog(props: SharedProps) {
const { locale } = useI18n(); // optional
const { search, setSearch, query } = useTypesenseSearch({
typesenseCollectionName: `YOUR_TYPESENSE_COLLECTION_NAME`,
locale,
legacy: false, // optional, set to true for @hanzo/docs-ui version < 16.6.0
client,
});
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
<SearchDialogFooter>
<div className="w-full text-right text-xs text-fd-muted-foreground">
<a href="https://typesense.org" rel="noreferrer noopener" target="_blank">
Search powered by Typesense
</a>
</div>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
```
<include>.shared.mdx</include>
### Tag Filter
Optionally, you can add UI for filtering results by tags. Configure [Tag Filter](/docs/headless/search/typesense#tag-filter) on search server and add the following:
```tsx
'use client';
import {
SearchDialog,
SearchDialogContent,
SearchDialogFooter,
SearchDialogOverlay,
type SharedProps,
TagsList,
TagsListItem,
} from '@hanzo/docs-ui/components/dialog/search';
import { useState } from 'react';
import { useTypesenseSearch } from 'typesense-fumadocs-adapter/client';
export default function TypesenseSearchDialog(props: SharedProps) {
// [!code ++]
const [tag, setTag] = useState<string | undefined>();
const { search, setSearch, query } = useTypesenseSearch({
tag, // [!code ++]
// other options...
});
return (
<SearchDialog>
<SearchDialogOverlay />
<SearchDialogContent>
...
<SearchDialogFooter className="flex flex-row">
{/* [!code ++:3] */}
<TagsList tag={tag} onTagChange={setTag}>
<TagsListItem value="my-value">My Value</TagsListItem>
</TagsList>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
```
+6
View File
@@ -0,0 +1,6 @@
{
"root": true,
"title": "Fumadocs CLI",
"description": "CLI tools for docs & automation",
"icon": "Terminal"
}
+28
View File
@@ -0,0 +1,28 @@
---
title: Fumapress
description: A light tool to view Markdown files.
---
## Introduction
Fumapress allows you to view Markdown files with Fumadocs, it is useful for reading skills files & notes.
Run the command under your project directory.
```npm
npx @hanzo/docs-press dir/to/content
```
### Configurations
For other configurations, you can install it locally:
```npm
npm install @hanzo/docs-press -D
```
Then init the config file:
```npm
npx @hanzo/docs-press init
```
@@ -0,0 +1,88 @@
---
title: Content Collections
description: Use Content Collections for Fumadocs
---
[Content Collections](https://www.content-collections.dev) is a library that transforms your content into type-safe data collections.
## Setup
Follow their [official guide](https://content-collections.dev/docs) to setup Content Collections for your React framework.
Make sure you have MDX configured:
```npm
npm i @content-collections/mdx
```
To integrate with Fumadocs, add the following to your `content-collections.ts`.
<include cwd meta='title="content-collections.ts"'>
../../examples/content-collections/content-collections.ts
</include>
And pass it to `loader()`.
<include cwd meta='title="lib/source.ts"'>
../../examples/content-collections/lib/source.ts
</include>
Done! You can access the pages and generated page tree from Source API.
```ts
import { getPage } from '@/lib/source';
const page = getPage(slugs);
// MDX output
page?.data.body;
// Table of contents
page?.data.toc;
// Structured Data, for Search API
page?.data.structuredData;
```
### MDX Options
You can customise MDX options in the `transformMDX` function.
```ts
import { defineCollection } from '@content-collections/core';
import { transformMDX } from '@hanzo/docs-content-collections/configuration';
const docs = defineCollection({
transform: (document, context) =>
transformMDX(document, context, {
// options here
}),
});
```
### Import Components
To use components from other packages like Fumadocs UI, pass them to your `<MDXContent />` component.
```tsx
import { MDXContent } from '@content-collections/mdx/react';
import { getMDXComponents } from '@/components/mdx';
return <MDXContent code={page.data.body} components={getMDXComponents()} />;
```
You can also import them in MDX Files, but it is not recommended.
<Callout title='Deep Dive: Why?'>
Content Collections uses `mdx-bundler` to bundle MDX files.
To support importing a package from node modules, Fumadocs added a default value to the `cwd` option of MDX Bundler.
It works good, but we still **do not** recommend importing components in MDX files.
Reasons:
- It requires esbuild to bundle these components, while it should be done by the framework's bundler (e.g. Vite or Turbopack)
- You can refactor the import path of components without changing your MDX files.
- With Remote Sources, it doesn't make sense to add an import in MDX files.
</Callout>
@@ -22,7 +22,7 @@ You can define a config to share between utilities.
To hide the locale prefix (e.g. `/en/page` -> `/page`), use the `hideLocale` option.
```ts
import { defineI18n } from 'fumadocs-core/i18n';
import { defineI18n } from '@hanzo/docs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
@@ -55,7 +55,7 @@ Supported:
- When set to `null`, no fallback will be used.
```ts
import { defineI18n } from 'fumadocs-core/i18n';
import { defineI18n } from '@hanzo/docs-core/i18n';
export const i18n = defineI18n({
languages: ['en', 'cn'],
@@ -0,0 +1,137 @@
---
title: Package Install
description: Generate code blocks for installing packages
---
<Callout type="warn" title='Deprecated'>
For Fumadocs MDX, it is now enabled by default.
You can use the [`remarkNpm`](/docs/headless/mdx/remark-npm) plugin instead.
</Callout>
## Usage
```package-install
@hanzo/docs-docgen
```
Add the remark plugin.
```ts title="source.config.ts" tab="Fumadocs MDX"
import { remarkInstall } from '@hanzo/docs-docgen';
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkPlugins: [remarkInstall],
},
});
```
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkInstall } from '@hanzo/docs-docgen';
await compile('...', {
remarkPlugins: [remarkInstall],
});
```
Define the required components.
```tsx title="components/mdx.tsx (Fumadocs UI)"
import { Tab, Tabs } from '@hanzo/docs-ui/components/tabs';
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// [!code ++:2]
Tab,
Tabs,
...components,
} satisfies MDXComponents;
}
```
| Component | |
| --------- | ---------------------------------- |
| Tabs | Accepts an array of item (`items`) |
| Tab | Accepts the name of item (`value`) |
Create code blocks with `package-install` as language.
````mdx
```package-install
my-package
```
```package-install
npm i my-package -D
```
````
### Output
The following structure should be generated by the plugin.
```mdx
<Tabs items={['npm', 'pnpm', 'yarn', 'bun']}>
<Tab value="npm">...</Tab>
<Tab value="pnpm">...</Tab>
<Tab value="yarn">...</Tab>
<Tab value="bun">...</Tab>
</Tabs>
```
```package-install
my-package
```
## Options
### Persistent
When using with Fumadocs UI, you can enable persistence with the `persist` option.
```ts title="source.config.ts" tab="Fumadocs MDX"
import { remarkInstall } from '@hanzo/docs-docgen';
import { defineConfig } from '@hanzo/docs-mdx/config';
const remarkInstallOptions = {
persist: {
id: 'some-id',
},
};
export default defineConfig({
mdxOptions: {
remarkPlugins: [[remarkInstall, remarkInstallOptions]],
},
});
```
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkInstall } from '@hanzo/docs-docgen';
const remarkInstallOptions = {
persist: {
id: 'some-id',
},
};
await compile('...', {
remarkPlugins: [[remarkInstall, remarkInstallOptions]],
});
```
This will instead generate:
```mdx
<Tabs groupId="some-id" persist items={[...]}>
...
</Tabs>
```
@@ -0,0 +1,142 @@
---
title: Remark LLMs
description: Generate markdown for LLM consumption with customizable placeholders
---
The `remarkLLMs` plugin stringifies the processed Markdown AST to plain Markdown, the output is generated as an ESM export. This is useful for feeding content to LLMs, search indexing, or other text-based processing.
## Usage
### Fumadocs MDX
Enable `includeProcessedMarkdown` in your collection config, Fumadocs MDX runs `remarkLLMs` internally when this is set.
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
import { type LLMsOptions } from '@hanzo/docs-core/mdx-plugins/remark-llms';
const llmsOptions: LLMsOptions = {
// options...
};
export default defineDocs({
docs: {
postprocess: {
includeProcessedMarkdown: llmsOptions,
},
},
});
```
The stringified Markdown is exported as `_markdown` and available via `getText('processed')`, see [Collections](/docs/mdx/collections#includeprocessedmarkdown) for details.
### MDX Compiler
When using the MDX Compiler (or a custom pipeline), add the plugin:
```ts
import { compile } from '@mdx-js/mdx';
import { remarkLLMs, type LLMsOptions } from '@hanzo/docs-core/mdx-plugins/remark-llms';
const llmsOptions: LLMsOptions = {
// The output will be exported as `_markdown`
as: '_markdown',
};
const vfile = await compile('...', {
remarkPlugins: [[remarkLLMs, llmsOptions]],
});
```
## Placeholders
Placeholders let you keep certain MDX components in the stringified output as special tokens instead of dropping or inlining them. That way you can:
1. Rehydrate those tokens later via `renderPlaceholder()`.
2. Generate a better Markdown representation with runtime data.
### Using `mdxAsPlaceholder`
List component names to treat as placeholders:
```ts
import { type LLMsOptions } from '@hanzo/docs-core/mdx-plugins/remark-llms';
const llmsOptions: LLMsOptions = {
// [!code ++]
mdxAsPlaceholder: ['Callout', 'Card'],
};
```
In the exported Markdown, `<Callout>` and `<Card>` will appear as placeholder tokens, like:
```
\0{"name": "Callout", ...}\0
```
### Using `stringify`
For full control, use the `stringify` option and with the `placeholder()` helper:
```ts
import { placeholder, type LLMsOptions } from '@hanzo/docs-core/mdx-plugins/remark-llms';
const llmsOptions: LLMsOptions = {
stringify(node, parent, state, info) {
if (node.type === 'mdxJsxFlowElement' && node.name === 'MyPage') {
return placeholder(node, parent, state, info);
}
},
};
```
### Rendering Placeholders
Use `renderPlaceholder()` from the runtime module to replace placeholder tokens with custom output:
```ts
import { renderPlaceholder } from '@hanzo/docs-core/mdx-plugins/remark-llms.runtime';
const markdown = await getExportedMarkdown(); // e.g. from _markdown
const rendered = await renderPlaceholder(markdown, {
async Callout({ name, attributes, children }) {
// note: you can also fetch data here
const data = await fetchData();
return `[${attributes.type ?? 'info'}] ${children}`;
},
Card({ attributes, children }) {
return `**${attributes.title}**\n\n${children}`;
},
});
```
Each renderer receives `PlaceholderData`:
- `name`
- `attributes`
- `children` (the stringified child content).
## Example
```mdx tab="Input"
# Getting started
<Callout type="tip">Hello **world**.</Callout>
Some paragraph.
```
```md tab="Output"
# Getting started [#getting-started]
\0{"name":"Callout","attributes":{"type":"tip"},"children":"Hello **world**."}\0
Some paragraph.
```
## Options
<auto-type-table
type="import('@hanzo/docs-core/mdx-plugins/remark-llms').LLMsOptions"
/>
@@ -0,0 +1,139 @@
---
title: Remark NPM
description: Generate code blocks for package managers
---
## Usage
Add the remark plugin.
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkNpm } from '@hanzo/docs-core/mdx-plugins';
await compile('...', {
remarkPlugins: [remarkNpm],
});
```
```ts title="source.config.ts" tab="Fumadocs MDX"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkNpmOptions: {
// it is enabled by default, customise it here
},
},
});
```
Define the required components:
```tsx tab="Custom" title="components/mdx.tsx"
import { CodeBlockTabs, CodeBlockTab, CodeBlockTabsList, CodeBlockTabsTrigger } from 'my-ui';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
CodeBlockTabs,
CodeBlockTab,
CodeBlockTabsList,
CodeBlockTabsTrigger,
...components,
} satisfies MDXComponents;
}
```
```tsx tab="Fumadocs UI" title="components/mdx.tsx"
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
// it's included by default in `defaultComponents`
...defaultComponents,
...components,
} satisfies MDXComponents;
}
```
| Component | |
| -------------------- | ----------------------------------------------- |
| CodeBlockTabs | Accepts a `defaultValue` prop for default value |
| CodeBlockTabsList | N/A |
| CodeBlockTab | Accepts a `value` prop |
| CodeBlockTabsTrigger | Accepts a `value` prop |
### Input
Create code blocks with `npm` as language.
````mdx
```npm
npm i my-package
```
```npm
npm i my-package -D
```
````
### Output
The following structure should be generated by the plugin.
```mdx
<CodeBlockTabs defaultValue="npm">
<CodeBlockTabsList>
<CodeBlockTabsTrigger value="npm">npm</CodeBlockTabsTrigger>
<CodeBlockTabsTrigger value="pnpm">pnpm</CodeBlockTabsTrigger>
<CodeBlockTabsTrigger value="yarn">yarn</CodeBlockTabsTrigger>
<CodeBlockTabsTrigger value="bun">bun</CodeBlockTabsTrigger>
</CodeBlockTabsList>
<CodeBlockTab value="npm">...</CodeBlockTab>
<CodeBlockTab value="pnpm">...</CodeBlockTab>
<CodeBlockTab value="yarn">...</CodeBlockTab>
<CodeBlockTab value="bun">...</CodeBlockTab>
</CodeBlockTabs>
```
```npm
npm i my-package
```
### Persistent
When using with Fumadocs UI, you can enable persist the selected value with the `persist` option.
```ts title="source.config.ts" tab="Fumadocs MDX"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkNpmOptions: {
persist: {
id: 'package-manager',
},
},
},
});
```
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkNpm, type RemarkNpmOptions } from '@hanzo/docs-core/mdx-plugins';
const remarkNpmOptions: RemarkNpmOptions = {
persist: {
id: 'package-manager',
},
};
await compile('...', {
remarkPlugins: [[remarkNpm, remarkNpmOptions]],
});
```
This will generate a [`persist`](/docs/ui/components/tabs#persistent) prop to `<CodeBlockTabs />`.
@@ -0,0 +1,88 @@
---
title: Remark TS to JS
description: A remark plugin to transform TypeScript codeblocks into two tabs of codeblock with its JavaScript variant.
---
## Usage
Install dependencies:
```npm
npm i @hanzo/docs-docgen oxc-transform
```
For Next.js, externalize `oxc-transform`:
```js title="next.config.mjs (Next.js)"
import { createMDX } from '@hanzo/docs-mdx/next';
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
serverExternalPackages: ['oxc-transform'],
};
const withMDX = createMDX();
export default withMDX(config);
```
Add the remark plugin:
```ts title="source.config.ts" tab="Fumadocs MDX"
import { remarkTypeScriptToJavaScript } from '@hanzo/docs-docgen/remark-ts2js';
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkPlugins: [remarkTypeScriptToJavaScript],
},
});
```
```ts tab="MDX Compiler"
import { remarkTypeScriptToJavaScript } from '@hanzo/docs-docgen/remark-ts2js';
import { compile } from '@mdx-js/mdx';
await compile('...', {
remarkPlugins: [remarkTypeScriptToJavaScript],
});
```
Finally, make sure to define the required MDX components: `Tabs` and `Tab`.
```tsx title="components/mdx.tsx (Fumadocs UI)"
import { Tab, Tabs } from '@hanzo/docs-ui/components/tabs';
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// [!code ++:2]
Tab,
Tabs,
...components,
} satisfies MDXComponents;
}
```
You can now enable it on TypeScript/TSX codeblocks, like:
````md
```tsx ts2js
import { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return <div>{children}</div>;
}
```
````
```tsx ts2js
import { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return <div>{children}</div>;
}
```
@@ -0,0 +1,21 @@
`filePath` refers to the path of pre-rendered `static.json`, choose one according to your setup:
<section id="static-route-output">
```ts tab="Next.js"
const filePath = '.next/server/app/static.json.body';
```
```ts tab="Tanstack Start"
const filePath = '.output/public/static.json';
```
```ts tab="React Router"
const filePath = 'build/client/static.json';
```
```ts tab="Waku"
const filePath = 'dist/public/static.json';
```
</section>
@@ -0,0 +1,173 @@
---
title: Algolia Search
description: Integrate Algolia Search with Fumadocs
---
<Callout title="Notice">
If you're using Algolia's free tier, you have to [display their logo on your search
dialog](https://algolia.com/policies/free-services-terms).
</Callout>
## Setup
Install dependencies:
```package-install
algoliasearch
```
### Sign up on Algolia
Sign up and obtain the app id and API keys for your search. Store these
credentials in environment variables.
### Sync Search Indexes
Pre-render a static route `/static.json` to export search indexes into production build:
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import type { DocumentRecord } from '@hanzo/docs-core/search/algolia';
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
for (const page of source.getPages()) {
results.push({
_id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
});
}
return results;
}
```
<include>.static-route-example.mdx</include>
Make a script to sync search indexes:
```ts title="scripts/sync-content.ts" twoslash
import { algoliasearch } from 'algoliasearch';
import { sync, DocumentRecord } from '@hanzo/docs-core/search/algolia';
import * as fs from 'node:fs';
const filePath = '<see below>';
const content = fs.readFileSync(filePath);
const records = JSON.parse(content.toString()) as DocumentRecord[];
const client = algoliasearch('id', 'key');
// update the index settings and sync search indexes
void sync(client, {
indexName: 'document',
documents: records,
});
```
<include>.static-route-output.mdx</include>
Now run the script after build:
```json title="package.json"
{
"scripts": {
"build": "... && bun ./scripts/sync-content.ts"
}
}
```
### Workflow
You may manually upload search indexes with the script, or integrate it with your CI/CD pipeline.
### Search UI
You can consider different options for implementing the UI:
- Using [Fumadocs UI search dialog](/docs/search/algolia).
- Build your own using the built-in search client hook:
```ts twoslash
import { liteClient } from 'algoliasearch/lite';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const client = liteClient('id', 'key');
const { search, setSearch, query } = useDocsSearch({
type: 'algolia',
indexName: 'document',
client,
});
```
- Use their official clients directly.
## Advanced
### Tag Filter
To configure tag filtering, add a `tag` value to indexes.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import type { DocumentRecord } from '@hanzo/docs-core/search/algolia';
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
for (const page of source.getPages()) {
results.push({
_id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
// [!code ++]
tag: '<your value>',
});
}
return results;
}
```
And update your search client:
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/algolia#tag-filter) on Search UI.
- **Search Client**: You can add the tag filter like:
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const { search, setSearch, query } = useDocsSearch({
tag: '<your tag value>',
// ...
});
```
The `tag` field is an attribute for faceting. You can also use the filter `tag:value` on Algolia search clients.
### Under the Hood
The Algolia Integration automatically configures Algolia Search for document search.
It creates a record for **each paragraph** in your document, it is also recommended by Algolia.
Each record contains searchable attributes:
| Attribute | Description |
| --------- | --------------------- |
| `title` | Page Title |
| `section` | Heading ID (nullable) |
| `content` | Paragraph content |
The `section` field only exists in paragraphs under a heading. Headings and
paragraphs are indexed as an individual record, grouped by their page ID.
Notice that it expects the `url` property of a page to be unique, you shouldn't have two pages with the same
url.
@@ -0,0 +1,186 @@
---
title: FlexSearch
description: Fast & minimal document search
---
## Setup
```npm
npm install flexsearch
```
```npm
npm install @types/flexsearch -D
```
### From Source
Create the server from source object.
```ts title="app/api/search/route.ts (Next.js)"
import { source } from '@/lib/source';
import { flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
export const { GET } = flexsearchFromSource(source);
```
### From Search Indexes
Create the server from search indexes, each index needs a `structuredData` field.
Usually, it is provided by your content source (e.g. Fumadocs MDX). You can also extract it from Markdown/MDX document using the [Remark Structure](/docs/headless/mdx/structure) plugin.
```ts title="app/api/search/route.ts (Next.js)"
import { source } from '@/lib/source';
import { flexsearch } from '@hanzo/docs-core/search/flexsearch';
export const { GET } = flexsearch({
indexes: source.getPages().map((page) => ({
title: page.data.title!,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
})),
});
```
### Searching Documents
You can search documents using:
- **Fumadocs UI**: Supported out-of-the-box, see [Search UI](/docs/search/flexsearch) for details.
- **Search Client**:
```ts twoslash
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const client = useDocsSearch({
type: 'fetch',
});
```
<auto-type-table type='Extract<import("@hanzo/docs-core/search/client").ClientPreset, { type: "fetch" }>' />
## Configurations
### Tag Filter
Support filtering results by tag, it's useful for implementing multi-docs similar to this documentation.
```ts
import { source } from '@/lib/source';
import { flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
const server = flexsearchFromSource(source, {
buildIndex(page) {
return {
title: page.data.title!,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
// use your desired value, like page.slugs[0] [!code ++]
tag: '<value>',
};
},
});
```
and update your search client:
- **Fumadocs UI**: Configure [Tag Filter](/docs/search/flexsearch#tag-filter) on Search UI.
- **Search Client**: pass a tag to the hook.
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const client = useDocsSearch({
type: 'fetch',
tag: '<value>', // [!code ++]
});
```
### Static Mode [#static-export]
To support usage with static site, use `staticGET` from search server and make the route static or pre-rendered.
```ts title="app/api/search/route.ts (Next.js)"
import { source } from '@/lib/source';
import { flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
// statically cached [!code highlight:2]
export const revalidate = false;
export const { staticGET: GET } = flexsearchFromSource(source);
```
> `staticGET` is also available on `flexsearch()`.
and update your search clients:
- **Fumadocs UI**: use [static client](/docs/search/flexsearch#static) on Search UI.
- **Search Client**: change your client adapter.
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { flexsearchStaticClient } from '@hanzo/docs-core/search/client/flexsearch-static';
const client = useDocsSearch({
client: flexsearchStaticClient({
// optional: pass tag & locale
tag,
}),
});
```
<auto-type-table type='import("@hanzo/docs-core/search/client/flexsearch-static").FlexsearchStaticOptions' />
<Callout type='warn' title="Be Careful">
Static Search requires clients to download the exported search indexes.
For large docs sites, it can be expensive.
You should use cloud solutions like Orama Cloud or Algolia for these cases.
</Callout>
## Internationalization
Flexsearch doesn't require configurations to work for other languages, but you can improve the results by specifying Flexsearch options for each locale.
```ts title="app/api/search/route.ts" tab="From Source"
import { source } from '@/lib/source';
import { flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
import { Charset } from 'flexsearch';
const server = flexsearchFromSource(source, {
localeMap: {
// [locale]: Flexsearch options [!code ++]
jp: { encoder: Charset.CJK },
},
});
```
```ts tab="From Search Indexes"
import { source } from '@/lib/source';
import { flexsearchI18n } from '@hanzo/docs-core/search/flexsearch';
import { i18n } from '@/lib/i18n';
import { Charset } from 'flexsearch';
const server = flexsearchI18n({
i18n, // [!code ++]
localeMap: {
// [locale]: Flexsearch options [!code ++]
jp: { encoder: Charset.CJK },
},
indexes: source.getPages().map((page) => ({
title: page.data.title!,
description: page.data.description,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
locale: page.locale!,
})),
});
```
@@ -0,0 +1,141 @@
---
title: Orama Cloud
description: Integrate with Orama Cloud
---
To begin, create an account on Orama Cloud.
## REST API
REST API integration requires your docs to upload the indexes.
1. Create a new project with **REST API** data source on Orama Cloud dashboard.
Store your credentials in environment variables, for example:
```dotenv
NEXT_PUBLIC_ORAMA_DATASOURCE_ID="Rest API data source ID"
NEXT_PUBLIC_ORAMA_PROJECT_ID="project ID"
NEXT_PUBLIC_ORAMA_API_KEY="public API key"
ORAMA_PRIVATE_API_KEY="private API key"
```
2. Export the search indexes by pre-rendering a static route.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import type { OramaDocument } from '@hanzo/docs-core/search/orama-cloud';
export async function exportSearchIndexes() {
return source.getPages().map((page) => {
return {
id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
} satisfies OramaDocument;
});
}
```
<include>.static-route-example.mdx</include>
3. Create a script to sync search indexes.
```ts title="scripts/sync-content.ts"
import { sync, type OramaDocument } from '@hanzo/docs-core/search/orama-cloud';
import * as fs from 'node:fs/promises';
import { OramaCloud } from '@orama/core';
const filePath = '<see below>';
async function main() {
const orama = new OramaCloud({
projectId: process.env.NEXT_PUBLIC_ORAMA_PROJECT_ID,
apiKey: process.env.ORAMA_PRIVATE_API_KEY,
});
const content = await fs.readFile(filePath);
const records = JSON.parse(content.toString()) as OramaDocument[];
await sync(orama, {
index: process.env.NEXT_PUBLIC_ORAMA_DATASOURCE_ID,
documents: records,
});
console.log(`search updated: ${records.length} records`);
}
void main();
```
<include>.static-route-output.mdx</include>
4. Run the script after production build, make sure the environment variables are available (e.g. Bun reads from `.env` files):
```json title="package.json"
{
"scripts": {
"build": "... && bun scripts/sync-content.ts"
}
}
```
### Search Client
To search documents on the client side, consider:
- Using [Fumadocs UI search dialog](/docs/search/orama-cloud).
- Custom search UI using the built-in hook of Fumadocs:
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { OramaCloud } from '@orama/core';
const client = new OramaCloud({
projectId: process.env.NEXT_PUBLIC_ORAMA_PROJECT_ID,
apiKey: process.env.NEXT_PUBLIC_ORAMA_API_KEY,
});
const { search, setSearch, query } = useDocsSearch({
type: 'orama-cloud',
client,
params: {
// optional search params
},
});
```
- Use their search client directly.
## Web Crawler
1. Create a Crawler data source from dashboard, and configure it correctly with the "Documentation" preset.
2. Copy the credentials from dashboard.
### Search Client
Same as REST API integration, but make sure to set `index` to `crawler`.
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { OramaCloud } from '@orama/core';
const client = new OramaCloud({
projectId: '<project id>',
apiKey: '<read only api key>',
});
const { search, setSearch, query } = useDocsSearch({
type: 'orama-cloud',
index: 'crawler',
client,
params: {
// optional search params
},
});
```
It's same for Fumadocs UI.
@@ -128,7 +128,7 @@ const client = useDocsSearch({
});
```
<auto-type-table type='Extract<import("@hanzo/docs-core/search/client").Client, { type: "fetch" }>' />
<auto-type-table type='Extract<import("@hanzo/docs-core/search/client").ClientPreset, { type: "fetch" }>' />
## Configurations
@@ -249,7 +249,7 @@ and update your search clients:
});
```
<auto-type-table type='Extract<import("@hanzo/docs-core/search/client").Client, { type: "static" }>' />
<auto-type-table type='import("@hanzo/docs-core/search/client/orama-static").StaticOptions' />
<Callout type='warn' title="Be Careful">
@@ -0,0 +1,202 @@
---
title: Trieve Search
description: Integrate Trieve Search with Fumadocs
---
> This is a community maintained integration.
## Introduction
The Trieve Integration automatically configures Trieve Search for site search.
By default, it creates a chunk for **each paragraph** in your document, it is
officially recommended by Trieve.
## Setup
### Install Dependencies
```package-install
trieve-ts-sdk trieve-fumadocs-adapter
```
### Sign up on Trieve
Sign up and create a dataset. Then obtain 2 API keys where one has only read access and the other has admin access to create and delete chunks.
Store these credentials in environment variables.
<Callout title="Notice">
One API Key should have only read access for the public facing search and the other should have
admin access to create and delete chunks.
</Callout>
### Sync Dataset
Export the search indexes by pre-rendering a static route.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import { type TrieveDocument } from 'trieve-fumadocs-adapter/search/sync';
export async function exportSearchIndexes() {
const results: TrieveDocument[] = [];
for (const page of source.getPages()) {
results.push({
_id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
});
}
return results;
}
```
<include>.static-route-example.mdx</include>
Create a script, the `sync` function will sync search indexes.
```ts title="scripts/sync-content.ts"
import * as fs from 'node:fs';
import { sync, type TrieveDocument } from 'trieve-fumadocs-adapter/search/sync';
import { TrieveSDK } from 'trieve-ts-sdk';
const filePath = '<see below>';
const content = fs.readFileSync(filePath);
const records: TrieveDocument[] = JSON.parse(content.toString());
const client = new TrieveSDK({
apiKey: 'adminApiKey',
datasetId: 'datasetId',
});
sync(client, records);
```
<include>.static-route-output.mdx</include>
Make sure to run the script after build:
```json title="package.json"
{
"scripts": {
"build": "... && bun scripts/sync-content.ts"
}
}
```
> You can also integrate it with your CI/CD pipeline.
### Search UI
You can use their `SearchDialog` component:
```tsx title="components/search.tsx"
'use client';
import type { SharedProps } from '@hanzo/docs-ui/components/dialog/search';
import SearchDialog from 'trieve-fumadocs-adapter/components/dialog/search';
import { TrieveSDK } from 'trieve-ts-sdk';
const trieveClient = new TrieveSDK({
apiKey: 'readOnlyApiKey',
datasetId: 'datasetId',
});
export default function CustomSearchDialog(props: SharedProps) {
return <SearchDialog trieveClient={trieveClient} {...props} />;
}
```
1. Replace `apiKey` and `datasetId` with your desired values.
2. Replace the default search dialog with your new one.
### Search Client
Add the `useTrieveSearch` hook:
```ts
import { TrieveSDK } from 'trieve-ts-sdk';
import { useTrieveSearch } from 'trieve-fumadocs-adapter/search/trieve';
const client = new TrieveSDK({
apiKey: 'readOnlyApiKey',
datasetId: 'datasetId',
});
const { search, setSearch, query } = useTrieveSearch(client);
```
## Options
### Tag Filter
To configure tag filtering, add a `tag` value to indexes.
```js
import { sync } from 'trieve-fumadocs-adapter/search/sync';
import { TrieveSDK } from 'trieve-ts-sdk';
const client = new TrieveSDK({
apiKey: 'adminApiKey',
datasetId: 'datasetId',
});
const documents = records.map((index) => ({
...index,
tag: 'value', // [!code highlight]
}));
sync(client, documents);
```
#### Search UI
Enable Tag Filter.
```tsx title="components/search.tsx"
import SearchDialog from 'trieve-fumadocs-adapter/components/dialog/search';
<SearchDialog
defaultTag="value"
tags={[
{
name: 'Tag Name',
value: 'value',
},
]}
/>;
```
#### Search Client
The `tag_set` field is an attribute for filtering. To filter indexes by tag, use the filter on Trieve search clients.
```json
{
"must": [
{
"field": "tag_set",
"match": ["value"]
}
]
}
```
Or with `useTrieveSearch` hook:
```ts
import { TrieveSDK } from 'trieve-ts-sdk';
import { useTrieveSearch } from 'trieve-fumadocs-adapter/search/trieve';
const client = new TrieveSDK({
apiKey: 'readOnlyApiKey',
datasetId: 'datasetId',
});
const { search, setSearch, query } = useTrieveSearch(client, undefined, '<your tag value>');
```
@@ -0,0 +1,210 @@
---
title: Typesense Search
description: Integrate Typesense Search with Fumadocs
---
> This is a community-maintained integration.
## Setup
### Install Dependencies
```package-install
typesense typesense-fumadocs-adapter
```
### Start Typesense Server
You can either self-host the Typesense server or use their cloud service. Follow their [getting started guide](https://typesense.org/docs/guide/install-typesense.html) to set up your server and obtain the API key and server URL.
### Sync Dataset
Export the search indexes by pre-rendering a static route.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import { findPath } from '@hanzo/docs-core/page-tree';
import type { DocumentRecord } from 'typesense-fumadocs-adapter';
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === 'string' && item.length > 0;
}
for (const page of source.getPages()) {
let breadcrumbs: string[] | undefined;
const pageTree = source.getPageTree(page.locale);
const path = findPath(
pageTree.children,
(node) => node.type === 'page' && node.url === page.url,
);
if (path) {
breadcrumbs = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
}
results.push({
_id: page.url,
structured: page.data.structuredData,
url: page.url,
title: page.data.title,
description: page.data.description,
breadcrumbs,
locale: page.locale,
});
}
return results;
}
```
<include>.static-route-example.mdx</include>
Create a script to sync search indexes:
```ts title="scripts/sync-content.ts"
import * as fs from 'node:fs';
import { sync, DocumentRecord } from 'typesense-fumadocs-adapter';
import { Client } from 'typesense';
const filePath = '<see below>';
const content = fs.readFileSync(filePath);
const records = JSON.parse(content.toString()) as DocumentRecord[];
const client = new Client({
nodes: [{ url: 'YOUR_TYPESENSE_SERVER_URL' }],
apiKey: 'YOUR_TYPESENSE_API_KEY_WITH_WRITE_ACCESS',
connectionTimeoutSeconds: 60 * 15,
});
// update the collection settings and sync search indexes
void sync(client, {
typesenseCollectionName: 'YOUR_COLLECTION_NAME',
documents: records,
});
```
<include>.static-route-output.mdx</include>
Make sure to run the script after build:
```json title="package.json"
{
"scripts": {
"build": "... && bun scripts/sync-content.ts"
}
}
```
> You can also integrate it with your CI/CD pipeline.
### Search UI
To implement the search UI, you can either:
- Use [Fumadocs UI search dialog](/docs/search/typesense).
- Build your own search UI with the Typesense search client hook.
```ts
import { Client } from 'typesense';
import { useTypesenseSearch } from 'typesense-fumadocs-adapter/client';
const client = new Client({
nodes: [{ url: 'YOUR_TYPESENSE_SERVER_URL' }],
apiKey: 'YOUR_TYPESENSE_SEARCH_ONLY_API_KEY',
});
const { search, setSearch, query } = useTypesenseSearch({
typesenseCollectionName: 'YOUR_COLLECTION_NAME',
client,
});
```
## Options
### Tag Filter
To configure tag filtering, add a `tag` value to indexes.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import type { DocumentRecord } from 'typesense-fumadocs-adapter';
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
for (const page of source.getPages()) {
results.push({
// other fields...
// [!code ++]
tag: '<your value>',
});
}
return results;
}
```
And update your search client:
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/typesense#tag-filter) on Search UI.
- **Search Client**: You can add the tag filter like:
```ts
import { useTypesenseSearch } from 'typesense-fumadocs-adapter/client';
const { search, setSearch, query } = useTypesenseSearch({
tag: '<your tag value>',
// ...
});
```
### Internationalization (i18n)
To support internationalization, make sure to add a `locale` value to each document.
```ts title="lib/export-search-indexes.ts"
import { source } from '@/lib/source';
import { findPath } from '@hanzo/docs-core/page-tree';
import type { DocumentRecord } from 'typesense-fumadocs-adapter';
export async function exportSearchIndexes() {
const results: DocumentRecord[] = [];
for (const page of source.getPages()) {
results.push({
// other fields...
// [!code ++]
locale: page.locale,
});
}
return results;
}
```
And update your search client:
```ts
import { useI18n } from '@hanzo/docs-ui/contexts/i18n';
import { useTypesenseSearch } from 'typesense-fumadocs-adapter/client';
const { locale } = useI18n();
const { search, setSearch, query } = useTypesenseSearch({
// [!code ++]
locale,
// other fields...
});
```
Under the hood, Typesense will create separate collections for each locale. The collection name is appended with the `_{locale}` suffix (e.g., `YOUR_COLLECTION_NAME_en`, `YOUR_COLLECTION_NAME_fr`).
@@ -23,8 +23,8 @@ description: Turn content sources into a unified interface
You can use it with content sources like Fumadocs MDX, see [`source`](/docs/headless/source-api/source) for details.
```ts
import { loader } from 'fumadocs-core/source';
import { docs } from 'fumadocs-mdx:collections/server';
import { loader } from '@hanzo/docs-core/source';
import { docs } from 'collections/server';
export const source = loader({
source: docs.toFumadocsSource(),
@@ -37,7 +37,7 @@ export const source = loader({
You can override the base URL, or specify a function to generate URL for each page.
```ts
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
loader({
baseUrl: '/docs',
@@ -54,7 +54,7 @@ loader({
Define a custom function to generate slugs from page files.
```ts
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
loader({
slugs(file) {
@@ -70,7 +70,7 @@ loader({
Load the [icon](/docs/headless/page-conventions#icons) property specified by page and meta files.
```ts
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
import { icons } from 'lucide-react';
import { createElement } from 'react';
@@ -92,7 +92,7 @@ Pass the `i18n` config to loader.
```ts title="lib/source.ts"
import { i18n } from '@/lib/i18n';
import { loader } from 'fumadocs-core/source';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
i18n, // [!code highlight]
@@ -202,7 +202,7 @@ async function loader() {
```
```tsx tab="Client"
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { useFumadocsLoader } from '@hanzo/docs-core/source/client';
function MyComponent() {
// `useLoaderData()` receives loader data (provided by your React framework)
@@ -0,0 +1,78 @@
---
title: Source
description: Setup sources for Loader API
---
## Overview
`loader()` accepts different content sources based on a `source` interface.
### Multiple Sources
Use `multiple` to combine multiple sources into one.
```ts
import { loader, multiple } from '@hanzo/docs-core/source';
import { openapiPlugin, openapiSource } from '@hanzo/docs-openapi/server';
import { blog, docs } from 'collections/server';
import { lucideIconsPlugin } from '@hanzo/docs-core/source/lucide-icons';
export const source = loader(
multiple({
docs: docs.toFumadocsSource(),
openapi: blog.toFumadocsSource(),
}),
{
baseUrl: '/docs',
},
);
```
To access properties exclusive to each source:
```ts
const page = source.getPage(['...']);
if (page.data.type === 'docs') {
console.log(page.data);
} else {
console.log(page.data);
}
```
### Custom Source
To plug your own content source, create a `Source` object.
Since Loader API doesn't rely on file system, file paths only allow virtual paths like `file.mdx` and `content/file.mdx`, `./file.mdx` and `D://content/file.mdx` are not allowed.
```ts twoslash
import { Source } from '@hanzo/docs-core/source';
export function createMySource(): Source<{
metaData: { title: string; pages: string[] }; // Your custom type
pageData: { title: string; description?: string }; // Your custom type
}> {
return {
files: [
{
type: 'page',
path: 'folder/index.mdx',
data: {
title: 'Hello World',
// ...
},
},
{
type: 'meta',
path: 'meta.json',
data: {
title: 'Docs',
pages: ['folder'],
// ...
},
},
],
};
}
```
@@ -0,0 +1,3 @@
{
"title": "Utilities"
}
@@ -0,0 +1,46 @@
---
title: Shiki (Internal)
description: Manage the Shiki instances.
---
## Overview
Fumadocs use its own Shiki factory API to manage internal Shiki instances.
You can customise it for special purposes like using fine-grained bundles, for example:
```ts
import { createShikiFactory } from '@hanzo/docs-core/highlight/shiki';
export const myShikiFactory = createShikiFactory({
async init(options) {
const { createHighlighter, createJavaScriptRegexEngine } = await import('shiki');
return createHighlighter({
langs: [],
themes: [],
langAlias: options?.langAlias,
engine: createJavaScriptRegexEngine(),
});
},
});
```
Then pass it to different modules/features:
```tsx tab="Dynamic CodeBlock (UI)"
'use client';
import * as Base from '@hanzo/docs-ui/dynamic-codeblock.core';
export type DynamicCodeblockProps = Omit<Base.DynamicCodeblockProps, 'highlighter'>;
export function DynamicCodeBlock(props: DynamicCodeblockProps) {
return <Base.DynamicCodeBlock highlighter={() => myShikiFactory.getOrInit()} {...props} />;
}
```
```ts tab="Rehype Code (Core)"
import { createRehypeCode } from '@hanzo/docs-core/mdx-plugins/rehype-code.core';
const rehypeCode = createRehypeCode(myShikiFactory);
```
@@ -0,0 +1,12 @@
Setup an import alias (recommended):
```json title="tsconfig.json"
{
"compilerOptions": {
"paths": {
// [!code ++]
"collections/*": [".source/*"]
}
}
}
```
@@ -0,0 +1,93 @@
---
title: Next.js
description: Use Fumadocs MDX with Next.js
---
## Setup
Set up Fumadocs MDX for your Next.js application.
<section id="setup-section">
<div className="fd-steps [&_h3]:fd-step">
### Installation
```npm
npm i @hanzo/docs-mdx @hanzo/docs-core @types/mdx
```
Create the configuration file:
```ts title="source.config.ts"
import { defineDocs, defineConfig } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
});
export default defineConfig();
```
Add the plugin to Next.js config:
```js title="next.config.mjs"
import { createMDX } from '@hanzo/docs-mdx/next';
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
// [!code ++:4]
const withMDX = createMDX({
// customise the config file path
// configPath: "source.config.ts"
});
// [!code highlight]
export default withMDX(config);
```
<Callout title="ESM Only" type='warn'>
Fumadocs MDX is ESM-only, it's recommended to use `next.config.mjs` for accurate ESM resolution.
For TypeScript config file, it requires Native Node.js TypeScript Resolver, you can see [Next.js docs](https://nextjs.org/docs/app/api-reference/config/typescript#using-nodejs-native-typescript-resolver-for-nextconfigts) for details.
</Callout>
<include>.shared.mdx</include>
### Integrate with Fumadocs
You can create a `lib/source.ts` file and obtain Fumadocs source from the `docs` collection output.
```ts title="lib/source.ts"
import { docs } from 'collections/server';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
```
The `.source` folder will be generated when you run `next dev` or `next build`.
### Done
You can now write content in `content/docs` folder.
</div>
</section>
## What is Next?
<Cards>
<Card title="Accessing Collections" href="/docs/mdx/entry">
Learn how to access collection outputs.
</Card>
<Card title="Lazy Loading" href="/docs/mdx/async">
Hit performance bottleneck? You can try lazy loading.
</Card>
</Cards>
@@ -0,0 +1,95 @@
---
title: Vite
description: Use Fumadocs MDX with Vite
---
## Setup
<div className="fd-steps [&_h3]:fd-step">
### Installation
```npm
npm i @hanzo/docs-mdx @hanzo/docs-core @types/mdx
```
Create the configuration file:
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
});
```
Add the Vite plugin:
```ts tab="Vite" title="vite.config.ts"
import { defineConfig } from 'vite';
import mdx from '@hanzo/docs-mdx/vite';
import * as MdxConfig from './source.config';
export default defineConfig({
plugins: [
// [!code ++]
mdx(MdxConfig),
// ...
],
});
```
```ts tab="Waku" title="waku.config.ts"
import { type Config, defineConfig } from 'waku/config';
import mdx from '@hanzo/docs-mdx/vite';
import * as MdxConfig from './source.config.js';
import type { UserConfig } from 'vite';
export default defineConfig({
vite: {
plugins: [
// [!code ++]
mdx(MdxConfig),
tsconfigPaths(),
],
resolve: {
tsconfigPaths: true,
},
} satisfies UserConfig as Config['vite'],
});
```
<include>./.shared.mdx</include>
### Integrate with Fumadocs
To integrate with Fumadocs, make a `lib/source.ts` file:
```ts title="app/lib/source.ts"
import { docs } from 'collections/server';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
```
The `.source` folder will be generated when you run development server or production build.
### Done
You can now write content in `content/docs` folder.
</div>
## What is Next?
<Cards>
<Card title="Accessing Collections" href="/docs/mdx/entry">
Learn how to access collection outputs.
</Card>
<Card title="Lazy Loading" href="/docs/mdx/async">
Hit performance bottleneck? You can try lazy loading.
</Card>
</Cards>
+115
View File
@@ -0,0 +1,115 @@
---
title: Lazy Loading
description: Configure lazy loading on collection outputs.
---
## Introduction
By default, all Markdown and MDX files need to be pre-compiled first. The same constraint also applies to the development server.
This may result in longer dev server start times for large docs sites. You can enable Async or Dynamic mode on doc collections to improve this.
## Async Mode
Async mode will generate collection outputs using async imports.
```ts tab="Docs Collection"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
// [!code ++]
async: true,
},
});
```
```ts tab="Doc Collection"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const doc = defineCollections({
type: 'doc',
dir: 'content/docs',
// [!code ++]
async: true,
});
```
Depending on your bundler, this may reduce compilation time significantly on development server.
<Callout title="For Next.js">
Turbopack doesn't support lazy bundling at the moment, async mode will only improve server
performance.
</Callout>
## Dynamic Mode
Dynamic mode works by performing on-demand compilation with MDX Remote.
You need to install extra dependencies:
```npm
npm i @hanzo/docs-mdx-remote shiki
```
```ts tab="Docs Collection"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
// [!code ++]
dynamic: true,
},
});
```
```ts tab="Doc Collection"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const doc = defineCollections({
type: 'doc',
dir: 'content/docs',
// [!code ++]
dynamic: true,
});
```
Then, follow [Dynamic Entry](/docs/mdx/entry/dynamic) to access the collection from dynamic entry instead.
<Callout title="Constraints" type='warn'>
Dynamic mode comes with some limitations on MDX features.
- **No import/export allowed in MDX files.** For MDX components, pass them from the `components` prop instead.
- **Images must be referenced with URL (e.g. `/images/test.png`).** Don't use **file paths** like `./image.png`. You should locate your images in the `public` folder and reference them with URLs.
</Callout>
## Usage
Frontmatter properties are still available on `page` object, but you need to invoke the `load()` async function to load the compiled content (and its exports).
For example:
```tsx title="page.tsx"
import { source } from '@/lib/source';
import { getMDXComponents } from '@/components/mdx';
const page = source.getPage(['...']);
if (page) {
// frontmatter properties are available
console.log(page.data);
// Markdown content requires await
const { body: MdxContent, toc } = await page.data.load();
console.log(toc);
return <MdxContent components={getMDXComponents()} />;
}
```
When using Async or Dynamic mode, we highly recommend to use third-party services to implement search, which usually have better capability to handle massive amount of content to index.
+252
View File
@@ -0,0 +1,252 @@
---
title: Collections
description: Collection of content data for your app
---
## Define Collections
Define a collection to parse a certain set of files.
```ts
import { defineCollections } from '@hanzo/docs-mdx/config';
import { z } from 'zod';
export const blog = defineCollections({
type: 'doc',
dir: './content/blog',
schema: z.object({
// schema
}),
// other options
});
```
### `type`
The accepted type of collection.
```ts
import { defineCollections } from '@hanzo/docs-mdx/config';
// only scan for json/yaml files
export const metaFiles = defineCollections({
type: 'meta',
// options
});
```
- `type: meta`
Accept JSON/YAML files, available options:
<auto-type-table path="./props.ts" name="MetaCollection" />
- `type: doc`
Markdown/MDX documents, available options:
<auto-type-table path="./props.ts" name="DocCollection" />
### `schema`
The schema to validate file data (frontmatter on `doc` type, content on `meta` type).
```ts
import { defineCollections } from '@hanzo/docs-mdx/config';
import { z } from 'zod';
export const blog = defineCollections({
type: 'doc',
dir: './content/blog',
schema: z.object({
name: z.string(),
}),
});
```
> [Standard Schema](https://standardschema.dev) compatible libraries, including Zod, are supported.
Note that the validation is done at build time, hence the output must be serializable.
You can also pass a function that receives the transform context.
```ts
import { defineCollections } from '@hanzo/docs-mdx/config';
import { z } from 'zod';
export const blog = defineCollections({
type: 'doc',
dir: './content/blog',
schema: (ctx) => {
return z.object({
name: z.string(),
testPath: z.string().default(
// original file path
ctx.path,
),
});
},
});
```
### `mdxOptions`
Customise MDX options at the collection level.
> This API is only available on `doc` type.
```ts title="source.config.ts"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const blog = defineCollections({
type: 'doc',
mdxOptions: {
// mdx options
},
});
```
By design, this will remove all default settings applied by your global config and Fumadocs MDX.
You have full control over MDX options.
You can use [`applyMdxPreset`](/docs/mdx/mdx) to apply the default MDX preset.
```ts title="source.config.ts"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const blog = defineCollections({
type: 'doc',
mdxOptions: applyMdxPreset({
// extend the preset
}),
});
```
### `postprocess`
> This API is only available on `doc` type.
You can pass build-time information to runtime using the `postprocess` API.
- #### `includeProcessedMarkdown`
Include the processed Markdown content (before being converted into HTML).
```ts tab="docs"
import { defineDocs } from '@hanzo/docs-mdx/config';
export default defineDocs({
docs: {
postprocess: {
// [!code ++]
includeProcessedMarkdown: true,
},
},
});
```
```ts tab="doc"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const blog = defineCollections({
type: 'doc',
postprocess: {
// [!code ++]
includeProcessedMarkdown: true,
},
});
```
You can obtain the included content with `getText()` (e.g. with `loader()`):
```ts
import { source } from '@/lib/source';
const page = source.getPage('...');
console.log(await page.data.getText('processed'));
```
See [available options](/docs/headless/mdx/remark-llms).
- #### `valueToExport`
Some remark plugins store their output in `vfile.data` (compile-time memory) which cannot be accessed from your code.
You can specify the name of properties to include, they will get converted into ESM exports that you can access when importing the MDX file.
```ts tab="docs"
import { defineDocs } from '@hanzo/docs-mdx/config';
export default defineDocs({
docs: {
postprocess: {
// [!code ++]
valueToExport: ['dataName'],
},
},
});
```
```ts tab="doc"
import { defineCollections } from '@hanzo/docs-mdx/config';
export const blog = defineCollections({
type: 'doc',
postprocess: {
// [!code ++]
valueToExport: ['dataName'],
},
});
```
## Define Docs
Define a collection for Fumadocs.
```ts
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: '/my/content/dir',
docs: {
// optional, options of `doc` collection
},
meta: {
// optional, options of `meta` collection
},
});
```
### `dir`
Instead of per collection, you should customise content directory from `defineDocs`:
```ts
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
dir: 'content/guides',
});
```
### `schema`
You can extend the default Zod 4 schema of `docs` and `meta`.
```ts
import { defineDocs } from '@hanzo/docs-mdx/config';
import { metaSchema, pageSchema } from '@hanzo/docs-core/source/schema';
import { z } from 'zod';
export const docs = defineDocs({
docs: {
schema: pageSchema.extend({
index: z.boolean().default(false),
}),
},
meta: {
schema: metaSchema.extend({
// other props
}),
},
});
```
+3 -3
View File
@@ -10,7 +10,7 @@ The generated outputs are optimized using async imports to optimize for browser
Only doc/docs collections are accessible on browser.
```tsx
import browserCollections from '@hanzo/docs-mdx:collections/browser';
import browserCollections from 'collections/browser';
// unloaded entries
console.log(browserCollections['collection name'].raw);
@@ -63,7 +63,7 @@ The examples are for non-RSC usage, we recommend using RSC whenever possible for
import { createFileRoute, notFound } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { source } from '@/lib/source';
import browserCollections from '@hanzo/docs-mdx:collections/browser';
import browserCollections from 'collections/browser';
export const Route = createFileRoute('/docs/$')({
component: Page,
@@ -110,7 +110,7 @@ function Page() {
```tsx
import type { Route } from './+types/docs';
import { source } from '@/lib/source';
import browserCollections from '@hanzo/docs-mdx:collections/browser';
import browserCollections from 'collections/browser';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
@@ -0,0 +1,33 @@
---
title: Dynamic Entry
description: Accessing your collections with on-demand compilation.
---
## Usage
Enable [dynamic mode](/docs/mdx/async#dynamic-mode) on docs/doc collections, and import it from `dynamic` entry instead of `server`.
It offers the same output as `server` entry, while compiled properties have to be loaded via `load()` method.
```ts tab="Docs (Meta + Doc)"
// [!code highlight]
import { docs } from 'collections/dynamic';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
```
```ts tab="Doc"
// [!code highlight]
import { blogPosts } from 'collections/dynamic';
import { toFumadocsSource } from '@hanzo/docs-mdx/runtime/server';
import { loader } from '@hanzo/docs-core/source';
export const blog = loader({
baseUrl: '/blog',
source: toFumadocsSource(blogPosts, []),
});
```
@@ -0,0 +1,94 @@
---
title: Import MDX Files
description: Importing MDX files directly.
---
## Introduction
MDX files will be compiled into JavaScript with exports like:
- `default`: the main component.
- `frontmatter`: frontmatter data.
- other properties generated by remark/rehype plugins.
Hence, they can also be used as a page, or components that you can import.
### Using as Component
You can import them as a component:
```tsx title="app/my-page.tsx"
import MyPage from '@/content/page.mdx';
import { getMDXComponents } from '@/components/mdx';
export default function Page() {
return (
<div className="prose">
{/* pass MDX components and render it */}
<MyPage components={getMDXComponents()} />
</div>
);
}
```
### Using as Page
On Next.js, you can use `page.mdx` instead of `page.tsx` for creating a new page under the app directory.
```mdx tab="app/my-page/page.mdx"
---
title: My Page
---
export { default } from '@/components/layouts/page';
# Hello World
This is my page.
```
```tsx tab="components/layouts/page.tsx"
import type { ComponentProps } from 'react';
export default function Content(props: ComponentProps<'main'>) {
return (
<main className="px-8 py-12">
<article className="prose">{props.children}</article>
</main>
);
}
```
It doesn't have **MDX components** by default, you have to provide them:
```tsx tab="components/mdx.tsx"
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents, // for Fumadocs UI
...components,
} satisfies MDXComponents;
}
// export a `useMDXComponents()` that returns MDX components
export const useMDXComponents = getMDXComponents; // [!code ++]
```
```ts tab="source.config.ts"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
// Path to import your `components/mdx.tsx` above. [!code ++]
providerImportSource: '@/components/mdx',
},
});
```
<Callout title="Other Frameworks?">
Other React.js frameworks like Tanstack Start usually prefer explicit declaration of loaders, it's recommended to use them as components in your pages instead.
</Callout>
@@ -0,0 +1,62 @@
---
title: Server Entry
description: Accessing collection outputs from server.
---
## Usage
While you can access the collections directly from entry file, we suggest using it with [`loader()`](/docs/headless/source-api#output).
```ts tab="Docs (Meta + Doc)"
import { docs } from 'collections/server';
import { loader } from '@hanzo/docs-core/source';
// raw collection
console.log(docs);
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});
```
```ts tab="Doc"
import { blogPosts } from 'collections/server';
import { toFumadocsSource } from '@hanzo/docs-mdx/runtime/server';
import { loader } from '@hanzo/docs-core/source';
// raw collection
console.log(blogPosts);
export const blog = loader({
baseUrl: '/blog',
source: toFumadocsSource(blogPosts, []),
});
```
## Examples
```tsx
import { source } from '@/lib/source';
const page = source.getPage(['slugs']);
if (page) {
// access page data [!code highlight]
console.log(page.data);
// frontmatter properties are also available [!code highlight]
console.log(page.data.title);
}
```
To render the page (on RSC), use `page.data.body` as a component.
```tsx
import { getMDXComponents } from '@/components/mdx';
const MDX = page.data.body;
// set your MDX components with `components` prop
return <MDX components={getMDXComponents()} />;
```
+123
View File
@@ -0,0 +1,123 @@
---
title: MDX Presets
description: Customise the default configurations for MDX processor.
---
Fumadocs MDX provides MDX presets to simplify configurations of features like **syntax highlighting**.
## Default Preset
A preset designed for documentation sites, it is enabled by default for global MDX options.
To override the defaults, it accepts an interface inherited from [`ProcessorOptions`](https://mdxjs.com/packages/mdx/#processoroptions):
### Remark Plugins
These plugins are applied by default:
- [Remark Image](/docs/headless/mdx/remark-image) - Handle images.
- [Remark Heading](/docs/headless/mdx/headings) - Extract table of contents.
- [Remark Structure](/docs/headless/mdx/structure) - Generate search indexes.
Add other remark plugins with:
```ts tab="Global Config"
import { defineConfig } from '@hanzo/docs-mdx/config';
import { myPlugin } from './remark-plugin';
export default defineConfig({
mdxOptions: {
remarkPlugins: [myPlugin],
// You can also pass a function to control the order of remark plugins.
remarkPlugins: (v) => [myPlugin, ...v],
},
});
```
```ts tab="Collection Config"
import { defineCollections, applyMdxPreset } from '@hanzo/docs-mdx/config';
import { myPlugin } from './remark-plugin';
export const blog = defineCollections({
type: 'doc',
mdxOptions: applyMdxPreset({
remarkPlugins: [myPlugin],
// You can also pass a function to control the order of remark plugins.
remarkPlugins: (v) => [myPlugin, ...v],
}),
});
```
### Rehype Plugins
These plugins are applied by default:
- [Rehype Code](/docs/headless/mdx/rehype-code) - Syntax highlighting.
- [Rehype TOC](/docs/headless/mdx/headings#rehype-toc) - Export table of contents.
Same as remark plugins, you can pass an array or a function to add other rehype plugins.
```ts tab="Global Config"
import { defineConfig } from '@hanzo/docs-mdx/config';
import { myPlugin } from './rehype-plugin';
export default defineConfig({
mdxOptions: {
rehypePlugins: (v) => [myPlugin, ...v],
},
});
```
```ts tab="Collection Config"
import { defineCollections, applyMdxPreset } from '@hanzo/docs-mdx/config';
import { myPlugin } from './rehype-plugin';
export const blog = defineCollections({
type: 'doc',
mdxOptions: applyMdxPreset({
rehypePlugins: (v) => [myPlugin, ...v],
}),
});
```
### Customise Built-in Plugins
Customise the options of built-in plugins like:
```ts tab="Global Config"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
rehypeCodeOptions: {
// options
},
remarkImageOptions: {
// set image placeholder to `blur` (only effective on Next.js)
placeholder: 'blur',
},
remarkHeadingOptions: {
// options
},
},
});
```
```ts tab="Collection Config"
import { defineCollections, applyMdxPreset } from '@hanzo/docs-mdx/config';
export const blog = defineCollections({
type: 'doc',
mdxOptions: applyMdxPreset({
rehypeCodeOptions: {
// options
},
remarkImageOptions: {
// options
},
remarkHeadingOptions: {
// options
},
}),
});
```
+98
View File
@@ -0,0 +1,98 @@
---
title: Workspace
description: Use Fumadocs MDX in multiple workspace.
---
## Overview
**Workspace** in Fumadocs MDX, refers to an independent project with its own config and content.
<Files>
<Folder name="my-workspace" defaultOpen>
<Folder name="content/docs" defaultOpen>
<File name="index.mdx" />
<File name="test.mdx" />
</Folder>
<File name="source.config.ts" />
</Folder>
<Folder name="content/docs" defaultOpen>
<File name="welcome.mdx" />
</Folder>
<File name="source.config.ts" />
</Files>
<Callout title="Good to Know">
Fumadocs MDX Workspace is not limited to the traditional meaning of workspace
in package managers.
They do not need to have its own `package.json`, only a config file is needed.
</Callout>
To define a workspace, add:
```ts title="source.config.ts"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
workspaces: {
'my-workspace': {
dir: 'my-workspace',
config: await import('./my-workspace/source.config.ts'),
},
},
});
```
When writing content in a workspace, note that:
- `cwd` refers to the **current workspace directory**.
- configs will not inherit, workspaces are always independent.
By running dev or build server, you should see collection entries from all workspaces to be generated.
### Accessing Collections
You can access the generated files of a workspace at `.source/{workspace}/*`. For example:
```ts title="lib/source.ts"
import { docs } from 'collections/my-workspace/server';
```
> The output location of root workspace is not changed.
To integrate multiple sources in Fumadocs, use [`multiple`](/docs/headless/source-api/source#multiple-sources):
```ts title="lib/source.ts"
import { loader, multiple } from '@hanzo/docs-core/source';
import { docs } from 'collections/server';
import * as MyWorkspace from 'collections/my-workspace/server';
export const source = loader(
multiple({
root: docs.toFumadocsSource(),
'my-workspace': MyWorkspace.docs.toFumadocsSource(),
}),
{
baseUrl: '/docs',
},
);
```
## When to Use
In some setups, you might have multiple Fumadocs MDX configs with their own content directory.
With workspaces, you can integrate them into one Fumadocs MDX config, and access all collections of each workspace.
This is crucial for use cases like storing content across multiple repos, a simplified setup would be:
- let your main docs repo be $A$, and other repos be $T_n$
- for each repo $T_n$:
- $T_n$ has $A$ as a git submodule.
- $T_n$ defines its own config `source.config.ts` and work independently.
- when a commit is made to the content in $T_n$, it triggers a GitHub action (or CI), which creates a new deployment on $A$.
- when a deployment is triggered on $A$:
- Fumadocs MDX handles each $T_n$ as a workspace.
- each workspace generates its own collection entries, e.g. `collections/{repo}/server`.
- Fumadocs `loader()` integrate multiple sources into one.
@@ -0,0 +1,31 @@
---
title: Component Library
description: The underlying headless component library.
icon: Library
---
## Overview
Fumadocs UI maintains support for both [Base UI](https://base-ui.com) and [Radix UI](https://radix-ui.com/primitives), while it uses Radix UI by default.
The configuration may be different if you use Fumadocs CLI for customising components.
### Base UI
You can opt-in to use Base UI by replacing `@hanzo/docs-ui` with the `@hanzo/docs-base-ui` package:
```json title="package.json"
{
"dependency": {
"@hanzo/docs-ui": "npm:@hanzo/docs-base-ui@latest"
}
}
```
For Fumadocs CLI, also configure it in your `cli.json` config:
```json title="cli.json"
{
"uiLibrary": "base-ui"
}
```
@@ -9,7 +9,7 @@ description: Auto-generated type table
/**
* Markdown syntax like links, `code` are supported.
* See https://docs.hanzo.ai/docs/ui/components/type-table
* See https://fumadocs.dev/docs/ui/components/type-table
**/
name: boolean | null;
@@ -50,24 +50,26 @@ npm i @hanzo/docs-typescript
Initialize the TypeScript compiler and add it as a MDX component.
```tsx title="mdx-components.tsx"
```tsx title="components/mdx.tsx"
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { createGenerator, createFileSystemGeneratorCache } from '@hanzo/docs-typescript';
import { AutoTypeTable } from '@hanzo/docs-typescript/ui';
import { AutoTypeTable, type AutoTypeTableProps } from '@hanzo/docs-typescript/ui';
const generator = createGenerator({
// set a cache, necessary for serverless platform like Vercel
cache: createFileSystemGeneratorCache('.next/@hanzo/docs-typescript'),
});
export function getMDXComponents(components?: MDXComponents): MDXComponents {
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// [!code ++:3]
AutoTypeTable: (props) => <AutoTypeTable {...props} generator={generator} />,
// [!code ++]
AutoTypeTable: (props: Partial<AutoTypeTableProps>) => (
<AutoTypeTable {...props} generator={generator} />
),
...components,
};
} satisfies MDXComponents;
}
```
@@ -0,0 +1,82 @@
---
title: Code Block
description: Displaying Shiki highlighted code blocks
---
<Wrapper>
<div className="bg-fd-background rounded-lg prose-no-margin">
```js title="config.js"
import createMDX from '@hanzo/docs-mdx/config';
const withMDX = createMDX();
// [!code word:config]
/** @type {import('next').NextConfig} */
const config = {
// [!code highlight]
reactStrictMode: true, // [!code highlight]
}; // [!code highlight]
export default withMDX(config);
```
</div>
</Wrapper>
<Installation name="codeblock" />
This is a **MDX component** meant to be used with [Rehype Code](/docs/headless/mdx/rehype-code) to display highlighted codeblocks.
You can refer to [Markdown](/docs/markdown#codeblock) for the syntax of writing codeblocks.
Supported features:
- Copy button
- Custom titles and icons
> If you're looking for an equivalent with runtime syntax highlighting, see [Dynamic Code Block](/docs/ui/components/dynamic-codeblock).
## Usage
Wrap the pre element in `<CodeBlock />`, which acts as the wrapper of code block.
```tsx title="components/mdx.tsx"
import defaultComponents from '@hanzo/docs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { CodeBlock, Pre } from '@hanzo/docs-ui/components/codeblock';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultComponents,
// HTML `ref` attribute conflicts with `forwardRef`
pre: ({ ref: _ref, ...props }) => (
<CodeBlock {...props}>
<Pre>{props.children}</Pre> {/* [!code highlight] */}
</CodeBlock>
),
...components,
} satisfies MDXComponents;
}
```
### Keep Background
Use the background color generated by Shiki.
```tsx
import { Pre, CodeBlock } from '@hanzo/docs-ui/components/codeblock';
<CodeBlock keepBackground {...props}>
<Pre>{props.children}</Pre>
</CodeBlock>;
```
### Icons
Specify a custom icon by passing an `icon` prop to `CodeBlock` component.
By default, the icon will be injected by the custom Shiki transformer.
```js title="config.js"
console.log('js');
```
@@ -0,0 +1,72 @@
---
title: Code Block (Dynamic)
description: A codeblock that also highlights code
preview: dynamicCodeBlock
---
## Usage
### Client Component
```tsx
import { DynamicCodeBlock } from '@hanzo/docs-ui/components/dynamic-codeblock';
export function MyComp() {
const code = `console.log("Hello World")`;
return <DynamicCodeBlock lang="ts" code={code} />;
}
```
Unlike the MDX [`CodeBlock`](/docs/ui/components/codeblock) component, this is a client component that can be used without MDX.
It highlights the code with Shiki and use the default component to render it.
Features:
- using React.js 19 Suspense.
- can be pre-rendered on server.
- load languages and themes on browser lazily.
#### Options
```tsx
import { DynamicCodeBlock } from '@hanzo/docs-ui/components/dynamic-codeblock';
<DynamicCodeBlock
lang="ts"
code='console.log("Hello World")'
options={{
themes: {
light: 'github-light',
dark: 'github-dark',
},
components: {
// override components (e.g. `pre` and `code`)
},
// other Shiki options
}}
/>;
```
### Server Component
For a server component equivalent, you can use the RSC version:
```tsx
import { ServerCodeBlock } from '@hanzo/docs-ui/components/codeblock.rsc';
<ServerCodeBlock
lang="ts"
code='console.log("Hello World")'
options={{
themes: {
light: 'github-light',
dark: 'github-dark',
},
components: {
// override components (e.g. `pre` and `code`)
},
// other Shiki options
}}
/>;
```
@@ -0,0 +1,50 @@
---
title: GitHub Info
description: Display your GitHub repository information
preview: githubInfo
---
<Installation name="github-info" />
## Usage
```tsx
import { GithubInfo } from '@hanzo/docs-ui/components/github-info';
export function MyComp() {
return (
<GithubInfo
owner="fuma-nama"
repo="fumadocs"
// your own GitHub access token (optional)
token={process.env.GITHUB_TOKEN}
/>
);
}
```
It's recommended to add it to your docs layout with `links` option:
```tsx title="app/docs/layout.tsx"
import { DocsLayout, type DocsLayoutProps } from '@hanzo/docs-ui/layouts/notebook';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import { GithubInfo } from '@hanzo/docs-ui/components/github-info';
function docsOptions(): DocsLayoutProps {
return {
...baseOptions(),
tree: source.getPageTree(),
links: [
{
type: 'custom',
children: <GithubInfo owner="fuma-nama" repo="fumadocs" />,
},
],
};
}
export default function Layout({ children }: { children: React.ReactNode }) {
return <DocsLayout {...docsOptions()}>{children}</DocsLayout>;
}
```

Some files were not shown because too many files have changed in this diff Show More