HUGO - поиск

26-04-2023

HUGO - идеально подходит для создания документации, и известный фреймворк Bootstrap это подтверждает, но не хватает функции поиска. На сайте HUGO есть несколько вариантов, как это организовать. За основу я взял более подходящее решение.

Первое, что нам нужно, - это один файл со всеми статьями - очищенный от всего HTML, только текстовое содержимое. Также будет проще иметь его в виде структуры JSON.

Добавим вывод JSON в config.toml:

[outputs]
    home = ["HTML", "RSS", "JSON"]

Затем нужно определить шаблон, который будет использоваться для генерации вывода JSON в /layouts/_default/index.json:

{{- .Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{ $.Scratch.Add "index" (dict "title" .Title "content" (.Plain | plainify) "permalink" .Permalink) }}
{{- end -}}
{{ $.Scratch.Get "index" | jsonify }}

Запустим hugo для генерации в корне сайта файла index.json.

Создадим отдельную страницу поиска в папку content/search добавим файл _index.md

---
title: "Поиск по сайту"
sitemap:
  priority : 0.1
layout: "search"
---

Добавим пункт меню в шапку сайта, в файл menus.yaml:

  - identifier: search
    name: 'Поиск'
    url: '/search/'
    weight: 50
  - identifier: about
    name: 'О проекте'
    url: '/about/'
    weight: 60

Создадим файл search.html в layouts/search, его содержимое аналогично шаблону single.html, за исключением нескольких строк:

{{ define "main" }}
<div class="row g-5">
  <div class="col-md-8">
    <h1 class="pb-4 mb-4 border-bottom">
      {{ .Title }}
    </h1>
*** Необходимый код ***
    <input id="search-query" class="mb-3 form-text form-control" placeholder="Поиск">
    <div id="search-results" class="p-2 bg-light rounded" style="display:none;"></div>
    <div id="content-results" class="">
      <p>Введите ключевое слово для поиска на этом сайте.</p>
    </div>
***
  </div>
  <div class="col-md-4">
    {{ partial "sidebar.html" . }}
    <div class="p-4 mb-3 bg-light rounded">
      {{- partial "tags.html" . -}}
    </div>
    {{ $related := .Site.RegularPages.RelatedIndices . "tags" | first 3 }}
    {{ with $related }}
    <div class="p-4 mb-3 bg-light rounded">
      <h3>Рекомендуем</h3>
      <ul class="list-group list-group-flush">
        {{ range . }}
        <li class="list-group-item"><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
        {{ end }}
      </ul>
    </div>
    {{ end }}
  </div>
</div>
{{ end }}

Также добавим в layouts/search немного измененный базовый шаблон baseof.html:

<!doctype html>
<html lang="ru" class="h-100">
{{- partial "head.html" . -}}

<body class="d-flex flex-column h-100">
    <i class="bi bi-caret-up-fill bg-secondary text-white" onclick="topFunction()" id="btnTop" title="Вверх"></i>
    {{- partial "header.html" . -}}
    <main class="flex-shrink-0 m-4">
        <div class="container">
            <div id="content">
                {{- block "main" . }}{{- end }}
            </div>
        </div>
    </main>
    {{- partial "footer.html" . -}}
    {{- partial "script.html" . -}}
    {{/* Подключаем скрипты поиска */}}
    {{- partial "script_search.html" . -}}
    {{- partial "btntop.html" . -}}
</body>

</html>

Скачаем и подключим js библиотеку mark.min.js, для подсветки найденных слов, разместим её в папке static/js. Там-же создадим файл поиска search.js:

var sidebars = document.getElementById("content-results");
var searchResults = document.getElementById("search-results");
var searchInput = document.getElementById("search-query");

// Длина отрывков
var contextDive = 50;

var timerUserInput = false;
searchInput.addEventListener("keyup", function () {
  // не начинать поиск каждый раз, когда нажимается клавиша,
  // немного подождем, пока пользователи перестанут печатать
  if (timerUserInput) { clearTimeout(timerUserInput); }
  timerUserInput = setTimeout(
    function () {
      search(searchInput.value.trim());
    },
    500
  );
});

function search(searchQuery) {
  // очистить предыдущие результаты поиска
  while (searchResults.firstChild) {
    searchResults.removeChild(searchResults.firstChild);
  }

  // игнорировать пустые и короткие поисковые запросы
  if (searchQuery.length === 0 || searchQuery.length < 3) {
    searchResults.style.display = "none";
    sidebars.style.display = "block";
    return;
  }

  sidebars.style.display = "none";
  searchResults.style.display = "block";

  // загрузите свой индексный файл
  getJSON("/index.json", function (contents) {
    var results = [];
    let regex = new RegExp(searchQuery, "i");
    // перебирать посты и собирать те, в которых есть совпадения
    contents.forEach(function (post) {
      // здесь вы также можете искать по тегам, категориям
      // или что вы поместите в макет index.json
      if (post.title.match(regex) || post.content.match(regex)) {
        results.push(post);
      }
    });

    if (results.length > 0) {
      searchResults.appendChild(
        htmlToElement("<div class=\"mt-3 mb-3\"><h2>Найдено: ".concat(results.length, "</h2></div>"))
      );

      // заполнить блок результатов поиска выдержками вокруг соответствующего поискового запроса
      results.forEach(function (value, key) {
        let firstIndexOf = value.content.toLowerCase().indexOf(searchQuery.toLowerCase());
        let lastIndexOf = firstIndexOf + searchQuery.length;
        let spaceIndex = firstIndexOf - contextDive;
        if (spaceIndex > 0) {
          spaceIndex = value.content.indexOf(" ", spaceIndex) + 1;
          if (spaceIndex < firstIndexOf) { firstIndexOf = spaceIndex; }
          else { firstIndexOf = firstIndexOf - contextDive / 2; }
        }
        else {
          firstIndexOf = 0;
        }
        let lastSpaceIndex = lastIndexOf + contextDive;
        if (lastSpaceIndex < value.content.length) {
          lastSpaceIndex = value.content.indexOf(" ", lastSpaceIndex);
          if (lastSpaceIndex !== -1) { lastIndexOf = lastSpaceIndex; }
          else { lastIndexOf = lastIndexOf + contextDive / 2; }
        }
        else {
          lastIndexOf = value.content.length - 1;
        }

        let summary = value.content.substring(firstIndexOf, lastIndexOf);
        if (firstIndexOf !== 0) { summary = "...".concat(summary); }
        if (lastIndexOf !== value.content.length - 1) { summary = summary.concat("..."); }

        let div = "".concat("<div id=\"search-summary-", key, "\">")
          .concat("<h2 class=\"post-title mb-1\"><a href=\"", value.permalink, "\">", value.title, "</a></h2>")
          .concat("<p>", summary, "</p>")
          .concat("</div>");
        searchResults.appendChild(htmlToElement(div));

        // выделить поисковый запрос используя mark.min.js
        new Mark(document.getElementById("search-summary-" + key))
          .mark(searchQuery, { "separateWordSearch": false });
      });
    }
    else {
      searchResults.appendChild(
        htmlToElement("<div class=\"alert alert-danger\" role=\"alert\"><b>Ничего не найдено</b></div>")
      );
    }
  });
}

function getJSON(url, fn) {
  let xhr = new XMLHttpRequest();
  xhr.open("GET", url);
  xhr.onload = function () {
    if (xhr.status === 200) {
      fn(JSON.parse(xhr.responseText));
    }
    else {
      console.error(
        "Обработка ошибок ".concat(url, ": ", xhr.status)
      );
    }
  };
  xhr.onerror = function () {
    console.error("Ошибка подключения: ".concat(xhr.status));
  };
  xhr.send();
}

// это быстрее (удобнее)
// для создания элемента из необработанного HTML-кода
function htmlToElement(html) {
  let template = document.createElement("template");
  html = html.trim();
  template.innerHTML = html;
  return template.content.firstChild;
}

Подключаем оба файла в partials/script_search.html:

<script src="{{ .Site.BaseURL }}js/search.js"></script>
<script src="{{ .Site.BaseURL }}js/mark.min.js"></script>

Результат работы:

Hugo - поиск

Дополнительно в файле style.css можно изменить цвета подсветки найденных слов:

mark{
  background: #F0AE4D;
  color: black;
}