Declaration merging для кастомных узлов

В экосистеме 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) для добавления новых свойств или типов узлов.
  • Совмещение пользовательских типов с уже существующими, чтобы редактор и компилятор TypeScript распознавали новые узлы без ошибок.
  • Поддержка типобезопасного взаимодействия плагинов и кастомных трансформаций AST.

Расширение интерфейсов mdast для кастомного узла

Предположим, необходимо добавить узел 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 и поиска нужных узлов.
  • Внутри плагина добавляются новые свойства, соответствующие объявленному интерфейсу.
  • TypeScript корректно проверяет типы при условии, что интерфейсы расширены через declaration merging.

Интеграция с Rehype

Для преобразования кастомных 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);
    });
  };
};

Заметки по реализации:

  • Создаётся HAST-узел с использованием hastscript.
  • Сохраняется структура и вложенность исходного Markdown-контента.
  • Новые свойства title и children доступны благодаря расширенному интерфейсу.

Поддержка сложных вложенных узлов

Declaration merging позволяет описывать многоуровневые и рекурсивные структуры. Например, блок callout может содержать другие callout блоки:

interface CalloutNode extends Node {
  type: 'callout';
  title: string;
  children: Array<CalloutNode | Node>; 
}

Такой подход:

  • Обеспечивает строгую типизацию для рекурсивных AST-структур.
  • Позволяет плагинам безопасно обходить дерево и модифицировать вложенные блоки.
  • Избегает использования any и ручного кастинга при разработке сложных Markdown-преобразований.

Рекомендации по практическому использованию

  1. Всегда расширять существующие интерфейсы, а не создавать новые независимые типы для узлов, чтобы сохранялась совместимость с плагинами.
  2. Использовать declaration merging для всех кастомных полей, особенно если узлы будут использоваться в нескольких плагинах или при конверсии в HTML.
  3. Описывать вложенность через union-тип, чтобы TypeScript корректно проверял дерево AST на всех уровнях.
  4. Обеспечивать соответствие между mdast и hast, чтобы последующая обработка HTML была безопасной и предсказуемой.

Примеры дополнительных кастомных узлов

  • AlertNode: для предупреждений с уровнями info, warning, error.
  • TabsNode: для табов с массивом children для каждой вкладки.
  • InteractiveNode: для вставки кастомных компонентов React или Vue.

Каждый из этих узлов может быть безопасно интегрирован через declaration merging, обеспечивая строгую типизацию и корректную работу плагинов.


Хотите, я подготовлю полный пример структуры проекта с Remark + Rehype, где все кастомные узлы объявлены через declaration merging и конвертируются в HTML? Это будет учебник, который можно запускать прямо в TypeScript.