跳过正文
  1. 文章/

实现md自定义块block和代码块组code-group

·4771 字·10 分钟·
hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录

先看看block和code-group的效果:

笔记

123 script baidu

提示

123 script baidu

警告

123 script baidu

注意

123 script baidu

笔记

123 script baidu

const foo = "foo";
const foo: string = "foo";

发现
#

在 markdown 中无法做到这种功能,我之前的博客是使用 vitepress 写的,vitepress 扩展了 markdown 的语法,其中就包括了 custom-blockcode-group.

在现在的博客中,我从之前的博客迁移文章过来才发现使用了大量的 vitepress 扩展的 markdown 语法,这就导致如果我要修改得花大量的时间去查找。这样做既没有效率,之后也无法使用这种语法,那就想着能不能在现在的博客去实现一个相同的功能?

Markdown-It
#

在实现之前还需要了解下 Markdown-It,这是一个 Markdown 解析器,并且支持扩展和语法插件。

Markdown-It 的原来总的来说可以分为两个步骤: parserender,解析和渲染。要实现 custom-block 和 code-group 主要在于 render 过程,我们不太需要关心 parse 的过程,但是还是会用到一点点 parse 的知识。

看看 render,解析后的内容是一个 tokens,renderer 函数接收 tokens 和其他参数,在这里我们就可以处理得到最后渲染的 html 了。

tokens

官方对于 tokens 的解释大概就是:

我们使用更底层的数据表示 – tokens,而不是传统的AST( 抽象语法树(Abstract Syntax Tree,AST),是源代码语法结构的一种抽象表示。 以树状的形式表现编程语言的语法结构,每个节点都表示源代码中的一种结构)

  • tokens是一个简单是序列数组
  • 开始和结束标签是分开的
  • 有一些特殊的标记对象,即内联容器,他们具有嵌套标记。这些带有内联标记的序列,例如粗体、斜体、文本等。

总的来说,token流是:

  • 在顶层一一成对或单个块标记的数组:
  • 开始/结束的标题、列表、块引用、段落等。
  • code blocks, fenced blocks, 水平线,HTML 块,内联容器
  • 每个内联token都有一个children属性,其中包含用于内联内容的嵌套token流:
  • 开始/结束的粗体、斜体、链接、内联代码等。
  • 文本,换行符

你可以通过 markdown-it demo 中的debug来查看内容转换成 token 后是什么样子。

custom-block
#

实现之前
#

ok,你现在有了一点点了解了,应该也知道我们可以怎么下手了:拿到解析后的tokens,筛选出对应的token并且重新渲染它。

然后在 markdown-it demo 中写了这个语法发现渲染后的 HTML 并非和预想中的一样,它会被渲染成 <p></p>, 而我需要的是渲染成 <div></div> 并且带上一些属性。

那能不能筛选出 token 的 type="paragraph_open/close" && tag="p",然后查找在这之中 content 包含了 ::: tip ::: 的token,然后再处理?想了想后要从非常庞大的tokens中去逐一查找就不太可取,而且从content查找关键字,那我这样写的文字(abcd::: tip :::efg)那也能会匹配上。

其实还需要另外一个库 markdown-it-container,它已经帮我们处理好并筛选出包含关键字的token了。

var md = require('markdown-it')()
            .use(require('markdown-it-container'), name [, options]);

通过 name 定义 ::: 后的关键字,然后 使用 options 中的 render 去渲染。详细的文档可以去它的仓库查看。

首先下载需要使用到的库:

pnpm install markdown-it markdown-it-container -D

createContainer
#

import type MarkdownIt from "markdown-it";
import type { RenderRule } from "markdown-it/lib/renderer.mjs";
import container from "markdown-it-container";

type ContainerArgs = [typeof container, string, { render: RenderRule }];

function createContainer(
  klass: string,
  defaultTitle: string,
  md: MarkdownIt,
): ContainerArgs {
  return [
    container,
    klass,
    {
      render() {
        return "";
      },
    },
  ];
}

编写一个通用的 container,之后我们就能在使用插件时直接使用:

md.use(...createContainer("tip", "TIP", md));

render
#

在上面我们已经知道了 Markdown-It 的大概原理,在这里我们就是需要通过 render 来渲染。

由于 Markdown-It-container 已经帮我们处理过了 tokens, 所以我们 tokens[idx] 所得到的就只会是 ::: 开头结尾的段落。

被container解析后的token
Token = {
  type: "container_tip_open",
  tag: "div",
  attrs: null,
  map: [158, 169],
  nesting: 1,
  level: 0,
  children: null,
  content: "",
  markup: ":::",
  info: " tip",
  meta: null,
  block: true,
  hidden: false,
};
Token = {
  type: "container_tip_close",
  tag: "div",
  attrs: null,
  map: null,
  nesting: -1,
  level: 0,
  children: null,
  content: "",
  markup: ":::",
  info: "",
  meta: null,
  block: true,
  hidden: false,
};

可以看出被解析后并且赋予了 type,并将 info 中的 ::: 解析到 markup 中,而 info::: 后跟的文本。

另外nesting这个字段代表标签的类型,后面会用到:

  • 1 代表标签的开始
  • -1 代表标签的闭合
  • 0 代表自闭合标签
function createContainer(
  klass: string,
  defaultTitle: string,
  md: MarkdownIt,
): ContainerArgs {
  return [
    container,
    klass,
    {
      /**
       * tokens 为解析的所有的标签
       * idx 为 markdown-it-container 解析后的包含 ::: 的索引
       * _options 为创建新的markdown-it对象时定义的选项
       * env 可以和 tokens 一起使用,将外部变量注入到解析器和渲染器中
       */
      render(tokens, idx, _options, env: { references?: any }) {
        // 拿到 `:::` 的token
        const token = tokens[idx];
        // 解析 token 中 info 的文本,slice的作用是文本后面还可能有其他文本,这里截取掉前面固有的关键字,获得后面的文本,如果没有则是''
        const info = token.info.trim().slice(klass.length).trim();
        // 获取标签的属性
        const attrs = md.renderAttrs(token);
        /**
         * 判断是否标签开始,否则一定为标签结束,不可能为自闭合标签,因为渲染的结果为div。
         * 获取title,就是 ::: 加上关键字后面的部分,还需看是否有链接引用
         * 如果是details则为可展开的标签`<details><summary></summary></details>`
         */
        if (token.nesting === 1) {
          const title = md.renderInline(info || defailtTitle, {
            references: env.references,
          });

          if (klass === "details") {
            return `<details class="${klass} custom-block"${attrs}><summary>${title}</summary>\n`;
          }
          return `<div class="${klass} custom-block"${attrs}><p class="custom-block-title">${title}</p>\n`;
        } else {
          return klass === "details" ? "</details>\n" : "</div>\n";
        }
      },
    },
  ];
}
提示

env 用于在分布式规则之间传递数据并返回附加的渲染器所需的 metadata, 例如reference。它也可以用来在特定情况下注入数据。通常,你可以通过 {} 空对象,然后将更新后的对象传递给渲染器。

references 在 MarkdownIt 渲染过程中用于存储和查找 Markdown 文档中的引用链接信息。它会使用 references 对象中的数据来生成正确的链接。

[link text][ref]

[ref]: http://example.com "Optional Title"

使用
#

这时核心的逻辑就已经写好了,接下来就是使用了。如果你是直接使用的 markdown-it 库,那就是直接使用 MarkdownIt.use(...createContainer('tip', 'TIP', md)) 即可。

我是使用了 unplugin-vue-markdown 在 vue 中可以使用 markdown 当作页面:

import Markdown from "unplugin-vue-markdown/vite";

export default defineConfig({
  plugins: [
    Markdown({
      async markdownItSetup(md) {
        md.use(...createContainer("tip", "TIP", md));
        md.use(...createContainer("warning", "WARNING", md));
        md.use(...createContainer("danger", "DANGER", md));
        md.use(...createContainer("info", "INFO", md));
        md.use(...createContainer("details", "Details", md));
      },
    }),
  ],
});

这样引入五次比较麻烦,并且写死了title,优化一下会更灵活:

// 定义一个ContainerOptions
export interface ContainerOptions {
  infoLabel?: string;
  tipLabel?: string;
  warningLabel?: string;
  dangerLabel?: string;
  detailsLabel?: string;
}

// 创建一个plugin方法
export function containerPlugin(
  md: MarkdownIt,
  options: Options,
  containerOptions?: ContainerOptions,
) {
  md.use(...createContainer("tip", containerOptions?.tipLabel || "TIP", md));
  md.use(
    ...createContainer(
      "warning",
      containerOptions?.warningLabel || "WARNING",
      md,
    ),
  );
  md.use(
    ...createContainer("danger", containerOptions?.dangerLabel || "DANGER", md),
  );
  md.use(...createContainer("info", containerOptions?.infoLabel || "INFO", md));
  md.use(
    ...createContainer(
      "details",
      containerOptions?.detailsLabel || "Details",
      md,
    ),
  );
}
export default defineConfig({
  plugins: [
    Markdown({
      async markdownItSetup(md) {
        md.use(containerPlugin);
      },
    }),
  ],
});

样式
#

这时候算是已经完成了80%了,你能看到没有自定义样式的这些 custom-block 了,接下来就要加上样式。

警告

这是我自己定义的样式,如果你想自定义样式,请自行修改;前提要求你也经给 markdown 设置过样式,否则可能样式会有问题。

:root {
  --c-text: inherit;
  --c-code: rgba(59, 130, 246, 0.72);
  /* bg */
  --c-info-bg: rgba(107, 114, 128, 0.1);
  --c-tip-bg: rgba(34, 197, 94, 0.08);
  --c-warning-bg: rgba(234, 179, 8, 0.1);
  --c-details-bg: rgba(107, 114, 128, 0.1);
  --c-danger-bg: rgba(239, 68, 68, 0.08);
  /* text */
}

html.dark {
  /* bg */
  --c-info-bg: rgba(107, 114, 128, 0.24);
  --c-tip-bg: rgba(34, 197, 94, 0.1);
  --c-warning-bg: rgba(234, 179, 8, 0.12);
  --c-details-bg: rgba(107, 114, 128, 0.24);
  --c-danger-bg: rgba(239, 68, 68, 0.12);
}

.custom-block {
  border: 1px solid transparent;
  border-radius: 8px;
  padding: 16px 16px 8px;
  margin: 24px 0;
  line-height: 24px;
  font-size: 14px;
  color: var(--c-text);
}

.custom-block.info {
  background-color: var(--c-info-bg);
}

.custom-block.info a,
.custom-block.info code {
  color: var(--c-code);
}

.custom-block.info a:hover,
.custom-block.info a:hover > code {
  color: var(--c-text);
}

.custom-block.tip {
  background-color: var(--c-tip-bg);
}

.custom-block.tip a,
.custom-block.tip code {
  color: var(--c-code);
}

.custom-block.tip a:hover,
.custom-block.tip a:hover > code {
  color: var(--c-text);
}

.custom-block.warning {
  background-color: var(--c-warning-bg);
}

.custom-block.warning a,
.custom-block.warning code {
  color: var(--c-code);
}

.custom-block.warning a:hover,
.custom-block.warning a:hover > code {
  color: var(--c-text);
}

.custom-block.danger {
  background-color: var(--c-danger-bg);
}

.custom-block.danger a,
.custom-block.danger code {
  color: var(--c-code);
}

.custom-block.danger a:hover,
.custom-block.danger a:hover > code {
  color: var(--c-text);
}

.custom-block.details {
  background-color: var(--c-details-bg);
}

.custom-block.details a,
.custom-block.details code {
  color: var(--c-code);
}

.custom-block.details a:hover,
.custom-block.details a:hover > code {
  color: var(--c-text);
}

.custom-block-title {
  font-weight: 600;
}

.custom-block p + p {
  margin: 8px 0;
}

.custom-block.details summary {
  margin: 0 0 8px;
  font-weight: 700;
  cursor: pointer;
  user-select: none;
}

.custom-block.details summary + p {
  margin: 8px 0;
}

.custom-block a {
  color: inherit;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 2px;
  transition: opacity 0.25s;
}

.custom-block a:hover {
  opacity: 0.75;
}

.custom-block code {
  font-size: 14px;
}

.custom-block.custom-block th,
.custom-block.custom-block blockquote > p {
  font-size: 14px;
  color: inherit;
}

code-group
#

实现之前
#

首先下载需要使用到的库:

pnpm install nanoid -D

那这个应该怎么实现呢?看效果我们知道它是一个切换的tabs,使用dom操作实现在这里貌似不太现实,那如何使用纯css去实现tabs呢?

我们可以通过 radio 的特性配合 label 来控制,点击 label 时,对应的代码块显示。你可以自己试试怎么实现tabs。不要忘记css中的伪元素选择器,通过判断input:checked状态去处理。

那么现在就该想想怎样去渲染HTML了,原本的两段代码块渲染后会是这样的:

<p>::: code-group</p>

<pre>
  <code></code>
</pre>

<pre>
  <code></code>
</pre>

<p>:::</p>

我们现在需要的HTML大概是这样的,还需要识别代码块后面的文本来渲染成label:

<div class="code-group">
  <div class="tabs">
    <input type="radio" id="" checked />
    <label for=""></label>
    <input type="radio" id="" />
    <label for=""></label>
  </div>
  <div>
    <pre><code></code></pre>
    <pre><code></code></pre>
  </div>
</div>

知道需要渲染的内容后,思路就清晰多了。同样的我们需要获取type === 'container_code-group_start'type === container_code-group_close 来处理标签的开始和结束:

<!-- 开始标签 -->
<div class="code-group">
  <div class="tabs">
    <input type="radio" id="" checked />
    <label for=""></label>
    <input type="radio" id="" />
    <label for=""></label>
  </div>
  <div>
    <!-- 代码块部分 -->
    <pre><code></code></pre>
    <pre><code></code></pre>

    <!-- 结束标签 -->
  </div>
</div>

在上面所了解到的 token 的结构,我们从 markdown-it demo可以知道代码块部分的 type 都是 fence,我们需要拿到代码块定义的标题,就需要知道 type === fence

那么就开始实现吧。

createCodeGroup
#

code-group 和 custom-block 类似,固定了 name 为 code-group,并且 render 的逻辑也不相同。

function createCodeGroup(): ContainerArgs {
  return [
    container,
    "code-group",
    {
      render() {
        return "";
      },
    },
  ];
}

render
#

function createCodeGroup(): ContainerArgs {
  return [
    container,
    "code-group",
    {
      render(tokens, idx) {
        // 以防忘记,这里判断的是带有 code-group 的 token 的标签是否是开始标签,而不是判断所有的 tokens
        if (tokens[idx].nesting === 1) {
          const name = nanoid(5); // radio 唯一 name,才能实现单选
          const tabs = ""; // tabs html
          const checked = "checked";

          /**
           * 这里除了要处理渲染开始和结束标签的HTML,还要拿到其中代码片段的 title
           * 这里的循环时查找 code-group 之内的其他标签
           */
          for (
            let i = idx + 1;
            !(
              tokens[i].nesting === -1 &&
              tokens[i].type === "container_code-group_close"
            );
            i++
          ) {
            // 兼容在md中直接使用 <pre><code></code></pre> 编写代码块,并包含属性data-title="",那么也可以识别出来
            const isHtml = tokens[i].type === "html_block";

            if (
              (tokens[i].type === "fence" && tokens[i].tag === "code") ||
              isHtml
            ) {
              // 获取 title
              const title = extractTitle(
                isHtml ? tokens[i].content : tokens[i].info,
                isHtml,
              );

              if (title) {
                const id = nanoid(7); // radio 中 id 和 label 中 for 对应
                tabs += `<input type="radio" name="group-${name}" id="tab-${id}" ${checked}><label for="tab-${id}">${title}</label>`;

                // 给第一个代码块 token.info 加上 active 属性
                if (checked && !isHtml) tokens[i].info += " active";
                checked = "";
              }
            }
          }

          return `<div class="code-group"><div class="tabs">${tabs}</div><div class="blocks">\n`;
        }
        return `</div></div>\n`;
      },
    },
  ];
}
render中使用到的工具
/**
 * 去除块内注释并提取data-title属性值
 */
export function extractTitle(info: string, html = false) {
  if (html) {
    return (
      info.replace(/<!--[\s\S]*?-->/g, "").match(/data-title="(.*?)"/)?.[1] ||
      ""
    );
  }
  return info.match(/\[(.*)\]/)?.[1] || extractLang(info) || "txt";
}
/**
 * 提取代码块的语言,```js = js
 */
export function extractLang(info: string) {
  return (
    info
      .trim()
      .replace(/=(\d*)/, "")
      // eslint-disable-next-line regexp/optimal-quantifier-concatenation
      .replace(/:(no-)?line-numbers(\{| |$|=\d*).*/, "")
      .replace(/(-vue|\{| ).*$/, "")
      .replace(/^vue-html$/, "template")
      .replace(/^ansi$/, "")
  );
}

使用
#

在containerPlugin中直接加上 md.use(...createCodeGroup()) 即可。

然后我们测试一下,会发现虽然HTML结果没问题,但是功能有问题。虽然没有样式,但是我们也能发现问题,看看HTML你会发现,我们在代码中加上的 active 只是在 token 中加上而已,并没有使用到。我们需要 active 来控制代码块的显隐。

那能不能不通过 active 来控制呢。实现tabs确实可以,通过给 tab panel 也设置特定的属性值,然后通过属性选择器去控制。但是怎么给它加上属性值呢?好像没有办法,markdown-it-container 只返回开始和结束的标签。

markdown-it 代码中的 renderer.mjs描述, 可以知道:从解析的 token 流 生成HTML。每个实例都有独立的 rules 副本。这些可以轻松重写。此外,如果您创建插件并添加新的令牌类型,则可以添加新的 rules。

现在我们知道我们可以编写一个新的插件来处理这个情况。因为都是代码块,所以它的 token 流的 type === 'fence'

处理代码块的 active
#

可以直接把 active 加在代码块 <pre class="active"></pre> 上,但是我选择用一层 div 包裹,显得层次清晰一点,更能看清修改痕迹。并且需要加上其他属性也不会混乱。

我们新建一个插件:

export function preWrapperPlugin(md: MarkdownIt) {
  // fence本身就是render,但是我们需要重写它
  const fence = md.renderer.rules.fence!;

  md.renderer.rules.fence = (...args) => {
    const [tokens, idx] = args;
    // 拿到所有 fence 的 token
    const token = tokens[idx];

    // 移除代码块定义的 title,eg: [index.js]会被整个移除
    token.info = token.info.replace(/\[.*\]/, "");

    // 判断 info 中是否有 `active`
    // eslint-disable-next-line regexp/no-unused-capturing-group
    const active = / active( |$)/.test(token.info) ? " active" : "";

    // 移除 active
    token.info = token.info.replace(/ active$/, "").replace(/ active /, " ");

    // 获取定义代码块的语言, 这个方法在上面有定义过 `render中使用到的工具`
    const lang = extractLang(token.info);

    // 自定义包裹后渲染原来的 fence即可
    return `<div class="language-${lang}${active}">${fence(...args)}</div>`;
  };
}

vite.config.ts 中使用这个插件:

export default defineConfig({
  plugins: [
    Markdown({
      async markdownItSetup(md) {
        md.use(preWrapperPlugin);

        // container
        md.use(containerPlugin);
      },
    }),
  ],
});

现在再看看HTML,它已经正确显示了,接下来我们只需要加上样式就可以了。

样式
#

警告

这是我自己定义的样式,如果你想自定义样式,请自行修改;前提要求你也经给 markdown 设置过样式,否则可能样式会有问题。

.code-group {
  margin-top: 16px;
}

.code-group .tabs {
  position: relative;
  display: flex;
  padding: 0 12px;
  background-color: #fafafa;
  overflow-x: auto;
  overflow-y: hidden;
  box-shadow: inset 0 -1px #ffffff;
  border-radius: 6px 6px 0 0;
}

html.dark .code-group .tabs {
  background-color: #0e0e0e;
  box-shadow: inset 0 -1px #000000;
}

.code-group .tabs input {
  position: fixed;
  opacity: 0;
  pointer-events: none;
}

.code-group .tabs label {
  position: relative;
  display: inline-block;
  border-bottom: 1px solid transparent;
  padding: 0 12px;
  line-height: 48px;
  font-size: 14px;
  font-weight: 500;
  color: inherit;
  opacity: 0.6;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.25s;
}

.code-group .tabs label::after {
  position: absolute;
  right: 8px;
  bottom: -1px;
  left: 8px;
  z-index: 1;
  height: 2px;
  border-radius: 2px;
  content: "";
  background-color: transparent;
  transition: background-color 0.25s;
}

.code-group label:hover {
  opacity: 1;
}

.code-group input:checked + label {
  opacity: 1;
}

.code-group input:checked + label::after {
  background: #000000;
  opacity: 0.6;
}

html.dark .code-group input:checked + label::after {
  background: #ffffff;
}

.code-group div[class*="language-"] {
  display: none;
  margin-top: 0 !important;
  border-top-left-radius: 0 !important;
  border-top-right-radius: 0 !important;
}

.code-group div[class*="language-"].active {
  display: block;
}

切换 tab 失效
#

这时候加上样式,会发现点击 tab 还是无法切换,因为之前只是默认给第一个 tab 加上了 active,但是没有处理切换时 active 也切换的逻辑。

我们需要处理监听点击 tab 的逻辑:

export function useCodeGroups() {
  const initializeCodeGroups = () => {
    document.querySelectorAll(".code-group > .blocks").forEach((el) => {
      Array.from(el.children).forEach((child) => {
        child.classList.remove("active");
      });
      el.children[0]?.classList.add("active");
    });
  };

  onMounted(() => {
    if (import.meta.env.DEV) {
      initializeCodeGroups();
    }

    if (typeof window !== "undefined") {
      window.addEventListener("click", (e) => {
        const el = e.target as HTMLInputElement;

        if (el.matches(".code-group input")) {
          const group = el.parentElement?.parentElement;
          if (!group) return;

          // 获取点击的 tab 索引
          const i = Array.from(group.querySelectorAll("input")).indexOf(el);
          if (i < 0) return;

          const blocks = group.querySelector(".blocks");
          if (!blocks) return;

          const current = Array.from(blocks.children).find((child) =>
            child.classList.contains("active"),
          );
          if (!current) return;

          // 获取点击的 tab 对应的代码块
          const next = blocks.children[i];
          if (!next || current === next) return;

          current.classList.remove("active");
          next.classList.add("active");

          const label = group.querySelector(`label[for="${el.id}"]`);
          label?.scrollIntoView({ block: "nearest" });
        }
      });
    }
  });

  onUpdated(() => {
    if (import.meta.env.DEV) {
      initializeCodeGroups();
    }
  });
}

最后在 App 中使用即可:

<script setup lang="ts">
useCodeGroups();
</script>

相关文章