📘 HubSpot CMS 組み込み構築 教科書
Chapter 4

カスタムモジュール設計・開発パターン

HubSpot CMS 開発の核心はカスタムモジュールにあります。741件のモジュール分析から導いたフィールドタイプの使用頻度・グループ/繰り返しフィールドの設計・module.html の全実装パターン・CSS/JS 設計・meta.json 設定まで。現場で即使えるモジュール開発の完全ガイドです。

🎯 対象レベル:中級〜上級
⏱ 読了目安:120〜150分
🔗 前章:第3章 テンプレート設計・実装パターン

この章の内容

  1. カスタムモジュールの5ファイル構成
  2. フィールドタイプ使用頻度ランキング(741件分析)
  3. fields.json 設計の基本と全フィールドタイプリファレンス
  4. グループ・繰り返しフィールドの設計パターン
  5. choice フィールドによるレイアウト制御
  6. module.html — 変数参照・存在チェック・デフォルト値
  7. module.html — 画像フィールドの完全実装
  8. module.html — リンクフィールドの実装パターン
  9. module.html — ループ処理と namespace パターン
  10. module.css — BEM・CSS変数・レスポンシブ設計
  11. module.js — DOM操作・データ属性・初期化パターン
  12. meta.json — モジュールのメタ情報設定
  13. 実案件から学ぶ 5 つのベストプラクティス
Section 4-1

カスタムモジュールの5ファイル構成

HubSpot CMS のカスタムモジュールは1モジュール=1ディレクトリ(.module)の単位で管理します。 そのディレクトリには必ず5種類のファイルが含まれます。 このセットが「モジュール開発の基本単位」です。

⚙️
fields.json
フィールド定義。ページエディターで表示される編集UIを決める
📄
module.html
HubLで書くテンプレート。フィールド値を参照してHTMLを出力
🎨
module.css
モジュール固有のCSS。BEM命名。ページに配置時のみ読み込まれる
module.js
モジュール固有のJS。スライダー・アコーディオン等の動作定義
🏷️
meta.json
モジュール名・アイコン・カテゴリ等のメタ情報
ターミナル — モジュール雛形の生成
# CLIでモジュールの雛形を生成(5ファイルが自動作成される)
$ hs create module hero-banner --path=./src/my-theme/modules

✔ Created "hero-banner.module" at:
  src/my-theme/modules/hero-banner.module/
  ├── fields.json   # 空の配列 [] が初期値
  ├── module.html   # 空ファイル
  ├── module.css    # 空ファイル
  ├── module.js     # 空ファイル
  └── meta.json     # 基本情報が自動設定される
📊 実案件の規模感

分析した実案件のコードベースには741モジュールが存在しました。 単一サイト・単一テーマの一般的な構成では20〜60モジュールが標準的です。 741件は複数テーマ・複数クライアント分が1リポジトリで管理されている規模です。 この章では741件全モジュールの分析から得られたパターンを体系化します。


Section 4-2

フィールドタイプ使用頻度ランキング(741件分析)

741件全モジュールの fields.json を走査して集計した、 フィールドタイプの実際の使用頻度ランキングです。 「どのフィールドタイプを先に習得すべきか」の優先度判断に使ってください。

1text
最多使用 — ラベル・見出し・サブテキスト等に必須
2image
ほぼ全モジュールに登場 — 背景・アイコン・サムネイル
3boolean
表示/非表示切り替えに多用
4choice
レイアウト・スタイル・カラム数切り替えに必須
5link
CTAボタン・カードリンク・ナビに登場
6richtext
本文・説明文・リッチコンテンツに使用
7group
FAQアイテム・カードリスト等の繰り返し構造
8color
背景色・テキスト色の個別指定(STYLEタブ)
9number
表示件数・アニメーション速度・余白量の指定
10url
外部URL直接指定(link型より簡易な場面)
✅ TOP5 を完璧にマスターすれば実案件の9割に対応できる

text / image / boolean / choice / link の5つが 実案件で最も頻繁に使われるフィールドタイプです。 この5つの設計パターンを習得することが最優先です。 group は構造が複雑ですが、FAQ・カードグリッド・タブ等あらゆる繰り返しコンテンツに必要なため、 必ずマスターしてください。


Section 4-3

fields.json 設計の基本と全フィールドタイプリファレンス

fields.json の基本構造

fields.json — 基本構造と共通プロパティ
[
  {
    // ===== 必須プロパティ =====
    "type"   : "text",          // フィールドタイプ
    "name"   : "heading",       // HubL から参照するキー名(snake_case)
    "label"  : "見出し",        // ページエディターに表示するラベル(日本語推奨)

    // ===== 強く推奨するプロパティ =====
    "default"   : "見出しテキスト", // デフォルト値(未設定でも表示が崩れないよう設定)
    "required"  : false,           // 入力必須フラグ
    "help_text" : "最大30文字推奨",   // エディターに表示するガイドテキスト
    "placeholder": "例:サービス紹介",  // 入力欄のプレースホルダー(text系のみ)

    // ===== 任意プロパティ =====
    "locked"    : false,           // true にするとエディターで編集不可にできる
    "hidden"    : false,           // true にするとエディターで非表示(HubLでは使える)
    "tab"       : "CONTENT"        // "CONTENT"(デフォルト)or "STYLE"(スタイルタブ)
  }
]

全フィールドタイプ リファレンス

"type": "text"

1行テキスト入力。見出し・ラベル・ボタンテキスト等に使用。
参照: module.text / "default": "テキスト"

"type": "richtext"

リッチテキストエディター。HTML込みの本文・説明文に使用。出力時は {{ module.body }} でそのまま展開。
参照: module.body / "default": "<p>テキスト</p>"

"type": "image"

画像選択フィールド。.src / .alt / .width / .height のサブプロパティを持つ。
参照: module.image.src / .alt / .width / .height

"type": "boolean"

ON/OFFトグル。セクションの表示/非表示・背景色あり/なし等の切り替えに使用。
参照: module.show_section / "default": true

"type": "choice"

選択肢から1つ選ぶフィールド。choices配列で選択肢を定義。display: "select" / "radio" / "checkbox"。
参照: module.layout / "display": "radio"

"type": "link"

URL・リンクテキスト・別タブ開く・nofollow を含むリンク情報一式。CTAボタンに最適。
参照: module.cta.url / .open_in_new_tab / .no_follow

"type": "color"

カラーピッカー。.color(16進数)と .opacity(0〜100)のサブプロパティ。tab:"STYLE" と組み合わせて使う。
参照: module.bg_color.color / .opacity

"type": "font"

フォントファミリー・サイズ・ウェイト・色のセット。通常はtheme.jsonで管理し、例外的な個別設定に使う。
参照: module.heading_font.font / .size / .bold

"type": "number"

数値入力。表示件数・アニメーション速度・余白量・カラム数等に使用。
参照: module.count / "default": 3 / "min": 1 / "max": 12

"type": "url"

URLのみのシンプルなリンクフィールド(link型より簡易)。画像リンク先・外部埋め込みURL等に使用。
参照: module.video_url / "default": ""

"type": "hubspot_cta"

HubSpotのCTA(コール・トゥ・アクション)オブジェクトを選択するフィールド。CTAの管理画面と連動。
参照: {% cta module.cta %}

"type": "form"

HubSpotフォームを選択するフィールド。フォームIDとフォームオブジェクトを返す。
参照: module.form_field.form_id / .response_redirect_url

"type": "blog"

ブログを選択するフィールド。blog_recent_posts()等に渡すブログIDを取得するのに使う。
参照: module.blog_field.id

"type": "tags"

ブログタグを複数選択するフィールド。タグフィルターのUIに使用。
参照: module.selected_tags(配列)

"type": "simple_menu"

シンプルなナビゲーションメニューを作成するフィールド。ラベル・URL・子メニューの階層構造を持つ。
参照: module.menu_field.children

"type": "group"

複数フィールドをまとめるグループ。occurrence で繰り返し数を制御。FAQや カードリストに必須。
参照: module.items / occurrence.min / .max

よく迷うフィールドタイプの使い分け

場面推奨タイプ理由
CTAボタン(テキスト+リンク先)link別タブ開く・nofollowまで1フィールドで管理できる
外部埋め込みURLのみurllink より軽量。テキストやターゲット設定が不要な場合
本文・説明文(書式あり)richtext太字・リスト・リンク等の書式設定が必要な場合
見出し・ラベル(書式なし)textプレーンテキストのみでよい場合はtextが軽量・シンプル
表示/非表示のオンオフbooleanトグルUIで直感的。{% if module.xxx %}で分岐
レイアウト・スタイルの切り替えchoice想定外の値の入力を防ぎ、エディターの使いやすさを確保

Section 4-4

グループ・繰り返しフィールドの設計パターン

"type": "group"occurrence の組み合わせが、 HubSpot CMS で最も重要かつ複雑な設計パターンです。 FAQ・カードグリッド・タブ・タイムライン等、「アイテムを繰り返す」あらゆるモジュールに必要です。

パターン① FAQアコーディオン(最も典型的な繰り返し)

fields.json — FAQ アコーディオンモジュール
[
  {
    "type"   : "text",
    "name"   : "section_title",
    "label"  : "セクション見出し",
    "default": "よくある質問"
  },
  {
    // ===== 繰り返しグループ(occurrence で件数を制御)=====
    "type"       : "group",
    "name"       : "items",
    "label"      : "FAQアイテム",
    "occurrence" : {
      "min"             : 1,   // 最小件数
      "max"             : 20,  // 最大件数
      "default"         : 3,   // 初期表示件数
      "sorting_label"   : "FAQ"  // エディターでの並び替えラベル
    },
    "children"   : [
      {
        "type"   : "text",
        "name"   : "question",
        "label"  : "質問",
        "required": true,
        "default": "質問を入力してください"
      },
      {
        "type"   : "richtext",
        "name"   : "answer",
        "label"  : "回答",
        "required": true,
        "default": "<p>回答を入力してください</p>"
      }
    ]
  }
]

パターン② カードグリッド(画像・テキスト・リンクのセット)

fields.json — カードグリッドモジュール
[
  {
    "type": "text",
    "name": "section_title",
    "label": "セクション見出し",
    "default": "サービス一覧"
  },
  {
    "type": "choice",
    "name": "columns",
    "label": "列数",
    "choices": [
      ["2", "2列"],
      ["3", "3列(デフォルト)"],
      ["4", "4列"]
    ],
    "default": "3",
    "display": "radio"
  },
  {
    // ===== カードの繰り返しグループ =====
    "type": "group",
    "name": "cards",
    "label": "カード",
    "occurrence": { "min": 1, "max": 12, "default": 3 },
    "children": [
      {
        "type"       : "image",
        "name"       : "image",
        "label"      : "画像",
        "help_text"  : "推奨サイズ: 640×360px",
        "default"    : { "src": "", "alt": "", "width": 640, "height": 360 }
      },
      {
        "type"   : "text",
        "name"   : "title",
        "label"  : "カードタイトル",
        "required": true,
        "default": "サービス名"
      },
      {
        "type"   : "richtext",
        "name"   : "description",
        "label"  : "説明文",
        "default": "<p>説明文を入力してください</p>"
      },
      {
        "type"   : "link",
        "name"   : "cta",
        "label"  : "リンク(任意)",
        "default": { "url": { "href": "" }, "open_in_new_tab": false }
      }
    ]
  }
]

パターン③ グループのネスト(タブ内のアイテム)

fields.json — タブモジュール(グループのネスト)
[
  {
    "type": "group",
    "name": "tabs",
    "label": "タブ",
    "occurrence": { "min": 2, "max": 8, "default": 3 },
    "children": [
      {
        "type"   : "text",
        "name"   : "tab_label",
        "label"  : "タブ名",
        "required": true,
        "default": "タブ名"
      },
      {
        "type"   : "richtext",
        "name"   : "tab_content",
        "label"  : "タブコンテンツ",
        "default": "<p>コンテンツを入力</p>"
      },
      {
        // ネストされたグループ(タブ内にアイコンリストを追加)
        "type": "group",
        "name": "icon_list",
        "label": "アイコンリスト(任意)",
        "occurrence": { "min": 0, "max": 6, "default": 0 },
        "children": [
          { "type": "image", "name": "icon",  "label": "アイコン"  },
          { "type": "text",  "name": "label", "label": "テキスト" }
        ]
      }
    ]
  }
]
💡 "occurrence" の min: 0 は「任意セクション」を実現する

"min": 0 にすると、エディターでアイテムを0件にすることができます。 上の例のアイコンリストのように「使わない場合は空にできる」オプション的なセクションを実現できます。 module.html 側で {% if module.items %} と存在チェックを組み合わせて使います。


Section 4-5

choice フィールドによるレイアウト制御

choice フィールドはマーケターが「コードを触らずに見た目を変更できる」 最強の仕組みです。実案件の分析で確認された代表的な3パターンを紹介します。

パターン① レイアウト方向の切り替え(左右反転)

fields.json + module.html — 画像とテキストの左右切り替え
// fields.json
{
  "type"    : "choice",
  "name"    : "layout",
  "label"   : "レイアウト",
  "choices" : [
    ["image-left",  "画像:左 / テキスト:右(デフォルト)"],
    ["image-right", "画像:右 / テキスト:左"]
  ],
  "default" : "image-left",
  "display" : "radio"
}

// module.html — choice値でCSSクラスを切り替える
<div class="split-section split-section--{{ module.layout }}">
  <div class="split-section__image">...</div>
  <div class="split-section__text">...</div>
</div>

/* module.css — CSSで実装(JSなし)*/
.split-section               { display: flex; gap: 40px; align-items: center; }
.split-section--image-left   { flex-direction: row; }
.split-section--image-right  { flex-direction: row-reverse; }
@media (max-width: 768px) {
  .split-section, .split-section--image-right { flex-direction: column; }
}

パターン② カラーテーマの切り替え

fields.json + module.html — 背景カラーテーマ
// fields.json
{
  "type"    : "choice",
  "name"    : "color_theme",
  "label"   : "カラーテーマ",
  "tab"     : "STYLE",
  "choices" : [
    ["white",   "白(デフォルト)"],
    ["light",   "ライトグレー"],
    ["primary", "プライマリカラー"],
    ["dark",    "ダーク"]
  ],
  "default" : "white",
  "display" : "select"
}

// module.html
<section class="cta-section cta-section--{{ module.color_theme }}">
  ...
</section>

/* module.css */
.cta-section--white   { background: #ffffff; color: #2d3748; }
.cta-section--light   { background: #f7fafc; color: #2d3748; }
.cta-section--primary { background: var(--color-primary); color: #ffffff; }
.cta-section--dark    { background: #1a202c; color: #f7fafc; }

パターン③ choice 値でモジュール全体の構造を分岐

module.html — choice 値で完全に異なるHTMLを出力する
{% set layout = module.card_style|default("vertical") %}

{% if layout == "vertical" %}
  {# 縦型カード(画像→テキスト)#}
  <div class="card card--vertical">
    {% if module.image.src %}
      <div class="card__thumb">
        <img src="{{ module.image.src }}?width=640&format=webp"
             alt="{{ module.image.alt|escape }}"
             loading="lazy">
      </div>
    {% endif %}
    <div class="card__body">
      <h3>{{ module.title }}</h3>
      {{ module.description }}
    </div>
  </div>

{% elif layout == "horizontal" %}
  {# 横型カード(画像+テキストを横並び)#}
  <div class="card card--horizontal">
    {% if module.image.src %}
      <div class="card__thumb">
        <img src="{{ module.image.src }}?width=320&format=webp"
             alt="{{ module.image.alt|escape }}"
             loading="lazy">
      </div>
    {% endif %}
    <div class="card__body">
      <h3>{{ module.title }}</h3>
      {{ module.description }}
    </div>
  </div>

{% elif layout == "icon" %}
  {# アイコン型カード(小さいアイコン+テキスト)#}
  <div class="card card--icon">
    {% if module.image.src %}
      <img class="card__icon"
           src="{{ module.image.src }}?width=80"
           alt="" aria-hidden="true"
           width="40" height="40">
    {% endif %}
    <h3>{{ module.title }}</h3>
    {{ module.description }}
  </div>
{% endif %}

Section 4-6

module.html — 変数参照・存在チェック・デフォルト値

module.html では module.フィールド名 でフィールド値を参照します。 存在チェックなしで参照すると値がない場合にエラーになるため、 適切なチェックパターンを使い分けることが必須です。

存在チェックのパターン別使い分け

module.html — フィールドタイプ別の存在チェック
{# ===== テキスト系(text / richtext)=====
   値が空文字の場合は falsy として扱われる #}
{% if module.heading %}
  <h2>{{ module.heading }}</h2>
{% endif %}

{# ===== 画像フィールド =====
   .src で画像URLの存在をチェックする
   ★ module.image だけではなく必ず .src まで確認すること #}
{% if module.image.src %}
  <img src="{{ module.image.src }}" alt="{{ module.image.alt|escape }}">
{% endif %}

{# ===== リンクフィールド =====
   .url.href で実際のURLの存在をチェックする #}
{% if module.cta.url.href %}
  <a href="{{ module.cta.url.href }}">{{ module.cta.name }}</a>
{% endif %}

{# ===== グループ(繰り返し)フィールド =====
   配列が空でないことを確認してからループ #}
{% if module.items %}
  {% for item in module.items %}
    <div>{{ item.title }}</div>
  {% endfor %}
{% endif %}

{# ===== boolean フィールド =====
   true/false をそのまま条件として使う #}
{% if module.show_button %}
  <button>{{ module.button_label }}</button>
{% endif %}

{# ===== |default() フィルターで存在チェックとデフォルト値を同時に =====
   チェックなしで参照する場面でフォールバック値を設定できる #}
<h2>{{ module.heading|default("見出しを入力してください") }}</h2>
<a href="{{ module.cta.url.href|default("#") }}">
  {{ module.cta.name|default("詳しく見る") }}
</a>

Section 4-7

module.html — 画像フィールドの完全実装

画像はパフォーマンスに直結するため、 HubSpot の画像変換機能・loading 属性・fetchpriority の 正しい設定が非常に重要です。実案件から抽出した完全実装パターンを紹介します。

module.html — 画像フィールドの完全実装パターン
{# ===== パターン1:標準的な画像(遅延読み込み)=====
   LCP要素でない場合(ファーストビュー以外の画像)に使う
===================================================== #}
{% if module.image.src %}
  <img
    src="{{ module.image.src }}?width=800&format=webp"
    alt="{{ module.image.alt|default("")|escape }}"
    width="{{ module.image.width|default(800) }}"
    height="{{ module.image.height|default(450) }}"
    loading="lazy"
    decoding="async">
{% endif %}

{# ===== パターン2:LCP要素(ヒーローバナーの画像)=====
   ファーストビューの最大コンテンツ要素には eager + high を使う
===================================================== #}
{% if module.hero_image.src %}
  <img
    src="{{ module.hero_image.src }}?width=1440&format=webp"
    alt="{{ module.hero_image.alt|default("")|escape }}"
    width="1440"
    height="640"
    loading="eager"
    fetchpriority="high"
    decoding="sync">
{% endif %}

{# ===== パターン3:picture要素でレスポンシブ画像 =====
   SP/PC で異なる画像サイズ・縦横比を出し分ける
===================================================== #}
{% if module.image.src %}
  <picture>
    {# WebP(モダンブラウザ向け)#}
    <source
      type="image/webp"
      srcset="
        {{ module.image.src }}?width=480&format=webp  480w,
        {{ module.image.src }}?width=800&format=webp  800w,
        {{ module.image.src }}?width=1200&format=webp 1200w
      "
      sizes="(max-width: 480px) 480px, (max-width: 800px) 800px, 1200px">
    {# フォールバック(JPG)#}
    <img
      src="{{ module.image.src }}?width=800"
      alt="{{ module.image.alt|default("")|escape }}"
      width="{{ module.image.width|default(800) }}"
      height="{{ module.image.height|default(450) }}"
      loading="lazy"
      decoding="async">
  </picture>
{% endif %}

{# ===== パターン4:CSS背景画像に画像URLを渡す =====
   グラデーションオーバーレイと組み合わせたヒーロー等
===================================================== #}
<div
  class="hero"
  {% if module.bg_image.src %}
    style="--bg-image: url('{{ module.bg_image.src|escape_url }}?width=1440&format=webp')"
  {% endif %}>
  ...
</div>
/* CSS側: .hero { background-image: var(--bg-image); } */

HubSpot 画像変換パラメータ一覧

パラメータ値の例効果
?width=Nwidth=800幅をNpxにリサイズ(高さはアスペクト比を維持)
?height=Nheight=600高さをNpxにリサイズ
?format=webpformat=webpWebP形式に変換(ファイルサイズ削減に効果大)
?format=jpgformat=jpgJPEG形式に変換
?quality=Nquality=80画質を0〜100で指定(デフォルト80)
複数組み合わせwidth=800&format=webp&quality=85&でパラメータを連結できる

Section 4-8

module.html — リンクフィールドの実装パターン

module.html — link フィールドの完全実装
{# ===== link フィールドのサブプロパティ =====
   module.cta(link型フィールド)が持つプロパティ:
   .url.href      : リンク先URL
   .url.type      : "EXTERNAL" / "INTERNAL" / "EMAIL"
   .name          : リンクテキスト(ラベル)
   .open_in_new_tab: 別タブで開くかどうか(true/false)
   .no_follow     : rel="nofollow" を付けるかどうか
===================================================== #}

{% if module.cta.url.href %}
  <a
    href="{{ module.cta.url.href }}"
    class="btn btn--primary"

    {# 別タブ・nofollow の制御 =====
       open_in_new_tab が true の場合のみ target を付ける
       no_follow が true の場合のみ nofollow を追加
       noopener / noreferrer はセキュリティのため常に付ける #}
    {% if module.cta.open_in_new_tab %}
      target="_blank"
    {% endif %}
    rel="noopener noreferrer{% if module.cta.no_follow %} nofollow{% endif %}"

    {# 外部リンクであることをスクリーンリーダーに伝える #}
    {% if module.cta.open_in_new_tab %}
      aria-label="{{ module.cta.name|escape }}(新しいタブで開く)"
    {% endif %}>

    {{ module.cta.name|default("詳しく見る") }}

    {# 別タブの場合は外部リンクアイコンを表示(任意)#}
    {% if module.cta.open_in_new_tab %}
      <span aria-hidden="true">↗</span>
    {% endif %}
  </a>
{% endif %}
⚠️ noopener / noreferrer は target="_blank" 時の必須セキュリティ対策

target="_blank" でリンクを開く場合、rel="noopener noreferrer"必ず付けることがセキュリティ上必須です。 これがないと開いたページから元のページを操作される(Tabnabbing攻撃)リスクがあります。 module.cta.open_in_new_tab が true の場合は条件付きで付与し、 false の場合でも rel="noopener noreferrer" だけは付けておくのがベストプラクティスです。


Section 4-9

module.html — ループ処理と namespace パターン

基本的なループ処理

module.html — グループフィールドのループ実装
{# ===== 基本ループ + loop 変数の活用 ===== #}
{% if module.items %}
  <ul class="card-grid card-grid--{{ module.columns|default("3") }}">
    {% for item in module.items %}

      {# loop 変数で制御できる値 #}
      {# loop.index   : 1始まりの連番(1, 2, 3...)#}
      {# loop.index0  : 0始まりの連番(0, 1, 2...)#}
      {# loop.first   : 最初のアイテムかどうか(true/false)#}
      {# loop.last    : 最後のアイテムかどうか(true/false)#}
      {# loop.length  : アイテムの総数 #}

      <li
        class="card-grid__item
          {% if loop.first %} is-first{% endif %}
          {% if loop.last  %} is-last{% endif %}"
        data-index="{{ loop.index0 }}">

        {% if item.image.src %}
          <img
            src="{{ item.image.src }}?width=640&format=webp"
            alt="{{ item.image.alt|default(item.title)|escape }}"
            loading="{% if loop.index <= 2 %}eager{% else %}lazy{% endif %}"
            width="640" height="360">
            {# ↑ 最初の2枚は eager(表示速度優先)、残りは lazy #}
        {% endif %}

        {% if item.title %}
          <h3>{{ item.title }}</h3>
        {% endif %}

        {% if item.description %}
          <div>{{ item.description }}</div>
        {% endif %}

        {% if item.cta.url.href %}
          <a href="{{ item.cta.url.href }}"
             class="btn"
             {% if item.cta.open_in_new_tab %}
               target="_blank" rel="noopener noreferrer"
             {% endif %}>
            {{ item.cta.name|default("詳しく見る") }}
          </a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>
{% endif %}

namespace パターン(ループ内での状態管理)

HubL のループ内では変数の再代入ができません。 ループをまたいで状態を持ち越したい場合は namespace を使います。 実案件でタグのプレフィックス判定に多用されたパターンです。

module.html — namespace でループ内の状態を管理
{# ===== 問題:ループ内では set で変数を書き換えられない =====
   以下のコードは動作しない(HubLの仕様)
   {% for item in module.items %}
     {% set found = true %}  ← ループを出ると found は false のまま
   {% endfor %}
===================================================== #}

{# ===== 解決策:namespace を使う ===== #}
{% set ns = namespace(
  has_featured = false,   // フィーチャー済みかどうか
  total_count  = 0        // カウンター
) %}

{% for item in module.items %}
  {% if item.is_featured %}
    {% set ns.has_featured = true %}   // namespace 経由で書き換えられる
    {% set ns.total_count = ns.total_count + 1 %}
  {% endif %}

  <div class="item{% if item.is_featured %} item--featured{% endif %}">
    {{ item.title }}
  </div>
{% endfor %}

{# ループ後に namespace の値を参照できる #}
{% if ns.has_featured %}
  <p>フィーチャーアイテム:{{ ns.total_count }}件</p>
{% endif %}

Section 4-10

module.css — BEM・CSS変数・レスポンシブ設計

BEM 命名規則の適用

実案件のコードベース分析で確認された命名パターンはBEM(Block__Element--Modifier)です。 モジュール名をBlock名のプレフィックスにすることで、グローバルスコープとの衝突を防ぎます。

module.css — BEM命名規則の実装例(カードグリッド)
/* ===================================================
   card-grid.module / module.css
   BEM命名規則: .card-grid(Block)を基点にする
=================================================== */

/* ===== Block ===== */
.card-grid {
  padding-block: var(--section-padding);
}

/* ===== Block の Element ===== */
.card-grid__title {
  font-size: clamp(1.5rem, 3vw, 2.25rem);
  font-weight: 700;
  text-align: center;
  margin-bottom: 2rem;
}

.card-grid__list {
  display: grid;
  gap: 24px;
  list-style: none;
  /* CSS変数でカラム数を柔軟に制御(module.html の data-columns 属性と連動)*/
  grid-template-columns: repeat(var(--cols, 3), 1fr);
}

/* ===== Modifier(グリッドの列数バリアント)===== */
.card-grid__list--2 { --cols: 2; }
.card-grid__list--3 { --cols: 3; }
.card-grid__list--4 { --cols: 4; }

/* ===== カード(子コンポーネント)===== */
.card-grid__item {}

.card-grid__card {
  background: #ffffff;
  border-radius: var(--border-radius, 8px);
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);
  transition: box-shadow 0.2s ease, transform 0.2s ease;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.card-grid__card:hover {
  box-shadow: 0 4px 16px rgba(0,0,0,0.14);
  transform: translateY(-2px);
}

.card-grid__thumb { aspect-ratio: 16/9; overflow: hidden; }
.card-grid__thumb img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform 0.3s ease;
}
.card-grid__card:hover .card-grid__thumb img { transform: scale(1.04); }

.card-grid__body {
  padding: 20px;
  flex: 1;
  display: flex;
  flex-direction: column;
}

.card-grid__card-title {
  font-size: 1.05rem;
  font-weight: 700;
  margin-bottom: 8px;
  color: var(--color-text, #2d3748);
}

.card-grid__card-desc {
  font-size: 0.9rem;
  color: #718096;
  line-height: 1.7;
  flex: 1;
}

.card-grid__card-cta {
  margin-top: 16px;
}

/* ===== レスポンシブ(モバイルファースト)===== */
@media (max-width: 480px) {
  .card-grid__list--2,
  .card-grid__list--3,
  .card-grid__list--4 { --cols: 1; }
}
@media (min-width: 481px) and (max-width: 768px) {
  .card-grid__list--3,
  .card-grid__list--4 { --cols: 2; }
}
@media (min-width: 769px) and (max-width: 1024px) {
  .card-grid__list--4 { --cols: 3; }
}

theme.json の CSS変数を参照する

実案件の分析では CSS変数(var(--xxx))の参照よりも HubL で直接値を展開するパターンが多く確認されましたが、 保守性の観点から CSS変数 経由での参照を推奨します。 CSS変数は base.html または 01-settings.css で定義します。

module.css — CSS変数を使ったテーマカラーの参照(推奨パターン)
/* ===== CSS変数を参照(theme.jsonの値と連動)=====
   var(--color-primary) は base.html で定義した変数
   theme.json を変更するだけで全モジュールに反映される
=================================================== */

.cta-banner { background: var(--color-primary); }
.cta-banner__title { color: #ffffff; }
.cta-banner__btn {
  background: #ffffff;
  color: var(--color-primary);
  border-radius: var(--border-radius);
}
.cta-banner__btn:hover {
  background: var(--color-primary);
  color: #ffffff;
  outline: 2px solid #ffffff;
}

Section 4-11

module.js — DOM操作・データ属性・初期化パターン

実案件の分析では、JSを持つモジュールは少数派でした。 アコーディオン・タブ・スライダーのような動的UIにのみ module.js を使います。 シンプルなコンテンツ表示にJSは不要です。

module.js — アコーディオン(FAQ)の実装パターン
/* faq-accordion.module / module.js
   ===================================================
   実装方針:
   - DOMContentLoaded を使わず、スクリプトは defer 属性で読み込む
   - クラス名で対象要素を特定(IDではなくクラス)
   - 複数モジュールが同じページに存在しても動作するよう設計
   - ARIA属性(aria-expanded / aria-controls)でアクセシビリティ対応
=================================================== */

(function() {
  // このモジュールのアコーディオンをすべて取得
  var accordions = document.querySelectorAll('.faq-accordion__item');

  accordions.forEach(function(item) {
    var btn     = item.querySelector('.faq-accordion__question');
    var content = item.querySelector('.faq-accordion__answer');

    if (!btn || !content) return;

    // 初期状態の ARIA 属性設定
    btn.setAttribute('aria-expanded', 'false');
    content.setAttribute('aria-hidden', 'true');
    content.style.height = '0';
    content.style.overflow = 'hidden';
    content.style.transition = 'height 0.3s ease';

    btn.addEventListener('click', function() {
      var isExpanded = btn.getAttribute('aria-expanded') === 'true';

      if (isExpanded) {
        // 閉じる
        content.style.height = content.scrollHeight + 'px';
        requestAnimationFrame(function() {
          content.style.height = '0';
        });
        btn.setAttribute('aria-expanded', 'false');
        content.setAttribute('aria-hidden', 'true');
        item.classList.remove('is-open');
      } else {
        // 開く
        content.style.height = content.scrollHeight + 'px';
        btn.setAttribute('aria-expanded', 'true');
        content.setAttribute('aria-hidden', 'false');
        item.classList.add('is-open');
        // transitionend 後に height: auto に変更(リサイズ対応)
        content.addEventListener('transitionend', function handler() {
          content.style.height = 'auto';
          content.removeEventListener('transitionend', handler);
        });
      }
    });
  });
})();
module.js — data属性でHubLのデータをJSに渡すパターン
/* ===== module.html 側(data属性でデータを渡す)=====
<div
  class="staff-slider"
  data-autoplay="{{ module.autoplay|lower }}"
  data-speed="{{ module.speed|default(3000) }}"
  data-items='{{ module.items|tojson }}'>
</div>
============================================== */

// module.js 側(data属性から設定を読み取る)
(function() {
  var sliders = document.querySelectorAll('.staff-slider');

  sliders.forEach(function(el) {
    // data属性から設定値を取得
    var autoplay = el.dataset.autoplay === 'true';
    var speed    = parseInt(el.dataset.speed, 10) || 3000;
    var items    = JSON.parse(el.dataset.items || '[]');

    if (!items.length) return;

    // スライダーの初期化(ライブラリに依存せずシンプルに実装)
    var current = 0;
    var slides  = [];

    items.forEach(function(item, i) {
      var slide = document.createElement('div');
      slide.className = 'staff-slider__slide' + (i === 0 ? ' is-active' : '');
      slide.innerHTML =
        '<img src="' + item.image + '?width=400&format=webp" alt="' + item.name + '">' +
        '<p class="staff-slider__name">' + item.name + '</p>' +
        '<p class="staff-slider__title">' + item.title + '</p>';
      el.appendChild(slide);
      slides.push(slide);
    });

    function goTo(index) {
      slides[current].classList.remove('is-active');
      current = (index + slides.length) % slides.length;
      slides[current].classList.add('is-active');
    }

    if (autoplay) {
      setInterval(function() { goTo(current + 1); }, speed);
    }
  });
})();

Section 4-12

meta.json — モジュールのメタ情報設定

meta.json はページエディターでのモジュールの表示・分類に関わる情報を定義します。 適切に設定することでマーケターがモジュールを見つけやすくなります。

meta.json — 完全設定例
{
  "label": "カードグリッド",
  // ページエディターに表示されるモジュール名(日本語OK)

  "icon": "layout",
  // アイコン名(HubSpot のアイコンセットから選択)
  // 例: "layout" / "image" / "form" / "blog" / "text" / "list"
  // など多数あり。HubSpotドキュメントで一覧を確認できる

  "categories": ["CONTENT"],
  // ページエディターのカテゴリ分類
  // "CONTENT" / "COMMERCE" / "FORMS" / "SOCIAL"
  // 複数指定可: ["CONTENT", "FORMS"]

  "content_types": ["SITE_PAGE", "LANDING_PAGE", "BLOG_POST"],
  // このモジュールが使えるページタイプを限定する
  // 省略すると全ページタイプで使用可能
  // "SITE_PAGE" / "LANDING_PAGE" / "BLOG_POST" / "BLOG_LISTING"

  "is_available_for_new_content": true,
  // false にすると新規ページへの追加が無効になる
  // 古い廃止予定モジュールの無効化に使う

  "smart_type": "NOT_SMART",
  // スマートコンテンツとして使うかどうか(Enterprise)
  // "NOT_SMART" / "SMART"

  "host_template_types": ["PAGE", "BLOG_POST"]
  // テンプレートタイプとの紐付け(content_typesと似た概念)
}

廃止予定モジュールの無効化パターン

リニューアルで古いモジュールを廃止する場合、 既存ページで使われているモジュールをいきなり削除するとページが壊れます。 is_available_for_new_content: false にして「新規追加を禁止」しながら、 既存ページへの影響を出さずに段階的に移行する方法が実案件で多用されます。


Section 4-13

実案件から学ぶ 5 つのベストプラクティス

741件のモジュール分析から選出した、 「このパターンを覚えるだけで品質が大きく上がる」5つの実装パターンです。

1

すべてのフィールドに意味のある default 値を設定する

フィールドに default が設定されていないと、新規ページ作成時にモジュールを配置した直後に 画像が表示されない・テキストが空のまま・レイアウトが崩れる等の問題が起きます。 「デフォルト状態でも見栄えする」ことがモジュール品質の最低ラインです。 特に image フィールドは {"src":"","alt":"","width":640,"height":360} を、 link フィールドは {"url":{"href":""},"open_in_new_tab":false} を必ず設定します。

2

画像は必ず存在チェック(.src)してから参照し、HubSpot変換パラメータを使う

{% if module.image.src %} の存在チェックは必須です({% if module.image %} だけでは不十分)。 そしてすべての画像に ?width=N&format=webp を付与します。 webp変換だけで平均30〜50%のファイルサイズ削減になります。 LCP要素(ファーストビューの最大画像)には loading="eager" fetchpriority="high"、 それ以外は loading="lazy" decoding="async" を使います。 この使い分けがCore Web Vitalsに直接影響します。

3

choice フィールドでCSSクラスを切り替えてレイアウトをコントロールする

レイアウトの変更をJavaScriptで実装するのではなく、 choice フィールドの値をCSSクラス名の一部として埋め込み(class="module--{{ module.layout }}")、 CSSだけで見た目を制御するパターンが最もシンプルで保守しやすいです。 マーケターはラジオボタンを選ぶだけでレイアウトが変わり、 エンジニアはCSSのルールを追加するだけで新しいバリアントを追加できます。

4

グループフィールドは「最小件数0・最大件数を現実的な数値」で設計する

occurrence.min: 0 を設定することで「アイテムがない場合は何も表示しない」状態を作れます。 module.html 側で {% if module.items %} と組み合わせることで、 任意のセクションをエンジニアなしで有効/無効にできます。 最大件数は「実際に使いそうな最大値の2倍」程度に設定しておくと余裕があります (3列カードなら12、FAQなら20程度)。

5

フィールドラベルを日本語にし、help_text で画像サイズ・文字数制限を明示する

"label": "bg_color" より "label": "背景色" の方がマーケターが迷いません。 さらに "help_text""推奨サイズ: 1440×640px、JPG形式、2MB以下" のような 具体的な指示を入れることで、マーケターが正しい画像を用意できます。 この1行が、「画像が変に見える」という保守依頼を何件も防ぎます。 特に image / richtext / text(長さ制限あり)フィールドへの設定を優先してください。

✅ モジュール開発の品質チェックリスト

□ すべてのフィールドに default 値が設定されているか
□ image フィールドは {% if module.xxx.src %} で存在チェックしているか
□ link フィールドは {% if module.xxx.url.href %} で存在チェックしているか
□ すべての画像に ?format=webp が付いているか
□ LCP要素に loading="eager" fetchpriority="high" が付いているか
target="_blank" のリンクに rel="noopener noreferrer" が付いているか
□ フィールドラベルが日本語になっているか
□ 画像フィールドに推奨サイズが help_text で記載されているか
□ module.css のクラス名が BEM に従っているか(Block名がモジュール名と対応しているか)
□ meta.json の label が日本語でわかりやすい名前になっているか


Section 4-14

第4章まとめ

📌 この章で押さえるべきポイント

5ファイル構成を体に覚える

fields.json / module.html / module.css / module.js / meta.json の5ファイルが1モジュール。CLIで雛形生成後、fields.json とmodule.html から実装を始めるのが正しい順序。

TOP5フィールドタイプを習得

text / image / boolean / choice / link の5つが実案件の9割をカバーする。次に group(繰り返し)をマスターすれば、FAQからカードグリッドまで何でも作れる。

存在チェックは型ごとに異なる

image → .src / link → .url.href / group → そのまま / text → そのまま。型に合った存在チェックを習慣化することで、エラーと表示崩れを防ぐ。

画像は変換パラメータ+loading属性の組み合わせ

?width=N&format=webp でファイルサイズを削減。LCP要素は eager+high、それ以外は lazy+async。この使い分けがCore Web Vitalsスコアを大きく左右する。

choice×CSSクラスでJSなしのバリアント

choice フィールドの値をCSSクラス名の一部として埋め込み、CSSだけでレイアウト・カラーを切り替えるパターンが最もシンプルで保守しやすい。

マーケターが使いやすい設計

日本語ラベル・help_text での画像サイズ明示・choice による入力制限・meaningful な default 値。開発時の丁寧さがクライアントの自走率を決定する。

次章:第5章 テーマ設計・構築

テーマ全体の設計思想・複数テンプレートの統合・グローバルスタイルの管理・theme.json による一元管理の実践まで解説します。

第5章へ →