Введение
Библиотека Unified и экосистема плагинов для работы с Markdown и HTML позволит очень просто создать конвертер и подключить компоненты React. Unified преобразует контент в синтаксическое дерево, и имеет набор утилит для работы с деревом, но даже без набора утилит это простая задача, достаточно уметь перебирать свойства объекта javascript. Все это будет продемонстрировано и объяснено.
В статье есть пример создания плагина и демо проект.
Причины выбора библиотеки Unified
Во-первых, интеграция с React плагины для компиляции HTML/Markdown в компоненты React:
rehype-react - для компиляции HTML
remark-react - для компиляции Markdown
Во-вторых мне понравилось наличие большого количества плагинов, среди которых есть компонент React react-markdown который я уже использовал в своих проектах, react-markdown под капотом использует библиотеку Unified и плагины rehype-react + remark-react.
Еще одной причиной является понятная и небольшая документация библиотеки Unified.
Оставлю ссылку на хорошую статью Как я Markdown парсер выбирал в которой можно найти несколько альтернативных решений, библиотека Unified тоже упоминается, но не так подробно.
Термины и определения
Markdown - облегчённый язык разметки, созданный с целью обозначения форматирования в простом тексте, с максимальным сохранением его читаемости человеком, и пригодный для машинного преобразования в языки для продвинутых публикаций. Ссылки для ознакомления markdownguide.org и Wiki
HTML - стандартизированный язык гипертекстовой разметки документов для просмотра веб-страниц в браузере. Ссылки для ознакомления Wiki и HTML Living Standard
AST(Abstract syntax tree) - это древовидное представление абстрактной синтаксической структуры текста, в нашем случае написанного на языке javascript. Далее в тексте буду писать AST для краткости. Ссылки для ознакомления Wiki
Unified - библиотека предоставляет интерфейс (смотри раздел processor API) для обработки контента с помощью AST. Экосистемы содержат плагины, работают с AST.
Экосистема плагинов - каждая экосистема добавляет поддержку какого-то типа контента. В этой статье будут использоваться экосистемы плагинов:
Процессор / Processor - создается с помощью вызова конструктора unified(), remark(), rehype() под капотом вызывается метод processor(). Создается процессор готовый к конфигурированию с последовательного подключения плагинов с помощью метода processor().use(). Контент обрабатывается с помощью подключенных плагинов.
Спецификация синтаксического дерева
Синтаксические деревья - это представления исходного кода или естественного языка. Эти деревья являются абстракциями, которые позволяют анализировать, преобразовывать и генерировать код.
В этом документе описан интерфейс синтаксического дерева: specification syntax tree
Набор утилит для работы с синтаксическими деревьями
hast - HTML abstract syntax tree
mdast - Markdown abstract syntax tree
nlcst - Natural language syntax tree
xast - XML abstract syntax tree
Node - узлы синтаксического дерева
Описание интерфейса Node можно найти в документации
в отладчике мы получим такую структуру, которая описана в документации
export type Node = Partial<{ type: string; // for example 'element' tagName: string; // for example "p" properties: { [key: string]: any }; // children: Array<Node>; value: string; position: { // положение этого узла в исходном документа start: { line: number; column: number; offset: number }; // { line: 1, column: 1, offset: 0 } end: { line: number; column: number; offset: number }; }; }>;
Краткое описание свойств:
type - из моего опыта отладки может иметь три значения:
root - корень дерева
text например для представления текстового блока
element - html элемент
tagName есть только у типа element, строковое значение наименование элемента.
value есть только у text
properties есть у type element это атрибуты html элемента
children это дочерние элементы
position содержит информацию о положении узла в исходном контенте до трансформаций. Например, чтобы узнать сколько строчек занимает контент просто выполним position.end.line - position.start.line.
Пример AST
Создаем процессо�� для преобразования markdown в html
result = unified() .use(remarkParse) .use(remarkRehype) .use(parseElements) .processSync("## Netlify");
Мы получим такое дерево
[ { type: "element", tagName: "h2", properties: { }, children: [ { type: "text", value: "Netlify", position: { start: { line: 1, column: 4, offset: 3, }, end: { line: 1, column: 11, offset: 10, }, }, }, ], position: { start: { line: 1, column: 1, offset: 0, }, end: { line: 1, column: 11, offset: 10, }, }, }, ]
Библиотека unified и экосистема
unified - библиотека, которая преобразует контент с помощью AST и предоставляет возможность промежуточной обработки деревьев с помощью экосистемы плагинов. Сайт проекта
Обработка контента проходит три обязательные стадии:
преобразование контента html или markdown в AST - используется плагин Parser
трансформации в AST - используется плагин Transformer
компиляция AST в контент html или markdown - используется плагин Compiler
Плагины подключаются с помощью универсального интерфейса Processor API, рассмотрим далее.
Экосистема rehype для работы с html
rehype - добавляет поддержку работы с html.
Плагины parser и compiler:
rehype-parse - разбирает строку HTML в AST
rehype-stringify - компилирует AST в строку HTML
rehype-react - компилирует AST HTML в компоненты React
Плагины Transformers, используемые в статье:
@mapbox/rehype-prism - подсветка синтаксиса с использованием PrismJS
rehype-sanitize - делает AST более безопасным для использования другими плагинами, в демо приложении будем использовать для безопасного компилирования html в компоненты React
Список всех плагинов экосистемы rehype
Экосистема remark для работы с markdown
remark - добавляет поддержку работы с markdown.
Плагины parser и compiler:
remark-parse - разбирает строку markdown в AST
remark-stringify- компилирует AST в строку markdown
Плагины Transformers, несколько часто используемых:
remark-rehype - трансформирует AST Markdown в AST HTML, после этой трансформации нужно использовать плагины для rehype
Список всех плагинов экосистемы remark
Основной разработчик
Titus (wroom) - основной разработчик спецификации синтаксических деревьев unist, библиотеки Unified и экосистемы плагинов remark и rehype, сделал 4100 вклада (не знаю как еще перевести contribution) на github за 2022 год, но удивительно не столько само число, а стабильность если смотреть на график почти нет дней без вкладов. Это объясняет как он успевает поддерживать столько разработок.

Processor API - интерфейс Unified
Процессоры используется для подключения и настройки плагинов, запуска процесса обработки контента. Который напомню состоит из трех фаз преобразование контента в AST, трансформации в AST и компиляции обратно контент.
Описание использованных в примерах методов процессора:
processor() - создает новый процессор, готовый к конфигурации с помощью подключения плагинов.
processor.use(plugin[, options]) - подключают плагин, чтобы сделать его частью сконфигурированного процессора
processor.parse(file) - используется с плагином parser, для того чтобы преобразовать контент в AST
processor.stringify(tree[, file]) - используется с плагином compiler, для того чтобы преобразовать AST в контент
processor.run(tree[, file][, done]) - асинхронный метод обработки AST с помощью плагинов трансформации
processor.runSync(tree[, file]) - синхронный метод обработки AST с помощью плагинов трансформации
processor.process(file[, done]) - асинхронный метод обработки контента с помощью плагинов: преобразование контента в AST > трансформация AST > компилятор AST в контент
processor.processSync(file) - синхронный метод обработки контента с помощью плагинов: преобразование контента в AST > трансформация AST > компилятор AST в контент
Типичный пример сконфигурированного процессора:
import {unified} from 'unified' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import rehypeDocument from 'rehype-document' import rehypeFormat from 'rehype-format' import rehypeStringify from 'rehype-stringify' const file = await unified() .use(remarkParse) .use(remarkRehype) .use(rehypeDocument, {title: '??'}) .use(rehypeFormat) .use(rehypeStringify) .process('# Hello world!') console.log(String(file))
Входные данные
# Hello world!
Выходные
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>??</title> <meta name="viewport" content="width=device-width, initial-scale=1"> </head> <body> <h1>Hello world!</h1> </body> </html>
Рассмотрим несколько примеров создания процессоров.
Пример описывающий способы подключения плагинов с помощью метода use
unified() // Plugin with options: .use(pluginA, {x: true, y: true}) // Passing the same plugin again merges // configuration (to `{x: true, y: false, z: true}`): .use(pluginA, {y: false, z: true}) // Plugins: .use([pluginB, pluginC]) // Two plugins, the second with options: .use([pluginD, [pluginE, {}]]) // Preset with plugins and settings: .use({plugins: [pluginF, [pluginG, {}]], settings: {position: false}}) // Settings only: .use({settings: {position: false}})
Пример обработки синтаксического дерева с помощью метода run
import {unified} from 'unified' import remarkReferenceLinks from 'remark-reference-links' import {u} from 'unist-builder' const tree = u('root', [ u('paragraph', [ u('link', {href: 'https://example.com'}, [u('text', 'Example Domain')]) ]) ]) const changedTree = await unified().use(remarkReferenceLinks).run(tree)
Пример обработки контента markdown с преобразованием в html c трансформациями
const processor = unified() // markdown преобразуем в AST .use(remarkParse) // markdown AST преобразуем в HTML AST и далее нужно // подключать плагины экосистемы Rehype .use(remarkRehype) // делаем трансформации в HTML AST добавляем // или меняем html тег title с контентом ? .use(rehypeDocument, {title: '?'}) // добавляем поддержку преобразования в строку для процессора .use(rehypeStringify) // Для того чтобы использовать процессор и получить результат // нужно вызвать метод process или processSync
Создание своего плагина
Из прочитанного ранее должно стать понятно: как создавать и сконфигурировать процессор, подключать плагины, какая структура узла дерева и представление о дереве в целом.
Теперь пришло время написать свой плагин. Мы будем писать Tranformer плагин, который обрабатывает AST дерево, а точнее вносит небольшие изменения в html тег "a" добавляет пару свойств target="_blank" и rel="noreferrer" в случае если href не начинается с "#". На сайте unifiedjs.com есть документация в которой описано создание плагина экосистемы retext для работы с естественным языком. Они очень похожи на rehype плагины, которые я использовал в статье, отличие названиях типов в узлах. также там демонстрируется утилита visit из пакета unist-util-visit, которая помогает искать узлы дерева по шаблону.
Шаблон плагина
export default function retextSentenceSpacing(options) { return (tree, file) => { } }
options - в документации не написали этот параметр, но его можно использовать для передачи настроек
tree это корневой узел дерева в rehype экосистеме будет иметь type root
рассмотрим работу на примере создания плагина работты с сcылками a[href], е��ли это внешняя ссылка будем добавлять свойства target="_blank" rel="noreferrer"
import type { Node } from "../unified.types"; import { visit, Node as NodeVisitor } from "unist-util-visit"; export const rehypeAhref = () => { return (tree: NodeVisitor) => { try { visit(tree, { tagName: "a" }, (node: Node) => { if(!node?.properties){ node.properties = {} } const props = node.properties if(props.href[0] !== "#"){ props.target = "_blank"; props.rel = "noreferrer"; } }); } catch (err) {} }; };
и подключение к процессору
unified() .use(remarkParse) .use(remarkRehype) .use(rehypeAhref) .use(rehypeStringify) .processSync("[Google](https://google.ru)") .toString()
результат должен быть
<a href="https://google.ru" target="_blank" rel="noreferrer">Google</a>
Демо приложение
Демо приложение клиент-серверное. На клиенте редактор Markdown, код markdown отправляется на сервер где будет выполнена трансформация Markdown в HTML, код HTML возвращается на клиент, для компиляции html в компоненты React.

Стек компонентов:
React - одна из самых популярных библиотек для создания Frontend-приложений, которую в настоящий момент я очень интенсивно использую.
NextJS - фреймворк для React, его возможности описаны в документации, в демо приложении NextJS используется только как сервер, который може�� предоставить api для выполнения запросов на сервер с клиента, NextJS разработан специально для React. Так что для меня выбор этого стека был очевидным.
Vercel - площадка на которой развернуто демо приложение
SimpleMDE - простой редактор markdown. В этом демо приложении установлен адаптированный для React пакет react-simplemde-editor. Библиотека unified, экосистема плагинов rehype и remark Для подсветки синтаксиса использовались библиотеки refractor + prismjs
Приложение не обязательно должно быть клиент серверным. Библиотека unified не привязана к React, их можно использовать в среде NodeJS или браузерном js, так как я в последнее время использую React то для меня NextJS это удобный выбор для создания приложения.
Плагины, разработанные для демо приложения:
Плагин, который добавляет к html элементу a[href] атрибут target="_blank" и rel="noreferrer" при условии, что ссылка не ссылает на id этой же страницы. Ссылка на github
Плагин подсветки синтаксиса использует PismJS и refractor, это адаптированный плагин rehype-prismjs. Ссылка на github
Плагин создания блока кода с нумерацией строк и кнопкой копировать код Ссылка на github
Компиляция AST HTML в React компоненты
Предварительно используем плагин rehype-sanitize для того чтобы компилируемое AST html было более безопасным. Например, если мы случайно забудем обернуть строку
<button className="codecopy" onClick={handlerCopy} ref={refBtnCopy} > {textBthCopy} </button>
в Fenced Code Block то плагин трансформации rehype-react будет воспринимать этот код, как JSX и скомпилирует компонент button у которого будет props ref как строка и получим Unhandled Runtime Error
Error: Function components cannot have string refs. We recommend using useRef() instead. Learn more about using refs safely here: reactjs.org/link/strict-mode-string-ref
я использовал rehype-sanitize чтобы компилировались только разрешенные html теги и только с разрешенным списком атрибутов. Плагин rehype-sanitize превратит небезопасную строку в безопасную изменив начальный символ < на escape символ html <, результат будет таким:
<p><button className="codecopy" onClick={handlerCopy} ref={refBtnCopy}</p>
потеряли часть строки, но визуально мы это заметим и исправил, это лучше, чем получить ошибку.
Теперь можно без проблем использовать плагин rehype-react для компиляции html в React компоненты. В демо приложении он используется для копирования кода по клику на кнопку Copy.

Компонент React
const CodeSection = ({ className, children, }: JSX.IntrinsicElements["section"]) => { const refCodeBlock = React.useRef<HTMLElement | null>(null); const refCodeBox = React.useRef<HTMLElement | null>(null); const refBtnCopy = React.useRef<HTMLButtonElement | null>(null); const [copyBtn, setCopyBtn] = React.useState(false); const [textBthCopy, setTextBtnCopy] = React.useState("Copy"); const handlerCopy = () => { const range = document.createRange(); range.selectNodeContents(refCodeBox.current as Node); const buffer = range.valueOf().toString(); navigator.clipboard .writeText(buffer) .then(() => { setTextBtnCopy("Copied"); refBtnCopy.current?.classList.add("copied"); setTimeout(() => { refBtnCopy.current?.classList.remove("copied"); setTextBtnCopy("Copy"); }, 2000); }) .catch(() => { setTextBtnCopy("Copy error"); refBtnCopy.current?.classList.add("error"); setTimeout(() => { refBtnCopy.current?.classList.remove("error"); setTextBtnCopy("Copy"); }, 2000); }); }; return ( <section className={className} ref={(node: HTMLElement) => { if (node && refCodeBlock.current === null) { refCodeBlock.current = node; refCodeBox.current = node.querySelector(".codebox"); if (refCodeBox.current) { setCopyBtn(true); } } }} > {copyBtn && ( <button className="codecopy" onClick={handlerCopy} ref={refBtnCopy} > {textBthCopy} </button> )} {children} </section> ); };
Подключение компонента
.use(rehypeReact, { createElement: React.createElement, Fragment: React.Fragment, components: { section: CodeSection, }, })
Заключение
В этой статье я изложил свой опыт использования библиотеки Unified для работы с контентом markdown и html, показал насколько просто работать с синтаксическим деревом, сконфигурировать плагины для выполнения изменения контента и разработать и подключить свой плагины. По моему мнению библиотека Unified обладает большим потенциалом и большим набором возможностей. Мне очень интересно ваше мнение и критика. Спасибо.
Оригинал статьи на моем сайте, я открыт для предложений по работе.
