Извлечение заголовков

В экосистеме JavaScript для работы с Markdown и HTML широко применяются библиотеки Remark и Rehype, которые предоставляют мощные средства для анализа, трансформации и генерации документов. Одной из частых задач является извлечение заголовков из Markdown-файлов для построения структуры документа, генерации оглавлений или индексирования.


Работа с AST в Remark

Remark преобразует Markdown в абстрактное синтаксическое дерево (AST), представленное в формате MDAST (Markdown AST). Для извлечения заголовков необходимо понимать структуру узлов:

  • type: 'heading' — узел, представляющий заголовок.
  • depth — уровень заголовка (от 1 до 6).
  • children — массив узлов, содержащих текст и возможные форматирования (emphasis, strong, inlineCode).

Пример структуры заголовка в AST:

{
  "type": "heading",
  "depth": 2,
  "children": [
    {
      "type": "text",
      "value": "Пример заголовка"
    }
  ]
}

Для извлечения заголовков удобно использовать рекурсивный обход дерева.


Рекурсивный обход AST

Для поиска всех заголовков можно использовать функцию обхода дерева:

import { unified } from 'unified';
import remarkParse from 'remark-parse';

const extractHeadings = (node, headings = []) => {
  if (node.type === 'heading') {
    const text = node.children
      .filter(child => child.type === 'text')
      .map(child => child.value)
      .join('');
    headings.push({ depth: node.depth, text });
  }

  if (node.children) {
    node.children.forEach(child => extractHeadings(child, headings));
  }

  return headings;
};

const markdown = `
# Заголовок 1
## Заголовок 2
`;

const tree = unified().use(remarkParse).parse(markdown);
const headings = extractHeadings(tree);
console.log(headings);

Результат:

[
  { "depth": 1, "text": "Заголовок 1" },
  { "depth": 2, "text": "Заголовок 2" }
]

Интеграция с Rehype

Rehype применяется для работы с HTML и использует HAST (HTML AST). После конвертации Markdown в HTML с помощью Remark, можно подключить Rehype для дальнейшего анализа заголовков HTML-документа.

Пример пайплайна:

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

const markdown = `
# Заголовок 1
## Заголовок 2
`;

const processor = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeStringify);

const html = processor.processSync(markdown).toString();
console.log(html);

HTML-результат:

<h1>Заголовок 1</h1>
<h2>Заголовок 2</h2>

Для извлечения заголовков из HAST можно использовать unist-util-visit, который позволяет обходить дерево:

import { visit } from 'unist-util-visit';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';

const headings = [];
const tree = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .parse(markdown);

visit(tree, 'element', node => {
  if (node.tagName && node.tagName.match(/^h[1-6]$/)) {
    const text = node.children
      .filter(child => child.type === 'text')
      .map(child => child.value)
      .join('');
    headings.push({ level: parseInt(node.tagName[1]), text });
  }
});

console.log(headings);

Обработка вложенных элементов заголовка

Заголовки могут содержать форматирование: *курсив*, **жирный**, `код`. В MDAST и HAST это представлено отдельными узлами emphasis, strong, inlineCode. Для получения чистого текста заголовка необходимо рекурсивно обходить детей узла и конкатенировать текстовые значения.

Пример рекурсивного извлечения текста:

const getText = node => {
  if (node.type === 'text' || node.type === 'inlineCode') return node.value;
  if (!node.children) return '';
  return node.children.map(getText).join('');
};

Использование вместе с обходом заголовков:

visit(tree, 'element', node => {
  if (node.tagName && node.tagName.match(/^h[1-6]$/)) {
    headings.push({ level: parseInt(node.tagName[1]), text: getText(node) });
  }
});

Генерация оглавления (TOC)

После извлечения заголовков можно построить иерархическое оглавление. Основная идея — группировать заголовки по уровням:

const buildTOC = headings => {
  const toc = [];
  const stack = [{ children: toc, level: 0 }];

  headings.forEach(heading => {
    while (heading.depth <= stack[stack.length - 1].level) {
      stack.pop();
    }
    const entry = { text: heading.text, children: [] };
    stack[stack.length - 1].children.push(entry);
    stack.push({ ...entry, level: heading.depth });
  });

  return toc;
};

const toc = buildTOC(headings);
console.log(JSON.stringify(toc, null, 2));

Пример структуры TOC:

[
  {
    "text": "Заголовок 1",
    "children": [
      {
        "text": "Заголовок 2",
        "children": []
      }
    ]
  }
]

Настройка Remark через плагины

Remark поддерживает плагины для упрощения работы с заголовками:

  • remark-toc — автоматическое создание оглавления.
  • remark-slug — добавление уникальных ID к заголовкам.
  • unist-util-visit — универсальный обход AST для любых узлов.

Пример использования remark-slug для генерации ID:

import remarkSlug from 'remark-slug';

const treeWithSlugs = unified()
  .use(remarkParse)
  .use(remarkSlug)
  .parse(markdown);

Теперь каждый заголовок будет иметь атрибут id, что облегчает ссылку на конкретные секции при создании TOC.


Практические советы

  • Всегда использовать unist-util-visit для обхода деревьев AST, чтобы избежать ручной рекурсии.
  • Для HTML лучше применять HAST через Rehype, если нужен доступ к тегам, атрибутам и содержимому.
  • Использовать плагины Remark для автоматической генерации ID заголовков и корректного формирования оглавления.
  • Рекурсивная функция для извлечения текста заголовка позволяет корректно обрабатывать форматирование и встроенный код.

Этот подход обеспечивает универсальный, гибкий и расширяемый механизм извлечения заголовков из Markdown и HTML документов в JavaScript, позволяя создавать динамические оглавления, индексировать контент и интегрировать структурированные данные в веб-приложения.