Так уж сложилось, что я иногда пишу всякие тексты. Индустрия ПО сделала всё, чтобы навсегда отвратить меня от всякого рода WYSIWYG’ов. Лексикон был милым, WordPerfect иногда приемлемым для коротких текстов, но эра, начавшаяся с Word2 и получившая естественное развитие в Word6 — это было для меня немного за гранью. Удивительно, как микрософты умудрились выпустить два таких разных по характеру близнеца: Excel был прекрасен буквально со старта, а Word просто закапывал себя глубже и глубже с каждой версией. Хотя, тут нет ничего удивительного: экселем занимался Джоэл Спольски, а вордом — я даже не знаю, кто. Жалко, что профессионалов типа Спольски пока не научились клонировать.
В начале 90-х я довольно много времени уделял написанию всякого рода статей (не тех текстиков, которые мы тут пописываем на хабр, а таких, которые отправляются в печатные журналы) — и я получил огромное эстетическое удовольствие, познакомившись с LaTeX. До сих пор, если мне хочется оформить что-либо в PDF, я возвращаюсь к Теху. Но для нескольких абзацев в бложег — этот текстовый процессор немного тяжеловат. Я даже однажды написал плагин к нему, который транслирует .tex
файлы в HTML, но всё это выглядело настолько неестественно, что я его забросил, а потом при очередном переезде куда-то протерял исходники. Я не имею привычки тащить за собой в новые этапы жизни бэкапы и юношеские дневники.
Потом я несколько лет вёл блог в одном большом XML-файлы со своей XSD-схемой и XSLT-трансформером, который производил из одной простыни — кучу отдельных HTML-файликов. Это было прикольно, и меня все устраивало, но потом я опять куда-то переехал, на пару лет забросил беллетристические потуги — и оно тоже кануло в лету.
Всё это время меня сильно раздражало то, что разметка занимает слишком уж большую часть собственно текста. И тут Грубер со Шварцем выкатили маркдаун. Это было как глоток свежего воздуха. Минимум разметки, читаемый текст даже до обработки, достаточный для рядового средней руки блогера набор красивостей.
Спустя пару лет мне стало мучительно недоставать возможности тонкой настройки. Я никогда не использовал маркдаун для разметки научных статей и документов с вложенными в нумерованные списки таблицами, а CommonMark двигался, почему-то, именно в этом направлении. Не, серьёзно, цитата с вложенной таблицей, в одной из ячеек которой — список? Кому вообще может прийти в голову реализовывать это на маркдауне?
В то же самое время я часто ссылался на твиты (это было золотое время твиттера, почти без политики, без рекламы, а главное — без Маска). Как нормальный человек хочет сослаться на твит? — Скопировать ссылку, и вставить её в свой текст. Что он хочет увидеть в сгенерированном HTML? — Ну я лично ожидаю там увидеть то же самое, что получится, если вставить ссылку на другой твит в самом твиттере — красивое окошечко, оформленное по их стандарту. Для этого даже протокол придумали (и не один). Open Graph вам предоставит всё нужное, только в HTML останется завернуть. Или банальную ссылку на пользователя: @cupraer
— почему я не могу в конфигурации маркдауна сказать: отображай её вот так, с всплывающей подсказкой с кармой и стрелочками? Почему каждый должен городить какие-то костыли вокруг этой тривиальной и крайне востребованной функциональности? И я молчу про ABBR
, DT
/DD
, SUP
/SUB
и всякое такое: я не для того выбирал маркдаун, чтобы пересыпа́ть текст HTML-тэгами.
Короче, я посмотрел на существующие парсеры маркдауна и решил написать свой. У меня к нему было три-четыре основные претензии: ① не сильно медленнее аналогов, ② полная настройка синтаксиса (в разумных пределах), ③ обработка потокового ввода, ④ возможность для пользовательского кода менять всё, что угодно, на лету. Так родилась библиотека md.
Ненужный исторический экскурс
Первая моя попытка была на руби. Я подошел к вопросу максимально рубически: разметка сама по себе была вызовом функций через method_missing
. Код сохранился, но использовать его нормальному человеку в голову не придёт.
Потом я попробовал еще раз, уже на эликсире, но запутался в абстракциях, начал приколачивать гвоздями лишнее, и уткнулся в нечитаемый и нерасширяемый код.
Тогда я закатал рукава и взялся за дело в третий раз, памятуя о накопленном негативном опыте. Так родилась библиотека md, которой до сих пор пользуюсь я сам и люди, которым нужно несложное, но очень гибкое форматирование текста.
Архитектура
С самого начала я знал, что мой парсер будет потоковым (потому что его должно быть легко использовать в реальном времени при наборе текста), быстрым, настраиваемым и отзывчивым к изменениям извне — как при компиляции, так и в рантайме.
Для начала я попытался проанализировать, какие типы разметки в принципе могут мне потребоваться. Помимо пустых (<hr>
), тривиальных (<b>bold<b/>
) и чуть менее тривиальных (<img src="…">
) и совсем менее тривиальных (<dl><dt>…</dt><dd>…</dd></dl>)
— я насчитал еще несколько (типа блоков кода), а в конечном итоге в списке их — пятнадцать.
Обработка входного потока выполняется одной рекурсивной функцией, полностью построенной на паттерн-матчинге входящих аргументов. Я буквально пытаюсь сматчить на начало остатка от входящей строки все открывающие разметочные примитивы, а если нет — откусываю один символ и иду дальше. Вот пример для комментариев:
# мы генерируем хендлеры во время компиляции, потому что # синтаксис — полностью настраивается через конфиг # вот соответствующая строка конфига: # comment: [{"<!--", %{closing: "-->"}}] Enum.each(comments, fn {md, properties} -> closing = Map.get(properties, :closing, md) _tag = Map.get(properties, :tag, :comment) # это сматчится на открывающий `<!--` если мы не в `:raw` моде defp do_parse(unquote(md) <> rest, state()) when mode not in [:raw, {:inner, :raw}] do state = %Md.Parser.State{state | bag: %{state.bag | stock: [""]}} |> push_mode(:comment) do_parse(rest, state) end # это сматчится на закрывающий `-->` если мы в `:comment` моде defp do_parse(unquote(closing) <> rest, state()) when mode == :comment do state = state |> listener({:comment, state.bag.stock}) |> pop_mode(:comment) do_parse(rest, %Md.Parser.State{state | bag: %{state.bag | stock: []}}) end # это в `:comment` моде подберёт символ и сохранит на будущее defp do_parse(<<x::utf8, rest::binary>>, state()) when mode == :comment do [stock] = state.bag.stock state = %Md.Parser.State{state | bag: %{state.bag | stock: [stock <> <<x::utf8>>]}} do_parse(rest, state) end end)
Если мы не встретили символ разметки, открывающий комментарий, и мы не внутри моды :comment
, здесь матчей не случится, отработает один из не показанных clauses.
Если ни один из разметочных символов не сматчился — штош. Мы провалимся в handle-’em-all clause.
defp do_parse(<<x::utf8, rest::binary>>, state()) do … end
Отлаживался я так: добавлял новые clauses и ловил ошибки на каком-то развесистом common-mark тесте. Если разметка казалась мне слишком уж вычурной (тот самый список внутри ячейки таблицы) — я удалял её из теста, в противном случае — добавлял новый clause чтобы сматчить и отработать. Спустя некоторое время результат показался мне приемлемым; достаточным, чтобы заняться колбэками для изменения результата на лету.
Гибкость в рантайме
Мне показалось уместным разрешить два варианта колбэков: не только уведомительные, но и мутирующие текущий стейт парсера как им вздумается. В конце концов, это прикладная библиотека для людей, которым нужна максимальная настраиваемость разметки; если они напортачат с изменением состояния — сами виноваты. Тут я целиком на стороне Матца, который просто на уровне языка разрешил изменять всё, от приватных методов — до имплементаций чего угодно, включая подмену в рантайме примитивных типов.
Но прям призывать пользователей копошиться во внутреннем стейте мне показалось опрометчивым, и я назвал этот колбэк — Md.Listener. Если из него вернуть :ok
, стейт не изменится. И да, можно вернуть {:update, state}
, и нет, я вряд ли стану писать обширную документацию по тому, как этот апдейт можно выполнить, чтобы всё не поломать. Кому прямо надо — а таких было за все время человек десять — заведут issue, или напишут мне письмо. Остальные пусть думают, что это просто listener.
DSL
Ну, разумеется, куда без него. В коммьюнити эликсира слишком много бывших рубистов, чтобы можно было надеяться, что мало-мальски популярная библиотека не получит вторым issue — запрос на DSL. Кто-то попросил, я прикрутил.
# syntax declared as DSL import Md.Parser.DSL list("- ", %{tag: :li, outer: :ul}) list("+ ", %{tag: :li, outer: :ol})
Примеры использования
В своем блоге я использую много шорткатов для своих нужд:
-
♫1846254219 → SoundCloud Embedded
-
✇Iem9f7Us9bk → YouTube Embedded
-
@mudasobwa → Mastodon handle with an info pop-up
-
^text^ → <sup>text<sup>
и еще много всякого. Также я через колбэк протаскиваю первую картинку в тексте в Open Graph preview, превращаю первую строку в заголовок, если она короткая, но у неё нет маркера «заголовок» (чтобы корректно отображать текст вообще без разметки), и многое другое.
Но главное — этот парсер можно вообще с нуля настроить на, например, распознавание синтаксиса какого-нибудь OrgMode, или прикрутить к мессенджеру с сильно кастрированным но все еще полнофункциональным синтаксисом. Есть и пара примеров прямо в репозитории.
А еще он в три-двадцать раз быстрее аналогов (в основном, потому что он читает входящий поток ровно один раз, то есть там O(length)
в общем случае).
Удачной разметки (и поменьше камер), в общем!
ссылка на оригинал статьи https://habr.com/ru/articles/894884/
Добавить комментарий