Фрагменты текста и подсветка
Название найденного урока помогает открыть материал, но не всегда объясняет совпадение. Статья может находиться по слову из примера, определения или подзаголовка. Короткий фрагмент рядом с карточкой позволяет посетителю понять, стоит ли переходить на страницу.
В этом уроке добавим извлечение фрагмента и подсветку буквальных совпадений. Данные документов остаются прежними. Поиск с синонимом или исправлением может найти материал без буквального слова запроса; мы не будем изображать точное совпадение там, где его нет.
Фрагмент и результат поиска
Поисковый алгоритм выбирает документы, а фрагмент представляет уже найденный материал. Эти операции связаны, но имеют разные цели. Изменение длины карточки само по себе не должно менять порядок выдачи.
Для простого примера найдём первое буквальное слово запроса в основном тексте, начнём немного раньше и покажем ограниченный участок. Если буквального совпадения нет, берём начало текста. Это объяснимое правило; оно не обещает выбрать самый полезный абзац каждого длинного урока.
Заголовок мог обеспечить высокий score, хотя основной текст не содержит такой же формы. В этом случае начало статьи всё равно полезно как описание содержания. Не нужно автоматически считать отсутствие подсветки ошибкой поиска.
Фрагмент не должен быть пустым только потому, что индекс использовал другое поле. Позднее можно выбирать между описанием, заголовками и текстом, а также передавать объяснение совпадения из поискового сервиса.
Совпадение в исходном тексте
Токенизатор преобразует C# во внутреннее имя csharp. Если искать это имя в показанном заголовке, подсветка не появится. Поэтому для буквального отображения используем слова исходного запроса, а не внутренние термины.
Также нельзя без проверки переносить позиции из нормализованной строки в оригинал. Некоторые Unicode-преобразования меняют длину и состав символов. Наш вариант выполняет регулярное выражение прямо на исходной строке и использует позиции найденных совпадений.
Сначала экранируем символы запроса, которые имеют специальное значение в регулярном выражении. Название C++ не должно превратиться в инструкцию повторения. Пользователь вводит текст для поиска, а не собственную регулярную программу.
Создайте файл public/presentation.js. Ниже полный модуль:
function literalPattern(query) {
const words = String(query).match(/c\+\+|c#|\.net|node\.js|[\p{L}\p{N}_]+/giu) || [];
const escaped = [...new Set(words)].sort((a, b) => b.length - a.length)
.map(word => Array.from(word, char =>
"\\^$.*+?()[]{}|".includes(char) ? "\\" + char : char
).join(""));
return escaped.length ? new RegExp(escaped.join("|"), "giu") : null;
}
export function snippet(text, query, limit = 200) {
const pattern = literalPattern(query);
const match = pattern?.exec(text);
const start = match ? Math.max(0, match.index - 60) : 0;
const end = Math.min(text.length, start + limit);
return (start ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
}
export function appendHighlighted(node, text, query) {
const pattern = literalPattern(query);
if (!pattern) { node.append(document.createTextNode(text)); return; }
let end = 0;
for (const match of text.matchAll(pattern)) {
node.append(document.createTextNode(text.slice(end, match.index)));
const mark = document.createElement("mark");
mark.textContent = match[0];
node.append(mark);
end = match.index + match[0].length;
}
node.append(document.createTextNode(text.slice(end)));
}
Функция literalPattern сохраняет несколько технических названий и приводит варианты к безопасному выражению. Сортировка по длине помогает сначала искать более длинный вариант, если один является началом другого.
snippet получает текст и исходный запрос. Условная граница 200 относится к карточке, а не к индексируемой длине статьи. Основной документ не обрезается для поиска вместе с фрагментом.
Безопасная подсветка
Распространённый короткий подход — вставить найденное слово между строками <mark> и передать результат в innerHTML. Он становится опасным, если исходный текст содержит настоящую разметку или если запрос влияет на строящийся HTML.
Наш модуль создаёт элементы DOM отдельно. Обычные участки добавляются как текстовые узлы, а совпадение попадает в textContent элемента mark. Ни документ, ни запрос не получают возможности стать исполняемой разметкой.
Это особенно важно для технических уроков, где встречаются строки <script>, атрибуты и примеры HTML. Поиск должен показать их читателю буквально. Безопасность текстовой вставки описана в справке textContent.
Подсветка не меняет исходный текст в данных. Она относится только к текущей карточке и запросу. Поэтому новый поиск может заново построить представление без удаления старых тегов из документа.
Изменение карточки
В начале app.js добавьте импорт:
import { snippet, appendHighlighted } from "./presentation.js";
В функции render используйте текущий исходный запрос из readState. Замените строку с присвоением заголовка и добавление элементов следующим фрагментом:
const query = readState().query;
appendHighlighted(a, doc.title, query);
const description = document.createElement("p");
appendHighlighted(description, snippet(doc.text, query), query);
li.append(a, description);
Остальные проверки URL и создание элементов сохраняются. Старую строку a.textContent = doc.title удалите: иначе заголовок появится дважды. Функция render продолжает получать только найденные документы.
Небольшой CSS помогает отличить выделение, но цвет не должен делать текст нечитаемым. Подсветка является дополнительным пояснением; сама ссылка остаётся понятной и без неё.
Ожидаемые случаи
Для запроса XML фрагмент соответствующего урока должен показать буквальное упоминание и выделить его. В материале о Fetch тоже может появиться XML, но окружающий текст объяснит, что речь идёт о формате ответа, а не об обработке документа через LINQ.
Для запроса ngnix алгоритм предыдущего урока способен найти Nginx. Однако буквальная подсветка ошибочной строки не появится, потому что её нет в тексте. Это правильная граница текущего представления.
Чтобы объяснять такие совпадения, можно передавать найденные варианты из queryGroups и показывать заметку «Найдено по Nginx». При этом важно не выдавать исправление за пользовательский ввод. В нашей основной версии такая заметка не требуется для работы карточки.
Если совпадение находится ближе к концу длинного абзаца, фрагмент начинается перед ним и помечает обрезание многоточием. Многоточие не является частью статьи. Посетитель открывает полный материал по прежнему URL.
Что улучшать дальше
Наш алгоритм не выбирает границы предложений и может обрезать слово. Для маленького учебного проекта это видимое ограничение, а не скрытая ошибка индекса. Следующим улучшением может быть поиск ближайшего пробела или сегмента предложения.
Для текстов с символами за пределами базовой Unicode-плоскости нужно учитывать границы кодовых точек, если срез попадает между частями символа. Общая интернационализация шире выбранных русских и технических примеров.
Нельзя сравнивать поисковое качество только по красивому фрагменту. Убедительная карточка неподходящего документа остаётся неподходящей. Поэтому в контрольной оценке сохраняем ID и порядок независимо от оформления.
Проверьте также текст с буквальными угловыми скобками. Визуально должна появиться строка, а не созданный ею элемент. Для этого наблюдения не требуется менять настоящий сайт или добавлять потенциально опасную разметку в опубликованную библиотеку.
Теперь результат сообщает не только название, но и небольшой контекст совпадения. Следующий урок добавит подсказки при вводе, сохраняя понятную форму и защиту от запоздавших ответов.