HubSpot CMSに限らず、CMSを扱ううえでよく出くわすのがリスト(または配列)です。ブログ記事一覧やタグ一覧など、いろいろなデータがリストで返ってきますね。HubSpot CMSに使われているHubLはPythonのテンプレートエンジンである Jinja2 の拡張ですので、厳密には配列ではなくリストとなります。
特にforループ内の変数の状態をブロックスコープ外に持ち出せない問題は深刻で、JavaScriptに慣れているフロントエンドエンジニアにはなかなかつらい制約です。例えば次のコードは、forループ後の変数で期待通りの値を取得できません。
forループ内で直接HTMLを書いていたりする場合は、あまりこれで困ることはないかもしれません。しかし出力が複雑な場合や、繰り返し出てくるロジックをmacroとして外部化している場合は、どうしてもリストを整形しておきたいケースがあります。その場合に使用できる解決方法を幾つかご紹介します。
なお前提として、データは単純なリストではなく、以下のようにリストの中にディクショナリが格納されている例で示します。blog_recent_posts関数や、hubdb_table_row関数で取得した返り値の形ですね。このリストから、各ポスト名だけを抽出したリストを作成し、そのリストを「post_list」macroに渡し、出力することをゴールとします。
解決方法
loop.last内で希望の処理を行う
まず1番最初は、最も泥臭い方法です。forループ後だと変数から期待する値を取得できないので、forループ内で事を済ませてしまう、という発想です。コードは次のようになります。
{% raw %}
{%- for post in posts -%}
{%- if loop.first -%}
{# 変数の初期化 #}
{%- set post_names = '' -%}
{%- endif -%}
{%- set post_names = post_names + post.name + ',' -%}
{%- if loop.last -%}
{# macroに文字列をリストとして渡す #}
{{ post_list({
items: post_names|split(',')
}) }}
{%- endif -%}
{%- endfor -%}
まず1つ目のループ(loop.first)で変数の初期化を行います。リストに対する追加メソッドがないので、結局文字列型としています。ループ毎にポスト名(post.name)をpost_names変数に追加していき、最後のループ(loop.last)でmacroに変数を渡します。loop.firstとloop.lastはforループ内に予め用意されている予約変数です(参考:For loops | HubL Reference)。
このとき変数に格納されている値は文字列型ですので、macroに渡すときにsplitフィルタを使用してリストに変換しています。これで見事、期待する形でデータが表示されます。
しかしまぁ、いかんせん泥臭いのと、ループがネストされると可読性がどんどん悪くなります。
HubSpotモジュールを使用してグローバル変数を作成する
次に紹介するのは、HubSpotのTextモジュールを使用してforループ内から擬似的にグローバル変数を作成する方法です。とはいってもグローバルに定義しますので、乱用は禁物です。ほぼ裏技みたいなものですね……。コードは次のようになります。
{%- for post in posts -%}
{%- if loop.first -%}
{# 変数の初期化 #}
{%- set post_names_in_loop = '' -%}
{%- endif -%}
{%- set post_names_in_loop = post_names_in_loop + post.name + ',' -%}
{%- if loop.last -%}
{# Textモジュールの設置、値の設定 #}
{%- text "post_names" , value="{{ post_names_in_loop }}", export_to_template_context=True, overrideable=False -%}
{%- endif -%}
{%- endfor -%}
{# macroに文字列をリストとして渡す #}
{{ post_list({
items: widget_data.post_names.value|split(',')
}) }}
まず本筋ではありませんが、変数名が紛らわしいためforループ内でのみ使用するpost_namesは「post_names_in_loop」という名前に変更しました。
loop.lastでmacroを呼び出すのではなく、Textモジュールを設置します。valueのダブルクォーテーションの中で変数展開することによってpost_names_in_loopの文字列(「ポスト名1,ポスト名2,ポスト名3,」)をTextモジュールに設定できます。Textモジュールは値のみを使用するため、export_to_template_contextをTrueに設定します。またこのままだとページ編集画面などでTextモジュールの編集が可能な状態ですので、overrideableをFalseに設定します。
これでグローバル変数のように、どこからでも値にアクセスできるようになりました。後はforループの外でmacroを呼び出すと、1つ目の方法と同じように期待通りに各ポスト名が出力されます。
appendメソッドを使用する
最後に紹介するのは、appendメソッドを使用するです。「pushのようなメソッドは無いのでは?」と思われるかもしれませんが、それはそれでその通りです。HubLやJinja2に配列の末尾に要素を追加するメソッドはありません。このappendメソッドはPythonのメソッドです。そのため、これもまた裏技というか、かなり非公式な方法になります……。が、この方法が1番シンプルで綺麗です。
コードは次のようになります。
{# 変数の初期化 #}
{%- set post_names = [] -%}
{%- for post in posts -%}
{{ post_names.append(post.name)|cut(true) }}
{%- endfor -%}
{# macroにリストを渡す #}
{{ post_list({
items: post_names
}) }}
かなりスッキリしましたね。変数も、最初から文字列型ではなくリストで初期化しています。そのためmacroに渡すときも、いちいちsplitフィルタを使う必要がありません。注意点として、まずappendメソッドを使用するには式デリミタ(波括弧2つ)内でなければなりません。
次の注意点として、このスタイルでappendメソッドを使用すると、返り値としてBooleanのtrueを返します。そのため、何もしないと「true」という文字が出力されていまいます。
この「true」を出力しないためにcutフィルタを使用してtrueを削除しています。文字列のためのcutフィルタをBooleanに使っていいのかって感じですが……。とにかく、これで無駄なものは表示されず、かつコードもかなりスッキリしました。HubLドキュメントにもJinja2ドキュメントにも書いていない非公式な方法ではありますが、Pythonのメソッドであればいきなり使えなくなることもないだろうと踏んで、現在私は主にこの方法を使用しています。
リストの活用例
データの整形は今まで紹介した形で概ねできるようになりましたので、ここからは実際の活用例を紹介します。
リストをChoiceモジュールに適用する
Choiceモジュールとは、ページ編集画面でこのUIを提供するモジュールですね。
通常通り使用するには、次のように「choices」属性にカンマ区切りで値を設定することにより、それぞれが選択肢として認識されます。
{%- choice "selected_post" label='ポストの選択', value='', choices='選択肢1,選択肢2,選択肢3' -%}
先ほど整形したデータをChoiceモジュールに渡すには、次のようにchoices属性の中で式デリミタを使用します。
{%- choice "selected_post" label='ポストの選択', value='', choices='{{ post_names }}' -%}
なおChoiceモジュールがchoicesの値として受け付けるのは、カンマ区切りの文字列型です。appnedメソッド以外でデータを整形する方法は最初から文字列型なのでよいですが、appendメソッドを使用してデータを用意した場合は、Choiceモジュールに渡す前にjoinフィルタを使用して文字列型に変換しておく必要があります(choicesのクォーテーション内でjoinフィルタを使用しても、上手くいきません)。
リストをChoiceモジュールに設定する場合
{# joinフィルタを使用して、予め文字列型にしておく #}
{%- set post_names = post_names|join(',') -%}
{%- choice "selected_post" label='ポストの選択', value='', choices='{{ post_names }}' -%}
「関連記事一覧」から現在閲覧中の記事を除外する
よくブログポスト画面で、同じタグが付いているポストを「関連記事」として表示したいことがあると思います。1番手っ取り早いベストプラクティスはBlog related postsタグを使用することですが、macroを多段に使用していたりなど、ときに使用しづらいこともあると思います。
そんなときはblog_recent_tag_posts関数を使用して同一のタグを含むポストを取得する訳ですが、普通にやると現在閲覧中の記事まで関連記事に出てしまいます。そんなときに、appendメソッドを使用して現在閲覧中の記事を除外するリストを作成することが可能です。コードは次の通りです。
{# 同じタグが付いたポストの取得 #}
{%- set relative_posts = blog_recent_tag_posts('default', content.topic_list[0].slug, 4) -%}
{# リストとカウンターの初期化 #}
{%- set posts = [] -%}
{%- set posts_count = 0 -%}
{%- for item in relative_posts -%}
{# 現在閲覧中の記事ではなく、ポストが3件に達していなければリストに追加 #}
{%- if item.name != content.name|striptags and posts_count < 3 -%}
{{ posts.append(item)|cut(true) }}
{%- set posts_count = posts_count + 1 -%}
{%- endif -%}
{%- endfor -%}
ちょっと複雑になってしまいましたね。1つずつ解説します。
- blog_recent_tag_posts……タグは簡易に、現在閲覧中の記事の1つ目を指定しています。今回出力したい関連記事は3件ですが、件数は4件取得します。これは現在閲覧中の記事を除外する可能性も考慮しなければならないためです。
- posts_count……取得した関連記事に万が一現在閲覧中の記事が含まれていない場合、4件出力されてしまいます。そのため、件数を制御するためにカウンターも用意します。
これでposts変数には、現在閲覧中の記事を除外した3件の記事のディクショナリがリストとして格納されています。
まとめ
以上、HubLでリストを扱う際の方法と、活用例をご紹介しました。解決方法については、結局どれも若干モヤっとするところはありますが、テンプレートエンジンなので表現力に乏しいのは仕方ないですね。HubSpot CMSのテンプレートを開発するにおいてリストは避けて通れませんので、何かの際に、ご参考になれば幸いです。