В этом посте я рассказываю о том как написал свой небольшой Emacs-плагин упрощающий работу с блогом на статическом движке.
С этим плагином возможно писать и публиковать посты для блога в OrgMode несмотря на то, что практически все распространённые генераторы статических сайтов поддерживают в основном языки разметки Markdown, reStructuredText или HTML🌚.
В плане блоггинга я успел перепробовать много всего:
- Писал в LiveJournal
- Сэлфхостил блог на Wordpress с «традиционным» LAMP-стэком
- Вёл блог на Blogger.com
- Снова сэлфхостил свой блог, собираемый при помощи генератора статических сайтов Pelican, с добавлением комментариев на Disqus
- Пробовал pencil
- и так далее…
В конечном итоге, необходимость администрирования и обновления Apache/Nginx, MySQL/PostgreSQL, php-fpm и т.д. и т.п. — изрядно мне надоела. Как и всякие куцые WYSIWYG-редакторы и ненужные «обновления» блоггинг-платформ. Тут же как раз подоспел всякий «virtue signaling», из-за которого я лишился своего VPS в Финляндии (он обеспечивал мне доступ в «большой интернет» — раньше, в 90-х и нулевых была такая глобальная сеть, при помощи которой можно было свободно общаться с людьми по всему миру и без границ, блокировок по GeoIP и DPI). А также почти лишился доменного имени.
Примерно в этот момент я пришёл к использованию Jekyll в качестве статического генератора для своего блога. Его копия, со всеми HTML и CSS файлами, хранится у меня на машине и не зависит от сторонних CDN, библиотек и всего прочего, к чему я могу потерять доступ в любой момент. Пришлось «немного» подправить тему Yat, чтобы там не было ни JS (чем проще, тем лучше), ни подгрузки ресурсов со сторонних CDN. При локальном использовании, единственная оставшаяся зависимость — от самого Jekyll и от его плагинов. Но они и так уже лежат на жёстком диске и работают без подключения к Интернету — а с жёсткого диска они никуда не пропадут (если помнить о бэкапах).
Тогда же я задумался о том, как бы организовать свои статьи для блога так, чтобы:
- Тексты постов были не сильно привязаны к генератору статических сайтов и я мог без особой боли сменить один генератор на другой, если понадобится.
- Блог как можно меньше зависел от чужой инфраструктуры. Единственная зависимость, от которой я пока что не могу уйти — это зависимость от регистратора доменных имён (остаётся только избегать регистраторов, замеченных в дискриминации пользователей по гражданству или по месту жительства, а также тех, что работают в моей местной юрисдикции). В качестве хостинга для статического сайта подойдёт же любая микроволновка в правильном месте — в таком, где не занимаются разрушением связности глобальной сети.
- Можно было редактировать посты удобным мне методом — т.е. в Emacs, при помощи Org Mode. А не с Markdown или же вообще в WYSIWYG веб-редакторе.
- Я мог сам выбрать для себя структуру каталогов с исходниками статей, а не пользоваться той, что навязана разработчиками генератора статических сайтов.
Если п. 2 нельзя решить, написав немного кода на Emacs Lisp, то остальные проблемы вполне себе решаемы подобным образом.
Содержание
- Первая версия генератора блога
- Генерация Jekyll-блога c помощью Emacs Lisp
- Преобразование org-файлов в HTML
- Копирование файлов в промежуточный каталог
- Редактирование HTML файлов
- Экспорт статических файлов
- Вызов Jekyll из Emacs
- Создание нового поста
- Запуск локального сервера Jekyll
- Очистка рабочего каталога Jekyll
- Интерфейс пользователя (transient)
- Оформление в виде плагина
- Загрузка плагина в Emacs
- Исходный код плагина
- Что ещё можно улучшить?
Первая версия генератора блога
Достаточно быстро я сделал первую версию конвертера OrgMode⇒Jekyll на основе bash, sed, pandoc и самописного Java-фильтра для pandoc — и использовал её почти год.
Все посты, которые обрабатывал этот конвертер, были размещены в каталоге articles/
, каждый в своём подкаталоге:
rsync/blog (master) % tree --noreport articles
articles/
├── arms/
├── cycling/
│ ├── 2020-05-17-thanks-for-living/
│ │ ├── article-ru.org
│ │ ├── hate of car drivers.jpg
│ │ ├── kamennoostrovskii.jpg
│ │ ├── trollface.jpg
│ │ ├── truck.gif
│ │ └── ushakovski most.jpg
│ ├── 2021-04-08-vk-cyclist-types/
│ │ ├── article-ru.org
│ │ ├── hate of car drivers.jpg
│ │ ├── usual-seat-as-urbanist-thinks.jpg
│ │ └── usual-seat.jpg
├── it/
│ ├── 2020-09-09-thinkpad-x220-freebsd/
│ │ ├── article-en.org
│ │ ├── article-ru.org
│ │ └── freebsd_intel_glitches.jpg
│ ├── 2023-12-20-plain-text-accounting/
│ ├── 2024-01-02-life-in-console/
│ ├── 2024-07-07-thinkpad-x220-second-life/
│ ├── 2024-10-27-freebsd-bhyve-windows/
│ ├── 2024-11-09-emacs-plugin-jekyll-blog/
│ ├── draft-palm-tung-e2-archaeological/
│ │ ├── 20231223_141710.jpg
│ │ ├── 20231223_142550.jpg
│ │ ├── 20231230_200500.jpg
│ │ ├── 20231231_144949.jpg
│ │ ├── 20231231_205901.jpg
│ │ ├── 20240101_162620.jpg
│ │ ├── 20240101_215815.jpg
│ │ ├── 20240101_215908.jpg
│ │ ├── article-ru.org
├── leatherwork/
│ └── 2021-01-29-leatherwork-useful-links/
│ └── article-ru.org
└── photo/
Эту древовидную структуру я использую до сих пор. Она позволяет иметь перед глазами все, относящиеся к конкретному посту, файлы. К тому же, я могу открыть org-файл с постом в Emacs и сразу же увижу его практически в том же виде, в каком он попадёт в блог:
В каталоге с блогом я создал специальный Makefile
, который запускал не менее специальный bash-скрипт. Этот скрипт сканировал каталог articles/
и помещал найденные файлы с текстами постов в следующий конвейер:
Посмотреть на использовавшийся код можно вот в этом коммите, в файле README.org
. Код для Java-фильтра для pandoc лежит в отдельном репозитории.
Очевидно, что всё это было переусложнено. Гораздо удобнее было бы, если бы итоговый HTML-файл генерировался напрямую из OrgMode, без всяких дополнительных преобразований. Тем более, что в OrgMode уже есть функции для экспорта файлов в различные форматы.
И тут в Mastodon мне попалась на глаза статья Christian Dewein: Publishing on the web with Jekyll, Emacs and Org-Mode…
Генерация Jekyll-блога c помощью Emacs Lisp
Как оказалось, весь мой конвейер из sed
+ awk
+ pandoc
+ Java-фильтр можно спокойно выкинуть и заменить на вызов функции org-publish-project
. Org Mode сам может экспортировать org-файлы в HTML-файлы, сразу готовые для использования в Jekyll, без дополнительной конвертации Markdown⇒HTML.
У меня уже был некоторый опыт программирования на Lisp, а точнее на Clojure, поэтому я спокойно взялся писать свой плагин, по мотивам кода от Christian Dewein. Программировать на Emacs Lisp в Emacs одно удовольствие — тут тебе и встроенная справка по языку через C-h f
, C-h v
и так далее. И встроенный REPL (M-x ielm
). И встроенный отладчик. Можно спокойно играться с S-expressions, сразу же проверяя как исполняются куски кода в REPL и строить программу «по кирпичикам».
Преобразование org-файлов в HTML
Вышеупомянутая функция org-publish-project
умеет брать файлы из одного каталога, конвертировать их в нужный формат и сохранять в другой каталог. Что, куда и как экспортировать настраивается внутри специального списка с именем org-publish-project-alist
, каждый элемент которого — отдельный параметр для тонкой настройки процесса экспорта.
Код, который умеет брать org-файлы каталога ~/test
, перегонять их в HTML для Jekyll и сохранять в ~/results
, будет выглядеть примерно вот так:
(let ((org-publish-project-alist `(("org-jekyll-org"
:base-directory "~/test"
:base-extension "org"
:publishing-directory "~/results"
:publishing-function org-html-publish-to-html
:html-extension "html"
:headline-levels 5
:html-toplevel-hlevel 2
:html-html5-fancy t
:html-table-attributes (:border "2" :cellspacing "0" :cellpadding "6" :frame "void")
:section-numbers nil
:html-inline-images t
:htmlized-source t
:with-toc nil
:with-sub-superscript nil
:body-only t
:recursive t))))
(org-publish-project "org-jekyll-org" t nil))
Из важных параметров здесь есть:
:base-directory
— путь к каталогу, откуда будут браться файлы для экспорта.:base-extension
— какие расширения должны быть у файлов для экспорта.:publishing-directory
— путь к каталогу, куда будут помещаться HTML-файлы после экспорта.
Остальные параметры содержат разные тонкие настройки для конвертации в HTML, с которыми мои посты в блоге выглядят так, как я хочу.
Неплохо бы иметь возможность настраивать имена каталогов, чтобы не копаться каждый раз в исходном коде, меняя строковые константы. Для этого в Emacs Lisp есть функция defcustom
. Она позволяет описать настройки для плагина так, чтобы их можно было менять общепринятыми способами — через M-x customize
или через секцию :custom
в use-package
:
Пути к нужным каталогам я описал через defcustom
следующим образом:
(defgroup org-jekyll ()
"Emacs mode to write on OrgMode for Jekyll blog."
:group 'local
:prefix "org-jekyll-"
:link '(url-link :tag "Source code" "https://github.com/eugeneandrienko/eugeneandrienko.github.io"))
(defgroup org-jekyll-paths nil
"Paths for emacs mode to write on OrgMode for Jekyll blog."
:group 'org-jekyll
:prefix "org-jekyll-paths-")
(defcustom org-jekyll-paths-base-path
"~/rsync/blog"
"Path to the base directory of my blog."
:type 'directory
:group 'org-jekyll-paths)
(defcustom org-jekyll-paths-articles-path
(concat org-jekyll-paths-base-path "/articles")
"Path to directory with original articles in Org format."
:type 'directory
:group 'org-jekyll-paths)
Здесь, первая S-expression описывает новый пункт меню в настройках Emacs, вторая создаёт внутри него подпункт, внутри которого будет две настройки — с путём к каталогу со всеми файлами для блога и с путём к каталогу со статьями.
В итоге, параметры в вышеприведённом вызове org-publish-project
можно переделать вот так:
(let ((org-publish-project-alist `(("org-jekyll-org"
:base-directory ,org-jekyll-paths-articles-path
:base-extension "org"
:publishing-directory ,(concat org-jekyll-paths-base-path "/_posts")
:publishing-function org-html-publish-to-html
Здесь, прямо внутри определения списка с настройками есть исполняемый код, который формирует пути к нужным каталогам. Чтобы всё это работало — приходится описывать список немного иначе, чем через привычную нотацию '(1 2 3)
.
С одной стороны нам не нужно, чтобы все S-expressions внутри этого списка исполнялись — ведь "org-jekyll-org"
не имя функции, а имя OrgMode проекта для публикации. Для этого можно было бы использовать привычный синтаксис вида '("a" "b" "c")
.
> ("a" "b" "c")
*** Eval error *** Invalid function: "a"
> '("a" "b" "c")
("a" "b" "c")
Но с другой стороны нам нужно, чтобы отдельные S-expressions — тот же concat
— всё же исполнялись. В нижеприведённом примере видно, что этого не происходит — конструкция (concat "b" "2")
воспринимается просто как отдельный элемент списка и вместо неё не подставляется строка "b2"
:
> '("a" (concat "b" "2") "c")
("a"
(concat "b" "2")
"c")
Чтобы определить список, в котором отдельные элементы являются исполняемым кодом, нужно использовать обратную кавычку, вместо обычной (https://www.gnu.org/software/emacs/manual/html_node/elisp/Backquote.html). Элементы, которые будут исполняемыми S-expressions, отмечаются при помощи запятой:
> `("a" ,(concat "b" "2") "c")
("a" "b2" "c")
В идеальном случае вышеприведённого вызова org-publish-project
достаточно для превращения org-файлов в HTML. Но мой случай не идеальный — у меня org-файлы не лежат все скопом в одном каталоге, а каждый в своём отдельном подкаталоге!
Значит, перед вызовом org-publish-project
нужно вызывать свою самописную функцию, которая скопирует org-файлы с постами в промежуточный каталог, откуда их и возьмёт org-publish-project
. Для вызова пользовательской функции перед началом публикации есть параметр :preparation-function
, с которым наш код начинает выглядеть вот так:
(let ((org-publish-project-alist `(("org-jekyll-org"
:base-directory ,(concat org-jekyll-paths-base-path "/_articles")
:base-extension "org"
:publishing-directory ,(concat org-jekyll-paths-base-path "/_posts")
:preparation-function org-jekyll--prepare-articles
Как видно, тут в качестве каталога с org-файлами для org-publish-project
уже указан промежуточный каталог _articles/
.
Копирование файлов в промежуточный каталог
Сначала нужно получить список org-файлов с постами, которые есть в каталоге articles/
. Его нам может вернуть функция directory-files-recursively
, если ей передать путь к каталогу и регулярку, которой будут выбираться только org-файлы:
(directory-files-recursively org-jekyll-paths-articles-path "\\.org$" nil nil nil)
("~/rsync/blog/articles/cycling/2020-05-17-thanks-for-living/article-ru.org"
"~/rsync/blog/articles/cycling/2021-04-08-vk-cyclist-types/article-ru.org"
"~/rsync/blog/articles/cycling/2021-04-12-balticstar-north-open-2021/article-ru.org"
"~/rsync/blog/articles/cycling/2021-05-17-insled-open/article-ru.org"
"~/rsync/blog/articles/cycling/draft-osmand-howto/article-ru.org"
"~/rsync/blog/articles/cycling/draft-qmapshack-howto/article-ru.org"
...
"~/rsync/blog/articles/_post_template.org")
Как видно, в результате есть и черновики, которые не нужно экспортировать в HTML, и файл с шаблоном для новых постов. Эти лишние файлы можно отфильтровать при помощи seq-filter
— он умеет убирать из списка (передаётся вторым параметром) элементы не проходящие проверку в предикате из первого параметра:
(seq-filter (lambda (path)
(and
(not (string-match org-jekyll-exclude-regex path))
(not (string-match "\\(draft-\\)\\|\\(hidden-\\)" path))))
(directory-files-recursively org-jekyll-paths-articles-path "\\.org$" nil nil nil))
Предикат — обычная лямбда-функция, которая проверяет, что путь из списка не является путём к файлу с шаблоном _post_template.org
и не содержит в себе каталогов, начинающихся с draft
или hidden
.
Здесь org-jekyll-exclude-regex
— ещё одна переменная, с регулярным выражением, по которому будут отбрасываться неподходящие пути к org-файлам:
(defcustom org-jekyll-exclude-regex
"\\(_post_template\\.org\\)\\|\\(\\.project\\)"
"Regex to exclude unwanted files."
:type 'regexp
:group 'org-jekyll)
Теперь, когда у нас есть правильный список путей к файлам, надо каждый его элемент передать в функцию для копирования файлов. Это делается при помощи mapc
, которая применяет лямбда-функцию из первого параметра к каждому элементу списка, переданному вторым параметром:
(mapc (lambda (article)
(
;; copy file in `article' path here
)
(seq-filter (lambda (path)
(and
(not (string-match org-jekyll-exclude-regex path))
(not (string-match "\\(draft-\\)\\|\\(hidden-\\)" path))))
(directory-files-recursively org-jekyll-paths-articles-path "\\.org$" nil nil nil))
Элементы пути из переменной article
: дата, URL и код языка — я
использую, для того чтобы получить уникальное имя файла для промежуточного каталога. Чтобы вытащить всё что надо из исходного пути к файлу — есть регулярки с capturing groups. В Emacs для этого можно использовать функции string-match
и match-string
:
(string-match
(concat org-jekyll-paths-articles-path
"/\\(\\w+\\)/\\([0-9-]+\\)-\\([[:alnum:]-]+\\)/article-\\([[:lower:]]\\{2\\}\\)\\.org$")
"~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org")
0 (#o0, #x0, ?\C-@)
(match-string 1 "~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org")
"photo"
(match-string 2 "~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org")
"2024-09-01"
(match-string 3 "~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org")
"summer-photos-2024"
(match-string 4 "~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org")
"en"
В коде лямбды я заворачиваю всё это в let*
, чтобы впоследствии
просто обращаться к соответствующим переменным:
(lambda (article)
(progn
(string-match
(concat org-jekyll-paths-articles-path
"/\\(\\w+\\)/\\([0-9-]+\\)-\\([[:alnum:]-]+\\)/article-\\([[:lower:]]\\{2\\}\\)\\.org$")
article)
(let*
((article-category (match-string 1 article))
(article-date (match-string 2 article))
(article-slug (match-string 3 article))
(article-lang (match-string 4 article)))
(
;copy-file-here
)))
Для удобства, добавим сюда ещё пару переменных:
- Переменную с именем промежуточного каталога: путь к
_articles/
+article-lang
. Путь к каталогу_articles/
можно вытащить из настроек проекта"org-jekyll-org"
— список с этими настройками передаётся в виде единственного параметра в функциюorg-jekyll--prepare-articles
и по имени параметра (:base-directory
) можно получить нужное значение:
(article-new-catalog (concat
(plist-get property-list ':base-directory)
"/"
article-lang))
- Переменную с уникальным путём к файлу со статьёй в промежуточном каталоге:
(article-processed (concat article-new-catalog "/" article-date "-" article-slug ".org"))
В итоге, если к нам в переменной article
пришёл путь
~/rsync/blog/articles/photo/2024-09-01-summer-photos-2024/article-en.org
, то в переменной article-processed
будет новый путь:
~/rsync/blog/_articles/en/2024-09-01-summer-photos-2024.org
.
Теперь, создание нового каталога (на всякий случай, если его нет) и копирование файла делается вызовом пары функций в теле let*
:
(make-directory article-new-catalog t)
(copy-file article article-processed t t t t)
Итоговая функция org-jekyll--prepare-articles
выглядит так:
(defun org-jekyll--prepare-articles (property-list)
"Copy articles to `_articles/' catalog before publishing. Rename
article file from `article-LANG.org' to
`YYYY-MM-DD-short-url.org'.
PROPERTY-LIST is a list of properties from
`org-publish-project-alist'."
(mapc (lambda (article)
(progn
(string-match
(concat org-jekyll-paths-articles-path
"/\\(\\w+\\)/\\([0-9-]+\\)-\\([[:alnum:]-]+\\)/article-\\([[:lower:]]\\{2\\}\\)\\.org$")
article)
(let*
((article-category (match-string 1 article))
(article-date (match-string 2 article))
(article-slug (match-string 3 article))
(article-lang (match-string 4 article))
(article-new-catalog (concat
(plist-get property-list ':base-directory)
"/"
article-lang))
(article-processed (concat article-new-catalog "/" article-date "-" article-slug ".org")))
(make-directory article-new-catalog t)
(copy-file article article-processed t t t t))))
(seq-filter (lambda (path)
(and
(not (string-match org-jekyll-exclude-regex path))
(not (string-match "\\(draft-\\)\\|\\(hidden-\\)" path))))
(directory-files-recursively org-jekyll-paths-articles-path "\\.org$" nil nil nil))))
Эта функция отлично работает в связке с org-publish-project
. Но есть один нюанс — в итоговом HTML файле оказываются битые ссылки на картинки к посту. Поскольку в исходном org-файле указаны пути к картинкам относительно каталога с этим файлом — эти пути попадают в таком же виде в HTML.
Но в Jekyll такие статические файлы лежат по пути /assets/static
. Решение тут простое — после вызова copy-file
поменять пути в скопированном временном файле. Для этого я написал просто ещё одну функцию:
(defun org-jekyll--prepare-article (article)
"Prepare article's text for Jekyll.
Modify OrgMode file before publish it. ARTICLE is a path to
OrgMode file with article. Files, stored in `_articles/' will be
modified, not original articles from `org-jekyll-paths-articles-path'
path.
ARTICLE is a path to intermediate org-file with article text"
(with-temp-buffer
(insert-file-contents article)
(goto-char (point-min))
(while (search-forward "[file:" nil t)
(replace-match "[file://assets/static/" t t))
(write-file article)))
Всё, что она делает — ищет в org-файле по пути из переменной article
включения статических файлов вида [file:somefile.ext]
и меняет их на [file://assets/static/somefile.ext]
.
Редактирование HTML файлов
К сожалению, org-publish-project
вставляет в HTML-файл вещи, которые я там не хочу видеть:
- Рандомно сгенерированные ID из HTML-тэгов
- Нумерацию изображений
- Тэг
:TOC_2_blog:
после заголовка для содержания. Этот тег нужен, чтобы расширение toc-org автоматически генерировало содержание для поста при каждом сохранении файла.
Мои настройки для плагина toc-org, с которыми он начинает понимать тег :TOC_2_blog:
и генерирует ссылки на разделы, правильно обрабатываемые при экспорте в HTML:
(use-package toc-org
:pin melpa
:hook (org-mode . toc-org-mode)
:config
(defun toc-org-hrefify-blog (str &optional hash)
(concat "* " (toc-org-format-visible-link str))))
- Лишний заголовок для примечаний, причём не на языке поста.
Решение этой проблемы примерно такое же, как и в случае с правкой путей к статическим файлам — нужна ещё одна функция, которая будет удалять всё лишнее из HTML при помощи регулярок. В настройках org-publish-project
можно указать эту функцию в параметре :completion-function
, чтобы она вызывалась после
экспорта в HTML.
Сама функция достаточно простая. Сначала получаем путь к каталогу с HTML файлами из настроек org-publish-project
и получаем список путей к этим файлам, который передаётся в лямбду:
(defun org-jekyll--complete-articles (property-list)
"Change published html-files via regular expressions.
Fix links to attached files. Remove \"Footnotes:\" section from
generated file. Remove autogenerated Org ids from html tags.
PROPERTY-LIST is a list of properties from
`org-publish-project-alist'."
(let*
((publishing-directory (plist-get property-list ':publishing-directory)))
(mapc (lambda (html)
; process `html' file
)
(directory-files-recursively publishing-directory "\\.html$" nil nil nil))))
Внутри лямбды есть ещё один вызов mapc
, который работает со списком регулярок:
(mapc (lambda (x)
(progn
(goto-char (point-min))
(while (re-search-forward (car x) nil t)
(replace-match (cdr x) t nil))))
'(("/" . "/")
("<p><span class=\"figure-number\">[[:alnum:] :]+</span>\\(.+\\)</p>" . "<p style=\"text-align: center\"><i>\\1</i></p>")
("<h2 class=\"footnotes\">Footnotes: </h2>" . "")
(" id=\"org[[:xdigit:]]\\{7\\}\"" . "")
(" id=\"outline-container-org[[:xdigit:]]\\{7\\}\"" . "")
(" id=\"text-org[[:xdigit:]]\\{7\\}\"" . "")
("<span class=\"TOC_2_blog\">TOC_2_blog</span>" . "")))
Здесь каждый элемент списка — ещё один список из двух элементов. Первый элемент — регулярка, по которой ищется текст для замены. Второй элемент — текст, на который надо заменить найденное. Обращения к этим элементам в коде происходят при помощи (car x)
и (cdr x)
соответственно. Замена текста производится стандартными для Emacs функциями для работы с регулярными выражениями через временные буферы.
Итоговый код org-jekyll--complete-articles
выглядит следующим образом:
(defun org-jekyll--complete-articles (property-list)
"Change published html-files via regular expressions.
Fix links to attached files. Remove \"Footnotes:\" section from
generated file. Remove autogenerated Org ids from html tags.
PROPERTY-LIST is a list of properties from
`org-publish-project-alist'."
(let*
((publishing-directory (plist-get property-list ':publishing-directory)))
(mapc (lambda (html)
(with-temp-buffer
(insert-file-contents html)
(mapc (lambda (x)
(progn
(goto-char (point-min))
(while (re-search-forward (car x) nil t)
(replace-match (cdr x) t nil))))
'(("/" . "/")
("<p><span class=\"figure-number\">[[:alnum:] :]+</span>\\(.+\\)</p>" . "<p style=\"text-align: center\"><i>\\1</i></p>")
("<h2 class=\"footnotes\">Footnotes: </h2>" . "")
(" id=\"org[[:xdigit:]]\\{7\\}\"" . "")
(" id=\"outline-container-org[[:xdigit:]]\\{7\\}\"" . "")
(" id=\"text-org[[:xdigit:]]\\{7\\}\"" . "")
("<span class=\"TOC_2_blog\">TOC_2_blog</span>" . "")))
(write-file html)))
(directory-files-recursively publishing-directory "\\.html$" nil nil nil))))
Экспорт статических файлов
Понятное дело, что одних лишь HTML-файлов для блога недостаточно. Нужны ещё изображения и прочие файлы.
Их можно скопировать при помощи всё той же org-publish-project
, причём настройки для этого будут гораздо проще:
(let ((org-publish-project-alist `(("org-jekyll-static"
:base-directory ,(concat org-jekyll-paths-base-path "/_static")
:base-extension "jpg\\|JPG\\|jpeg\\|png\\|gif\\|webm\\|webp\\|gpx\\|tar.bz2\\|uxf"
:publishing-directory ,(concat org-jekyll-paths-base-path "/assets/static")
:publishing-function org-publish-attachment
:preparation-function org-jekyll--prepare-static
:exclude ,org-jekyll-exclude-regex
:recursive t)))))
Здесь, в :base-extension
указаны расширения для файлов, которые будут экспортированы в каталог :publishing-directory
.
Экспорт HTML файлов и копирование статических файлов можно объединить в одном «проекте», чтобы выполнять все нужные действия с файлами за один вызов функции:
(let ((org-publish-project-alist `(("org-jekyll-org"
...)
("org-jekyll-static"
...)
("org-jekyll" :components ("org-jekyll-org" "org-jekyll-static")))))
(org-publish-project "org-jekyll" t nil))
Как видно из кода, при копировании статических файлов используется ещё одна :preparation-function
— org-jekyll--prepare-static
. Она делает примерно то же, что и org-jekyll--prepare-articles
— копирует статические файлы из множества подкаталогов с постами для блога в один временный каталог, откуда их сможет
взять org-jekyll-project
. Работает эта функция примерно так же —
directory-files-recursively
пробегается по каталогу /articles
и копирует в каталог /_static
все файлы, кроме org-файлов с текстами статей:
(defun org-jekyll--prepare-static (property-list)
"Copy static files to `/_static' directory.
PROPERTY-LIST is a list of properties from
`org-publish-project-alist'."
(let
((static-directory (plist-get property-list `:base-directory)))
(make-directory static-directory t)
(mapc (lambda (filename)
(progn
(string-match (concat org-jekyll-paths-articles-path "/[[:alnum:]-/]+/\\([[:alnum:][:blank:]-_.]+\\)$") filename)
(let
((static-filename (match-string 1 filename)))
(copy-file filename (concat static-directory "/" static-filename) t t t t))))
(seq-filter (lambda (path)
(not (string-match
(concat org-jekyll-exclude-regex "\\|\\(article-[[:lower:]]+\\.org\\)")
path)))
(directory-files-recursively org-jekyll-paths-articles-path "." nil nil nil)))))
Вызов Jekyll из Emacs
После того как у нас появились готовые HTML-файлы и все прочие статические файлы, лежащие в нужных местах — нужно вызвать Jekyll, чтобы он собрал мой статический блог внутри каталога _site/
. Для этого используется консольная команда bundle exec jekyll build
.
В статье Christian Dewein для вызова консольной команды используется плагин Prodigy. Я счёл этот подход переусложнённым и просто запускаю отдельный процесс при помощи функции make-process
:
(make-process
:name "jekyll-build"
:buffer "jekyll-build"
:command '("bundle" "exec" "jekyll" "build")
:delete-exited-processes t
:sentinel (lambda (process state)
(cond
((and (eq (process-status process) 'exit)
(zerop (process-exit-status process)))
(message "%s" (propertize "Blog built" 'face '(:foreground "blue"))))
((eq (process-status process) 'run)
(accept-process-output process))
(t (error (concat "Jekyll Build: " state))))))
Здесь, помимо банального вызова нужной команды внутри отдельного процесса, обрабатывается её вывод через лямбду, которая либо выводит сообщение об успехе, либо печатает ошибку.
Сообщения для пользователя печатаются в minibuffer при помощи функции message
, текстом синего текста (настраивается при помощи propertize
):
Вывод запущенного процесса отправляется в буфер jekyll-build
, который используется потом для просмотра лога сборки.
Экспорт файлов и запуск bundle exec
объединяются в функции
org-jekyll--suffix-build
, чтобы за один вызов превращать org-файлы в готовый статический сайт:
(defun org-jekyll--suffix-build ()
"Build the blog."
(interactive)
(cd (expand-file-name org-jekyll-paths-base-path))
(let ((org-publish-project-alist `(("org-jekyll-org"
...)
("org-jekyll-static"
...)
("org-jekyll" :components ("org-jekyll-org" "org-jekyll-static"))))
(current-path (file-name-directory buffer-file-name)))
(cd (expand-file-name org-jekyll-paths-base-path))
(org-publish-project "org-jekyll" t nil))
(make-process
:name "jekyll-build"
:buffer "jekyll-build"
:command '("bundle" "exec" "jekyll" "build")
:delete-exited-processes t
:sentinel (lambda (process state)
...))
(cd current-path))
Поскольку при редактировании поста мы находимся в каталоге с постом, а сборка блога будет работать только в каталоге из переменной org-jekyll-paths-base-path
— мы сначала переходим в нужный каталог и только потом начинаем сборку. После того как все нужные команды были вызваны — возвращаемся в каталог с постом (сохранён в переменной current-path
), чтобы можно было спокойно продолжать работать с его файлами.
Создание нового поста
Добившись успешной сборки своего статического сайта, мне захотелось иметь отдельную функцию, чтобы полуавтоматически создавать новый пост — не создавая вручную новый подкаталог для него и не копируя каждый раз front matter для Jekyll в org-файл. Будет удобно, если Emacs сам спросит у меня всё необходимое, подготовит структуру файлов и каталогов, а затем сам откроет буфер с уже готовым исходником нового поста.
Для получения ввода от пользователя в Emacs есть множество функций, но для плагина достаточно четырёх самых простых:
read-string
: выводит вспомогательный текст в минибуфере и возвращает строку, введённую пользователем.completing-read
: выводит меню в минибуфере и возвращает строку с выбранным пунктом меню. Элементы меню передаются вторым параметром. Третий параметр, если он неnil
, включает режим строгого совпадения ввода пользователя с одним из пунктов меню.y-or-n-p
: выводит текст в минибуфере и ждёт ответа «Да» или «Нет» от пользователя. Возвращаетt
илиnil
.read-file-name
: выводит меню выбора файла в минибуфере и возвращает путь к выбранному файлу.
Достаточно быстро я набросал следующую конструкцию, которая спрашивает всё необходимое у пользователя и сохраняет результаты в отдельных переменных:
(let* ((category (completing-read "Enter category: "
(seq-filter
(lambda (category) (string-match "^[[:lower:]]+$" category))
(directory-files org-jekyll-paths-articles-path nil
directory-files-no-dot-files-regexp
nil nil))
nil t))
(name (read-string "Enter title: "))
(summary (read-string "Enter summary: "))
(tags (read-string "Enter tags (space separated): "))
(permalink (read-string "Enter permalink: "))
(language (completing-read "Enter post language: " org-jekyll-languages nil t))
(use-banner (y-or-n-p "Use banner?"))
(banner (if use-banner
(read-file-name "Path to banner image: " nil nil t nil nil)
nil))))
Внутри этого же let*
сразу же вычисляются:
- Часть front matter для вставки заглавного изображения в блог:
(additional (concat (if use-banner
(concat "image: /assets/static/" (file-name-nondirectory banner) "\n"
"banner:\n"
" image: /assets/static/" (file-name-nondirectory banner) "\n"
" opacity: 0.6\n")
"")
(concat "summary: " summary "\n")
(concat "tags: " tags)))
- Путь к новому посту:
(dirname (concat path "/" category "/" date "-" permalink))
- Имя файла с постом — к
article
добавляется введённый language code:
(filename (concat dirname "/" "article-" language ".org"))
После вычисления всех переменных, в теле let*
выполняется основная работа:
- Создаётся подкаталог с ранее вычисленным именем:
(make-directory dirname t)
- Если для поста используется баннер, то в этот каталог копируется соответствующее изображение:
(if use-banner
(copy-file banner (concat dirname "/" (file-name-nondirectory banner))))
- Берётся шаблон для поста по пути из переменной
org-jekyll-paths-template-path
и открывается во временном буфере для замены placeholder'ов реальными значениями. Потом этот буфер сохраняется как файл с именем изfilename
, по пути изdirname
:
(with-temp-buffer
(insert-file-contents template)
(mapc
(lambda (x) (progn
(goto-char (point-min))
(while (search-forward (car x) nil t)
(replace-match (cdr x) t t))))
`(("{%NAME%}" . ,name)
("{%CATEGORY%}" . ,category)
("{%DATE%}" . ,date)
("{%LANG%}" . ,language)
("{%ADDITIONAL%}" . ,additional)))
(write-file filename))
- Сгенерированный файл открывается в текущем буфере с курсором в конце файла, чтобы сразу начать писать текст:
(with-current-buffer (find-file filename)
(goto-char (point-max)))
Шаблон поста, который я использую, лежит по пути из переменной org-jekyll-paths-template-path
(внутри функции он скопирован в локальную переменную template
для удобства):
(defcustom org-jekyll-paths-template-path
(concat org-jekyll-paths-articles-path "/_post_template.org")
"Path to post template."
:type '(file :must-match t)
:group 'org-jekyll-paths)
У меня эта переменная равна ~/rsync/blog/articles/_post_template.org
. Сам файл выглядит вот так:
#+BEGIN_EXPORT html
---
layout: post
title: {%NAME%}
category: {%CATEGORY%}
date: {%DATE%}
lang: {%LANG%}
comments: false
hidden:
- related_posts
{%ADDITIONAL%}
---
#+END_EXPORT
Как видно, тут просто описан jekyll-овский front matter и ничего больше.
Запуск локального сервера Jekyll
Сборка блога и создание нового поста средствами Emacs Lisp готовы. Из часто используемых действий у меня остался запуск локального сервера и очистка рабочего каталога Jekyll от сгенерированных файлов.
С запуском сервера всё просто — надо лишь вызвать make-process
с нужными аргументами:
(make-process
:name "jekyll-serve"
:buffer "jekyll-serve"
:command '("bundle" "exec" "jekyll" "serve")
:delete-exited-processes t
:filter (lambda (process text)
(if (string-match ".*done in [0-9.]+ seconds.*" text)
(message "%s" (propertize "Blog serve: running" 'face '(:foreground "blue"))))
(internal-default-process-filter process text))
:sentinel (lambda (process state)
(cond
((and (eq (process-status process) 'exit)
(zerop (process-exit-status process)))
(message "%s" (propertize "Blog serve: stopped" 'face '(:foreground "blue"))))
((eq (process-status process) 'run)
(accept-process-output process))
(t (error (concat "Jekyll Serve: " state))))))
Я хотел, чтобы одна и та же функция запускала и останавливала локальный сервер — для удобства. Логика для этого максимально простая:
- Если процесс
jekyll-serve
существует, то убиваем его. - Если процесса нет — запускаем сервер.
(defun org-jekyll--suffix-serve-toggle ()
"Serve blog or stop serving the blog."
(interactive)
(let ((current-path (file-name-directory buffer-file-name)))
(if (eq (process-status "jekyll-serve") ' run)
(interrupt-process "jekyll-serve")
(cd (expand-file-name org-jekyll-paths-base-path))
(make-process ...)
(cd current-path))))
Очистка рабочего каталога Jekyll
Очистка рабочего каталога уже не так проста. Если с вызовом команды bundle exec jekyll clean
всё просто — нужен ещё один вызов make-process
:
(make-process
:name "jekyll-clean"
:buffer "jekyll-clean"
:command '("bundle" "exec" "jekyll" "clean")
:delete-exited-processes t
:sentinel (lambda (process state)
(cond
((and (eq (process-status process) 'exit)
(zerop (process-exit-status process)))
(message "%s" (propertize "Blog cleaned" 'face '(:foreground "blue"))))
((eq (process-status process) 'run)
(accept-process-output process))
(t (error (concat "Jekyll Clean: " state))))))
То с результатами работы экспорта из Org Mode всё сложнее — Jekyll о них не знает и эти файлы останутся в файловой системе. Следовательно, перед вызовом jekyll clean
надо бы почистить каталоги _articles/
, _static/
и _post/
от того, что туда добавила org-publish-project
. Это я сделал через следующий S-expression:
(mapc (lambda (x)
(mapc (lambda (file)
(delete-file file nil))
(mapcan (lambda (directory)
(directory-files-recursively (concat org-jekyll-paths-base-path directory) (cdr x) nil nil nil))
(car x))))
`((("/_posts/en" "/_posts/ru") . "\\.html$")
(("/assets/static" "/_static") . ,(concat "\\.png\\|\\.jpg$\\|\\.jpeg$"
"\\|"
"\\.JPG$\\|\\.svg$\\|\\.webm$"
"\\|"
"\\.webp$\\|\\.html$\\|\\.tar.bz2$"
"\\|"
"\\.org$\\|\\.gif$\\|\\.gpx$"
"\\|"
"\\.uxf$"))
(("/_articles") . "\\.org$")))
На первый взгляд код может выглядеть переусложнённым, но всё что он делает — пробегается по заданным каталогам и удаляет из них файлы, подпадающие под заданное регулярное выражение.
Первая лямбда (lambda (x) ...)
просто передаёт каждый элемент из основного списка (например, первый элемент: (("/_posts/en" "/_posts/ru") . "\\.html$")
) в следующий S-expression:
(mapc (lambda (file)
(delete-file file nil))
(mapcan (lambda (directory)
(directory-files-recursively (concat org-jekyll-paths-base-path directory) (cdr x) nil nil nil))
(car x)))
Тут уже всё немного сложнее. Второй параметр mapc
не просто переменная x
с переданным элементом списка внутри, а ещё одно S-expression. Оно будет сначала вычислено и его результат (ещё один список — список файлов), будет поэлементно обработан последней лямбдой, которая просто удалит файл:
(lambda (file)
(delete-file file nil))
S-expression с mapcan
делает следующее:
- Берёт первый элемент списка с путями/регулярками через
(car x)
— это будет ещё один список с путями к директориям, например:("/_posts/en" "/_posts/ru")
. - В лямбде с
directory-files-recursively
пробегается по этому списку и получает список файлов в каталоге, которые подпадают под заданное регулярное выражение. Регулярка — последний элемент спискаx
и его можно получить через(cdr x)
. - В итоге получается что-то вроде
(("/_posts/en/article1/file.org" "/_posts/en/article2/file.org") ("/_posts/ru/article1/file.org" "/_posts/ru/article2/file.org"))
. Если бы я использовалmapc
, то на вход лямбда-функции для удаления файлов попал бы список вместо строки с путём к файлу иdelete-file
сломался бы.
Для примера, в следующем коде печатается содержимое переменной file
, которое попадает в лямбду, если бы использовался mapc
:
(mapc (lambda (file)
(print file))
(mapc (lambda (directory)
directory)
'(("a" "b") ("c" "d"))))
("a" "b")
("c" "d")
- Надо «сплющить» список и этим как раз занимается
mapcan
. Она превращает список из предыдущего пункта в:("/_posts/en/article1/file.org" "/_posts/en/article2/file.org" "/_posts/ru/article1/file.org" "/_posts/ru/article2/file.org")
— и возвращает результат в качестве второго параметра в вышележащийmapc
.
Вот пример того, что оказывается на входе лямбды для удаления файлов при использовании mapcan
— уже не список, а отдельные его элементы:
(mapc (lambda (file)
(print file))
(mapcan (lambda (directory)
directory)
'(("a" "b") ("c" "d"))))
"a"
"b"
"c"
"d"
Итоговая функция для очистки рабочего каталога Jekyll выглядит следующим образом:
(defun org-jekyll--suffix-clear ()
"Clear blog files."
(interactive)
(let ((current-path (file-name-directory buffer-file-name)))
(cd (expand-file-name org-jekyll-paths-base-path))
(mapc (lambda (x)
(mapc (lambda (file)
(delete-file file nil))
(mapcan (lambda (directory)
(directory-files-recursively (concat org-jekyll-paths-base-path directory) (cdr x) nil nil nil))
(car x))))
`((("/_posts/en" "/_posts/ru") . "\\.html$")
(("/assets/static" "/_static") . ,(concat "\\.png$\\|\\.jpg$\\|\\.jpeg$"
"\\|"
"\\.JPG$\\|\\.svg$\\|\\.webm$"
"\\|"
"\\.webp$\\|\\.html$\\|\\.tar.bz2$"
"\\|"
"\\.org$\\|\\.gif$\\|\\.gpx$"
"\\|"
"\\.svg$"))
(("/_articles") . "\\.org$\\|\\.png$")))
(make-process
:name "jekyll-clean"
:buffer "jekyll-clean"
:command '("bundle" "exec" "jekyll" "clean")
:delete-exited-processes t
:sentinel (lambda (process state)
(cond
((and (eq (process-status process) 'exit)
(zerop (process-exit-status process)))
(message "%s" (propertize "Blog cleaned" 'face '(:foreground "blue"))))
((eq (process-status process) 'run)
(accept-process-output process))
(t (error (concat "Jekyll Clean: " state))))))))
Интерфейс пользователя (transient)
Ко всей этой красоте неплохо было бы добавить удобный для пользователя Emacs интерфейс, чтобы не вызывать каждый раз нужную функцию через M-x
.
Здесь я особо не мудрствовал и просто использовал библиотеку Transient, как и Christian Dewein. В итоге получилась вот такая штука:
Ряд суффиксов (функций, которые будут вызываться при выборе соответствующих пунктов меню) я уже описал выше. Префикс (код, описывающий панель) выглядит следующим образом:
;; Transient keys description:
(transient-define-prefix org-jekyll-layout-descriptions ()
"Transient layout with blog commands."
[:description (lambda () (concat org-jekyll-url " control panel" "\n"))
["Development"
("b" "Build blog" org-jekyll--suffix-build)
("s" org-jekyll--suffix-serve-toggle
:description (lambda () (if (eq (process-status "jekyll-serve") 'run)
"Stop serving local blog"
"Serve local blog")))
("o" "Open served blog" org-jekyll--suffix-open-blog)
("O" "Open blog in Web" org-jekyll--suffix-open-remote-blog)
("B" "Open build log" org-jekyll--suffix-open-build-log)
("l" "Open serve log" org-jekyll--suffix-open-serve-log)
("C" "Clear blog directory" org-jekyll--suffix-clear)]
["Actions"
("n" "New blog post" org-jekyll--suffix-create-post)]])
;; Function to call main menu:
(defun org-jekyll-menu ()
"Open blog control center."
(interactive)
(org-jekyll-layout-descriptions))
Функции-суффиксы это обычные функции без параметров, например:
(defun org-jekyll--suffix-open-blog ()
"Open locally served blog."
(interactive)
(browse-url "http://127.0.0.1:8000/"))
(defun org-jekyll--suffix-open-remote-blog ()
"Open remote blog."
(interactive)
(browse-url org-jekyll-url))
(defun org-jekyll--suffix-create-post ()
"Create new blog post."
(interactive)
(cd (expand-file-name org-jekyll-paths-base-path))
(org-jekyll--create-new-post))
В описании пункта для запуска/остановки локального сервера я сделал так, чтобы он сразу же показывал запущен ли локальный сервер или нет — через проверку наличия процесса "jekyll-serve"
в системе.
Отобразить эту панельку можно вызвав функцию org-jekyll-menu
. Забегая немного вперёд — эта функция вызывается хоткеем в моём плагине.
Оформление в виде плагина
Осталось оформить всё как Emacs-плагин — не буду же я каждый раз делать eval-buffer
? Пусть Emacs сам подгружает весь нужный код при старте.
Для начала я прогнал исходный код через M-x checkdoc
и добавил недостающие комментарии. Потом добавил зависимости в заголовок:
(require 'htmlize)
(require 'ox-publish)
(require 'transient)
Здесь: htmlize
нужен Org Mode для подсветки кода в сгенерированном HTML, ox-publish
— расширение для публикации файла средствами Org Mode. Использование библиотеки transient
я уже описал выше.
Ещё я добавил provide
в конец файла:
(provide 'org-jekyll)
Ну и описал minor mode, который будет вызывать описанное выше transient-меню по хоткею C-c b
:
;; Minor mode:
;;;###autoload
(define-minor-mode org-jekyll-mode
"Enable transient menu to operate with blog-related OrgMode files."
:lighter " oj"
:global nil
:init-value nil
:keymap (list (cons (kbd "C-c b") #'org-jekyll-menu)))
Теперь, если этот режим включён через M-x org-jekyll-mode
, то по нажатию на C-c b b
собирается блог, по нажатию C-c b n
создаётся новая статья и так далее. Если просто нажать C-c b
, то покажется transient-меню со скриншота выше.
Загрузка плагина в Emacs
Осталось правильным образом загрузить этот плагин в Emacs, чтобы новый minor mode сам включался только при открытии файла с постом для блога и не включался при открытии остальных org-файлов.
Для этого я добавил ещё одну функцию, которая проверяет что мы открыли файл со статьей в буфере и включает мой minor mode:
;;;###autoload
(defun org-jekyll-init ()
(if (and buffer-file-name
(string-match "^/.+/article-[[:lower:]]\\{2\\}\\.org" (buffer-file-name)))
(org-jekyll-mode 1)))
Ну а сам плагин загружается в Emacs через use-package
следующим образом:
(use-package org-jekyll
:load-path "~/rsync/blog/"
:ensure nil
:commands org-jekyll-init
:hook (org-mode . org-jekyll-init))
Теперь, каждый раз когда открывается org-файл, вызывается функция org-jekyll-init
. И если мы открыли файл с текстом для блога, то включается org-jekyll-mode
и мой хоткей вместе с transient-меню становятся доступны.
Исходный код плагина
Я не задумывал этот плагин пригодным для использования другими людьми — в конце концов в нём захардкожена моя структура каталогов со статьями и мои методы именования файлов. Поэтому я не публиковал его в MELPA и не заводил для него отдельный репозиторий.
Исходный код лежит в том же репозитории, где и файлы для моего
блога. Посмотреть на него можно по этой ссылке.
В результате, если мне необходимо будет перейти на другой генератор статических сайтов — достаточно будет подправить функции, участвующие в экспорте из OrgMode, чтобы сгенерированный HTML подходил к новому движку. Мои исходники статей и вся структура каталогов для них — останется неизменной.
Что ещё можно улучшить?
В текущей версии плагина есть несколько моментов, которые определённо стоит улучшить:
- Вызов функции
org-publish-project
нужно сделать асинхронным, чтобы он не блокировал Emacs при запуске, как сейчас. При этом,org-publish-project
и последующий вызовmake-process
должны работать строго последовательно, иначе Jekyll попытается собрать блог когда файлы для сборки ещё не подготовлены. org-publish-project
умеет работать с блоками Org Babel, которые я хочу использовать для описания всяких сложных схем в виде кода для PlantUML прямо в тексте поста. После вызоваorg-publish-project
в каталоге_articles/
будут лежать готовые изображения со схемами, сделанными на основе PlantUML описаний (см. блогпост @dsu с деталями реализации: https://blog.lazy-evaluation.net/posts/orgmode-diagrams.html).
Надо лишь, чтобы функция org-jekyll--prepare-static
умела копировать файлы с изображениями из нового места. А функция org-jekyll--suffix-clear
умела удалять эти файлы.
Уже реализовано — см. коммит e919bd6d2b7f3a0b853fdf71f288f5c9f1749575.
Интересная тема. Иногда мне кажется, что мы блоги заводим для того чтобы с ними постоянно куда-то переезжать и изобретать новые велосипеды, а уж точно не посты постить.) У меня под капотом Jekyll, Gulp, VS Code и куча самописных liquid-тэгов.
Для того чтобы не копировать ассеты постоянно, я храню их в отдельном репозитории и во время генерации просто добавляю симлинк (для финального билда вся графика в любом случае процессится через sharp js). Путь до папки с ассетами записан в мете поста. Это удобно втч потому что этот репозиторий хочется хранить отдельно — он очень сильно отличается по размеру от основного.
Лучшее, что случалось с моим статическим сайтом — его переезд в контейнер. Оттуда разрабатывается сайт и пишется контент. Мне удобно: всё под VS Code и сборка теперь локальная, без GitHub Actions, которые могли мариновать билды по 40 минут.
В контейнере это легко можно отдать кому угодно и не поддерживать окружение. Потому что поддержка (проклятых) зависимостей Ruby + Node — это причина по которой все 5 лет назад уходили с Джекилла на Hugo, а 2 года назад начали переезжать на 11ty. Впрочем, как я писал выше: переезды и велосипеды > контент.)
Вообще интересно было бы увидеть всю эту автоматизацию в деле. В виде короткого ролика, например.