Фильтрация заголовков

В работе с Markdown и HTML через библиотеки Remark и Rehype часто возникает задача избирательно обрабатывать заголовки определённых уровней или с конкретными текстовыми шаблонами. Оба инструмента используют структуру AST (Abstract Syntax Tree), что позволяет эффективно фильтровать, трансформировать и извлекать данные из документа.


Работа с AST

В Remark Markdown преобразуется в дерево узлов MDAST. Узлы заголовков имеют тип heading и содержат следующие ключевые свойства:

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

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

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

Для Rehype HTML-документ конвертируется в дерево HAST, где заголовки представлены узлами element с тегами h1h6. Пример узла заголовка:

{
  "type": "element",
  "tagName": "h2",
  "properties": {},
  "children": [
    { "type": "text", "value": "Пример заголовка" }
  ]
}

Фильтрация по уровню заголовка

Для извлечения заголовков определённого уровня в Remark используется пакет unist-util-visit:

import { visit } from 'unist-util-visit';

const headings = [];
visit(tree, 'heading', (node) => {
  if (node.depth === 2) { // выбираем только h2
    headings.push(node);
  }
});

Для Rehype проверка уровня заголовка осуществляется через tagName:

import { visit } from 'unist-util-visit';

const headings = [];
visit(tree, 'element', (node) => {
  if (node.tagName === 'h2') {
    headings.push(node);
  }
});

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


Фильтрация по тексту заголовка

Для более точной выборки используется текстовое содержимое заголовка. В MDAST текст находится в дочерних узлах типа text. Пример извлечения текста заголовка в Remark:

function getText(node) {
  return node.children
    .filter(child => child.type === 'text')
    .map(child => child.value)
    .join('');
}

const filteredHeadings = [];
visit(tree, 'heading', (node) => {
  const text = getText(node);
  if (text.includes('Важная тема')) {
    filteredHeadings.push(node);
  }
});

В Rehype для извлечения текста необходимо рекурсивно обходить все дочерние узлы element и text:

function getTextFromHast(node) {
  if (node.type === 'text') return node.value;
  if (!node.children) return '';
  return node.children.map(getTextFromHast).join('');
}

const filteredHeadings = [];
visit(tree, 'element', (node) => {
  if (/h[1-6]/.test(node.tagName)) {
    const text = getTextFromHast(node);
    if (text.includes('Важная тема')) {
      filteredHeadings.push(node);
    }
  }
});

Комбинированная фильтрация

Часто требуется одновременно фильтровать по уровню и по содержимому:

visit(tree, 'heading', (node) => {
  if (node.depth >= 2 && node.depth <= 3) {
    const text = getText(node);
    if (/важно/i.test(text)) {
      console.log(node);
    }
  }
});

Для Rehype подход аналогичен, но вместо depth проверяется соответствие tagName регулярному выражению /h[2-3]/.


Создание плагина для автоматической фильтрации

Remark и Rehype поддерживают написание плагинов, которые могут автоматически собирать заголовки или модифицировать дерево:

function remarkFilterHeadings(options) {
  return (tree) => {
    const filtered = [];
    visit(tree, 'heading', (node) => {
      const text = getText(node);
      if (options.levels.includes(node.depth) && text.includes(options.keyword)) {
        filtered.push(node);
      }
    });
    return filtered;
  };
}

// Использование
const filteredHeadings = remark().use(remarkFilterHeadings, { levels: [2,3], keyword: 'Важное' }).processSync(markdown).result;

Подобная структура позволяет переиспользовать логику фильтрации и интегрировать её в цепочку обработки Markdown или HTML.


Оптимизация обхода дерева

  • Для больших документов предпочтительно использовать unist-util-visit вместо рекурсивного обхода вручную, так как это снижает вероятность ошибок и обеспечивает стабильную работу с деревом AST.
  • Фильтрация по регулярным выражениям на уровне текста позволяет гибко выбирать заголовки без строгой зависимости от регистра или пробелов.
  • Для Rehype рекомендуется использовать hast-util-to-string для безопасного извлечения текста из элементов, особенно если структура сложная.

Применение фильтрации

  • Автоматическое создание оглавления: собираются все заголовки определённых уровней.
  • Сбор тематических разделов: извлечение заголовков с ключевыми словами для дальнейшей обработки.
  • SEO-анализ и генерация метаданных: фильтрация заголовков помогает оценивать структуру документа и выявлять важные темы.

Фильтрация заголовков с использованием Remark и Rehype позволяет точечно контролировать структуру документа, облегчает трансформацию Markdown и HTML, а также служит основой для создания динамических инструментов обработки контента.