使用 markdown 快速搭建 React 组件库文档网站

3,274 阅读1分钟

背景

希望能够像写 markdown 一样编写组件库文档,自动生成组件库文档网站。

简单效果演示

第一步,配置 markdown-site-loader

{
  test: /\.md$/,
  use: [
    {
      loader: "markdown-site-loader",
      options: {
        codeOutputPath: CODE_OUTPUT_PATH,
      },
    },
  ],
},

其中 codeOutputPath 是提取的代码的输出路径。

它的内容是 markdown-site-loader 自动输出的,包含 markdown 里面所有的代码片段,以及 index.json 配置文件,你可以通过这个配置文件查找到相应的代码片段。

.
├── 271100111991154798117116116111110471151169711411646109100.tsx
├── 27147113117105991078311697114116471151169711411646109100.tsx
├── 91100111991154798117116116111110471151169711411646109100.tsx
├── 9111547113117105991078311697114116471001011159946109100.tsx
├── 9147113117105991078311697114116471151169711411646109100.tsx
├── 919947100111991154798117116116111110479811611046109100.tsx
└── index.json

第二步,编写 config.ts

这其实也是最后一步,编写一份 config.ts 文件。在文件中指定网站导航的标题(title),路由路径(path),markdown 文件路径(MDPath)。markdown-site 会自动为你做好 markdown 文件查找、解析及渲染逻辑,你只需要专注使用 markdown 编写你的文档内容就可以了!

export default [
  {
    title: "介绍",
    children: [
      {
        title: "快速上手",
        path: "/",
        MDPath: "./docs/quickStart/start.md",
      },
      {
        title: "描述",
        path: "/quickStart/desc",
        MDPath: "./docs/quickStart/desc.md",
      },
    ],
  },
  {
    title: "Button",
    children: [
      {
        title: "开始",
        path: "/button/start",
        MDPath: "./docs/button/start.md",
      },
      {
        title: "按钮使用",
        path: "/button/btn",
        MDPath: "./docs/button/btn.md",
      },
    ],
  },
];

比如 /docs/quickStart/start.md 的内容如下:

# Dialog

弹窗

## 例子

~~~code
import React from "react";
import { Dialog, Button } from "zent";

const { openDialog } = Dialog;

const Demo: React.FC = () => {
  const open = () => {
    openDialog({
      title: "title1100",
      children: <div>Dialog</div>,
    });
  };

  return (
    <Button type="primary" onClick={open}>
      点击打开弹窗
    </Button>
  );
};

export default Demo;
~~~

在网站中的呈现效果如下(样式可以自己定制):

技术选型

  • unified 让你使用语法树处理 markdown 文本
  • react-markdown 将 markdown 渲染成 React 组件
  • React.lazy 异步加载组件文件

原理简介

首先使用 unified 编写一个 webpack loader,将 markdown 中的代码提取出来。类似如下的代码片段会被提取出来:

~~~code
import React from "react";
import { Button} from "zent";

const Demo: React.FC = () => {
  return <Button type="primary">按钮</Button>;
};

export default Demo;
~~~

然后使用 react-markdown 将 markdown 渲染成 React 组件(即 html),呈现在网站中。

最后是最关键的一步,由于 react-markdown 在渲染的时候可以判断文本的 language。

比如

~~~code
一些代码...
~~~

文本的 language 就是 code。

所以我们可以在判断 language = code 时,除了正常展示代码以外,再利用前面提取出来的代码,使用 React.lazy 将代码编写的 React 组件也渲染出来。这样就实现了文档网站 即可以显示示例代码,也可以显示示例代码的 UI 及交互效果 的功能。

实现详解

工程由三部分组成。

markdown-site-front 负责网站的展示,如导航、布局、文档内容渲染等。

markdown-site-loader 用来编译 markdown 文件,提取 markdown 中的代码,生成代码配置文件。

markdown-site-shared 没什么特别需要介绍的,存放一些公共常量、ts 类型

三个部分(包)使用 lerna 管理。

├── markdown-site-front
├── markdown-site-loader
└── markdown-site-shared

markdown-site-loader

获取 markdownParser

// markdownParser
import unified from "unified";
import remarkParse from "remark-parse";

export default unified().use(remarkParse).freeze();

获取 markdown 语法树

module.exports = function (source: string) {
  const ast = markdownParser.parse(source);

  return `export default ${JSON.stringify(source)};`;
};

提取代码,将代码写入 markdown-site-loader option 配置的 codeOutputPath 目录下。为了能够查找到相应的代码文件,同时需要生成一份配置文件(writeCodeConfig)。

const codeConfig: ICodeConfigItem[] = [];

ast.children.forEach((child: IASTChild) => {
  const { type, value } = child;

  // CODE_IDENTIFIER(default)= "code"
  if (type === CODE_IDENTIFIER) {
    const position = child.position.start;
    const codeFileName = genCodeFileName(resourcePath, position);

    writeCodeFile(codeOutputPath, codeFileName, value);

    codeConfig.push({
      position,
      resourcePath,
      codePath: `${codeFileName}.tsx`,
    });
  }
});

writeCodeConfig(codeOutputPath, JSON.stringify(codeConfig));

genCodeFileName 是为了生成代码文件的唯一名称。使用 positionStr + pathCharCodeStr 生成。

import { IASTPosition } from "markdown-site-shared";

const PATH_LIMIT = 20;

const genCodeFileName = (resourcePath: string, position: IASTPosition) => {
  const positionStr = `${position.line}${position.column}`;

  const pathList = resourcePath.split("");
  const len = pathList.length;
  const pathCharCodeStr = pathList
    .slice(len - PATH_LIMIT, len)
    .map((char) => char.charCodeAt(0))
    .join("");

  return positionStr + pathCharCodeStr;
};

export default genCodeFileName;

markdown-site-front

markdown-site-front 的核心逻辑都在 Markdown.tsx 中(后面考虑把它提出来)

首先它会根据传入的 markdown 文件路径获取 markdown 里的内容。

/**
 * 1. 动态引入路径不支持传变量,所以用模板字符串
 * 2. loader 路径匹配需要后缀,所以用 .md 结尾
 * */
const getMarkdown = (path) => {
  const pathWithoutSuffix = path.replace(/\.md$/, "");

  return new Promise<string>((resolve) => {
    import(`${pathWithoutSuffix}.md`).then((module) => resolve(module.default));
  });
};

const Markdown: React.FC<IMarkdownProps> = ({ path }) => {
  const [content, setContent] = useState("");
  useEffect(() => {
    path && getMarkdown(path).then((content) => setContent(content));
  }, []);

  if (!content) {
    return null;
  }
};

export default Markdown;

然后借助 react-markdown 提供的能力,判断 language = "code" 时,除了正常高亮展示代码以外

<SyntaxHighlighter
  children={String(children).replace(/\n$/, "")}
  style={vscDarkPlus}
  language="javascript"
  PreTag="div"
/>

再根据 codeConfig 获取相应代码的内容(getCodeConf),再使用 React.lazy 展示代码表示的 React 组件。这样就实现了文档既有代码示例,又有相应的UI及交互的功能了!

至此,markdown-site 所有的关键逻辑就介绍完毕了。

import React, { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import codeConfig from "./codeDist/index.json";
import { CODE_IDENTIFIER, IASTPosition } from "markdown-site-shared";

const getCodeConf = (path: string, position: IASTPosition) => {
  const comparePath = path
    .split("/")
    .filter((item) => !/^\.+$/.test(item))
    .join("/");

  const conf = codeConfig.find((item) => {
    return (
      item.resourcePath.endsWith(comparePath) &&
      item.position.offset === position.offset
    );
  });

  return conf;
};

const Markdown: React.FC<IMarkdownProps> = ({ path }) => {
  return (
    <ReactMarkdown
      children={content}
      components={{
        code({ className, children, ...element }) {
          if (className === `language-${CODE_IDENTIFIER}`) {
            const position = element.node.position?.start as IASTPosition;

            const conf = getCodeConf(path, position);

            const Code = React.lazy(
              () => import(`./codeDist/${conf?.codePath}`)
            );

            return (
              <>
                <React.Suspense fallback={<div>loading...</div>}>
                  <Code />
                </React.Suspense>
                <SyntaxHighlighter
                  children={String(children).replace(/\n$/, "")}
                  style={vscDarkPlus}
                  language="javascript"
                  PreTag="div"
                />
              </>
            );
          } else {
            return <code className={className}>{children}</code>;
          }
        },
      }}
    />
  );
};

export default Markdown;

源代码地址