S

个人网站的富文本渲染

·Shinji

最近在重新开发个人网站,博客文章的渲染做了一点研究,最后选定了 markdown 作为内容格式,简单聊聊。

富文本渲染主要有两种方案

  • HTML 字符串
  • virtual DOM

markdown 可以算作 HTML 的子方案,virtual DOM 的路线也演化出一些私有数据格式,比如 portabletext,Delta。

回到我个人的场景里,文章首先是在 Obsidian 里编辑,定稿后发布到 Supabase,所以数据源肯定是 markdown,markdown 的语法简单,且所见即所得,同时大多数平台编辑器都支持以 markdown 的形式进行输入;而 virtual DOM 格式对人类不友好,在需要直接编辑格式数据的情况下 virtual DOM 便直接被排除了;更重要的是数据迁移难度,现在各种云服务使用私有格式存储,如果数据完全依赖于某个平,只能希望该平台活得比你久,一旦需要迁移,必然会面临非常困难的情况。

markdown 本质上是类似于 HTML 的标记语言,规定一些书写格式来对应 HTML 的标签,比如 # h1<h1>h1<h1>,渲染规则其实是匹配格式转换成 HTML 代码,非规定格式直接复制过去,所以,markdown 也可以说是 HTML 的简写形式。但是 markdown 原生语法非常少,如果希望展示一些更丰富的内容时就会感到捉襟见肘,遑论交互,所以 必然要对 markdown 语法进行扩展。

在这个领域中,已经形成了一个非常强大的技术生态 unifiedjs ,它是一套处理结构化文本的方案体系,能够处理 Markdown、HTML、自然语言等,它可以将文本内容编译为语法树并进行渲染,拥有数百个包来处理中间的转换过程,并且在渲染阶段可以自定义渲染方式。旗下 markdown 相关插件集合称为 remark,提供了 150 多个可供选择的插件,可以扩展支持 emoji,数学公式,化学方程式,TOC 等。

一个简单的例子:

import React, { Fragment } from 'react';
import { Remark } from 'react-remark';
import remarkGemoji from 'remark-gemoji';
import rehypeSlug from 'rehype-slug';
import rehypeAutoLinkHeadings from 'rehype-autolink-headings';

// ...

<Remark
  remarkPlugins={[remarkGemoji]}
  remarkToRehypeOptions={{ allowDangerousHtml: true }}
  rehypePlugins={[rehypeSlug, rehypeAutoLinkHeadings]}
  rehypeReactOptions={{
    components: {
      p: (props) => <p className="custom-paragraph" {...props} />,
    },
  }}
>
  {markdownSource}
</Remark>;

扩展后的 markdown 已经非常强大了,在内容展示层面几乎可以满足所有需求,但是细看可以发现,它其实还是在 HTML 和 CSS 的范围内,停留在静态编译阶段,运行时还是一片空白,前端最强大的 JavaScript 生态并没有发挥出来。

这里补充一个前端的技术 JSX

JSX 是 JavaScript 语法扩展,可以让你在 JavaScript 文件中书写类似 HTML 的标签。虽然还有其它方式可以编写组件,但大部分 React 开发者更喜欢 JSX 的简洁性,并且在大部分代码库中使用它。

用大白话说就是你可以在 JSX 中把 HTML 和 JavaScript 写在一起,前面说了 markdown 可以说是 HTML 的简写形式,那么把 JSX 中的 HTML 替换成 markdown 理论上也是可以的,所以就出现了一项新技术MDX

MDX 允许你在 Markdown 文档中无缝地插入 JSX 代码。 你还可以导入(import)组件,例如交互式图表或弹框,并将它们嵌入到内容当中。

这样一来,等于是把博客文章放进一个页面的运行时里,拥有完整的上下文,且能够像 React 或 Vue 一样编写,然后就是发挥你想象力的时刻了,无论是文本内容动态生成图表,还是直接写功能 demo 等等都变得非常简单。

import { notFound } from 'next/navigation'
import * as runtime from 'react/jsx-runtime'
import { compile, run } from '@mdx-js/mdx'
import { Fragment } from 'react'

export default async function BlogPost({
  params,
}: {
  params: { slug: string }
}) {
  // get your post data

  if (post === null) {
    notFound()
  }

  const content = String(
    await compile(post.content, {
      outputFormat: 'function-body',
      development: false,
    }
  )

  const contentModule = await run(content, runtime)
  const PostContent = contentModule ? contentModule.default : Fragment
 
  return (
    <>
      <article>
        <h1>{post.title}</h1>
        <PostContent />
      </article>
    </>
  )
}

至此,一套基于 markdown ,可扩展,功能完善的渲染方案就出炉了。