В работе с Markdown и HTML через библиотеки Remark и Rehype часто возникает задача избирательно обрабатывать заголовки определённых уровней или с конкретными текстовыми шаблонами. Оба инструмента используют структуру AST (Abstract Syntax Tree), что позволяет эффективно фильтровать, трансформировать и извлекать данные из документа.
В Remark Markdown преобразуется в дерево узлов
MDAST. Узлы заголовков имеют тип heading и
содержат следующие ключевые свойства:
type: 'heading' — идентификатор узла заголовка.depth — уровень заголовка (от 1 до 6, соответствующий
h1–h6).children — массив дочерних узлов, обычно содержащих
текстовые узлы (type: 'text').Пример структуры узла заголовка:
{
"type": "heading",
"depth": 2,
"children": [
{ "type": "text", "value": "Пример заголовка" }
]
}
Для Rehype HTML-документ конвертируется в дерево
HAST, где заголовки представлены узлами
element с тегами h1–h6. Пример
узла заголовка:
{
"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.hast-util-to-string для безопасного извлечения текста из
элементов, особенно если структура сложная.Фильтрация заголовков с использованием Remark и Rehype позволяет точечно контролировать структуру документа, облегчает трансформацию Markdown и HTML, а также служит основой для создания динамических инструментов обработки контента.