Hakyll mini-howto

Опубликовано 18 Января, 2015 под тегами Сайт, Haskell, Hakyll

Hakyll – генератор статических сайтов на основе великолепного pandoc. По сути, Hakyll представляет из себя библиотеку Haskell для написания подобных генераторов. Понятно, чтобы внятно им пользоваться, нужно хотя бы базовое знание Haskell или сильное желание разбираться.

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

Установка

В gentoo все элементарно просто: Hakyll, как и все его зависимости, могут быть найдены в оверлее gentoo-haskell (доступен в layman). О настройке layman лишний раз говорить не буду, интернет полнится. Тем не менее, если layman уже стоит и настроен, добавляется оверлей командой

layman -a haskell

Установить Hakyll соответственно можно командой

emerge -av hakyll

В случае прочих дистрибутивов, я настоятельно рекомендую пользоваться пакетным менеджером дистрибутива, если это возможно. В крайнем случае, можно использовать cabal install hakyll (при условии, что у Вас стоит Haskell Platform), но этот вариант череват проблемами в дальнейшем.

Первоначальная настройка

Первое, что можно сделать, установив Hakyll – это сгенерировать базовый шаблон для сайта. Делается это командой

hakyll-init <dirname>

Где <dirname> – это название директории, в которой будет размещена базовая структура Hakyll.

По умолчанию, создается cabal-файл проекта <dirname>.cabal, основной скрипт генератора под названием site.hs, несколько примеров статических страниц, а именно

  • about.rst
  • contact.markdown
  • index.html

несколько постов в директории posts/, состоящих практически целиком из lorem ipsum, базовые шаблоны в директории templates/ и основной css в директории css/.

Собственно, с этим уже можно поэкспериментировать. Общий алгоритм действий такой:

  1. Собираем site.hs командой ghc --make site.hs
  2. Запускаем ./site watch и получаем превью сайта на localhost:8000 (порт можно задать, передав опцию -p: ./site watch -p 8765)
  3. Редактируем файлы (в т.ч. добавляем посты в posts/, редактируем шаблоны, etc)
  4. Смотрим что получилось на http://localhost:8000 (или на том порту, что Вы указали)

Сразу оговорюсь, что если Вы изменили генератор, то его нужно пересобрать, а затем им пересобрать сайт:

  1. ghc --make site.hs
  2. ./site rebuild

Ну и до кучи, если Вы забыли запустить ./site watch перед редактированием файлов, всегда можно сделать ./site build. Однако у меня он не всегда находит измененные файлы и мне приходится делать ./site rebuild.

Сами файлы сайта помещаются в директорию _site.

Это все, конечно, здорово, но как добавить новую статическую страницу, или хотя бы писать about в формате Markdown, а не rST? Для этого придется лезть в код site.hs.

Правка генератора

В общем случае, site.hs представляет из себя исходник программы на языке Haskell, однако можно его воспринимать как набор правил для Hakyll.

В начале файла мы видим текст

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


--------------------------------------------------------------------------------
main :: IO ()
main = hakyll $ do

Его (за исключением добавления новых импортов) менять, скорее всего, не понадобится.

Далее мы можем наблюдать набор блоков вида

match <path> $ do
    <commands>

Это правила генерации. Здесь <path> представляет из себя какое-то указание на путь к файлам, которые должны быть обработаны генератором. Это может быть строка со звездочкой в качестве маски: так, например, правило match "posts/*" $ do будет выполняться для всех файлов в директории posts/. Это так же может быть список значений, так, например

match (fromList ["about.rst", "contact.markdown"]) $ do

будет выполняться для файлов about.rst и contact.markdown.

Кроме директивы match существует директива create, которая создает новый файл в структуре сайта.

Порядок следования директив не принципиален.

Теперь разберемся с действиями (то есть с <commands>). Первое действие в умолчальном site.hs – всегда route <...>. Эта команда задает относительный url для (будущей) сгенерированной страницы. route idRoute означает “такой же url, как путь к файлу в директории Hakyll”. route $ setExtension "..." делает почти то же самое, но еще меняет расширение файла на значение в кавычках. Так, например, если файл, переданный генератору, назывался posts/2012-01-01-lorem-ipsum.markdown, и имеется правило вида

match "posts/*" $ do
    route $ setExtension "html"

то в структуре сайта он будет размещен по адресу posts/2012-01-01-lorem-ipsum.html

Так же можно написать route $ customRoute ..., где ... – это функция, которая принимает идентификатор страницы и геренирует строку-url. Однако этот вариант для продвинутых пользователей.

Следующая команда обычно compile – она собственно призвана собрать конечную html-страницу из Ваших исходников.

… и вот дальше рассказывать на простом языке становится невозможно. Далее я полагаю, что читатель знаком с Haskell достаточно, чтобы примерно понимать, что такое монада.

compile принимает аргументом вычисление в монаде Compiler. “Стартовой точкой” для этих вычислений может служить одно из определений компиляторов, например

  • copyFileCompiler
  • compressCssCompiler
  • pandocCompiler
  • getResourceBody

и другие. В общем можно сказать, что любая функция, возвращающая что-то в монаде Compiler годится (в том числе return). Дальше эти вычисления можно передавать в другие функции, возвращающие “что-то в монаде Compiler” (или банально добавлять return) при помощи оператора >>=, который называется bind.

Основные функции компилятора, которые полезно знать, это

  • loadAndApplyTemplate <template> <context> – загружает шаблон и применяет его к результату работы предыдущей функции (в порядке следования >>=). Про контекст ниже.
  • applyAsTemplate <context> – применяет результат работы предыдущей функции как шаблон.
  • relativizeUrls – исправляет относительные URL’ы. В целом, эту функцию лучше всего применять и лучше всего последней.

Функции применения шаблонов принимают контекст. По сути контекст задает переменные шаблона. Задается он при помощи комбинирования функций полей шаблонов (и, возможно, других контекстов, в частности контекста по умолчанию) при помощи функции mappend. В Haskell любая функция, принимающая хотя бы два аргумента, может быть использована как бинарный оператор:

mappend a b == a `mappend` b

Поэтому контексты задаются, например, так:

postCtx =
    dateField "date" "%B %e, %Y"    `mappend`
    teaserField "teaser" "content"  `mappend`
    defaultContext

defaultContext – это контекст по-умолчанию, задающий переменные шаблона

  1. $body$ – тело страницы
  2. Метаданные
  3. $url$ – url страницы
  4. $path$ – путь к файлу-исходнику
  5. $title$ – заголовок страницы “по умолчанию” – базовое имя файла-исходника.

Поля, определенные ранее в списке имеют приоритет, т.е. если сперва объявить constField "title" "blah" и затем добавить defaultContext, шаблон увидит только $title$=blah. Подробнее о шаблонах в соответствующем разделе.

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

Шаблоны

Синтаксис шаблонов в Hakyll очень простой. Все переменные шаблона обозначаются символом $ с обеих сторон. То же самое касается директив шаблонизатора. Например, если есть переменная constField "foo" "bar" все вхождения $foo$ в шаблоне будут заменены на bar.

Если шаблонизатор находит переменную, которая не определена, он падает с ошибкой. Чтобы этого избежать, необходимо явно использовать директиву $if(var)$, например:

$if(foo)$
  $foo$
$else$
  bar
$endif$

С переменными типа listField можно работать при помощи директивы for. Допустим, что у нас есть переменная listField "foo" context items. Тогда

$for(foo)$
  $x$
$sep$,
$endfor$

Для каждого элемента из списка items будет искать переменную x в контексте context. Специальный оператор $sep$c вставит c между каждой парой значений (но не в конце) и может быть опущен.

Директива $partial(path)$ загружает шаблон из файла path и применяет его в рамках текущего контекста. Результат вставляется вместо директивы.

До сих пор в этом разделе я вольно пересказывал документацию. Один момент, в документации не освященный – это поля-функции.

functionField :: String -> ([String] -> Item a -> Compiler String) -> Context a

определяет поле-функцию, которое по сути является пользовательской директивой.

Первый параметр – название, второй – функция, принимающая список аргументов, текущий объект, над которым работает шаблонизатор и должна возвращать строку в монаде Compiler. Например, определен

let reverseF [] _ = return ""
    reverseF args item = return $ reverse $ head args
functionField "reverse" reverseF

Тогда использование в шаблоне $reverse("foobar")$ превратится в raboof.

Советую определить случай пустого списка args, чтобы можно было использовать $if(reverse)$, не задавая аргументы.

Параметр item содержит информацию об идентификаторе и о теле объекта. Идентификатор может быть получен как itemIdentifier item и далее преобразован в строку: toFilePath $ itemIdentifier item вернет путь к файлу-исходнику. Тело itemBody item – по сути содержимое переменной шаблона $body$.

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

    let navigationField = functionField "navigation" navigationLink
        navigationLink [] _ = return ""
        navigationLink args item = do
                  let filePath = head args
                      text = args !! 1
                      activeClass = args !! 2
                      identifier = fromFilePath filePath
                  Just argUrl <- getRoute identifier
                  let cls =
                        if identifier==itemIdentifier item then
                          activeClass
                        else
                          ""
                  return $
                      "<a href=\""++argUrl++"\""++
                      "class=\""++ cls ++"\"" ++
                      ">"++ text ++"</a>"

Теперь в шаблоне, допустим, конструкция $navigation("static/about.markdown","О сайте","active")$ будет заменена на <a href="static/about.html" class>О сайте</a>, если текущая страница отличается от static/about.html и на ... class="active" ..., если совпадает.

Так же в качестве параметров могут быть переданы переменные шаблона. Они передаются без $ и без кавычек, так же, как в директивы $if()$ и $for()$.

Метадата

Метадата определяется в шаблоне в формате, похожем на YAML в начале файла. Первая строчка метадаты должна иметь вид ---, последняя – ... или ---. Внутри метадаты строчка начинается с названия переменной, затем идет двоеточие и значение переменной без кавычек. Списки разделяются запятой.

Например, метадата этого поста выглядит так:

---
author: Livid
title: Hakyll mini-howto
tags: Сайт, Haskell, Hakyll
---

Немного о постах

Имя файла поста имеет специфический формат: YYYY-MM-DD-..., где YYYY – год, MM – номер месяца, DD – число. Делается это для того, чтобы dateField мог распарсить дату из имени файла. Альтернативный вариант – поле published в метадате. Подробнее можно посмотреть здесь

Развертывание

У собираемого генератора есть такая специальная команда ./site deploy, которая в общем случае призвана разворачивать сайт на хостинг. Фактически, она просто выполняет некую команду в директории сайта, однако это достаточно удобный метод.

Команду нужно задать в конфиге Hakyll. Сделать это можно, например, так:

<...snip...>
main :: IO ()
main = hakyllWith config $ do
<...snip...>
config :: Configuration
config = defaultConfiguration
  {   deployCommand = "rsync -avz -e ssh ./_site/ solar:/var/www/livid.pp.ru/hakyll"}

Не мудрствуя лукаво, предлагаю использовать, например, rsync via ssh, как в конфиге выше. Другие варианты, ясно, возможны.

Заключение

Эта заметка уже вышла за собственные рамки, и потому не претендует на полноту. API генератора сам по себе достаточно объемен, чтобы его хватило на целый цикл подобных заметок.

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

Ссылки по теме