Перейти к содержанию

Статический поисковый индекс

Добавим поиск по названиям и описаниям. Индекс будет заранее создан как JSON, а браузер получит его только при отправке запроса. Такой поиск подходит маленькой статической библиотеке. Он не заменяет серверный полнотекстовый поиск и не обещает качество морфологии. Результат этапа — согласованный путь от публичной выборки до безопасных ссылок в интерфейсе.

Endpoint search-index.json.ts использует publishedLessons и articleUrl. Каждая строка содержит название, описание и публичный адрес. Тело Markdown в индекс не включается. Поэтому найденные результаты определяются редакционными метаданными. Если понятие есть только в середине текста, текущая модель его не найдёт: это известная граница, которую нужно объяснить посетителю и автору.

Поиск как статический ресурс и маленький сценарий

В static-режиме endpoint подготавливает файл при сборке. Запрос браузера получает готовый JSON, а не запускает поиск на Node-сервере. Изменение статьи требует обновления индекса вместе с выпуском. Нельзя загрузить новый урок и забыть старый поисковый файл: сайт покажет содержание, которое его собственный поиск ещё не знает.

Функция нормализации приводит строки к нижнему регистру, использует Unicode NFKC и приравнивает ё к е. Запрос разбивается на слова, каждое из которых должно присутствовать в соединённых названии и описании. Это простое AND-условие. Оно не знает словоформ, синонимов и ранжирования. Подстрока может совпасть внутри другого слова, что тоже является частью выбранного ограничения.

Первый fetch сохраняется в indexPromise, чтобы повторные запросы не загружали тот же набор каждый раз. При ошибке обещание сбрасывается, позволяя попробовать снова. Номер revision предотвращает показ устаревшего результата, когда пользователь отправил следующий запрос до завершения предыдущего. Текущая строка запроса должна оставаться единственным источником видимого состояния.

Результаты создаются через document.createElement, textContent и присваивание href. Мы не вставляем название через innerHTML. Дополнительно адрес разбирается new URL и ограничивается текущим origin с HTTP или HTTPS. Внешний или исполняемый адрес не должен становиться ссылкой только потому, что попал в JSON. Это ограничение полезно даже для собственного статического индекса.

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

Форма сохраняет запрос в адресной строке через replaceState; при прямом открытии параметр читается и используется снова. Основная ссылка на каталог существует независимо от сценария и ошибки загрузки. Пустой запрос предлагает ввести текст, ошибка индекса предлагает повтор или каталог. Эти состояния являются частью интерфейса поиска, а не исключительными техническими деталями.

Ожидаемые результаты следует сравнивать с названием и описанием пяти материалов. Исполнение fetch и браузерная проверка не проводились. Для будущего просмотра рассмотрите пустой запрос, отсутствие совпадений, два быстро отправленных запроса и недоступный индекс. Успех одного слова не подтверждает полноту такого договора.

Полный файл src/pages/search-index.json.ts текущего этапа:

import { publishedLessons, articleUrl } from '../lib/lessons';
export async function GET() {
  const entries = await publishedLessons();
  const rows = entries.map(entry => ({ title: entry.data.title, description: entry.data.description, url: articleUrl(entry) }));
  return new Response(JSON.stringify(rows), { headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}

Полный файл src/pages/search.astro текущего этапа:

---
import Base from '../layouts/Base.astro';
---
<Base title="Поиск"><h1>Поиск по названиям</h1><form id="search-form"><label for="query">Запрос</label><input id="query" name="q" type="search"><button>Найти</button></form><p id="search-status" role="status">Для полного списка откройте каталог.</p><ul id="search-results"></ul><p><a href="/catalog/">Каталог уроков</a></p></Base>
<script>
  const form = document.querySelector<HTMLFormElement>('#search-form')!;
  const input = document.querySelector<HTMLInputElement>('#query')!;
  const status = document.querySelector<HTMLParagraphElement>('#search-status')!;
  const list = document.querySelector<HTMLUListElement>('#search-results')!;
  type Row = { title: string; description: string; url: string };
  let indexPromise: Promise<Row[]> | undefined;
  let revision = 0;
  const normalize = (value: string) => value.toLocaleLowerCase('ru').normalize('NFKC').replace(/ё/g, 'е');
  form.addEventListener('submit', async event => {
    event.preventDefault();
    const current = ++revision;
    const query = normalize(input.value.trim());
    history.replaceState(null, '', query ? `?q=${encodeURIComponent(input.value.trim())}` : location.pathname);
    list.replaceChildren();
    if (!query) { status.textContent = 'Введите запрос.'; return; }
    status.textContent = 'Загрузка индекса…';
    try {
      indexPromise ??= fetch('/search-index.json').then(response => {
        if (!response.ok) throw new Error('index unavailable');
        return response.json();
      });
      const rows = await indexPromise;
      if (current !== revision) return;
      const words = query.split(/\s+/);
      const matches = rows.filter(row => words.every(word => normalize(`${row.title} ${row.description}`).includes(word)));
      for (const row of matches.slice(0, 30)) {
        const url = new URL(row.url, location.origin);
        if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) continue;
        const item = document.createElement('li');
        const link = document.createElement('a');
        link.href = url.href; link.textContent = row.title;
        item.append(link); list.append(item);
      }
      status.textContent = `Найдено: ${matches.length}. Показано не более 30.`;
    } catch {
      indexPromise = undefined;
      if (current === revision) status.textContent = 'Индекс недоступен. Откройте каталог или повторите запрос.';
    }
  });
  input.value = new URLSearchParams(location.search).get('q') ?? '';
  if (input.value.trim()) form.requestSubmit();
</script>

Разбор состояния библиотеки

Поисковый индекс является публичным файлом. Поэтому в него нельзя включать черновики, закрытые сведения или редакционные комментарии с расчётом, что интерфейс их не покажет. Пользователь может получить JSON напрямую. Фильтрация должна происходить до создания ресурса, а не только внутри браузерного шаблона. В этой серии индекс содержит исключительно title, description и адрес публичной записи.

Нормализация Unicode помогает согласовать разные текстовые представления, но не является исправлением всех запросов. Одна и та же тема может называться несколькими словами, а русские окончания меняют форму. Текущий AND-поиск остаётся простым по намерению. Если пользователь ищет навигацию по слову меню, отсутствие совпадения может быть ограничением словаря, а не ошибкой fetch. Улучшение качества потребует отдельной модели синонимов или полнотекстового индекса.

Состояние загрузки сообщается через status, но фокус не переносится автоматически к результатам. Человек может продолжить вводить запрос и отправить его снова. Старые элементы очищаются сразу, чтобы они не выглядели ответом на новый текст. При успешном завершении список строится только для актуальной revision. Такая последовательность отделяет ожидаемое состояние от времени прихода асинхронного ресурса.

Для самостоятельного варианта добавьте описание с символами угловых скобок и кавычками. Название должно отображаться текстом, не создавая новых HTML-элементов. Затем поместите в учебный индекс внешний адрес и рассмотрите ограничение origin. Это будущий безопасный локальный опыт с контролируемыми данными. Не требуется обращаться к чужому сервису или отправлять настоящее пользовательское содержание.

Обработка Response проверяет ok до чтения JSON. Однако правильный HTTP-статус ещё не гарантирует нужную схему ресурса. Производственный компонент должен валидировать, что получен массив строк с ожидаемыми полями. В маленьком примере endpoint и потребитель принадлежат одному проекту, поэтому доверие ограничено собственным выпуском. Если индекс станет внешним, эту границу нужно расширить и проверять данные явно.

Ссылка на каталог важна и при полностью отключённом JavaScript. Форма поиска тогда не реализует клиентский алгоритм, но библиотека остаётся доступной через обычные маршруты. Для большого сайта можно добавить серверный fallback, однако это другой договор инфраструктуры. Урок не утверждает, что statically generated JSON сам по себе обеспечивает поиск без сценария; он обеспечивает переносимый ресурс для маленького клиентского поиска.

При открытии сохранённого поискового адреса query берётся из URLSearchParams, но не вставляется как HTML. Оно попадает в значение поля и затем нормализуется алгоритмом. Таким образом адрес можно копировать без создания новой статьи для каждого параметра. Для индексирования эта страница остаётся интерфейсом поиска. Смысл параметра относится к пользовательскому состоянию, а не к отдельному публичному материалу коллекции.

Условия для самостоятельного наблюдения

Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.

Условие Ожидаемое состояние и его граница
Пустой запрос Сообщение просит ввести текст, старые результаты очищаются.
Есть совпадения Каждое слово проверяется в объединённых названии и описании записи.
Нет совпадений Общее число равно нулю, каталог остаётся доступной альтернативой.
Индекс недоступен Promise сбрасывается, новый запрос может повторить загрузку.
Два запроса подряд Revision не допускает перезаписи текущего результата более старым ответом.
Понятие только в теле Текущий индекс его не содержит, полного поиска по статье здесь нет.

Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-10.

Первичные источники: Endpoints, Scripts.

Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.