В экосистеме Remark и Rehype,
построенной на основе Unified, обработка AST (Abstract
Syntax Tree) является ядром работы с Markdown и HTML. Стандартные узлы,
такие как paragraph, heading,
text или link, имеют заранее определённые типы
и интерфейсы. Однако при расширении функционала часто возникает
необходимость добавления кастомных узлов, специфичных
для проекта: например, специальные блоки заметок, интерактивные
компоненты или нестандартные элементы синтаксиса. Для корректной работы
TypeScript с такими узлами применяется declaration
merging.
Declaration merging в TypeScript позволяет расширять существующие интерфейсы без их прямого редактирования. В контексте Remark / Rehype это означает:
mdast (Markdown AST)
или hast (HTML AST) для добавления новых свойств или типов
узлов.Предположим, необходимо добавить узел callout,
представляющий информационный блок с заголовком и содержимым.
Стандартный Node из mdast не содержит такого
типа. С помощью declaration merging можно расширить типы следующим
образом:
import { Node } from 'unist';
declare module 'mdast' {
interface StaticPhrasingContentMap {
callout: CalloutNode;
}
interface BlockContentMap {
callout: CalloutNode;
}
}
interface CalloutNode extends Node {
type: 'callout';
title: string;
children: Node[]; // вложенные стандартные mdast узлы
}
Пояснения:
StaticPhrasingContentMap и BlockContentMap
позволяют TypeScript понимать новый тип узла в контексте, где ожидаются
стандартные блоки или строчные элементы.children задаёт возможность вложенности
стандартных блоков Markdown внутри кастомного блока.type: 'callout' обеспечивает
корректную идентификацию узла при обходе AST.После объявления типа создаётся плагин, который преобразует исходный Markdown в AST с кастомным узлом:
import { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { Node } from 'unist';
const remarkCallout: Plugin = () => {
return (tree) => {
visit(tree, 'paragraph', (node: Node) => {
const paragraph = node as any;
if (paragraph.children[0]?.value?.startsWith('!callout')) {
const title = paragraph.children[0].value.replace('!callout ', '');
node.type = 'callout';
(node as any).title = title;
}
});
};
};
Особенности:
unist-util-visit для обхода AST и поиска
нужных узлов.Для преобразования кастомных mdast узлов в HTML через Rehype необходимо определить трансформацию:
import { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { h } from 'hastscript';
const remarkCalloutToHtml: Plugin = () => {
return (tree) => {
visit(tree, 'callout', (node: any) => {
const htmlNode = h('div', { class: 'callout' }, [
h('strong', node.title),
...node.children.map(child => h(child.type, child))
]);
Object.assign(node, htmlNode);
});
};
};
Заметки по реализации:
hastscript.title и children доступны
благодаря расширенному интерфейсу.Declaration merging позволяет описывать многоуровневые и
рекурсивные структуры. Например, блок callout
может содержать другие callout блоки:
interface CalloutNode extends Node {
type: 'callout';
title: string;
children: Array<CalloutNode | Node>;
}
Такой подход:
any и ручного кастинга при
разработке сложных Markdown-преобразований.info, warning, error.children для каждой вкладки.Каждый из этих узлов может быть безопасно интегрирован через declaration merging, обеспечивая строгую типизацию и корректную работу плагинов.
Хотите, я подготовлю полный пример структуры проекта с Remark + Rehype, где все кастомные узлы объявлены через declaration merging и конвертируются в HTML? Это будет учебник, который можно запускать прямо в TypeScript.