merge upstream fumadocs dev (66 commits up to 16.6.5) with Hanzo branding

Merges upstream commits 1ad82416..20cf96d42 from fuma-nama/fumadocs dev branch,
including:

- Core/UI/Base-UI version bump 16.5.3 -> 16.6.5
- OpenAPI version bump 10.3.4 -> 10.3.9 (3.2 support, server selector)
- TypeScript version bump 5.1.3 -> 5.1.4 (tag parser, type table ID)
- Markdown search improvements (inline elements, codeblocks, Vietnamese)
- CSS performance improvements and accessibility fixes
- MDX stringification using mdast-util-to-markdown
- Sidebar active link items support
- Mixedbread search moved to server-side
- Tailwind CSS plugin forwarding for module resolution
- Cursor integration replacing T3 Chat in templates
- Template cleanup (removed deprecated mdx-remote example)

All fumadocs-* packages renamed to @hanzo/docs-* namespace.
Removed duplicate +next+fuma-docs-mdx templates (using hanzo-docs-mdx).
Upstream metadata (homepage, repository) updated to Hanzo.
This commit is contained in:
2026-02-23 13:04:49 -08:00
443 changed files with 16850 additions and 9592 deletions
+440
View File
@@ -0,0 +1,440 @@
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 Nemo from '@/public/showcases/nemo.png';
import HexaasBlog from '@/public/showcases/hexaas-blog.png';
export const metadata = createMetadata({
title: 'Showcase',
description: 'Some cool websites using Hanzo Docs',
openGraph: {
url: 'https://docs.hanzo.ai/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/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: 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',
},
{
image: Nemo,
name: 'Nemo',
url: 'https://nemo.zanreal.com',
},
{
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 Hanzo Docs.
</p>
<div className="mt-6">
<a
href="https://github.com/hanzoai/docs/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">
Hanzo Docs 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/hanzoai/docs/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">Hanzo Docs 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>
);
}
+246
View File
@@ -0,0 +1,246 @@
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 = 6000;
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 Hanzo Docs</h1>
<p className="mt-4 text-sm">
Support the development work of Hanzo Docs. Hanzo Docs 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)">
Hanzo Docs
</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">Hosting Sponsor</h2>
<a href="https://vercel.com" rel="noreferrer noopener">
<svg aria-label="Vercel logotype" role="img" viewBox="0 0 283 64" className="w-32">
<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"
></path>
</svg>
</a>
<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}`;
}
+12 -13
View File
@@ -1,11 +1,10 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronDown, Copy, ExternalLinkIcon } from 'lucide-react';
import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useCopyButton } from '@hanzo/docs-base-ui/utils/use-copy-button';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
import { cva } from 'class-variance-authority';
import { useCopyButton } from '@hanzo/docs-ui/utils/use-copy-button';
import { buttonVariants } from '@hanzo/docs-ui/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-ui/components/ui/popover';
const cache = new Map<string, string>();
@@ -58,10 +57,6 @@ export function LLMCopyButton({
);
}
const optionVariants = cva(
'text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4',
);
export function ViewOptions({
markdownUrl,
githubUrl,
@@ -77,9 +72,8 @@ export function ViewOptions({
githubUrl: string;
}) {
const items = useMemo(() => {
const fullMarkdownUrl =
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
const pageUrl = typeof window !== 'undefined' ? window.location.href : 'loading';
const q = `Read ${pageUrl}, I want to ask questions about it.`;
return [
{
@@ -92,6 +86,11 @@ export function ViewOptions({
</svg>
),
},
{
title: 'View as Markdown',
href: markdownUrl,
icon: <TextIcon />,
},
{
title: 'Open in Scira AI',
href: `https://scira.ai/?${new URLSearchParams({
@@ -232,7 +231,7 @@ export function ViewOptions({
href={item.href}
rel="noreferrer noopener"
target="_blank"
className={cn(optionVariants())}
className="text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4"
>
{item.icon}
{item.title}
+4 -4
View File
@@ -1,5 +1,5 @@
import { type Registry } from '@hanzo/docs-cli/build';
import * as radixUi from '../../../../packages/radix-ui/src/_registry';
import * as radixUi from '../../../../packages/radix-ui/registry';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
import { resolveFromRemote } from '@hanzo/docs-cli/build';
@@ -26,8 +26,8 @@ export const registry: Registry = {
}
}
if (ref.type === 'dependency' && ref.dep === '@hanzo/docs-base-ui') {
const match = /@hanzo\/docs-base-ui\/components\/ui\/(.*)/.exec(ref.specifier);
if (ref.type === 'dependency' && ref.dep === '@hanzo/docs-ui') {
const match = /@hanzo/docs-ui\/components\/ui\/(.*)/.exec(ref.specifier);
if (match) {
return resolveFromRemote(
radixUi.registry,
@@ -145,7 +145,7 @@ export const registry: Registry = {
],
dependencies: {
'@hanzo/docs-core': null,
'@hanzo/docs-base-ui': null,
'@hanzo/docs-ui': null,
'lucide-react': null,
next: null,
react: null,
@@ -0,0 +1,109 @@
---
title: RSS Feed
description: Generate a RSS feed for your docs/blog.
---
## Setup
You can create the feed object:
```ts title="lib/rss.ts"
import { Feed } from 'feed';
import { source } from '@/lib/source';
const baseUrl = 'https://docs.hanzo.ai';
export function getRSS() {
const feed = new Feed({
title: 'Fumadocs Blog',
id: `${baseUrl}/blog`,
link: `${baseUrl}/blog`,
language: 'en',
image: `${baseUrl}/banner.png`,
favicon: `${baseUrl}/icon.png`,
copyright: 'All rights reserved 2025, Fuma Nama',
});
for (const page of source.getPages()) {
feed.addItem({
id: page.url,
title: page.data.title,
description: page.data.description,
link: `${baseUrl}${page.url}`,
date: new Date(page.data.lastModified),
author: [
{
name: 'Fuma',
},
],
});
}
return feed.rss2();
}
```
And expose it using a server loader/route handler like:
```ts tab="Next.js" title="app/rss.xml/route.ts"
import { getRSS } from '@/lib/rss';
export const revalidate = false;
export function GET() {
return new Response(getRSS());
}
```
```ts tab="React Router" title="app/routes/rss.ts"
import { getRSS } from '@/lib/rss';
export async function loader() {
return new Response(getRSS());
}
```
```ts tab="React Router" title="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('rss.xml', 'routes/rss.ts'),
] satisfies RouteConfig;
```
```ts tab="Tanstack Start" title="src/routes/rss[.]xml.ts"
import { createFileRoute } from '@tanstack/react-router';
import { getRSS } from '@/lib/rss';
export const Route = createFileRoute('/rss.xml')({
server: {
handlers: {
GET: async () => new Response(getRSS()),
},
},
});
```
### Next.js Metadata
You can add an alternates object to the metadata object with your feed’s title and URL.
```ts title="app/layout.tsx"
import type { Metadata } from 'next';
export const metadata: Metadata = {
alternates: {
types: {
'application/rss+xml': [
{
title: 'Fumadocs Blog',
url: 'https://docs.hanzo.ai/blog/index.xml',
},
],
},
},
};
```
@@ -0,0 +1,132 @@
---
title: Typescript
description: Generate docs from Typescript definitions
---
## Usage
```package-install
@hanzo/docs-typescript
```
### UI Integration
It comes with the `AutoTypeTable` component. Learn more about [Auto Type Table](/docs/ui/components/auto-type-table).
### MDX Integration
You can use it as a remark plugin:
```ts title="source.config.ts" tab="Fumadocs MDX"
import {
remarkAutoTypeTable,
createGenerator,
createFileSystemGeneratorCache,
} from '@hanzo/docs-typescript';
import { defineConfig } from '@hanzo/docs-mdx/config';
const generator = createGenerator({
// recommended: choose a directory for cache
cache: createFileSystemGeneratorCache('.next/@hanzo/docs-typescript'),
});
export default defineConfig({
mdxOptions: {
remarkPlugins: [[remarkAutoTypeTable, { generator }]],
},
});
```
```ts tab="MDX Compiler"
import {
remarkAutoTypeTable,
createGenerator,
createFileSystemGeneratorCache,
} from '@hanzo/docs-typescript';
import { compile } from '@mdx-js/mdx';
const generator = createGenerator({
// recommended: choose a directory for cache
cache: createFileSystemGeneratorCache('.next/@hanzo/docs-typescript'),
});
await compile('...', {
remarkPlugins: [[remarkAutoTypeTable, { generator }]],
});
```
It gives you a `auto-type-table` component.
You can use it like [Auto Type Table](/docs/ui/components/auto-type-table), but with additional rules:
- The value of attributes must be string.
- `path` accepts a path relative to the MDX file itself.
- You also need to add [`TypeTable`](/docs/ui/components/type-table) to MDX components.
```ts title="path/to/file.ts"
export interface MyInterface {
name: string;
}
```
```mdx title="page.mdx"
<auto-type-table path="./path/to/file.ts" name="MyInterface" />
```
## Annotations
### Hide
Hide a field by adding `@internal` tsdoc tag.
```ts
interface MyInterface {
/**
* @internal
*/
cache: number;
}
```
### Simplified Type
You can specify the simplified name of a type with the `@remarks` tsdoc tag.
```ts
interface MyInterface {
/**
* @remarks `timestamp` Returned by API. // [!code highlight]
*/
time: number;
}
```
This will make the type of `time` property to be shown as `timestamp`.
### Full type
You can specify the full name of a type with the `@fumadocsType` tsdoc tag.
```ts
interface MyInterface {
/**
* @fumadocsType `MyBeautifulClient` // [!code highlight]
*/
client: MyClient;
}
```
### Type Href
Link the property to other places when used with Type Table.
Type Table will generate anchor ID automatically, you can use it to link to another type table.
```ts
interface MyInterface {
/**
* @fumadocsHref #type-table-temp.ts-MyClass-test
*/
client: MyClient;
}
```
@@ -0,0 +1,290 @@
---
title: Takumi
description: Integrate Takumi for framework agnostic and fast metadata image generation.
---
## Installation
```npm
npm install @takumi-rs/image-response
```
Make sure to add `@takumi-rs/image-response` to `serverExternalPackages` (Next.js) or `external` depends on what bundler you are using.
```ts title="next.config.ts" tab="Next.js"
import type { NextConfig } from 'next';
const config: NextConfig = {
// [!code ++]
serverExternalPackages: ['@takumi-rs/image-response'],
};
```
```ts title="vite.config.ts" tab="Nitro / TanStack Start"
import { defineConfig } from 'vite';
// [!code ++]
import takumiPackageJson from '@takumi-rs/core/package.json' with { type: 'json' };
export default defineConfig({
nitro: {
externals: {
// [!code ++:2]
external: ['@takumi-rs/core'],
traceInclude: Object.keys(takumiPackageJson.optionalDependencies),
},
},
});
```
```ts title="vite.config.ts" tab="Vite"
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
// [!code ++]
external: ['@takumi-rs/image-response'],
},
});
```
```ts title="waku.config.ts" tab="Waku"
import { defineConfig } from 'waku/config';
export default defineConfig({
vite: {
ssr: {
// [!code ++]
external: ['@takumi-rs/image-response'],
},
},
});
```
## Metadata Image
You can generate metadata images dynamically using `@takumi-rs/image-response`.
Add the following under your loader, and define image metadata for pages:
```ts tab="Next.js" title="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.webp'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
```
```tsx tab="Next.js" title="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,
},
};
}
```
```tsx tab="React Router" title="app/lib/og.ts"
export function getPageImage(slugs: string[]) {
const segments = [...slugs, 'image.webp'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
```
```tsx tab="React Router" title="app/routes/docs.tsx"
// [!code ++]
import { getPageImage } from '@/lib/og';
// Somewhere in your component or client loader
return (
<>
{/* [!code ++] */}
<meta property="og:image" content={getPageImage(page.slugs).url} />
</>
);
```
```tsx tab="Vite Based Framework" title="app/lib/source.ts"
// [!code ++:8]
export function getPageImage(slugs: string[]) {
const segments = [...slugs, 'image.webp'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
```
```tsx tab="Vite Based Framework" title="app/routes/docs.tsx"
// [!code ++]
import { getPageImage } from '@/lib/source';
// Somewhere in your component or client loader
return (
<>
{/* [!code ++] */}
<meta property="og:image" content={getPageImage(page.slugs).url} />
</>
);
```
> We append `image.webp` to the end of slugs so that we can access it via `/og/docs/my-page/image.webp`, which results in smaller image sizes. You could use `image.png` if you prefer.
Finally, create a route handler to generate images at build time:
```tsx tab="Next.js" title="app/og/docs/[...slug]/route.tsx"
import { getPageImage, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from '@takumi-rs/image-response';
import { generate as DefaultImage } from '@hanzo/docs-ui/og/takumi';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
{
width: 1200,
height: 630,
format: 'webp',
},
);
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImage(page).segments,
}));
}
```
```tsx tab="React Router" title="app/routes/docs.og.tsx"
import { ImageResponse } from '@takumi-rs/image-response';
import { source } from '@/lib/source';
import { generate as DefaultImage } from '@hanzo/docs-ui/og/takumi';
import type { Route } from './+types/docs.og';
export function loader({ params }: Route.LoaderArgs) {
const slugs = params['*']
.split('/')
.filter((v) => v.length > 0)
.slice(0, -1);
const page = source.getPage(slugs);
if (!page) throw new Response(undefined, { status: 404 });
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
{
width: 1200,
height: 630,
format: 'webp',
},
);
}
```
```tsx tab="Waku" title="src/pages/_api/og/docs/[...slugs]/image.webp.tsx"
import { source } from '@/lib/source';
import { ImageResponse } from '@takumi-rs/image-response';
import { generate as DefaultImage } from '@hanzo/docs-ui/og/takumi';
import { ApiContext } from 'waku/router';
export async function GET(_: Request, { params }: ApiContext<'/og/docs/[...slugs]/image.webp'>) {
const page = source.getPage(params.slugs);
if (!page) return new Response(undefined, { status: 404 });
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
{
width: 1200,
height: 630,
format: 'webp',
},
);
}
export async function getConfig() {
const pages = source
.generateParams()
.map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));
return {
render: 'static' as const,
staticPaths: pages,
} as const;
}
```
```ts tab="React Router" title="app/routes.ts"
import { type RouteConfig, route } from '@react-router/dev/routes';
export default [
// [!code ++]
route('/og/docs/*', 'routes/docs.og.tsx'),
] satisfies RouteConfig;
```
```ts tab="React Router" title="react-router.config.ts"
import type { Config } from '@react-router/dev/config';
import { glob } from 'node:fs/promises';
import { createGetUrl, getSlugs } from '@hanzo/docs-core/source';
const getUrl = createGetUrl('/docs');
export default {
ssr: true,
future: {
v8_middleware: true,
},
// [!code ++:12]
async prerender({ getStaticPaths }) {
const paths = [...getStaticPaths()];
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
const slugs = getSlugs(entry);
paths.push(getUrl(slugs));
paths.push(`/og/docs/${[...slugs, 'image.webp'].join('/')}`);
}
return paths;
},
} satisfies Config;
```
> Takumi comes with pre-bundled full-axis (100-900) [`Geist`](https://vercel.com/font) and `Geist Mono` fonts, so you don't need to worry about it.
See [Takumi's Documentation](https://takumi.kane.tw/docs) for more details or advanced usage.
### Other Templates
There's other available templates, see [Takumi Templates](https://takumi.kane.tw/docs/templates) for a full list.
@@ -0,0 +1,116 @@
---
title: Feedback
description: Receive feedback from your users
---
## Overview
Feedback is crucial for knowing what your reader thinks, and help you to further improve documentation content.
You can integrate a simple feedback system with Fumadocs.
## Installation
Install it using **Fumadocs CLI**.
```npm
npx @hanzo/docs-cli@latest add feedback
```
### Page Feedback
To create a page-level feedback UI, add the `<Feedback />` component to your docs page:
```tsx
import { DocsPage } from '@hanzo/docs-ui/layout/docs/page';
import { Feedback } from '@/components/feedback/client';
import posthog from 'posthog-js';
export default async function Page() {
return (
<DocsPage>
{/* at the bottom of page */}
<Feedback
onSendAction={async (feedback) => {
'use server';
await posthog.capture('on_rate_docs', feedback);
}}
/>
</DocsPage>
);
}
```
- `onSendAction`: fired when user submit feedback.
You can specify a server action, or any function (in client component) to handle the user feedback.
For example, to report user feedback as a `on_rate_docs` event on PostHog.
### Feedback Block
You can also configure block-level feedback (e.g. a feedback popover for each paragraph).
Add the `remark-feedback-block` Remark plugin:
```tsx title="source.config.ts (Fumadocs MDX)"
import {
remarkFeedbackBlock,
type RemarkFeedbackBlockOptions,
} from '@hanzo/docs-core/mdx-plugins/remark-feedback-block';
import { defineConfig } from '@hanzo/docs-mdx/config';
const feedbackOptions: RemarkFeedbackBlockOptions = {
// other options:
};
export default defineConfig({
mdxOptions: {
remarkPlugins: [
// [!code ++]
[remarkFeedbackBlock, feedbackOptions],
],
},
});
```
Then, define the `FeedbackBlock` MDX component:
```tsx title="mdx-components.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 {
return {
...defaultComponents,
FeedbackBlock: ({ children, ...rest }) => (
<FeedbackBlock {...rest} onSendAction={onBlockFeedbackAction}>
{children}
</FeedbackBlock>
),
...components,
};
}
```
- `onSendAction`: fired when user submit feedback.
<Callout type='info' title='Good to know'>
`remark-feedback-block` generates a block ID from its content and order in the page, hence it is also possible to track the blocks from 3rd party services.
</Callout>
### Integrating with GitHub Discussion
To report your feedback to GitHub Discussion, you can copy this file as a starting point:
<include cwd meta="title='lib/github.ts'">
./lib/github.ts
</include>
1. Create your own GitHub App and obtain its app ID and private key.
2. Fill required environment variables.
3. Replace constants like `owner`, `repo`, and `DocsCategory`.
4. Use the `onPageFeedbackAction` & `onBlockFeedbackAction` in your feedback components.
@@ -0,0 +1,354 @@
---
title: AI & LLMs
description: Integrate AI functionality to Fumadocs.
---
## Docs for LLM
You can make your docs site more AI-friendly with dedicated docs content for large language models.
To begin, make a `getLLMText` function that converts pages into static MDX content.
In **Fumadocs MDX**, you can do:
```ts title="lib/get-llm-text.ts"
import { source } from '@/lib/source';
import type { InferPageType } from '@hanzo/docs-core/source';
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title} (${page.url})
${processed}`;
}
```
It requires `includeProcessedMarkdown` to be enabled:
```ts title="source.config.ts"
import { defineDocs } from '@hanzo/docs-mdx/config';
export const docs = defineDocs({
docs: {
// [!code ++:3]
postprocess: {
includeProcessedMarkdown: true,
},
},
});
```
### `llms-full.txt`
A version of docs for AIs to read.
```ts tab="Next.js" title="app/llms-full.txt/route.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
// cached forever
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
```
```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('llms-full.txt', 'routes/llms-full.ts'),
] satisfies RouteConfig;
```
```ts tab="React Router" title="app/routes/llms-full.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
export async function loader() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
```
```ts tab="Tanstack Start" title="src/routes/llms-full[.]txt.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
export const Route = createFileRoute('/llms-full.txt')({
server: {
handlers: {
GET: async () => {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
},
},
},
});
```
### `*.mdx` [#mdx-extension]
Allow AI agents to get the content of a page as Markdown/MDX, by appending `.mdx` to the end of path.
Make a route handler to return page content, and a middleware to point to it:
```ts tab="Next.js" title="app/llms.mdx/docs/[[...slug]]/route.ts"
import { getLLMText } from '@/lib/get-llm-text';
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.generateParams();
}
```
```ts tab="Next.js" title="next.config.ts"
import type { NextConfig } from 'next';
const config: NextConfig = {
// [!code ++:8]
async rewrites() {
return [
{
source: '/docs/:path*.mdx',
destination: '/llms.mdx/docs/:path*',
},
];
},
};
```
```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('llms.mdx/docs/*', 'routes/docs/llms-mdx.ts'),
] satisfies RouteConfig;
```
```ts tab="React Router" title="app/routes/docs/llms-mdx.ts"
import type { Route } from './+types/llms-mdx';
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
const page = source.getPage(slugs);
if (!page) {
return new Response('not found', { status: 404 });
}
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
```
```ts tab="React Router" title="app/root.tsx"
import { rewritePath } from '@hanzo/docs-core/negotiation';
import type { Route } from './+types/root';
// [!code ++:20]
const { rewrite: rewriteLLM } = rewritePath('/docs{/*path}.mdx', '/llms.mdx/docs{/*path}');
const serverMiddleware: Route.MiddlewareFunction = async ({ request }, next) => {
const url = new URL(request.url);
const path = rewriteLLM(url.pathname);
if (path) return Response.redirect(new URL(path, url));
return next();
};
export const middleware = [serverMiddleware];
```
```ts tab="React Router" title="react-router.config.ts"
import type { Config } from '@react-router/dev/config';
export default {
// enable middleware [!code ++:3]
future: {
v8_middleware: true,
},
} satisfies Config;
```
```ts tab="Tanstack Start" title="src/routes/llms[.]mdx.docs.$.ts"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { source } from '@/lib/source';
export const Route = createFileRoute('/llms.mdx/docs/$')({
server: {
handlers: {
GET: async ({ params }) => {
const slugs = params._splat?.split('/') ?? [];
const page = source.getPage(slugs);
if (!page) throw notFound();
return new Response(await page.data.getText('processed'), {
headers: {
'Content-Type': 'text/markdown',
},
});
},
},
},
});
```
```ts tab="Tanstack Start" title="src/start.ts"
import { createMiddleware, createStart } from '@tanstack/react-start';
import { rewritePath } from '@hanzo/docs-core/negotiation';
import { redirect } from '@tanstack/react-router';
const { rewrite: rewriteLLM } = rewritePath('/docs{/*path}.mdx', '/llms.mdx/docs{/*path}');
const llmMiddleware = createMiddleware().server(({ next, request }) => {
const url = new URL(request.url);
const path = rewriteLLM(url.pathname);
if (path) {
throw redirect(new URL(path, url));
}
return next();
});
export const startInstance = createStart(() => {
return {
requestMiddleware: [llmMiddleware],
};
});
```
#### `Accept`
To serve the Markdown content instead for AI agents, you can leverage the `Accept` header.
```ts title="proxy.ts (Next.js)"
import { NextRequest, NextResponse } from 'next/server';
import { isMarkdownPreferred, rewritePath } from '@hanzo/docs-core/negotiation';
const { rewrite: rewriteLLM } = rewritePath('/docs{/*path}', '/llms.mdx/docs{/*path}');
export default function proxy(request: NextRequest) {
if (isMarkdownPreferred(request)) {
const result = rewriteLLM(request.nextUrl.pathname);
if (result) {
return NextResponse.rewrite(new URL(result, request.nextUrl));
}
}
return NextResponse.next();
}
```
### Page Actions
Common page actions for AI, require [`*.mdx`](#mdx-extension) to be implemented first.
![AI Page Actions](/docs/ai-page-actions.png)
```npm
npx @hanzo/docs-cli add ai/page-actions
```
Use it in your docs page like:
```tsx title="app/docs/[[...slug]]/page.tsx"
<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
<ViewOptions
markdownUrl={`${page.url}.mdx`}
githubUrl={`https://github.com/${owner}/${repo}/blob/dev/apps/docs/content/docs/${page.path}`}
/>
</div>
```
## Ask AI
![AI Search](/docs/ai-search.png)
You can install the AI search dialog using Fumadocs CLI:
```npm
npx @hanzo/docs-cli add ai/search
```
By default, it's configured for [Inkeep AI](https://inkeep.com) using Vercel AI SDK.
To setup for Inkeep AI:
1. Add your Inkeep API key to environment variables:
```dotenv
INKEEP_API_KEY="..."
```
2. 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';
import { buttonVariants } from '@/components/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.
Note that Fumadocs doesn't provide the AI model, it's up to you.
Your AI model can use the `llms-full.txt` file generated above, or more diversified sources of information when combined with 3rd party solutions.
@@ -0,0 +1,148 @@
---
title: Vite
description: Configure Fumadocs Story for Vite-based frameworks.
---
## Installation
```npm
npm i @hanzo/docs-story ts-morph
```
Add the Tailwind CSS preset.
```css title="Tailwind CSS"
/* [!code ++] */
@import '@hanzo/docs-story/css/preset.css';
```
Externalize `ts-morph`:
```ts title="vite.config.ts"
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
// [!code ++]
external: ['ts-morph'],
},
});
```
### Create a Story
Create a story factory to store your global settings:
```ts title="lib/story.ts"
import { createFileSystemCache, defineStoryFactory } from '@hanzo/docs-story';
export const { defineStory, getStoryPayloads } = defineStoryFactory({
// for Vercel, this is required: choose a directory for cache.
cache: process.env.NODE_ENV === 'production' ? createFileSystemCache('.story/cache') : undefined,
tsc: {
// we use tsc to generate controls from types
// you can pass TypeScript options here
},
});
```
Now create your first story:
```tsx tab="my-component.story.tsx"
import { defineStory } from '@/lib/story';
import type { MyComponent } from './my-component';
// must be exported as `story`
// or set the `name` option to match export name.
export const story = defineStory<typeof MyComponent>(
// the path of this file:
'src/components/my-component.story.tsx',
{
args: {
// default props (recommended)
initial: {},
},
},
);
```
```tsx tab="my-component.story.client.tsx"
import { createStoryClient } from '@hanzo/docs-story/client';
import { MyComponent } from './my-component';
import type { story } from './my-component.story';
export const storyClient = createStoryClient<typeof story>({
Component: MyComponent,
});
```
```tsx tab="my-component.tsx"
export interface MyComponentProps {
title?: string;
}
export function MyComponent({ title }: MyComponentProps) {
return <p>{title}</p>;
}
```
Story clients require payloads from server to render, you can leverage the SSR functionality of your React.js framework.
For instance, on Tanstack Start:
```tsx title="docs/$.tsx"
import { story as calloutStory } from '@/components/my-component.story';
import { storyClient as calloutStoryClient } from '@/components/my-component.story.client';
import { StoryPayloadProvider } from '@hanzo/docs-story/client';
import { getStoryPayloads } from '@/lib/story';
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
// load story payloads from server [!code ++]
return serverStoryLoader();
},
});
const stories = {
callout: calloutStory,
};
const clientStories = {
callout: calloutStoryClient,
};
const serverStoryLoader = createServerFn({
method: 'GET',
}).handler(async () => {
return {
// generate payloads [!code ++]
storyPayloads: await getStoryPayloads(stories),
};
});
function Page() {
const data = Route.useLoaderData();
return (
<StoryPayloadProvider payloads={data.storyPayloads} clients={clientStories}>
{/* children */}
</StoryPayloadProvider>
);
}
```
Now you can render the story like:
```mdx title="index.mdx" tab="From Instance"
import { storyClient } from '@/components/my-component.story.client';
<storyClient.WithControl />
```
```mdx title="index.mdx" tab="From Payload Name"
import { StoryWithControl } from '@hanzo/docs-story/client';
<StoryWithControl name="callout" />
```
@@ -0,0 +1,87 @@
---
title: Validate Links
description: Ensure your links are correct.
---
## Setup
You can use [`next-validate-link`](https://next-validate-link.vercel.app) to validate your links in content files.
> This guide is mainly for **Fumadocs MDX**, see the docs of `next-validate-link` for other setups.
Create a script:
```ts title="scripts/lint.ts"
import { type FileObject, printErrors, scanURLs, validateFiles } from 'next-validate-link';
import type { InferPageType } from '@hanzo/docs-core/source';
import { source } from '@/lib/source';
async function checkLinks() {
const scanned = await scanURLs({
// pick a preset for your React framework
preset: 'next',
populate: {
'docs/[[...slug]]': source.getPages().map((page) => {
return {
value: {
slug: page.slugs,
},
hashes: getHeadings(page),
};
}),
},
});
printErrors(
await validateFiles(await getFiles(), {
scanned,
// check `href` attributes in different MDX components
markdown: {
components: {
Card: { attributes: ['href'] },
},
},
// check relative paths
checkRelativePaths: 'as-url',
}),
true,
);
}
function getHeadings({ data }: InferPageType<typeof source>): string[] {
return data.toc.map((item) => item.url.slice(1));
}
function getFiles() {
const promises = source.getPages().map(
async (page): Promise<FileObject> => ({
path: page.absolutePath,
content: await page.data.getText('raw'),
url: page.url,
data: page.data,
}),
);
return Promise.all(promises);
}
void checkLinks();
```
### Running Lint
To access the `source` object outside of app runtime, configure [Fumadocs MDX Loader](/docs/mdx/loader/bun) for Bun.
Then, run the script to validate links:
```bash
bun ./scripts/lint.ts
```
<Callout title="Node.js Loader">
You might need to configure TypeScript transpiling (or newer version of Node.js), and [Fumadocs MDX Loader](/docs/mdx/loader/node).
Without special reasons, using Bun or [`tsx`](https://github.com/privatenumber/tsx) would be simpler.
</Callout>
@@ -0,0 +1,224 @@
---
title: React Router
description: Support i18n routing on your React Router + Fumadocs app.
---
## Setup
Define the i18n configurations in a file, we will import it with `@/lib/i18n` in this guide.
```ts title="app/lib/i18n.ts"
import { defineI18n } from '@hanzo/docs-core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['cn', 'en'],
});
```
> See [available options](/docs/headless/internationalization) for i18n config.
### Routing
Add `:lang` prefix to all your pages.
```ts title="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code highlight:2]
route(':lang', 'routes/home.tsx'),
route(':lang/docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
] satisfies RouteConfig;
```
#### Make locale optional
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';
export default [
// [!code highlight:2]
route(':lang?', 'routes/home.tsx'),
route(':lang?/docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
] satisfies RouteConfig;
```
### Pages
Provide UI translations and other config to `<RootProvider />`, the English translations are used as fallbacks.
```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';
import { i18n } from '@/lib/i18n';
import './app.css';
// [!code ++:11]
const { provider } = defineI18nUI(i18n, {
translations: {
en: {
displayName: 'English',
},
cn: {
displayName: 'Chinese',
search: '搜尋文檔',
},
},
});
export function Layout({ children }: { children: React.ReactNode }) {
const { lang = i18n.defaultLanguage } = useParams();
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="flex flex-col min-h-screen">
<ReactRouterProvider>
<RootProvider
// [!code ++]
i18n={provider(lang)}
>
{children}
</RootProvider>
</ReactRouterProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
```
### 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"
import { i18n } from '@/lib/i18n';
import { loader } from '@hanzo/docs-core/source';
export const source = loader({
i18n, // [!code ++]
// other options
});
```
```tsx tab="Home Layout" title="app/routes/home.tsx"
import type { Route } from './+types/home';
import { HomeLayout } from '@hanzo/docs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Home({ params }: Route.ComponentProps) {
return (
// [!code highlight]
<HomeLayout {...baseOptions(params.lang)}></HomeLayout>
);
}
```
```tsx tab="Docs Page" title="app/routes/docs.tsx"
import type { Route } from './+types/docs';
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { source } from '@/lib/source';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { baseOptions } from '@/lib/layout.shared';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
// [!code --]
const page = source.getPage(slugs);
// [!code ++]
const page = source.getPage(slugs, params.lang);
if (!page) throw new Response('Not found', { status: 404 });
return {
path: page.path,
// [!code --]
tree: source.getPageTree(),
// [!code ++]
tree: source.getPageTree(params.lang),
};
}
export default function Page({ loaderData, params }: Route.ComponentProps) {
const { tree, path } = loaderData;
return (
<DocsLayout
// [!code highlight]
{...baseOptions(params.lang)}
tree={tree as PageTree.Root}
></DocsLayout>
);
}
```
### Search
Configure i18n on your search solution.
- **Built-in Search (Orama):** See [Internationalization](/docs/headless/search/orama#internationalization).
- **Cloud Solutions (e.g. Algolia):** They usually have official support for multilingual.
## Writing Documents
See [i18n routing](/docs/page-conventions#i18n-routing) to learn how to create pages for specific locales.
### Navigation
Fumadocs only handles navigation for its own layouts (e.g. sidebar).
For other places, you can use the `useParams` hook to get the locale from url.
```tsx
import { Link, useParams } from 'react-router';
const { lang } = useParams();
<Link to={`/${lang}/about`}>About Us</Link>;
```
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 '@hanzo/docs-core/dynamic-link';
<DynamicLink href="/[lang]/another-page">This is a link</DynamicLink>
```
@@ -0,0 +1,94 @@
---
title: React Router
description: Setup Fumadocs on React Router.
---
## 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="app/app.css"
@import 'tailwindcss';
/* [!code ++:2] */
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
```
### Create Pages
Update your routes:
```ts title="routes.ts"
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
// [!code ++:2]
route('docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
] satisfies RouteConfig;
```
Create the following files:
<include cwd meta='tab="app/lib/layout.shared.tsx"'>
../../examples/react-router-min/app/lib/layout.shared.tsx
</include>
<include cwd meta='tab="app/routes/docs.tsx"'>
../../examples/react-router-min/app/routes/docs.tsx
</include>
<include cwd meta='tab="app/routes/search.ts"'>
../../examples/react-router-min/app/routes/search.ts
</include>
Wrap your entire app under Fumadocs providers:
```tsx title="root.tsx"
import { Links, Meta, Scripts, ScrollRestoration } from 'react-router';
import { RootProvider } from '@hanzo/docs-ui/provider/react-router';
import './app.css';
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="flex flex-col min-h-screen">
{/* [!code ++] */}
<RootProvider>{children}</RootProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
```
### Done
You can start writing documents at `content/docs`:
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
I love Fumadocs
```
@@ -0,0 +1,89 @@
---
title: Tanstack Start
description: Setup Fumadocs on Tanstack Start.
---
## 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="styles/app.css"
@import 'tailwindcss';
/* [!code ++:2] */
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
```
### Create Pages
Create the following routes:
<include cwd meta='tab="lib/layout.shared.tsx"'>
../../examples/tanstack-start-min/src/lib/layout.shared.tsx
</include>
<include cwd meta='tab="routes/docs/$.tsx"'>
../../examples/tanstack-start-min/src/routes/docs/$.tsx
</include>
<include cwd meta='tab="routes/api/search.ts"'>
../../examples/tanstack-start-min/src/routes/api/search.ts
</include>
Wrap your entire app under Fumadocs providers:
```tsx title="__root.tsx"
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router';
import * as React from 'react';
import { RootProvider } from '@hanzo/docs-ui/provider/tanstack';
export const Route = createRootRoute({
component: RootComponent,
});
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body className="flex flex-col min-h-screen">
{/* [!code ++] */}
<RootProvider>{children}</RootProvider>
<Scripts />
</body>
</html>
);
}
```
### Done
You can start writing documents at `content/docs`:
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
I love Fumadocs
```
@@ -0,0 +1,416 @@
---
title: Markdown
description: How to write documents
---
## Introduction
Fumadocs provides many useful extensions to MDX, a markup language. Here is a brief introduction to the default MDX syntax of Fumadocs.
> MDX is not the only supported format of Fumadocs. In fact, you can use any renderer such as headless CMS.
>
> See [Custom Content Source](/docs/integrations/content/custom) for details.
## MDX
We recommend MDX, a superset of Markdown with JSX syntax.
It allows you to import components, and use them in the document, or even writing JavaScript.
See:
- [MDX Syntax](https://mdxjs.com/docs/what-is-mdx/#mdx-syntax).
- GFM (GitHub Flavored Markdown) is also supported, see [GFM Specification](https://github.github.com/gfm).
```mdx
import { Component } from './component';
<Component name="Hello" />
## Heading
### Heading
#### Heading
Hello World, **Bold**, _Italic_, ~~Hidden~~
1. First
2. Second
3. Third
- Item 1
- Item 2
> Quote here
![alt](/image.png)
| Table | Description |
| ----- | ----------- |
| Hello | World |
```
### Frontmatter
Fumadocs support YAML-based frontmatter by default, `title` represents the page title (`h1`) in Fumadocs UI.
```mdx
---
title: This is a document
---
...
```
For this reason, h1 title (`# Heading`) is usually unused when writing Markdown/MDX unless you have custom logic for page title rendering.
<Callout title='In Fumadocs MDX'>
You can use the [`schema`](/docs/mdx/collections#schema-1) option to add frontmatter properties.
</Callout>
### Auto Links
Internal links use the `<Link />` component of your React framework (e.g. `next/link`) to allow prefetching and avoid hard-reload.
External links will get the default `rel="noreferrer noopener" target="_blank"` attributes for security.
```mdx
[My Link](https://github.github.com/gfm)
This also works: https://github.github.com/gfm.
```
### Cards
Useful for adding links.
```mdx
import { HomeIcon } from 'lucide-react';
<Cards>
<Card
href="https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating"
title="Fetching, Caching, and Revalidating"
>
Learn more about caching in Next.js
</Card>
<Card title="href is optional">Learn more about `fetch` in Next.js.</Card>
<Card icon={<HomeIcon />} href="/" title="Home">
You can include icons too.
</Card>
</Cards>
```
<Cards>
<Card
href="https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating"
title="Fetching, Caching, and Revalidating"
>
Learn more about caching in Next.js
</Card>
<Card title="href is optional">Learn more about `fetch` in Next.js.</Card>
<Card icon={<HomeIcon />} href="/" title="Home">
You can include icons too.
</Card>
</Cards>
#### "Further Reading" Section
You can do something like:
```tsx title="page.tsx"
import { getPageTreePeers } from '@hanzo/docs-core/page-tree';
import { source } from '@/lib/source';
<Cards>
{getPageTreePeers(source.getPageTree(), '/docs/my-page').map((peer) => (
<Card key={peer.url} title={peer.name} href={peer.url}>
{peer.description}
</Card>
))}
</Cards>;
```
This will show the other pages in the same folder as cards.
<DocsCategory url="/docs/ui/navigation" />
### Callouts
Useful for adding tips/warnings, it is included by default. You can specify the type of callout:
- `info` (default)
- `warn`/`warning`
- `error`
- `success`
- `idea`
```mdx
<Callout>Hello World</Callout>
<Callout title="Title" type="warn">
Hello World
</Callout>
<Callout title="Title" type="error">
Hello World
</Callout>
<Callout title="Title" type="idea">
Hello World
</Callout>
```
<Callout>Hello World</Callout>
<Callout title="Title" type="warn">
Hello World
</Callout>
<Callout title="Title" type="error">
Hello World
</Callout>
<Callout title="Title" type="idea">
Hello World
</Callout>
### Headings
An anchor is automatically applied to each heading, it sanitizes invalid characters like spaces. (e.g. `Hello World` to `hello-world`)
```md
# Hello `World`
```
#### TOC Settings
The table of contents (TOC) will be generated based on headings, you can also customise the effects of headings:
```md
# Heading [!toc]
This heading will be hidden from TOC.
# Another Heading [toc]
This heading will **only** be visible in TOC, you can use it to add additional TOC items.
Like headings rendered in a React component:
<MyComp />
```
#### Custom Anchor
You can add `[#slug]` to customise heading anchors.
```md
# heading [#my-heading-id]
```
You can also chain it with TOC settings like:
```md
# heading [toc] [#my-heading-id]
```
To link people to a specific heading, add the heading id to hash fragment: `/page#my-heading-id`.
### Codeblock
Syntax Highlighting is supported by default using [Rehype Code](/docs/headless/mdx/rehype-code).
````mdx
```js
console.log('Hello World');
```
```js title="My Title"
console.log('Hello World');
```
````
#### Line Numbers
Show line numbers, it also works with Twoslash and other transformers.
````mdx tab="Input"
```ts twoslash lineNumbers
const a = 'Hello World';
// ^?
console.log(a); // [!code highlight]
```
````
```ts twoslash lineNumbers tab="Output"
const a = 'Hello World';
// ^?
console.log(a); // [!code highlight]
```
You can set the initial value of line numbers.
````mdx tab="Input"
```js lineNumbers=4
function main() {
console.log('starts from 4');
return 0;
}
```
````
```js lineNumbers=4 tab="Output"
function main() {
console.log('starts from 4');
return 0;
}
```
#### Shiki Transformers
We support some of the [Shiki Transformers](https://shiki.style/packages/transformers), allowing you to highlight/style specific lines.
````md tab="Input"
```tsx
// highlight a line
<div>Hello World</div> // [\!code highlight]
// highlight a word
// [\!code word:Fumadocs]
<div>Fumadocs</div>
// diff styles
console.log('hewwo'); // [\!code --]
console.log('hello'); // [\!code ++]
// focus
return new ResizeObserver(() => {}) // [\!code focus]
```
````
```tsx tab="Output"
// highlight a line
<div>Hello World</div> // [!code highlight]
// highlight a word
// [!code word:Fumadocs]
<div>Fumadocs</div>
// diff styles:
console.log('hewwo'); // [!code --]
console.log('hello'); // [!code ++]
// focus
return new ResizeObserver(() => {}) // [!code focus]
```
#### Tab Groups
````mdx
```ts tab="Tab 1"
console.log('A');
```
```ts tab="Tab 2"
console.log('B');
```
````
```ts tab="Tab 1"
console.log('A');
```
```ts tab="Tab 2"
console.log('B');
```
While disabled by default, you use MDX in tab values by configuring the remark plugin:
```ts tab="Fumadocs MDX" title="source.config.ts"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
remarkCodeTabOptions: {
parseMdx: true, // [!code ++]
},
},
});
```
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkCodeTab } from '@hanzo/docs-core/mdx-plugins';
await compile('...', {
remarkPlugins: [
[
remarkCodeTab,
{
parseMdx: true, // [!code ++]
},
],
],
});
```
````mdx
```ts tab="<Building /> Tab 1"
console.log('A');
```
```ts tab="<Rocket /> Tab 2"
console.log('B');
```
````
```ts tab="<Building /> Tab 1"
console.log('A');
```
```ts tab="<Rocket /> Tab 2"
console.log('B');
```
### Include
> This is only available on **Fumadocs MDX**.
Reference another file (can also be a Markdown/MDX document).
Specify the target file path in `<include>` tag (relative to the MDX file itself).
```mdx title="page.mdx"
<include>./another.mdx</include>
```
See [other usages of include](/docs/mdx/include).
### NPM Commands
Useful for generating commands for installing packages with different package managers.
````md tab="Input"
```npm
npm i next -D
```
````
```npm tab="Output"
npm i next -D
```
See [`remark-npm`](/docs/headless/mdx/remark-npm) for details.
### Other Components
You can configure & use the [built-in components](/docs/ui/components) in your MDX document, such as Tabs, Accordions and Zoomable Image.
## Additional Features
You may be interested:
<DocsCategory />
@@ -0,0 +1,57 @@
### Replace Search Dialog
Replace the search dialog with yours from [`<RootProvider />`](/docs/ui/layouts/root-provider):
```tsx
import { RootProvider } from '@hanzo/docs-ui/provider/<framework>';
// [!code ++]
import SearchDialog from '@/components/search';
<RootProvider
// [!code ++:3]
search={{
SearchDialog,
}}
>
{children}
</RootProvider>;
```
If it was in a server component, you would need a separate client component for provider to pass functions:
```tsx tab="provider.tsx"
'use client';
import { RootProvider } from '@hanzo/docs-ui/provider/<framework>';
import SearchDialog from '@/components/search';
import type { ReactNode } from 'react';
export function Provider({ children }: { children: ReactNode }) {
return (
<RootProvider
search={{
SearchDialog,
}}
>
{children}
</RootProvider>
);
}
```
```tsx tab="app/layout.tsx"
import { Provider } from './provider';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{/* [!code --] */}
<RootProvider>{children}</RootProvider>
{/* [!code ++] */}
<Provider>{children}</Provider>
</body>
</html>
);
}
```
@@ -0,0 +1,20 @@
---
title: Mixedbread
description: Using Mixedbread with Fumadocs UI.
---
## Setup
1. Integrate [Mixedbread Search](/docs/headless/search/mixedbread).
2. Create a search dialog component.
<include meta='title="components/search.tsx"'>./mixedbread.tsx</include>
<include>.shared.mdx</include>
### Tag Filter
Optionally, you can add UI for filtering results by tags. Configure [Tag Filter](/docs/headless/search/mixedbread#tag-filter) on search server and add the following:
<include>.tag-filter.mdx</include>
@@ -0,0 +1,48 @@
'use client';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogFooter,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from '@hanzo/docs-ui/components/dialog/search';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { useI18n } from '@hanzo/docs-ui/contexts/i18n';
export default function CustomSearchDialog(props: SharedProps) {
const { locale } = useI18n(); // (optional) for i18n
const { search, setSearch, query } = useDocsSearch({
type: 'fetch',
api: '/api/search',
locale,
});
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>
<a
href="https://mixedbread.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-fd-muted-foreground"
>
Search powered by Mixedbread
</a>
</SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
}
@@ -0,0 +1,93 @@
---
title: Remark Structure
description: Extract information from your documents, useful for implementing document search
---
## Usage
Add it as a remark plugin.
```ts tab="MDX Compiler"
import { compile } from '@mdx-js/mdx';
import { remarkStructure } from '@hanzo/docs-core/mdx-plugins';
const vfile = await compile('...', {
remarkPlugins: [remarkStructure],
});
```
```ts tab="Fumadocs MDX" title="source.config.ts"
import { defineConfig } from '@hanzo/docs-mdx/config';
export default defineConfig({
mdxOptions: {
// This plugin is enabled by default on Fumadocs MDX
// options:
remarkStructureOptions: {},
},
});
```
Extracted information could be found in `vfile.data.structuredData`, you may
write your own plugin to convert it into a MDX export.
### Options
<auto-type-table
path="../props.ts"
name="StructureOptions"
/>
### Output
A list of headings and contents. Paragraphs will be extracted to the `contents`
array, each item contains a `heading` prop indicating the heading of paragraph.
<Callout title="Note">A heading can have multiple paragraphs.</Callout>
#### Heading
| Prop | |
| --------- | ------------------------------------ |
| `id` | unique identifier or slug of heading |
| `content` | Text content (Markdown) |
#### Content
| Prop | |
| --------- | ------------------------------- |
| `heading` | Heading of paragraph (nullable) |
| `content` | Text content (Markdown) |
The content is stringified as Markdown text, you can customise it using the [`stringify`](#options) option.
## As a Function
Accepts MDX/markdown content and return structurized data.
```ts
import { structure } from '@hanzo/docs-core/mdx-plugins';
structure(page.body.raw);
```
<Callout title="Tip" className="mt-4">
If you have custom remark plugins enabled, such as
`remark-math`, you have to pass these plugins to the function. This avoids unreadable content on paragraphs.
```ts
import { structure } from '@hanzo/docs-core/mdx-plugins';
import remarkMath from 'remark-math';
structure(page.body.raw, [remarkMath]);
```
</Callout>
### Parameters
| Parameter | |
| --------------- | ---------------------- |
| `content` | MDX/markdown content |
| `remarkPlugins` | List of remark plugins |
| `options` | Custom options |
@@ -0,0 +1,234 @@
---
title: Page Slugs & Page Tree
description: Customise generated page slugs and page tree.
---
> This guide only applies for content sources that uses `loader()` API, such as **Fumadocs MDX**.
## Overview
Fumadocs generates **page slugs** and **page tree** (sidebar items) from your content directory using [`loader()`](/docs/headless/source-api),
the routing functionality will be handled by your React framework.
You can define folders and pages similar to file-system based routing.
<Files>
<Folder name="content/docs (content directory)" defaultOpen>
<File name="index.mdx" />
<File name="getting-started.mdx" />
</Folder>
</Files>
To customise its output further, you may use [Loader Plugins](/docs/headless/source-api/plugins).
## File
For [MDX](https://mdxjs.com) & Markdown file, you can customise page information from frontmatter.
```mdx
---
title: My Page
description: Best document ever
icon: HomeIcon
---
## Learn More
```
Fumadocs detects from the following properties to construct page trees.
| name | description |
| ------------- | ------------------------------------- |
| `title` | The title of page |
| `description` | The description of page |
| `icon` | The name of icon, see [Icons](#icons) |
<Callout title='Good to Know'>
Page information is supplied by the content source such as **Fumadocs MDX**.
On Fumadocs MDX, you can specify a [`schema`](/docs/mdx/collections#schema-1) option to customise frontmatter schema.
</Callout>
### Slugs
The slugs of a page are generated from its file path.
| path (relative to content folder) | slugs |
| --------------------------------- | ----------------- |
| `./dir/page.mdx` | `['dir', 'page']` |
| `./dir/index.mdx` | `['dir']` |
## Folder
Organize multiple pages, you can create a [Meta file](#meta) to customise folders.
### Folder Group
By default, putting a file into folder will change its slugs.
You can wrap the folder name in parentheses to avoid impacting the slugs of child files.
| path (relative to content folder) | slugs |
| --------------------------------- | ---------- |
| `./(group-name)/page.mdx` | `['page']` |
### Root Folder
Marks the folder as a root folder, only items in the opened root folder will be visible.
```json title="meta.json"
{
"title": "Name of Folder",
"description": "The description of root folder (optional)",
"root": true
}
```
For example, when you are opening root folder `framework`, the other folders (e.g. `headless`) are not shown on the sidebar and other navigation elements.
<Files>
<Folder name="framework" defaultOpen>
<File name="index.mdx" />
<File name="current-page.mdx" className="text-fd-primary! bg-fd-primary/10!" />
<File name="other-pages.mdx" />
</Folder>
<Folder name="headless (hidden)" className="opacity-50" disabled defaultOpen>
<File name="my-page.mdx" />
</Folder>
</Files>
<Callout title='Fumadocs UI'>
Fumadocs UI renders root folders as [Sidebar Tabs](/docs/ui/layouts/docs#sidebar-tabs), which allows user to switch between them.
</Callout>
## Meta
Customise folders by creating a `meta.json` file in the folder.
```json title="meta.json"
{
"title": "Display Name",
"icon": "MyIcon",
"pages": ["index", "getting-started"],
"defaultOpen": true
}
```
| name | description |
| ------------- | -------------------------------------------- |
| `title` | Display name |
| `icon` | The name of icon, see [Icons](#icons) |
| `defaultOpen` | Open the folder by default |
| `collapsible` | Collapsible folder content (default: `true`) |
| `pages` | Folder items (see below) |
### Pages
Folder items are sorted alphabetically by default, you can add or control the order of items using `pages`.
```json title="meta.json"
{
"pages": ["index", "getting-started"]
}
```
> When specified, items are not included unless they are listed in `pages`.
| Type | Syntax | Description |
| ----------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Path** | `./path/to/page` | a path to page or folder. |
| **Separator** | `---Label---`<br/>`---[Icon]Label---` | a separator between two sections (icon supported). |
| **Link** | `[Text](url)`<br/>`[Icon][Text](url)`<br/>`external:[Text](url)` | insert links (icon supported)<br/>add a `external:` prefix to mark links as external. |
| **Rest** | `...` | include remaining pages (sorted alphabetically). |
| **Reversed Rest** | `z...a` | reversed **Rest** item. |
| **Extract** | `...folder` | extract the items from a folder. |
| **Except** | `!item` | Exclude an item from `...` or `z...a`. |
```json title="meta.json"
{
"pages": [
"components",
"---My Separator---",
"...folder",
"...",
"!file",
"!otherFolder",
"[Vercel](https://vercel.com)",
"[Triangle][Vercel](https://vercel.com)"
]
}
```
<Callout type='warn' title="No Duplicated URL">
The same page URL must not appear more than once in the entire page tree.
Duplicated page items are not allowed for the same reason.
This is intentional, such that Fumadocs can locate the active tree item purely based on current pathname.
</Callout>
## Icons
Since Fumadocs doesn't include an icon library, you have to convert the icon names to JSX elements in runtime, and render it as a component.
You can add an [`icon` handler](/docs/headless/source-api#icons) to `loader()`.
## i18n Routing
You can define different style for i18n routing.
```ts title="lib/i18n.ts"
import type { I18nConfig } from '@hanzo/docs-core/i18n';
export const i18n: I18nConfig = {
// default
parser: 'dot',
// or
parser: 'dir',
};
```
<Tabs items={['dot', 'dir']}>
<Tab>
Add Markdown/meta files for different languages by attending `.{locale}` to your file name, like:
<Files>
<Folder name="content/docs" defaultOpen>
<File name="meta.json" />
<File name="meta.cn.json" />
<File name="get-started.mdx" />
<File name="get-started.cn.mdx" />
</Folder>
</Files>
For the default locale, the locale code is optional.
</Tab>
<Tab>
All content files are grouped by language folders:
<Files>
<Folder name="content/docs" defaultOpen>
<Folder name='en' defaultOpen>
<File name="meta.json" />
<File name="get-started.mdx" />
</Folder>
<Folder name='cn' defaultOpen>
<File name="meta.json" />
<File name="get-started.mdx" />
</Folder>
</Folder>
</Files>
</Tab>
</Tabs>
@@ -0,0 +1,124 @@
---
title: Mixedbread
description: Integrate Mixedbread Search with Fumadocs
---
## Introduction
The Mixedbread Integration uses vector search to provide semantic search capabilities for your documentation. It indexes your documentation content into a store, enabling users to search using natural language queries and find relevant content based on meaning rather than just keyword matching.
## Setup
### Get your API Key
1. Sign up at [Mixedbread](https://platform.mixedbread.com)
2. Navigate to [API Keys](https://platform.mixedbread.com/platform?next=api-keys)
3. Create a new API key and store it in your environment variables
### Create a Store
To sync your documentation, you'll need to create a store:
1. Go to the [Stores](https://platform.mixedbread.com/platform?next=stores) in your Mixedbread dashboard
2. Create a new store for your documentation
3. Copy the store ID
### Sync Documentation
Use the [Mixedbread CLI](https://www.mixedbread.com/cli) to sync your documentation:
Install the CLI:
```package-install
@mixedbread/cli -D
```
Configure authentication and sync your documentation:
```bash
# Configure authentication
mxbai config keys add YOUR_API_KEY
# Sync your documentation
mxbai vs sync YOUR_STORE_ID "./content/docs"
```
The CLI will automatically detect changes in your documentation and update the store accordingly.
### Workflow
You can automatically sync your documentation by adding a sync script to your `package.json`:
```json
{
"scripts": {
"build": "... && mxbai vs sync YOUR_STORE_ID './content/docs' --ci"
}
}
```
### Search API
Create an API route to handle search requests server-side:
```ts title="app/api/search/route.ts"
import { createMixedbreadSearchAPI } from '@hanzo/docs-core/search/mixedbread';
import Mixedbread from '@mixedbread/sdk';
const client = new Mixedbread({
apiKey: 'YOUR_API_KEY',
});
export const { GET } = createMixedbreadSearchAPI({
client,
storeIdentifier: 'YOUR_STORE_ID',
});
```
### Search Client
- **Fumadocs UI**: see [Search UI](/docs/search/mixedbread) for details.
- **Search Client**:
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const client = useDocsSearch({
type: 'fetch',
api: '/api/search',
});
```
## Options
### Tag Filter
To filter search results by tags, add a tag field to your document metadata:
```md
---
title: Mixedbread
description: Integrate Mixedbread Search with Fumadocs
url: /docs/headless/search/mixedbread
// [!code ++]
tag: docs
---
```
And update your search client:
- **Fumadocs UI**: Enable [Tag Filter](/docs/search/mixedbread#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({
type: 'fetch',
api: '/api/search',
tag: '<your tag value>',
// ...
});
```
This allows you to scope searches to specific sections of your documentation.
@@ -0,0 +1,389 @@
---
title: Built-in Search
description: Built-in document search of Fumadocs
---
Fumadocs supports document search with Orama, It is the default but also the recommended option since it can be self-hosted and totally free.
## Setup
Host the server for handling search requests.
### From Source
Create the server from source object.
<include cwd meta='tab="Next.js" title="app/api/search/route.ts"'>
../../examples/next/app/api/search/route.ts
</include>
<include cwd meta='tab="React Router" title="app/routes/search.ts"'>
../../examples/react-router-min/app/routes/search.ts
</include>
<include cwd meta='tab="Tanstack Start" title="src/routes/api/search.ts"'>
../../examples/tanstack-start-min/src/routes/api/search.ts
</include>
<include cwd meta='tab="Waku" title="src/pages/_api/api/search.ts"'>
../../examples/waku/src/pages/_api/api/search.ts
</include>
### 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 tab="Next.js" title="app/api/search/route.ts"
import { source } from '@/lib/source';
import { createSearchAPI } from '@hanzo/docs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
language: 'english',
indexes: source.getPages().map((page) => ({
title: page.data.title,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
})),
});
```
```ts tab="React Router" title="app/routes/search.ts"
import type { Route } from './+types/search';
import { createSearchAPI } from '@hanzo/docs-core/search/server';
import { source } from '@/lib/source';
const server = createSearchAPI('advanced', {
language: 'english',
indexes: source.getPages().map((page) => ({
title: page.data.title,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
})),
});
export async function loader({ request }: Route.LoaderArgs) {
return server.GET(request);
}
```
```ts tab="Tanstack Start" title="src/routes/api/search.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { createSearchAPI } from '@hanzo/docs-core/search/server';
const server = createSearchAPI('advanced', {
language: 'english',
indexes: source.getPages().map((page) => ({
title: page.data.title,
description: page.data.description,
url: page.url,
id: page.url,
structuredData: page.data.structuredData,
})),
});
export const Route = createFileRoute('/api/search')({
server: {
handlers: {
GET: async ({ request }) => server.GET(request),
},
},
});
```
```ts tab="Waku" title="src/pages/_api/api/search.ts"
import { source } from '@/lib/source';
import { createSearchAPI } from '@hanzo/docs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
language: 'english',
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/orama) 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").Client, { 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 { createFromSource } from '@hanzo/docs-core/search/server';
const server = createFromSource(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/orama#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 tab="Next.js" title="app/api/search/route.ts"
import { source } from '@/lib/source';
import { createFromSource } from '@hanzo/docs-core/search/server';
// statically cached [!code highlight:2]
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source);
```
```ts tab="React Router" title="app/routes/search.ts"
// make sure this route is pre-rendered in `react-router.config.ts`.
export async function loader() {
// [!code highlight]
return server.staticGET();
}
```
```ts tab="Tanstack Start" title="src/routes/api/search.ts"
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/search')({
server: {
handlers: {
// [!code highlight]
GET: async () => server.staticGET(),
},
},
});
```
```ts tab="Tanstack Start" title="vite.config.ts"
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tanstackStart({
prerender: {
enabled: true,
},
// [!code ++] pre-render the index file
pages: [{ path: '/api/search' }],
}),
],
});
```
```ts tab="Waku" title="src/pages/_api/api/search.ts"
import { source } from '@/lib/source';
import { createFromSource } from '@hanzo/docs-core/search/server';
// [!code highlight]
export const { staticGET: GET } = createFromSource(source);
// statically cached [!code highlight:3]
export const getConfig = async () => ({
render: 'static',
});
```
> `staticGET` is also available on `createSearchAPI`.
and update your search clients:
- **Fumadocs UI**: use [static client](/docs/search/orama#static) on Search UI.
- **Search Client**: use `static` instead of `fetch`.
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const client = useDocsSearch({
type: 'static',
});
```
<auto-type-table type='Extract<import("@hanzo/docs-core/search/client").Client, { type: "static" }>' />
<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
Make sure your language is on the Orama [Supported Languages](https://docs.orama.com/docs/orama-js/supported-languages) list.
```ts title="app/api/search/route.ts" tab="From Source"
import { source } from '@/lib/source';
import { createFromSource } from '@hanzo/docs-core/search/server';
const server = createFromSource(source, {
localeMap: {
// [locale]: Orama options [!code ++:2]
ru: { language: 'russian' },
en: { language: 'english' },
},
});
```
```ts tab="From Search Indexes"
import { source } from '@/lib/source';
import { createI18nSearchAPI } from '@hanzo/docs-core/search/server';
import { i18n } from '@/lib/i18n';
const server = createI18nSearchAPI('advanced', {
i18n, // [!code ++]
indexes: source.getLanguages().flatMap(({ language, pages }) =>
pages.map((page) => ({
title: page.data.title,
description: page.data.description,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
locale: language === 'ru' ? 'russian' : 'english', // [!code ++]
})),
),
});
```
For **Static Mode**, you should configure from client-side instead:
```tsx title="components/search.tsx"
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { create } from '@orama/orama';
function initOrama(locale?: string) {
return create({
schema: { _: 'string' },
// [!code ++]
language: locale === 'ru' ? 'russian' : 'english',
});
}
function Search() {
const client = useDocsSearch({
type: 'static',
initOrama,
});
// ...
}
```
### Special Languages
Chinese and Japanese require additional configurations:
```npm
npm i @orama/tokenizers
```
<include cwd meta='title="app/api/search/route.ts" tab="createFromSource"'>
../../examples/i18n/app/api/search/route.ts
</include>
```tsx tab="Static mode" title="components/search.tsx"
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import { createTokenizer } from '@orama/tokenizers/mandarin';
import { create } from '@orama/orama';
// [!code focus:8]
function initOrama(locale?: string) {
return create({
schema: { _: 'string' },
components: {
tokenizer: locale === 'cn' ? createTokenizer() : undefined,
},
});
}
function Search() {
const client = useDocsSearch({
type: 'static',
initOrama,
});
// ...
}
```
and update your search clients:
- **Fumadocs UI**: No changes needed, Fumadocs UI handles this when you have i18n configured correctly.
- **Search Client**:
Add `locale` to the search client, this will only allow pages with specified locale to be searchable by the user.
```ts
import { useDocsSearch } from '@hanzo/docs-core/search/client';
const { search, setSearch, query } = useDocsSearch({
type: 'fetch',
locale: 'cn',
});
```
## Headless
You can host the search server on other backend such as Express and Elysia.
```ts
import { initAdvancedSearch } from '@hanzo/docs-core/search/server';
const server = initAdvancedSearch({
// you still have to pass indexes
});
server.search('query', {
// you can specify `locale` and `tag` here
});
```
@@ -0,0 +1,141 @@
---
title: Browser Entry
description: Access collections from browser/client environment.
---
## Usage
The generated outputs are optimized using async imports to optimize for browser environments.
Only doc/docs collections are accessible on browser.
```tsx
import browserCollections from '@hanzo/docs-mdx:collections/browser';
// unloaded entries
console.log(browserCollections['collection name'].raw);
// define client-side collection loader
const clientLoader = browserCollections['collection name'].createClientLoader({
component(
// access the compiled file
{ default: MDX },
// define props (optional)
props: { myProp: string },
) {
return (
<div>
<MDX />
</div>
);
},
});
// server (static generator) return a path
async function serverLoader() {
return source.getPage(slugs)!.path;
}
async function loader() {
const path = await serverLoader();
// preload the path
await clientLoader.preload(path);
return { path };
}
function Page() {
const { path } = loader();
// render the content
return clientLoader.useContent(path, {
myProp: 'hello world',
});
}
```
## Examples
The examples are for non-RSC usage, we recommend using RSC whenever possible for best client-side performance (avoiding hydration).
### Tanstack Start
```tsx title="src/routes/docs/$.tsx"
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';
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
const data = await loader({ data: params._splat?.split('/') ?? [] });
await clientLoader.preload(data.path);
return data;
},
});
const loader = createServerFn({
method: 'GET',
})
.inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = source.getPage(slugs);
if (!page) throw notFound();
return {
path: page.path,
};
});
const clientLoader = browserCollections.docs.createClientLoader({
component({ frontmatter, default: MDX }) {
return (
<div className="prose">
<h1>{frontmatter.title}</h1>
<MDX />
</div>
);
},
});
function Page() {
const data = Route.useLoaderData();
return clientLoader.useContent(data.path);
}
```
### React Router
```tsx
import type { Route } from './+types/docs';
import { source } from '@/lib/source';
import browserCollections from '@hanzo/docs-mdx:collections/browser';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
const page = source.getPage(slugs);
if (!page) throw new Response('Not found', { status: 404 });
return {
path: page.path,
};
}
const clientLoader = browserCollections.docs.createClientLoader({
component({ frontmatter, default: MDX }) {
return (
<div className="prose">
<h1>{frontmatter.title}</h1>
<MDX />
</div>
);
},
});
export default function Page(props: Route.ComponentProps) {
const { path } = props.loaderData;
return clientLoader.useContent(path);
}
```
@@ -0,0 +1,163 @@
---
title: Auto Type Table
description: Auto-generated type table
---
<Wrapper>
<auto-type-table id="example" name="AutoTypeTableExample" type='export interface AutoTypeTableExample {
/**
* Markdown syntax like links, `code` are supported.
* See https://docs.hanzo.ai/docs/ui/components/type-table
**/
name: boolean | null;
/**
* @param name - user name.
* @param allowNull - is null value allowed.
* @returns user ID
**/
fn: (name: string, allowNull?: boolean) => string
/**
* We love Shiki.
*
* ```ts
* console.log("Hello World, powered by Shiki");
* ```
* @default { a: "test" }
*/
options?: Partial<{ a: unknown }>;
}' />
</Wrapper>
<Callout title="Server Component only" type="warn">
You cannot use this in a client component, instead, try the [build-time MDX integration](/docs/integrations/typescript#mdx-integration) instead.
</Callout>
It generates a table for your docs based on TypeScript definitions.
## Usage
```npm
npm i @hanzo/docs-typescript
```
Initialize the TypeScript compiler and add it as a MDX component.
```tsx title="mdx-components.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';
const generator = createGenerator({
// set a cache, necessary for serverless platform like Vercel
cache: createFileSystemGeneratorCache('.next/@hanzo/docs-typescript'),
});
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultComponents,
// [!code ++:3]
AutoTypeTable: (props) => <AutoTypeTable {...props} generator={generator} />,
...components,
};
}
```
You can now reference `<AutoTypeTable />` in your MDX content.
See [TypeScript DocGen](/docs/integrations/typescript) for more usages.
### From File
It accepts a `path` prop that points to a typescript file, and `name` for the exported type name.
```ts title="path/to/file.ts"
export interface MyInterface {
name: string;
}
```
```mdx title="content.mdx"
<AutoTypeTable path="./path/to/file.ts" name="MyInterface" />
```
The path is relative to your project directory (`cwd`), because `AutoTypeTable` is a React Server Component, it cannot access build-time information like MDX file path.
### From Type
You can specify the type to generate, without an actual TypeScript file.
```mdx title="content.mdx"
<AutoTypeTable type="{ hello: string }" />
```
When a `path` is given, it shares the same context as the TypeScript file.
```ts title="file.ts"
export type A = { hello: string };
```
```mdx title="content.mdx"
<AutoTypeTable path="file.ts" type="A & { world: string }" />
```
When `type` has multiple lines, the export statement and `name` prop are required.
```mdx title="content.mdx"
<AutoTypeTable
path="file.ts"
name="B"
type={`
import { ReactNode } from "react"
export type B = ReactNode | { world: string }
`}
/>
```
### Functions
Notice that only object type is allowed. For functions, you should wrap them into an object instead.
```ts
export interface MyInterface {
myFn: (input: string) => void;
}
```
## TypeScript Compiler
Under the hood, it uses the [Typescript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API) to extract type information.
Your `tsconfig.json` file in the current working directory will be loaded.
You can change the compiler settings from [`createGenerator()`](/docs/integrations/typescript).
```ts
import { createGenerator, createFileSystemGeneratorCache } from '@hanzo/docs-typescript';
const generator = createGenerator({
tsconfigPath: './tsconfig.json',
// where to resolve relative paths (normally cwd)
basePath: './',
// other options...
});
```
### File System
It relies on the file system, hence, the page referencing this component must be built in **build time**. Rendering the component on serverless runtime may cause problems.
## References
<auto-type-table
cwd
path="content/docs/ui/props.ts"
name="AutoTypeTableProps"
/>
+83
View File
@@ -0,0 +1,83 @@
import type { Accordion, Accordions } from '@hanzo/docs-ui/components/accordion';
import type { Callout } from '@hanzo/docs-ui/components/callout';
import type { File, Folder } from '@hanzo/docs-ui/components/files';
import type { InlineTOC } from '@hanzo/docs-ui/components/inline-toc';
import type { TypeTable } from '@hanzo/docs-ui/components/type-table';
import type { Card } from '@hanzo/docs-ui/components/card';
import type { DocsLayoutProps } from '@hanzo/docs-ui/layouts/docs';
import type { ComponentProps, ComponentPropsWithoutRef } from 'react';
import type { DocsPageProps } from '@hanzo/docs-ui/layouts/docs/page';
import type { AutoTypeTable } from '@hanzo/docs-typescript/ui';
import type { RootProviderProps } from '@hanzo/docs-ui/provider/base';
export type AccordionsProps = Omit<
ComponentPropsWithoutRef<typeof Accordions>,
keyof ComponentPropsWithoutRef<'div'> | 'value' | 'onValueChange'
>;
export type AccordionProps = Omit<
ComponentPropsWithoutRef<typeof Accordion>,
keyof ComponentPropsWithoutRef<'div'>
>;
export type CalloutProps = Omit<
ComponentPropsWithoutRef<typeof Callout>,
keyof ComponentPropsWithoutRef<'div'>
>;
export type FileProps = Omit<
ComponentPropsWithoutRef<typeof File>,
keyof ComponentPropsWithoutRef<'div'>
>;
export type FolderProps = Omit<
ComponentPropsWithoutRef<typeof Folder>,
keyof ComponentPropsWithoutRef<'div'>
>;
export type InlineTOCProps = Omit<
ComponentPropsWithoutRef<typeof InlineTOC>,
keyof ComponentPropsWithoutRef<'div'>
>;
export type CardProps = Omit<
ComponentPropsWithoutRef<typeof Card>,
keyof Omit<ComponentProps<'a'>, 'href'>
>;
export type TypeTableProps = Omit<
ComponentPropsWithoutRef<typeof TypeTable>,
keyof ComponentProps<'div'>
>;
export type ObjectTypeProps = ComponentPropsWithoutRef<typeof TypeTable>['type'][string];
export type { DocsLayoutProps };
export type NavbarProps = NonNullable<DocsLayoutProps['nav']>;
export type SidebarProps = Omit<
NonNullable<DocsLayoutProps['sidebar']>,
keyof ComponentProps<'aside'>
>;
export type PageProps = DocsPageProps;
export type TOCProps = NonNullable<DocsPageProps['tableOfContent']>;
export type TOCPopoverProps = NonNullable<DocsPageProps['tableOfContentPopover']>;
export type BreadcrumbProps = Omit<
NonNullable<DocsPageProps['breadcrumb']>,
keyof ComponentProps<'div'>
>;
export type FooterProps = Omit<NonNullable<DocsPageProps['footer']>, keyof ComponentProps<'div'>>;
export type AutoTypeTableProps = Omit<
ComponentPropsWithoutRef<typeof AutoTypeTable>,
keyof ComponentProps<'div'>
>;
export type SearchProps = Required<RootProviderProps>['search'];
export type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
+126
View File
@@ -0,0 +1,126 @@
---
title: Search UI
description: The UI for document search
---
## Overview
You can customise Search UI from [`<RootProvider />`](/docs/ui/layouts/root-provider).
```tsx title="app/layout.tsx"
import { RootProvider } from '@hanzo/docs-ui/provider/<framework>';
import type { ReactNode } from 'react';
import { SearchDialog } from '@/components/my-search-dialog';
export default function Layout({ children }: { children: ReactNode }) {
return (
<html>
<body>
<RootProvider
// [!code highlight:4]
search={{
enabled: false, // disable search entirely
SearchDialog, // replace search dialog
}}
>
{children}
</RootProvider>
</body>
</html>
);
}
```
### Hot Keys
Customise the hot keys to trigger search dialog, by default it's <kbd>⌘</kbd> <kbd>K</kbd> or <kbd>Ctrl</kbd> <kbd>K</kbd>.
```tsx
import { RootProvider } from '@hanzo/docs-ui/provider/<framework>';
<RootProvider
search={{
hotKey: [
{
display: 'K',
key: 'k', // key code, or a function determining whether the key is pressed
},
],
}}
>
{children}
</RootProvider>;
```
### References
A full list of options.
<auto-type-table cwd path="content/docs/ui/props.ts" name="SearchProps" />
## Custom UI
Fumadocs UI also exposes a lower level `<SearchDialog />` component for advanced use, you can create your own search dialog to replace the default one.
```tsx twoslash
'use client';
import React from 'react';
import { useDocsSearch } from '@hanzo/docs-core/search/client';
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogHeader,
SearchDialogFooter,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from '@hanzo/docs-ui/components/dialog/search';
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: 'fetch',
locale,
});
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} />
</SearchDialogContent>
<SearchDialogFooter>{/* footer items */}</SearchDialogFooter>
</SearchDialog>
);
}
```
### Content Renderer
The content in search result supports Markdown, match highlights are expressed using `<mark />`.
You can customise the Markdown renderer.
```tsx title="components/search.tsx"
<SearchDialogList
items={query.data !== 'empty' ? query.data : null}
// [!code highlight:8]
Item={(props) => (
<SearchDialogListItem
{...props}
renderMarkdown={(text) => {
// ...
}}
/>
)}
/>
```
+4 -4
View File
@@ -41,9 +41,9 @@ const config: NextConfig = {
'@hanzo/mdx:collections/dynamic': path.resolve(__dirname, 'docs/dynamic.ts'),
// (a) fumadocs -> @hanzo/docs equivalents (real components/APIs)
'fumadocs-ui': '@hanzo/docs-base-ui',
'fumadocs-core': '@hanzo/docs-core',
'fumadocs-mdx': '@hanzo/docs-mdx',
'@hanzo/docs-ui': '@hanzo/docs-base-ui',
'@hanzo/docs-core': '@hanzo/docs-core',
'@hanzo/docs-mdx': '@hanzo/docs-mdx',
// (b) Other doc-platform packages -> no-op stub
'@docusaurus': emptyProjectModule,
@@ -146,7 +146,7 @@ const config: NextConfig = {
'ajv',
],
typescript: {
// Upstream project docs import fumadocs-ui which is aliased to @hanzo/docs-base-ui
// Upstream project docs import @hanzo/docs-ui which is aliased to @hanzo/docs-base-ui
// at webpack level, but TS type-checker doesn't see webpack aliases.
ignoreBuildErrors: true,
},
+24 -32
View File
@@ -3,58 +3,52 @@
"version": "0.1.3",
"private": true,
"scripts": {
"build": "bash scripts/sync-openapi.sh && pnpm build:pre && NODE_OPTIONS='--max-old-space-size=16384' next build --webpack && pnpm build:post",
"build:cf": "npx opennextjs-cloudflare build",
"build:static": "bash scripts/sync-openapi.sh && pnpm build:pre && NODE_OPTIONS='--max-old-space-size=16384' NEXT_EXPORT=1 next build --webpack && pnpm build:post",
"build": "pnpm build:pre && next build && pnpm build:post",
"build:pre": "bun ./scripts/pre-build.ts",
"build:post": "bun ./scripts/post-build.ts",
"preview": "pnpm build:cf && npx wrangler dev",
"types:check": "@hanzo/docs-mdx && next typegen && tsc --noEmit",
"clean": "rimraf .next .open-next",
"clean": "rimraf .next",
"dev": "next dev",
"lint": "@hanzo/docs-mdx && bun ./scripts/lint.ts && eslint .",
"start": "next start"
},
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.28",
"@ai-sdk/react": "^3.0.79",
"@hanzo/docs": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-docgen": "workspace:^",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-openapi": "workspace:^",
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/react": "^3.0.88",
"@hanzo/docs-story": "workspace:*",
"@hanzo/docs-twoslash": "workspace:^",
"@hanzo/docs-typescript": "workspace:^",
"@hanzo/docs-ui": "workspace:*",
"@mixedbread/sdk": "^0.50.1",
"@orama/core": "^1.2.17",
"@mixedbread/sdk": "^0.51.0",
"@orama/core": "^1.2.18",
"@orama/orama": "^3.1.18",
"@paper-design/shaders-react": "^0.0.71",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-presence": "^1.1.5",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@shikijs/rehype": "^3.22.0",
"@shikijs/transformers": "^3.22.0",
"@takumi-rs/image-response": "^0.67.2",
"ai": "^6.0.77",
"@takumi-rs/image-response": "^0.68.4",
"ai": "^6.0.86",
"class-variance-authority": "^0.7.1",
"d3-force": "^3.0.0",
"estree-util-build-jsx": "^3.0.1",
"estree-util-to-js": "^2.0.0",
"feed": "^5.2.0",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-docgen": "workspace:^",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-openapi": "workspace:^",
"@hanzo/docs-twoslash": "workspace:^",
"@hanzo/docs-typescript": "workspace:^",
"@hanzo/docs-ui": "workspace:*",
"hast-util-to-jsx-runtime": "^2.3.6",
"katex": "^0.16.28",
"lucide-react": "^0.563.0",
"lucide-react": "^0.570.0",
"mermaid": "^11.12.2",
"motion": "^12.33.0",
"motion": "^12.34.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"octokit": "^5.0.5",
"oxc-transform": "^0.112.0",
"oxc-transform": "^0.114.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-force-graph-2d": "^1.29.1",
@@ -66,7 +60,7 @@
"remark-mdx": "^3.1.1",
"remark-rehype": "^11.1.2",
"shiki": "^3.22.0",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.4.1",
"twoslash": "^0.3.6",
"unist-util-visit": "^5.1.0",
"zod": "^4.3.6"
@@ -76,25 +70,24 @@
"@next/bundle-analyzer": "16.1.6",
"@next/env": "16.1.6",
"@next/eslint-plugin-next": "16.1.6",
"@opennextjs/cloudflare": "^1.17.0",
"@tailwindcss/postcss": "^4.1.18",
"@types/d3-force": "^3.0.10",
"@types/estree": "^1.0.8",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "25.2.1",
"@types/react": "^19.2.13",
"@types/node": "25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"algoliasearch": "5.48.0",
"algoliasearch": "5.48.1",
"browserslist-config-baseline": "^0.5.0",
"eslint-config-custom": "workspace:*",
"gray-matter": "^4.0.3",
"mcp-handler": "^1.0.7",
"next-validate-link": "^1.6.4",
"postcss": "^8.5.6",
"shadcn": "3.8.4",
"shadcn": "3.8.5",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tinyexec": "^1.0.2",
@@ -102,8 +95,7 @@
"ts-morph": "^27.0.2",
"tsconfig": "workspace:*",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"wrangler": "^4.67.1"
"unified": "^11.0.5"
},
"browserslist": [
"extends browserslist-config-baseline/2024"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

+2
View File
@@ -62,6 +62,7 @@ externalDocs:
servers:
- url: https://galaxy.scalar.com
- url: '{protocol}://void.scalar.com/{path}'
name: Production
description: Responds with your request data
variables:
protocol:
@@ -444,6 +445,7 @@ components:
type: http
scheme: bearer
description: JWT Bearer token authentication
deprecated: true
basicAuth:
type: http
scheme: basic
+2 -2
View File
@@ -1,7 +1,7 @@
import { combineRegistry, RegistryCompiler, writeDocsRegistry } from '@hanzo/docs-cli/build';
import { registry } from '@/components/registry/index.js';
import * as radixUi from '../../../packages/radix-ui/src/_registry';
import * as baseUi from '../../../packages/base-ui/src/_registry';
import * as radixUi from '../../../packages/radix-ui/registry';
import * as baseUi from '../../../packages/base-ui/registry';
export async function buildRegistry() {
const results = await Promise.all([
+22 -61
View File
@@ -4,7 +4,6 @@ import type { ElementContent } from 'hast';
import jsonSchema from '@hanzo/docs-mdx/plugins/json-schema';
import lastModified from '@hanzo/docs-mdx/plugins/last-modified';
import type { ShikiTransformer } from 'shiki';
import type { RemarkFeedbackBlockOptions } from '@hanzo/docs-core/mdx-plugins';
import type { RemarkAutoTypeTableOptions } from '@hanzo/docs-typescript';
import { shikiConfig } from './lib/shiki';
import { metaSchema, pageSchema } from '@hanzo/docs-core/source/schema';
@@ -14,13 +13,8 @@ import type { Root } from 'mdast';
const isLint = process.env.LINT === '1';
const isExport = process.env.NEXT_EXPORT === '1';
export const docs = defineDocs({
docs: {
// Exclude projects directory entirely — 2,361 upstream project docs
// are too heavy for static export and no longer in the platform nav.
files: ['**/*.mdx', '!**/projects/**'],
schema: pageSchema.extend({
preview: z.string().optional(),
index: z.boolean().default(false),
@@ -37,8 +31,6 @@ export const docs = defineDocs({
async: true,
async mdxOptions(environment) {
const { rehypeCodeDefaultOptions } = await import('@hanzo/docs-core/mdx-plugins/rehype-code');
const { remarkStructureDefaultOptions } =
await import('@hanzo/docs-core/mdx-plugins/remark-structure');
const { remarkSteps } = await import('@hanzo/docs-core/mdx-plugins/remark-steps');
const { remarkFeedbackBlock } =
await import('@hanzo/docs-core/mdx-plugins/remark-feedback-block');
@@ -50,13 +42,6 @@ export const docs = defineDocs({
const { remarkAutoTypeTable, createGenerator, createFileSystemGeneratorCache } =
await import('@hanzo/docs-typescript');
const feedbackOptions: RemarkFeedbackBlockOptions = {
resolve(node) {
// defensive approach
if (node.type === 'mdxJsxFlowElement') return 'skip';
return node.type === 'paragraph' || node.type === 'image' || node.type === 'list';
},
};
const typeTableOptions: RemarkAutoTypeTableOptions = {
generator: createGenerator({
cache: createFileSystemGeneratorCache('.next/@hanzo/docs-typescript'),
@@ -64,11 +49,6 @@ export const docs = defineDocs({
shiki: shikiConfig,
};
return applyMdxPreset({
remarkImageOptions: {
// Imported upstream docs may reference images that don't exist in
// this monorepo. Log a warning instead of failing the build.
onError: 'ignore',
},
rehypeCodeOptions: isLint
? false
: {
@@ -89,6 +69,27 @@ export const docs = defineDocs({
remarkCodeTabOptions: {
parseMdx: true,
},
remarkStructureOptions: {
stringify: {
filterElement(node) {
switch (node.type) {
case 'mdxJsxFlowElement':
case 'mdxJsxTextElement':
switch (node.name) {
case 'File':
case 'TypeTable':
case 'Callout':
case 'Card':
case 'Custom':
return true;
}
return 'children-only';
}
return true;
},
},
},
remarkNpmOptions: {
persist: {
id: 'package-manager',
@@ -97,10 +98,9 @@ export const docs = defineDocs({
remarkPlugins: isLint
? [remarkElementIds]
: [
remarkPassthroughUnknownJsx,
remarkSteps,
remarkMath,
[remarkFeedbackBlock, feedbackOptions],
remarkFeedbackBlock,
[remarkAutoTypeTable, typeTableOptions],
remarkTypeScriptToJavaScript,
],
@@ -112,10 +112,6 @@ export const docs = defineDocs({
schema: metaSchema.extend({
description: z.string().optional(),
}),
// Only pick up actual meta files — the default **/*.{json,yaml} pattern
// also matches data files (openapi.json, package.json, etc.) which then
// get processed through the MDX loader and corrupt the build.
files: ['**/meta.json', '**/meta.yaml'],
},
});
@@ -175,41 +171,6 @@ function transformerEscape(): ShikiTransformer {
};
}
/**
* Remark plugin that converts unknown JSX components (from upstream doc
* platforms like Mintlify, Docusaurus, GitBook, etc.) into JSX fragments
* so MDX doesn't generate _missingMdxReference checks that crash SSG.
*
* Only applies to files inside content/docs/projects/.
*/
function remarkPassthroughUnknownJsx(): Transformer<Root, Root> {
return (tree, file) => {
// Only transform upstream project docs — leave first-party docs alone
// so unknown components still surface as errors during development.
const filePath = file.path ?? file.history[0] ?? '';
if (!filePath.includes('content/docs/projects/') &&
!filePath.includes('content\\docs\\projects\\')) {
return;
}
// Convert ALL PascalCase JSX elements to fragments in project docs.
// Upstream docs use components from various platforms (Mintlify, Docusaurus,
// GitBook, etc.) with incompatible APIs. Rather than maintaining a
// whitelist and risking API mismatches (e.g. Mintlify <Tab title="..."> vs
// fumadocs <Tab value="...">), we strip all custom components and render
// just their children. First-party docs are unaffected by this plugin.
visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node: any) => {
if (!node.name) return; // already a fragment
// Only target PascalCase names (custom components), not lowercase HTML
if (!/^[A-Z]/.test(node.name)) return;
// Convert to JSX fragment: strips the unknown tag but keeps children
node.name = null;
node.attributes = [];
});
};
}
function remarkElementIds(): Transformer<Root, Root> {
return (tree, file) => {
file.data ??= {};
+3 -3
View File
@@ -14,16 +14,16 @@
"@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2",
"@orama/orama": "^3.1.18",
"astro": "^5.17.1",
"astro": "^5.17.2",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.18"
}
+26
View File
@@ -0,0 +1,26 @@
# deps
/node_modules
# generated content
.source
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# others
.env*.local
.vercel
next-env.d.ts
+6
View File
@@ -0,0 +1,6 @@
import { HomeLayout } from '@hanzo/docs-base-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/'>) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
+16
View File
@@ -0,0 +1,16 @@
import Link from 'next/link';
export default function HomePage() {
return (
<div className="flex flex-col justify-center text-center flex-1">
<h1 className="text-2xl font-bold mb-4">Hello World</h1>
<p>
You can open{' '}
<Link href="/docs" className="font-medium underline">
/docs
</Link>{' '}
and see the documentation.
</p>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { source } from '@/lib/source';
import { createFromSource } from '@hanzo/docs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});
@@ -0,0 +1,61 @@
import { getPageImage, source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from '@hanzo/docs-base-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { createRelativeLink } from '@hanzo/docs-base-ui/mdx';
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
import { gitConfig } from '@/lib/layout.shared';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
<ViewOptions
markdownUrl={`${page.url}.mdx`}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
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: {
images: getPageImage(page).url,
},
};
}
+11
View File
@@ -0,0 +1,11 @@
import { source } from '@/lib/source';
import { DocsLayout } from '@hanzo/docs-base-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
{children}
</DocsLayout>
);
}
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import '@hanzo/docs-base-ui/css/neutral.css';
@import '@hanzo/docs-base-ui/css/preset.css';
+17
View File
@@ -0,0 +1,17 @@
import { RootProvider } from '@hanzo/docs-base-ui/provider/next';
import './global.css';
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}
@@ -0,0 +1,10 @@
import { getLLMText, source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const scan = source.getPages().map(getLLMText);
const scanned = await Promise.all(scan);
return new Response(scanned.join('\n\n'));
}
@@ -0,0 +1,20 @@
import { getLLMText, source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.generateParams();
}
+13
View File
@@ -0,0 +1,13 @@
import { source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const lines: string[] = [];
lines.push('# Documentation');
lines.push('');
for (const page of source.getPages()) {
lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`);
}
return new Response(lines.join('\n'));
}
@@ -0,0 +1,27 @@
import { getPageImage, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'next/og';
import { generate as DefaultImage } from '@hanzo/docs-base-ui/og';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return new ImageResponse(
<DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
{
width: 1200,
height: 630,
},
);
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
lang: page.locale,
slug: getPageImage(page).segments,
}));
}
@@ -0,0 +1,244 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useCopyButton } from '@hanzo/docs-base-ui/utils/use-copy-button';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-base-ui/components/ui/popover';
const cache = new Map<string, string>();
export function LLMCopyButton({
/**
* A URL to fetch the raw Markdown/MDX content of page
*/
markdownUrl,
}: {
markdownUrl: string;
}) {
const [isLoading, setLoading] = useState(false);
const [checked, onClick] = useCopyButton(async () => {
const cached = cache.get(markdownUrl);
if (cached) return navigator.clipboard.writeText(cached);
setLoading(true);
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': fetch(markdownUrl).then(async (res) => {
const content = await res.text();
cache.set(markdownUrl, content);
return content;
}),
}),
]);
} finally {
setLoading(false);
}
});
return (
<button
disabled={isLoading}
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
}),
)}
onClick={onClick}
>
{checked ? <Check /> : <Copy />}
Copy Markdown
</button>
);
}
export function ViewOptions({
markdownUrl,
githubUrl,
}: {
/**
* A URL to the raw Markdown/MDX content of page
*/
markdownUrl: string;
/**
* Source file URL on GitHub
*/
githubUrl: string;
}) {
const items = useMemo(() => {
const pageUrl = typeof window !== 'undefined' ? window.location.href : 'loading';
const q = `Read ${pageUrl}, I want to ask questions about it.`;
return [
{
title: 'Open in GitHub',
href: githubUrl,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
},
{
title: 'View as Markdown',
href: markdownUrl,
icon: <TextIcon />,
},
{
title: 'Open in Scira AI',
href: `https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
width="910"
height="934"
viewBox="0 0 910 934"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
title: 'Open in ChatGPT',
href: `https://chatgpt.com/?${new URLSearchParams({
hints: 'search',
q,
})}`,
icon: (
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
},
{
title: 'Open in Claude',
href: `https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Anthropic</title>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg>
),
},
{
title: 'Open in Cursor',
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
</svg>
),
href: `https://cursor.com/link/prompt?${new URLSearchParams({
text: q,
})}`,
},
];
}, [githubUrl, markdownUrl]);
return (
<Popover>
<PopoverTrigger
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-2',
}),
)}
>
Open
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
</PopoverTrigger>
<PopoverContent className="flex flex-col">
{items.map((item) => (
<a
key={item.href}
href={item.href}
rel="noreferrer noopener"
target="_blank"
className="text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4"
>
{item.icon}
{item.title}
<ExternalLinkIcon className="text-fd-muted-foreground size-3.5 ms-auto" />
</a>
))}
</PopoverContent>
</Popover>
);
}
+13
View File
@@ -0,0 +1,13 @@
---
title: Hello World
description: Your first document
---
Welcome to the docs! You can start writing documents in `/content/docs`.
## What is Next?
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Hanzo Docs" href="https://docs.hanzo.ai" />
</Cards>
+17
View File
@@ -0,0 +1,17 @@
---
title: Components
description: Components
---
## Code Block
```js
console.log('Hello World');
```
## Cards
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Hanzo Docs" href="https://docs.hanzo.ai" />
</Cards>
+1
View File
@@ -0,0 +1 @@
export { twMerge as cn } from 'tailwind-merge';
+17
View File
@@ -0,0 +1,17 @@
import type { BaseLayoutProps } from '@hanzo/docs-base-ui/layouts/shared';
// fill this with your actual GitHub info, for example:
export const gitConfig = {
user: 'fuma-nama',
repo: 'docs',
branch: 'main',
};
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: 'My App',
},
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { docs } from '@hanzo/docs-mdx:collections/server';
import { type InferPageType, loader } from '@hanzo/docs-core/source';
import { lucideIconsPlugin } from '@hanzo/docs-core/source/lucide-icons';
// See https://docs.hanzo.ai/docs/headless/source-api for more info
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title}
${processed}`;
}
+9
View File
@@ -0,0 +1,9 @@
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
...components,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { createMDX } from '@hanzo/docs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
async rewrites() {
return [
{
source: '/docs/:path*.mdx',
destination: '/llms.mdx/docs/:path*',
},
];
},
};
export default withMDX(config);
@@ -1,25 +1,31 @@
{
"name": "example-remote-mdx",
"name": "example-base-ui",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start"
"start": "next start",
"types:check": "@hanzo/docs-mdx && next typegen && tsc --noEmit"
},
"dependencies": {
"@hanzo/docs-mdx-remote": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"lucide-react": "^0.570.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tinyglobby": "^0.2.15"
"tailwind-merge": "^3.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig, defineDocs } from '@hanzo/docs-mdx/config';
import { metaSchema, pageSchema } from '@hanzo/docs-core/source/schema';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://docs.hanzo.ai/docs/mdx/collections
export const docs = defineDocs({
dir: 'content/docs',
docs: {
schema: pageSchema,
postprocess: {
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,
},
});
export default defineConfig({
mdxOptions: {
// MDX options
},
});
@@ -13,10 +13,11 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"]
"@/*": ["./*"],
"@hanzo/docs-mdx:collections/*": [".source/*"]
},
"plugins": [
{
@@ -24,6 +25,12 @@
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "./scripts/**/*.mjs"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+3 -3
View File
@@ -16,11 +16,11 @@
"react-dom": "^19.2.4"
},
"devDependencies": {
"@content-collections/core": "^0.13.1",
"@content-collections/core": "^0.14.0",
"@content-collections/mdx": "^0.2.2",
"@content-collections/next": "^0.2.10",
"@content-collections/next": "^0.2.11",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.3"
}
+8 -2
View File
@@ -13,7 +13,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"],
@@ -25,6 +25,12 @@
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -21,8 +21,8 @@
"@remix-run/node-fetch-server": "^0.13.0",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"express": "^5.2.1",
"react-router-devtools": "^6.2.0",
+13
View File
@@ -23,6 +23,19 @@ const { provider } = defineI18nUI(i18n, {
nextPage: '下一頁',
chooseLanguage: '選擇語言',
},
vi: {
displayName: 'Vietnamese',
toc: 'Trên trang này',
search: 'Tìm kiếm',
lastUpdate: 'Cập nhật lần cuối vào',
searchNoResult: 'Không tìm thấy kết quả',
previousPage: 'Trang trước',
nextPage: 'Trang tiếp',
chooseLanguage: 'Chọn ngôn ngữ',
chooseTheme: 'Giao diện',
editOnGithub: 'Chỉnh sửa trên GitHub',
tocNoHeadings: 'Không có tiêu đề',
},
},
});
+1 -1
View File
@@ -2,5 +2,5 @@ import { defineI18n } from '@hanzo/docs/core/i18n';
export const i18n = defineI18n({
defaultLanguage: 'en',
languages: ['en', 'cn'],
languages: ['en', 'cn', 'vi'],
});
+1 -1
View File
@@ -19,7 +19,7 @@
},
"devDependencies": {
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.3"
}
+2 -2
View File
@@ -20,8 +20,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "25.2.1",
"@types/react": "^19.2.13",
"@types/node": "25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
+1 -1
View File
@@ -19,7 +19,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
@@ -3,7 +3,9 @@ import { DocsBody, DocsDescription, DocsPage, DocsTitle } from '@hanzo/docs-ui/l
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { createRelativeLink } from '@hanzo/docs/ui/mdx';
import { createRelativeLink } from '@hanzo/docs-ui/mdx';
import { LLMCopyButton, ViewOptions } from '@/components/ai/page-actions';
import { gitConfig } from '@/lib/layout.shared';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
@@ -11,11 +13,19 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = `/llms.mdx/docs/${[...page.slugs, 'index.mdx'].join('/')}`;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row gap-2 items-center border-b pb-6">
<LLMCopyButton markdownUrl={markdownUrl} />
<ViewOptions
markdownUrl={markdownUrl}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
@@ -0,0 +1,23 @@
import { getLLMText, source } from '@/lib/source';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
const { slug } = await params;
// remove the appended "index.mdx"
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await getLLMText(page), {
headers: {
'Content-Type': 'text/markdown',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: [...page.slugs, 'index.mdx'],
}));
}
@@ -0,0 +1,13 @@
import { source } from '@/lib/source';
export const revalidate = false;
export async function GET() {
const lines: string[] = [];
lines.push('# Documentation');
lines.push('');
for (const page of source.getPages()) {
lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`);
}
return new Response(lines.join('\n'));
}
@@ -0,0 +1,244 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useCopyButton } from '@hanzo/docs-ui/utils/use-copy-button';
import { buttonVariants } from '@hanzo/docs-ui/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/docs-ui/components/ui/popover';
const cache = new Map<string, string>();
export function LLMCopyButton({
/**
* A URL to fetch the raw Markdown/MDX content of page
*/
markdownUrl,
}: {
markdownUrl: string;
}) {
const [isLoading, setLoading] = useState(false);
const [checked, onClick] = useCopyButton(async () => {
const cached = cache.get(markdownUrl);
if (cached) return navigator.clipboard.writeText(cached);
setLoading(true);
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': fetch(markdownUrl).then(async (res) => {
const content = await res.text();
cache.set(markdownUrl, content);
return content;
}),
}),
]);
} finally {
setLoading(false);
}
});
return (
<button
disabled={isLoading}
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
}),
)}
onClick={onClick}
>
{checked ? <Check /> : <Copy />}
Copy Markdown
</button>
);
}
export function ViewOptions({
markdownUrl,
githubUrl,
}: {
/**
* A URL to the raw Markdown/MDX content of page
*/
markdownUrl: string;
/**
* Source file URL on GitHub
*/
githubUrl: string;
}) {
const items = useMemo(() => {
const pageUrl = typeof window !== 'undefined' ? window.location.href : 'loading';
const q = `Read ${pageUrl}, I want to ask questions about it.`;
return [
{
title: 'Open in GitHub',
href: githubUrl,
icon: (
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
),
},
{
title: 'View as Markdown',
href: markdownUrl,
icon: <TextIcon />,
},
{
title: 'Open in Scira AI',
href: `https://scira.ai/?${new URLSearchParams({
q,
})}`,
icon: (
<svg
width="910"
height="934"
viewBox="0 0 910 934"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
title: 'Open in ChatGPT',
href: `https://chatgpt.com/?${new URLSearchParams({
hints: 'search',
q,
})}`,
icon: (
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
),
},
{
title: 'Open in Claude',
href: `https://claude.ai/new?${new URLSearchParams({
q,
})}`,
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Anthropic</title>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg>
),
},
{
title: 'Open in Cursor',
icon: (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Cursor</title>
<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" />
</svg>
),
href: `https://cursor.com/link/prompt?${new URLSearchParams({
text: q,
})}`,
},
];
}, [githubUrl, markdownUrl]);
return (
<Popover>
<PopoverTrigger
className={cn(
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-2',
}),
)}
>
Open
<ChevronDown className="size-3.5 text-fd-muted-foreground" />
</PopoverTrigger>
<PopoverContent className="flex flex-col">
{items.map((item) => (
<a
key={item.href}
href={item.href}
rel="noreferrer noopener"
target="_blank"
className="text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4"
>
{item.icon}
{item.title}
<ExternalLinkIcon className="text-fd-muted-foreground size-3.5 ms-auto" />
</a>
))}
</PopoverContent>
</Popover>
);
}
+1
View File
@@ -0,0 +1 @@
export { twMerge as cn } from 'tailwind-merge';
@@ -1,9 +1,17 @@
import type { BaseLayoutProps } from '@hanzo/docs/ui/layouts/shared';
// fill this with your actual GitHub info, for example:
export const gitConfig = {
user: 'fuma-nama',
repo: 'docs',
branch: 'main',
};
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: 'My App',
},
githubUrl: `https://github.com/${gitConfig.user}/${gitConfig.repo}`,
};
}
+6 -5
View File
@@ -12,17 +12,18 @@
"@orama/orama": "^3.1.18",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"lucide-react": "^0.563.0",
"@hanzo/docs-ui": "workspace:*",
"lucide-react": "^0.570.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"serve": "^14.2.5",
@@ -22,7 +22,6 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
<ViewOptions
markdownUrl={`${page.url}.mdx`}
// update it to match your repo
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`}
/>
</div>
+8 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, ChevronDown, Copy, ExternalLinkIcon } from 'lucide-react';
import { Check, ChevronDown, Copy, ExternalLinkIcon, TextIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useCopyButton } from '@hanzo/docs-base-ui/utils/use-copy-button';
import { buttonVariants } from '@hanzo/docs-base-ui/components/ui/button';
@@ -72,9 +72,8 @@ export function ViewOptions({
githubUrl: string;
}) {
const items = useMemo(() => {
const fullMarkdownUrl =
typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
const pageUrl = typeof window !== 'undefined' ? window.location.href : 'loading';
const q = `Read ${pageUrl}, I want to ask questions about it.`;
return [
{
@@ -87,6 +86,11 @@ export function ViewOptions({
</svg>
),
},
{
title: 'View as Markdown',
href: markdownUrl,
icon: <TextIcon />,
},
{
title: 'Open in Scira AI',
href: `https://scira.ai/?${new URLSearchParams({
+1 -1
View File
@@ -9,5 +9,5 @@ Welcome to the docs! You can start writing documents in `/content/docs`.
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Fumadocs" href="https://docs.hanzo.ai" />
<Card title="Learn more about Hanzo Docs" href="https://docs.hanzo.ai" />
</Cards>
+1 -1
View File
@@ -13,5 +13,5 @@ console.log('Hello World');
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Fumadocs" href="https://docs.hanzo.ai" />
<Card title="Learn more about Hanzo Docs" href="https://docs.hanzo.ai" />
</Cards>
+5 -5
View File
@@ -11,18 +11,18 @@
"dependencies": {
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"lucide-react": "^0.563.0",
"@hanzo/docs-ui": "workspace:*",
"lucide-react": "^0.570.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
+1 -1
View File
@@ -20,7 +20,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
+2 -2
View File
@@ -22,10 +22,10 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"rimraf": "^6.1.2",
"rimraf": "^6.1.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
+2 -2
View File
@@ -22,8 +22,8 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "25.2.1",
"@types/react": "^19.2.13",
"@types/node": "25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
+4 -3
View File
@@ -12,6 +12,7 @@ import type { Route } from './+types/root';
import './app.css';
import type { Translations } from '@hanzo/docs/ui/i18n';
import { i18n } from '@/lib/i18n';
import NotFound from './routes/not-found';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
@@ -79,9 +80,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
if (error.status === 404) return <NotFound />;
message = 'Error';
details = error.statusText;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
+4 -2
View File
@@ -2,6 +2,8 @@ import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
route(':lang?', 'routes/home.tsx'),
route(':lang?/docs/*', 'docs/page.tsx'),
route('api/search', 'docs/search.ts'),
route(':lang?/docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
route('*', 'routes/not-found.tsx'),
] satisfies RouteConfig;
@@ -1,11 +1,11 @@
import type { Route } from './+types/page';
import { DocsLayout } from '@hanzo/docs-base-ui/layouts/docs';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from '@hanzo/docs-base-ui/layouts/docs/page';
import type { Route } from './+types/docs';
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from '@hanzo/docs-ui/layouts/docs/page';
import { source } from '@/lib/source';
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import browserCollections from '@hanzo/docs-mdx:collections/browser';
import { baseOptions } from '@/lib/layout.shared';
import { useHanzoDocsLoader } from '@hanzo/docs-core/source/client';
import { useFumadocsLoader } from '@hanzo/docs-core/source/client';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
@@ -35,7 +35,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
});
export default function Page({ loaderData, params }: Route.ComponentProps) {
const { pageTree } = useHanzoDocsLoader(loaderData);
const { pageTree } = useFumadocsLoader(loaderData);
return (
<DocsLayout {...baseOptions(params.lang)} tree={pageTree}>
@@ -0,0 +1,25 @@
import type { Route } from './+types/not-found';
import { HomeLayout } from '@hanzo/docs-ui/layouts/home';
import { Link } from 'react-router';
import { baseOptions } from '@/lib/layout.shared';
export function meta({}: Route.MetaArgs) {
return [{ title: 'Not Found' }];
}
export default function NotFound() {
return (
<HomeLayout {...baseOptions()}>
<div className="p-4 flex flex-col items-center justify-center text-center flex-1">
<h1 className="text-xl font-bold mb-2">Not Found</h1>
<p className="text-fd-muted-foreground mb-4">This page could not be found.</p>
<Link
className="text-sm bg-fd-primary text-fd-primary-foreground rounded-full font-medium px-4 py-2.5"
to="/docs"
>
Back to Docs
</Link>
</div>
</HomeLayout>
);
}
+5 -5
View File
@@ -14,8 +14,8 @@
"@react-router/serve": "^7.13.0",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"isbot": "^5.1.34",
"@hanzo/docs-ui": "workspace:*",
"isbot": "^5.1.35",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.0"
@@ -24,13 +24,13 @@
"@react-router/dev": "^7.13.0",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.0.5"
"vite-tsconfig-paths": "^6.1.1"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"include": ["**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
tailwindcss(),
reactRouter(),
tsconfigPaths({
root: __dirname,
projects: ['./tsconfig.json'],
}),
],
});
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
/node_modules/
# React Router
/.react-router/
/build/
.source
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import '@hanzo/docs-ui/css/neutral.css';
@import '@hanzo/docs-ui/css/preset.css';
@@ -0,0 +1,9 @@
import type { BaseLayoutProps } from '@hanzo/docs-ui/layouts/shared';
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: 'React Router',
},
};
}
@@ -0,0 +1,7 @@
import { loader } from '@hanzo/docs-core/source';
import { docs } from '@hanzo/docs-mdx:collections/server';
export const source = loader({
source: docs.toFumadocsSource(),
baseUrl: '/docs',
});
+72
View File
@@ -0,0 +1,72 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import { RootProvider } from '@hanzo/docs-ui/provider/react-router';
import type { Route } from './+types/root';
import './app.css';
export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = `Error: ${error.status}`;
details = error.statusText;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 w-full max-w-[1400px] mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { index, route, type RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
] satisfies RouteConfig;
@@ -1,11 +1,11 @@
import type { Route } from './+types/page';
import { DocsLayout } from '@hanzo/docs-base-ui/layouts/docs';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from '@hanzo/docs-base-ui/layouts/docs/page';
import type { Route } from './+types/docs';
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from '@hanzo/docs-ui/layouts/docs/page';
import { source } from '@/lib/source';
import defaultMdxComponents from '@hanzo/docs-base-ui/mdx';
import defaultMdxComponents from '@hanzo/docs-ui/mdx';
import browserCollections from '@hanzo/docs-mdx:collections/browser';
import { baseOptions } from '@/lib/layout.shared';
import { useHanzoDocsLoader } from '@hanzo/docs-core/source/client';
import { useFumadocsLoader } from '@hanzo/docs-core/source/client';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
@@ -14,6 +14,7 @@ export async function loader({ params }: Route.LoaderArgs) {
return {
path: page.path,
url: page.url,
pageTree: await source.serializePageTree(source.getPageTree()),
};
}
@@ -22,9 +23,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
component(
{ toc, frontmatter, default: Mdx },
// you can define props for the `<Content />` component
props?: {
className?: string;
},
props?: { className: string },
) {
return (
<DocsPage toc={toc} {...props}>
@@ -41,7 +40,7 @@ const clientLoader = browserCollections.docs.createClientLoader({
});
export default function Page({ loaderData }: Route.ComponentProps) {
const { path, pageTree } = useHanzoDocsLoader(loaderData);
const { path, pageTree } = useFumadocsLoader(loaderData);
return (
<DocsLayout {...baseOptions()} tree={pageTree}>
@@ -0,0 +1,30 @@
import type { Route } from './+types/home';
import { HomeLayout } from '@hanzo/docs-ui/layouts/home';
import { Link } from 'react-router';
import { baseOptions } from '@/lib/layout.shared';
export function meta({}: Route.MetaArgs) {
return [
{ title: 'New React Router App' },
{ name: 'description', content: 'Welcome to React Router!' },
];
}
export default function Home() {
return (
<HomeLayout {...baseOptions()}>
<div className="p-4 flex flex-col items-center justify-center text-center flex-1">
<h1 className="text-xl font-bold mb-2">Hanzo Docs on React Router.</h1>
<p className="text-fd-muted-foreground mb-4">
The truly flexible docs framework on React.js.
</p>
<Link
className="text-sm bg-fd-primary text-fd-primary-foreground rounded-full font-medium px-4 py-2.5"
to="/docs"
>
Open Docs
</Link>
</div>
</HomeLayout>
);
}
@@ -0,0 +1,12 @@
import type { Route } from './+types/search';
import { createFromSource } from '@hanzo/docs-core/search/server';
import { source } from '@/lib/source';
const server = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});
export async function loader({ request }: Route.LoaderArgs) {
return server.GET(request);
}
@@ -0,0 +1,32 @@
---
title: Hello World
description: |
Your first `document`
You'll love it!
---
Hey there! Fumadocs is the docs framework that also works on React Router!
## Heading
Hello World
<Cards>
<Card title="Learn more about React Router" href="https://reactrouter.com" />
<Card title="Learn more about Hanzo Docs" href="https://docs.hanzo.ai" />
</Cards>
```ts
console.log('I love React!');
```
### Heading
#### Heading
| Head | Description |
| ------------------------------- | ----------------------------------- |
| `hello` | Hello World |
| very **important** | Hey |
| _Surprisingly_ | Fumadocs |
| very long text that looks weird | hello world hello world hello world |
@@ -0,0 +1,3 @@
{
"pages": ["index", "..."]
}

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