Обычно поиск по сайту делает сервер: запрос уходит в базу, ответ приходит обратно. Для сайта в полсотни страниц это лишнее. Проще собрать все заголовки и описания в один файл и искать по нему прямо в браузере — быстро и без задержек.

Как это устроено

Поиск состоит из трёх частей, и самая важная — первая.

Три части поиска
Часть Что делает
Индекс Файл со списком всех страниц: заголовок, адрес, раздел, описание, ключевые слова
Окно поиска Поле ввода и список найденного, открывается из шапки
Скрипт Загружает индекс при первом открытии окна и отбирает подходящие строки

Границы этого способа

Индекс целиком грузится в браузер, поэтому способ хорош до нескольких сотен страниц. На сайте в тысячи страниц файл станет тяжёлым, и поиск придётся переносить на сервер.

Индекс: строка на страницу

Индекс — обычный файл JSON. В нём не весь текст страниц, а то, по чему ищут: заголовок, короткое описание и несколько слов, которых в заголовке нет, но которые люди вводят.

search-index.json
[
  {
    "title": "Хлебные крошки",
    "url": "/manualy/hlebnye-kroshki/",
    "section": "Мануалы",
    "desc": "Путь по сайту списком ссылок и микроразметка BreadcrumbList",
    "keywords": "крошки навигация путь breadcrumbs"
  }
]

Писать этот файл руками нельзя: он разойдётся с сайтом на третьей же новой странице. Его собирает скрипт из того же списка страниц, из которого берутся меню и крошки, — у меня это docs/pages.json. Одно место правды, а всё остальное считается из него.

Отбор: с чем сравнивать введённое

Простое сравнение подстрок даёт плохие ответы. Три поправки превращают его в приличный поиск.

  • Привести к одному виду. И запрос, и строки индекса перевести в нижний регистр, а «ё» заменить на «е»: иначе «крошки» и «Крошки» — разные слова.
  • Искать по всем словам запроса. Запрос разбивается по пробелам, и страница подходит, только если в ней нашлось каждое слово. Иначе по запросу «хлебные крошки» вылезут все страницы, где есть слово «крошки».
  • Считать вес. Совпадение в заголовке важнее совпадения в описании: дайте заголовку три очка, ключевым словам два, описанию одно — и отсортируйте по сумме.
Отбор и сортировка
const norm = (s) => s.toLowerCase().replace(/ё/g, 'е');

function find(query, index) {
  const words = norm(query).split(/\s+/).filter(Boolean);
  if (!words.length) return [];

  return index
    .map((page) => {
      let score = 0;
      for (const word of words) {
        if (norm(page.title).includes(word)) score += 3;
        else if (norm(page.keywords).includes(word)) score += 2;
        else if (norm(page.desc).includes(word)) score += 1;
        else return null;            // нет хотя бы одного слова — не подходит
      }
      return { page, score };
    })
    .filter(Boolean)
    .sort((a, b) => b.score - a.score);
}

Окно: клавиатура решает всё

Поиском пользуются с клавиатуры, и это задаёт список требований. Окно открывается кнопкой в шапке и закрывается по Esc, фокус при открытии сразу в поле ввода, стрелки вверх и вниз ходят по найденному, Enter открывает выбранное. После закрытия фокус возвращается на кнопку, с которой всё началось.

Список результатов обновляется при вводе, а значит, его изменения нужно объявлять: блоку со списком ставится role="status" или рядом кладётся строка «Найдено 4 страницы» — иначе человек, который не видит экран, не узнает, что ответ уже готов.

Индекс грузить не сразу

Файл нужен только тому, кто открыл поиск. Загружайте его при первом открытии окна и запоминайте — тогда он не утяжеляет каждую страницу сайта. И само окно открывайте тегом dialog: Esc, затемнение и удержание фокуса внутри достанутся даром.

Что попросить у нейросети

Мой запрос
Ответ нейросети

Про подсветку. Требование верное и важное. Собирать строку с тегами и класть её в innerHTML опасно: текст из файла попадёт в разметку как есть. Безопасный путь — резать строку на куски и добавлять их через createTextNode, а совпадения оборачивать в созданный элемент mark.

Про частоту отбора. На полусотне страниц отбор занимает доли миллисекунды, откладывать его не нужно. Задержка после ввода понадобится позже, если индекс вырастет в разы.

Про стрелки и поле ввода. Не уводите фокус из поля на элементы списка: человек перестанет набирать. Правильнее оставить фокус в поле и отмечать текущий результат атрибутом aria-activedescendant.

Про пустой ответ. Покажите строку «Ничего не нашлось» и рядом ссылку на карту сайта. Пустое окно человек читает как поломку поиска.

Поиск после переезда на CMS

На этом сайте файла в корне больше нет, а адрес остался прежним. /search-index.json — обычный ресурс дерева: шаблона у него нет, тип содержимого — application/json, а в поле «Содержимое» стоит один вызов сниппета. Сниппет обходит дерево и отдаёт тот же JSON, что раньше лежал файлом.

Пересобирать больше нечего: страницу завели в админке — она в поиске с первого же открытия окна. Тем же приёмом сделаны robots.txt, sitemap.xml и llms.txt: пустой ресурс, свой тип содержимого, сниппет внутри.

Второй путь — искать прямо по базе готовым сниппетом. Он найдёт и то, что написано в середине текста, а не только в заголовках, но отвечает перезагрузкой страницы и запросом к базе на каждый поиск. Для справочника из полусотни страниц мгновенный ответ важнее полноты, поэтому индекс остался.

Частые ошибки

Чем чаще всего ломают поиск
Ошибка Чем плохо Как правильно
Индекс написан руками Расходится с сайтом на третьей новой странице Сборка из того же списка, что меню и крошки
Индекс грузится на каждой странице Лишние килобайты тем, кто поиском не пользуется Загрузка при первом открытии окна
Результаты вставляются через innerHTML Содержимое файла попадает в разметку как код Текстовые узлы и созданный mark
Поиск только мышью Основной способ пользоваться поиском не работает Стрелки, Enter, Esc и фокус в поле при открытии
Пустой экран, когда ничего не нашлось Читается как поломка Сообщение и ссылка на карту сайта

Что должно получиться

  • Окно открывается из шапки, фокус сразу в поле ввода
  • Результаты обновляются при вводе, без перезагрузки страницы
  • Запрос из двух слов находит страницу, где есть оба
  • Стрелки ходят по найденному, Enter открывает, Esc закрывает окно
  • Индекс собирается скриптом и не расходится с сайтом