seo/single-h1 · 見出し階層
<h1> は 1 ページにつき 1 つだけにしましょう。
重大度: warning(<h1> なし)/ info(<h1> が複数)
チェック内容
<h1> が 0 個(主見出しなし、warning)、または 2 個以上(info)のページを検出します。ちょうど 1 個で合格です。ページのレイアウトチェーンにある見出しもそのルートの集計に含めるため、+layout.svelte の <h1> もカウントされます。
グローバルな重大度上書き(rules: { 'seo/single-h1': <severity> })は両方のケースに一律で適用されます。設定はルール ID 単位のキーであり、どちらのケースで検出されたかは区別しないため、上書きすると分割は 1 つの重大度に均されます。
なぜ重要か
<h1> はページの主トピックを表す見出しです。<h1> が 0 個だとページに主見出しがなくなります。シグナルが本当に欠けている状態なので warning です。一方、単一の明確な <h1> はページのトピックを示す慣例的なシグナルですが、複数の <h1> は現代の見出しアルゴリズムでも許容されています。複数あることでランキングにペナルティが科されるという公式情報源はないため、こちらはスタイル上の指摘(info)にとどめ、欠陥としては扱いません。
修正方法
<h1>ページ唯一の、内容を表す説明的なメインの見出し</h1>
<h2>サブセクション</h2>
<h2>別のサブセクション</h2>
モードによる違い
両モードとも見出しを収集しますが、収集元が異なるため結果が食い違うことがあります。
- ソース解析(CLI、ダッシュボードの静的ベースライン)はルートの
.svelteテンプレートを走査します。インポートしたローカルコンポーネントが描画する見出しも(head 解決と同じ、深さ制限付きの走査で再帰的に)追跡するため、<h1>を$libコンポーネントに切り出しても検出されます。barrel のindex.ts経由で再エクスポートされたコンポーネント、名前空間のメンバー(<Ui.Title>)、インポートしたコンポーネントを保持するconst/$derived/{@const}や、関数がimport()で代入するletも追跡します。コンポーネントとして描画する markdown モジュール(mdsvex の.mdや.svxの import)も追跡し、その ATX 見出しを読みます。複数の候補のどれかを保持する場合、そのいずれかに<h1>があれば、そのルートを「<h1>が見つかりません」とは報告しません。import()で読み込むコンポーネントはブラウザでしか描画されないため、その<h1>がこの扱いになるのはssr = falseのルートだけです。1 つの{#if}/{:else if}/{:else}や{#await}ブロックの各分岐は同時に描画されません。<svelte:boundary>の中身とfailed/pendingスニペットも同様です。そのため、<h1>が最も多い分岐だけを数えます。分岐には、その中に置いたコンポーネントも含みます。ページは、レイアウトの<slot />/{@render children()}がある分岐の中で描画されるものとして扱います。1 つの{#if}の複数のアームで描画するレイアウトでは、ページをそれぞれのアームに置きます。別々のブロックに分かれている場合は、共通する位置に置きます。page.url.pathname($page.url.pathnameも。直接でも$derived経由でも)を文字列リテラルとstartsWith・endsWith・includes・===で比べる分岐は、そのルートのパスでは成り立たない場合に除外します。同じ読み取りで、レイアウトやページのどの{#if}もルートごとに判定します。条件には、page.route.id($page.route.id)と文字列リテラルの比較や配列リテラルか、ファイル内で書き換えない文字列だけのconst配列のincludes(['/a', '/b'].includes(page.route.id))、正規表現リテラルによるパスの.test()、同じ配列の要素ごとにパスをstartsWith・endsWith・includesで調べる.some(PUBLIC.some((r) => page.url.pathname.startsWith(r)))、パスとテンプレートリテラルの===(`/c/${slug}`。各${…}はその位置のルートパラメータとみなします)も使えます。アプリ自身のモジュールがリテラルfalseのconstとして名前付きで export する値(export const FLAG = false)を import し、その値だけで結果が決まる{#if}では、描画されない側の分岐も除外します。除外するのは{#if FLAG}と{#if FLAG && …}の最初の分岐、{#if !FLAG}と{#if !FLAG || …}の{:else}です。{#if !FLAG && …}と{#if FLAG || …}は、もう一方の条件しだいなので判定しません。その結果レイアウトが children を描画する位置が残らない場合、そのルートではページとその下のレイアウトを数えません。コンポーネントに渡した中身(子要素や、タグ内の{#snippet})は、そのコンポーネントがそれを描画する分岐に置きます。そのため、{@render children()}とエラー画面を 1 つの<svelte:boundary>や{#if}に置くラッパーのエラー用<h1>は、ラップしたページの<h1>に加算しません。追跡できないコンポーネントに渡した中身は、書いた位置のまま扱います。ファイル内で描画するスニペットは、各{@render}の位置で、その分岐の見出しとして数えます。別々のブロックは両方の条件が成り立ちうるので合算します。ただし、同じ分岐にある{:else}のない{#if}(間に別のブロック・{#each}・コンポーネントを挟まないもの。要素は挟んでもかまいません)で、条件が 1 つの値について互いに矛盾するもの({#if pitch}と{#if !pitch}、{#if step === 0}と{#if step === 1}、異なる 2 つの数値への==。&&の条件の 1 つとしても)は、1 つのブロックの分岐として数えます。同じファイルの 2 か所で、{:else}のない{#if}の下にあり、外側のブロックからたどった条件が同時には成り立たない場合も同様です({#if a || b}と、{#if !a}の中の{#if !b}など)。{:else}のない{#if}の下にある見出しは、その条件で{:else}のあるブロックの分岐が 1 つに決まる場合、その分岐の見出しとして数えます({#if signedIn}<h1>…{/if}と、{#if signedIn}の{:else}にある<h1>など)。条件は、参照とそのリテラルへの===/!==を&&・||・!で組み合わせたものとして読みます。{#each}、引数を取るスニペット、{:then}の中の条件は複数の値に対して評価されるため、対象外です。コンポーネント内で 1 つの prop だけで決まる{#if}({#if isHome}、{#if !title}、{#if variant === 'detail'}、{#if typeof title === 'string'}、{#if title.length > 0})は、呼び出し側がその prop をリテラルで渡すか渡さない(デフォルト値、なければundefined)場合、その prop が選ぶ分岐だけを数えます。prop を trim や既定値で包むだけの$derived($derived(title?.trim() ?? ''))を通した場合も同じです。リテラルで受け取った prop を、間のコンポーネントが書き換えずにそのまま渡す場合(<Media {view} />)も、その値で判定します。空でない文字列リテラルを渡す関数呼び出し(t('Title')のような翻訳呼び出し)は、{#if title}では空でない文字列として扱います。式やスプレッドで渡す場合、bind する場合、コンポーネント自身が書き換える場合は両方の分岐を数えます。そうした判定を&&や||でつないだ条件({#if title || header})も、呼び出し側がそれぞれを決めるなら判定します。分割代入しない$props()の束縛のメンバーとして prop を読む場合(let props = $props()、{#if props.title})も同じです。そうした判定を保持する$derivedに対する{#if}も同じように判定します(const embedded = $derived(variant === 'embedded')、{#if embedded})。{#if false}のようなレンダリングされない分岐や、それ以外の実行時の条件に依存する分岐の中の見出しも数えてしまう一方、インストールしていない npm パッケージのコンポーネント(アプリが宣言したワークスペースパッケージと、インストール済みの Svelte パッケージはたどります。設定を参照)や、実行時にデータや対応表から選ばれるコンポーネントは見えません。そのため、解決できないコンポーネント内の見出しは依然として偽の「<h1>が見つかりません」を招くことがあります。また、別々の条件ブロックにある<h1>は、infoレベルの過剰カウントを招くことがあります。role="heading"の要素は、aria-levelにかかわらず<h1>として数えません。<svelte:element this={…}>は、タグの式が文字列リテラルの場合、または両方の分岐がリテラルで見出しレベルが 1 つに定まる条件式(this={featured ? 'h1' : 'span'})の場合、解決先の要素としてカウントします。this={`h${level}`}・変数・見出しレベルが 2 つに分かれる条件式のようにタグを特定できない場合は、その要素がページの<h1>である可能性があるため、「<h1>が見つかりません」と報告せず、そのルートを対象外にします。<svg>の中(<foreignObject>を含まない場合)は見出しになりえないので、図形を<svelte:element this={tag}>で描くアイコンコンポーネントでは対象外にしません。 たどれないコンポーネント(インストールしていないパッケージのものなど)に、tag・as・element・isのいずれかに文字列リテラルh1を渡している場合(<Heading tag="h1">)も同じ扱いです。そのコンポーネントがページの<h1>を描画する可能性があるため、そのルートは報告しません。ほかの属性(value="h1")やほかのレベル(tag="h3")は<h1>の有無と関係がないため、対象外です。 - レンダリング解析(Vite プラグインのビルド、ダッシュボードで訪問したルート)はレンダリング済みの HTML を読みます。そのため、コンポーネントが描画したすべての見出しを認識し、実際にレンダリングされた分岐だけを数えます。
両者が食い違う場合は、レンダリング結果(@svelte-vitals/vite)を信頼してください。ブラウザに配信される内容を反映しているのはそちらです。
無効化
既存の検出は suppressions ファイルに記録して抑制できます(npx svelte-vitals --update-suppressions)。overrides でルートやパス単位に絞るか、ルールごと無効化するには、次のように設定します。
export default {
rules: {
'seo/single-h1': 'off'
}
};