📚ByteGuide
← Все статьи🇷🇺 Русский

Как извлечь заголовки из Markdown: jsdom vs rehype vs regex

Разбираемся в способах парсинга Markdown для создания Table of Contents. Когда нужен jsdom, а когда достаточно простого regex? Реальный опыт и сравнение подходов.

markdownparsingnodejsrehypeast
⏱️ 12 мин. чтения

Проблема

Представь: ты создаёшь блог на Next.js. Статьи пишешь в Markdown. Хочешь добавить Table of Contents (оглавление) — список всех заголовков статьи с ссылками на них.

Вот твой Markdown:

## Введение

Какой-то текст...

### Подзаголовок 1

Ещё текст...

## Основная часть

И так далее...

Нужно получить массив:

[
  { id: 'введение', text: 'Введение', level: 2 },
  { id: 'подзаголовок-1', text: 'Подзаголовок 1', level: 3 },
  { id: 'основная-часть', text: 'Основная часть', level: 2 }
]

Вопрос: Как извлечь эти заголовки?

Кажется простым, но есть 4 разных подхода, каждый со своими плюсами и минусами. Давай разберёмся какой выбрать.

Контекст: как работает Markdown → HTML

Сначала нужно понять pipeline обработки Markdown:

1. Markdown file (строка)
   ↓
2. Parser → AST (Abstract Syntax Tree)
   ↓
3. Transformers (plugins)
   ↓
4. HTML Generator
   ↓
5. HTML string

Ключевой момент: Markdown сначала превращается в дерево (AST), потом в HTML.

Пример AST для ## Заголовок:

{
  "type": "heading",
  "depth": 2,
  "children": [
    {
      "type": "text",
      "value": "Заголовок"
    }
  ]
}

А в HTML это станет:

<h2 id="заголовок">Заголовок</h2>

Можно извлекать заголовки на разных этапах этого pipeline. Отсюда и разные подходы.

Вариант 1: Regex (парсинг строки)

Суть

Берём уже готовый HTML и ищем <h2>, <h3> регулярными выражениями.

Код

function extractHeadings(html: string) {
  const headingRegex = /<h([23])>(.*?)<\/h[23]>/g
  const headings = []
  let match
  
  while ((match = headingRegex.exec(html)) !== null) {
    const level = parseInt(match[1]) // 2 или 3
    const text = match[2].replace(/<[^>]*>/g, '') // убрать вложенные теги
    const id = text.toLowerCase().replace(/\s+/g, '-')
    
    headings.push({ level, text, id })
  }
  
  return headings
}

// Использование
const html = await markdownToHtml(markdown)
const headings = extractHeadings(html)

Плюсы

Простота — 10 строк кода, понятно любому
Нулевые зависимости — не нужно ставить библиотеки
Быстро работает — regex очень быстрый
Работает везде — клиент, сервер, даже в браузере

Минусы

"Never parse HTML with regex" — классическая ошибка
Хрупкость — сломается если HTML изменится:

<h2 class="title">Заголовок</h2> <!-- regex не найдёт -->
<h2>
  <a href="#link">Заголовок</a> <!-- вложенные теги сломают парсинг -->
</h2>

Парсим дважды — сначала MD→HTML, потом HTML→массив (неэффективно)
Нет контекста — не знаем откуда взялся заголовок, не можем его изменить

Когда использовать

Prototype/MVP — нужно быстро проверить идею
Контролируемый HTML — ты точно знаешь формат (например, свой генератор)
Клиентская сторона — парсишь HTML в браузере без Node.js

Реальный кейс: У тебя простой блог, 20 статей, все заголовки в одном формате. Regex справится.

Вариант 2: jsdom (виртуальный браузер)

Суть

Берём HTML, создаём виртуальный DOM в Node.js и используем знакомые querySelector, textContent как в браузере.

Что такое jsdom?

jsdom — это полноценная реализация веб-стандартов (DOM, HTML, CSS) для Node.js. По сути, браузер без UI.

Если в браузере ты пишешь:

document.querySelector('h2').textContent

То в Node.js с jsdom:

const { JSDOM } = require('jsdom')
const dom = new JSDOM(html)
dom.window.document.querySelector('h2').textContent

Это тот же API, но на сервере.

Код

import { JSDOM } from 'jsdom'

function extractHeadings(html: string) {
  const dom = new JSDOM(html)
  const document = dom.window.document
  
  const headingElements = document.querySelectorAll('h2, h3')
  const headings = []
  
  headingElements.forEach((element) => {
    const level = element.tagName === 'H2' ? 2 : 3
    const text = element.textContent || ''
    const id = element.id || text.toLowerCase().replace(/\s+/g, '-')
    
    headings.push({ level, text, id })
  })
  
  return headings
}

// Использование
const html = await markdownToHtml(markdown)
const headings = extractHeadings(html)

Установка

npm install jsdom
npm install --save-dev @types/jsdom

Плюсы

Знакомый API — как в браузере, легко писать
Надёжность — правильно парсит любой HTML
Мощность — можешь делать всё что угодно с DOM (менять, добавлять, искать)
Стандарты — следует веб-спецификациям

Минусы

Тяжёлая библиотека — ~1.5 MB, долгая установка
Overkill — зачем виртуальный браузер для простого парсинга?
Только Node.js — не работает в браузере (там уже есть настоящий DOM)
Медленнее — инициализация JSDOM занимает время
Парсим дважды — как и с regex

Когда использовать

Сложный HTML — вложенная структура, атрибуты, классы
Web scraping — парсишь чужой HTML с неизвестной структурой
Тестирование — эмулируешь браузер в тестах (Jest + jsdom)
DOM манипуляции — нужно не только читать, но и изменять HTML

Реальный кейс: Парсишь статьи с чужого сайта (scraping), структура меняется, нужна надёжность.

Вариант 3: cheerio (jQuery для Node.js)

Суть

Золотая середина между regex и jsdom. Легковесный HTML парсер с jQuery-подобным API.

Код

import * as cheerio from 'cheerio'

function extractHeadings(html: string) {
  const $ = cheerio.load(html)
  const headings: any[] = []
  
  $('h2, h3').each((_, element) => {
    const $el = $(element)
    const level = element.tagName === 'h2' ? 2 : 3
    const text = $el.text()
    const id = $el.attr('id') || text.toLowerCase().replace(/\s+/g, '-')
    
    headings.push({ level, text, id })
  })
  
  return headings
}

Плюсы

Легче jsdom — быстрая установка, меньше весит
Удобный API — jQuery знаком многим
Быстрее jsdom — не создаёт полноценный DOM
Популярный — используется везде (15M downloads/week)

Минусы

Ещё одна зависимость
Парсим дважды — как и предыдущие варианты

Когда использовать

Нужна надёжность — но jsdom слишком тяжёлый
Знаком с jQuery — синтаксис похож
Средняя сложность HTML

Реальный кейс: RSS парсер, email newsletter генератор.

Вариант 4: rehype (парсинг AST) ⭐ РЕКОМЕНДУЮ

Суть

НЕ парсим HTML вообще! Извлекаем заголовки ДО конвертации в HTML, прямо из Markdown AST.

Как это работает?

Markdown → HTML проходит через unified/remark/rehype pipeline:

Markdown string
    ↓ remark-parse
Markdown AST (дерево)
    ↓ remark-rehype
HTML AST (дерево)
    ↓ rehype-stringify
HTML string

Мы добавляем свой plugin который обходит AST и собирает заголовки:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import { visit } from 'unist-util-visit'
import type { Root, Element } from 'hast'

interface Heading {
  id: string
  text: string
  level: 2 | 3
}

async function markdownToHtmlWithHeadings(markdown: string) {
  const headings: Heading[] = []

  // Наш custom plugin
  function extractHeadings() {
    return (tree: Root) => {
      visit(tree, 'element', (node: Element) => {
        // Ищем h2 и h3 в HTML AST
        if (node.tagName === 'h2' || node.tagName === 'h3') {
          const level = node.tagName === 'h2' ? 2 : 3
          
          // Извлекаем текст из children
          const text = extractText(node)
          
          // ID уже добавлен rehype-slug
          const id = node.properties?.id as string
          
          headings.push({ id, text, level: level as 2 | 3 })
        }
      })
    }
  }

  // Helper для извлечения текста
  function extractText(node: any): string {
    if (node.type === 'text') return node.value
    if (node.children) {
      return node.children.map(extractText).join('')
    }
    return ''
  }

  // Pipeline обработки
  const result = await unified()
    .use(remarkParse)           // Markdown → Markdown AST
    .use(remarkRehype)          // Markdown AST → HTML AST
    .use(rehypeSlug)            // Добавляем id к заголовкам
    .use(extractHeadings)       // ⭐ НАШ PLUGIN
    .use(rehypeStringify)       // HTML AST → HTML string
    .process(markdown)

  return {
    html: result.toString(),
    headings
  }
}

Установка

npm install unified remark-parse remark-rehype rehype-stringify rehype-slug unist-util-visit

Звучит много, но это стандартный стек для работы с Markdown. Скорее всего он у тебя уже есть.

Плюсы

Правильный подход — работаем со структурой, не со строками
Один проход — извлекаем заголовки по ходу парсинга
Гибкость — можем менять AST, добавлять атрибуты, преобразовывать
Надёжность — не зависим от HTML форматирования
Производительность — не парсим HTML дважды
Расширяемость — легко добавить другие фичи (anchor links, иконки, etc.)

Минусы

Сложнее для новичка — нужно понимать AST и unified ecosystem
Больше кода — хотя переиспользуемый
Зависимости — но они уже нужны для Markdown

Когда использовать

Production Markdown processing — серьёзный проект
Нужна гибкость — планируешь расширять функционал
Уже используешь unified/remark — логичное продолжение
Документация, блог, CMS — где контент важен

Реальный кейс: Документация проекта, tech блог, Markdown CMS (как Contentful, Strapi).

Сравнительная таблица

Критерий Regex jsdom cheerio rehype
Простота ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
Надёжность ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Производительность ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Размер библиотеки 0 KB ~1500 KB ~200 KB ~50 KB
Гибкость ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
Подходит для production

Что выбрал я для ByteGuide?

rehype (Вариант 4).

Почему?

  1. У меня уже есть unified/remark/rehype — для Markdown обработки
  2. Нужна надёжность — блог будет расти, заголовки могут усложняться
  3. Планирую расширять — хочу добавить anchor links, копирование ссылок на секции
  4. Производительность — один проход вместо двух
  5. Правильная архитектура — работаю с AST, а не костыляю regex

Почему НЕ другие?

  • Regex: Слишком хрупко для production
  • jsdom: Overkill, слишком тяжёлый для простой задачи
  • cheerio: Хороший вариант, но зачем парсить HTML если можно AST?

Реальный код из проекта

Вот как это работает в моём блоге:

// lib/content.ts
export async function markdownToHtmlWithHeadings(markdown: string) {
  const headings: Heading[] = []

  function extractHeadings() {
    return (tree: Root) => {
      visit(tree, 'element', (node: Element) => {
        if (node.tagName === 'h2' || node.tagName === 'h3') {
          const level = node.tagName === 'h2' ? 2 : 3
          const text = extractTextFromNode(node)
          const id = node.properties?.id as string
          
          headings.push({ id, text, level: level as 2 | 3 })
        }
      })
    }
  }

  const result = await unified()
    .use(remarkParse)
    .use(remarkGfm)           // GitHub Flavored Markdown
    .use(remarkRehype)
    .use(rehypeSlug)          // Добавляем ID к заголовкам
    .use(extractHeadings)     // Извлекаем заголовки
    .use(rehypeHighlight)     // Syntax highlighting для кода
    .use(rehypeStringify)
    .process(markdown)

  return { html: result.toString(), headings }
}

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  const post = await getPostBySlug(params.slug)
  const { html, headings } = await markdownToHtmlWithHeadings(post.content)

  return (
    <div>
      <article dangerouslySetInnerHTML={{ __html: html }} />
      <TableOfContents headings={headings} />
    </div>
  )
}

Результат: Table of Contents генерируется автоматически, работает надёжно, легко расширяется.

Выводы

Используй regex если:

  • 🏃 Быстрый прототип, MVP
  • 📊 Контролируемый простой HTML
  • 🌐 Нужно работать в браузере

Используй jsdom если:

  • 🔍 Web scraping (парсишь чужой HTML)
  • 🧪 Тестирование (эмуляция браузера)
  • 🎛️ Сложные DOM манипуляции

Используй cheerio если:

  • ⚖️ Нужна золотая середина
  • 💡 Знаешь jQuery
  • 📧 RSS, newsletters, средняя сложность

Используй rehype если:

  • 🚀 Production проект
  • 📝 Работаешь с Markdown
  • 🎯 Нужна надёжность и гибкость
  • 💪 Планируешь расширять функционал

Дополнительные ресурсы


P.S. Это реальный опыт создания ByteGuide. Пробовал разные варианты, остановился на rehype. Надеюсь моя боль сэкономит твоё время 😄