Для создания документации к нескольким проектам я пару раз использовал Hexo. С поставленной задачей он справился, но статическая генерация имеет неприятный момент: в случае незначительного обновления темы или алгоритма преобразования почти все страницы сайта будут сильно изменены, что чревато коварными ошибками. Достаточно затруднительно в условиях пет-проекта компенсировать это тестами или каким-либо валидатором. Чем больше материалов на сайте - тем больше проблем, поэтому неплохо бы написать генератор самому, чтобы контролировать его изменения с кодовой базой и не от кого не зависеть, в т.ч. от разработчиков плагинов на которые опирается множество сторонних генераторов. Такие плагины имеют очень нехорошую тенденцию отваливаться в самый неподходящий момент. Да и сама по себе задача разработки подобного генератора достаточно интересна.

Для генератора я выбрал Groovy. Учитывая, что генератор подвержен коварным ошибкам, то тут может помочь особенность Groovy в наличии строгой проверки типов. Её включение делает недоступными многие динамические возможности, но если использовать такие дополнительные гарантии все же планируется, то удобнее включить её сразу, а где нужна динамика - для cli, метапрограммирования, dsl-билдеров xml и т.п. вынести и пометить как TypeCheckingMode.SKIP или другим подобным образом обойти проверку.

Если это сделать наоборот, то после включения проверок есть шанс получить огромное количество ошибок в самых разных файлах, которые еще и будут зависеть от версии самого Groovy. Удобно, что включить проверку для всего приложения можно через запуск Groovy с кастомным файлом-конфигом (--configscript) с настроенным CompilerCustomizationBuilder. Получается такой динамический язык с встроенным статическим анализатором, что очень и очень удобно, ну разве что после исправления всех ошибок и кастов.

Удобны также и assert-ы, которые хорошо дебажат ошибки, для подобного проекта генератора есть смысл агрессивно усыпать код этими проверками, хотя это может сделать приложение более хрупким в сравнении с использованием исключений.

Конечно же, ориентация Groovy на DSL тоже должна найти отражение в генераторе. Сразу приходит на мысль возможность реализовать её в конфиге сайта, получая возможность инклюдинга конфигов друг в друга, особенно это полезно для разных конфигураций окружения, наличия зеркала у сайта и т.п. Можно вспомнить статью Использование возможностей Groovy DSL для конфигурации Java-приложения. Файл конфига можно оформить и в виде простого "ключ-значение" (например, используя ConfigSlurper), но возможность иметь гибкое расширение на будущее за счет полноценного Groovy-кода очень сильно соблазняет, да и такая реализация полезна для других проектов.

Смысл такой DSL-конфигурации достаточно прост: настройки сайта сохраняются в файле, этот файл интерпретируется Groovy в виде кода и все методы или записи ключ-значения отображаются на какой-нибудь объект, который устанавливается как делегат. Соответственно, вызов include в файле конфига может быть отображен на метод у делегата или какой-нибудь другой объект.

Раз уж речь зашла о конфигурации генератора, то еще отмечу, что CliBuilder у Groovy (их несколько, я подразумеваю groovy.cli.commons и не знаю насчет groovy.cli.picocli) имеет свойство при комбинации опций коварно ломаться при смешении формата ключей. Например: -Dgrape.root=foo --outDir bar, то ошибки может не быть, а директория назначения будет null. В случае длинного списка аргументов командной строки это легко упустить, отсюда нужно обязательно нашпиговать эту логику проверками.

Небольшая примитивная систематизация структуры генератора:

Информационная составляющая:

  • Конфигурация сайта: описание, автор и т.п. Сюда же пока входит и конфигурация развертывания в т.ч. для локальной разработки и тестирования. Например, для локального запуска нужно заменить все url-адреса.
  • Статья:
    • Информация о статье: дата и время создания, название, метки, ключевые слова, описание для метатегов, краткое описание для вывода на главной.
    • Текст статьи.
    • Ресурсы статьи - изображения и т.п.
  • Множество статей:
    • Систематизация статей.
    • Информация об обновлениях.

Инфраструктурная составляющая:

  • Конфигурация генератора - расположение директории Markdown-контента, темы и т.п.
  • Шаблоны сайта:
    • Общий шаблон, часто называемый layout. Контентная страница рендерится в переменную, которая выводится в общем шаблоне, повсеместно данная переменная называется content или что-то такое.
    • Контентные шаблоны - для постов, тегов и т.п.
  • Вставки шаблонизации:
    • Включение (инклюдинг) файлов шаблонов друг в друга.
    • Логика вывода в шаблоне.
    • Вспомогательная логика (хелперы):
      • Информация о текущем адресе страницы.
      • Определение типа страницы - главная, статья, страница тегов и т.п.
      • Вывод адреса сайта - базовый URL и главная.
      • Формирование url по имени и т.п.
  • Ресурсы для сайта: css, js, иконки и т.п.
  • Ресурсы для окружения: Robots.txt, sitemap.xml и т.п.

Конечно, данная классификация сильно упрощена и создана для наглядности, да и я немного нарушил структуру, сокращая уровни, иначе на мобильном эти иерархии выглядят жутко неудобно из-за отступов. Можно добавить самый разный функционал: интернационализация, кеши, информация обновления статей и т.п. по вкусу.

Я покопался в github-репозиториях в поисках вдохновляющих проектов, но не нашел ничего подходящего, в топе с большим количеством звезд проекты, которые проблематично портировать на Groovy по разным причинам, так что без велосипедостроения тут не обойтись.

Трансформацию текста статьи в html-код сайта можно выразить в структуре генератора через набор классов-конвертеров (или трансформеров), аналоги их часто можно встретить в языках в виде класса с параметризованным входом и выходом. Если мы коснемся мира Java, то в качестве примера можно вспомнить тот же интерфейс org.springframework.core.convert.converter.Converter, который имеет единственный метод T convert(S source). С другой стороны, его также можно рассмотреть как частный случай джавовского функционального интерфейса java.util.function.Function<T,R>.

Какие этапы преобразования можно придумать для статического генератора, например:

  • Директория со статьями в список директорий, каждая из которых содержит файлы статей.
  • Список файлов в список объектов с информацией о главном файле статьи и с содержимым в виде строк как List (или даже Collection), сведем это в список строк файла статьи.
  • Список строк в объект с частями статьи - хидером статьи (список строк) и её телом (то же список строк).
  • Части статьи в объект статьи с тайтлом, временем создания и правки, тегами и т.п. При этом такое преобразование требует еще двух:
    • Преобразования текста статьи в html, у меня используется Markdown
    • Получения метаданных из html, например, какое количество картинок в посте, есть ли код и т.п. Получить эти данные из сырого текста проблематично из-за различных настроек или особенностей markdown-процессора.

Если использовать классическое внедрение зависимостей, то разные этапы преобразования вполне будут поддаваться замене. В схеме выше есть небольшой нюанс: кроме самого файла с текстом статьи к ней могут быть прилеплены и различные материалы, в частности - изображения. Поэтому я и стал отталкиваться именно от отдельных директорий, которые содержат файлы статей, а не от простого списка файлов а-ля post1.md, post2.md в одной директории. Тогда изображения можно также поместить в эту директорию статьи в виде субдиректории. Например, в самом простом случае как:
hello-post-dir
├── images
│   ├── image1.png
└── main.md

Тогда менеджеру ресурсов при генерации нужно аккуратно скопировать изображения в директорию с сгенерированным сайтом, желательно не делая этого многократно. Отсюда также может быть проблема, что схема преобразований выше не учитывает эти ресурсы и получить к ним доступ из информации о самой статье на последних этапах цепочки преобразований проблематично. С другой стороны, это не завязывает генератор на структуру директории, разделяя ресурсы и статью, что выглядит более правильным, а изначального сохранения пути к директории статьи вполне достаточно для менеджера, т.е. это намекает на то, что на каждом последующем этапе выгодно сохранять результат предыдущего. Тогда в контроллере менеджер ресурсов может получить информацию от самых первых этапах преобразования и получить путь до директории статьи, с которого все начиналось.

С другой стороны, частным случаем может быть генерация из другого источника, например, из базы данных. Если на каких-то этапах будет использован, например, более специфический легаси File (или Path из java.nio), то на фоне сохранения предыдущих результатов это может завязать все api генератора только на файловую систему, сделав генерацию из других источников очень проблемной. В примере выше менеджер ресурсов захочет получить список изображений для статьи, обратившись к пути директории и для серверной базы данных никакого File там быть не может. Есть смысл заранее посмотреть в сторону более универсальных ресурсов - URL и URI.

Таким же случаем может быть смена выходного формата: не в html, а в тот же pdf. Если на промежуточных этапах будет завязка на html, то замена будет болезненной. С другой стороны, у html-сайта много специфических настроек, например, количество постов на главной странице, seo-метатеги и т.п. так что безболезненно реиспользовать выйдет разве что самые простые трансформеры.

Могут быть и более сложные случаи, например, можно пожелать держать текст для статьи сразу в нескольких файлах, а кроме изображений может быть большое количество других ресурсов, которые удобно скопировать одной директорией сразу. В таком случае наличие изображений без отдельной поддиректории для ресурсов и .md-файла статьи как в примере выше явно не слишком хорошая затея. Для будущей расширяемости есть смысл ресурсы положить в одно директорию, а файлы статей - в другую. Хотя если информация о них не используется на промежуточных этапах, то это изменение вполне можно провести без особой боли.

Учитывая возможность легко внести ошибку в генератор, можно подумать о дополнительных валидациях промежуточных результатов преобразований, например, корректности всех полей в объекте статье или даже проверке выходного html-кода. Если при парсинге даты\времени еще могут вылетать исключения, то в тайтл запросто может пробраться пустая строка или строка из одних пробелов, как и символов переноса и т.п., что отследить достаточно трудно.

Groovy имеет встроенную поддержку самых разных шаблонов, что хорошо описано в доках Template engines. Текст может быть неопределенно большим и я выбрал StreamingTemplateEngine. На самом деле, StreamingTemplateEngine и GStringTemplateEngine могут быть взаимозаменяемыми, но последний движок имеет свойство конфликтовать, например, с идентификатором JQuery - '$', который для него нужно экранировать (а вот для других - нет) и о чем легко забыть.

А вот инклюдинг поддерживает только MarkupTemplateEngine, но я не хотел бы завязывать шаблоны на его DSL, предпочитая чистый HTML, поэтому беглым взглядом стал искать возможности реализации. Сам класс движка написан не очень удобно: большая часть кода просто засунута в приватный статический класс и наследованием там трудно что-то сделать, разве что переписывать всю реализацию, но это такое себе. Второй вариант - использовать метапрограммирование, добавив метод include к скрипту, но он формируется приватными методами и наследуется от groovy.lang.Script, тоже такое себе. До Binding, который становится делегатом скрипта шаблона добраться тоже нельзя, остается простой вариант положить в него либо объект с таким методом, либо замыкание, которое вызвать через call.

После ряда экспериментов у меня получилось что-то такое, биндинг нужен для передачи аргументов при итерации, например, по статьям, для включения простых шаблонов его может не быть:

commonBinding.putIfAbsent("include", { String sourceFileName, Map<String, Object> binding = null ->
			assert sourceFileName != null && !sourceFileName.isBlank()
			//baseIncludePath - путь директории с темой, чтобы путь включаемого шаблона рассчитывался от директории темы и вставки можно было легко перемещать между шаблонами. Можно и без геттера как свойство absolutePath() 
			File fileToInclude = Paths.get(baseIncludePath.getAbsolutePath(), sourceFileName).toFile()
			//как нибудь его проверить
			assert fileToInclude.isFile() && fileToInclude.canRead()

			//напомню, что есть перегрузка метода чтения с указанием кодировки (т.к. внутри там BufferedReader, то по идее должно работать и StandardCharsets, чтобы не хардкодить строку кодировки, но на всякий случай сделаю по мануалам). 
			//Передаю список строк т.к. он не завязан на файловую систему и удобнее разбирать метаданные
			List<String> contentForRender = fileToInclude.readLines("UTF-8")
			//можно повыделываться и создать мапу иконографикой, вместо new, при этом там будет LinkedHashMap
			Map<String, Object> newBinding = [:] << commonBinding
			if (binding != null) {
				//пустой биндинг выглядит подозрительным
				assert !binding.isEmpty()
				newBinding << binding
			}

			//делаем отдельную переменную для более удобной точки останова при дебаге с переносом return на новую строку, она тут скорее всего пригодится и вызываем какой-нибудь метод, которые будет все это рендерить
			def result = render(newBinding, contentForRender, baseIncludePath)
			return result
		})

Конечно, такое количество логики внутри замыкания делает его крайне специфическим, в данном случае завязывая его на файловую систему. Эти мапы с биндигами можно передать в шаблон через groovy.text.Template#make(binding), возможно, для больших гарантий безопасности есть смысл ограничить из изменение в шаблонах.

Напомню, на всякий случай, что апи File неформально легаси, но в Groovy есть дефолтный импорт java.io.*, а вот java.nio нет, что может доставить определенные неудобства, поэтому я выбрал старый способ.

Наиболее интересной ситуацией будет включение с итерацией по множеству, где на каждом шаге нужно передавать переменные:

//Для обхода списка статей
<% pages.each { page ->
out << include.call("page/_page_overview.html", ["page": page])
} %>

//В случае более простого шаблона, используем %= для непосредственного вывода, вместо записи в out, переменную, которая всегда определена в данных тегах по умолчанию и является groovy.lang.Writable
<%= include.call("header/_header.html") %>

Не очень удобно, но работает, не имея глобальных побочных эффектов. Изначально я пытался соответствовать заветам многих генераторов и разделять биндинги: один для контентного шаблона, а второй для layout. В итоге началась путаница и неочевидные баги, я отказался от этой затеи, несуществующие переменные все равно дают ошибку.

Что касается хелперных классов, то для вызова как функцию класс сделать вызываемым достаточно легко, создав в нем метод call. Но при попытке его вызова, как и в случае выше с инклюдингом движок будет рассматривать его как метод скрипта шаблона. Если сделать .dump() в коде шаблона, то можно увидеть его контекст: groovy.tmp.templates.StreamingTemplateScript#с_каким_то_номером, где в качестве делегата установлен groovy.lang.Binding, поэтому вызов url("about") он будет транслировать по дефолту в вызов StreamingTemplateScript#url

Я попробовал несколько вариантов организации хелперов. Проще всего сделать для них менеджер в виде объект-агрегата, кроме того, это также предотвратит их разброс по шаблонам, можно всегда найти регекспом вызовы в случае чего. Можно использовать Expando или ExpandoMetaClass, динамически создавая в нем методы хелперов или сделать обычный класс, чтобы при наличии динамического создания методов и замыканий не воевать с TypeChecked проверками или делать их пропуск. Хотя и не хорошо называть класс-менеджер во множественном числе, но мне показалось удобным обращаться к нему как helpers.home(), helpers.url('name'), helpers.page('name') и т.п. не придумал для него подходящего короткого имени. Как вариант, можно вызывать их по короткому текстовому алиасу, но это может быть подвержено различным ошибкам, но как вариант.

Теоретически, при использовании генератора на других кейсах в него могут попасть небезопасные данные и тогда нужно следить за выводом в шаблонах, экранируя его и не допуская XSS-инъекции. Экранирование зависит от шаблона, по крайней мере MarkupTemplateEngine имеет конфиг с возможностью включения автоматического экранирования. StreamingTemplateEngine по дефолту пропускает иньекцию, о чем следует помнить.

Если в биндинге шаблона отсутствует содержимое, к которому идет обращение напрямую, то будет исключение. Например, для формирования тайтла в шаблоне главной страницы нельзя обратиться к той же переменной\ключу tag, которая для данной страницы не существует, но наличествует в странице с тегами, храня их список. Можно подумать, что методы проверки и геттер располагается в самом классе скрипта и можно сделать в шаблоне как hasProperty("tag"), но нет, hasProperty() и getProperty() не сработают. Проверка должна быть в делегате, которым установлен groovy.lang.Binding, а это вызовы hasVariable() и getVariable: hasVariable("tag"), hasVariable("page") и т.п. Эту же методику можно использовать для расшаривания переменных между частями шаблона без создания специального хелпера, разве что есть опасность случайно перезаписать существующее.

Текст преобразовывается из Markdown с помощью com.vladsch.flexmark и нескольких расширений, коих там очень много, разве что устроены они несколько запутанно. На первый взгляд кажется, что здесь узкое место и обязательно нужны кэши, но по наблюдениям узким местом становятся именно сами Groovy-шаблоны, требуя несколько секунд на двойной парсинг: шаблона и его контента, да и включение шаблонов друг в друга тоже уменьшают скорость генерации, так что кэширование страниц и страниц с тэгами явно не помешает, чтобы при отладке или написании новой статьи не терять лишнее время.

Первые злобные баги у меня появились именно из-за работы расширений. Например, если после аббревиатуры (AbbreviationExtension) следовала ссылка (AutolinkExtension), то последняя просто прыгала на начало абзаца, достаточно любопытное поведение. Еще напомню, что зачеркнутый текст зависит от расширения StrikethroughExtension и не поддерживается по умолчанию, что может быть неочевидным.

Конечно же, было бы неплохо иметь эмодзи, но расширение Emoji работает с emoji-cheat-sheet.com, лицензия которого очень сомнительна, по-сути, её там и нет. Я посмотрел на несколько аналогов и они меня не особо впечатлили, поэтому решил вспомнить золотой век ICQ\Jabber-клиентов и моей любимой Miranda IM, которую я пичкал плагинами и конечно же, смайликами. Да и вообще эпоха форумов и чатов у меня ассоциируется именно с колобками, почему бы и нет. Я решил использовать обычные колобки. Конечно, не так стильно, модно, молодежно, да и на мобильном отображение стандартных размеров не очень хорошее, но функцию передачи эмоций они выполняют. Единственное, что будучи анимацией они могут как-то криво отображаться в современных браузерах, но это уже мелочи. Пришлось на основе расширения Emoji сделать свое, которое меняет в тексте название смайлика на url. Что касается удобства их выбора, то я прошелся скриптом по директории с выводом в html-таблицу, пока так. yes4

Таким же способом пришлось написать расширение для установки url картинок в статьях, хотя может оно там уже и было где-то. Ужасно неудобно постоянно вставлять в статью какой-то длинный адрес изображения, намного проще обращаться по его имени, генерируя путь автоматически.

На этом этапе самописность генератора начинает себя оправдывать: мне понадобилось расширение для ссылок картинок к статьям, расширение для колобков, расширения для установки класса для библиотеки подсветки кода. Если бы генератор был сторонним, то мне нужно было бы тесно соприкасаться с его внутренностями и следить за их изменением. В случае динамического языка это чрезвычайно большая проблема.

Дизайн я выбрал самый простой, фронтенд по минимуму, чтобы большую часть времени уделять самому генератору и экспериментам с ним. Возможно, позже поэкспериментирую с транспайлерами в JS, например, меня интересует в этом плане Dart, но там есть определенные риски, особенно с долговременной поддержкой готового кода.

Что можно сказать в заключение. Работать на такой задаче с Groovy достаточно приятно, продуманные нюансы улучшают настроение. Например, наличие метода collate в списках, что удобно для пагинации, наличие пакета groovy.time, который позволяет несколькими строками сделать замер времени скрипта и т.п. Такие мелочи всегда приятны и подкупают заботой о конечном пользователе.

Неприятны реализации шаблонов, эти классы полезно было бы иметь хотя бы минимально расширяемыми. К слову, пакетная видимость, финализация классов и внутренние статические классы тесно сопровождают всю Java-индустрию. Типична ситуация: начинается интеграция либы, а после длительной работы оказывается, что нужный функционал зашит во внутреннем классе или финализирован, но либа уже внедрилась в код: не туда и не сюда, в условиях модульности рефлексия уже не спасет положение как раньше. Это настолько сильно может нарушить и нарушает разработку, что я серьезно изменил свое мнение о классическом ООП и потокобезопасности и уже не отношусь так критично к языкам с упрощенными модификаторами доступа или вообще с их отсутствием.

Немного мучает очень долгая инициализация скрипта. Есть также определенная проблема с изначально динамической природой Groovy, где большая часть функционала реализуется замыканием и обращением к несуществующим методам, что в условиях строгой проверки типов требует или переписывания кода (просто так вам не дадут обратиться к несуществующему методу), или пропуска проверки, что может привести к хорошо запрятанным багам, однако большая часть туториалов в сети и даже некоторые api завязаны на динамику. Это можно даже назвать придиркой для языка, который изначально ориентирован на DSL, но всё же.

Можно еще вспомнить не очень хорошую поддержку Groovy в IDE, даже в IntelliJ, в которой с настройками по умолчанию очень легко пропускать ошибки. Кроме того, могут быть баги с Grape, передачи параметров Groovy и т.п. что может привести к переносу части работы в системный шелл или терминал.

Пока я доволен: этот пет-проект мне очень понравился и позже планирую несколько экспериментов с генератором, нужно попробовать разные его архитектуры и поэкспериментировать с возможностями, что может сказаться на работе сайта. Надеюсь, что эти проблемы не принесут слишком больших неудобств читателям, мне бы этого не хотелось. Посмотрим, что выйдет из этого эксперимента.