Как извлечь заголовки из Markdown: jsdom vs rehype vs regex
Разбираемся в способах парсинга Markdown для создания Table of Contents. Когда нужен jsdom, а когда достаточно простого regex? Реальный опыт и сравнение подходов.
Проблема
Представь: ты создаёшь блог на 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).
Почему?
- У меня уже есть unified/remark/rehype — для Markdown обработки
- Нужна надёжность — блог будет расти, заголовки могут усложняться
- Планирую расширять — хочу добавить anchor links, копирование ссылок на секции
- Производительность — один проход вместо двух
- Правильная архитектура — работаю с 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
- 🎯 Нужна надёжность и гибкость
- 💪 Планируешь расширять функционал
Дополнительные ресурсы
- unified — экосистема для работы с контентом
- rehype plugins — готовые плагины
- AST Explorer — визуализация AST в браузере
- unist-util-visit — обход дерева
P.S. Это реальный опыт создания ByteGuide. Пробовал разные варианты, остановился на rehype. Надеюсь моя боль сэкономит твоё время 😄