跳转到主要内容
在 Next.js 博客中使用 Remark 创建交互式目录
阅读需 17 分钟

Build the internal tools you need(Dashboard, Workflow Management, CRM, Support and Billing management...) in minutes.

在 Next.js 博客中使用 Remark 创建交互式目录

目录具有许多好处,对于网站,特别是博客,是一项有价值的增加。一个组织良好且易于导航的目录显著提升用户体验,为读者简化查找所需信息的过程。通过添加目录,您不仅可以为读者提供简化的导航,还可以提高内容的整体可访问性和可用性。

在这篇文章中,我们将介绍使用Remark(一种强大的Markdown处理器)为Next.js博客创建交互式目录的必要步骤。虽然一些Remark插件(如Remark-toc)提供了这种功能,但生成的目录位于内容本身内部,限制了其潜在用途。例如,在此博客上,目录被呈现在博客内容之外,使其在导航时可见。这是我们将在本教程中构建的目录类型。我们将从简要讨论Remark的基本知识、其插件以及与Next.js的集成开始。然后,我们将深入研究实现自定义目录的实际步骤,最后,我们将使其具有交互性,以便单击目录项将页面滚动到相应的部分。

Remark及其插件

Remark是一款可扩展的Markdown处理器,简化了将Markdown文件转换为HTML或其他格式的过程。Remark的关键方面是其基于插件的架构,使开发人员能够扩展和定制其功能。这些插件可以处理诸如语法高亮显示、添加目录或解析自定义Markdown语法等任务。将Remark与Next.js集成非常简单——通常与getStaticProps函数一起使用,以在构建过程中处理Markdown文件。它还可以处理MDX文件,使其成为使用新的“app”目录的Next.js网站的可行选择。Remark强大的处理能力和与Next.js的无缝集成使其成为增强Next.js博客和网站内容以及用户体验的理想选择。

入门

尽管我们正在构建一个自定义目录,但我们不必从头开始编写所有内容。为了将Markdown/MDX内容与前置内容分离,我们将使用Gray-matter包。如果Markdown文件中没有前置内容,这是可选的。为了处理Markdown本身,我们将使用Remark包。我们还需要unist-util-visit包来遍历节点树,以及mdast-util-to-string包来获取节点的文本内容。

让我们安装所有这些包。

npm i remark mdast-util-to-string gray-matter unist-util-visit

用于从内容中提取标题的自定义Remark插件

在呈现目录之前,我们必须从Markdown文件中提取所有标题,并将它们组织成一个节点数组。这个过程可以分为几个步骤:

  1. 解析文件内容以将前置内容与内容分离。
  2. 为每个标题元素生成ID。这对于后面实现滚动到部分功能是必要的。
  3. 解析内容,提取标题及其属性。

对于第2步,我们可以手动添加IDs作为自定义Markdown属性,例如## Heading 1 {#heading-id},然后使用类似Remark-heading-id的库将它们渲染为HTML。然而,这种方法需要手动添加和维护这些标题,效率较低。更高效的方法是根据标题文本自动生成IDs,例如,当转换为HTML时,标题Heading 1将自动获得ID heading-1。 此外,我们可以通过创建自定义Remark插件将步骤2和3结合起来。 js复制代码

export function headingTree() {
return (node, file) => {
file.data.headings = getHeadings(node);
};
}

function getHeadings(root) {
const nodes = {};
const output = [];
const indexMap = {};
visit(root, "heading", (node) => {
addID(node, nodes);
transformNode(node, output, indexMap);
});

return output;
}

在这里,我们有我们的自定义Remark插件headingTree,它从文档中提取标题并将它们作为headings属性添加到处理后的内容中。

插件的主要组件是getHeadings函数,这是一个访问器函数,遍历节点树并操纵它们。为了提高可读性,该函数分为两个部分。

addID函数遍历文档中的标题节点,替换它们所有的特殊字符,并将它们作为小写字符串输出,其中空格由破折号替换。这些ID将存储在标题的hProperties属性中。

function addID(node, nodes) {
const id = node.children.map((c) => c.value).join("");
nodes[id] = (nodes[id] || 0) + 1;
node.data = node.data || {
hProperties: {
id: `${id}${nodes[id] > 1 ? ` ${nodes[id] - 1}` : ""}`
.replace(/[^a-zA-Z\d\s-]/g, "")
.split(" ")
.join("-")
.toLowerCase(),
},
};
}

注意,我们使用nodes变量来跟踪每个标题出现的次数。这样做是为了在文档中出现多次的标题(例如,某些部分可能具有相同文本的子标题)前缀带有数字。transformNode函数接受从解析后的Markdown抽象语法树(AST)中得到的节点,并将其转换为一个更适用于构建目录的格式。

import { toString } from "mdast-util-to-string";

function transformNode(node, output, indexMap) {
const transformedNode = {
value: toString(node),
depth: node.depth,
data: node.data,
children: [],
};

if (node.depth === 2) {
output.push(transformedNode);
indexMap[node.depth] = transformedNode;
} else {
const parent = indexMap[node.depth - 1];
if (parent) {
parent.children.push(transformedNode);
indexMap[node.depth] = transformedNode;
}
}
}

该函数检查节点是否具有深度为2(Markdown中的##元素)。如果是,转换后的节点将添加到输出数组中,并保存在indexMap中相应深度的位置。这表示转换后的节点位于目录的顶级。在此处,我们将深度2指定为顶级深度,因为这将在HTML输出中产生<h2>标签。我们不使用深度1,因为在页面上有多个<h1>元素对于页面的可访问性和SEO来说并不好。

如果节点的深度大于2(例如,###或####元素),该函数通过查找indexMap中当前节点上一级深度的位置(即node.depth - 1)来识别父节点。如果找到父节点,则将转换后的节点添加到父节点的children数组中,并相应地更新indexMap。这有助于构建目录的嵌套结构,其中深层次的节点成为较高层次节点的子节点。

值得注意的是,为了使该函数正常工作,目录应具有有效的结构,例如,不应该在节点深度2直接跳转到深度4。

现在我们有了实现getHeadings函数所需的一切。

import matter from "gray-matter";
import { remark } from "remark";

import { headingTree } from "./headings";

const postsDirectory = path.join(process.cwd(), "posts");

export async function getHeadings(id) {
const fullPath = path.join(postsDirectory, `${id}.mdx`);
const fileContents = fs.readFileSync(fullPath, "utf8");

// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents);

// Use remark to convert Markdown into HTML string
const processedContent = await remark()
.use(headingTree)
.process(matterResult.content);

return processedContent.data.headings;
}

有了这个,我们就有了来自文档的标题数组,以及它们的数据属性。数组的结构如下。

[
{
value: "Heading 1",
depth: 2,
data: { hProperties: { id: "heading-1" } },
children: [
{
value: "Heading 2",
depth: 3,
data: { hProperties: { id: "heading-2" } },
children: [
{
value: "Heading 3",
depth: 4,
data: { hProperties: { id: "heading-3" } },
children: [],
},
],
},
],
},
{
value: "Heading 4",
depth: 2,
data: { hProperties: { id: "heading-4" } },
children: [],
},
];

渲染目录

现在我们有了标题数据,可以使用它来渲染目录。首先,我们将创建一个TableOfContents组件,它将是目录渲染逻辑的包装器。

"use client";

export const TableOfContents = ({ nodes }) => {
if (!nodes?.length) {
return null;
}

return (
<div className={"toc"}>
<h3 className={"secondary-text"}>Table of contents</h3>
{renderNodes(nodes)}
</div>
);
};

请注意,如果您使用的是Next.js的“app”目录,您需要使用"use client"指令将此组件标记为客户端组件。

目录的实际渲染将由renderNodes函数管理。由于渲染逻辑是递归的,我们使用单独的函数而不是在组件内部定义它。

function renderNodes(nodes) {
return (
<ul>
{nodes.map((node) => (
<li key={node.data.hProperties.id}>
<a href={`#${node.data.hProperties.id}`}>{node.value}</a>
{node.children?.length > 0 && renderNodes(node.children)}
</li>
))}
</ul>
);
}

目录中的每个元素都是一个链接,通过其href属性指向相应标题的ID。

单击目录链接时添加平滑滚动效果

基本的目录已经完成。在我们渲染文章的页面上,我们可以通过调用await getHeadings(postId)或者在使用 "pages" 目录时在getStaticProps中执行此操作)获取标题,并将数据传递给 TableOfContents 组件。在文章页面上,当我们点击目录链接时,应该导航到页面的相应部分。然而,与其突然跳转到标题,我们可以启用平滑滚动。作为附加的增强,我们可以根据其深度逐渐减小子链接的字体大小。

为了实现这一点,我们将引入一个TOCLink组件,负责平滑滚动和个别链接样式,然后我们将在renderNodes中使用它。

function renderNodes(nodes) {
return (
<ul>
{nodes.map((node) => (
<li key={node.data.hProperties.id}>
<TOCLink node={node} />
{node.children?.length > 0 && renderNodes(node.children)}
</li>
))}
</ul>
);
}

const TOCLink = ({ node }) => {
const fontSizes = { 2: "base", 3: "sm", 4: "xs" };
const id = node.data.hProperties.id;
return (
<a
href={`#${id}`}
className={`block text-${fontSizes[node.depth]} hover:accent-color py-1`}
onClick={(e) => {
e.preventDefault();
document
.getElementById(id)
.scrollIntoView({ behavior: "smooth", block: "start" });
}}
>
{node.value}
</a>
);
};

为了平滑滚动到网页上的特定元素,我们首先使用其 ID 定位元素,然后使用带有behavior: "smooth"选项的scrollIntoView方法。有关此方法的更多信息,请参阅MDN网站。该方法在浏览器支持方面具有广泛的支持,但smooth选项可能与一些较旧的浏览器不兼容。通过采用这种方法,现在点击目录链接会产生一个漂亮的滚动动画,而不是之前的突然过渡。

如果您需要在滚动到标题元素时添加偏移量(例如,当页面有一个固定的导航栏时),您可以将scroll-margin-top CSS 属性应用于标题元素。

此外,我们可以使用TailwindCSS和其text实用程序类,根据深度逐渐减小目录链接的字体大小。

强调活动链接

为了增强目录导航,最后的一点是在页面上查看其相应标题时突出显示目录链接。

为了检测页面上元素的可见性,我们将使用Intersection Observer API,该API具有良好的浏览器支持,但有一些小问题。此外,我们将此功能转移到一个自定义的钩子中,该钩子返回一个布尔值,指示链接是否突出显示,并提供手动设置高亮状态的回调。这个钩子将在TOCLink组件中使用。

import { useEffect, useRef, useState } from "react";

function useHighlighted(id) {
const observer = useRef();
const [activeId, setActiveId] = useState("");

useEffect(() => {
const handleObserver = (entries) => {
entries.forEach((entry) => {
if (entry?.isIntersecting) {
setActiveId(entry.target.id);
}
});
};

observer.current = new IntersectionObserver(handleObserver, {
rootMargin: "0% 0% -35% 0px",
});

const elements = document.querySelectorAll("h2, h3, h4");
elements.forEach((elem) => observer.current.observe(elem));
return () => observer.current?.disconnect();
}, []);

return [activeId === id, setActiveId];
}

const TOCLink = ({ node }) => {
const fontSizes = { 2: "base", 3: "sm", 4: "xs" };
const id = node.data.hProperties.id;
const [highlighted, setHighlighted] = useHighlighted(id);
return (
<a
href={`#${id}`}
className={`block text-${fontSizes[node.depth]} hover:accent-color py-1 ${
highlighted && "accent-color"
}`}
onClick={(e) => {
e.preventDefault();
setHighlighted(id);
document
.getElementById(id)
.scrollIntoView({ behavior: "smooth", block: "start" });
}}
>
{node.value}
</a>
);
};

在这个钩子中handleObserver函数作为Intersection Observer的回调函数,处理被观察元素的可见性变化,接受一个条目数组作为其参数。 handleObserver函数遍历条目,其中包括 h2、h3 和 h4 元素,检查isIntersecting属性是否为true — 表示元素在视口中可见 — 如果是,使用setActiveId更新目录中的活动部分。当链接被点击时,我们通过setHighlighted回调将其设置为突出显示。

此外,我们将一个新的Intersection Observer实例存储在一个ref中,以在组件渲染期间保持其身份不变。

通过滚动页面,您可以在此页面上看到这个目录的实际效果,观察随着页面到达相应部分,目录中的活动部分如何更新。

结论

总的来说,使用Remark和自定义插件为 Next.js 博客创建目录可以为您的网站的用户体验和可访问性带来许多好处。通过 Remark,这个强大的 Markdown 处理器,以及它丰富的插件范围,可以轻松从 Markdown 文件中提取标题,并将它们转换为交互式和易于导航的目录。

通过引入目录,您可以增强 Next.js 博客上的用户体验,使读者更容易找到他们需要的信息。此外,使用 Remark 创建自定义目录插件使您能够将目录集成到内容之外,从而提高内容的可用性和可访问性。通过使用诸如mdast-util-to-stringunist-util-visit等插件,可以从内容中提取标题,生成唯一的 ID,并将它们解析为适用于构建目录的格式。

本教程通过创建具有嵌套结构、平滑滚动和活动链接突出显示的自定义目录,引导您完成了这个过程。因此,读者现在可以快速找到并导航到他们感兴趣的内容,增强了博客的整体可用性和价值。

Related Articles

PostgreSQL表格清单获取方法

本文介绍了在PostgreSQL数据库中列出所有表格的两种方法。PostgreSQL提供了两种方法来列出数据库中的所有表格。

PostgreSQL SELECT 语句

本文介绍了 `PostgreSQL` 中的 `SELECT` 语句的基本形式,以及如何使用 `SELECT` 语句从数据表中查询数据。在 `PostgreSQL` 中,`SELECT` 语句用于从一个或多个表中检索数据,它可能是使用最多的语句。

PostgreSQL中ISNULL和ISNOTNULL的最佳实践

本文介绍了在 PostgreSQL 中如何使用 `IS NULL` 运算符检查一个值是不是 `NULL`。PostgreSQL `IS NULL` 是一个布尔运算符,它检查一个值是不是`NULL`。 `NULL`值是一个特殊的值,它表示什么都没有,它即不是空字符串也不是false。