Как я сделал Emacs-плагин для сборки своего блога

 Публичный пост
3 декабря 2024  318

В этом посте я рассказываю о том как написал свой небольшой 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 и от его плагинов. Но они и так уже лежат на жёстком диске и работают без подключения к Интернету — а с жёсткого диска они никуда не пропадут (если помнить о бэкапах).

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

  1. Тексты постов были не сильно привязаны к генератору статических сайтов и я мог без особой боли сменить один генератор на другой, если понадобится.
  2. Блог как можно меньше зависел от чужой инфраструктуры. Единственная зависимость, от которой я пока что не могу уйти — это зависимость от регистратора доменных имён (остаётся только избегать регистраторов, замеченных в дискриминации пользователей по гражданству или по месту жительства, а также тех, что работают в моей местной юрисдикции). В качестве хостинга для статического сайта подойдёт же любая микроволновка в правильном месте — в таком, где не занимаются разрушением связности глобальной сети.
  3. Можно было редактировать посты удобным мне методом — т.е. в Emacs, при помощи Org Mode. А не с Markdown или же вообще в WYSIWYG веб-редакторе.
  4. Я мог сам выбрать для себя структуру каталогов с исходниками статей, а не пользоваться той, что навязана разработчиками генератора статических сайтов.

Если п. 2 нельзя решить, написав немного кода на Emacs Lisp, то остальные проблемы вполне себе решаемы подобным образом.

Содержание

Первая версия генератора блога

Достаточно быстро я сделал первую версию конвертера 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 и сразу же увижу его практически в том же виде, в каком он попадёт в блог:

Черновик поста, открытый в Emacs
Черновик поста, открытый в Emacs

В каталоге с блогом я создал специальный Makefile, который запускал не менее специальный bash-скрипт. Этот скрипт сканировал каталог articles/ и помещал найденные файлы с текстами постов в следующий конвейер:

Конвейер, превращающий org-файлы в HTML-файлы
Конвейер, превращающий org-файлы в HTML-файлы

Посмотреть на использовавшийся код можно вот в этом коммите, в файле 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:

Интерфейс M-x customize
Интерфейс M-x customize

Пути к нужным каталогам я описал через 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
               )))

Для удобства, добавим сюда ещё пару переменных:

  1. Переменную с именем промежуточного каталога: путь к _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))
  1. Переменную с уникальным путём к файлу со статьёй в промежуточном каталоге:
(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-functionorg-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))))

UI создания нового поста
UI создания нового поста

Внутри этого же 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* выполняется основная работа:

  1. Создаётся подкаталог с ранее вычисленным именем:
(make-directory dirname t)
  1. Если для поста используется баннер, то в этот каталог копируется соответствующее изображение:
(if use-banner
    (copy-file banner (concat dirname "/" (file-name-nondirectory banner))))
  1. Берётся шаблон для поста по пути из переменной 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))
  1. Сгенерированный файл открывается в текущем буфере с курсором в конце файла, чтобы сразу начать писать текст:
(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 делает следующее:

  1. Берёт первый элемент списка с путями/регулярками через (car x) — это будет ещё один список с путями к директориям, например: ("/_posts/en" "/_posts/ru").
  2. В лямбде с directory-files-recursively пробегается по этому списку и получает список файлов в каталоге, которые подпадают под заданное регулярное выражение. Регулярка — последний элемент списка x и его можно получить через (cdr x).
  3. В итоге получается что-то вроде (("/_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")
  1. Надо «сплющить» список и этим как раз занимается 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 подходил к новому движку. Мои исходники статей и вся структура каталогов для них — останется неизменной.

Что ещё можно улучшить?

В текущей версии плагина есть несколько моментов, которые определённо стоит улучшить:

  1. Вызов функции org-publish-project нужно сделать асинхронным, чтобы он не блокировал Emacs при запуске, как сейчас. При этом, org-publish-project и последующий вызов make-process должны работать строго последовательно, иначе Jekyll попытается собрать блог когда файлы для сборки ещё не подготовлены.
  2. 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.

3 комментария 👇
🕵️ Юзер скрыл свои комментарии от публичного просмотра...
Михаил Санников Продюсер потребительской электроники 4 декабря 2024

Интересная тема. Иногда мне кажется, что мы блоги заводим для того чтобы с ними постоянно куда-то переезжать и изобретать новые велосипеды, а уж точно не посты постить.) У меня под капотом Jekyll, Gulp, VS Code и куча самописных liquid-тэгов.

Для того чтобы не копировать ассеты постоянно, я храню их в отдельном репозитории и во время генерации просто добавляю симлинк (для финального билда вся графика в любом случае процессится через sharp js). Путь до папки с ассетами записан в мете поста. Это удобно втч потому что этот репозиторий хочется хранить отдельно — он очень сильно отличается по размеру от основного.

Лучшее, что случалось с моим статическим сайтом — его переезд в контейнер. Оттуда разрабатывается сайт и пишется контент. Мне удобно: всё под VS Code и сборка теперь локальная, без GitHub Actions, которые могли мариновать билды по 40 минут.

В контейнере это легко можно отдать кому угодно и не поддерживать окружение. Потому что поддержка (проклятых) зависимостей Ruby + Node — это причина по которой все 5 лет назад уходили с Джекилла на Hugo, а 2 года назад начали переезжать на 11ty. Впрочем, как я писал выше: переезды и велосипеды > контент.)

Вообще интересно было бы увидеть всю эту автоматизацию в деле. В виде короткого ролика, например.

  Развернуть 1 комментарий

😎

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

Что вообще здесь происходит?


Войти  или  Вступить в Клуб