¿Cómo implementar un buscador con Hugo? Mi primer impulso fue buscar en la documentación oficial, donde encontré una serie de links y guías que me permitieron orientarme un poco, pero no terminaba de convencerme ninguna. Asi que en esta guía voy a detallar cual es la solución que he desarrollado.
En los distintos sitios que encontré guías básicamente las posibilidades eran 2:
- Algolia: Es un servicio que se encarga de rastrear todas las páginas de un sitio web. De esa manera es capaz de proveer una API que podremos utilizar para realizar búsquedas en nuestra web. Tiene la ventaja de que al ser un servicio de un tercero, facilita la integración en sitios estáticos pues basta con llamar a la API desde el front. Es una opción muy válida y utilizada en multitud de sitios, pero en mi caso prefería decantarme por una opción sin depender de servicios de terceros.
- Lunr: Lunr es una librería Javascript que permite realizar búsquedas en ficheros con formato JSON. Ofrece una interfaz sencilla pero potente para hacer búsquedas en cliente. Permite realizar fuzzy search, que es un algoritmo que permite devolver los resultados más relevantes para una búsqueda a pesar de que el término introducido no coincida exactamente. Esta opción es la que he elegido e implementado en este blog.
Definiendo la salida de nuestro contenido en formato JSON
Con Lunr como librería Javascript para realizar fuzzy search tan solo nos falta la otra parte para completar nuestro buscador: un fichero JSON con el contenido de todos los posts del blog. Para ello podemos definirlo en 2 sitios distintos:
- En el fichero de configuración global de Hugo,
config.toml
en mi caso. Si no definimos nada, por defecto todo el contenido estará disponible en formato HTML y RSS. En mi caso, tengo todos los artículos del blog en lo que se define en Hugo como unasection
, concretamente en la carpetacontent/blog
. Por tanto podría definir de manera global que todas las secciones del sitio también tuvieran como formato de salida JSON de la siguiente manera:
[outputs]
section = ["JSON", "HTML", "RSS"]
De esta manera tendríamos un fichero JSON por cada section. Si además de blog como es en mi caso, tuviéramos otra sección (cursos), cada una
dispondría de su propio fichero index.json
con el contenido propio de cada sección, lo que nos permitiría poder realizar búsquedas independientes
por cada sección. ¿Cómo indico el contenido y formato de ese fichero JSON? En este caso al igual que disponemos de un template HTML llamado list.html
para mostrar el listado de contenidos de una sección, crearemos un fichero list.json
para generar el JSON.
Si queremos que sean distintos por cada sección, dentro de la carpeta layouts
y siguiendo con el ejemplo de este sitio, en una carpeta blog
crearíamos un fichero list.json
y en la carpeta cursos
crearíamos otro list.json
con contenido distinto. Si el formato del fichero JSON
va a ser el mismo para cada sección, podemos crear el fichero list.json
dentro de la carpeta _default
, y valdría para todas las secciones
(siempre que la sección no contenga un fichero list.json
, en cuyo caso prevalecería este último al de _default
).
Veamos el contenido del fichero list.json
, que simplemente genera usando sintaxis de Hugo un array de posts.
[
{{ range $index, $value := where .Site.Pages "Type" "post" }}
{{ if $index }}, {{ end }}
{
"url": "{{ .RelPermalink }}",
"title": "{{ .Title }}",
"content": {{ .Content | plainify | jsonify }},
"summary": "{{.Summary}}"
}
{{ end }}
]
El contenido es personalizable, y el nombre de cada campo también. Yo por el momento al tener un número reducido de artículos, tengo
el contenido de cada uno en un campo content
, pero a futuro con un número grande de artículos por temas de rendimiento sería conveniente reducir
el tamaño del fichero JSON quizás dejando solo el título y el sumario.
- Se puede activar para cada section el formato de salida desde el front matter. En mi caso, en
content/blog
tengo un fichero_index.md
e_index.en.md
para el idioma inglés. Desde ahí puedo activar también el formato JSON de la siguiente manera:
---
title: Blog personal
subtitle: Artículos escritos sobre temática variada relacionada con el mundo de la tecnología y la programación.
outputs:
- html
- rss
- json
---
La parte del template list.json
es exactamente igual que en el punto anterior. Puedes leer en la documentación de Hugo
acerca de la personalización de formato de salida del contenido.
Implementando el buscador usando lunr con Javascript
Una vez tenemos la fuente de datos contra la cual vamos a realizar las búsquedas, veamos el código que nos va a permitir
terminar la implementación del buscador. Yo he decidido poner el buscador simplemente en una página, por lo que en
layouts/page
he creado un fichero search.html
. Analicemos parte por parte y simplificando su contenido. Lo primero es el código html
que consta de un input para recoger el término de búsqueda y un elemento section
donde mostraremos los resultados de la
búsqueda.
<label for="search-input">Término de búsqueda</label>
<input type="text" id="search-input" name="search" placeholder="{{i18n "search_loading"}}...">
<section id="search-results"></section>
En el mismo fichero html se encuentra el código Javascript, ya que no es muy largo y podemos crear variables dinámicas en
Javascript con Hugo para obtener los literales multi idioma y la ruta relativa al index.json
para cada lenguaje.
Importamos Lunr, yo lo tengo en la carpeta static/js/
:
{{ $lunr := "js/lunr.min.js" | absURL }}
<script src="{{ $lunr }}"></script>
También podría importarse desde CDN:
<script src="https://unpkg.com/lunr/lunr.js"></script>
A continuación, el código javascript que al cargar la página hace una petición para cargar el fichero index.json
y crea el
documento indexado que posteriormente se utilizará para devolver los resultados de la búsqueda. Las búsquedas se van lanzando
según el usuario va escribiendo en el input.
<script type="text/javascript">
(function () {
let idx;
let documents = [];
const URL_LIST_POSTS = '{{ "blog/index.json" | relLangURL }}';
const searchInput = document.getElementById("search-input");
const searchResults = document.getElementById("search-results");
// Request and index documents
fetch(URL_LIST_POSTS, {
method: "get",
})
.then((res) => res.json())
.then((res) => {
// Create index document with lunr
idx = lunr(function () {
this.ref("url");
this.field("title");
this.field("content");
this.field("summary");
res.forEach(function (doc) {
this.add(doc);
documents[doc.url] = {
title: doc.title,
content: doc.content,
summary: doc.summary,
};
}, this);
});
// Once data is loaded we can register handler
registerSearchHandler();
})
.catch((err) => {
console.log({ err });
const errorMsg = '{{ i18n "search_error" }}';
searchResults.innerHTML = `
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4" role="alert">
<p>${errorMsg}</p>
</div>`;
});
///////////////////////////////////////////////////////////
function renderSearchResults(results) {
const noResults = '{{ i18n "search_noCoincidence" }}';
// If results are empty
if (results.length === 0) {
searchResults.innerHTML = `
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4" role="alert">
<p>${noResults}</p>
</div>
`;
return;
}
// Show max 10 results
if (results.length > 9) {
results = results.slice(0, 10);
}
// Reset search results
searchResults.innerHTML = "";
// Append results
results.forEach((result) => {
// Create result item
let article = document.createElement("article");
article.classList.add("mb-8");
article.innerHTML = `
<a href="${result.ref}" class="block group">
<h2 class="article-title group-hover:text-green-500 pb-1">${documents[result.ref].title}</h2>
<div class="text-gray-700 dark:text-gray-300"><p>${documents[result.ref].summary}</p></div>
</a>`;
searchResults.appendChild(article);
});
}
function registerSearchHandler() {
// Register on input event
searchInput.oninput = function (event) {
if (searchInput.value === "") {
searchResults.innerHTML = "";
return;
}
// Get input value
const query = event.target.value;
// Run fuzzy search
const results = idx.query(function(q) {
q.term(lunr.tokenizer(query.trim()), { usePipeline: true, boost: 100 });
q.term(lunr.tokenizer(query.trim()) + '*', { usePipeline: false, boost: 10 });
q.term(lunr.tokenizer(query.trim()), { usePipeline: false, editDistance: 1 });
});
// Render results
renderSearchResults(results);
};
searchInput.placeholder = '{{ i18n "search_inputPlaceholder" }}';
}
})();
</script>
Y con esto ya tendríamos nuestro buscador finalizado. Para elaborar esta solución he seguido las guías de Joseph Earl y Matt Walters. He adaptado y actualizado el código Javascript y he corregido la manera de lanzar la búsqueda con Lunr, ya que no era del todo precisa y no funcionaba correctamente en todos los casos. Esta es la parte más importante, y la solución la encontré en una issue en el github de Lunr:
// Run fuzzy search
const results = idx.query(function(q) {
q.term(lunr.tokenizer(query.trim()), { usePipeline: true, boost: 100 });
q.term(lunr.tokenizer(query.trim()) + '*', { usePipeline: false, boost: 10 });
q.term(lunr.tokenizer(query.trim()), { usePipeline: false, editDistance: 1 });
});
Puedes consultar mi fichero search.html con el código completo en github y adaptarlo para tu caso de uso.