Hakyll, asciidoctor и KaTeX

Опубликовано 18.07.2021
Последние изменения 24.10.2021

Сегодня разбавим решения задач с Project Euler статьёй о том, как можно приготовить блог с использованием таких популярных языков, как Haskell, Ruby и Javascript. Выясним, почему AsciiDoc круче, чем Markdown, а Hakyll на голову выше Jekyll.

AsciiDoc vs Markdown

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

Ещё одна проблема markdown в том, что его проблематично экспортировать в человекочитаемый LaTeX. Конечно можно через pandoc, но вычищать его потом можно замучиться. А мне хочется ещё и pdf версии статей делать с его помощью.

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

  • математические формулы

  • оглавление

  • директивы препроцессора, позволяющие делать условную компиляцию документа

  • введение и приложения

  • сноски

  • пользовательские типы блоков и макросы

Захотелось мне странного — попробовать AsciiDoc вместо markdown для написания постов в блоге. А в Jekyll это сделать оказалось не так-то просто. Да, есть плагин jekyll-asciidoc, но он не поддерживается Github Pages, поэтому сайт придётся собирать локально или использовать Github Actions. А удобство Jekyll именно в том, что не нужно делать лишних действий — запушил код на Github и сайт сам пересобрался.

Если сайт в любом случае теперь придётся собирать самому, то можно попробовать любой другой генератор статических сайтов. Давно присматривался к hakyll, поэтому решил остановиться на нём.

Hakyll

Hakyll — это скорее библиотека для построения статического сайта, чем утилита. Вместо декларативной конфигурации сайта в YAML, как это происходит в случае Jekyll, здесь ты сам описываешь процедуру построения сайта от начала и до конца при помощи DSL на базе Haskell. Это позволяет очень гибко настраивать различные аспекты процесса и добавлять новую функциональность, дописывая недостающие функции прямо в конфигурационном файле.

Стандартный вариант, генерируемый командой hakyll-init выглядит так:

--------------------------------------------------------------------------------
{-# LANGUAGE OverloadedStrings #-}
import           Data.Monoid (mappend)
import           Hakyll


--------------------------------------------------------------------------------
main :: IO ()
main = hakyll $ do
    match "images/*" $ do
        route   idRoute
        compile copyFileCompiler

    match "css/*" $ do
        route   idRoute
        compile compressCssCompiler

    match (fromList ["about.rst", "contact.markdown"]) $ do
        route   $ setExtension "html"
        compile $ pandocCompiler
            >>= loadAndApplyTemplate "templates/default.html" defaultContext
            >>= relativizeUrls

    match "posts/*" $ do
        route $ setExtension "html"
        compile $ pandocCompiler
            >>= loadAndApplyTemplate "templates/post.html"    postCtx
            >>= loadAndApplyTemplate "templates/default.html" postCtx
            >>= relativizeUrls

    create ["archive.html"] $ do
        route idRoute
        compile $ do
            posts <- recentFirst =<< loadAll "posts/*"
            let archiveCtx =
                    listField "posts" postCtx (return posts) `mappend`
                    constField "title" "Archives"            `mappend`
                    defaultContext

            makeItem ""
                >>= loadAndApplyTemplate "templates/archive.html" archiveCtx
                >>= loadAndApplyTemplate "templates/default.html" archiveCtx
                >>= relativizeUrls


    match "index.html" $ do
        route idRoute
        compile $ do
            posts <- recentFirst =<< loadAll "posts/*"
            let indexCtx =
                    listField "posts" postCtx (return posts) `mappend`
                    defaultContext

            getResourceBody
                >>= applyAsTemplate indexCtx
                >>= loadAndApplyTemplate "templates/default.html" indexCtx
                >>= relativizeUrls

    match "templates/*" $ compile templateBodyCompiler


--------------------------------------------------------------------------------
postCtx :: Context String
postCtx =
    dateField "date" "%B %e, %Y" `mappend`
    defaultContext

За генерацию html-страниц с постами отвечают вот эти строчки

match "posts/*" $ do
    route $ setExtension "html"
    compile $ pandocCompiler
        >>= loadAndApplyTemplate "templates/post.html"    postCtx
        >>= loadAndApplyTemplate "templates/default.html" postCtx
        >>= relativizeUrls

Как видно, hakyll использует pandoc для преобразования форматов. Но сгенерировать html из asciidoc он не в состоянии, поэтому пришлось написать свою функцию для компиляции. В составе hakyll присутствует функция unixFilter, которая позволяет запустить процесс и передать ему на стандартный поток ввода содержимое файла

htmlCompiler :: Compiler (Item String)
htmlCompiler = do
    getResourceString >>= withItemBody (unixFilter "asciidoctor-latex"
                                                  ["-b"
                                                  , "html5"
                                                  , "-s"
                                                  , "-a"
                                                  , "embedded"
                                                  , "-a"
                                                  , "skip-front-matter"
                                                  , "-"])

...

    match "posts/*" $ do
        route $ setExtension "html"
        compile $ htmlCompiler
            >>= loadAndApplyTemplate "templates/post.html"    postCtx
            >>= loadAndApplyTemplate "templates/default.html" postCtx
            >>= relativizeUrls

Здесь во втором аргументе unixFilter передаётся список параметров командной строки для asciidoctor-latex, а именно:

  • -b html5 указывает, что нужно конвертировать asciidoc в html5

  • -s не добавляет собственных asciidoctorовских хедера и футера, только конвертирует содержимое

  • -a embedded не печатает заголовок, это отдано на откуп шаблонам hakyll

  • -a skip-front-matter игнорирует front-matter с метаданными для hakyll в начале файла

  • - заставляет читать со стандартного потока ввода

Далее мне захотелось, чтобы материалы, относящиеся к посту (картинки, код) лежали в каталоге этого поста. В результате получилось следующее

htmlCompiler :: Compiler (Item String)
htmlCompiler = do
    path <- getResourceFilePath
    let (dir,_) = splitFileName path
    getResourceString >>= withItemBody (unixFilter "asciidoctor-latex"
                                                  ["-b"
                                                  , "html5"
                                                  , "-B"
                                                  , dir
                                                  , "-s"
                                                  , "-a"
                                                  , "embedded"
                                                  , "-a"
                                                  , "skip-front-matter"
                                                  , "-"])

...

    match "posts/*/index.adoc" $ do
        route $ setExtension "html"
        compile $ htmlCompiler
            >>= loadAndApplyTemplate "templates/post.html"    postCtx
            >>= loadAndApplyTemplate "templates/default.html" postCtx
            >>= relativizeUrls

    match "posts/**/*" $ do
        route   idRoute
        compile copyFileCompiler

Ну и чтобы избавиться от index.html в конце ссылок

removeIndexHtml :: Item String -> Compiler (Item String)
removeIndexHtml item = return $ fmap (withUrls removeIndexStr) item
    where
        removeIndexStr :: String -> String
        removeIndexStr url = case splitFileName url of
                                (dir, "index.html") | isLocal dir -> dir
                                _                                 -> url
        isLocal :: String -> Bool
        isLocal uri        = not (isInfixOf "://" uri)

...

    match "posts/*/index.adoc" $ do
        route $ setExtension "html"
        compile $ htmlCompiler
            >>= loadAndApplyTemplate "templates/post.html"    postCtx
            >>= loadAndApplyTemplate "templates/default.html" postCtx
            >>= relativizeUrls
            >>= removeIndexHtml

LaTeX

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

[env.environment]
--
Тут что-то по-латеховски
--

что при компиляции в latex превратится в

\begin{environment}
Тут что-то по-латеховски
\end{environment}

Так, для формул он предоставляет блок env.equation:

[env.equation]
--
\lim_{x \to 0} \frac{\sin x}{x} = 1
--

Для рендеринга формул в html я использую KaTeX, пока на стороне браузера с использованием js, но в планах довести до ума asciidoctor-latex, чтобы делать это во время построения сайта.

Тёмная тема

Для поддержки тёмной темы в вебе пришлось использовать svg для графиков. Для этого их приходится вставлять напрямую в html. В тоже время, этот подход не подойдёт для других форматов, например, для pdf. Там графику нужно вставить картинкой. Asciidoc позволяет решить эту проблему конструкциями ifdef и ifndef:

ifdef::backend-html5[]
++++
include::time_improved.svg[]
++++
endif::[]

ifndef::backend-html5[]
image::time_improved.png[]
endif::[]

В данном случае, при компиляции в html вставится содержимое файла time_improved.svg, а при любом другом формате вывода будет добавлено изображение time_improved.png.

Заключение

Основным недостатком данного способа является использование заброшенного сообществом asciidoctor-latex.