基于MdIt的无序列表折叠插件

当前Markdown已经成为最好的编程语言,同样的Md也成为了产品文档最需要支持的格式,特别是面向开发者的文档。实际上很多情况下编程和文档的场景是非常类似的,因此在时代的推动下,原生支持Md生产和消费的文档系统的需求重新出现。

在这里我们关注于API文档类型的展示,在OpenAIClaudeAPI文档中,可以看到其表达参数列表的形式类似折叠列表。而观察原始的Md文档,就可以看出其参数列表的形式是无序列表,因此我们也实现类似的功能来将无序列表转换为折叠列表展示。

实际上,将无序列表渲染成折叠列表这件事,本身还是面向开发者阅读的,如果单纯是面向AI来消费,则仅提供纯文本的Md内容即可。目前来看,同时需要面向开发者和AI的状态应该还需要存在较长的时间,因此实现一套Md渲染器还是有必要的。

解析规则

首先我们需要分析无序列表结构及其解析后的HTML,基本的无序列表结构如下所示:

- 0 
- 1 
  - 1.1 
  - 1.2 
    - 1.2.1 
    - 1.2.2 
  - 1.3 
    with desc
    - 1.3.1 
    - 1.3.2 
- 2
<ul>
  <li>0</li>
  <li> 1
    <ul>
      <li>1.1</li>
      <li> 1.2
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </li>
      <li> 1.3 <br /> with desc
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>2</li>
</ul>

可以看出示例中存在三级ul元素结构嵌套,以及描述内容的li元素,我们需要根据不同的情况来解析。理论上而言,只有存在嵌套结构的li元素才需要解析为折叠结构,其子元素内起始到ul之间的内容需要作为标题,ul内元素则作为折叠展开的内容。

通常来说,实现类似手风琴的效果,大概会主动管理状态,用div等元素来绘制折叠面板,然后主动处理点击事件,来切换折叠展开的状态。不过,HTML原生支持了details元素以及summary元素,我们可以借助原生元素来实现折叠列表的效果,其主要优点是:

  • 简单易用,通常情况下不需要主动管理状态,仅需要维护DOM结构。
  • 无需处理事件,特别是在SSR的情况下,不需要再hydrate注入事件。
  • 原生支持搜索,使用浏览器搜索时,可以自动展开包含搜索关键词的折叠列表。
<details>
  <summary>Details</summary>
  Something more.
</details>

那么根据以上的HTML结构,我们可以根据无序列表的结构,转换为details+summary元素的结构。观察其结构,我们可以实现如下转换规则:

  • ul元素作为折叠展开的内容,这里可以自定义为block元素,也可以保持ul元素。
  • li元素内存在嵌套的直属ul元素时,该li元素需要转换为details元素。
  • 转换的details元素的子元素,从起始到ul元素之间的内容,需要包装summary元素。

根据上述的转换规则,我们可以将最开始的无序列表HTML内容转换为details + summary元素的结构:

<ul>
  <li>0</li>
  <details>
    <summary>1</summary>
    <ul>
      <li>1.1</li>
      <details>
        <summary>1.2</summary>
        <ul>
          <li>1.2.1</li>
          <li>1.2.2</li>
        </ul>
      </details>
      <details>
        <summary>1.3 <br /> with desc</summary>
        <ul>
          <li>1.3.1</li>
          <li>1.3.2</li>
        </ul>
      </details>
    </ul>
  </details>
  <li>2</li>
</ul>

元素重建

在设计好HTML结构的转换规则后,我们需要在MarkdownIt的基础上实现转换逻辑。在MdIt中提供了诸多时机的Hook函数,我们需要根据处理的时机来实现转换逻辑,通常来说应该尽可能在后处理阶段来实现相关逻辑,这里我们分别实现解析后处理和渲染时处理。

渲染时处理

因此,我们首先来看仅渲染阶段的rule处理逻辑,在上述的转换规则中,将ul元素转换为block元素,以及将li元素渲染为details元素,这两点是没什么问题的。然而,为子节点包装summary元素,则是比较麻烦的。

在仅渲染阶段,这件事并非不能实现,但是却容易破坏MdIt的线性解析模式。如果这是个递归结构,则仅需要将其节点包一层DOM元素即可,而在线性结构中,包装一层summary元素需要在li_open追加<summary>元素,在ul_open前置</summary>元素。

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    const token = tokens[i];
    if (token.level < current.level - 1) break;
    if (token.type === "list_item_open" && token.level === current.level - 1) {
      return "</summary>" + "<ul class=\"bullet-summary-group\">";
    }
  }
  return "<ul class=\"bullet-summary-group\">";
};
mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx + 1; i < tokens.length; i++) {
    const token = tokens[i];
    if (token.level <= current.level)  break;
    if (token.type === "bullet_list_open" && token.level === current.level + 1) {
      return "<details>" + "<summary>";
    }
  }
  return "<li>";
};
mdIt.renderer.rules.list_item_close = (tokens: Token[], idx: number) => {
  const prevToken = tokens[idx - 1];
  if (prevToken && prevToken.tag === "ul") return "</details>";
  return "</li>";
};

虽然这种模式实现起来简单,理论上也并没有什么问题。然而这里存在的问题是,如果我们需要判断大多情况下保持无序列表,仅表达API参数时才将其渲染为折叠列表,那么此时我们在ul元素上方添加@bullet-summary指令来指定渲染模式。

@bullet-summary
- ul
   - li
   - li

那么此时问题在于,如何判断现在现在嵌入的ul元素需要渲染为折叠列表。那么在渲染时机,取得这个渲染指令并不是很容易,因为其本身是扁平的,那么每次调度rule渲染时,都需要迭代向上查找该指令。而如果在渲染时处理p元素的话,则在消费时实现写数据,有点反逻辑。

mdIt.renderer.rules.list_item_open = (tokens: Token[], idx: number) => {
  const current = tokens[idx];
  for (let i = idx - 1; i >= 0; i--) {
    // 找到该组顶级 ul 元素, 检查其前置 @bullet-summary 指令 
  }
};

mdIt.renderer.rules.bullet_list_open = (tokens: Token[], idx: number) => {
  // 检查其前置元素是否为 @bullet-summary 指令, 此时在 env 设置变量
};
mdIt.renderer.rules.bullet_list_close = (tokens: Token[], idx: number) => {
  // 检查其匹配的 ul env 设置的环境变量, 此时在 env 清理环境变量
};

解析时处理

MdIt的解析过程中,除了渲染时的rule处理逻辑,还可以在解析阶段后处理Token,此时可以找到相关指令再实现相关的转换逻辑。由于我们并不没有额外实现新的语法,指令更多是起到了标记的作用,因此不需要时机解析内容,而是重新组织Tokens

那么此时,我们先来判断一下指令标记,如果匹配到了该标记,则需要进入到重建Tokens的阶段。不过在此之前,我们需要将该指令节点隐藏,不过如果渲染指令是注释类型的话,倒是可以直接隐藏而无需特殊处理。

// paragraph_open
//   inline: @bullet-summary
// paragraph_close
// bullet_list_open
if (
  token.content === identifier &&
  token.type === "inline" &&
  nextToken &&
  nextStep2Token &&
  nextToken.type === "paragraph_close" &&
  nextStep2Token.type === "bullet_list_open"
) {
  prevToken && (prevToken.hidden = true);
  (token.hidden = true) && (token.children = []);
  nextToken && (nextToken.hidden = true);
  rebuildUlTokens(state, i + 2);
}

紧接着,我们需要找到该节点的对应close节点,以此来圈定具体需要处理的范围。说起来,由于MdIt的解析是线性的,虽然规避了递归的问题,但是最差情况下时间复杂度还是O(n)。此外,由于token.level并不太准确,因此还需要维护一个栈深度来记录当前的层级。

const baseType = openToken.type.slice(0, -5);
const closeType = baseType + "_close";
// open      1
// start iterator
//   open    2
//   close   1
// close     0
// end iterator
let level = 1;
for (let i = openIdx + 1; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.type === openToken.type) {
    level++;
  } else if (token.type === closeType) {
    level--;
    if (level <= 0) return i;
  }
}
return -1;

接下来,需要对ul元素做一些修改,主要是为ul加入class属性,用以指定样式。然后维护一个栈,来记录li元素相互对应的节点。此外,这里有个重要的点是要从后向前遍历,以免前置内容的修改影响后续节点的处理,特别是在插入元素的情况下。

const stack: Token[] = [];
// 从后向前遍历, 避免修改后, 影响后续 i 遍历
for (let i = closeIdx; i >= startIdx; i--) {
  const token = tokens[i];
  if (token.type === "bullet_list_open") {
    token.attrJoin("class", "bullet-summary-group");
  }
  if (token.type === "list_item_close") {
    stack.push(token);
  }
  if (token.type === "list_item_open") {
    const peer = stack.pop();
    rebuildLiTokens(state, i, peer, actions);
  }
}

在匹配到list_item_open节点时,就需要重建li元素结构了,这部分就会更复杂一些。首先我们创建一个对应元素区域的迭代器,来遍历openclose之间的所有节点。迭代器中重要的实现是要携带相关的meta信息,辅助计算层级关系。

let depth = 0;
for (let i = openIdx; i < tokens.length; i++) {
  const token = tokens[i];
  if (token.nesting >= 0) {
    depth++;
  }
  yield { token, depth: depth - 1, idx: i, serial: i - openIdx };
  if (token.nesting <= 0) {
    depth--;
    if (depth <= 0) break;
  }
}

li节点区域遍历过程中,我们需要根据depth来判断其直属子元素。如果直属子元素为ul,则代表该li元素嵌套了无序列表,这样就需要将其转换为details元素。注意,这里修改其type不应该影响外层的栈,需要注意保持关系正确。

// 查找 li 下的子项, 主要目的是检查其直属子元素
for (const node of walker) {
  const k = node.idx;
  const tokenK = node.token;
  if (node.depth !== 1) continue;
  // 直属的 ul 子项, 若是存在则需要转换为 details 组
  if (tokenK.type === "bullet_list_open") {
    // i 的 li 元素需要变为 details 元素
    liToken.type = "li_details_open";
    liToken.tag = "details";
  }
}

接下来,我们需要为i - k之间的元素创建summary元素,用以指定折叠标题。这里是最难以处理的点,因为不仅是修改内容,还需要插入新的token。并且需要对其peer节点进行处理,将其token.type转换为li_details_close元素。

// 为 i - k 之间的元素创建 summary
const sOpen = new state.Token("li_summary_open", "summary", 1);
const sClose = new state.Token("li_summary_close", "summary", -1);
// 现在 peer 是 i 之后的元素, 不会影响原始遍历 li 的栈平衡
if (peer) {
  peer.type = "li_details_close";
  peer.tag = "details";
}
// 处理 summary 元素的插入位置
actions.push({ idx: openIdx + 1, token: sOpen });
actions.push({ idx: k, token: sClose });

上述的actions是需要关注的点,我们并不会直接修改tokens数组,因为此时修改tokens数组会导致其长度发生变化,从而影响到后续节点的遍历,以及插入位置的计算。在这里我们统一处理插入行为,这里需要关注的是按索引从大到小排序, 后索引的元素, 不影响前索引的元素。

actions
  .sort((a, b) => b.idx - a.idx)
  .forEach(action => {
    tokens.splice(action.idx, 0, action.token);
  });

最后,由于我们插入了新的层级,我们需要将内部的level也更新一下。因此从这里也可以看出来level并不是那么准确,如果注册的插件并没有处理好level的话,则会影响到后续依赖该字段的插件。

// 处理 summary 及其内部元素的 level
sOpen.level = liToken.level + 1;
sClose.level = liToken.level + 1;
for (let i = openIdx + 1; i < k; i++) {
  const token = tokens[i];
  token.level = (token.level || 0) + 1;
}

CSS 样式

实际上,由于不同浏览器的details + summary元素的默认样式不同,因此需要对其样式进行统一化处理。不过,这部分主要是由组件库来实现的,我们只需要关注其基本功能即可。此外提一下,summary还是需要一个border样式的,特别是存在多行内容的情况下。

/*
 * Add the correct display in Edge, IE 10+, and Firefox.
 */
details {
  display: block;
}

/*
 * Add the correct display in all browsers.
 */
summary {
  display: list-item;
}

总结

在这里我们基于MdIt,解析了基础的无序列表结构,并且观察了其层级关系,设计出了一套DOM结构转换规则。基于此分别使用纯渲染模式以及解析后处理模式,实现了无序列表折叠插件,这种结构表达在思维导图和API参数表达中非常有用。

实际上,我们实现的插件还有很多可以优化的地方。首先我们可以将结构化表达和渲染时表达结合起来,在解析后处理时仅需要将需要相关token写入标记,在渲染时处理标签结构即可。此外,结构处理写入的时候实际上应该将所有变更统一处理,以避免影响现有遍历和判断,理论上应该引入OT-JSON来处理各个变更之间的相互影响。

每日一题

参考