271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import type { Metadata } from "next";
|
|
import type { MDXComponents } from "mdx/types";
|
|
import { notFound } from "next/navigation";
|
|
import { Banner } from "fumadocs-ui/components/banner";
|
|
import { DocsLayout } from "fumadocs-ui/layouts/notebook";
|
|
import {
|
|
DocsBody,
|
|
DocsDescription,
|
|
DocsPage,
|
|
DocsTitle,
|
|
MarkdownCopyButton,
|
|
ViewOptionsPopover,
|
|
} from "fumadocs-ui/layouts/notebook/page";
|
|
import { createRelativeLink } from "fumadocs-ui/mdx";
|
|
import { baseOptions } from "@/lib/layout.shared";
|
|
import { getMDXComponents } from "@/components/mdx";
|
|
import { gitConfig } from "@/lib/shared";
|
|
import { getPageContributors } from "@/lib/contributors";
|
|
import { getPageDescription } from "@/lib/source";
|
|
import Footer from "@/src/layouts/Footer";
|
|
import NavHeader from "@/src/layouts/NavHeader";
|
|
import TocFooter from "@/src/components/TocFooter";
|
|
import SidebarSearch from "@/src/layouts/SidebarSearch";
|
|
import LanguageSelector from "@/components/language-selector/language-selector";
|
|
import Link from "next/link";
|
|
|
|
// Each section's fumadocs-mdx collection resolves to a differently-typed
|
|
// `LoaderOutput` (docs vs guides vs integrations all have their own
|
|
// schema generics). The cross-section factory here is intentionally
|
|
// agnostic to that shape, so the source is typed loosely. Using a
|
|
// stricter shared type (`ReturnType<typeof loader>`) doesn't unify
|
|
// across collections and would require each caller to cast.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type Source = any;
|
|
|
|
type SectionPageProps = {
|
|
params: Promise<{ slug?: string[] }>;
|
|
};
|
|
|
|
// Pages produced by our fumadocs-mdx collections carry the standard MDX frontmatter
|
|
// (title, description) plus body/toc/full injected by fumadocs-mdx. The core loader
|
|
// type is generic over this, so cast to a minimal shape we rely on here.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type Page = any;
|
|
|
|
export type SectionConfig = {
|
|
/** Fumadocs loader for this section. */
|
|
source: Source;
|
|
/** Relative path inside the repo where the MDX files live, used to build the "Edit on GitHub" URL. */
|
|
contentDir: string;
|
|
/** Optional helper returning the public raw-markdown URL for a page (enables the copy-markdown / view-options buttons). */
|
|
getMarkdownUrl?: (page: Page) => string;
|
|
/** Optional helper returning an OG image URL for a page. */
|
|
getImageUrl?: (page: Page) => string;
|
|
/**
|
|
* Optional custom content rendered between the page description/copy-markdown
|
|
* header and the main MDX body. Used by the blog section to surface author
|
|
* avatars + date; other sections leave this undefined and get the default
|
|
* layout.
|
|
*/
|
|
renderBeforeBody?: (page: Page) => ReactNode;
|
|
/**
|
|
* Show the build-time git-derived contributor strip below the
|
|
* "last updated" line. Opt-in per section — docs has it, blog
|
|
* already surfaces authors in the byline so it skips this.
|
|
*/
|
|
showContributors?: boolean;
|
|
/**
|
|
* Optional per-section metadata extension. Return value is shallow-merged
|
|
* over the base metadata produced by `generateMetadata` (title,
|
|
* description, canonical, optional OG image) — with `openGraph` and
|
|
* `alternates` deep-merged so a section that sets
|
|
* `openGraph.type = 'article'` doesn't clobber the per-page OG image.
|
|
*
|
|
* Used by the blog section to set `openGraph.type`, `publishedTime`,
|
|
* `modifiedTime`, and the author list on individual posts.
|
|
*/
|
|
extendMetadata?: (page: Page) => Promise<Metadata> | Metadata;
|
|
/**
|
|
* Section-scoped MDX components, merged on top of the global map from
|
|
* `getMDXComponents`. Lets a section register components globally for
|
|
* its own content (so authors don't `import` them per file) without
|
|
* leaking them into other sections. Used by the blog to expose
|
|
* `ClaudeCodeTerminal` and friends.
|
|
*/
|
|
mdxComponents?: MDXComponents;
|
|
};
|
|
|
|
/**
|
|
* Build the layout + page handlers for a docs section.
|
|
*
|
|
* Usage in `app/<section>/layout.tsx`:
|
|
* export default sectionDocs.Layout;
|
|
*
|
|
* Usage in `app/<section>/[[...slug]]/page.tsx`:
|
|
* export default sectionDocs.Page;
|
|
* export const generateStaticParams = sectionDocs.generateStaticParams;
|
|
* export const generateMetadata = sectionDocs.generateMetadata;
|
|
*/
|
|
export function createSection(config: SectionConfig) {
|
|
const {
|
|
source,
|
|
contentDir,
|
|
getMarkdownUrl,
|
|
getImageUrl,
|
|
renderBeforeBody,
|
|
showContributors,
|
|
extendMetadata,
|
|
mdxComponents,
|
|
} = config;
|
|
|
|
function Layout({ children }: { children: ReactNode }) {
|
|
const { nav, ...rest } = baseOptions();
|
|
return (
|
|
<>
|
|
<Banner id="docs-announcement" height="30px">
|
|
🔥 DeepEval 4.0 just got released.{" "}
|
|
<Link href="/blog/introducing-deepeval-4">Read the announcement</Link>.
|
|
</Banner>
|
|
<DocsLayout
|
|
{...rest}
|
|
nav={{ ...nav, mode: "top" }}
|
|
tabMode="navbar"
|
|
tree={source.getPageTree()}
|
|
// Swizzled header: three-column grid aligned with the body
|
|
// grid (sidebar / main / toc). See NavHeader for the layout;
|
|
// `slots.header` is the documented override point.
|
|
slots={{ header: NavHeader }}
|
|
// Search lives at the top of the sidebar instead of inside
|
|
// the top nav. Fumadocs' Sidebar appends the `banner` node
|
|
// inside its own `p-4 pb-2` wrapper, so we get consistent
|
|
// spacing above the first page-tree section (e.g. "Getting
|
|
// Started") and in the mobile drawer. The header still
|
|
// keeps the compact (magnifying-glass) search trigger for
|
|
// mobile reachability — see NavHeader col 3.
|
|
sidebar={{
|
|
banner: (
|
|
<div key="sidebar-banner" className="flex flex-col gap-2">
|
|
<SidebarSearch />
|
|
<LanguageSelector />
|
|
</div>
|
|
),
|
|
}}
|
|
>
|
|
{children}
|
|
</DocsLayout>
|
|
<Footer />
|
|
</>
|
|
);
|
|
}
|
|
|
|
async function Page(props: SectionPageProps) {
|
|
const params = await props.params;
|
|
const rawPage = source.getPage(params.slug);
|
|
if (!rawPage) notFound();
|
|
const page = rawPage as Page;
|
|
|
|
const MDX = page.data.body;
|
|
const markdownUrl = getMarkdownUrl?.(page);
|
|
|
|
// Meta strip rendered underneath the TOC (and mirrored into the
|
|
// mobile TOC popover) — "Last updated" + contributor avatars. Kept
|
|
// together so they share one small attribution column next to the
|
|
// prose instead of pushing the `next/prev` nav further down the
|
|
// page. Passed to both `tableOfContent.footer` and
|
|
// `tableOfContentPopover.footer` so the mobile/condensed TOC (which
|
|
// Fumadocs renders as a popover, not the sidebar) gets parity.
|
|
const contributors = showContributors
|
|
? getPageContributors(contentDir, page.path)
|
|
: [];
|
|
const tocFooter = (
|
|
<TocFooter
|
|
contributors={contributors}
|
|
lastModified={page.data.lastModified}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<DocsPage
|
|
toc={page.data.toc}
|
|
full={page.data.full}
|
|
tableOfContent={{ style: "normal", footer: tocFooter }}
|
|
tableOfContentPopover={{ footer: tocFooter }}
|
|
>
|
|
<DocsTitle>{page.data.title}</DocsTitle>
|
|
<DocsDescription className="mb-0 text-[15px] font-light">
|
|
{page.data.description}
|
|
</DocsDescription>
|
|
{markdownUrl ? (
|
|
// `MarkdownCopyButton` / `ViewOptionsPopover` default to fumadocs'
|
|
// `size="sm"` variant (the smallest they expose). The className
|
|
// overrides here trim padding + icon size one notch smaller so
|
|
// the header feels less button-heavy. `cn()` inside fumadocs
|
|
// merges our classes after the defaults, so tailwind-merge wins
|
|
// for padding/gap. Icons need `!` because `ViewOptionsPopover`
|
|
// hardcodes `size-3.5` directly on its chevron child — a plain
|
|
// parent selector loses that specificity fight, so we force it.
|
|
<div className="flex flex-row gap-2 items-center mb-4">
|
|
<MarkdownCopyButton
|
|
markdownUrl={markdownUrl}
|
|
className="px-1.5 py-1 gap-1.5 [&_svg]:!size-3"
|
|
/>
|
|
<ViewOptionsPopover
|
|
markdownUrl={markdownUrl}
|
|
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${contentDir}/${page.path}`}
|
|
className="px-1.5 py-1 gap-1.5 [&_svg]:!size-3"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{renderBeforeBody?.(page)}
|
|
<DocsBody>
|
|
<MDX
|
|
components={getMDXComponents({
|
|
a: createRelativeLink(source, page),
|
|
...mdxComponents,
|
|
})}
|
|
/>
|
|
</DocsBody>
|
|
</DocsPage>
|
|
);
|
|
}
|
|
|
|
async function generateStaticParams() {
|
|
return source.generateParams();
|
|
}
|
|
|
|
async function generateMetadata(props: SectionPageProps): Promise<Metadata> {
|
|
const params = await props.params;
|
|
const page = source.getPage(params.slug);
|
|
if (!page) notFound();
|
|
|
|
const imageUrl = getImageUrl?.(page);
|
|
// Prefer frontmatter `description:`; otherwise derive from the first
|
|
// real paragraph of the MDX body (matches the old Docusaurus
|
|
// auto-description behavior we lost in the migration).
|
|
const description = await getPageDescription(page);
|
|
|
|
// Per-section override (e.g. blog sets `openGraph.type = 'article'`).
|
|
// Shallow-merge `extra` at top-level, but deep-merge `openGraph` and
|
|
// `alternates` so a section adding article fields doesn't clobber
|
|
// the per-page OG image or the canonical we computed above.
|
|
const extra = (await extendMetadata?.(page)) ?? {};
|
|
const {
|
|
openGraph: extraOg,
|
|
alternates: extraAlternates,
|
|
...extraTop
|
|
} = extra;
|
|
|
|
const baseOg: NonNullable<Metadata["openGraph"]> = imageUrl
|
|
? { images: imageUrl }
|
|
: {};
|
|
const mergedOg = { ...baseOg, ...(extraOg ?? {}) };
|
|
|
|
return {
|
|
title: page.data.title,
|
|
...(description ? { description } : {}),
|
|
...extraTop,
|
|
// Relative URL — resolved against the root `metadataBase` in
|
|
// `app/layout.tsx`. `page.url` is the public path like
|
|
// `/docs/metrics-faithfulness`.
|
|
alternates: { canonical: page.url, ...(extraAlternates ?? {}) },
|
|
...(Object.keys(mergedOg).length > 0
|
|
? { openGraph: mergedOg as Metadata["openGraph"] }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
return { Layout, Page, generateStaticParams, generateMetadata };
|
|
}
|