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>
Результат работы:
Дополнительно в файле style.css можно изменить цвета подсветки найденных слов:
mark{
background: #F0AE4D;
color: black;
}