@liduchuan一叶渡川

Fumadocs 草稿支持

结合 vercel 的 preview 环境能力,扩展 draft 字段实现在线阅读草稿

本文主要介绍基于 Next.js 的集成方式,并且基于多语言支持的前提下进行数据处理。没有配置多语言支持时,getPage 等相关代码会有略微不同。如有不理解之处,请参考官方资料:

依赖版本说明

本文采取的 Fumadocs 相关依赖的版本信息如下:

依赖项版本号
fumadocs-core15.7.7
fumadocs-mdx11.8.1
fumadocs-ui15.7.6

流程概览

扩展字段和定义环境变量

source.config.ts
import { defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config";
import z from "zod";

export const docs = defineDocs({
  docs: {
    schema: frontmatterSchema.extend({
      // 扩展字段
      draft: z.boolean().default(false),
    }),
  },
  meta: {
    schema: metaSchema,
  },
});

启动草稿,支持显示草稿内容。线上环境建议设置为 false

.env.local
DRAFT=true

然后记得在 mdx 文章中添加 draft 字段,让这篇文章变为草稿。

---
title: hello
description: fumadocs
draft: true
---

拦截具备 draft 字段的页面

app/[lang]/docs/[[...slug]]/page.tsx
import { DraftReadme } from "@/components/draft-readme";
import { source } from "@/lib/source";
import { getMDXComponents } from "@/mdx-components";
import {
  DocsBody,
  DocsDescription,
  DocsPage,
  DocsTitle,
} from "fumadocs-ui/page";
import type { Metadata } from "next";
import { notFound } from "next/navigation";

export default async function Page(
  props: PageProps<"/[lang]/docs/[[...slug]]">
) {
  const params = await props.params;
  const page = source.getPage(params.slug, params.lang);
  if (!page) notFound();

  const MDXContent = page.data.body;

  // 对于不支持阅读草稿内容的环境,将看到这个内容
  if (page.data.draft && process.env.DRAFT === "false") {
    return <DraftReadme lang={params.lang} />;
  }

  return (
    <DocsPage toc={page.data.toc} full={page.data.full}>
      <DocsTitle className="flex justify-between items-center">
        <span>
          {/* 添加一个 🚧,标识内容还在编写 */}
          {page.data.draft ? `🚧 ${page.data.title}` : page.data.title}
        </span>
      </DocsTitle>
      <DocsDescription>{page.data.description}</DocsDescription>
      <DocsBody>
        <MDXContent components={getMDXComponents()} />
      </DocsBody>
    </DocsPage>
  );
}

这里的 DraftReadme 是我自定义的一个提醒组件,告知用户内容处于编写状态,暂未发布。你可以自定义自己需要的内容,或者直接执行 notFound 跳转。

搜索过滤

app/api/search/route.ts
import { source } from "@/lib/source";
import { createTokenizer } from "@orama/tokenizers/mandarin";
import { createFromSource } from "fumadocs-core/search/server";
import { NextRequest, NextResponse } from "next/server";

const formSource = createFromSource(source, {
  localeMap: {
    cn: {
      components: {
        tokenizer: createTokenizer(),
      },
      search: {
        threshold: 0,
        tolerance: 0,
      },
    },
  },
});

export const GET = async (req: NextRequest) => {
  const query = req.nextUrl.searchParams.get("query")!;
  const locale = req.nextUrl.searchParams.get("locale")!;
  const data = await formSource.search(query, { locale });
  const filteredData = data.filter((item) => {
    const [slug] = item.url.split("#");
    const slugs = slug.split("/").filter(Boolean).slice(2);
    const page = source.getPage(slugs, locale);
    if (page) {
      if (page.data.draft) {
        return process.env.DRAFT === "true";
      } else {
        return true;
      }
    } else {
      return false;
    }
  });
  return NextResponse.json(filteredData);
};
app/[lang]/docs/layout.tsx
import { filterPageTree } from "@/lib/filterPageTree";
import { baseOptions } from "@/lib/layout.shared";
import { source } from "@/lib/source";
import { DocsLayout } from "fumadocs-ui/layouts/docs";

export default async function Layout({
  children,
  params,
}: LayoutProps<"/[lang]/docs">) {
  const { lang } = await params;

  return (
    <DocsLayout
      tree={filterPageTree(source.pageTree[lang])}
      {...baseOptions(lang)}
    >
      {children}
    </DocsLayout>
  );
}
lib/filterPageTree.ts
import { source } from "@/lib/source";
import { PageTree } from "fumadocs-core/server";

export const filterPageTree = (pageTree: PageTree.Root): PageTree.Root => {
  const filterNode = (node: PageTree.Node): PageTree.Node | null => {
    if (node.type === "page") {
      const slug = node.url?.split("/").slice(3);
      if (slug) {
        const page = source.getPage(slug);
        if (page?.data.draft) {
          if (process.env.DRAFT === "false") {
            return null;
          }
        }
      }
      return node;
    } else if (node.type === "folder") {
      const filteredChildren = node.children
        .map(filterNode)
        .filter((child): child is PageTree.Node => child !== null);
      if (filteredChildren.length === 0) {
        return null;
      }

      return {
        ...node,
        children: filteredChildren,
      };
    } else if (node.type === "separator") {
      return node;
    }

    return node;
  };

  const filteredChildren = pageTree.children
    .map(filterNode)
    .filter((child): child is PageTree.Node => child !== null);

  return {
    ...pageTree,
    children: filteredChildren,
  };
};

关于 vercel

草稿的显示,核心就是控制环境变量里的 DRAFT 字段值。可以设置根据不同的环境来控制它的值。我只会在 Preview 环境设置为 true,vercel 的 Preview 环境本身就只有自己可以访问,不是公开的。所以也不用担心草稿内容会泄露,也不用担心被 SEO 收录,两全其美。如果你高兴,用一个 PRIVATE 来控制显示也挺不错的。