使用 Prism 在静态网站上实现语法高亮(以及更多功能)

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

假设您已决定使用 Next.js 构建博客。像任何开发人员博主一样,您希望在文章中包含格式良好的带语法高亮的代码片段。也许您还想在代码片段中显示行号,甚至可能拥有突出显示特定代码行的功能。

本文将向您展示如何设置这些功能,以及一些关于如何实现这些其他功能的技巧和窍门。其中一些比您预期的要复杂。

先决条件

我们将使用 Next.js 博客入门 作为我们项目的基线,但相同的原则应该适用于其他框架。该仓库提供了清晰(且简单)的入门说明。搭建博客,我们开始吧!

我们在这里使用的另一个工具是 Prism.js,这是一个流行的语法高亮库,甚至在 CSS-Tricks 上也有使用。Next.js 博客入门使用 Remark 将 Markdown 转换为标记,因此我们将使用 remark-Prism.js 插件 来格式化我们的代码片段。

Prism.js 的基本集成

我们先将 Prism.js 集成到 Next.js 入门项目中。由于我们已经知道将使用 remark-prism 插件,因此首先需要使用您最喜欢的包管理器安装它

npm i remark-prism

现在进入 /lib 文件夹中的 markdownToHtml 文件,并启用 remark-prism

import remarkPrism from "remark-prism";

// later ...

.use(remarkPrism, { plugins: ["line-numbers"] })

根据您使用的 remark-html 版本,您可能还需要将其用法更改为 .use(html, { sanitize: false })

现在整个模块应该如下所示

import { remark } from "remark";
import html from "remark-html";
import remarkPrism from "remark-prism";

export default async function markdownToHtml(markdown) {
  const result = await remark()
    .use(html, { sanitize: false })
    .use(remarkPrism, { plugins: ["line-numbers"] })
    .process(markdown);

  return result.toString();
}

添加 Prism.js 样式和主题

现在,让我们导入 Prism.js 需要用来为代码片段设置样式的 CSS。在 pages/_app.js 文件中,导入主要的 Prism.js 样式表,以及您要使用的主题的样式表。我使用的是 Prism.js 的“Tomorrow Night”主题,因此我的导入如下所示

import "prismjs/themes/prism-tomorrow.css";
import "prismjs/plugins/line-numbers/prism-line-numbers.css";
import "../styles/prism-overrides.css";

请注意,我还开始了一个 prism-overrides.css 样式表,我们可以在其中调整一些默认值。这在以后会很有用。现在,它可以保持为空。

有了这些,我们现在就有一些基本样式了。以下 Markdown 中的代码

```js
class Shape {
  draw() {
    console.log("Uhhh maybe override me");
  }
}

class Circle {
  draw() {
    console.log("I'm a circle! :D");
  }
}
```

…应该可以很好地格式化

添加行号

您可能已经注意到,我们生成的代码片段即使导入支持它的插件时也未显示行号,我们导入的插件就是 remark-prism。解决方法在 remark-prism README 中一目了然

请不要忘记在您的样式表中包含相应的 css。

换句话说,我们需要在生成的 <pre> 标签上强制添加 .line-numbers CSS 类,我们可以这样做

有了这些,我们现在就可以显示行号了!

请注意,根据我拥有的 Prism.js 版本和选择的“Tomorrow Night”主题,我需要在上面开始的 prism-overrides.css 文件中添加以下内容

.line-numbers span.line-numbers-rows {
  margin-top: -1px;
}

您可能不需要这样做,但这就是方法。我们现在有了行号!

突出显示行

我们的下一个功能需要更多工作。我们要实现的功能是突出显示或标记代码片段中的特定代码行。

有一个 Prism.js 行高亮插件;不幸的是,它没有集成到 remark-prism 中。该插件的工作原理是分析格式化代码在 DOM 中的位置,并根据这些信息手动突出显示行。这在 remark-prism 插件中是不可能的,因为插件运行时没有 DOM。毕竟,这是静态网站生成。Next.js 在构建步骤中运行我们的 Markdown 并生成 HTML 来呈现博客。所有这些 Prism.js 代码都在静态网站生成期间运行,此时没有 DOM。

但不要担心!有一个有趣的解决方法非常适合 CSS-Tricks:我们可以使用纯 CSS(以及一些 JavaScript)来突出显示代码行。

我明确地说,这需要不少工作。如果您不需要行高亮,那么可以跳过下一部分。但即使如此,它也可以作为展示可能性的有趣示例。

我们的基本 CSS

我们先将以下 CSS 添加到 prism-overrides.css 样式表中

:root {
  --highlight-background: rgb(0 0 0 / 0);
  --highlight-width: 0;
}

.line-numbers span.line-numbers-rows > span {
  position: relative;
}

.line-numbers span.line-numbers-rows > span::after {
  content: " ";
  background: var(--highlight-background);
  width: var(--highlight-width);
  position: absolute;
  top: 0;
}

我们提前定义了一些 CSS 自定义属性:背景颜色和高亮宽度。我们现在将它们设置为空值。但是,以后我们将使用 JavaScript 为要高亮的代码行设置有意义的值。

然后,我们将行号 <span> 设置为 position: relative,以便我们可以添加具有绝对定位的 ::after 伪元素。我们将使用此伪元素来突出显示我们的代码行。

声明高亮的代码行

现在,让我们手动将 数据属性 添加到生成的 <pre> 标签中,然后在代码中读取该属性,并使用 JavaScript 调整上面的样式以突出显示特定的代码行。我们可以像以前添加行号那样完成此操作

这将导致我们的 <pre> 元素以 data-line="3,8-10" 属性呈现,其中代码片段中第 3 行和第 8-10 行将被高亮显示。我们可以用逗号分隔行号,或者提供范围。

让我们看看如何在 JavaScript 中解析它,并使高亮显示生效。

读取高亮的代码行

转到 components/post-body.tsx。如果此文件是 JavaScript,您可以随意将其转换为 TypeScript (.tsx),或者忽略所有类型。

首先,我们需要一些导入

import { useEffect, useRef } from "react";

我们需要向此组件添加一个 ref

const rootRef = useRef<HTMLDivElement>(null);

然后,将其应用于 root 元素

<div ref={rootRef} className="max-w-2xl mx-auto">

接下来的代码有点长,但它并没有做任何疯狂的事情。我将展示它,然后逐行分析。

useEffect(() => {
  const allPres = rootRef.current.querySelectorAll("pre");
  const cleanup: (() => void)[] = [];

  for (const pre of allPres) {
    const code = pre.firstElementChild;
    if (!code || !/code/i.test(code.tagName)) {
      continue;
    }

    const highlightRanges = pre.dataset.line;
    const lineNumbersContainer = pre.querySelector(".line-numbers-rows");

    if (!highlightRanges || !lineNumbersContainer) {
      continue;
    }

    const runHighlight = () =>
      highlightCode(pre, highlightRanges, lineNumbersContainer);
    runHighlight();

    const ro = new ResizeObserver(runHighlight);
    ro.observe(pre);

    cleanup.push(() => ro.disconnect());
  }

  return () => cleanup.forEach(f => f());
}, []);

我们运行一次效果,在内容完全渲染到屏幕上时。我们使用 querySelectorAll 来获取此 root 元素下的所有 <pre> 元素;换句话说,就是用户正在查看的博客文章。

对于每个元素,我们确保它下面有一个 <code> 元素,并且我们检查行号容器和 data-line 属性。这就是 dataset.line 所检查的。有关更多信息,请参阅 文档

如果我们通过了第二个 continue,那么 highlightRanges 就是我们之前声明的高亮显示集,在本例中是 "3,8-10",其中 lineNumbersContainer 是具有 .line-numbers-rows CSS 类的容器。

最后,我们声明了一个 runHighlight 函数,它调用一个 highlightCode 函数,我将稍后展示。然后,我们设置了一个 ResizeObserver,以便在博客文章大小发生变化时(例如,用户调整浏览器窗口大小)运行相同的函数。

highlightCode 函数

最后,让我们看看 highlightCode 函数。

function highlightCode(pre, highlightRanges, lineNumberRowsContainer) {
  const ranges = highlightRanges.split(",").filter(val => val);
  const preWidth = pre.scrollWidth;

  for (const range of ranges) {
    let [start, end] = range.split("-");
    if (!start || !end) {
      start = range;
      end = range;
    }

    for (let i = +start; i <= +end; i++) {
      const lineNumberSpan: HTMLSpanElement = lineNumberRowsContainer.querySelector(
        `span:nth-child(${i})`
      );
      lineNumberSpan.style.setProperty(
        "--highlight-background",
        "rgb(100 100 100 / 0.5)"
      );
      lineNumberSpan.style.setProperty("--highlight-width", `${preWidth}px`);
    }
  }
}

我们获取每个范围并读取 <pre> 元素的宽度。然后我们遍历每个范围,找到相关的行号 <span>,并为它们设置 CSS 自定义属性值。我们设置我们想要的任何高亮颜色,并将宽度设置为 <pre> 元素的总 scrollWidth 值。我保持简单并使用了 "rgb(100 100 100 / 0.5)",但您可以随意使用您认为最适合您博客的任何颜色。

这就是它的样子。

Syntax highlighting for a block of Markdown code.

没有行号的行高亮

您可能已经注意到,到目前为止,所有这些都依赖于行号的存在。但是,如果我们想要高亮显示行,但不带行号呢?

实现此的一种方法是保持所有内容不变,并添加一个新选项,仅使用 CSS 隐藏这些行号。首先,我们将添加一个新的 CSS 类 .hide-numbers

```js[class="line-numbers"][class="hide-numbers"][data-line="3,8-10"]
class Shape {
  draw() {
    console.log("Uhhh maybe override me");
  }
}

class Circle {
  draw() {
    console.log("I'm a circle! :D");
  }
}
```

现在让我们添加 CSS 规则,以便在应用 .hide-numbers 类时隐藏行号。

.line-numbers.hide-numbers {
  padding: 1em !important;
}
.hide-numbers .line-numbers-rows {
  width: 0;
}
.hide-numbers .line-numbers-rows > span::before {
  content: " ";
}
.hide-numbers .line-numbers-rows > span {
  padding-left: 2.8em;
}

第一个规则撤消了我们基础代码向右的偏移,以便为行号腾出空间。默认情况下,我选择的 Prism.js 主题的填充为 1em。行号插件将其增加到 3.8em,然后使用绝对定位插入行号。我们所做的将填充恢复到 1em 默认值。

第二个规则获取行号的容器,并将其压缩为没有宽度。第三个规则擦除所有行号本身(它们使用 ::before 伪元素生成)。

最后一个规则只是将现在为空的行号 <span> 元素移回它们本应在的位置,以便高亮显示可以按照我们想要的方式定位。同样,对于我的主题,行号通常会添加 3.8em 的左侧填充,我们将其恢复为默认的 1em。这些新样式添加了另外 2.8em,因此事物回到了它们应该在的位置,但行号隐藏了。如果您使用不同的插件,您可能需要略微不同的值。

结果如下所示。

Syntax highlighting for a block of Markdown code.

复制到剪贴板功能

在我们结束之前,让我们添加一个最后的修饰:一个按钮,允许我们亲爱的读者从我们的代码段中复制代码。这是一个不错的增强功能,可以省去人们手动选择和复制代码段的麻烦。

实际上相当简单。为此有一个 navigator.clipboard.writeText API。我们将我们想要复制的文本传递给该方法,就是这样。我们可以在每个 <code> 元素旁边插入一个按钮,将代码的文本发送到该 API 调用以复制它。我们已经在修改这些 <code> 元素以高亮显示行,所以让我们在同一个地方集成我们的复制到剪贴板按钮。

首先,从上面的 useEffect 代码中,让我们添加一行。

useEffect(() => {
  const allPres = rootRef.current.querySelectorAll("pre");
  const cleanup: (() => void)[] = [];

  for (const pre of allPres) {
    const code = pre.firstElementChild;
    if (!code || !/code/i.test(code.tagName)) {
      continue;
    }

    pre.appendChild(createCopyButton(code));

注意最后一行。我们将在 <pre> 元素下方直接将我们的按钮追加到 DOM 中,该元素已经是 position: relative,允许我们更轻松地定位按钮。

让我们看看 createCopyButton 函数的样子。

function createCopyButton(codeEl) {
  const button = document.createElement("button");
  button.classList.add("prism-copy-button");
  button.textContent = "Copy";

  button.addEventListener("click", () => {
    if (button.textContent === "Copied") {
      return;
    }
    navigator.clipboard.writeText(codeEl.textContent || "");
    button.textContent = "Copied";
    button.disabled = true;
    setTimeout(() => {
      button.textContent = "Copy";
      button.disabled = false;
    }, 3000);
  });

  return button;
}

很多代码,但大多数是样板代码。我们创建了按钮,然后为它提供一个 CSS 类和一些文本。当然,我们还创建了一个点击处理程序来执行复制。复制完成后,我们会更改按钮的文本并将其禁用几秒钟,以帮助为用户提供反馈,表明它已成功。

真正的工作在这行代码上。

navigator.clipboard.writeText(codeEl.textContent || "");

我们传递 codeEl.textContent 而不是 innerHTML,因为我们只想要实际呈现的文本,而不是 Prism.js 添加的所有标记,以便以更好的方式格式化我们的代码。

现在让我们看看如何设置这个按钮的样式。我不是设计师,但这是我想到的。

.prism-copy-button {
  position: absolute;
  top: 5px;
  right: 5px;
  width: 10ch;
  background-color: rgb(100 100 100 / 0.5);
  border-width: 0;
  color: rgb(0, 0, 0);
  cursor: pointer;
}

.prism-copy-button[disabled] {
  cursor: default;
}

看起来像这样。

Syntax highlighting for a block of Markdown code.

它可以正常工作!它复制了我们的代码,甚至保留了格式(即换行符和缩进)!

总结

希望这对您有所帮助。Prism.js 是一个很棒的库,但它最初并非为静态网站编写。这篇文章向您介绍了一些技巧和窍门,可以弥合这种差距,并使其与 Next.js 网站良好地协同工作。