В экосистеме JavaScript для работы с Markdown и HTML широко применяются библиотеки Remark и Rehype, которые предоставляют мощные средства для анализа, трансформации и генерации документов. Одной из частых задач является извлечение заголовков из Markdown-файлов для построения структуры документа, генерации оглавлений или индексирования.
Remark преобразует Markdown в абстрактное синтаксическое дерево (AST), представленное в формате MDAST (Markdown AST). Для извлечения заголовков необходимо понимать структуру узлов:
type: 'heading' — узел, представляющий заголовок.depth — уровень заголовка (от 1 до 6).children — массив узлов, содержащих текст и возможные
форматирования (emphasis, strong, inlineCode).Пример структуры заголовка в AST:
{
"type": "heading",
"depth": 2,
"children": [
{
"type": "text",
"value": "Пример заголовка"
}
]
}
Для извлечения заголовков удобно использовать рекурсивный обход дерева.
Для поиска всех заголовков можно использовать функцию обхода дерева:
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 применяется для работы с 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) });
}
});
После извлечения заголовков можно построить иерархическое оглавление. Основная идея — группировать заголовки по уровням:
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-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, чтобы избежать ручной рекурсии.Этот подход обеспечивает универсальный, гибкий и расширяемый механизм извлечения заголовков из Markdown и HTML документов в JavaScript, позволяя создавать динамические оглавления, индексировать контент и интегрировать структурированные данные в веб-приложения.