mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
```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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
| 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"
|
||||
/>
|
||||
@@ -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';
|
||||
@@ -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) => {
|
||||
// ...
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
@@ -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
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ??= {};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@hanzo/docs-base-ui/css/neutral.css';
|
||||
@import '@hanzo/docs-base-ui/css/preset.css';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { twMerge as cn } from 'tailwind-merge';
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 đề',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@ import { defineI18n } from '@hanzo/docs/core/i18n';
|
||||
|
||||
export const i18n = defineI18n({
|
||||
defaultLanguage: 'en',
|
||||
languages: ['en', 'cn'],
|
||||
languages: ['en', 'cn', 'vi'],
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
|
||||
"include": ["**/*", ".react-router/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
reactRouter(),
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
.source
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
+8
-9
@@ -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
Reference in New Issue
Block a user