middleman-blogでタグやカレンダーをカテゴリーの下階層に入れる方法

middleman

公式マニュアルではカスタム記事コレクションという機能を使って、カテゴリーを実現できるとしていますが、この機能だとタグと機能が同じなので、あまり存在意義を感じられません。

カテゴリーをタグやカレンダーより上位のレベルにあるものとして扱うにはどうすればよいのでしょうか?

具体的にいうとURLでいうと下のようにタグやカレンダーページを特定のカテゴリーの下に配置するにはどうすればよいのでしょうか。

1
2
http://example.com/カテゴリー名/タグ名/
http://example.com/カテゴリー名/2016/01/

これはこのブログの構成です。タグやカレンダーページをいくつか開いてみると構成がわかると思います。

2つの方法がある

  1. 複数ブログ機能を使う
  2. middleman-blogを拡張する

1の複数ブログ機能は一つのドメインでディレクトリを分けて、複数のブログを運営できる機能です。

この方法はまだ試していないのですが、私としては完全にブログを分けるのでは、次の条件のうち上の二つを実現できなさそうだったので、1ではなく2を採用しました。

  • ホーム画面では全カテゴリーの記事を表示したい
  • 各記事のURLはルート直下に配置したい
  • サイドバーの最新記事やタグやカレンダーはカテゴリー内の記事のみ対象にしたい

(やりようによっては1の方法でも実現できるかもしれません)

middleman-blogにモンキーパッチをあてる

middleman-blogの挙動を変えるには、単純にカスタム拡張を追加するのでは無理で、middleman-blogのclassにモンキーパッチを当てる必要があります。

カスタム拡張のafter_configurationコールバック内で、middleman-blogの3つのclassをリオープンしてモンキーパッチをあてます。
(リオープンとは宣言済みのクラスを再び宣言して変更を加えるという意味です。)

config.rb

下のコードをconfig.rbに追記します。(別ファイルのカスタム拡張にしてもOK。詳しくはカスタム拡張にて)

activate :blogよりも後ろに書きます。

config.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
class CategorizedBlog < Middleman::Extension
  def after_configuration
    Middleman::Blog::BlogData.class_eval do
      def articles_categorized_by( category=nil )
        category.nil? ? articles : articles.select{|a| category == a.data[:category] }
      end

      def tags_categorized_by( category=nil )
        tags = []
        I18n.t(:category).select{|c| category.nil? || category.to_sym == c }.
          each do | cate_key, category_val |
          category_val[:tag].each do | tag_key, label |
            tag = { tag: tag_key.to_s, label: label, category: cate_key.to_s, articles: [] }
            articles_categorized_by( category ).each do |article|
              article.tags.each do | tag_str |
                if article.data[:category] == tag[:category] && tag_str == tag[:tag]
                  tag[:articles] << article
                end
              end
            end
            tags << tag if tag[:articles].length > 0
          end
        end
        tags
      end
    end

    Middleman::Blog::TagPages.class_eval do
      def link(tag, category: nil)
        params = { tag: tag }
        if category.present?
          params[:category] = category
          template = Addressable::Template.new(
            ::Middleman::Util.normalize_path("{category}/"+ @tag_link_template.pattern)
          )
        end
        apply_uri_template template, params
      end

      def manipulate_resource_list(resources)
        resources + @blog_data.tags_categorized_by().map do |tag|
          tag_page_resource(tag[:tag], tag[:articles], category: tag[:category])
        end
      end

      private

      def tag_page_resource(tag, articles, category: nil)
        Sitemap::Resource.new(@sitemap, link(tag, category: category)).tap do |p|
          p.proxy_to(@tag_template)

          p.add_metadata locals: {
            'page_type' => 'tag',
            'tagname' => tag,
            'category' => category,
            'articles' => articles,
            'blog_controller' => @blog_controller
          }
        end
      end

    end

    Middleman::Blog::CalendarPages.class_eval do
      def link(year, month=nil, day=nil, category: nil)
        template = if day
                     @day_link_template
                   elsif month
                     @month_link_template
                   else
                     @year_link_template
                   end

        params = date_to_params(Date.new(year, month || 1, day || 1))
        if category.present?
          params[:category] = category
          template = Addressable::Template.new(
           ::Middleman::Util.normalize_path("{category}/"+ template.pattern)
          )
        end
        apply_uri_template template, params
      end

      def manipulate_resource_list(resources)
        @blog_data.articles.group_by {|a| a.date.year }.each do |year, year_articles|
          year_articles.group_by {|a| a.date.month }.each do |month, month_articles|
            resources << month_page_resource(year, month, month_articles)
          end
        end
        @blog_data.articles.group_by {|p| p.data["category"] }.each do |category, pages|
          next if category.nil?
          pages.group_by {|a| a.date.year }.each do |year, year_articles|
            year_articles.group_by {|a| a.date.month }.each do |month, month_articles|
              resources << month_page_resource(year, month, month_articles, category: category)
            end
          end
        end
        resources
      end

      private
      def month_page_resource(year, month, month_articles, category: nil)
        Sitemap::Resource.new(@sitemap, "#{link(year, month, category: category)}").tap do |p|
          p.proxy_to(@month_template)
          p.add_metadata locals: {
            'page_type' => 'month',
            'year' => year,
            'month' => month,
            'category' => category,
            'articles' => month_articles,
            'blog_controller' => @blog_controller
          }
        end
      end
    end
  end

  helpers do
    def blog_month_path(year, month,  blog_name=nil, category: nil)
      build_url blog_controller(blog_name).calendar_pages.link(year, month, category: category)
    end

    def tag_path(tag, blog_name=nil, category: nil)
      build_url blog_controller(blog_name).tag_pages.link(tag, category: category)
    end

  end
end

::Middleman::Extensions.register(:categorized_blog, CategorizedBlog)
activate :categorized_blog

after_configurationでモンキーパッチする理由

カスタム拡張ではいくつかのコールバックが使えるのですが、このモンキーパッチはafter_configuration内で行う必要があります。

manipulate_resource_listでは既存のmiddleman-blogで記事が読み込み済みになってしまっています。

また、config.rbのreadyイベント時も同じように読み込み済みになってしまっています。(詳しくはconfig.rb の中でサイトマップを使う

config.rbのトップコンテキストでリオープンすることも考えられますが、そうすると逆にmiddleman-blogの一部のclassが読み込まれていないのでモンキーパッチできません。

なので、classが全て読み込み済み、記事の読み込み前のafter_configurationで行う必要があります。

config.rbの解説

BlogDataTagPagesCalendarPagesを拡張しています。また、middleman-blogによって作られたhelperメソッド、blog_month_pathtag_pathを上書きしています。

BlogData

articles_categorized_bytags_categorized_byというメソッドを新たに追加しています。

これは、特定のカテゴリーに属する記事、タグをそれぞれ取得するメソッドです。引数にcategoryをとりますが、引数なしの場合は、全カテゴリーに属するものを返します。これはトップページでは、全カテゴリーに属する情報が必要だけど、インターフェイスを揃えるためです。

このメソッドはconfig.rb内でもtemplate側でも使えるように、template側に渡されるBlogDataというオブジェクトに追加しました。テンプレート側ではblogでアクセスできます。

TagPages

linkmanipulate_resource_listtag_page_resourceというメソッドを変更しています。

具体的にそれぞれのタグごとに

1
http://example.com/カテゴリー名/タグ名/

とうURLでページを生成するようにしています。テンプレート側にcategoryという名前でカテゴリーの種類を渡しています。

CalendarPages

ほぼTagPagesの拡張内容と同じです。

ただ、私は月ごとのカレンダーページしか必要としていないので、年と日のページ生成部分は削除しています。必要であればmiddleman-blogの元ファイルを参考に追加してください。

また、カレンダーページはカテゴリーごとの

1
http://example.com/カテゴリー名/2016/01/

というページももちろん作るのですが、

1
http://example.com/2016/01/

というように、全カテゴリーの記事についても作成するようにしています。これはトップページのサイドバーに表示するカレンダーは全カテゴリーを対象にしたいためです。

blog_month_pathとtag_path

こちらはmiddleman-blogで定義されるヘルパーですが、カテゴリーを引数にとり、カテゴリーが第一階層にくるパスを返すように上書きしています。カテゴリーを与えない場合は、ルート直下に配置されたパスを返します。

ja.ymlでタグとカテゴリーの階層関係を定義

タグとカテゴリーの階層関係をja.yml内で定義しています。

ja.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ja:
  category:
    affiliate:
      label: アフィリエイト
      tag:
        result: 成果報告
        serius: シリウス
        review: レビュー
    web-app-dev:
      label: Web・アプリ開発
      tag:
        middleman: Middleman
        atom: Atom
        neat: Bourbon Neat
        html_css: HTML/CSS
        rails: Rails
        swift: Swift
        sketch_app: Sketch App
        aws: AWS
        review: レビュー
    culture:
      label: カルチャー
      tag:
        music: 音楽
        supercollider: SuperCollider

template側の書き方

サイドバーはトップページでは全カテゴリーの記事、カテゴリーページではそのカテゴリーだけの記事が反映されるようにします。

なので、サイドバーのpartialは例えばこんな感じです。(slimで書いています)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- articles = blog.articles_categorized_by(yield_content(:category))
#subnav
  .subnav__section
    .subnav__title
      i.fa.fa-history
      |最近の投稿
    ol
      - articles[0...10].each do |article|
        li.subnav__item
          = link_to article.title, article, class: "u-title_link"
  .subnav__section
    .subnav__title
      i.fa.fa-tags
      |タグ
    ol.subnav__tagcloud.u-clearfix
      - blog.tags_categorized_by(yield_content(:category)).each do |tag|
        li
          = link_to tag[:label],  tag_path(tag[:tag], category: tag[:category] )
  .subnav__section
    .subnav__title
      i.fa.fa-archive
      |過去の投稿
    ol
      - articles.group_by { |a| a.date.year }.each do |year, year_articles|
        - year_articles.group_by { |a| a.date.month }.each do |month, month_articles|
          li.subnav__item
            = link_to\
              "#{year}#{sprintf('%02d', month)}月 (#{month_articles.size})",
              blog_month_path(year, month, category: yield_content(:category)),
              class: "u-title_link"

さきほど追加したarticles_categorized_bytags_categorized_byでカテゴリーごとの記事やタグを取り出しています。

また、メインのテンプレートでは、categoryという変数が渡されているので、メインメニューのどのカテゴリーをアクティブにするかの選択に使用できます。

参考

Middleman 3.0 extensions and execution order

さいごに

いかがでしたでしょうか。これでタグやカレンダーをカテゴリーの下に所属することができます。コードが長くわかりづらいかもしれませんが、middleman-blogの元コードをpryなどでデバッグしていくと動きがわかると思います。

なおバージョンは、

  • middleman (3.4.0)
  • middleman-blog (3.6.0.beta.2)

を使用しています。

羊毛や小麦