seo/heading-level-skip · 見出しの順序
見出しのレベルは飛ばさずに使いましょう。
重大度: info
チェック内容
直前の見出しからレベルが2段以上跳ね上がる見出し(例: <h2> の直後に <h4>)を検出します。単一の <h1> の有無は seo/single-h1 が担当します。
なぜ重要か
見出しレベルを飛ばすとドキュメントアウトラインが崩れます。支援技術はこのアウトラインを頼りにページ構造を読み取っており、検索エンジンも構造シグナルの1つとして利用します。
修正方法
<h1>ページタイトル</h1><h2>セクション</h2><h3>サブセクション</h3>
モードによる違い
両モードとも見出しを収集しますが、収集元が異なるため結果が食い違うことがあります。
- ソース解析(CLI、ダッシュボードの静的ベースライン)はルートの
.svelteテンプレートを走査します。そのため、{#if false}のようなレンダリングされない分岐の中にある見出しも数えます。見出しは文書順に読みます。ページはレイアウトが<slot />/{@render children()}を描画する位置に、スニペットの見出しは{@render}の位置に、インポートした子コンポーネントが描画する見出し(seo/single-h1 と同じくたどります)はそのコンポーネントのタグの位置に置きます。コンポーネントの見出しは、ルート自身のファイルの見出しの直前の見出しとしてだけ使います。また、その見出しが描画されるときに必ず描画される場合に限ります。閉じたダイアログやメニューの中の見出しや、独自の{#if}の中に置いたコンポーネントの見出しは、間を埋めません。コンポーネントの見出しでのレベル飛ばしは報告しません。レイアウトの共通部分の飛ばしが、各ページ自身の指摘の代わりになってしまうのを避けるためです。{#each}の本体は空になりうるものとして読みます。直前の見出しがブロックの 1 つの分岐にある場合は、分岐ごとに 1 つの描画として扱います。各分岐の最後の見出しと比べ、最も低いレベルを使います。例外は、その見出しが判定する見出しの外側の{#each}で繰り返される場合です。最後の回にどの分岐を通るかはデータ次第なので、直前の見出しだけを使います。ソースからレベルが決まらない<svelte:element>(this={h${level}}やタグを受け取る prop)は、レベルを持たないまま位置だけを占め、その直後の見出しは判定しません。コンポーネントとして描画する markdown モジュール(mdsvex の.mdや.svxの import、<Content />)は、その ATX 見出し(## Part one)を読みます。コンポーネントがそうしたモジュールの 1 つを保持し($derived(sv ? Sv : En))、どれもが必ず見出しを描画する場合は、その位置にレベルが決まらない見出しを同じように置きます。role="heading"の要素は、そのaria-level(指定がなければ 2)の見出しとして数えます。aria-levelが式の場合は、同じくレベルを持たないまま位置だけを占めます。1 つの{#if}/{:else if}/{:else}や{#await}ブロックの各分岐は別々に読みます。<svelte:boundary>の中身とfailed/pendingスニペットも同様です。ページは、レイアウトの<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 を描画する位置が残らない場合、そのルートではページとその下のレイアウトを数えません。コンポーネントに渡した中身も、そのコンポーネントがそれを描画する分岐の中にあるものとして読みます。見出しは、同時に描画されうる直前の見出しと比べ、隣の分岐の見出しとは比べません。見出し自身の条件が、それより前の{:else}を持つブロックの 1 つの分岐としか両立しない場合は、その分岐と一緒に描画されるものとして読みます({#if !rule}…{:else}<h2>…{/if}の後の{#if open && rule}内の見出しは、その<h2>に続きます)。別々のブロックはすべて描画されるものとして扱います。ただし、同じ分岐にある{:else}のない{#if}(間に別のブロック・{#each}・コンポーネントを挟まないもの。要素は挟んでもかまいません)で、条件が 1 つの値について互いに矛盾するもの({#if pitch}と{#if !pitch})は、1 つのブロックの分岐として読みます。 - レンダリング解析(Vite プラグインのビルド、ダッシュボードで訪問したルート)はレンダリング済みの HTML を読みます。そのため、コンポーネントが描画した見出しも認識し、実際にレンダリングされた分岐だけを数えます。
両者が食い違う場合は、レンダリング結果を信頼してください。ブラウザに配信される内容を反映しているのはそちらです。
無効化
既存の検出は suppressions ファイルに記録して抑制できます(npx svelte-vitals --update-suppressions)。overrides でルートやパス単位に絞るか、ルールごと無効化するには、次のように設定します。
export default {
rules: {
'seo/heading-level-skip': 'off'
}
};