Первоначально опубликовано на https://claritydev.net

Оглавление (TOC) предлагает множество преимуществ и является ценным дополнением к веб-сайтам, особенно к блогам. Хорошо организованное и удобное для навигации оглавление значительно улучшает взаимодействие с пользователем, упрощая для читателей процесс поиска необходимой им информации. Включив оглавление, вы не только упростите навигацию для своих читателей, но и повысите общую доступность и удобство использования вашего контента.

В этом посте мы рассмотрим необходимые шаги для создания интерактивного оглавления для блога Next.js с помощью Remark, мощного процессора Markdown. Хотя некоторые плагины Remark, такие как Remark-toc, предоставляют эту функциональность, сгенерированное оглавление помещается в сам контент, что ограничивает его потенциальное использование. Например, в моем блоге оглавление отображается вне содержимого блога, что делает его видимым во время навигации по записям. Это тип оглавления, который мы создадим в этом уроке. Мы начнем с краткого обсуждения основ Remark, его плагинов и того, как он интегрируется с Next.js. Затем мы углубимся в практические шаги реализации пользовательского оглавления и, наконец, сделаем его интерактивным, чтобы щелчок по элементу оглавления прокручивал страницу до соответствующего раздела.

Remark и его плагины

Remark — это расширяемый процессор Markdown, который упрощает процесс преобразования файлов Markdown в HTML или другие форматы. Ключевым аспектом Remark является его архитектура на основе подключаемых модулей, которая позволяет разработчикам расширять и настраивать его функциональность. Эти плагины могут выполнять такие задачи, как подсветка синтаксиса, добавление оглавления или анализ пользовательского синтаксиса Markdown. Интеграция Remark с Next.js проста — обычно она используется в сочетании с функцией getStaticProps для обработки файлов Markdown в процессе сборки. Он также может обрабатывать файлы MDX, что делает его жизнеспособным вариантом для веб-сайтов 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

Если вам интересно настроить непрерывное развертывание для вашего веб-сайта с помощью действий GitHub, вам может быть интересна эта статья: Автоматизация рабочего процесса развертывания: непрерывное развертывание веб-сайта Next.js в DigitalOcean с помощью действий GitHub.

Плагин Custom Remark для извлечения заголовков из контента

Перед рендерингом оглавления мы должны извлечь все заголовки из файла Markdown и организовать их в массив узлов. Этот процесс можно разделить на несколько этапов:

  1. Проанализируйте содержимое файла, чтобы отделить вступительную часть от содержимого.
  2. Создайте идентификаторы для каждого из элементов заголовка. Это будет необходимо для реализации функции прокрутки к разделу позже.
  3. Проанализируйте содержимое, извлекая заголовки и их атрибуты.

На шаге 2 мы могли бы вручную добавить идентификаторы в качестве настраиваемых атрибутов Markdown, например, ## Heading 1 {#heading-id}, а затем использовать библиотеку, например Remark-heading-id, для их преобразования в HTML. Однако этот подход требует ручной работы для добавления и сохранения этих заголовков. Более эффективный метод — автоматически генерировать идентификаторы из самого текста заголовка, чтобы заголовок Heading 1 автоматически получал идентификатор heading-1 при преобразовании в HTML.

Кроме того, мы можем объединить шаги 2 и 3, создав собственный подключаемый модуль Remark, который извлекает заголовки и возвращает их в виде дерева узлов.

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 перебирает узлы заголовков в документе, заменяет все их специальные символы и выводит их в виде строк нижнего регистра с пробелами, замененными тире. Идентификаторы будут храниться в атрибуте hProperties заголовка.

/*
 * Add an "id" attribute to the heading elements based on their content
 */
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 как верхнюю глубину здесь, потому что она создаст теги <h2> в выводе HTML. Мы не используем глубину 1, поскольку наличие нескольких элементов <h1> на странице плохо влияет на доступность страницы и SEO.

Если глубина узла больше 2 (например, ### или #### элементов), функция идентифицирует родительский узел, ища indexMap на уровне глубины непосредственно над текущим узлом (то есть node.depth - 1). Если родительский узел найден, преобразованный узел добавляется в дочерний массив родительского узла, и 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, который будет оболочкой для логики рендеринга TOC.

"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>
  );
};

Обратите внимание: если вы используете каталог «app» Next.js, вам нужно использовать директиву "use client", чтобы пометить этот компонент как клиентский.

Фактическим рендерингом TOC будет управлять функция 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>
  );
}

Каждый из элементов TOC представляет собой ссылку, указывающую на идентификатор соответствующего заголовка через его атрибут href.

Основное оглавление завершено. На странице, на которой мы отображаем нашу запись, мы можем получить заголовки, вызвав await getHeadings(postId) (или сделав это внутри getStaticProps, если вы используете каталог «pages»), и передать данные компоненту TableOfContents. На странице сообщения, когда мы нажимаем на ссылку TOC, мы должны перейти в соответствующий раздел страницы. Однако вместо резкого перехода к заголовку мы можем включить плавную прокрутку. В качестве дополнительного улучшения мы можем постепенно уменьшать размер шрифта подссылки в зависимости от его глубины.

Для этого мы введем компонент 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>
  );
};

Чтобы плавно перейти к определенному элементу на веб-странице, мы сначала находим элемент, используя его идентификатор, а затем применяем метод scrollIntoView с параметром behavior: "smooth". Для получения дополнительной информации об этом методе обратитесь к веб-сайту MDN. Метод может похвастаться обширной поддержкой браузера; однако опция smooth может быть несовместима с некоторыми старыми браузерами. Используя этот подход, нажатие на ссылку TOC теперь приводит к красивой анимации прокрутки, в отличие от ранее резкого перехода.

Если вам нужно добавить смещение к элементам заголовка при их прокрутке (например, когда на странице есть фиксированная панель навигации), вы можете применить свойство CSS scroll-margin-top к элементам заголовка.

Кроме того, мы можем постепенно уменьшать размер шрифта ссылок TOC по отношению к их глубине, используя TailwindCSS и его вспомогательные классы text.

Подсветка активных ссылок

Последним штрихом для улучшения навигации по оглавлению является выделение ссылок оглавления, когда соответствующие заголовки видны на странице.

Чтобы определить видимость элемента на странице, мы будем использовать Intersection Observer 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 внутри ссылки, чтобы сохранить его идентичность во время рендеринга компонента.

Вы можете увидеть это оглавление в действии на этой странице, прокручивая страницу и наблюдая, как активный раздел оглавления обновляется по мере достижения соответствующего раздела на странице.

Если вам интересно добавить кнопку копировать в буфер обмена на сайт Next.js, вам может быть интересна эта статья: Кнопка копирования в буфер обмена в MDX с помощью Next.js и Rehype Pretty Code.

Заключение

В заключение, создание оглавления (TOC) для вашего блога Next.js с помощью Remark и настраиваемого плагина может обеспечить многочисленные преимущества для удобства пользователей и доступности вашего веб-сайта. С помощью Remark, мощного процессора Markdown, и его обширного набора подключаемых модулей легко извлекать заголовки из файлов Markdown и преобразовывать их в интерактивное и удобное для навигации оглавление.

Включив оглавление, вы можете улучшить взаимодействие с пользователем в своем блоге Next.js, упростив читателям поиск необходимой информации. Кроме того, создание пользовательского плагина оглавления с помощью Remark позволяет интегрировать оглавление вне самого контента, что повышает удобство использования и доступность вашего контента. С помощью таких плагинов, как mdast-util-to-string и unist-util-visit, вы можете извлекать заголовки из своего контента, генерировать уникальные идентификаторы и преобразовывать их в формат, подходящий для создания оглавления.

Это руководство провело вас через процесс создания пользовательского оглавления с вложенной структурой, плавной прокруткой и подсветкой активных ссылок. В результате читатели теперь могут быстро находить интересующий их контент и переходить к нему, повышая общее удобство использования и ценность вашего блога.

Ссылки и ресурсы

Первоначально опубликовано на https://claritydev.net

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Хлопайте за историю и подписывайтесь на автора 👉
  • 📰 Смотрите больше контента в публикации Level Up Coding
  • 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
  • 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу