在静态网站上用Prism进行语法高亮教程

944 阅读10分钟

所以,你已经决定用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

现在进入markdownToHtml 文件,在/lib 文件夹中,打开 remark-prism。

import remarkPrism from "remark-prism";

// later ...

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

根据你所使用的review-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的 "明晚 "主题,所以我的导入看起来像这样:

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 时导入了支持行号的插件。解决方案就藏在review-prism的README中。

不要忘记在你的样式表中包含适当的css。

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

这样一来,我们现在就有了行号了!

注意,根据我所拥有的Prism.js版本和我选择的 "明晚 "主题,我需要将其添加到我们上面开始的prism-overrides.css 文件中:

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

你可能不需要这个,但你有了它。我们有了行号!

突出显示行

我们的下一个功能将是更多的工作。这就是我们希望能够突出显示,或者叫出代码片段中的某些行。

有一个Prism.js行高亮插件;不幸的是,它没有与review-prism集成。该插件通过分析格式化的代码在DOM中的位置来工作,并根据该信息手动高亮行。这在 remark-prism 插件中是不可能的,因为在插件运行时并没有 DOM。这毕竟是静态网站的生成。Next.js正在通过构建步骤运行我们的Markdown,并生成HTML来渲染博客。所有这些Prism.js代码都是在这个静态网站生成过程中运行的,当时没有DOM。

但是不要害怕!有一个有趣的解决办法。我们可以使用普通的CSS(和少量的JavaScript)来突出显示代码行,这也是一种适合CSS-Tricks的有趣的解决方法。

让我说清楚,这是一项非同小可的工作。如果你不需要突出显示行,那么请跳到下一节。但是,如果没有其他原因,它可以成为一个有趣的示范,说明什么是可能的。

我们的基础CSS

让我们先在我们的prism-overrides.css 样式表中添加以下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主题的padding是1em 。行号插件将其增加到3.8em ,然后用绝对定位插入行号。我们所做的是将padding恢复到默认的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网站很好地配合。