T-CREATOR

Astro の大規模ナビゲーション設計:メガメニュー/パンくず/サイト内検索

Astro の大規模ナビゲーション設計:メガメニュー/パンくず/サイト内検索

大規模な Web サイトを構築する際、ユーザーが迷わず目的のページにたどり着けるかどうかは、サイトの成功を左右する重要な要素です。数百、数千ページを抱えるサイトでは、適切なナビゲーション設計がなければ、どれだけ優れたコンテンツがあっても、ユーザーはそこにたどり着けません。

Astro で大規模サイトを開発する際、メガメニュー、パンくずリスト、サイト内検索という 3 つのナビゲーション要素をどう設計・実装するかは、開発者にとって避けては通れない課題です。本記事では、Astro における大規模ナビゲーションの設計パターンとベストプラクティスを、実装例を交えながら徹底解説します。この記事を読めば、ユーザビリティと保守性を両立したナビゲーションシステムを構築できるようになるでしょう。

背景

大規模サイトにおけるナビゲーションの役割

Web サイトが成長し、ページ数が増えるにつれて、ユーザーが情報を見つける難易度は指数関数的に上昇します。小規模サイトでは単純なヘッダーメニューで十分だったものが、大規模サイトでは複雑な階層構造を持つナビ �ة ーションシステムが必要になるのです。

ナビゲーションには主に 3 つの役割があります。第一に、サイト全体の構造を視覚的に示すこと。第二に、ユーザーの現在地を明確に伝えること。第三に、目的のページへ最短距離でたどり着けるショートカットを提供することです。

Astro が大規模サイト構築に適している理由

Astro は静的サイト生成(SSG)に特化したフレームワークで、ビルド時にすべてのページを事前生成します。これにより、大規模サイトでも高速なページ表示が可能になるのです。

また、Astro のコンポーネントベースの設計は、ナビゲーション要素の再利用性と保守性を高めます。メガメニュー、パンくず、検索バーといった各要素を独立したコンポーネントとして実装でき、サイト全体で一貫性を保ちながら効率的に管理できるでしょう。

大規模ナビゲーションの 3 本柱

本記事で扱う 3 つのナビゲーション要素は、それぞれ異なる役割を持ちます。

メガメニューは、多階層の情報を一度に表示し、ユーザーが深い階層のページに直接ジャンプできる仕組みです。パンくずリストは、現在地を示し、上位階層へ戻る導線を提供します。サイト内検索は、キーワードから直接目的のページを探せる機能です。

以下の図は、これら 3 つの要素がどのようにユーザーの行動をサポートするかを示しています。

mermaidflowchart TB
    user["ユーザー"]
    mega["メガメニュー<br/>(階層探索)"]
    breadcrumb["パンくずリスト<br/>(現在地確認)"]
    search["サイト内検索<br/>(直接検索)"]
    target["目的ページ"]

    user -->|"構造から探す"| mega
    user -->|"上位階層へ戻る"| breadcrumb
    user -->|"キーワードで探す"| search

    mega --> target
    breadcrumb --> target
    search --> target

この図から分かるように、ユーザーは状況に応じて異なるナビゲーション方法を選択します。3 つすべてを適切に実装することで、どのようなユーザーにも対応できる柔軟なナビゲーションシステムが完成するのです。

課題

大規模サイトのナビゲーション設計で直面する問題

大規模サイトのナビゲーションを設計する際、開発者は複数の技術的・設計的課題に直面します。これらの課題を理解することが、適切な解決策を選ぶ第一歩となるでしょう。

メガメニューの設計課題

メガメニューは視覚的に大きな面積を占めるため、パフォーマンスとユーザビリティのバランスが重要です。数百のリンクを含むメガメニューをすべてのページで読み込むと、HTML サイズが肥大化し、初期表示速度が低下します。

また、階層構造のデータ管理も課題です。カテゴリ、サブカテゴリ、個別ページという多層構造をどのように定義し、コンポーネントに渡すか。手動で配列を書くのは保守性が低く、ページ追加のたびに更新が必要になってしまいます。

さらに、レスポンシブ対応も悩みどころです。デスクトップでは横に広がるメガメニューが、モバイルではアコーディオン式に変化する必要があります。同じデータ構造から異なる UI を生成する設計が求められるのです。

パンくずリストの課題

パンくずリストは一見シンプルですが、動的生成の仕組みが必要です。すべてのページで手動でパンくずを書くのは現実的ではありません。URL パスから自動的に階層を判断し、適切なラベルとリンクを生成する仕組みが必要でしょう。

また、構造化データ(BreadcrumbList schema)の実装も重要です。検索エンジンがパンくずを理解し、検索結果に表示するには、JSON-LD 形式のメタデータが必要になります。

サイト内検索の課題

静的サイトでサイト内検索を実装する場合、バックエンドがないため検索インデックスをどこに持つかが課題です。ビルド時にすべてのページから検索インデックスを生成し、クライアント側で検索処理を行う必要があります。

検索インデックスのサイズも問題です。数千ページのサイトでは、インデックスファイルが数 MB に達することもあります。これをすべてのページで読み込むとパフォーマンスに影響するため、遅延読み込みや分割といった工夫が求められるでしょう。

以下の図は、これらの課題の関連性を示しています。

mermaidflowchart TD
    scale["大規模サイト<br/>(数百〜数千ページ)"]

    scale --> mega_issues["メガメニュー課題"]
    scale --> breadcrumb_issues["パンくず課題"]
    scale --> search_issues["検索課題"]

    mega_issues --> perf1["HTML 肥大化"]
    mega_issues --> data1["階層データ管理"]
    mega_issues --> responsive1["レスポンシブ対応"]

    breadcrumb_issues --> auto["自動生成の仕組み"]
    breadcrumb_issues --> schema["構造化データ"]

    search_issues --> index["インデックス生成"]
    search_issues --> size["ファイルサイズ"]

これらの課題に対して、Astro の機能を活用した解決策を次のセクションで詳しく見ていきましょう。

解決策

Astro における大規模ナビゲーション設計の基本方針

Astro で大規模ナビゲーションを実装する際は、3 つの基本方針を押さえることが重要です。

第一に、データ駆動型の設計です。ナビゲーション構造を TypeScript の型定義と JSON ファイルで管理し、コンポーネントはそれを受け取って描画するだけにします。これにより、構造変更時もデータファイルの編集だけで済むようになるでしょう。

第二に、段階的な読み込みです。すべてのナビゲーションデータを初回に読み込むのではなく、必要なタイミングで必要な分だけ読み込みます。メガメニューは hover 時、検索インデックスは検索ボックスのフォーカス時に読み込むといった工夫が効果的です。

第三に、SEO とアクセシビリティの両立です。構造化データを適切に実装し、キーボード操作にも対応することで、検索エンジンとスクリーンリーダーの両方に優しいナビゲーションを実現できます。

ナビゲーションデータの設計パターン

まず、ナビゲーション全体のデータ構造を TypeScript で型定義します。これにより、型安全性が保たれ、エディタの補完も効くようになります。

typescript// src/types/navigation.ts

/**
 * ナビゲーション項目の基本型
 */
export interface NavItem {
  // 表示テキスト
  label: string;

  // リンク先 URL
  href: string;

  // 子要素(サブメニュー)
  children?: NavItem[];

  // 説明文(メガメニュー用)
  description?: string;

  // アイコン名(オプション)
  icon?: string;
}

この型定義に基づいて、実際のナビゲーションデータを JSON ファイルとして管理します。

json// src/data/navigation.json
{
  "mainMenu": [
    {
      "label": "製品情報",
      "href": "/products",
      "description": "当社の製品ラインナップ",
      "children": [
        {
          "label": "ソフトウェア",
          "href": "/products/software",
          "children": [
            {
              "label": "開発ツール",
              "href": "/products/software/dev-tools"
            }
          ]
        }
      ]
    }
  ]
}

データとコンポーネントを分離することで、非エンジニアでもナビゲーション構造を編集できるようになります。

メガメニューの実装戦略

メガメニューは、階層構造を視覚的に展開する UI コンポーネントです。Astro での実装は、コンポーネントの再帰的な呼び出しと CSS によるスタイリングで実現できます。

typescript---
// src/components/MegaMenu.astro

import type { NavItem } from '../types/navigation';

interface Props {
  items: NavItem[];
}

const { items } = Astro.props;
---

コンポーネントの Props として、ナビゲーションデータの配列を受け取ります。この例では TypeScript の型チェックにより、正しいデータ構造が渡されることが保証されるのです。

astro<nav class="mega-menu" aria-label="メインナビゲーション">
  <ul class="mega-menu__list">
    {items.map((item) => (
      <li class="mega-menu__item">
        <a href={item.href} class="mega-menu__link">
          {item.label}
        </a>

        {/* 子要素がある場合はサブメニューを展開 */}
        {item.children && (
          <div class="mega-menu__dropdown">
            <ul class="mega-menu__sublist">
              {item.children.map((child) => (
                <li class="mega-menu__subitem">
                  <a href={child.href}>
                    <strong>{child.label}</strong>
                    {child.description && (
                      <span class="description">
                        {child.description}
                      </span>
                    )}
                  </a>
                </li>
              ))}
            </ul>
          </div>
        )}
      </li>
    ))}
  </ul>
</nav>

このマークアップでは、aria-label 属性でナビゲーションの役割を明示しています。スクリーンリーダーユーザーにとって、どのナビゲーションなのかが明確に伝わるでしょう。

次に、CSS でメガメニューのドロップダウン表示を制御します。

css/* メガメニューのドロップダウンは最初は非表示 */
.mega-menu__dropdown {
  display: none;
  position: absolute;
  top: 100%;
  left: 0;
  background: white;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  padding: 2rem;
  min-width: 600px;
}

/* hover 時にドロップダウンを表示 */
.mega-menu__item:hover .mega-menu__dropdown {
  display: block;
}

ただし、CSS の :hover だけではキーボード操作に対応できません。次にフォーカス管理を追加します。

css/* フォーカス時もドロップダウンを表示 */
.mega-menu__item:focus-within .mega-menu__dropdown {
  display: block;
}

:focus-within 擬似クラスを使うことで、子要素のいずれかにフォーカスがある間、ドロップダウンが表示され続けます。

パンくずリストの自動生成パターン

パンくずリストは、URL パスから自動生成する仕組みが効率的です。Astro の Astro.url.pathname を使って現在のパスを取得し、階層構造に分解します。

typescript---
// src/components/Breadcrumb.astro

interface BreadcrumbItem {
  label: string;
  href: string;
}

// 現在のパスを取得
const pathname = Astro.url.pathname;

// パスを '/' で分割して階層構造を作る
const segments = pathname.split('/').filter(Boolean);

パスを分割したら、各セグメントからパンくずの項目を生成します。

typescript// パスラベルのマッピング(日本語表示用)
const pathLabels: Record<string, string> = {
  'products': '製品情報',
  'software': 'ソフトウェア',
  'dev-tools': '開発ツール',
};

// パンくず項目を生成
const breadcrumbs: BreadcrumbItem[] = [
  { label: 'ホーム', href: '/' },
];

let currentPath = '';
segments.forEach((segment) => {
  currentPath += `/${segment}`;
  breadcrumbs.push({
    label: pathLabels[segment] || segment,
    href: currentPath,
  });
});
---

この処理により、​/​products​/​software​/​dev-tools というパスから、自動的に「ホーム > 製品情報 > ソフトウェア > 開発ツール」というパンくずが生成されるのです。

次に、構造化データを JSON-LD 形式で出力します。

astro<!-- 構造化データ(BreadcrumbList) -->
<script type="application/ld+json" set:html={JSON.stringify({
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": breadcrumbs.map((item, index) => ({
    "@type": "ListItem",
    "position": index + 1,
    "name": item.label,
    "item": `${Astro.site}${item.href}`
  }))
})} />

JSON-LD により、Google などの検索エンジンがパンくず構造を理解し、検索結果に表示してくれます。SEO 効果が期待できるでしょう。

パンくずリストのマークアップは、セマンティックな HTML で実装します。

astro<nav aria-label="パンくずリスト">
  <ol class="breadcrumb">
    {breadcrumbs.map((item, index) => (
      <li class="breadcrumb__item">
        {index < breadcrumbs.length - 1 ? (
          <a href={item.href}>{item.label}</a>
        ) : (
          <span aria-current="page">{item.label}</span>
        )}
      </li>
    ))}
  </ol>
</nav>

最後の項目(現在ページ)にはリンクを付けず、aria-current="page" 属性で現在地であることを明示します。

サイト内検索の実装パターン

静的サイトでサイト内検索を実装するには、ビルド時に検索インデックスを生成し、クライアント側で検索処理を行う方法が一般的です。

まず、ビルド時にすべてのページ情報を収集するスクリプトを作成します。

typescript// scripts/generate-search-index.ts

import { getCollection } from 'astro:content';
import fs from 'fs';

/**
 * 検索インデックスの項目型
 */
interface SearchIndexItem {
  title: string;
  url: string;
  description: string;
  content: string;
}

// すべてのページコンテンツを取得
const pages = await getCollection('blog');

Content Collections API を使って、すべてのブログ記事を取得します。この方法なら、Markdown ファイルのメタデータと本文に簡単にアクセスできるのです。

typescript// 検索インデックスを生成
const searchIndex: SearchIndexItem[] = pages.map(
  (page) => ({
    title: page.data.title,
    url: `/blog/${page.slug}`,
    description: page.data.description || '',
    content: page.body.slice(0, 500), // 本文の最初の500文字
  })
);

// JSON ファイルとして出力
fs.writeFileSync(
  'public/search-index.json',
  JSON.stringify(searchIndex)
);

console.log(
  `検索インデックスを生成しました: ${searchIndex.length} ページ`
);

このスクリプトを package.json のビルドコマンドに追加します。

json{
  "scripts": {
    "build": "yarn generate-index && astro build",
    "generate-index": "tsx scripts/generate-search-index.ts"
  }
}

ビルドのたびに自動的に最新の検索インデックスが生成されます。

次に、フロントエンド側で検索機能を実装します。ここでは軽量な検索ライブラリ Fuse.js を使用します。

typescript---
// src/components/SiteSearch.astro
---

<div class="site-search">
  <input
    type="search"
    id="search-input"
    placeholder="サイト内を検索..."
    aria-label="サイト内検索"
  />
  <div id="search-results" role="region" aria-live="polite"></div>
</div>

<script>
  import Fuse from 'fuse.js';

  let searchIndex = [];
  let fuse;

  // 検索ボックスにフォーカスした時だけインデックスを読み込む
  const searchInput = document.getElementById('search-input');

  searchInput.addEventListener('focus', async () => {
    if (searchIndex.length === 0) {
      // 初回のみ検索インデックスを fetch
      const response = await fetch('/search-index.json');
      searchIndex = await response.json();

      // Fuse.js の設定
      fuse = new Fuse(searchIndex, {
        keys: ['title', 'description', 'content'],
        threshold: 0.3,
      });
    }
  });

検索インデックスは、ユーザーが検索ボックスにフォーカスした時に初めて読み込みます。これにより、検索を使わないユーザーには負荷がかかりません。

typescript  // 入力内容が変更されたら検索を実行
  searchInput.addEventListener('input', (e) => {
    const query = e.target.value;

    if (query.length < 2) {
      // 2文字未満は検索しない
      document.getElementById('search-results').innerHTML = '';
      return;
    }

    // 検索実行
    const results = fuse.search(query);

    // 結果を表示
    displayResults(results);
  });

  function displayResults(results) {
    const resultsContainer = document.getElementById('search-results');

    if (results.length === 0) {
      resultsContainer.innerHTML = '<p>検索結果が見つかりませんでした</p>';
      return;
    }

    const html = results.slice(0, 10).map(({ item }) => `
      <article class="search-result">
        <h3><a href="${item.url}">${item.title}</a></h3>
        <p>${item.description}</p>
      </article>
    `).join('');

    resultsContainer.innerHTML = html;
  }
</script>

検索結果は最大 10 件に絞り、リアルタイムで表示が更新されます。ユーザーは入力しながら即座に結果を確認できるでしょう。

以下の図は、これら 3 つのナビゲーション要素がどのように連携して動作するかを示しています。

mermaidflowchart LR
    data["ナビゲーションデータ<br/>(JSON)"]

    data --> mega["メガメニュー<br/>コンポーネント"]
    data --> breadcrumb["パンくず<br/>コンポーネント"]

    build["ビルド時<br/>インデックス生成"]
    build --> search_index["search-index.json"]
    search_index --> search["検索<br/>コンポーネント"]

    mega --> page["ページ出力"]
    breadcrumb --> page
    search --> page

この設計により、データの一元管理とコンポーネントの独立性が保たれます。

具体例

実際のサイト構造でのメガメニュー実装

ここからは、実際のサイト構造を想定した実装例を見ていきましょう。例として、製品情報サイトのメガメニューを構築します。

まず、完全なナビゲーションデータを定義します。

typescript// src/data/mega-menu-data.ts

import type { NavItem } from '../types/navigation';

export const megaMenuData: NavItem[] = [
  {
    label: '製品情報',
    href: '/products',
    children: [
      {
        label: 'ソフトウェア',
        href: '/products/software',
        description: '開発支援ツールとアプリケーション',
        children: [
          {
            label: '開発ツール',
            href: '/products/software/dev-tools',
            description: 'コーディングを加速するツール群',
          },
          {
            label: 'デザインツール',
            href: '/products/software/design-tools',
            description: 'UI/UX デザインソフトウェア',
          },
        ],
      },
      {
        label: 'ハードウェア',
        href: '/products/hardware',
        description: '高性能デバイスとアクセサリ',
        children: [
          {
            label: 'ノート PC',
            href: '/products/hardware/laptops',
          },
          {
            label: 'デスクトップ',
            href: '/products/hardware/desktops',
          },
        ],
      },
    ],
  },
  {
    label: 'サポート',
    href: '/support',
    children: [
      {
        label: 'ドキュメント',
        href: '/support/docs',
      },
      {
        label: 'お問い合わせ',
        href: '/support/contact',
      },
    ],
  },
];

このデータ構造により、3 階層のメニューを表現できます。

次に、メガメニューコンポーネントを完成させます。

astro---
// src/components/MegaMenu.astro

import { megaMenuData } from '../data/mega-menu-data';
---

<nav class="mega-menu" aria-label="メインナビゲーション">
  <ul class="mega-menu__list">
    {megaMenuData.map((topItem) => (
      <li class="mega-menu__item">
        <a
          href={topItem.href}
          class="mega-menu__link"
          aria-haspopup={topItem.children ? 'true' : undefined}
        >
          {topItem.label}
        </a>

        {topItem.children && topItem.children.length > 0 && (
          <div class="mega-menu__panel">
            <div class="mega-menu__grid">
              {topItem.children.map((category) => (
                <div class="mega-menu__category">
                  <a href={category.href} class="mega-menu__category-link">
                    <h3>{category.label}</h3>
                    {category.description && (
                      <p class="mega-menu__description">
                        {category.description}
                      </p>
                    )}
                  </a>

                  {category.children && (
                    <ul class="mega-menu__sublist">
                      {category.children.map((item) => (
                        <li>
                          <a href={item.href}>
                            {item.label}
                            {item.description && (
                              <small>{item.description}</small>
                            )}
                          </a>
                        </li>
                      ))}
                    </ul>
                  )}
                </div>
              ))}
            </div>
          </div>
        )}
      </li>
    ))}
  </ul>
</nav>

aria-haspopup 属性により、スクリーンリーダーにサブメニューの存在を伝えます。

スタイリングでは、グリッドレイアウトを使って視覚的に整理します。

css.mega-menu__panel {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  background: white;
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
  padding: 2rem;
  display: none;
}

.mega-menu__item:hover .mega-menu__panel,
.mega-menu__item:focus-within .mega-menu__panel {
  display: block;
}

.mega-menu__grid {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(250px, 1fr)
  );
  gap: 2rem;
}

グリッドレイアウトにより、カテゴリ数に応じて自動的にカラム数が調整されます。

パンくずリストの実践的な実装

パンくずリストを実際のサイトで使えるよう、カスタマイズ可能な実装にします。

typescript---
// src/components/Breadcrumb.astro

interface Props {
  // 手動でパンくずを指定する場合
  customBreadcrumbs?: Array<{ label: string; href: string }>;
  // ページラベルのマッピング
  labelMap?: Record<string, string>;
}

const { customBreadcrumbs, labelMap = {} } = Astro.props;

// デフォルトのラベルマッピング
const defaultLabels: Record<string, string> = {
  'products': '製品情報',
  'software': 'ソフトウェア',
  'hardware': 'ハードウェア',
  'dev-tools': '開発ツール',
  'design-tools': 'デザインツール',
  'support': 'サポート',
  'docs': 'ドキュメント',
  'contact': 'お問い合わせ',
  ...labelMap,
};

let breadcrumbs;

if (customBreadcrumbs) {
  // 手動指定がある場合はそれを使用
  breadcrumbs = [{ label: 'ホーム', href: '/' }, ...customBreadcrumbs];
} else {
  // URL から自動生成
  const pathname = Astro.url.pathname;
  const segments = pathname.split('/').filter(Boolean);

  breadcrumbs = [{ label: 'ホーム', href: '/' }];

  let currentPath = '';
  segments.forEach((segment) => {
    currentPath += `/${segment}`;
    breadcrumbs.push({
      label: defaultLabels[segment] || segment,
      href: currentPath,
    });
  });
}
---

この実装により、自動生成と手動指定の両方に対応できます。

構造化データも含めた完全なマークアップは以下の通りです。

astro<script type="application/ld+json" set:html={JSON.stringify({
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": breadcrumbs.map((item, index) => ({
    "@type": "ListItem",
    "position": index + 1,
    "name": item.label,
    "item": new URL(item.href, Astro.site).toString()
  }))
})} />

<nav aria-label="パンくずリスト" class="breadcrumb-nav">
  <ol class="breadcrumb">
    {breadcrumbs.map((item, index) => (
      <li class="breadcrumb__item">
        {index < breadcrumbs.length - 1 ? (
          <>
            <a href={item.href} class="breadcrumb__link">
              {item.label}
            </a>
            <span class="breadcrumb__separator" aria-hidden="true">›</span>
          </>
        ) : (
          <span class="breadcrumb__current" aria-current="page">
            {item.label}
          </span>
        )}
      </li>
    ))}
  </ol>
</nav>

スタイリングでは、インラインレイアウトとアクセシブルなフォーカススタイルを実装します。

css.breadcrumb {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
  font-size: 0.875rem;
}

.breadcrumb__item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.breadcrumb__link {
  color: #0066cc;
  text-decoration: none;
}

.breadcrumb__link:hover,
.breadcrumb__link:focus {
  text-decoration: underline;
  outline: 2px solid #0066cc;
  outline-offset: 2px;
}

.breadcrumb__current {
  color: #333;
  font-weight: 600;
}

.breadcrumb__separator {
  color: #999;
}

これで、視覚的にも機能的にも優れたパンくずリストが完成します。

サイト内検索の完全実装

最後に、サイト内検索の完全な実装例を見てみましょう。

まず、より実用的な検索インデックス生成スクリプトです。

typescript// scripts/generate-search-index.ts

import { getCollection } from 'astro:content';
import fs from 'fs/promises';
import path from 'path';

interface SearchIndexItem {
  id: string;
  title: string;
  url: string;
  description: string;
  tags: string[];
  content: string;
  publishedAt: string;
}

async function generateSearchIndex() {
  console.log('検索インデックスを生成中...');

  // すべてのブログ記事を取得
  const blogPosts = await getCollection('blog');

  // 検索インデックスを生成
  const searchIndex: SearchIndexItem[] = blogPosts.map(
    (post) => {
      // Markdown の記号を除去
      const cleanContent = post.body
        .replace(/[#*`]/g, '')
        .replace(/\n+/g, ' ')
        .trim();

      return {
        id: post.id,
        title: post.data.title,
        url: `/blog/${post.slug}`,
        description: post.data.description || '',
        tags: post.data.tags || [],
        content: cleanContent.slice(0, 1000),
        publishedAt: post.data.publishedAt.toISOString(),
      };
    }
  );

  // public ディレクトリに出力
  const outputPath = path.join(
    process.cwd(),
    'public',
    'search-index.json'
  );
  await fs.writeFile(
    outputPath,
    JSON.stringify(searchIndex, null, 2)
  );

  console.log(
    `✓ 検索インデックスを生成しました: ${searchIndex.length} 件`
  );
}

generateSearchIndex().catch(console.error);

Markdown の記号を除去し、検索しやすいクリーンなテキストに変換しています。

次に、検索 UI コンポーネントの完全版です。

astro---
// src/components/SiteSearch.astro
---

<div class="site-search">
  <div class="site-search__input-wrapper">
    <input
      type="search"
      id="search-input"
      class="site-search__input"
      placeholder="記事を検索..."
      aria-label="サイト内検索"
      autocomplete="off"
    />
    <button
      id="search-clear"
      class="site-search__clear"
      aria-label="検索をクリア"
      style="display: none;"
    >
      ✕
    </button>
  </div>

  <div
    id="search-results"
    class="site-search__results"
    role="region"
    aria-live="polite"
    aria-atomic="true"
  ></div>
</div>

<style>
  .site-search {
    position: relative;
    width: 100%;
    max-width: 600px;
  }

  .site-search__input-wrapper {
    position: relative;
  }

  .site-search__input {
    width: 100%;
    padding: 0.75rem 2.5rem 0.75rem 1rem;
    border: 2px solid #ddd;
    border-radius: 8px;
    font-size: 1rem;
  }

  .site-search__input:focus {
    outline: none;
    border-color: #0066cc;
    box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
  }

  .site-search__clear {
    position: absolute;
    right: 0.5rem;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    cursor: pointer;
    padding: 0.5rem;
    color: #999;
  }

  .site-search__results {
    position: absolute;
    top: calc(100% + 0.5rem);
    left: 0;
    right: 0;
    background: white;
    border: 1px solid #ddd;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    max-height: 400px;
    overflow-y: auto;
    display: none;
  }

  .site-search__results:not(:empty) {
    display: block;
  }
</style>

<script>
  import Fuse from 'fuse.js';

  interface SearchIndexItem {
    id: string;
    title: string;
    url: string;
    description: string;
    tags: string[];
    content: string;
  }

  let searchIndex: SearchIndexItem[] = [];
  let fuse: Fuse<SearchIndexItem>;
  let isIndexLoaded = false;

  const searchInput = document.getElementById('search-input') as HTMLInputElement;
  const searchResults = document.getElementById('search-results') as HTMLElement;
  const clearButton = document.getElementById('search-clear') as HTMLButtonElement;

  // 検索インデックスを読み込む
  async function loadSearchIndex() {
    if (isIndexLoaded) return;

    try {
      const response = await fetch('/search-index.json');
      searchIndex = await response.json();

      fuse = new Fuse(searchIndex, {
        keys: [
          { name: 'title', weight: 3 },
          { name: 'description', weight: 2 },
          { name: 'tags', weight: 2 },
          { name: 'content', weight: 1 },
        ],
        threshold: 0.4,
        includeScore: true,
      });

      isIndexLoaded = true;
    } catch (error) {
      console.error('検索インデックスの読み込みに失敗しました:', error);
    }
  }

  // フォーカス時にインデックスを読み込む
  searchInput.addEventListener('focus', loadSearchIndex);

  // 検索実行
  searchInput.addEventListener('input', (e) => {
    const query = (e.target as HTMLInputElement).value;

    // クリアボタンの表示切替
    clearButton.style.display = query ? 'block' : 'none';

    if (query.length < 2) {
      searchResults.innerHTML = '';
      return;
    }

    if (!isIndexLoaded) {
      searchResults.innerHTML = '<div class="search-loading">読み込み中...</div>';
      return;
    }

    const results = fuse.search(query);
    displayResults(results, query);
  });

  // 検索結果を表示
  function displayResults(results: Fuse.FuseResult<SearchIndexItem>[], query: string) {
    if (results.length === 0) {
      searchResults.innerHTML = `
        <div class="search-empty">
          <p>「${query}」の検索結果が見つかりませんでした</p>
        </div>
      `;
      return;
    }

    const html = results.slice(0, 8).map(({ item, score }) => {
      // マッチ度をパーセンテージに変換
      const relevance = Math.round((1 - (score || 0)) * 100);

      return `
        <article class="search-result">
          <a href="${item.url}" class="search-result__link">
            <h3 class="search-result__title">${highlightMatch(item.title, query)}</h3>
            <p class="search-result__description">${item.description}</p>
            ${item.tags.length > 0 ? `
              <div class="search-result__tags">
                ${item.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
              </div>
            ` : ''}
            <div class="search-result__relevance" aria-label="関連度 ${relevance}%">
              関連度: ${relevance}%
            </div>
          </a>
        </article>
      `;
    }).join('');

    searchResults.innerHTML = html;
  }

  // マッチした部分をハイライト
  function highlightMatch(text: string, query: string): string {
    const regex = new RegExp(`(${query})`, 'gi');
    return text.replace(regex, '<mark>$1</mark>');
  }

  // クリアボタンの処理
  clearButton.addEventListener('click', () => {
    searchInput.value = '';
    searchResults.innerHTML = '';
    clearButton.style.display = 'none';
    searchInput.focus();
  });

  // 外側クリックで検索結果を閉じる
  document.addEventListener('click', (e) => {
    const target = e.target as HTMLElement;
    if (!target.closest('.site-search')) {
      searchResults.innerHTML = '';
    }
  });
</script>

この実装には以下の機能が含まれています。

  • 遅延読み込み: フォーカス時のみインデックスを読み込み
  • リアルタイム検索: 入力するたびに結果を更新
  • 関連度表示: マッチ度をパーセンテージで表示
  • ハイライト: マッチした部分を強調表示
  • キーボード操作: クリアボタンとフォーカス管理
  • アクセシビリティ: aria-live で結果の変化を読み上げ

これで、ユーザーフレンドリーな検索体験が実現できます。

まとめ

Astro で大規模サイトのナビゲーションを設計する際は、データ駆動型のアプローチが効果的です。メガメニュー、パンくず、サイト内検索という 3 つの要素を、それぞれ独立したコンポーネントとして実装することで、保守性の高いシステムが構築できるでしょう。

メガメニューでは、階層データを JSON で管理し、TypeScript の型定義で安全性を確保します。パンくずリストは URL パスから自動生成し、構造化データで SEO 効果を高められます。サイト内検索は、ビルド時のインデックス生成とクライアント側の検索処理を組み合わせることで、静的サイトでも高速な検索を実現できるのです。

これらの実装パターンを活用することで、数千ページ規模のサイトでも、ユーザーが迷わず目的の情報にたどり着けるナビゲーションシステムが完成します。アクセシビリティと SEO も考慮した設計により、すべてのユーザーにとって使いやすいサイトになるでしょう。

ナビゲーション設計は、サイトの成長とともに進化させていくものです。本記事で紹介したパターンを基礎として、自サイトの特性に合わせてカスタマイズし、最適なユーザー体験を提供してください。

関連リンク