上帝说,要有光,于是便有了光
为了 Markdown 更加具有可玩性,一般我们无法满足于标准的 Markdown 语法,所以有了 GFM (GitHub Flavored Markdown),这是 GitHub 扩展 Markdown 语法的规范。但是如果这也无法满足我们的需求呢?那么就需要我们自己来定制了。

开始之前

首先需要安装如下几个库
yarn add react-markdown remark-parse
至于需要 react 之类的话,就不必多说了。此文章基于 react-markdown 库进行定制 markdown 语法。

简单使用

react-markdown 的使用方法非常简单,只需要这样就行了。
import ReactMarkdown, { ReactMarkdownProps } from 'react-markdown';
const Markdown: FC = props => {
  return <ReactMarkdown>
    {/* value 为 markdown 内容 */}
  	{props.value}
  </ReactMarkdown>
}
一般的,到这里其实就 ok 了。
如果你不满足于此,那么进入今天的主题。

定制语法

spoiler 是一个新的语法,token 为 ||这是文字||,通过两个竖线包裹,被渲染为文字和背景同色,鼠标移上后使背景透明。类似萌娘的剧透内容的样式。
扩展之前,我们首先要知道 react-markdown 是对 remark 的一次封装,所以是可以使用 remark 的插件来扩展语法的。那么接下来我们就来做一个插件。
// rules/spoiler.ts
import { Eat, Parser } from 'remark-parse';

function tokenizeSpoiler(eat: Eat, value: string, silent?: boolean): any {
  const match = /\|\|(.*)\|\|/.exec(value);

  if (match) {
    if (silent) {
      return true;
    }
    try {
      return eat(match[0])({
        type: 'spoiler',
        value: match[1]
      });
    } catch {
      // console.log(match[0]);
    }
  }
}
tokenizeSpoiler.notInLink = true;
tokenizeSpoiler.locator = function(value, fromIndex) {
  return value.indexOf('||', fromIndex);
};

function spoilerSyntax(this: any) {
  const Parser = this.Parser as { prototype: Parser };
  const tokenizers = Parser.prototype.inlineTokenizers;
  const methods = Parser.prototype.inlineMethods;

  // Add an inline tokenizer (defined in the following example).
  tokenizers.spoiler = tokenizeSpoiler;

  // Run it just before `text`.
  methods.splice(methods.indexOf('text'), 0, 'spoiler');
}
export { spoilerSyntax };

// index.tsx
// import ...
const RenderSpoiler: FC<{ value: string }> = props => {
  return <span className={styles['spoiler']}>{props.value}</span>;
};

const Markdown = props => {
  return <ReactMarkdown plugins={[spoilerSyntax]} 
           renderers={{
        					spoiler: RenderSpoiler
    			}}
           >
  	{props.value}
  </ReactMarkdown>
}
以上的代码就完成了一个插件的开发,是不是特别简单呢。
你说你看不懂?没事,慢慢来。
首先,react-markdown 支持传入 plugins,为一个数组。数组里每个元素是一个函数,值得注意的是这个函数中的 this 是有值的,所以不要习惯用箭头函数了。
function spoilerSyntax(this: any) { // 插件入口函数
  const Parser = this.Parser as { prototype: Parser };
  const tokenizers = Parser.prototype.inlineTokenizers;
  const methods = Parser.prototype.inlineMethods; // 获取所有的 inline types 的渲染顺序 是一个数组

  tokenizers.spoiler = tokenizeSpoiler; // 把我们定义的渲染器挂载到上面
	// spoiler 为 name,如果是自定义规则,那么这个 name 和下面的 第三个参数 应相同
  
  methods.splice(methods.indexOf('text'), 0, 'spoiler'); // 把定义的规则放在哪个顺序执行呢,就放在 `text` 之前吧。`text` 也是一个规则,在整个渲染的最后一个
  
}
那么这就是入口函数了,接下来来看 tokenizeSpoiler 函数, 这个是定义如何解析的函数。
function tokenizeSpoiler(eat: Eat, value: string, silent?: boolean): any {
  const match = /\|\|(.*)\|\|/.exec(value); // 通过正则匹配字符串, value 是这一行的字符串

  if (match) {
    if (silent) { // 这个我也不知道干嘛用的,没用过,可以省略
      return true;
    }
    try { // 多吃可能导致 crash, 需要 catch
      return eat(match[0])({
        type: 'spoiler', // 自定义类型,必须在入口函数注册该名称,或使用内置名称
        value: match[1]
      });
    } catch {
      // console.log(match[0]);
    }
  }
}
// 内联规格必须制定一个定位器,以保证性能。一般是规则前缀
tokenizeSpoiler.locator = function(value, fromIndex) {
  return value.indexOf('||', fromIndex);
};
主要说一下 eat 函数,这个名字起得有点奇怪,不过理解之后就感觉起得很生动。
这是一个柯里化 (Currying) 函数,传入一个字符串,一般是匹配到的字符串,返回一个函数,该函数是你对上一个传入的字符串,做何种解析,需要传一个对象。相当于前一个函数是把原字符串(待解析)的传入串吃掉了,后一个就是这么吐出来的过程。除了type 是必须的,其他的任意,你可以传入任意 key-value,都会在渲染的时候暴露出来。
回到 Markdown 组件。
// index.tsx
// import ...
import styles from './index.module.scss';
import ReactMarkdown, { ReactMarkdownProps } from 'react-markdown';

// 这个 value 就是之前 eat 传入的对象中的 value,在这里暴露出来了
const RenderSpoiler: FC<{ value: string }> = props => { 
  // 可以写点 styles 装饰一下?当然可以!
  return <span className={styles['spoiler']}>{props.value}</span>;
};

const Markdown = props => {
  return <ReactMarkdown plugins={[spoilerSyntax]} // 这个插件就是刚刚写得导出项
           renderers={{ // 为 spoiler 指定 renderer
        					spoiler: RenderSpoiler
    			}}
           >
  	{props.value}
  </ReactMarkdown>
}
// index.module.scss
.spoiler {
    background-color: currentColor;
    transition: background 0.5s;
    &:hover {
      background-color: transparent;
    }
 }
到此为止,一个简单的规则就完成了。是不是很简单呢。

定义多个

很多情况我们不止于只定义单个规则,既然多个,就需要封装。
这里给一个示例代码,之后有时间再详讲。
interface defineNewinLineSyntaxProps {
  regexp: RegExp;
  type: string;
  name?: string;
  locator: string | Locator;
  render?: ({ value: string }) => JSX.Element | null;
  handler?: (
    eat: Eat,
    type: string,
    value: string,
    matched: RegExpExecArray | null
  ) => object;
}

export function defineNewinLineSyntax({
  regexp,
  type,
  locator,
  render,
  handler,
  name
}: defineNewinLineSyntaxProps) {
  function tokenize(eat: Eat, value: string, silent?: boolean): any {
    const match = regexp.exec(value);

    if (match) {
      if (silent) {
        return true;
      }
      try {
        return (
          handler?.(eat, type, value, match) ??
          eat(match[0])({
            type,
            value: match[1],
            component: render?.({ value: match[1] as string }) ?? null
          })
        );
      } catch {
        // console.log(match[0]);
      }
    }
  }
  tokenize.notInLink = true;
  tokenize.locator =
    typeof locator === 'function'
      ? locator
      : function(value, fromIndex) {
          return value.indexOf(locator, fromIndex);
        };

  return function(this: any) {
    const Parser = this.Parser as { prototype: Parser };
    const tokenizers = Parser.prototype.inlineTokenizers;
    const methods = Parser.prototype.inlineMethods;

    tokenizers[name ?? type] = tokenize;

    methods.splice(methods.indexOf('text'), 0, name ?? type);
  };
}