Abstract background

在 Markdown 中使用自定义指令,并渲染为 React 组件

/static/css-is-awesome-directive-3cfd0221.png

什么是自定义指令

Markdown 自定义指令的格式可以参考讨论帖。通过自定义指令,可以扩展 Markdown 基础语法,增加一些个性化的功能(当然,需要渲染器/编辑器支持)。我在博客中希望能有一些基于 Markdown 编写的外链嵌入、图表视频、特殊样式文本等,丰富文章内容。使用自定义指令主要是希望替代部分 MDX 的功能,因为 MDX 的语法与 Markdown 不完全兼容,会造成旧数据迁移困难、复制粘贴文章内容编译报错等很多困扰。

GPT-4o 如是说:

根据该网页,Directive有以下几种格式:

  1. Inline Directives:以一个冒号开头,格式为:name[content]{key=val},用于内联元素。
  2. Leaf Block Directives:以两个冒号开头,格式为::name [content] {key=val},用于独立的块元素。
  3. Container Block Directives:以三个或更多冒号开头,格式为:::name [inline-content] {key=val} contents :::,用于包含其他块元素的容器。

更多详细信息和示例可以在网页上找到:Generic directives/plugins syntax

remark 和 rehype

GPT-4o 如是说:

remarkrehype 是两个用于处理和转换 Markdown 和 HTML 内容的工具链。它们通常结合在一起使用,以便将 Markdown 转换为 HTML,或在处理 Markdown 和 HTML 时进行一些额外的操作。以下是对它们的介绍:

remark

remark 是一个基于 AST(抽象语法树)的 Markdown 解析器和转换器。它提供了一系列的插件来解析、转换和格式化 Markdown 文本。remark 的核心库是 remark,而它的生态系统包含许多插件,用于不同的转换和操作。以下是一些常见的 remark 插件:

  • remark-parse:解析 Markdown 文本为 AST。
  • remark-stringify:将 AST 转换回 Markdown 文本。
  • remark-lint:对 Markdown 内容进行语法检查。
  • remark-html:将 Markdown 转换为 HTML。
  • remark-rehype:将 Markdown 转换为 rehype 的节点树。

rehype

rehype 是一个基于 AST 的 HTML 解析器和转换器。它可以解析 HTML,将其转换为 AST,进行各种转换操作,然后再将 AST 转换回 HTML。rehype 也有许多插件来处理和转换 HTML。以下是一些常见的 rehype 插件:

  • rehype-parse:解析 HTML 为 AST。
  • rehype-stringify:将 AST 转换为 HTML。
  • rehype-minify:压缩 HTML 内容。
  • rehype-highlight:对代码块进行语法高亮。

remark 和 rehype 的结合使用

remarkrehype 通常结合使用来处理和转换 Markdown 和 HTML。例如,可以先使用 remark 解析和转换 Markdown 内容,然后使用 rehype 进一步处理生成的 HTML。一个常见的用例是将 Markdown 转换为 HTML,同时对代码块进行语法高亮。

以下是一个示例代码,展示了如何将 Markdown 转换为 HTML 并对代码块进行语法高亮:

1const remark = require('remark'); 2const remarkHtml = require('remark-html'); 3const rehype = require('rehype'); 4const rehypeHighlight = require('rehype-highlight'); 5 6remark() 7 .use(remarkHtml) 8 .process('# Hello world!', (err, file) => { 9 if (err) throw err; 10 rehype() 11 .data('settings', { fragment: true }) 12 .use(rehypeHighlight) 13 .process(file.contents, (err, file) => { 14 if (err) throw err; 15 console.log(String(file)); 16 }); 17 });

在这个示例中,remark 用于解析 Markdown 并转换为 HTML,然后 rehype 用于对生成的 HTML 进行语法高亮处理。

我使用 Velite 定义 Markdown 文档的组织结构和转换逻辑,Velite 使用的 remarkrehype 插件可以如下定义:

1const blogMarkdown = s.markdown({ 2 gfm: true, 3 removeComments: false, 4 copyLinkedFiles: true, 5 remarkPlugins: [ 6 remarkParse, 7 remarkBreaks, 8 remarkFrontmatter, 9 remarkGfm, 10 remarkMath, 11 remarkEmoji, 12 remarkDirective, 13 remarkDirectiveRehype, 14 ], 15 rehypePlugins: [rehypeSlug, rehypeAutolinkHeadings, rehypeKatex, rehypeStringify], 16});

一般地,使用将 Markdown 转换为 HTML 字符串的方法如下:

1npm install remark remark-parse rehype rehype-stringify unified
1import { unified } from 'unified'; 2import remarkParse from 'remark-parse'; 3import remarkRehype from 'remark-rehype'; 4import rehypeStringify from 'rehype-stringify'; 5 6async function convertMarkdownToHtml(markdown: string): Promise<string> { 7 const file = await unified() 8 .use(remarkParse) // 使用 remark 解析 Markdown 9 .use(remarkRehype) // 将 Markdown 转换为 rehype 的节点树 10 .use(rehypeStringify) // 将 rehype 的节点树转换为 HTML 11 .process(markdown); // 处理输入的 Markdown 字符串 12 13 return String(file); // 返回 HTML 字符串 14}

代码实现

主要使用 remark-directiveremark-directive-rehyperehype-react 这三个插件。

GPT-4o 如是说:

remark-directive 是一个用于处理自定义指令的 remark 插件。自定义指令是一种扩展 Markdown 语法的方式,可以插入特定的 HTML 元素或属性,适用于需要在 Markdown 文档中添加自定义内容或样式的场景。

主要功能包括:

  1. 解析自定义指令:识别并解析 Markdown 文档中的自定义指令。
  2. 生成抽象语法树(AST)节点:将解析后的指令转换为 remark 的抽象语法树(AST)节点,便于进一步处理。

remark-directive-rehype 是一个插件,允许你在使用 remarkrehype 处理 Markdown 时,处理自定义指令。这个项目在你希望由 remark-directive 解析的指令在使用 remark-rehype 时被解析为 HTML(hast 节点)时非常有用。这特别适用于将 Markdown 指令转换为 HTML 标签,并通过 react-markdown 输出为组件的场景。

这个插件的主要功能包括:

  1. 解析自定义指令:它能够识别并解析 Markdown 中的自定义指令语法。
  2. 转换为 rehype 兼容的节点:将解析后的自定义指令转换为 rehype 兼容的节点,使其可以进一步处理和转换为 HTML。

这个插件的使用场景包括需要在 Markdown 中插入复杂的 HTML 元素,或者希望通过 Markdown 语法实现某些特殊功能,如插入图表、视频、特殊样式的文本等。

你可以在 remarkrehype 的处理管道中使用这个插件,结合其他插件,实现复杂的 Markdown 转换和处理。

rehype-react 是一个用于将 HTML(hast)语法树转换为 React 组件的插件。它适用于希望在 React 应用中渲染经过处理的 HTML 内容的场景。

主要功能和特点包括:

  1. 将 HTML 转换为 React 组件rehype-react 能够将经过 rehype 处理的 HTML 语法树(hast)转换为 React 组件,从而使得在 React 应用中可以方便地使用和渲染这些内容。
  2. 支持自定义组件:在转换过程中,你可以定义和使用自定义的 React 组件,替代标准的 HTML 元素。这使得你可以在渲染过程中插入自定义逻辑或样式。
  3. 集成性强rehype-react 可以与 unified 生态系统中的其他插件结合使用,如 remarkrehype,以创建强大且灵活的内容处理管道。

我的博客中,为了加速 SSR 响应,Markdown 到 HTML 转换部分是在编译期进行的,代码如下。这段 TypeScript 代码定义了一个名为 parseMarkdown 的异步函数,该函数接受一个包含 Markdown 内容的字符串作为输入,并返回一个转换后的 HTML 字符串。函数使用 unified 处理器和一系列插件来解析和转换 Markdown 内容。你可以直观地看到上文提及的插件所处的位置。

1// 引入所需的库和插件 2import { unified } from 'unified'; // 引入 unified 库 3import remarkParse from 'remark-parse'; // 引入 remark-parse 插件,用于解析 Markdown 语法 4import remarkBreaks from 'remark-breaks'; // 引入 remark-breaks 插件,用于将换行符转换为 <br> 标签 5import remarkFrontmatter from 'remark-frontmatter'; // 引入 remark-frontmatter 插件,用于解析前置数据块 6import remarkGfm from 'remark-gfm'; // 引入 remark-gfm 插件,用于支持 GitHub Flavored Markdown 语法 7import remarkMath from 'remark-math'; // 引入 remark-math 插件,用于解析数学公式 8import remarkEmoji from 'remark-emoji'; // 引入 remark-emoji 插件,用于解析表情符号 9import remarkDirective from 'remark-directive'; // 引入 remark-directive 插件,用于解析自定义指令 10import remarkDirectiveRehype from 'remark-directive-rehype'; // 引入 remark-directive-rehype 插件,用于将自定义指令转换为 Rehype 节点 11import remarkRehype from 'remark-rehype'; // 引入 remark-rehype 插件,用于将 Markdown 转换为 Rehype(HTML AST) 12import rehypeRaw from 'rehype-raw'; // 引入 rehype-raw 插件,用于解析原始 HTML 13import rehypeSlug from 'rehype-slug'; // 引入 rehype-slug 插件,用于为标题生成唯一的 slug 14import rehypeAutolinkHeadings from 'rehype-autolink-headings'; // 引入 rehype-autolink-headings 插件,用于为标题添加自动链接 15import rehypeKatex from 'rehype-katex'; // 引入 rehype-katex 插件,用于使用 KaTeX 渲染数学公式 16import rehypeStringify from 'rehype-stringify'; // 引入 rehype-stringify 插件,用于将 Rehype AST 转换为 HTML 字符串 17 18/** 19 * 解析 Markdown 内容并转换为 HTML 字符串 20 * 21 * @param {string} markdown - 要解析的 Markdown 字符串 22 * @returns {Promise<string>} - 返回解析后的 HTML 字符串 23 */ 24export async function parseMarkdown(markdown: string): Promise<string> { 25 // 创建统一处理器并添加解析和转换插件 26 const file = await unified() 27 .use(remarkParse) // 使用 remark-parse 解析 Markdown 语法 28 .use(remarkBreaks) // 将换行符转换为 <br> 标签 29 .use(remarkFrontmatter) // 解析前置数据块(YAML 格式) 30 .use(remarkGfm) // 支持 GitHub Flavored Markdown (GFM) 语法 31 .use(remarkMath) // 解析数学公式 32 .use(remarkEmoji) // 解析表情符号 33 .use(remarkDirective) // 解析自定义指令 34 .use(remarkDirectiveRehype) // 将自定义指令转换为 Rehype 节点 35 .use(remarkRehype, { allowDangerousHtml: true }) // 将 Markdown 转换为 Rehype (HTML AST) 36 // 允许危险的 HTML 才能实现解析文本中的 HTML 标签,在我们能控制原始 MD 内容的情况下是安全的 37 .use(rehypeRaw) // 解析原始 HTML 38 .use(rehypeSlug) // 为标题生成唯一的 slug 39 .use(rehypeAutolinkHeadings) // 为标题添加自动链接 40 .use(rehypeKatex) // 使用 KaTeX 渲染数学公式 41 .use(rehypeStringify) // 将 Rehype AST 转换为 HTML 字符串 42 .process(markdown); // 处理 Markdown 内容并返回结果 43 44 return String(file); // 将处理后的文件内容转换为字符串并返回 45}

现在我们可以测试一下管线是否正常工作:

1:::gpt{model="人类"} 2这是自定义组件测试。 3::: 4 5::css-is-awesome

输出的 HTML 如下:

1<gpt model="人类"><p>这是自定义组件测试。</p></gpt> 2<css-is-awesome></css-is-awesome>

很好。这些自定义 HTML 标签和属性,就是我们接下来识别并转换 React 组件的依据,我们将这个 HTML 持久化到文件。为什么不一步到位?至少据我所知,React Component 无法序列化,我们无法将其保存到文本中。

在我们展示 Markdown 渲染结果的组件里(我这里是 BlogHtmlRenderer),将拿到的 HTML 解析回 AST,然后使用 rehype-react 匹配自定义的 HTML 标签,将其转换为组件:

1import React from "react"; 2import { unified } from "unified"; 3import rehypeParse from "rehype-parse"; 4import rehypeReact from "rehype-react"; 5import * as prod from "react/jsx-runtime"; 6import { GptBlock } from "@/src/components/markdown/GptBlock"; 7import CssIsAwesome from "@/src/components/markdown/CssIsAwesome"; 8import "./markdown.css"; // 可以在这里自定义标签的 CSS,也可以直接使用 className 属性 9 10const BlogHtmlRenderer: React.FC<HtmlProcessorProps> = ({ html }) => { 11 // 定义 production 对象,用于指定 Fragment 和 JSX 方法 12 const production = { Fragment: prod.Fragment, jsx: prod.jsx, jsxs: prod.jsxs }; 13 14 // 创建 unified 处理器,并配置使用 rehype 插件 15 const processor = unified() 16 .use(rehypeParse, { fragment: true }) // 使用 rehypeParse 解析 HTML 片段 17 // @ts-ignore 忽略 TypeScript 错误 18 .use(rehypeReact, { 19 passNode: true, // 传递节点信息,非常重要! 20 components: { 21 // 自定义 gpt 标签的渲染方式 22 gpt: ({ node, children }: { node: any; children: React.ReactNode }) => { 23 const { properties } = node; // 获取节点属性 24 return <GptBlock properties={properties}>{children}</GptBlock>; // 返回 GptBlock 组件 25 }, 26 // 自定义 css-is-awesome 标签的渲染方式 27 "css-is-awesome": () => { 28 return <CssIsAwesome />; // 返回 CssIsAwesome 组件 29 }, 30 }, 31 ...production, // 将 production 对象展开,添加到配置中 32 }); 33 34 // 同步处理 HTML 内容,生成 React 组件 35 const ContentComponents = processor.processSync(html).result; 36 37 // 返回处理后的内容 38 return <>{ContentComponents}</>; 39};

为了参考,这是 GptBlock 组件的内容:

1"use client"; 2 3import React, { useState } from "react"; 4import { Icon } from "@iconify-icon/react"; 5import { gsap, useGSAP } from "@/src/lib/gsap"; 6import { cn } from "@/src/lib/cn"; 7 8interface GptBlockProps { 9 properties: { model?: string }; 10 children: React.ReactNode; 11} 12 13export const GptBlock: React.FC<GptBlockProps> = ({ properties, children }) => { 14 const divRef = React.useRef<HTMLDivElement>(null); 15 const [open, setOpen] = useState<boolean>(true); 16 17 useGSAP(() => { 18 if (!open) gsap.set(divRef.current, { opacity: 0, height: 0, display: "none" }); 19 }, []); 20 21 useGSAP(() => { 22 if (open) { 23 gsap.to(divRef.current, { display: "block" }); 24 gsap.to(divRef.current, { opacity: 1, height: "auto" }); 25 } else { 26 gsap.to(divRef.current, { opacity: 0, height: 0 }); 27 gsap.to(divRef.current, { display: "none" }); 28 } 29 }, [open]); 30 31 return ( 32 <div className="gpt -mx-content"> 33 <div 34 className={cn( 35 "flex flex-row items-center justify-between m-0 pt-4 opacity-80 hover:opacity-100 transition-apple cursor-pointer", 36 open ? "pb-0 select-auto" : "pb-4 select-none", 37 )} 38 onClick={() => setOpen(!open)} 39 > 40 <span>{properties.model ?? "大语言模型"} 如是说:</span> 41 <button className="btn btn-sm btn-ghost rounded-xl text-base font-normal outline-none border-none"> 42 <Icon icon="heroicons:chevron-down" className={cn("transition-apple", open && "rotate-180")} /> 43 </button> 44 </div> 45 <div ref={divRef}> 46 {children} 47 <div className="h-4" /> 48 </div> 49 </div> 50 ); 51};

效果测试

1:::gpt{model="人类"} 2这是自定义组件测试。 3::: 4 5::css-is-awesome
人类 如是说:

这是自定义组件测试。

CSS
IS
AWESOME

如果是不存在的组件,则会输出文本部分的内容。

1:::some-nonexistent-directive[blahblahblah]{somekey="somevalue"} 2 3**The quick brown fox jumps over the lazy dog** 4 5:::

blahblahblah

The quick brown fox jumps over the lazy dog

总结

我们实现的这套管线非常强大。自定义指令允许你以便于书写的形式,向博客中添加复杂组件和可交互内容。演示的功能比较简单,但这套管线完全可以实现例如嵌入视频、增加投票功能、显示对话框、订阅邮件、甚至直接跳转网站下单……等等功能,全部在 Markdown 文本编辑器中实现,无需修改代码。此外,由于 BlogHtmlRenderer 在渲染时解析 HTML,也可以直接在 Markdown 中书写 HTML 标签,能达到同样的解析效果。