a11y/duplicate-landmark · ランドマークの重複
1つのルートに main・banner・contentinfo ランドマークはそれぞれ1つまでにしましょう。
重大度: warning · カテゴリ: a11y
チェック内容
ルートを構成するレイアウトチェーン(+layout.svelte から +page.svelte まで)と、そこから解決したローカルコンポーネントを合わせて、main・banner・contentinfo のいずれかのランドマークが2つ以上現れるルートを検出します。分岐を考慮して判定し、{#if}/{#await} ブロックでは出現数が最も多いアーム(同数ならドキュメント順で先のアーム)だけを数えるため、1 つの {#if} のアーム同士が合算されることはありません。ただし別々の {#if} ブロック同士は独立です(条件式を評価するわけではないため)。また {#each}/{#snippet} の中身は0〜N回描画されるため対象外です。ページ(と内側のレイアウト)は親レイアウトが {@render children()} または <slot /> を描画する位置に置かれるので、レイアウトの別のアームもページと排他的に扱います。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 を描画する位置が残らない場合、そのルートではページとその下のレイアウトを数えません。コンポーネント内で 1 つの prop だけで決まる {#if} は、呼び出し側がその prop をリテラルで渡すか渡さない場合、その prop が選ぶ分岐だけを数えます(seo/single-h1 と同じ)。そうした判定を && や || でつないだ条件({#if showcase && layout === 'cover'})も、呼び出し側がそれぞれを決めるなら判定します。分割代入しない $props() の束縛のメンバーとして prop を読む場合({#if props.title})も同じです。そうした判定を保持する $derived に対する {#if}($derived(variant === 'embedded') の {#if embedded})も同じです。レイアウトやページ自身のランドマークは、それがある分岐と、上位のレイアウトが children を描画する分岐が load データの同じフィールドについて両立しない位置には置きません。data プロップ、$app/state の page.data、$app/stores の $page.data、それらを保持する $derived は、チェーンのどのファイルでも同じ値として読みます。{#if data.user}<main>{@render children()}</main>{:else}{@render children()}{/if} のレイアウトと {#if !data.user}<main>…</main>{/if} のページでは、<main> は 1 つと数えます。そのレイアウトより下の load がそのフィールドに別の値を返す場合は読まないため、各ファイルは同じ値を見るとみなします。navigation(<nav>)と complementary は意図的にこの集合の外です: APG はページ内にそれぞれ複数あることを認めています。
ランドマークはファイルをまたいで検出します。+layout.svelte の <main> と +page.svelte の別の <main> は、1つのルートに main ランドマークが2つある状態です。レイアウトの <main> と、インポートした $lib コンポーネントが描画する <main> の組み合わせも同様です。<main> および明示的な role="banner"/role="contentinfo" は、ルートを構成するどこにあっても数えます。一方 <header>/<footer>(暗黙の banner/contentinfo ランドマーク)は、チェーン内のファイルのテンプレート最上位にある場合のみ数えます。ネストしたコンポーネントの中にある場合、それを使う側のコンポーネント次第でセクショニングコンテンツの中に置かれる可能性があり、そこまで数えると存在しない重複を作り出しかねないためです。
3種類のランドマークがいずれも存在しないルートは、何も出力しません。
なぜ重要か
支援技術のユーザーは、main・banner・contentinfo といったランドマーク間をキー操作でジャンプし、繰り返しの定型部分を飛ばしてページの本文にたどり着きます。同じ種類のランドマークが複数あると、このジャンプ先が曖昧になります。どちらが本当のページ本文で、どちらが本当のサイトヘッダーなのか分からなくなるのです。
修正方法
1つのルートにつき <main>、<header>/role="banner"、<footer>/role="contentinfo" をそれぞれ1つに保ちます。レイアウトが既にこれらのどれかを描画しているなら、ページ側(やインポートしたコンポーネント)で重ねて描画しないようにします。
<header>すべてのルートに表示するナビゲーション</header>
<main>{@render children()}</main>
<footer>すべてのルートに表示するフッター</footer>
<h1>ページの本文</h1>
<!-- ここに2つ目の <main> を置かない -->
モードによる違い
両モードともランドマークを収集しますが、収集元が異なるため結果が食い違うことがあります。
- ソース解析(CLI、ダッシュボードの静的ベースライン)は、ルートのレイアウトチェーンと、そこから解決したローカルコンポーネントを、分岐を考慮した判定で合成します。
{#if}/{#await}ブロックでは出現数が最も多いアームだけを数えるため、実際にはレンダリングされないアームを選んでしまうことがあります。解決できないコンポーネント(node_modulesや、実行時にデータや対応表から選ばれるコンポーネント)が持つランドマークは見えず、{#each}/{#snippet}の中身は0〜N回描画されるため対象外です。 - ソース解析(CLI、ダッシュボードの静的ベースライン)はまた、
<header>/<footer>がランドマークかどうかを、自ファイル内の位置で決めます。チェーンファイル(レイアウトまたはページ)のテンプレートのトップレベルにあるときだけbanner/contentinfoと数え、コンポーネント内では数えません。コンポーネントの header は、そのコンポーネントから見えないセクショニング内容の下にあるかもしれないためです。これは両方向の近似です: コンポーネント自身のトップレベルの<header>は数えないので、同じコンポーネントが 1 ルートに 2 つあっても描画後の DOM のようにはbannerの重複になりません。レイアウトがページを<article>・<section>・<nav>で包む場合はそれを検出できず、ページのトップレベルの<header>を数えてしまいます。レイアウトがページを<main>か<aside>の中に描画する場合は扱えており、ページのトップレベルの<header>/<footer>は数えません。 - レンダリング解析(Vite プラグインのビルド、ダッシュボードで訪問したルート)はレンダリング済みの HTML を読みます。そのため、実際にレンダリングされた分岐だけを認識し、
{#each}が生成したランドマークもすべて数えます。ソースファイルを持たないため、検出結果の位置は特定のファイル・行ではなくルート自体に紐づきます。同じ不具合でも、永続化される検出キーはモードによって異なります。
両者が食い違う場合は、レンダリング結果を信頼してください。ブラウザに配信される内容を反映しているのはそちらです。
無効化
検出が指す行の直上にインラインの svelte-vitals-disable-next-line コメントを置けば抑制できます(ソース解析のみ。ビルドの検出はプリレンダリングされた HTML を指すため、直上に置くべきソース行がありません)。その行が合成されたコンポーネント内にある場合、1 つのディレクティブがそれを合成するすべてのルートで効きます。ルート単位で抑制したい場合は suppressions ファイル(npx svelte-vitals --update-suppressions)を使ってください。overrides でルートやパス単位に絞るか、ルールごと無効化するには、次のように設定します。
export default {
rules: {
'a11y/duplicate-landmark': 'off'
}
};