HubSpot CMS 開発の核心はカスタムモジュールにあります。741件のモジュール分析から導いたフィールドタイプの使用頻度・グループ/繰り返しフィールドの設計・module.html の全実装パターン・CSS/JS 設計・meta.json 設定まで。現場で即使えるモジュール開発の完全ガイドです。
HubSpot CMS のカスタムモジュールは1モジュール=1ディレクトリ(.module)の単位で管理します。 そのディレクトリには必ず5種類のファイルが含まれます。 このセットが「モジュール開発の基本単位」です。
# 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件全モジュールの分析から得られたパターンを体系化します。
741件全モジュールの fields.json を走査して集計した、
フィールドタイプの実際の使用頻度ランキングです。
「どのフィールドタイプを先に習得すべきか」の優先度判断に使ってください。
text / image / boolean / choice / link の5つが 実案件で最も頻繁に使われるフィールドタイプです。 この5つの設計パターンを習得することが最優先です。 group は構造が複雑ですが、FAQ・カードグリッド・タブ等あらゆる繰り返しコンテンツに必要なため、 必ずマスターしてください。
[
{
// ===== 必須プロパティ =====
"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"(スタイルタブ)
}
]
| 場面 | 推奨タイプ | 理由 |
|---|---|---|
| CTAボタン(テキスト+リンク先) | link | 別タブ開く・nofollowまで1フィールドで管理できる |
| 外部埋め込みURLのみ | url | link より軽量。テキストやターゲット設定が不要な場合 |
| 本文・説明文(書式あり) | richtext | 太字・リスト・リンク等の書式設定が必要な場合 |
| 見出し・ラベル(書式なし) | text | プレーンテキストのみでよい場合はtextが軽量・シンプル |
| 表示/非表示のオンオフ | boolean | トグルUIで直感的。{% if module.xxx %}で分岐 |
| レイアウト・スタイルの切り替え | choice | 想定外の値の入力を防ぎ、エディターの使いやすさを確保 |
"type": "group" と occurrence の組み合わせが、
HubSpot CMS で最も重要かつ複雑な設計パターンです。
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>"
}
]
}
]
[
{
"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 }
}
]
}
]
[
{
"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": "テキスト" }
]
}
]
}
]
"min": 0 にすると、エディターでアイテムを0件にすることができます。
上の例のアイコンリストのように「使わない場合は空にできる」オプション的なセクションを実現できます。
module.html 側で {% if module.items %} と存在チェックを組み合わせて使います。
choice フィールドはマーケターが「コードを触らずに見た目を変更できる」
最強の仕組みです。実案件の分析で確認された代表的な3パターンを紹介します。
// 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 { "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; }
{% 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 %}
module.html では module.フィールド名 でフィールド値を参照します。
存在チェックなしで参照すると値がない場合にエラーになるため、
適切なチェックパターンを使い分けることが必須です。
{# ===== テキスト系(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>
画像はパフォーマンスに直結するため、
HubSpot の画像変換機能・loading 属性・fetchpriority の
正しい設定が非常に重要です。実案件から抽出した完全実装パターンを紹介します。
{# ===== パターン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); } */
| パラメータ | 値の例 | 効果 |
|---|---|---|
| ?width=N | width=800 | 幅をNpxにリサイズ(高さはアスペクト比を維持) |
| ?height=N | height=600 | 高さをNpxにリサイズ |
| ?format=webp | format=webp | WebP形式に変換(ファイルサイズ削減に効果大) |
| ?format=jpg | format=jpg | JPEG形式に変換 |
| ?quality=N | quality=80 | 画質を0〜100で指定(デフォルト80) |
| 複数組み合わせ | width=800&format=webp&quality=85 | &でパラメータを連結できる |
{# ===== 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 %}
target="_blank" でリンクを開く場合、rel="noopener noreferrer" を
必ず付けることがセキュリティ上必須です。
これがないと開いたページから元のページを操作される(Tabnabbing攻撃)リスクがあります。
module.cta.open_in_new_tab が true の場合は条件付きで付与し、
false の場合でも rel="noopener noreferrer" だけは付けておくのがベストプラクティスです。
{# ===== 基本ループ + 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 %}
HubL のループ内では変数の再代入ができません。
ループをまたいで状態を持ち越したい場合は 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 %}
実案件のコードベース分析で確認された命名パターンはBEM(Block__Element--Modifier)です。 モジュール名をBlock名のプレフィックスにすることで、グローバルスコープとの衝突を防ぎます。
/* =================================================== 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; } }
実案件の分析では CSS変数(var(--xxx))の参照よりも HubL で直接値を展開するパターンが多く確認されましたが、 保守性の観点から CSS変数 経由での参照を推奨します。 CSS変数は base.html または 01-settings.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;
}
実案件の分析では、JSを持つモジュールは少数派でした。 アコーディオン・タブ・スライダーのような動的UIにのみ module.js を使います。 シンプルなコンテンツ表示にJSは不要です。
/* 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.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); } }); })();
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 にして「新規追加を禁止」しながら、
既存ページへの影響を出さずに段階的に移行する方法が実案件で多用されます。
741件のモジュール分析から選出した、 「このパターンを覚えるだけで品質が大きく上がる」5つの実装パターンです。
フィールドに default が設定されていないと、新規ページ作成時にモジュールを配置した直後に
画像が表示されない・テキストが空のまま・レイアウトが崩れる等の問題が起きます。
「デフォルト状態でも見栄えする」ことがモジュール品質の最低ラインです。
特に image フィールドは {"src":"","alt":"","width":640,"height":360} を、
link フィールドは {"url":{"href":""},"open_in_new_tab":false} を必ず設定します。
{% 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に直接影響します。
レイアウトの変更をJavaScriptで実装するのではなく、
choice フィールドの値をCSSクラス名の一部として埋め込み(class="module--{{ module.layout }}")、
CSSだけで見た目を制御するパターンが最もシンプルで保守しやすいです。
マーケターはラジオボタンを選ぶだけでレイアウトが変わり、
エンジニアはCSSのルールを追加するだけで新しいバリアントを追加できます。
occurrence.min: 0 を設定することで「アイテムがない場合は何も表示しない」状態を作れます。
module.html 側で {% if module.items %} と組み合わせることで、
任意のセクションをエンジニアなしで有効/無効にできます。
最大件数は「実際に使いそうな最大値の2倍」程度に設定しておくと余裕があります
(3列カードなら12、FAQなら20程度)。
"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 が日本語でわかりやすい名前になっているか
fields.json / module.html / module.css / module.js / meta.json の5ファイルが1モジュール。CLIで雛形生成後、fields.json とmodule.html から実装を始めるのが正しい順序。
text / image / boolean / choice / link の5つが実案件の9割をカバーする。次に group(繰り返し)をマスターすれば、FAQからカードグリッドまで何でも作れる。
image → .src / link → .url.href / group → そのまま / text → そのまま。型に合った存在チェックを習慣化することで、エラーと表示崩れを防ぐ。
?width=N&format=webp でファイルサイズを削減。LCP要素は eager+high、それ以外は lazy+async。この使い分けがCore Web Vitalsスコアを大きく左右する。
choice フィールドの値をCSSクラス名の一部として埋め込み、CSSだけでレイアウト・カラーを切り替えるパターンが最もシンプルで保守しやすい。
日本語ラベル・help_text での画像サイズ明示・choice による入力制限・meaningful な default 値。開発時の丁寧さがクライアントの自走率を決定する。