Octopressで見出しの目次を作る方法

調べて見つかった方法だとうまく行かなかったので、少し筋の悪い方法ですが自分で考えた方法をメモしておきます。

追記:ブログエンジンをHexoに移行しましたので、この記事の内容は現状と齟齬を生じている可能性があります。

最終的には、この記事に出ているような見出しの目次を作ることができました。この方法の特徴は下記の通りです。

  1. 日本語の見出しでもエラーにならず出力される。
  2. OctopressデフォルトのMarkdownパーサであるrdiscountを使用している。
  3. リンクが絶対パスになる(記事一覧画面のような「続きを読む(Read On)」で目次のリンク先が省略されるような状況でも有効なリンクになる)
  4. リンクが重複しない(後述)

前半はアプローチのメモです。実現方法は解決案以降に書いています。

前提条件

  1. 2014-05-22のコミット 以降のoctopressをcloneしていること。
  2. Markdownパーサとしてrdiscountを使用していること。

目次のサンプルと見出しの生成に失敗するまで

gemはvendor/bundle以下にインストールしてあると仮定します。

$ git clone git://github.com/imathis/octopress.git octopress
$ bundle install --path=vendor/bundle
$ bundle exec rake install

見出し機能を有効にします。

diff --git _config.yml _config.yml
index e51e7c3..0969488 100644
--- _config.yml
+++ _config.yml
@@ -39,6 +39,8 @@ rdiscount:
     - autolink
     - footnotes
     - smart
+    - generate_toc
+  toc_token: "{!TOC}"
 pygments: false # default python pygments have been replaced by pygments.rb

 paginate: 10          # Posts per page on the blog index

以下、投稿例です。

$ bundle exec rake 'new_post[octopress_toc]'
$ cat <<'EOF' > source/_posts/2014-06-16-octopress-toc.markdown
layout: post
title: "octopress_toc"
date: 2014-06-16 09:54:31 +0900
comments: true
categories:
---

{!TOC}

見出し目次のテストです。

<!-- more -->

# 見出し1

本文

## 見出し2

本文

## 見出し3

本文
EOF

このままgenerateしようとするとエラーになります。

$ bundle exec rake generate
## Generating Site with Jekyll
identical source/stylesheets/screen.css
Configuration from octopress/_config.yml
Building site: source -> public
octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/converters/markdown.rb:140:in `gsub!': incompatible character encodings: ASCII-8BIT and UTF-8 (Encoding::CompatibilityError)
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/converters/markdown.rb:140:in `convert'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/convertible.rb:46:in `transform'
        from octopress/plugins/post_filters.rb:150:in `transform'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/convertible.rb:88:in `do_layout'
        from octopress/plugins/post_filters.rb:167:in `do_layout'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/post.rb:195:in `render'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/site.rb:200:in `block in render'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/site.rb:199:in `each'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/site.rb:199:in `render'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/lib/jekyll/site.rb:41:in `process'
        from octopress/vendor/bundle/ruby/2.1.0/gems/jekyll-0.12.0/bin/jekyll:264:in `<top (required)>'
        from octopress/vendor/bundle/ruby/2.1.0/bin/jekyll:23:in `load'
        from octopress/vendor/bundle/ruby/2.1.0/bin/jekyll:23:in `<main>'

既知のパッチ

前述のエラーについてはパッチがあります。

参考:octopressで見出しの目次を記事に埋め込む方法 - T.I.D.

jekyll本体に手を入れることになるので、ローカルにチェックアウトします。

$ mkdir vendor/github
$ git clone [email protected]:jekyll/jekyll -b v0.12.0 vendor/github/jekyll

Gemfileを修正して、bundle install --path=vendor/bundleします。

diff --git Gemfile Gemfile
index cd8ce57..c87d7ec 100644
--- Gemfile
+++ Gemfile
@@ -2,7 +2,7 @@ source "https://rubygems.org"

 group :development do
   gem 'rake', '~> 0.9'
-  gem 'jekyll', '~> 0.12'
+  gem 'jekyll', :path => "vendor/github/jekyll"
   gem 'rdiscount', '~> 2.0.7'
   gem 'pygments.rb', '~> 0.3.4'
   gem 'RedCloth', '~> 4.2.9'

jekyllのコードを修正します。

cd vendor/github/jekyll
diff --git lib/jekyll/converters/markdown.rb lib/jekyll/converters/markdown.rb
index 3c6ba53..b866617 100644
--- lib/jekyll/converters/markdown.rb
+++ lib/jekyll/converters/markdown.rb
@@ -137,7 +137,7 @@ module Jekyll
           rd = RDiscount.new(content, *@rdiscount_extensions)
           html = rd.to_html
           if rd.generate_toc and html.include?(@config['rdiscount']['toc_token'])
-            html.gsub!(@config['rdiscount']['toc_token'], rd.toc_content)
+            html.gsub!(@config['rdiscount']['toc_token'], rd.toc_content.force_encoding("utf-8"))
           end
           html
         when 'maruku'

もう一度generateしてみます。

% bundle exec rake generate
## Generating Site with Jekyll
identical source/stylesheets/screen.css
Configuration from octopress/_config.yml
Building site: source -> public
octopress/plugins/raw.rb:11:in `gsub': invalid byte sequence in UTF-8 (ArgumentError)
        from octopress/plugins/raw.rb:11:in `unwrap'
        from octopress/plugins/octopress_filters.rb:18:in `post_filter'
        from octopress/plugins/octopress_filters.rb:33:in `post_render'
        from octopress/plugins/post_filters.rb:124:in `block in post_render'
        from octopress/plugins/post_filters.rb:123:in `each'
        from octopress/plugins/post_filters.rb:123:in `post_render'
        from octopress/plugins/post_filters.rb:151:in `transform'
        from octopress/vendor/github/jekyll/lib/jekyll/convertible.rb:88:in `do_layout'
        from octopress/plugins/post_filters.rb:167:in `do_layout'
        from octopress/vendor/github/jekyll/lib/jekyll/post.rb:195:in `render'
        from octopress/vendor/github/jekyll/lib/jekyll/site.rb:200:in `block in render'
        from octopress/vendor/github/jekyll/lib/jekyll/site.rb:199:in `each'
        from octopress/vendor/github/jekyll/lib/jekyll/site.rb:199:in `render'
        from octopress/vendor/github/jekyll/lib/jekyll/site.rb:41:in `process'
        from octopress/vendor/github/jekyll/bin/jekyll:264:in `<top (required)>'
        from octopress/vendor/bundle/ruby/2.1.0/bin/jekyll:23:in `load'
        from octopress/vendor/bundle/ruby/2.1.0/bin/jekyll:23:in `<main>'

別のエラーが出ました。

次の一手

いくつかの方法が考えられます。

  1. 別のパーサを使う。(redcarpet, kramdown)
  2. rdiscount, jekyllのバージョンを上げる。

redcarpetについては別の問題が発生してうまく行きませんでした。 octopressのドキュメント自体がredcarpetに移行しているみたいなので本命だったのですが諦めて別の方法を探しました。

kramdownはcodeblockの利用に制限がかかりそうだったので見送りました。

rdiscountとjekyllのバージョンをあげたら解決する可能性があるかと思ったのですが、両方ともmasterのHEADに持っていくだけではダメでした。 そもそもoctopressが使っているjekyllのバージョンはかなり古いので、互換性の面で不安もありました。

というわけで諦めて強引に修正することにしました。(今回は多少汚い手を使っても困るのは俺だけだし)

問題点は何か

そもそもの問題点は2つあります。

  1. rdiscountの出力するtocをクリーンなutf-8に変換できない。
  2. rdiscountが出力する見出しのアンカータグのname属性が重複する可能性がある。

前者はjekyllのコード修正後に発生したエラーの原因です。force_encodingするだけでは不十分でした。

ただ、後者のほうがより重要で、これがあるかぎり頑張って前者を解決してもダメです。 わかり易い例を示します。

` ruby toc_test.rb require ‘rdiscount’

rd = RDiscount.new(<<-EOF, :autolink, :footnotes, :smart, :generate_toc)

ヘッダ1

本文1

ヘッダ2

本文2 EOF

puts rd.to_html


``` bash
$ bundle exec ruby toc_test.rb
<a name="L............"></a>
<h1>ヘッダ1</h1>

<p>本文1</p>

<a name="L............"></a>
<h2>ヘッダ2</h2>

<p>本文2</p>

ヘッダ1、ヘッダ2はname属性が同じになるようにあえて全角数字で書いています。

rdiscountに下記のようなコードがあるので、おそらく意図した動作なのでしょう。 ただ、アルファベット以外の見出しはすべて”.”に”mangle”されてしまうので、 日本語で見出しを書くとたまたま長さが同じになったときにリンクが重複します。

// vendor/bundle/ruby/2.1.0/gems/rdiscount-2.0.7.3/ext/mkdio.c

/* write out a Cstring, mangled into a form suitable for `<a href=` or `<a id=`
 */
void
mkd_string_to_anchor(char *s, int len, mkd_sta_function_t outchar,
                                       void *out, int labelformat)
{
    unsigned char c;

    int i, size;
    char *line;

    size = mkd_line(s, len, &line, IS_LABEL);

    if ( labelformat && size && !isalpha(line[0]) )
        (*outchar)('L',out);
    for ( i=0; i < size ; i++ ) {
        c = line[i];
        if ( labelformat ) {
            if ( isalnum(c) || (c == '_') || (c == ':') || (c == '-') || (c == '.' ) )
                (*outchar)(c, out);
            else
                (*outchar)('.',out);
        }
        else
            (*outchar)(c,out);
    }

    if (line)
        free(line);
}

解決案

以下の方法を取りました。

  • jekyllを修正して、RDiscount#toc_contentをブログ本文のドキュメントに挿入する前後でHTMLを書き換える。
    • 本文のアンカータグのname属性をsection_#{n}というフォーマットの連番に書き換える。
    • RDiscount#toc_contentのリンクを上記で書き換えたアンカーに置換する。

差分は下記のとおりです。Nokogiriを使ってます。 あとRDiscount#toc_contentの不正なバイトを綺麗にするためにString#scrubを使っているのでruby2.1系じゃないと動きません。

$ cd vendor/github/jekyll
diff --git jekyll.gemspec jekyll.gemspec
index f88d073..e9a09b4 100644
--- jekyll.gemspec
+++ jekyll.gemspec
@@ -29,6 +29,7 @@ Gem::Specification.new do |s|
   s.add_runtime_dependency('maruku', "~> 0.5")
   s.add_runtime_dependency('kramdown', "~> 0.13.4")
   s.add_runtime_dependency('pygments.rb', "~> 0.3.2")
+  s.add_runtime_dependency('nokogiri', "~> 1.6.1")

   s.add_development_dependency('rake', "~> 0.9")
   s.add_development_dependency('rdoc', "~> 3.11")
diff --git lib/jekyll/converters/markdown.rb lib/jekyll/converters/markdown.rb
index 3c6ba53..31f0e9a 100644
--- lib/jekyll/converters/markdown.rb
+++ lib/jekyll/converters/markdown.rb
@@ -1,3 +1,5 @@
+require 'nokogiri'
+
 module Jekyll

   class MarkdownConverter < Converter
@@ -137,7 +139,15 @@ module Jekyll
           rd = RDiscount.new(content, *@rdiscount_extensions)
           html = rd.to_html
           if rd.generate_toc and html.include?(@config['rdiscount']['toc_token'])
-            html.gsub!(@config['rdiscount']['toc_token'], rd.toc_content)
+            doc = Nokogiri::HTML::DocumentFragment.parse(rd.to_html)
+            toc  = Nokogiri::HTML::DocumentFragment.parse(rd.toc_content.force_encoding("utf-8").scrub("."))
+            doc.css("a[name]").each_with_index{|elem, i| elem['name'] = "section_#{i}" }
+            anchors = doc.css("a[name]")
+            toc.css("a[href]").each_with_index{|elem, i|
+              elem["href"] = "#" + anchors[i]["name"]
+            }
+            html = doc.to_html
+            toc = html.gsub!(@config['rdiscount']['toc_token'], toc.to_html)
           end
           html
         when 'maruku'

ここまでで見出し目次は出力されるようになりますが、アンカータグは<a href="#section_0">見出し1</a>のような相対パスでのリンクになります。

<ul>
 <li><ul>
  <li><a href="#section_0">categories:</a></li>
 </ul></li>
 <li><a href="#section_1">見出し1</a></li>
 <li><ul>
  <li><a href="#section_2">見出し2</a></li>
  <li><a href="#section_3">見出し3</a></li>
 </ul></li>
</ul>

<p>見出し目次のテストです。</p>

<!-- more -->

<a name="section_1"></a>
<h1>見出し1</h1>


<a name="section_1"></a>
<h1>見出し1</h1>

<p>本文</p>

<a name="section_2"></a>
<h2>見出し2</h2>

<p>本文</p>

<a name="section_3"></a>
<h2>見出し3</h2>

<p>本文</p>

そのため、投稿の単体ページでは意図したように動作しますが、投稿一覧ページに表示されていて、かつ参照先が<!-- more -->によって省略されているような場合には意図したように動作しません。

これを解決するためにプラグインを書きます。

` ruby plugins/toc_linker.rb require ‘nokogiri’

module Jekyll require_relative ‘post_filters’

class TocLinker < PostFilter def post_render(post) if post.is_post? doc = Nokogiri::HTML::DocumentFragment.parse(post.content) doc.css(“a[href]”).each do |elem| next if elem[“href”] !~ /^#/ elem[“href”] = post.full_url + elem[“href”] end post.content = doc.to_html end end end end


これにより、下記のようにアンカータグのリンク先を絶対パスに書き換えてしまいます。

``` html
<ul>
 <li><ul>
  <li><a href="http://yoursite.com/blog/2014/06/16/octopress-toc/#section_0">categories:</a></li>
 </ul></li>
 <li><a href="http://yoursite.com/blog/2014/06/16/octopress-toc/#section_1">見出し1</a></li>
 <li><ul>
  <li><a href="http://yoursite.com/blog/2014/06/16/octopress-toc/#section_2">見出し2</a></li>
  <li><a href="http://yoursite.com/blog/2014/06/16/octopress-toc/#section_3">見出し3</a></li>
 </ul></li>
</ul>

フィード

ついでに出力されるフィードにも手を入れておきます。

<!-- more -->で省略している箇所はフィードに含めず、
それよりも前に見出し目次がある場合はフィードに含めて、
見出し目次のリンク先は投稿本文になるようにします。

{% raw %}
diff --git source/atom.xml source/atom.xml
index 83af3f8..035a610 100644
--- source/atom.xml
+++ source/atom.xml
@@ -21,7 +21,13 @@ layout: nil
     <link href="{{ site.url }}{{ post.url }}">
     <updated>{{ post.date | date_to_xmlschema }}</updated>
     <id>{{ site.url }}{{ post.id }}</id>
-    <content type="html"><!--[CDATA[{{ post.content | expand_urls: site.url | cdata_escape }}]]--></content>
+    <content type="html"><!--[CDATA[
+      {{ post.content | excerpt | expand_urls: site.url | cdata_escape }}
+      {% capture excerpted %}{{ post.content | has_excerpt }}{% endcapture %}
+      {% if excerpted == 'true' %}
+        <a rel="full-article" href="{{ site.url }}{{ root_url }}{{ post.url }}">{{ site.excerpt_link | cdata_escape }}</a>
+      {% endif %}
+    ]]--></content>
   
   {% endfor %}
 
{% endraw %}

課題

  • 見出し目次のリンクに対応する本文中のDOM要素をアンカータグのname属性だけでサーチしているため、それ以外のname属性付きアンカータグを含むような記事では誤動作します。
  • 見出し目次のリンクがbundle exec rake previewでも絶対パスになります。
  • octopressの更新によってjekyllのバージョンが上がってしまった場合には再度対処が必要になる可能性があります。

最初に書いたように筋悪な方法だと思ってるのでプルリクとかもしません。

もっとスマートな方法知ってる人いたらtwitterで教えて下さい。

追記:twitterで筋の良い方法を教えて頂きましたので、その紹介の記事を書きました。

Octopressで見出しの目次を作る方法 改 - 割り箸ポテチ