Emacsで個人サイト制作

目次

投稿日: 2024-12-24

1. はじめに

個人ホームページ訪問 Advent Calendar 2024 の24日目の記事です。

この記事ではこのサイトをどのように作成し、 運用しているのかについて紹介します。

このサイトのコンテンツである HTML は Emacs を使って生成しているので、 主に Emacs で Web サイト生成をする方法について解説します。

なお本記事で記載している Emacs Lisp のコードのライセンスは GPL 3+ です。

2. 前提: Emacs と org-mode とは

2.1. Emacs とは

Emacs とは拡張性とカスタマイズ性に 優れた自由なテキストエディターです。

私は日常でも仕事でも常に Emacs を使っており、 メモ、日記、プログラミング、コンピュータの設定管理、タスク管理、 テキスト Web ブランジング、メール、RSS の購読、 カレンダー管理、表計算、お金の管理など さまざまなことのために Emacs を利用しています。

そして、もちろん Web サイトの作成も Emacs でしているので、 その方法について説明します。

2.2. Emacs の org-mode とは

Emacs の org-mode は org という Emacs で扱えるテキストファイルの文書フォーマットを扱うモードのことで、 文書作成、メモ作成、タスク管理、スケジュール管理、リンク管理、表計算、 文書内でのコード実行など、これまたいろんなことができます。

Emacs には org 形式の文書を html に変換して export する機能があるので、 この機能を活用して HTML を生成します。

3. 経緯

3.1. Haunt 時代

最初の投稿は 2022/02/18 (火)のこんにちは、世界! という記事です。 この投稿で紹介しているように初期は GNU Guile 製の Haunt という静的サイトジェネレータを使用していました。 当時は Web 記事を GitLab で管理をしていて、 www-tojo-tokyo というリポジトリで管理していました。 サイトにこなくてもリポジトリの posts ファイルの中身を見れば記事が読めるという状況になっていたようです。

Haunt は大変優れた静的サイトジェネレータです。 Haunt では公式では Texinfo, Skribe, CommonMark の形式をサポートしているのですが、 Reader を追加することで新しい形式を追加することができます。 私は org-mode の形式を pandoc で変換する方法追加していました(実装)

3.2. Emacs で HTML の生成をしたい

最初は pandoc での変換結果に満足していたのですが、 後に Emacs の HTML Export 機能で出力される HTML と、 pandoc で生成される HTML の違いに不満を抱くようになりました。 まず Emacs にはソースコードに色を付ける機能が内包されているのですが、 pandoc で HTML に変換すると Emacs が付けていた色の情報は失なってしまいます。 Emacs の機能で HTML エクスポートをすれば適切に色を付けてくれるのでその機能を使いたいと思うわけです。

(define (factorial n)
  (if (<= n 0)
      1
      (* n (factorial (- n 1)))))

また、 org-babel という機能で文書内のソースコードを実行して、実行結果を文書に含めてくれる機能があるのですが、 エクスポートしたHTMLにコードを含めるのか実行結果を含めるのかを制御する機能があったり、 HTMLのHEADに追加特定のHTMLタグを追加することができたりと、EmacsでHTMLを出力した方が便利なところがたくさんあるのです。

Emacs のエクスポート機能を使うのであれば、 Haunt に依存しなくても Emacs の機能のみを使えばいいのではないかと思うようになりました。

3.3. Emacs で HTML 生成するように変更

そのため、2022/03/04(金)の Web サイトをリニューアルしました にて、 静的サイトジェネレータを Haunt から GNU Emacs に変更しました。

このときのリニューアルでは Haunt 時代から不便になった部分も多くあったので、 2022/11/19(土) のWeb サイトをリニューアルしてタグを追加した のリニューアルで仕組みを大幅に改善しています。

この記事では 2022/11/19(土)以降の私のサイトの仕組みについて紹介します。

4. ブログ形式の Web サイトに必要なページ

まず、 ブログ形式のホームページに必要な構成要素について考えてみましょう。

ブログで必要なページは究極的には以下の二つのみです。

index.html
記事の一覧ページ
posts/<filename>.html
記事の詳細ページ

ブログを公開するには究極的には上記二種類の HTML を作成して配置をすればよいということになります。

もちろん、HTML を直接配置しても問題ないでしょう。 しかし、私は HTML ではなくて org-mode で文書作成したいので、 org-mode で書いたものを自動的に HTML に変換して配置する仕組みが必要になるのです。

5. org-publish で「出版」する

org-mode には org-publish という機能があります。 これは、org などの文書形式のファイルや css や js などを 公開用の形式(HTMLなど)に変換し、 指定したディレクトリに配置し、 そのまま公開できる状態にするものです。

例えば以下のようなディレクトリ構造で、 サイトを作成したとします。

.
├── index.org
├── posts
│   ├── article1.org
│   ├── article2.org
│   └── article3.org
├── publish.el
└── static
    ├── css
    │   └── base.css
    └── js
        └── test.js

次に Emacs Lisp で次のようにプログラムを書きます。

(require 'org)

(defun publish-www ()
  (let* ((org-html-htmlize-output-type 'css)
         (org-html-head-include-default-style nil)
         (base-directory ".")
         (publishing-directory "./site")
         (org-publish-project-alist
          `(("www"
             :language "ja"
             :base-directory ,(concat base-directory)
             :base-extension "org"
             :recursive t
             :exclude "posts.org"
             :publishing-directory ,publishing-directory
             :publishing-function org-html-publish-to-html
             :html-doctype "html5")
            ("www-static"
             :language "ja"
             :base-directory ,(concat base-directory "/static")
             :base-extension "css\\|js"
             :recursive t
             :html-doctype "html5"
             :publishing-directory ,(concat publishing-directory "/static")
             :publishing-function org-publish-attachment))))
    (org-publish-project "www" t)
    (org-publish-project "www-static" t)))

これを以下のように実行すると…

emacs --batch -l publish.el -f publish-www

site というディレクトリが作成されていて、 次のようにサイトで公開可能な状態にできるわけです。

site
├── index.html
├── posts
│   ├── article1.html
│   ├── article2.html
│   └── article3.html
└── static
    ├── css
    │   └── base.css
    └── js
        └── test.js

これを Web サーバーに配置すれば、デプロイが完了です。

6. 一覧ページの記述と詳細記事の配置を自動化したい

しかし、たいへん怠惰な私はこれでは満足できません。

この方法でも問題なく org 文書でブログを作成することはできるのですが、 以下二点の問題があります。

6.1. 記事を作成するたびにファイルを作成する必要がある

この程度のことができない人にはもはや何もできないのではないかという疑いが発生しますが、 ファイルを作成するというのは意外と簡単ではありません。 ファイルを作成にするには以下の作業が必要です。

  • ファイル名を決めないといけない
    • ファイル名はパーマリンクになるので真面目に考えることになります
    • 変更したい場合、ファイル名を変更しないといけない
  • 前の記事を確認するのに別のファイルを開かないといけない
    • 前例踏襲をしたいときに困る
    • posts/ 以下のファイルを選択しなければならない
      • 直近の記事を参考にしたいが、どれが直近だか分からない

このように posts 以下にファイルを新規作成するのは面倒なのです。

6.2. 一覧ページを手書きしたくない

記事を書いたら、一覧ページに記事へのリンクを張らなければなりません。 これもまた面倒な作業ですし、リンクを間違える可能性もあります。

Emacs の org-html-export では、 相対リンクを書いたときに、 リンクが先ファイルがない場合はエラーとなり失敗してくれるという機能があります。 そのため、間違えて 404 Not Found になってしまうリンクを書いてしまうリスクはないのですが、 それでも過去記事からコピペして変更せずに過去記事にリンクを張ってしまうというリスクが残ります。

直感的にもこの作業が本質的にやらないといけないことだとは思えません。

7. 理想の形式

では、どうすればよいでしょうか? 前述した課題を解決するには次の方法で記事をかけるとよいです。

posts.org という名前のファイルを一つだけ作成し、 以下形式で全ての記事を含めてしまえばよいのです。

#+TITLE: 私のWebサイト

* 記事3
:PROPERTIES:
:FILENAME: article3
:PUBDATE: <2024-12-23 月 00:00>
:DESCRIPTION: みっつめの記事です
:END:

** はじめに

この記事は3つめです

** おわりに

3つめの記事でした

* 記事2
:PROPERTIES:
:FILENAME: article2
:PUBDATE: <2024-12-22 日 00:00>
:DESCRIPTION: ふたつめの記事です
:END:

** はじめに

この記事は2つめです

** おわりに

2つめの記事でした


* 記事1
:PROPERTIES:
:FILENAME: article1
:PUBDATE: <2024-12-21 Fri 00:00>
:DESCRIPTION: ひとつめの記事です
:END:

** はじめに

この記事は1つめです

** おわりに

1つめの記事でした

このファイルを解析して、 次のような構成でファイルを置くスクリプトを書けば問題が解決するわけです。

.
├── index.org
└── posts
     ├── article1.org
     ├── article2.org
     └── article3.org

7.1. org-capture で記事を新規作成する

しかし、スクリプトうんぬんの前にこの形式の posts.org を書くこと自体が面倒くさいのではないか?という問いがあります。 この問題は Emacs の org-mode の、 org-capture という機能を使うことで解決します。

Emacs に以下のような設定をします。

(global-set-key (kbd "C-c c") 'org-capture)

(setq org-capture-templates
      `(("w" "新しい記事" entry (file "~/www/posts.org")
         "* %^{TITLE}
:PROPERTIES:
:FILENAME: %^{ID}
:PUBDATE: %^T
:DESCRIPTION: %^{DESCRIPTION}
:END:

%?"
         :prepend t
         :jump-to-captured t)))

この設定を反映した後に C-c c (Controlを押しながらcを押した後に単独で c を押す)と、 org-capture で記事を新規作成できます。

demo.gif

図1: org-capture で記事を新規作成するデモ

8. Emacs を静的サイト生成器に

さて、 posts.org から index.orgposts/<filename>.org を生成するにはどうしたらよいでしょうか? org 形式はテキストフォーマットではあるのですが、 結構複雑な形式です。 これを解析するのには少し手間がかかります。

しかし、実は私は org 形式の解析器を既に手にしています。 そう Emacs です。Emacs はテキストエディタですが、 その高い拡張性から Emacs をorg形式を解析して、 必要なorgファイルを作成するツールとしても使うことができるのです。

8.1. posts.org から index.org を作成する

次のようなスクリプトを作成します。

なお、実際の私のサイトはタグ機能などをサポートしていて、複雑になっているため簡略化しています。

;; 簡単に html-escape する
(defun html-escape (str)
  (let* ((str (string-replace "&" "&amp;" str))
         (str (string-replace "<" "&lt;" str))
         (str (string-replace ">" "&gt;" str))
         (str (string-replace "\"" "&quot;" str)))
    str))

(defun find-index-file ()
  (let ((description "私の Web サイト &&& デモ &&&"))
    (cond ((not (file-exists-p *path*))
           ;; 初回は index.org の最初の部分を作成する
           (find-file *path*)
           (insert "#+TITLE: 私のWebサイト!\n"
                   "#+HTML_HEAD: <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
                   "#+HTML_HEAD: <link rel=\"stylesheet\" href=\"/static/css/base.css\"/>\n"
                   "#+OPTIONS: email:nil num:nil toc:nil\n")
           (insert (format "#+DESCRIPTION: %s\n" (html-escape description))))
          (t (find-file *path*)))))

;; 時刻から日付を取り出す
(defun strip-time (pubdate)
  (concat (substring pubdate 0 -7)">"))

(defvar *path* "./index.org")

;; posts.org を開く
(find-file "posts.org")

(let ((pt nil))         ; pt はカーソルの位置を記録しておくための変数
  ;; ファイルの最小の heading までカーソルを移動
  (org-next-visible-heading 1)
  ;; pt とカーソルの位置が同じになるまで繰り返す
  ;; 等しい場合は最後まで進んだということで処理終了
  (while (not (equal pt (point)))
    ;; いまのカーソルの位置を覚えておく
    (setq pt (point))
    (let ((link ; posts 下のファイルへのリンク
           (concat "./posts/"
                   (org-element-property :FILENAME (org-element-at-point))
                   ".org"))

          (title (org-get-heading t)) ; h1 をタイトルとする
          (pubdate (org-element-property :PUBDATE (org-element-at-point))) ; 公開日を取得
          (description (org-element-property :DESCRIPTION (org-element-at-point))) ; 詳細を取得
          )

      ;; index.org ファイルを開く
      (find-index-file)

      ;; index.org に書き込む
      (insert (format "* [[%s][%s]]\n" link title))
      (insert "#+HTML: <div class=\"info\">\n")
      (insert (format "#+HTML: <div><span class=\"date\">%s</span></div>\n"
                      (html-escape (strip-time pubdate))))
      (insert "#+HTML: </div>\n")
      (insert "\n")
      (insert description)
      (insert "\n")
      (save-buffer)

      ;; 元の場所に戻る
      (find-file "posts.org")
      (goto-char pt))
    ;; 次の h1 に移動する
    ;; 最後の場合は移動できないので動かない
    (org-forward-heading-same-level 1)))

これを実行すると…

emacs --batch -l generate-index.el 

次のような index.org ファイルが作成されます。

#+TITLE: 私のWebサイト!
#+HTML_HEAD: <meta name="viewport" content="width=device-width, initial-scale=1"/>
#+HTML_HEAD: <link rel="stylesheet" href="/static/css/base.css"/>
#+OPTIONS: email:nil num:nil toc:nil
#+DESCRIPTION: 私の Web サイト &amp;&amp;&amp; デモ &amp;&amp;&amp;
* [[./posts/article3.org][記事3]]
#+HTML: <div class="info">
#+HTML: <div><span class="date">&lt;2024-12-23 月&gt;</span></div>
#+HTML: </div>

みっつめの記事です
* [[./posts/article2.org][記事2]]
#+HTML: <div class="info">
#+HTML: <div><span class="date">&lt;2024-12-22 日&gt;</span></div>
#+HTML: </div>

ふたつめの記事です
* [[./posts/article1.org][記事1]]
#+HTML: <div class="info">
#+HTML: <div><span class="date">&lt;2024-12-21 Fri&gt;</span></div>
#+HTML: </div>

ひとつめの記事です

このように posts.org を走査することでいい感じに index.org を作成することができます。

8.2. posts.org から posts/<filename>.org を作成する

以下のスクリプトを作成することで、 posts/ 以下に詳細記事ファイルを作成します。 (なお、実際には ox-rss で RSS を生成していたり、 タグの一覧ページへのリンクを張ったりと色々しているせいで複雑なのでここでは簡略化しています)

(find-file "posts.org")

;; html-escape する
(defun html-escape (str)
  (let* ((str (string-replace "&" "&amp;" str))
         (str (string-replace "<" "&lt;" str))
         (str (string-replace ">" "&gt;" str))
         (str (string-replace "\"" "&quot;" str)))
    str))

;; posts ディレクトリを作成
(make-directory "./posts")

;; posts.org を開く
(let ((pt nil))          ; pt はカーソルの位置を記録しておくための変数
  ;; ファイルの最小の heading までカーソルを移動
  (org-next-visible-heading 1)
  ;; pt とカーソルの位置が同じになるまで繰り返す
  ;; 等しい場合は最後まで進んだということで処理終了
  (while (not (equal pt (point)))
    ;; いまのカーソルの位置を覚えておく
    (setq pt (point))
    (let ((title (org-get-heading t))
          (link ; posts 下のファイルへのリンク
           (concat "./posts/"
                   (org-element-property :FILENAME (org-element-at-point))
                   ".org"))
          (pubdate (org-element-property :PUBDATE (org-element-at-point)))
          (description (org-element-property :DESCRIPTION (org-element-at-point))))
      (when (file-exists-p link)
        (princ "エラー: 同じパスの記事が存在します")
        (exit 1))

      ;; 現在位置の記事全体をコピー
      (org-copy-subtree)

      ;; 記事ファイルを開く
      (find-file link)
      ;; タイルトルを挿入
      (insert (format "#+TITLE: %s\n" title))
      ;; 記事の公開日を設定
      (insert (format "#+DATE: %s\n" pubdate))
      ;; ある場合は DESCRIPTION を設定
      (when description
        (insert (format "#+DESCRIPTION: %s\n" description)))

      ;; ここで記事全体をペースト
      (yank)

      ;; ファイルの一番上に戻る
      (goto-char (point-min))
      ;; 最初の h1 に移動する
      (org-next-visible-heading 1)
      ;; プロパティ部分を削除する
      (let ((begin (car (org-get-property-block)))
            (end (cdr (org-get-property-block))))
        (kill-region begin end))
      ;; またファイルの最初に戻る
      (goto-char (point-min))
      ;; 最初のヘッダーに移動する
      (org-next-visible-heading 1)
      ;; タイトルのヘッダは不要なので削除する
      ;; :PROPETY: と :END の行も消すから3行消す必要あり
      (kill-line 3)

      ;; 投稿日を挿入
      (insert (format "#+HTML: <div class=\"publish-date-wrap\"><div class=\"publish-date\">投稿日: %s</div></div>\n"
                      (html-escape
                       (substring pubdate
                                  1
                                  (- (length pubdate) (length pubdate) 7)))))

      ;; 1段階無駄に深くなっているので、
      ;; 1段階だけ浅くする
      (goto-char (point-min))
      (let ((pt2 (point)))
        (while (not (org-next-visible-heading 1))
          (org-promote)))

      ;; 保存
      (save-buffer)

      ;; 元の位置に戻る
      (find-file "../posts.org")
      (goto-char pt))
    (org-forward-heading-same-level 1)))

これを実行すると…

emacs --batch -l generate-posts.el 

posts 下に以下ファイルが作成されます。

たとえば posts/article1.org はこんな感じです。

#+TITLE: 記事1
#+DATE: <2024-12-21 Fri 00:00>
#+DESCRIPTION: ひとつめの記事です
#+HTML: <div class="publish-date-wrap"><div class="publish-date">投稿日: 2024-12-21 Fri</div></div>

* はじめに

この記事は1つめです

* おわりに

1つめの記事でした

8.3. Emacs を静的サイトジェネレータにする

これ準備ができました。 スクリプトを次の順番で実行すれば、 posts.org から index.orgposts/<filename>.org を作成します。 配置された状態で org-publish を実行すれば、 site ディレクトリにWeb サイトができます。

emacs --batch -l generate-index.el 
emacs --batch -l generate-posts.el 
emacs --batch -l publish.el -f publish-www

これで完成です。

私のサイトはこんな感じで、 Emacs を静的サイトジェネレータとして利用し、 サイトを作っています。

9. おわりに

私はこんな感じで Emacs で個人サイトを構築しています。 Emacs って本当に何でもできますね。

私は本当はどちらかというと関数型プログラミングをしたいのですが、Emacs でこういうことをするとゴリゴリの手続き型プログラミングになりやすいのでそこは苦しいところです。 このあたりの問題を解決したい場合は、 org-mode のファイルを解析するところまでは Emacs でやって、 必要な情報をS式とか JSON で出力して別のプログラミング言語で処理するといいと思います。

とりあえずのやっつけで作ってしまった感はあるのですが、 そもそも大したことはしていないためメンテナンス性において特に課題はないため、 今後も引き続きこの仕組みでこのサイトを運用していきたいと思います。

また、似たような仕組みでTojoQKの日記というサイトも運営しています。 こちらは比較的どうでもいい記事を置く場所となっているので、 興味があればこちらも見ていってください。

最後まで読んでくれてありがとうございます。 Emacs の素晴しさが伝わっていれば幸いです。

10. おまけ

10.1. インフラ

詳しくは説明しないですが、 私のサイトは VPS で GNU Guix System という OS を動かしているサーバーの nginx で配信しています。

AWS で S3 と CloundFront を使うなどをすればもっと安く運用できるというのはそうなのですが、 個人的な心情として趣味で AWS を使いたくないというのがあります。 私は GNU Guix System が大好きであり、サーバーメンテナンスすること自体が趣味ということもあるので仕方ありません。 (といっても、個人的なデータ置き場としてS3 は活用していますが…)

また、私の Web サーバーは別のサイトへのリバースプロキシをするとか、Certbot で複数のドメインのSSL証明書を管理するというような大切な仕事もしているので、一概に高いとは言い切れないところがあるわけです。

外形監視には UptimeRobot を使っています。 また、一応 Zabbix サーバーの監視対象となっていて、 ディスク使用率とか CPU/メモリの使用率なども監視しています。

10.2. デプロイ

デプロイは私の git サーバーに push すると、 CI が回ってに自動的にデプロイされるようになっています。

main ブランチに push すると記事が公開され、 その他のブランチに push するとテスト環境に記事が公開されます。

git push で自動的に記事が公開されると、とっても快適なのでこういう感じにするのはおすすめです。

CI は Laminar CI というのを使っていて、 git のフックを起点にデプロイを実行しています。 CI サーバーで Emacs をバッチ実行して、 Web サーバーに rsync してデプロイまでしています。

Emacs でバッチ実行していると思うと面白いですね。

著者: Masaya Tojo

Mastodon: @tojoqk

RSS を購読する

トップページに戻る