Ryan Wang's Blog

Ryan Wang

为 Docusaurus 添加 Shiki 代码高亮支持

2025-10-29
为 Docusaurus 添加 Shiki 代码高亮支持

本文记录 https://docs.halo.run 集成 Shiki 代码高亮的过程。

引言

从 Halo 1.x 开始,我们就一直在使用 Docusaurus 来构建 Halo 的文档。直到 Halo 2.21,我们已经累积了大量的文档,期间发现代码块高亮的问题难以解决。Docusaurus 默认使用 Prism.js 来渲染代码块,且几乎没有其他选择。而我们的文档中使用的一些语言或框架(如 Vue),Prism 并没有提供高亮支持,因此长期以来这些代码块都没有显示语法高亮。即使是 Prism 所支持的语言,渲染出来的语法高亮效果也不尽如人意。

直到后来了解到了 https://shiki.style/,一个较新的代码高亮库,基于与 VSCode 同源的 TextMate 代码高亮引擎,渲染出来的代码效果与 VSCode 一致,并且支持现在主流语言和框架代码的高亮。我认为这几乎是当前各方面最优的代码高亮库。后面我们也为 Halo 开发了 Shiki 代码高亮插件,表现十分良好。因此最近也考虑将 Halo 文档中的代码高亮渲染改为使用 Shiki。好在搜索一番后,已经有了一个广泛讨论的 issue:https://github.com/facebook/docusaurus/issues/9122,并且里面也有人提供了可行的方案。于是我按照这个方案并添加了一些额外功能,本文将记录实现的完整过程。如果你也在用 Docusaurus,并且对 Shiki 有需求,可以参考本文。

背景

  • 目前使用的 Docusaurus 版本是 3.8.0

安装依赖

Docusaurus 的 Markdown 引擎核心为 https://mdxjs.com/,而 MDX 内部基于 remarkrehype,因此 Docusaurus 实际上预留了可以添加 rehype 插件的配置,所以可以直接使用 Shiki 官方的 rehype 插件

pnpm add @shikijs/rehype shiki -D

添加配置

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
// [!code ++:11]
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
                // or
                // langs: ['js', 'ts']
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

其中,langs 可以只填写所需的语言列表,我这里为了省事直接添加 Shiki 所有语言,主要是因为文档太多,已经懒得去统计用到了哪些语言。

此外,theme 也可以指定多主题,如果需要让文档的暗色和亮色模式下代码块的主题不同,可以按照下面的方式更改:

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
// [!code --]
                theme: "catppuccin-mocha",
// [!code ++:4]
                themes: {
                  light: "github-light",
                  dark: "github-dark"
                },
                langs: Object.keys(bundledLanguages),
                // or
                // langs: ['js', 'ts']
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

module.exports = config;

然后在 custom.css 中添加:

[data-theme="dark"] pre {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}
[data-theme="dark"] pre span {
  color: var(--shiki-dark) !important;
}

由于我期望亮色和暗色模式下都使用暗色的代码块主题,所以没有添加多主题配置。

组件覆盖

由于需要完全让 @shikijs/rehype 接管 Markdown 文档中的代码块渲染,我们需要覆盖 Docusaurus 内部 Pre/Code 的组件,避免被默认的 Prism 处理。Docusaurus 默认提供了 CLI 用于导出 Docusaurus 主题中的组件。

npx docusaurus swizzle @docusaurus/theme-classic MDXComponents/Code --typescript --eject

然后打开 src/theme/MDXComponents/Code.tsx 并修改为:

import type { ComponentProps, ReactNode } from "react";
import React from "react";
import CodeInline from "@theme/CodeInline";
import type { Props } from "@theme/MDXComponents/Code";

function shouldBeInline(props: Props) {
  return (
    // empty code blocks have no props.children,
    // see https://github.com/facebook/docusaurus/pull/9704
    typeof props.children !== "undefined" &&
    React.Children.toArray(props.children).every(
      (el) => typeof el === "string" && !el.includes("\n")
    )
  );
}

// [!code ++:3]
function CodeBlock(props: ComponentProps<"code">): JSX.Element {
  return <code {...props} />;
}

export default function MDXCode(props: Props): ReactNode {
  return shouldBeInline(props) ? (
    <CodeInline {...props} />
  ) : (
    <CodeBlock {...(props as ComponentProps<typeof CodeBlock>)} />
  );
}
npx docusaurus swizzle @docusaurus/theme-classic MDXComponents/Pre --typescript --eject

然后打开 src/theme/MDXComponents/Pre.tsx 并修改为:

import React, { type ReactNode } from "react";
import type { Props } from "@theme/MDXComponents/Pre";
export default function MDXPre(props: Props): ReactNode | undefined {
  return <pre {...props} />;
}

小插曲:当时到了这一步的时候,突然意识到似乎可以复用之前为 Halo 开发 Shiki 插件时发布的 NPM 包(@halo-dev/shiki-code-element)。因为这个包封装了一个 Web Component,所以肯定可以用在这里,只需要在 pre 标签外包裹一个 shiki-code 即可。尝试了一下确实可行,但这样就必须在客户端渲染了。虽然可行,但始终不如在构建阶段就渲染好。虽然可以尝试使用 Lit SSR,但考虑到文档中有一些代码块使用了 Title Meta,而目前 @halo-dev/shiki-code-element 还不支持,所以放弃了这个方案。

完成这一步之后,就可以尝试启动开发服务器了。不出意外的话,代码块就可以正常使用 Shiki 来渲染了。

添加标题支持

原来 Docusaurus 的默认方案是支持为代码块添加顶部标题的,切换到 Shiki 之后,这一部分需要自行实现,以下是具体步骤:

首先为 Shiki 添加一个自定义的 Transformer,用于解析标题的书写语法和添加代码块参数:

创建 src/shiki/meta-transformer.js

function parseTitleFromMeta(meta) {
  if (!meta) {
    return "";
  }
  const kvList = meta.split(" ").filter(Boolean);
  for (const item of kvList) {
    const [k, v = ""] = item.split("=").filter(Boolean);
    if (k === "title" && v.length > 0) {
      return v.replace(/["'`]/g, "");
    }
  }
  return "";
}

export function transformerAddMeta() {
  return {
    name: "shiki-transformer:add-meta",
    pre(pre) {
      const title = parseTitleFromMeta(this.options.meta?.__raw);
      if (title.length > 0) {
        pre.properties = {
          ...pre.properties,
          "data-title": title,
        };
      }
      return pre;
    },
  };
}

然后修改配置:

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");
const { transformerAddMeta } = require("./src/shiki/meta-transformer");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
// [!code ++:3]
                transformers: [
                  transformerAddMeta(),
                ]
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

修改 Pre.tsx 显示标题:

import React, { type ReactNode } from "react";
import type { Props } from "@theme/MDXComponents/Pre";

type PreWithDataTitle = Props & { "data-title"?: string };

export default function MDXPre(props: Props): ReactNode | undefined {
  const title = props["data-title"];
  return (
    <div
      style={{
        ...props.style,
        borderRadius: "var(--ifm-pre-border-radius)",
      }}
      className="shiki-code-wrapper"
    >
      {title && (
        <div className="shiki-code-header">
          <span>{title}</span>
        </div>
      )}
      <div className="shiki-code-content">
        <pre {...props} ref={preRef} />
      </div>
    </div>
  );
}

这里还修改了 MDXPre 标签的组件结构,为后续的功能做准备,其中最外层的 div 添加的 style 属性来自于 Shiki 渲染结果的 pre 标签的样式,包含背景色和字体默认颜色。

最后我们需要为自定义的 MDXPre 组件结构添加样式,这里为了让结构看起来更清晰,我引入了 SASS 插件:

# 安装所需依赖
pnpm add docusaurus-plugin-sass sass -D

修改配置文件:

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        // ...
        theme: {
// [!code --]
          customCss: require.resolve("./src/css/custom.css"),
// [!code ++:4]
          customCss: [
            require.resolve("./src/css/custom.css"),
            require.resolve("./src/css/shiki.scss"),
          ],
        },
      }),
    ],
  ],
// [!code ++]
  plugins: [require.resolve("docusaurus-plugin-sass")],
};

然后创建 src/css/shiki.scss

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }
}

这样就支持使用原有的语法为代码块添加标题了。

显示行号

Shiki 原生并不支持在渲染的 HTML 结果中包含行号信息,但社区中有人提供了一种使用纯 CSS 实现的方案,详见:https://github.com/shikijs/shiki/issues/3#issuecomment-830564854

修改 shiki.scss

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }
// [!code ++:36]
  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end
      }
    }
  }
}

这样就可以默认为所有代码块添加行号显示了。

复制按钮

Docusaurus 默认的代码块有复制按钮,改为 Shiki 之后这部分也需要自行实现,以下是具体步骤:

修改 src/theme/MDXComponents/Pre.tsx

import React, { type ReactNode, useRef, useState } from "react";
import type { Props } from "@theme/MDXComponents/Pre";

type PreWithDataTitle = Props & { "data-title"?: string };

export default function MDXPre(props: PreWithDataTitle): ReactNode | undefined {
  const title = props["data-title"];
// [!code ++:11]
  const preRef = useRef<HTMLPreElement>(null);
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    const code = preRef.current?.innerText || preRef.current?.textContent || "";

    copyText(code, () => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };

  return (
    <div
      style={{
        ...props.style,
        borderRadius: "var(--ifm-pre-border-radius)",
      }}
      className="shiki-code-wrapper"
    >
      {title && (
        <div className="shiki-code-header">
          <span>{title}</span>
        </div>
      )}
      <div className="shiki-code-content">
// [!code ++:8]
        <button
          className="shiki-code-copy-button"
          onClick={handleCopy}
          title={copied ? "已复制!" : "复制代码"}
          style={{ ...props.style }}
        >
          <i className={copied ? "tabler--check" : "tabler--copy"}></i>
        </button>
        <pre {...props} ref={preRef} />
      </div>
    </div>
  );
}

// [!code ++:24]
export function copyText(text: string, cb: () => void) {
  if (navigator.clipboard) {
    navigator.clipboard.writeText(text).then(() => {
      cb();
    });
  } else {
    const textArea = document.createElement("textarea");
    textArea.value = text;
    textArea.style.position = "fixed";
    textArea.style.opacity = "0";
    document.body.appendChild(textArea);
    textArea.focus();
    textArea.select();
    try {
      const successful = document.execCommand("copy");
      if (successful) {
        cb();
      }
    } catch (err) {
      console.error("Fallback: Oops, unable to copy", err);
    }
    document.body.removeChild(textArea);
  }
}

添加样式:

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }

  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end
      }
    }
// [!code ++:15]
    .shiki-code-copy-button {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      opacity: 0;
      z-index: 2;
      width: 2rem;
      height: 2rem;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0.4rem;
      border: none;
      cursor: pointer;
    }
  }
// [!code ++:33]
  &:hover {
    .shiki-code-copy-button {
      opacity: 1;
    }
  }

  .tabler--copy {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M7 9.667A2.667 2.667 0 0 1 9.667 7h8.666A2.667 2.667 0 0 1 21 9.667v8.666A2.667 2.667 0 0 1 18.333 21H9.667A2.667 2.667 0 0 1 7 18.333z'/%3E%3Cpath d='M4.012 16.737A2 2 0 0 1 3 15V5c0-1.1.9-2 2-2h10c.75 0 1.158.385 1.5 1'/%3E%3C/g%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }

  .tabler--check {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12l5 5L20 7'/%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }
}

这样就可以在鼠标悬停到代码块时显示复制按钮了。

集成必要的 Transformer

Shiki 官方提供了一些非常有用的 Transformers,比如行高亮、代码对比等,这里根据自身需要添加即可。

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");
const { transformerAddMeta } = require("./src/shiki/meta-transformer");
// [!code ++:4]
const { transformerMetaHighlight } = require("@shikijs/transformers");
const { transformerNotationDiff } = require("@shikijs/transformers");
const { transformerNotationFocus } = require("@shikijs/transformers");
const { transformerNotationErrorLevel } = require("@shikijs/transformers");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
                transformers: [
// [!code ++:8]
                  // 行高亮,使用 Meta 信息的方式,比如 ```java {1}
                  transformerMetaHighlight(),
                  // 代码对比,使用注释的方式
                  transformerNotationDiff(),
                  // 行聚焦,使用注释的方式
                  transformerNotationFocus(),
                  // 行高亮的错误和警告变体,使用注释的方式
                  transformerNotationErrorLevel(),
                  transformerAddMeta(),
                ]
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

这些 Transformers 只是为对应的行添加了 class,我们需要自行实现样式。以下是完整的 shiki.scss

点击查看 shiki.scss
.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }

  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {
        z-index: 1;
        display: block;
        width: max-content;
        position: relative;
        min-width: 100%;

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end

        // highlighted lines start
        .highlighted {
          width: 100%;
          display: inline-block;
          position: relative;
        }

        .highlighted::after {
          content: "";
          position: absolute;
          top: 0;
          bottom: 0;
          left: -0.75rem;
          right: -0.75rem;
          background: rgba(101, 117, 133, 0.16);
          border-left: 1px solid rgba(34, 197, 94, 0.8);
          z-index: 0;
        }

        .highlighted.error::after {
          background: rgba(244, 63, 94, 0.16) !important;
        }

        .highlighted.warning::after {
          background: rgba(234, 179, 8, 0.16) !important;
        }
        // highlighted lines end
      }

      // focus line start
      &.has-focused .line:not(.focused) {
        opacity: 0.7;
        filter: blur(0.095rem);
        transition: filter 0.35s, opacity 0.35s;
      }

      &.has-focused:hover .line:not(.focused) {
        opacity: 1;
        filter: blur(0);
      }
      // focus line end

      // diff start
      &.has-diff .diff {
        width: 100%;
        display: inline-block;
        position: relative;
      }

      &.has-diff .diff.remove::before {
        content: "-";
      }

      &.has-diff .diff.add::before {
        content: "+";
      }

      &.has-diff .diff.remove::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        left: -0.75rem;
        right: -0.75rem;
        background: rgb(239 68 68 / 0.15);
        border-left: 1px solid rgb(239 68 68 / 0.8);
        z-index: -1;
      }

      &.has-diff .diff.add::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        left: -0.75rem;
        right: -0.75rem;
        background: rgb(34 197 94 / 0.15);
        border-left: 1px solid rgb(34 197 94 / 0.8);
        z-index: -1;
      }
      // diff end
    }

    .shiki-code-copy-button {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      opacity: 0;
      z-index: 2;
      width: 2rem;
      height: 2rem;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0.4rem;
      border: none;
      cursor: pointer;
    }
  }

  &:hover {
    .shiki-code-copy-button {
      opacity: 1;
    }
  }

  .tabler--copy {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M7 9.667A2.667 2.667 0 0 1 9.667 7h8.666A2.667 2.667 0 0 1 21 9.667v8.666A2.667 2.667 0 0 1 18.333 21H9.667A2.667 2.667 0 0 1 7 18.333z'/%3E%3Cpath d='M4.012 16.737A2 2 0 0 1 3 15V5c0-1.1.9-2 2-2h10c.75 0 1.158.385 1.5 1'/%3E%3C/g%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }

  .tabler--check {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12l5 5L20 7'/%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }
}

至此,一个比较好看、功能丰富的代码高亮改造方案就完成了,具体修改代码也可以查阅 Halo 文档的 PR:https://github.com/halo-dev/docs/pull/521,也可以访问 Halo 文档查看修改之后的效果。

参考资料